feat(base): signing in with SimpleLogin through the register (ADR 0020, step 2)
The `simpleLogin` endpoint on halkyon_oidc's SimpleLogin preset: `begin` for the app and platform (the state's marker for the forwarding page), at most 30 an hour per network; `complete` with SimpleLogin's code, signed out a session for the account with that SimpleLogin ID, signed in SimpleLogin added to the caller's account, who becomes identified and anonymous no longer. Someone new gets no account yet: their identity waits 30 minutes under the attempt's ID (D16), with a handle suggested from their name, `anna_mueller` (D17), for the sign-up of step 3. The register alone holds the instance's SimpleLogin app (`simpleLoginClientId`, `simpleLoginClientSecret`) and exchanges codes; a member asks it for both steps and issues its own session. `SIGN_IN_WAYS` says which ways the register offers, the instance's basics and the ways in carry `simpleLogin`, GitHub links nobody more where SimpleLogin is offered, and Learn's and Todo's pruning forgets the unfinished sign-ins hourly. No migration: the tables are halkyon_oidc's, already in place. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a0842e401d
commit
42d06f7ee8
1 changed files with 3017 additions and 44 deletions
|
|
@ -0,0 +1,602 @@
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
import 'package:halkyon_oidc_server/testing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
import '../support/fixtures.dart';
|
||||
import '../support/mailbox.dart';
|
||||
import '../support/password_device.dart';
|
||||
import '../support/password_support.dart';
|
||||
import '../test_tools/serverpod_test_tools.dart';
|
||||
|
||||
// Signing in with SimpleLogin on the instance's register (ADR 0020, 3 to 5;
|
||||
// steps 3 and 4 make accounts for newcomers): an attempt begun, signed out
|
||||
// or in, SimpleLogin's code ending it once, a session for the account with
|
||||
// that identity, the identity added to the caller's account, or someone new
|
||||
// whose identity waits half an hour with a handle suggested from their name.
|
||||
// Against a fake SimpleLogin signing real ID tokens (halkyon_oidc's
|
||||
// testing.dart); every way an ID token is refused is the module's test.
|
||||
|
||||
void main() {
|
||||
late TestKey key;
|
||||
|
||||
setUpAll(() => key = TestKey.generate('sl', seed: 11));
|
||||
|
||||
withServerpod(
|
||||
'SimpleLogin on the register',
|
||||
// Sessions issued, and the rate limiter's attempts in transactions of
|
||||
// their own: these tests delete what they make instead.
|
||||
rollbackDatabase: RollbackDatabase.disabled,
|
||||
(sessionBuilder, endpoints) {
|
||||
late Session session;
|
||||
late Serverpod pod;
|
||||
late FakeProvider simpleLogin;
|
||||
|
||||
/// Anonymous, signed in on her phone.
|
||||
late UuidValue anna;
|
||||
late UuidValue annasPhone;
|
||||
|
||||
/// Identified, signing in with SimpleLogin's ID `sl-ben`.
|
||||
late UuidValue ben;
|
||||
|
||||
setUp(() async {
|
||||
useBase(sessionBuilder, app: 'learn');
|
||||
session = sessionBuilder.build();
|
||||
pod = session.serverpod;
|
||||
await _deleteAll(session);
|
||||
simpleLogin = FakeProvider.simpleLogin(
|
||||
key: key,
|
||||
clientId: 'halkyon-learn',
|
||||
clientSecret: 'a secret of the app',
|
||||
callbackUrl: Uri.parse('https://learn.test/simplelogin-callback'),
|
||||
);
|
||||
useSimpleLogin(sessionBuilder, simpleLogin);
|
||||
anna = await newAnonymousPerson(session);
|
||||
annasPhone = await signInNewDevice(session, anna);
|
||||
ben = (await AuthServices.instance.authUsers.create(
|
||||
session,
|
||||
scopes: {identified},
|
||||
)).id;
|
||||
await OidcIdentity.db.insertRow(
|
||||
session,
|
||||
OidcIdentity(
|
||||
authUserId: ben,
|
||||
provider: simpleLoginProvider,
|
||||
subject: 'sl-ben',
|
||||
),
|
||||
);
|
||||
});
|
||||
tearDown(() async {
|
||||
useSimpleLoginApp(pod, null);
|
||||
await _deleteAll(sessionBuilder.build());
|
||||
});
|
||||
|
||||
TestSessionBuilder asAnna() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.authenticationInfo(
|
||||
anna.uuid,
|
||||
{},
|
||||
authId: annasPhone.uuid,
|
||||
),
|
||||
);
|
||||
TestSessionBuilder notSignedIn() => sessionBuilder.copyWith(
|
||||
authentication: AuthenticationOverride.unauthenticated(),
|
||||
);
|
||||
|
||||
Matcher refused(OidcRefusal reason) => throwsA(
|
||||
isA<OidcRefusedException>().having((e) => e.reason, 'reason', reason),
|
||||
);
|
||||
|
||||
/// A sign-in begun as [as] and allowed at SimpleLogin by [subject],
|
||||
/// who shares [name]: the attempt and SimpleLogin's code.
|
||||
Future<({UuidValue attempt, String code})> allowed(
|
||||
TestSessionBuilder as, {
|
||||
required String subject,
|
||||
String? name,
|
||||
}) async {
|
||||
final start = await endpoints.simpleLogin.begin(
|
||||
as,
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
);
|
||||
return (
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: subject,
|
||||
claims: {'name': ?name},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<OidcIdentity>> identitiesOf(UuidValue person) =>
|
||||
OidcIdentity.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(person),
|
||||
);
|
||||
|
||||
group('is offered', () {
|
||||
test('where the register has its SimpleLogin app', () async {
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('not without one, beginning nothing', () async {
|
||||
useSimpleLoginApp(pod, null);
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isFalse,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
|
||||
test('not where the operator offers passwords alone, nor ends what '
|
||||
'was begun before', () async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-ben',
|
||||
);
|
||||
useOfferedWays(pod, {OfferedWay.password});
|
||||
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isFalse,
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
|
||||
test('alone where the operator offers it alone: no password sign-in, '
|
||||
'its keys there or not', () async {
|
||||
EmailCodes.of(pod).mailer = Mailbox();
|
||||
usePasswordKeys(pod, testPasswordKeys);
|
||||
addTearDown(() {
|
||||
EmailCodes.of(pod).mailer = null;
|
||||
usePasswordKeys(pod, null);
|
||||
});
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).passwordSignIn,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
useOfferedWays(pod, {OfferedWay.simpleLogin});
|
||||
|
||||
final basics = await endpoints.instance.basics(notSignedIn());
|
||||
expect(basics.passwordSignIn, isFalse);
|
||||
expect(basics.simpleLogin, isTrue);
|
||||
});
|
||||
});
|
||||
|
||||
group('beginning', () {
|
||||
test('sends the person to SimpleLogin, the state marked with the app '
|
||||
'and its platform for the forwarding page', () async {
|
||||
final desktop = await endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
);
|
||||
final phone = await endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.phone,
|
||||
);
|
||||
|
||||
final url = desktop.authorizationUrl;
|
||||
expect(
|
||||
'${url.origin}${url.path}',
|
||||
'https://app.simplelogin.io/oauth2/authorize',
|
||||
);
|
||||
expect(url.queryParameters['client_id'], 'halkyon-learn');
|
||||
expect(
|
||||
url.queryParameters['redirect_uri'],
|
||||
'https://learn.test/simplelogin-callback',
|
||||
);
|
||||
expect(desktop.state, startsWith('learn-desktop.'));
|
||||
expect(phone.state, startsWith('learn-phone.'));
|
||||
expect(
|
||||
(await OidcAttempt.db.findById(session, desktop.attempt))!.callerId,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('signed in, notes who began it', () async {
|
||||
final start = await endpoints.simpleLogin.begin(
|
||||
asAnna(),
|
||||
platform: SimpleLoginPlatform.phone,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await OidcAttempt.db.findById(session, start.attempt))!.callerId,
|
||||
anna,
|
||||
);
|
||||
});
|
||||
|
||||
test('for an app the forwarding page has no way back to is its '
|
||||
"host's mistake", () async {
|
||||
// The tests' host, which the forwarding page knows nothing of.
|
||||
useBase(sessionBuilder);
|
||||
useSimpleLogin(sessionBuilder, simpleLogin);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('at most 30 times an hour from one network', () async {
|
||||
for (var i = 0; i < 30; i++) {
|
||||
await endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
);
|
||||
}
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
),
|
||||
refused(OidcRefusal.tooManyAttempts),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('signed out', () {
|
||||
test('signs in to the account with that identity, a session of '
|
||||
"SimpleLogin's", () async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-ben',
|
||||
name: 'Ben Berg',
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.signedIn);
|
||||
expect(completed.suggestedHandle, isNull);
|
||||
expect(completed.session!.authUserId, ben);
|
||||
final sessions = await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
);
|
||||
expect(sessions.single.method, simpleLoginMethod);
|
||||
expect(sessions.single.scopeNames, contains(identified.name));
|
||||
});
|
||||
|
||||
test('someone new gets no account yet: their identity waits half an '
|
||||
'hour, a handle suggested from their name', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
final now = DateTime.now().toUtc();
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-carla',
|
||||
name: 'Carla Müller',
|
||||
);
|
||||
|
||||
final completed = await withClock(
|
||||
Clock.fixed(now),
|
||||
() => endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
||||
expect(completed.session, isNull);
|
||||
expect(completed.suggestedHandle, 'carla_mueller');
|
||||
expect(await AuthUser.db.count(session), people);
|
||||
expect(await OidcIdentity.db.count(session), 1);
|
||||
final kept = (await OidcAttempt.db.findById(session, attempt))!;
|
||||
expect(kept.subject, 'sl-carla');
|
||||
expect(kept.expiresAt, now.add(const Duration(minutes: 30)));
|
||||
});
|
||||
|
||||
test('the handle suggested is one nobody has, whatever its case, and '
|
||||
'none without a name', () async {
|
||||
await AccountRegister.of(pod).saveProfile(
|
||||
session,
|
||||
ben,
|
||||
handle: 'Carla_Mueller',
|
||||
displayName: 'Ben',
|
||||
bio: '',
|
||||
links: const [],
|
||||
);
|
||||
|
||||
final taken = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-carla',
|
||||
name: 'Carla Müller',
|
||||
);
|
||||
final nameless = await allowed(notSignedIn(), subject: 'sl-dora');
|
||||
|
||||
expect(
|
||||
(await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: taken.attempt,
|
||||
code: taken.code,
|
||||
)).suggestedHandle,
|
||||
'carla_mueller2',
|
||||
);
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: nameless.attempt,
|
||||
code: nameless.code,
|
||||
);
|
||||
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
||||
expect(completed.suggestedHandle, isNull);
|
||||
});
|
||||
|
||||
test('ends an attempt once', () async {
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-ben',
|
||||
);
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses an attempt begun signed in, signing nobody in', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
expect(
|
||||
await ServerSideSession.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(ben),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a code SimpleLogin refuses, and an attempt whose ten '
|
||||
'minutes are over', () async {
|
||||
final first = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
final second = await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: first.attempt,
|
||||
code: second.code,
|
||||
),
|
||||
refused(OidcRefusal.rejected),
|
||||
);
|
||||
await expectLater(
|
||||
withClock(
|
||||
Clock.fixed(DateTime.now().add(const Duration(minutes: 11))),
|
||||
() => endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: second.attempt,
|
||||
code: second.code,
|
||||
),
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('signed in', () {
|
||||
test('adds SimpleLogin to the caller, who becomes identified and is '
|
||||
'anonymous no longer', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.linked);
|
||||
expect(completed.session, isNull);
|
||||
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
|
||||
expect(
|
||||
(await endpoints.account.signInWays(asAnna())).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, anna))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('the identity they have already changes nothing', () async {
|
||||
final first = await allowed(asAnna(), subject: 'sl-anna');
|
||||
await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: first.attempt,
|
||||
code: first.code,
|
||||
);
|
||||
final again = await allowed(asAnna(), subject: 'sl-anna');
|
||||
|
||||
expect(
|
||||
(await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: again.attempt,
|
||||
code: again.code,
|
||||
)).outcome,
|
||||
SimpleLoginOutcome.linked,
|
||||
);
|
||||
expect(await identitiesOf(anna), hasLength(1));
|
||||
});
|
||||
|
||||
test("refuses another person's identity, switching nobody and "
|
||||
'changing nothing', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-ben');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.takenByAnotherPerson),
|
||||
);
|
||||
expect(await identitiesOf(anna), isEmpty);
|
||||
expect(
|
||||
await AnonymousAccount.db.count(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses a second SimpleLogin identity', () async {
|
||||
final first = await allowed(asAnna(), subject: 'sl-anna');
|
||||
await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: first.attempt,
|
||||
code: first.code,
|
||||
);
|
||||
final other = await allowed(asAnna(), subject: 'sl-anna-2');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: other.attempt,
|
||||
code: other.code,
|
||||
),
|
||||
refused(OidcRefusal.otherIdentityLinked),
|
||||
);
|
||||
expect((await identitiesOf(anna)).single.subject, 'sl-anna');
|
||||
});
|
||||
|
||||
test("refuses an attempt begun signed out, or by someone else's "
|
||||
'session', () async {
|
||||
final signedOut = await allowed(notSignedIn(), subject: 'sl-anna');
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: signedOut.attempt,
|
||||
code: signedOut.code,
|
||||
),
|
||||
refused(OidcRefusal.wrongCaller),
|
||||
);
|
||||
expect(await identitiesOf(anna), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('the identity goes with the account', () async {
|
||||
final (:attempt, :code) = await allowed(asAnna(), subject: 'sl-anna');
|
||||
await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
|
||||
await deletePerson(session, anna);
|
||||
|
||||
expect(await identitiesOf(anna), isEmpty);
|
||||
expect(await identitiesOf(ben), hasLength(1));
|
||||
});
|
||||
|
||||
test('no new GitHub account is linked where SimpleLogin is offered: '
|
||||
'GitHub is deprecated', () async {
|
||||
await expectLater(
|
||||
linkGitHubAccount(session, authUserId: anna, gitHubUserId: '2002'),
|
||||
throwsA(
|
||||
isA<GitHubSignInRefusedException>().having(
|
||||
(e) => e.reason,
|
||||
'reason',
|
||||
GitHubSignInRefusal.deprecated,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("forgets the attempts whose time ran out, a newcomer's too, and "
|
||||
'says how many', () async {
|
||||
await allowed(notSignedIn(), subject: 'sl-ben');
|
||||
final (:attempt, :code) = await allowed(
|
||||
notSignedIn(),
|
||||
subject: 'sl-carla',
|
||||
);
|
||||
await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: attempt,
|
||||
code: code,
|
||||
);
|
||||
|
||||
final now = DateTime.now();
|
||||
expect(
|
||||
await forgetUnfinishedSimpleLogins(
|
||||
session,
|
||||
now: now.add(const Duration(minutes: 11)),
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
await forgetUnfinishedSimpleLogins(
|
||||
session,
|
||||
now: now.add(const Duration(minutes: 31)),
|
||||
),
|
||||
1,
|
||||
);
|
||||
expect(await OidcAttempt.db.count(session), 0);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await OidcAttempt.db.deleteWhere(session, where: (t) => t.id.notEquals(null));
|
||||
await PublicProfile.db.deleteWhere(session, where: (t) => t.id > 0);
|
||||
await deleteAll(session);
|
||||
}
|
||||
Loading…
Reference in a new issue