import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:clock/clock.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 'test_tools/serverpod_test_tools.dart'; // Signing in with an OpenID Connect provider (ADR 0023): an attempt begun, // the person sent to the provider, the code it sends back ending the attempt // once, with the ID token it gives checked (test/unit/id_token_test.dart has // every way a token is refused), the identity linked to a person or a // session issued. Against a fake provider answering in the test. void main() { late TestKey key; setUpAll(() => key = TestKey.generate('k', seed: 7)); withServerpod('OpenID Connect', (sessionBuilder, endpoints) { late Session session; late FakeProvider fake; late OidcIdp idp; void useProvider(FakeProvider provider, {RateLimit? beginsPerNetwork}) { fake = provider; AuthServices.set( tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()], identityProviderBuilders: [ OidcIdpConfig( providers: [provider.provider], httpClient: provider.client, beginsPerNetwork: beginsPerNetwork ?? OidcIdpConfig.defaultBegins, ), ], ); idp = AuthServices.instance.oidcIdp; } setUp(() { session = sessionBuilder.build(); useProvider(FakeProvider(key: key)); }); Future newPerson() async => (await AuthServices.instance.authUsers.create(session)).id; Matcher refused(OidcRefusal reason) => throwsA( isA().having((e) => e.reason, 'reason', reason), ); /// A sign-in begun and allowed by [subject] at the provider: the attempt /// and the code. Future<({UuidValue attempt, String code})> allowed( String subject, { UuidValue? caller, Map claims = const {}, }) async { final start = await idp.begin(session, provider: 'fake', caller: caller); return ( attempt: start.attempt, code: fake.authorize( start.authorizationUrl, subject: subject, claims: claims, ), ); } group('beginning', () { test( 'keeps an attempt for ten minutes and sends the person to the provider', () async { final now = DateTime.utc(2026, 10, 23, 18); final start = await withClock( Clock.fixed(now), () => idp.begin( session, provider: 'fake', statePrefix: 'learn-desktop.', ), ); final attempt = (await OidcAttempt.db.findById( session, start.attempt, ))!; final query = start.authorizationUrl.queryParameters; expect(start.authorizationUrl.path, '/authorize'); expect(query['client_id'], 'client-1'); expect(query['redirect_uri'], '${fake.callbackUrl}'); expect(start.state, startsWith('learn-desktop.')); expect(query['state'], start.state); expect(attempt.state, start.state); expect(query['nonce'], attempt.nonce); expect(query['code_challenge'], pkceChallenge(attempt.codeVerifier!)); expect(attempt.expiresAt, now.add(const Duration(minutes: 10))); expect(attempt.callerId, isNull); expect( '${start.authorizationUrl}', isNot(contains(start.attempt.uuid)), ); }, ); test('without PKCE where the provider has none', () async { useProvider(FakeProvider(key: key, pkce: false)); final start = await idp.begin(session, provider: 'fake'); final attempt = (await OidcAttempt.db.findById( session, start.attempt, ))!; expect(attempt.codeVerifier, isNull); expect( start.authorizationUrl.queryParameters, isNot(contains('code_challenge')), ); }); test('a provider the host does not name is refused', () async { await expectLater( idp.begin(session, provider: 'other'), refused(OidcRefusal.unknownProvider), ); }); test('too often from one network is refused', () async { useProvider( FakeProvider(key: key), beginsPerNetwork: const RateLimit( maxAttempts: 2, timeframe: Duration(hours: 1), ), ); final network = randomText(); await idp.begin(session, provider: 'fake', network: network); await idp.begin(session, provider: 'fake', network: network); await expectLater( idp.begin(session, provider: 'fake', network: network), refused(OidcRefusal.tooManyAttempts), ); await idp.begin(session, provider: 'fake', network: randomText()); }); test('forgets the attempts whose time ran out', () async { final old = await idp.begin(session, provider: 'fake'); await withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () => idp.begin(session, provider: 'fake'), ); expect(await OidcAttempt.db.findById(session, old.attempt), isNull); }); }); group('verifying', () { test( "proves the provider's ID of the person and what its token says", () async { final (:attempt, :code) = await allowed( '42', claims: {'name': 'Ada'}, ); final verified = await idp.verify( session, attempt: attempt, code: code, ); expect(verified.provider, 'fake'); expect(verified.subject, '42'); expect(verified.claims['name'], 'Ada'); expect(verified.caller, isNull); }, ); test( 'asks the token endpoint as the client, with the verifier', () async { final (:attempt, :code) = await allowed('42'); final verifier = (await OidcAttempt.db.findById( session, attempt, ))!.codeVerifier; await idp.verify(session, attempt: attempt, code: code); final form = fake.tokenRequests.single.bodyFields; expect(form['grant_type'], 'authorization_code'); expect(form['code'], code); expect(form['redirect_uri'], '${fake.callbackUrl}'); expect(form['code_verifier'], verifier); expect(form, isNot(contains('client_secret'))); }, ); test('ends the attempt: a second time, it is unknown', () async { final (:attempt, :code) = await allowed('42'); await idp.verify(session, attempt: attempt, code: code); expect(await OidcAttempt.db.findById(session, attempt), isNull); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.unknownAttempt), ); }); test('an attempt nobody began is unknown', () async { await expectLater( idp.verify(session, attempt: const Uuid().v4obj(), code: 'c'), refused(OidcRefusal.unknownAttempt), ); }); test('an attempt older than ten minutes is unknown', () async { final (:attempt, :code) = await allowed('42'); await expectLater( withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () => idp.verify(session, attempt: attempt, code: code), ), refused(OidcRefusal.unknownAttempt), ); }); test( 'by someone other than who began it is refused, and ends it', () async { final anna = await newPerson(); final ben = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); await expectLater( idp.verify(session, attempt: attempt, code: code, caller: ben), refused(OidcRefusal.wrongCaller), ); await expectLater( idp.verify(session, attempt: attempt, code: code, caller: anna), refused(OidcRefusal.unknownAttempt), ); }, ); test('signed out, of an attempt begun signed in, is refused', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.wrongCaller), ); }); test('signed in, of an attempt begun signed out, is refused', () async { final (:attempt, :code) = await allowed('42'); await expectLater( idp.verify( session, attempt: attempt, code: code, caller: await newPerson(), ), refused(OidcRefusal.wrongCaller), ); }); test('with the caller who began it, names them', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); final verified = await idp.verify( session, attempt: attempt, code: code, caller: anna, ); expect(verified.caller, anna); }); test('a code caught on its way to another device ends nothing', () async { // The catcher's attempt is their own: with PKCE its verifier isn't // the one the code was asked with, and without (SimpleLogin) the // token carries the other attempt's nonce. for (final pkce in [true, false]) { useProvider(FakeProvider(key: key, pkce: pkce)); final victims = await idp.begin(session, provider: 'fake'); final catchers = await idp.begin(session, provider: 'fake'); final caught = fake.authorize( victims.authorizationUrl, subject: '42', ); await expectLater( idp.verify(session, attempt: catchers.attempt, code: caught), refused(OidcRefusal.rejected), reason: 'PKCE: $pkce', ); } }); test('a code the provider refuses is rejected', () async { final (:attempt, code: _) = await allowed('42'); await expectLater( idp.verify(session, attempt: attempt, code: 'made-up'), refused(OidcRefusal.rejected), ); }); test('a token answer without an ID token is rejected', () async { final (:attempt, :code) = await allowed('42'); fake.tokenAnswer = (status: 200, body: '{"access_token": "a"}'); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.rejected), ); }); test('a provider failing or out of reach is unavailable', () async { for (final failure in [ () => fake.tokenAnswer = (status: 503, body: ''), () => fake.tokenAnswer = (status: 200, body: 'not json'), () => fake.tokenError = const SocketException('down'), ]) { fake ..tokenAnswer = null ..tokenError = null; failure(); final (:attempt, :code) = await allowed('42'); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.providerUnavailable), ); } }); test('a provider answering too slowly is unavailable', () async { AuthServices.set( tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()], identityProviderBuilders: [ OidcIdpConfig( providers: [fake.provider], httpClient: fake.client, timeout: const Duration(milliseconds: 50), ), ], ); idp = AuthServices.instance.oidcIdp; fake.tokenDelay = const Duration(seconds: 2); final (:attempt, :code) = await allowed('42'); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.providerUnavailable), ); }); test('reads the endpoints from the discovery document, once', () async { useProvider(FakeProvider(key: key, discovered: true)); for (final subject in ['1', '2']) { final (:attempt, :code) = await allowed(subject); expect( (await idp.verify(session, attempt: attempt, code: code)).subject, subject, ); } expect(fake.discoveries, 1); expect(fake.keyFetches, 1); }); test( 'a discovery document of another issuer makes the provider unavailable', () async { useProvider(FakeProvider(key: key, discovered: true)); fake.discoveryIssuer = 'https://evil.example'; await expectLater( idp.begin(session, provider: 'fake'), refused(OidcRefusal.providerUnavailable), ); }, ); }); group('keeping a verified attempt', () { test('keeps the identity for the host to take once', () async { final (:attempt, :code) = await allowed('42'); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, ), keepFor: const Duration(minutes: 10), ); final kept = await idp.takeKept(session, attempt: attempt); expect((kept.provider, kept.subject), ('fake', '42')); await expectLater( idp.takeKept(session, attempt: attempt), refused(OidcRefusal.unknownAttempt), ); }); test('can be verified no more', () async { final (:attempt, :code) = await allowed('42'); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, ), keepFor: const Duration(minutes: 10), ); await expectLater( idp.verify(session, attempt: attempt, code: code), refused(OidcRefusal.unknownAttempt), ); }); test('keeps it no longer than said', () async { final (:attempt, :code) = await allowed('42'); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, ), keepFor: const Duration(minutes: 10), ); await expectLater( withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () => idp.takeKept(session, attempt: attempt), ), refused(OidcRefusal.unknownAttempt), ); }); test('is taken by who began it alone', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, caller: anna, ), keepFor: const Duration(minutes: 10), ); await expectLater( idp.takeKept(session, attempt: attempt), refused(OidcRefusal.wrongCaller), ); }); test("is taken in the host's transaction, which gives it back as it " 'fails', () async { final (:attempt, :code) = await allowed('42'); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, ), keepFor: const Duration(minutes: 10), ); await expectLater( session.db.transaction((transaction) async { await idp.takeKept( session, attempt: attempt, transaction: transaction, ); throw StateError("the host's own step failed"); }), throwsStateError, ); final kept = await idp.takeKept(session, attempt: attempt); expect(kept.subject, '42'); }); test('is told kept for who began it, without being taken', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); expect( await idp.isKept(session, attempt: attempt, caller: anna), isFalse, ); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, caller: anna, ), keepFor: const Duration(minutes: 10), ); expect( await idp.isKept(session, attempt: attempt, caller: anna), isTrue, ); expect( await idp.isKept(session, attempt: attempt, caller: anna), isTrue, ); expect(await idp.isKept(session, attempt: attempt), isFalse); expect( await idp.isKept( session, attempt: attempt, caller: await newPerson(), ), isFalse, ); expect( await withClock( Clock.fixed(DateTime.now().add(const Duration(minutes: 11))), () => idp.isKept(session, attempt: attempt, caller: anna), ), isFalse, ); expect( (await idp.takeKept(session, attempt: attempt, caller: anna)).subject, '42', ); expect( await idp.isKept(session, attempt: attempt, caller: anna), isFalse, ); }); test('an attempt not verified yet is not taken', () async { final (:attempt, code: _) = await allowed('42'); await expectLater( idp.takeKept(session, attempt: attempt), refused(OidcRefusal.unknownAttempt), ); }); }); group('identities', () { test('linked, name their person', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), anna, ); expect( await idp.accountOf(session, provider: 'fake', subject: '43'), isNull, ); expect( await idp.accountOf(session, provider: 'other', subject: '42'), isNull, ); expect(await idp.hasIdentity(session, authUserId: anna), isTrue); }); test('linked again to the same person change nothing', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); expect(await OidcIdentity.db.count(session), 1); }); test("another person's are refused", () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); await expectLater( idp.link( session, authUserId: await newPerson(), provider: 'fake', subject: '42', ), refused(OidcRefusal.takenByAnotherPerson), ); }); test('a second at the same provider is refused', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); await expectLater( idp.link(session, authUserId: anna, provider: 'fake', subject: '43'), refused(OidcRefusal.otherIdentityLinked), ); }); test('go when unlinked', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); await idp.unlink(session, authUserId: anna, provider: 'fake'); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), isNull, ); expect(await idp.hasIdentity(session, authUserId: anna), isFalse); }); test('go with their person', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); await AuthServices.instance.authUsers.delete(session, authUserId: anna); expect(await OidcIdentity.db.count(session), 0); }); test('move to the person kept when two are merged', () async { final kept = await newPerson(); final removed = await newPerson(); await idp.link( session, authUserId: removed, provider: 'fake', subject: '42', ); await session.db.transaction( (transaction) => idp.mergeAuthUsers( session, userToKeepId: kept, userToRemoveId: removed, transaction: transaction, ), ); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), kept, ); }); test('at a provider both merged people have stop the merge', () async { final kept = await newPerson(); final removed = await newPerson(); await idp.link( session, authUserId: kept, provider: 'fake', subject: '41', ); await idp.link( session, authUserId: removed, provider: 'fake', subject: '42', ); await expectLater( session.db.transaction( (transaction) => idp.mergeAuthUsers( session, userToKeepId: kept, userToRemoveId: removed, transaction: transaction, ), ), throwsA(isA()), ); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), removed, ); }); }); group('the stock sign-in', () { test('makes a new person for an ID nobody has, with a session', () async { final (:attempt, :code) = await allowed('42'); final signedIn = await idp.login(session, attempt: attempt, code: code); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), signedIn.authUserId, ); expect(signedIn.token, isNotEmpty); }); test('signs in the person who has the ID', () async { final anna = await newPerson(); await idp.link( session, authUserId: anna, provider: 'fake', subject: '42', ); final (:attempt, :code) = await allowed('42'); final signedIn = await idp.login(session, attempt: attempt, code: code); expect(signedIn.authUserId, anna); expect(await AuthUser.db.count(session), 1); }); test('refuses an attempt begun signed in', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); await expectLater( idp.login(session, attempt: attempt, code: code), refused(OidcRefusal.wrongCaller), ); }); test('links the ID to who began it signed in', () async { final anna = await newPerson(); final (:attempt, :code) = await allowed('42', caller: anna); await idp.linkToCaller( session, attempt: attempt, code: code, caller: anna, ); expect( await idp.accountOf(session, provider: 'fake', subject: '42'), anna, ); }); }); test("SimpleLogin's preset signs in as SimpleLogin has it: its " 'endpoints, no PKCE, no discovery', () async { useProvider( FakeProvider.simpleLogin( key: key, clientId: 'halkyon', clientSecret: 'secret-2', callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'), ), ); final start = await idp.begin(session, provider: 'simplelogin'); final url = start.authorizationUrl; expect( '${url.origin}${url.path}', 'https://app.simplelogin.io/oauth2/authorize', ); expect(url.queryParameters, isNot(contains('code_challenge'))); final identity = await idp.verify( session, attempt: start.attempt, code: fake.authorize(url, subject: 'sl-1'), ); expect((identity.provider, identity.subject), ('simplelogin', 'sl-1')); expect(fake.tokenRequests.single.url.path, '/oauth2/token'); expect(fake.discoveries, 0); }); test("sends the client's ID and secret form-encoded, as OAuth 2.0 " 'has them in Basic authentication (RFC 6749, 2.3.1)', () async { useProvider( FakeProvider.simpleLogin( key: key, clientId: 'halkyon:learn', clientSecret: 'a secret/+&', callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'), ), ); final start = await idp.begin(session, provider: 'simplelogin'); await idp.verify( session, attempt: start.attempt, code: fake.authorize(start.authorizationUrl, subject: 'sl-1'), ); expect( fake.tokenRequests.single.headers['authorization'], 'Basic ${base64.encode(utf8.encode('halkyon%3Alearn:a+secret%2F%2B%26'))}', ); }); test('forgets attempts whose time ran out', () async { await idp.begin(session, provider: 'fake'); final (:attempt, :code) = await allowed('42'); await idp.keep( session, attempt: attempt, identity: await idp.verify( session, attempt: attempt, code: code, ), keepFor: const Duration(minutes: 30), ); final later = DateTime.now().add(const Duration(minutes: 11)); expect( await withClock( Clock.fixed(later), () => idp.forgetExpiredAttempts(session), ), 1, ); expect(await OidcAttempt.db.count(session), 1); }); }); }