Skip to content

Commit

Permalink
feat(celest_auth): Update with IDP + Celest Cloud integration
Browse files Browse the repository at this point in the history
- Updates celest_auth to use Celest Cloud API
- Adds IDP authentication support
  • Loading branch information
dnys1 committed Aug 27, 2024
1 parent 63450fd commit 2e39315
Show file tree
Hide file tree
Showing 18 changed files with 510 additions and 62 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/celest_cloud.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,4 @@ jobs:
run: dart analyze --fatal-infos --fatal-warnings .
- name: Format
working-directory: packages/celest_cloud
run: dart format --set-exit-if-changed --dry-run .
run: dart format --set-exit-if-changed .
14 changes: 14 additions & 0 deletions packages/celest_auth/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,20 @@ class _MainAppState extends State<MainApp> {
child: const Text('Sign out'),
),
],
AuthLinkUser() => [
const Text('User already exists'),
TextButton(
onPressed: state.confirm,
child: const Text('Link account'),
),
],
AuthRegisterUser() => [
const Text('User does not exist'),
TextButton(
onPressed: state.confirm,
child: const Text('Create new account'),
),
],
},
],
);
Expand Down
100 changes: 84 additions & 16 deletions packages/celest_auth/lib/src/auth_impl.dart
Original file line number Diff line number Diff line change
@@ -1,19 +1,38 @@
import 'dart:async';
import 'dart:convert';

import 'package:celest_auth/celest_auth.dart';
import 'package:celest_auth/src/flows/auth_flow.dart';
import 'package:celest_auth/src/flows/idp_flow.dart';
import 'package:celest_auth/src/model/cloud_interop.dart';
import 'package:celest_auth/src/model/initial_uri.dart';
import 'package:celest_auth/src/version.dart';
import 'package:celest_cloud/celest_cloud.dart' as celest_cloud;
import 'package:celest_core/_internal.dart';
import 'package:celest_core/celest_core.dart';
import 'package:celest_core/src/auth/auth_client.dart';
import 'package:native_authentication/native_authentication.dart';
import 'package:stream_transform/stream_transform.dart';

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

final class AuthImpl implements Auth {
AuthImpl(
this.celest, {
NativeStorage? storage,
}) : _storage = (storage ?? celest.nativeStorage).scoped('/celest/auth');
celest_cloud.CelestCloud? cloud,
}) : _storage = (storage ?? celest.nativeStorage).scoped('/celest/auth') {
this.cloud = cloud ??
celest_cloud.CelestCloud(
authenticator: Authenticator(
secureStorage: _storage.secure,
onRevoke: signOut,
),
uri: celest.baseUri,
httpClient: celest.httpClient,
userAgent: 'celest/$packageVersion',
);
}

AuthState? _authState;

Expand All @@ -37,19 +56,64 @@ final class AuthImpl implements Auth {
return _init ??= Future.sync(() async {
_authStateSubscription =
_authStateController.stream.listen((state) => _authState = state);
AuthState initialState;
try {
final user = await protocol.userInfo();
initialState = Authenticated(user: user);
_authStateController.add(initialState);
} on UnauthorizedException {
initialState = const Unauthenticated();
signOut();
}
final initialState = await _initialState;
_authStateController.add(initialState);
return initialState;
});
}

Future<AuthState> get _initialState async {
var initialState = await _hydrateSession();
if (initialState != null) {
return initialState;
}
if (localStorage.read('userId') == null) {
_reset();
return const Unauthenticated();
}
try {
final user = await cloud.users.get('me');
initialState = Authenticated(user: user.toCelest());
} on UnauthorizedException {
initialState = const Unauthenticated();
_reset();
} on NotFoundException {
initialState = const Unauthenticated();
_reset();
}
return initialState;
}

Future<AuthState?> _hydrateSession() async {
final pendingSessionId = localStorage.read('pendingSessionId');
if (pendingSessionId == null) {
return null;
}
try {
final pendingSessionStateJson =
await secureStorage.read('session/$pendingSessionId');
if (pendingSessionStateJson == null) {
return null;
}
final pendingSessionState = celest_cloud.SessionState.fromJson(
jsonDecode(pendingSessionStateJson) as Map<String, dynamic>,
);
final callbackUri = initialUri;
if (callbackUri == null ||
pendingSessionState is! celest_cloud.IdpSessionAuthorize) {
return null;
}
final result = await cloud.authentication.idp.postRedirect(
state: pendingSessionState,
redirectUri: callbackUri,
);
return result.toCelest(hub: this, sink: _authStateController.sink);
} finally {
localStorage.delete('pendingSessionId');
secureStorage.delete('session/$pendingSessionId').ignore();
}
}

Future<AuthState>? _init;

Future<AuthFlowController> requestFlow() async {
Expand Down Expand Up @@ -82,25 +146,29 @@ final class AuthImpl implements Auth {

@override
Future<void> signOut() async {
localStorage.delete('userId');
secureStorage.delete('cork').ignore();
try {
await protocol.signOut();
await cloud.authentication.endSession(null);
} finally {
_reset();
if (!_authStateController.isClosed) {
_authStateController.add(const Unauthenticated());
}
}
}

void _reset() {
localStorage.delete('userId');
secureStorage.delete('cork').ignore();
}

final CelestBase celest;
late final celest_cloud.CelestCloud cloud;
final NativeAuthentication nativeAuth = NativeAuthentication();
final NativeStorage _storage;

NativeStorage get localStorage => _storage;
IsolatedNativeStorage get secureStorage => _storage.secure.isolated;

late final AuthClient protocol = AuthClient(celest);

Future<void> close() async {
await _authStateSubscription?.cancel();
await _authFlowSubscription?.cancel();
Expand Down
119 changes: 96 additions & 23 deletions packages/celest_auth/lib/src/flows/email_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ 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';
import 'package:celest_core/src/auth/auth_protocol.dart';
import 'package:celest_core/src/auth/otp/otp_types.dart';

extension type Email(AuthImpl _hub) {
/// Authenticates a user with the given [email] using a one-time password
/// (OTP) sent to that email.
///
/// OTP codes are valid for 15 minutes and can be resent after 60 seconds
/// OTP codes are valid for 15 minutes and can be resent after 30 seconds
/// by calling `resend` on the returned state object.
Future<EmailNeedsVerification> authenticate({
required String email,
Expand All @@ -28,34 +28,81 @@ final class EmailFlow implements AuthFlow {
final AuthImpl _hub;
final AuthFlowController _flowController;

EmailProtocol get _protocol => _hub.protocol.email;

Future<EmailNeedsVerification> _authenticate({
required String email,
}) {
return _flowController.capture(() async {
final parameters = await _protocol.sendOtp(
request: OtpSendRequest(email: email),
final state = await _hub.cloud.authentication.email.start(
email: email,
);
switch (state) {
case cloud.EmailSessionVerifyCode():
return _EmailNeedsVerification(
flow: this,
innerState: state,
email: state.email,
);
default:
throw StateError('Unexpected state after start: $state');
}
});
}

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

Future<EmailNeedsVerification> _resendOtp({
required cloud.EmailSessionVerifyCode state,
}) {
return _flowController.capture(() async {
state = await _hub.cloud.authentication.email.resendCode(
state: state,
);
return _EmailNeedsVerification(
flow: this,
email: email,
parameters: parameters,
innerState: state,
email: state.email,
);
});
}

Future<Authenticated> _verifyOtp({
required String email,
required String otp,
Future<AuthState> _confirm({
required cloud.EmailSessionRegisterUser state,
}) {
return _flowController.capture(() async {
final user = await _protocol.verifyOtp(
verification: OtpVerifyRequest(email: email, otp: otp),
final newState = await _hub.cloud.authentication.email.confirm(
state: state,
);
_hub.secureStorage.write('cork', user.cork);
_hub.localStorage.write('userId', user.user.userId);
return Authenticated(user: user.user);
switch (newState) {
case cloud.EmailSessionSuccess(:final identityToken, :final user):
_hub.secureStorage.write('cork', identityToken);
_hub.localStorage.write('userId', user.userId);
return Authenticated(user: user.toCelest());
case cloud.EmailSessionRegisterUser(:final user):
return _EmailRegisterUser(
user: user.toCelest(),
flow: this,
innerState: newState,
);
case cloud.EmailSessionVerifyCode():
return _EmailNeedsVerification(
flow: this,
innerState: newState,
email: state.email,
);
}
});
}

Expand All @@ -66,25 +113,51 @@ final class EmailFlow implements AuthFlow {
final class _EmailNeedsVerification extends EmailNeedsVerification {
_EmailNeedsVerification({
required EmailFlow flow,
required this.innerState,
required super.email,
required OtpParameters parameters,
}) : _flow = flow;

final EmailFlow _flow;
final cloud.EmailSessionVerifyCode innerState;

@override
Future<void> resend() async {
await _flow._protocol.resendOtp(
request: OtpSendRequest(email: email),
);
await _flow._resendOtp(state: innerState);
}

@override
Future<User> verify({required String otpCode}) async {
final authenticated = await _flow._verifyOtp(email: email, otp: otpCode);
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 _EmailRegisterUser extends AuthRegisterUser {
_EmailRegisterUser({
required super.user,
required EmailFlow flow,
required cloud.EmailSessionRegisterUser innerState,
}) : _flow = flow,
_innerState = innerState;

final EmailFlow _flow;
final cloud.EmailSessionRegisterUser _innerState;

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

@override
Future<User> confirm() async {
final authenticated =
await _flow._confirm(state: _innerState) as Authenticated;
return authenticated.user;
}
}
Loading

0 comments on commit 2e39315

Please sign in to comment.