halkyon/packages/halkyon_base_server/test/integration/simplelogin/simplelogin_test.dart

602 lines
19 KiB
Dart
Raw Normal View History

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);
}