Skip to content

Commit

Permalink
feat(auth): Add SMS OTP support (#188)
Browse files Browse the repository at this point in the history
Adds SMS + OTP authentication support to the celest_auth library
  • Loading branch information
dnys1 authored Oct 4, 2024
1 parent 45a1a07 commit e545a0c
Show file tree
Hide file tree
Showing 58 changed files with 3,953 additions and 6 deletions.
1 change: 1 addition & 0 deletions packages/celest_auth/lib/src/auth_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import 'package:stream_transform/stream_transform.dart';

export 'flows/email_flow.dart';
export 'flows/idp_flow.dart';
export 'flows/sms_flow.dart';

final class AuthImpl implements Auth {
AuthImpl(
Expand Down
163 changes: 163 additions & 0 deletions packages/celest_auth/lib/src/flows/sms_flow.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import 'dart:async';

import 'package:celest_auth/src/auth_impl.dart';
import 'package:celest_auth/src/flows/auth_flow.dart';
import 'package:celest_auth/src/model/cloud_interop.dart';
import 'package:celest_auth/src/state/auth_state.dart';
import 'package:celest_cloud/celest_cloud.dart' as cloud;
import 'package:celest_core/celest_core.dart';

extension type Sms(AuthImpl _hub) {
/// Authenticates a user with the given [phoneNumber] using a one-time password
/// (OTP) sent to that email.
///
/// OTP codes are valid for 15 minutes and can be resent after 30 seconds
/// by calling `resend` on the returned state object.
Future<SmsNeedsVerification> authenticate({
required String phoneNumber,
}) async {
final flowController = await _hub.requestFlow();
final flow = SmsFlow._(_hub, flowController);
return flow._authenticate(phoneNumber: phoneNumber);
}
}

final class SmsFlow implements AuthFlow {
SmsFlow._(this._hub, this._flowController);

final AuthImpl _hub;
final AuthFlowController _flowController;

Future<SmsNeedsVerification> _authenticate({
required String phoneNumber,
}) {
return _flowController.capture(() async {
final state = await _hub.cloud.authentication.sms.start(
phoneNumber: phoneNumber,
);
switch (state) {
case cloud.SmsSessionVerifyCode():
return _SmsNeedsVerification(
flow: this,
innerState: state,
phoneNumber: state.phoneNumber,
);
default:
throw StateError('Unexpected state after start: $state');
}
});
}

Future<Authenticated> _verifyOtp({
required cloud.SmsSessionVerifyCode state,
required String code,
}) {
return _flowController.capture(() async {
final success = await _hub.cloud.authentication.sms.verifyCode(
state: state,
code: code,
);
await _hub.secureStorage.write('cork', success.identityToken);
_hub.localStorage.write('userId', success.user.userId);
return Authenticated(user: success.user.toCelest());
});
}

Future<SmsNeedsVerification> _resendOtp({
required cloud.SmsSessionVerifyCode state,
}) {
return _flowController.capture(() async {
state = await _hub.cloud.authentication.sms.resendCode(
state: state,
);
return _SmsNeedsVerification(
flow: this,
innerState: state,
phoneNumber: state.phoneNumber,
);
});
}

Future<AuthState> _confirm({
required cloud.SmsSessionRegisterUser state,
}) {
return _flowController.capture(() async {
final newState = await _hub.cloud.authentication.sms.confirm(
state: state,
);
switch (newState) {
case cloud.SmsSessionSuccess(:final identityToken, :final user):
await _hub.secureStorage.write('cork', identityToken);
_hub.localStorage.write('userId', user.userId);
return Authenticated(user: user.toCelest());
case cloud.SmsSessionRegisterUser(:final user):
return _SmsRegisterUser(
user: user.toCelest(),
flow: this,
innerState: newState,
);
case cloud.SmsSessionVerifyCode():
return _SmsNeedsVerification(
flow: this,
innerState: newState,
phoneNumber: newState.phoneNumber,
);
}
});
}

@override
void cancel() => _flowController.cancel();
}

final class _SmsNeedsVerification extends SmsNeedsVerification {
_SmsNeedsVerification({
required SmsFlow flow,
required this.innerState,
required super.phoneNumber,
}) : _flow = flow;

final SmsFlow _flow;
final cloud.SmsSessionVerifyCode innerState;

@override
Future<void> resend() async {
await _flow._resendOtp(state: innerState);
}

@override
Future<User> verify({
required String otpCode,
}) async {
final authenticated = await _flow._verifyOtp(
state: innerState,
code: otpCode,
);
return authenticated.user;
}

@override
void cancel() => _flow.cancel();
}

final class _SmsRegisterUser extends AuthRegisterUser {
_SmsRegisterUser({
required super.user,
required SmsFlow flow,
required cloud.SmsSessionRegisterUser innerState,
}) : _flow = flow,
_innerState = innerState;

final SmsFlow _flow;
final cloud.SmsSessionRegisterUser _innerState;

@override
void cancel() => _flow.cancel();

@override
Future<User> confirm() async {
final authenticated =
await _flow._confirm(state: _innerState) as Authenticated;
return authenticated.user;
}
}
30 changes: 25 additions & 5 deletions packages/celest_auth/lib/src/state/auth_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,23 +29,43 @@ sealed class AuthFlowInProgress extends AuthState {
void cancel();
}

/// {@template celest_auth.otp_needs_verification}
/// The user is in the process of authenticating with an OTP code.
/// {@endtemplate}
abstract interface class OtpNeedsVerification implements AuthFlowInProgress {
/// Resend the verification code.
Future<void> resend();

/// Verify the [otpCode] sent to the user.
Future<User> verify({required String otpCode});
}

/// {@template celest_auth.email_needs_verification}
/// The user is in the process of authenticating with their email.
/// {@endtemplate}
abstract class EmailNeedsVerification extends AuthFlowInProgress {
abstract class EmailNeedsVerification extends AuthFlowInProgress
implements OtpNeedsVerification {
/// {@macro celest_auth.email_needs_verification}
const EmailNeedsVerification({
required this.email,
});

/// The email address to be verified.
final String email;
}

/// Resend the verification code.
Future<void> resend();
/// {@template celest_auth.sms_needs_verification}
/// The user is in the process of authenticating with their phone number.
/// {@endtemplate}
abstract class SmsNeedsVerification extends AuthFlowInProgress
implements OtpNeedsVerification {
/// {@macro celest_auth.sms_needs_verification}
const SmsNeedsVerification({
required this.phoneNumber,
});

/// Verify the [otpCode] sent to the [email].
Future<User> verify({required String otpCode});
/// The phone number to be verified.
final String phoneNumber;
}

/// {@template celest_auth.auth_link_user}
Expand Down
4 changes: 4 additions & 0 deletions packages/celest_cloud/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 0.1.3

- feat: Add SMS OTP flow

# 0.1.2

- feat: Add `Subscriptions.DescribePricing` endpoint
Expand Down
157 changes: 157 additions & 0 deletions packages/celest_cloud/lib/src/cloud/authentication/authentication.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ final class Authentication with BaseService {
_clientType,
);

late final SmsAuthentication sms = SmsAuthentication._(
_protocol,
logger,
_clientType,
);

Future<Uri?> endSession(SessionState? state) async {
final request = EndSessionRequest(
sessionId: state?.sessionId,
Expand Down Expand Up @@ -198,6 +204,157 @@ final class EmailAuthentication with BaseService {
}
}

final class SmsAuthentication with BaseService {
SmsAuthentication._(this._client, this.logger, this._clientType);

final AuthenticationProtocol _client;
@override
final Logger? logger;
final ClientType _clientType;

Future<SmsSessionState> start({
required String phoneNumber,
}) async {
final request = StartSessionRequest(
smsOtp: AuthenticationFactorSmsOtp(
phoneNumber: phoneNumber,
),
client: SessionClient(
clientType: _clientType,
),
);
final response = await run(
'Sms.StartSession',
request: request,
action: _client.startSession,
);
return switch (response.whichState()) {
Session_State.nextStep
when response.nextStep.hasNeedsProof() &&
response.nextStep.needsProof.hasSmsOtp() =>
SmsSessionVerifyCode(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
phoneNumber: phoneNumber,
),
Session_State.nextStep when response.nextStep.hasPendingConfirmation() =>
switch (response.nextStep.pendingConfirmation.whichPending()) {
AuthenticationPendingConfirmation_Pending.registerUser =>
SmsSessionRegisterUser(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
phoneNumber: phoneNumber,
user: response.nextStep.pendingConfirmation.registerUser,
),
_ => throw StateError('Unexpected response: $response'),
},
_ => throw StateError('Unexpected response: $response'),
};
}

Future<SmsSessionVerifyCode> resendCode({
required SmsSessionVerifyCode state,
}) async {
final request = ContinueSessionRequest(
sessionId: state.sessionId,
sessionToken: state.sessionToken,
resend: AuthenticationFactor(
smsOtp: AuthenticationFactorSmsOtp(
phoneNumber: state.phoneNumber,
),
),
);
final response = await run(
'Sms.ContinueSession',
request: request,
action: _client.continueSession,
);
return switch (response.whichState()) {
Session_State.nextStep
when response.nextStep.hasNeedsProof() &&
response.nextStep.needsProof.hasSmsOtp() =>
SmsSessionVerifyCode(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
phoneNumber: state.phoneNumber,
),
_ => throw StateError('Unexpected response: $response'),
};
}

Future<SmsSessionSuccess> verifyCode({
required SmsSessionVerifyCode state,
required String code,
}) async {
final request = ContinueSessionRequest(
sessionId: state.sessionId,
sessionToken: state.sessionToken,
proof: AuthenticationFactor(
smsOtp: AuthenticationFactorSmsOtp(
phoneNumber: state.phoneNumber,
code: code,
),
),
);
final response = await run(
'Sms.ContinueSession',
request: request,
action: _client.continueSession,
);
if (response.whichState() != Session_State.success) {
throw StateError('Unexpected response: $response');
}
return SmsSessionSuccess(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
isNewUser: response.success.isNewUser,
identityToken: response.success.identityToken,
user: response.success.user,
phoneNumber: state.phoneNumber,
);
}

Future<SmsSessionState> confirm({
required SmsSessionNeedsConfirmation state,
}) async {
final request = ContinueSessionRequest(
sessionId: state.sessionId,
sessionToken: state.sessionToken,
confirmation: switch (state) {
SmsSessionRegisterUser(:final user) =>
AuthenticationPendingConfirmation(
registerUser: user,
),
},
);
final response = await run(
'Sms.ContinueSession',
request: request,
action: _client.continueSession,
);
return switch (response.whichState()) {
Session_State.nextStep when response.nextStep.hasNeedsProof() => switch (
response.nextStep.needsProof.whichFactor()) {
AuthenticationFactor_Factor.smsOtp => SmsSessionVerifyCode(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
phoneNumber: state.phoneNumber,
),
_ => throw StateError('Unexpected response: $response'),
},
Session_State.success => SmsSessionSuccess(
sessionId: response.sessionId,
sessionToken: response.sessionToken,
identityToken: response.success.identityToken,
user: response.success.user,
isNewUser: response.success.isNewUser,
phoneNumber: state.phoneNumber,
),
_ => throw StateError('Unexpected response: $response'),
};
}
}

final class IdpAuthentication with BaseService {
IdpAuthentication._(this._client, this.logger, this._clientType);

Expand Down
Loading

0 comments on commit e545a0c

Please sign in to comment.