Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 24 additions & 12 deletions packages/clerk_auth/lib/src/clerk_api/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,18 +362,30 @@ class Api with Logging {
'`redirectUrl` required for strategy $strategy',
);

final factor = signIn.factorFor(strategy, stage);
return await _fetchApiResponse(
'/client/sign_ins/${signIn.id}/prepare_${stage}_factor',
params: {
'strategy': strategy,
'email_address_id': factor.emailAddressId,
'phone_number_id': factor.phoneNumberId,
'web3_wallet_id': factor.web3WalletId,
'passkey_id': factor.passkeyId,
'redirect_url': redirectUrl,
},
);
if (signIn.factorFor(strategy, stage: stage) case Factor factor) {
return await _fetchApiResponse(
'/client/sign_ins/${signIn.id}/prepare_${stage}_factor',
params: {
'strategy': strategy,
'email_address_id': factor.emailAddressId,
'phone_number_id': factor.phoneNumberId,
'web3_wallet_id': factor.web3WalletId,
'passkey_id': factor.passkeyId,
'redirect_url': redirectUrl,
},
);
} else {
switch (stage) {
case Stage.first:
throw const ExternalError(
message: 'Strategy unsupported for first factor',
);
case Stage.second:
throw const ExternalError(
message: 'Strategy unsupported for second factor',
);
}
}
}

/// Attempt a [SignIn] according to the [strategy].
Expand Down
28 changes: 15 additions & 13 deletions packages/clerk_auth/lib/src/clerk_auth/auth.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ class Auth {
static const _refetchDelay = Duration(seconds: 10);
static const _kClientKey = '\$client';
static const _kEnvKey = '\$env';
static const _codeLength = 6;
static const _defaultPollDelay = Duration(seconds: 53);

Timer? _clientTimer;
Expand Down Expand Up @@ -546,7 +545,7 @@ class Auth {

case SignIn signIn
when strategy.isPasswordResetter &&
code?.length == _codeLength &&
code?.length == Strategy.numericalCodeLength &&
password is String:
await _api
.attemptSignIn(
Expand All @@ -558,14 +557,13 @@ class Auth {
)
.then(_housekeeping);

case SignIn signIn
when strategy == Strategy.emailLink && redirectUrl is String:
case SignIn signIn when strategy.isEmailLink:
await _api
.prepareSignIn(
signIn,
stage: Stage.first,
strategy: Strategy.emailLink,
redirectUrl: redirectUrl,
redirectUrl: redirectUrl ?? ClerkConstants.oauthRedirect,
)
.then(_housekeeping);
unawaited(_pollForEmailLinkCompletion());
Expand All @@ -583,20 +581,23 @@ class Auth {
)
.then(_housekeeping);

case SignIn signIn
when signIn.status.needsFactor && strategy.requiresCode:
case SignIn signIn when signIn.status.needsFactor:
final stage = Stage.forStatus(signIn.status);
if (signIn.verificationFor(stage) is! Verification) {
if (signIn.requiresPreparationFor(strategy)) {
await _api
.prepareSignIn(signIn, stage: stage, strategy: strategy)
.then(_housekeeping);
}
if (client.signIn case SignIn signIn
when signIn.verificationFor(stage) is Verification &&
code?.length == _codeLength) {
when signIn.requiresPreparationFor(strategy) == false &&
strategy.mightAccept(code)) {
await _api
.attemptSignIn(signIn,
stage: stage, strategy: strategy, code: code)
.attemptSignIn(
signIn,
stage: stage,
strategy: strategy,
code: code,
)
.then(_housekeeping);
}
}
Expand Down Expand Up @@ -1066,9 +1067,10 @@ class Auth {
await Future.delayed(const Duration(seconds: 1));

final client = await _api.currentClient();
if (client.user is User) {
if (client.user is User || client.signIn?.needsSecondFactor == true) {
this.client = client;
update();
break;
} else {
final expiry = client.signIn?.firstFactorVerification?.expireAt ??
client.signUp?.verifications[Field.emailAddress]?.expireAt;
Expand Down
98 changes: 60 additions & 38 deletions packages/clerk_auth/lib/src/models/client/sign_in.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import 'package:clerk_auth/src/clerk_auth/clerk_error.dart';
import 'package:clerk_auth/src/models/client/auth_object.dart';
import 'package:clerk_auth/src/models/client/factor.dart';
import 'package:clerk_auth/src/models/client/strategy.dart';
Expand Down Expand Up @@ -80,24 +79,35 @@ class SignIn extends AuthObject with InformativeToStringMixin {
);

/// The currently most important verification
Verification? get verification =>
firstFactorVerification ?? secondFactorVerification;
Verification? get verification {
return switch (status) {
Status.needsFirstFactor => firstFactorVerification,
Status.needsSecondFactor => secondFactorVerification,
_ => null,
};
}

/// Does this [SignIn] require preparation for the given [Strategy]?
bool requiresPreparationFor(Strategy strategy) =>
strategy.requiresPreparation && verification is! Verification;

/// Do we have a verification in operation>?
bool get hasVerification => verification is Verification;

/// Is this [SignIn] transferable to a [SignUp]?
bool get isTransferable => verification?.status.isTransferable == true;
/// Do we need a first factor?
bool get needsFirstFactor => status == Status.needsFirstFactor;

/// fromJson
static SignIn fromJson(Map<String, dynamic> json) => _$SignInFromJson(json);
/// Do we need a second factor?
bool get needsSecondFactor => status == Status.needsSecondFactor;

/// toJson
@override
Map<String, dynamic> toJson() => _$SignInToJson(this);
/// Do we need a factor?
bool get needsFactor => needsFirstFactor || needsSecondFactor;

/// Is this [SignIn] transferable to a [SignUp]?
bool get isTransferable => verification?.status.isTransferable == true;

/// Find a [Verification] if one exists for this [SignIn]
/// at the giver [Stage]
/// at the given [Stage]
///
Verification? verificationFor(Stage stage) {
return switch (stage) {
Expand All @@ -106,8 +116,8 @@ class SignIn extends AuthObject with InformativeToStringMixin {
};
}

/// Find the [Factor]s for this [SignIn] that match
/// the [stage]
/// Find a list of [Factor]s for this [SignIn]
/// at the given [Stage]
///
List<Factor> factorsFor(Stage stage) {
return switch (stage) {
Expand All @@ -116,38 +126,50 @@ class SignIn extends AuthObject with InformativeToStringMixin {
};
}

/// The factors for the current stage
List<Factor> get factors => switch (status) {
Status.needsFirstFactor => supportedFirstFactors,
Status.needsSecondFactor => supportedSecondFactors,
_ => const [],
};
/// Do we need factors for the given [Stage]?
///
bool needsFactorsFor(Stage stage) {
return switch (stage) {
Stage.first => needsFirstFactor,
Stage.second => needsSecondFactor,
};
}

/// The factors for the current status
List<Factor> get factors {
return switch (status) {
Status.needsFirstFactor => supportedFirstFactors,
Status.needsSecondFactor => supportedSecondFactors,
_ => const [],
};
}

/// can we handle the password strategy?
bool get canUsePassword => factors.any((f) => f.strategy.isPassword);

/// Find the [Factor] for this [SignIn] that matches
/// the [strategy] and [stage]
///
/// Throw an error on failure
/// the [strategy] and optional [stage], or null
///
Factor factorFor(Strategy strategy, Stage stage) {
for (final factor in factorsFor(stage)) {
Factor? factorFor(
Strategy strategy, {
Stage? stage,
}) {
final factors = switch (stage) {
Stage.first => supportedFirstFactors,
Stage.second => supportedSecondFactors,
null => this.factors,
};
for (final factor in factors) {
if (factor.strategy == strategy) return factor;
}
switch (stage) {
case Stage.first:
throw ClerkError(
message: 'Strategy {arg} unsupported for first factor',
argument: strategy.toString(),
code: ClerkErrorCode.noSuchFirstFactorStrategy,
);
case Stage.second:
throw ClerkError(
message: 'Strategy {arg} unsupported for second factor',
argument: strategy.toString(),
code: ClerkErrorCode.noSuchSecondFactorStrategy,
);
}

return null;
}

/// fromJson
static SignIn fromJson(Map<String, dynamic> json) => _$SignInFromJson(json);

/// toJson
@override
Map<String, dynamic> toJson() => _$SignInToJson(this);
}
65 changes: 63 additions & 2 deletions packages/clerk_auth/lib/src/models/client/strategy.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ class Strategy {
/// provider
final String? provider;

/// The length of a numerical code
static const numericalCodeLength = 6;

/// The length of a backup code
static const textualCodeLength = 8;

static const _oauthTokenGoogleName = 'google_one_tap';
static const _oauthToken = 'oauth_token';
static const _oauthCustom = 'oauth_custom';
Expand Down Expand Up @@ -143,10 +149,23 @@ class Strategy {
username.name: username,
};

/// totp strategy
static const totp = Strategy(name: 'totp');

/// backup code strategy
static const backupCode = Strategy(name: 'backup_code');

/// the collected secondary authentication strategies
static final secondaryAuthenticationStrategies = {
backupCode.name: backupCode,
totp.name: totp,
};

static final _strategies = {
...oauthStrategies,
...verificationStrategies,
...identificationStrategies,
...secondaryAuthenticationStrategies,
};

/// is unknown?
Expand All @@ -158,6 +177,9 @@ class Strategy {
/// is password?
bool get isPassword => this == password;

/// is email link?
bool get isEmailLink => this == emailLink;

/// is some variety of oauth?
bool get isOauth => name == _oauth || isOauthCustom || isOauthToken;

Expand Down Expand Up @@ -186,12 +208,32 @@ class Strategy {
resetPasswordPhoneCode
].contains(this);

/// requires six digit code?
bool get requiresNumericalCode => const [
emailCode,
phoneCode,
resetPasswordEmailCode,
resetPasswordPhoneCode,
totp
].contains(this);

/// requires textual code?
bool get requiresTextualCode => const [
backupCode,
].contains(this);

/// requires code?
bool get requiresCode => const [
bool get requiresCode => requiresNumericalCode || requiresTextualCode;

/// requires user to take some action outside of the app?
bool get requiresExternalAction => requiresCode || isEmailLink;

/// requires preparation?
bool get requiresPreparation => const [
emailCode,
phoneCode,
resetPasswordEmailCode,
resetPasswordPhoneCode
resetPasswordPhoneCode,
].contains(this);

/// requires signature?
Expand All @@ -214,6 +256,25 @@ class Strategy {
bool get requiresRedirect =>
name == _oauth || const [emailLink, enterpriseSSO].contains(this);

/// numerical code regex - [numericalCodeLength] digits
static final _numericalCodeRE = RegExp('^\\d{$numericalCodeLength}\$');

/// textual code regex - [textualCodeLength] lowercase characters or digits
/// the format used for Clerk backup codes
static final _textualCodeRE = RegExp('^[a-z\\d]{$textualCodeLength}\$');

/// Is this code acceptable for validation against the Frontend API?
bool mightAccept(String? code) {
if (requiresNumericalCode) {
return code is String && _numericalCodeRE.hasMatch(code);
}
if (requiresTextualCode) {
return code is String && _textualCodeRE.hasMatch(code);
}

return false;
}

/// For a given [name] return the [Strategy] it identifies.
/// Create one if necessary and possible
///
Expand Down
Loading