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
8 changed files with 3017 additions and 44 deletions
|
|
@ -7,6 +7,7 @@ import 'package:halkyon_base_server/src/register/deletion_notices.dart';
|
|||
import 'package:halkyon_base_server/src/register/instance_api.dart';
|
||||
import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
||||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
|
@ -39,6 +40,13 @@ final class _Register implements AccountRegister {
|
|||
/// Whether it offers password sign-in.
|
||||
bool passwordOffered = true;
|
||||
|
||||
/// Whether it offers SimpleLogin, the sign-ins begun with it, the accounts
|
||||
/// it was added to, and what the next sign-in proves.
|
||||
bool simpleLoginIsOffered = true;
|
||||
final simpleLoginsBegun = <({String marker, UuidValue? caller})>[];
|
||||
final simpleLoginLinked = <UuidValue>{};
|
||||
SimpleLoginProof? simpleLoginProof;
|
||||
|
||||
/// The accounts with a password, and those with an authenticator too.
|
||||
final passwords = <UuidValue>{};
|
||||
final authenticated = <UuidValue>{};
|
||||
|
|
@ -206,6 +214,7 @@ final class _Register implements AccountRegister {
|
|||
() => SignInWays(
|
||||
gitHub: gitHub.containsValue(person),
|
||||
email: addresses.containsValue(person),
|
||||
simpleLogin: simpleLoginLinked.contains(person),
|
||||
password: passwords.contains(person),
|
||||
authenticator: authenticated.contains(person),
|
||||
passwordOnly:
|
||||
|
|
@ -241,6 +250,43 @@ final class _Register implements AccountRegister {
|
|||
Future<bool> passwordSignInOffered(Session session) =>
|
||||
_answer('passwordSignInOffered', () => passwordOffered);
|
||||
|
||||
@override
|
||||
Future<bool> simpleLoginOffered(Session session) =>
|
||||
_answer('simpleLoginOffered', () => simpleLoginIsOffered);
|
||||
|
||||
@override
|
||||
Future<OidcStart> beginSimpleLogin(
|
||||
Session session, {
|
||||
required String marker,
|
||||
UuidValue? caller,
|
||||
required String network,
|
||||
}) => _answer('beginSimpleLogin', () {
|
||||
networks.add(network);
|
||||
simpleLoginsBegun.add((marker: marker, caller: caller));
|
||||
return OidcStart(
|
||||
attempt: const Uuid().v7obj(),
|
||||
authorizationUrl: Uri.parse(
|
||||
'https://app.simplelogin.io/oauth2/authorize',
|
||||
),
|
||||
state: '$marker.state',
|
||||
);
|
||||
});
|
||||
|
||||
@override
|
||||
Future<SimpleLoginProof> completeSimpleLogin(
|
||||
Session session, {
|
||||
required UuidValue attempt,
|
||||
required String code,
|
||||
UuidValue? caller,
|
||||
}) => _answer('completeSimpleLogin', () {
|
||||
final proof = simpleLoginProof!;
|
||||
if (proof.outcome == SimpleLoginOutcome.linked) {
|
||||
simpleLoginLinked.add(caller!);
|
||||
return SimpleLoginProof(outcome: proof.outcome, account: caller);
|
||||
}
|
||||
return proof;
|
||||
});
|
||||
|
||||
@override
|
||||
Future<void> setPassword(
|
||||
Session session, {
|
||||
|
|
@ -951,6 +997,154 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('SimpleLogin, by asking the register (ADR 0020)', () {
|
||||
test('begins for this app and platform, signed out, the device '
|
||||
'network passed on', () async {
|
||||
final start = await endpoints.simpleLogin.begin(
|
||||
notSignedIn(),
|
||||
platform: SimpleLoginPlatform.phone,
|
||||
);
|
||||
|
||||
expect(register.asked, ['beginSimpleLogin']);
|
||||
expect(register.simpleLoginsBegun.single, (
|
||||
marker: '$testApp-phone',
|
||||
caller: null,
|
||||
));
|
||||
expect(register.networks, hasLength(1));
|
||||
expect(start.state, startsWith('$testApp-phone.'));
|
||||
});
|
||||
|
||||
test('begins signed in for the caller', () async {
|
||||
await endpoints.simpleLogin.begin(
|
||||
asAnna(),
|
||||
platform: SimpleLoginPlatform.desktop,
|
||||
);
|
||||
|
||||
expect(register.simpleLoginsBegun.single, (
|
||||
marker: '$testApp-desktop',
|
||||
caller: anna,
|
||||
));
|
||||
});
|
||||
|
||||
test('signs the account the register found in here, made a person '
|
||||
'here the first time', () async {
|
||||
final carla = const Uuid().v7obj();
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.signedIn,
|
||||
account: carla,
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
);
|
||||
|
||||
expect(register.asked, ['completeSimpleLogin']);
|
||||
expect(completed.outcome, SimpleLoginOutcome.signedIn);
|
||||
expect(completed.session!.authUserId, carla);
|
||||
expect(await scopesOf(carla), {identified.name});
|
||||
expect(
|
||||
(await ServerSideSession.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(carla),
|
||||
)).single.method,
|
||||
simpleLoginMethod,
|
||||
);
|
||||
});
|
||||
|
||||
test('makes the caller identified here once the register added '
|
||||
'SimpleLogin to their account', () async {
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.linked,
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.linked);
|
||||
expect(completed.session, isNull);
|
||||
expect(register.simpleLoginLinked, {anna});
|
||||
expect(await scopesOf(anna), contains(identified.name));
|
||||
expect(
|
||||
await AnonymousAccount.db.find(
|
||||
session,
|
||||
where: (t) => t.authUserId.equals(anna),
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
expect(
|
||||
(await endpoints.account.signInWays(asAnna())).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('passes on the handle suggested to someone new, with no session '
|
||||
'and no person here', () async {
|
||||
final people = await AuthUser.db.count(session);
|
||||
register.simpleLoginProof = SimpleLoginProof(
|
||||
outcome: SimpleLoginOutcome.newcomer,
|
||||
suggestedHandle: 'carla_mueller',
|
||||
);
|
||||
|
||||
final completed = await endpoints.simpleLogin.complete(
|
||||
notSignedIn(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
);
|
||||
|
||||
expect(completed.outcome, SimpleLoginOutcome.newcomer);
|
||||
expect(completed.session, isNull);
|
||||
expect(completed.suggestedHandle, 'carla_mueller');
|
||||
expect(await AuthUser.db.count(session), people);
|
||||
});
|
||||
|
||||
test('refuses as the register refuses', () async {
|
||||
register.failing = OidcRefusedException(
|
||||
reason: OidcRefusal.takenByAnotherPerson,
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
endpoints.simpleLogin.complete(
|
||||
asAnna(),
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: 'code',
|
||||
),
|
||||
throwsA(
|
||||
isA<OidcRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
OidcRefusal.takenByAnotherPerson,
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(await scopesOf(anna), isNot(contains(identified.name)));
|
||||
});
|
||||
|
||||
test('is offered as the register offers it, and not while it is out '
|
||||
'of reach', () async {
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
register.simpleLoginIsOffered = false;
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isFalse,
|
||||
);
|
||||
register
|
||||
..simpleLoginIsOffered = true
|
||||
..failing = RegisterUnavailableException('test');
|
||||
expect(
|
||||
(await endpoints.instance.basics(notSignedIn())).simpleLogin,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('profiles', () {
|
||||
test('are saved and found at the register', () async {
|
||||
final saved = await endpoints.profile.save(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import 'package:halkyon_base_server/src/register/register_protocol.dart';
|
|||
import 'package:halkyon_base_server/src/register/remote_register.dart';
|
||||
import 'package:halkyon_base_server/src/register/sessions_ended.dart'
|
||||
show sessionNoticesDelivered;
|
||||
import 'package:halkyon_oidc_server/oidc.dart';
|
||||
import 'package:halkyon_oidc_server/testing.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
|
|
@ -463,6 +465,162 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
group('SimpleLogin (ADR 0020)', () {
|
||||
late TestKey key;
|
||||
late FakeProvider simpleLogin;
|
||||
|
||||
setUpAll(() => key = TestKey.generate('sl', seed: 12));
|
||||
setUp(() {
|
||||
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);
|
||||
});
|
||||
tearDown(() => useSimpleLoginApp(pod, null));
|
||||
|
||||
test('is offered as the register offers it', () async {
|
||||
expect(await remote.simpleLoginOffered(session), isTrue);
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
expect(await remote.simpleLoginOffered(session), isFalse);
|
||||
});
|
||||
|
||||
test("begins for a member's app, signed in or out, and signs in "
|
||||
'the account with that identity', () async {
|
||||
await OidcIdentity.db.insertRow(
|
||||
session,
|
||||
OidcIdentity(
|
||||
authUserId: ben,
|
||||
provider: simpleLoginProvider,
|
||||
subject: 'sl-ben',
|
||||
),
|
||||
);
|
||||
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
final proof = await remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-ben',
|
||||
),
|
||||
);
|
||||
|
||||
expect(start.state, startsWith('todo-desktop.'));
|
||||
expect(proof.outcome, SimpleLoginOutcome.signedIn);
|
||||
expect(proof.account, ben);
|
||||
});
|
||||
|
||||
test("adds SimpleLogin to a member's person, who becomes an account "
|
||||
'under the same ID', () async {
|
||||
final annaOnTodo = const Uuid().v7obj();
|
||||
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-phone',
|
||||
caller: annaOnTodo,
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
final proof = await remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-anna',
|
||||
),
|
||||
caller: annaOnTodo,
|
||||
);
|
||||
|
||||
expect(proof.outcome, SimpleLoginOutcome.linked);
|
||||
expect(proof.account, annaOnTodo);
|
||||
expect(
|
||||
(await AuthUser.db.findById(session, annaOnTodo))!.scopeNames,
|
||||
contains(identified.name),
|
||||
);
|
||||
expect(
|
||||
(await remote.signInWaysOf(session, annaOnTodo)).simpleLogin,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('passes on the handle suggested to someone new', () async {
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
final proof = await remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-carla',
|
||||
claims: {'name': 'Carla Müller'},
|
||||
),
|
||||
);
|
||||
|
||||
expect(proof.outcome, SimpleLoginOutcome.newcomer);
|
||||
expect(proof.account, isNull);
|
||||
expect(proof.suggestedHandle, 'carla_mueller');
|
||||
});
|
||||
|
||||
test("refuses as the register refuses, halkyon_oidc's refusals "
|
||||
'crossing whole', () async {
|
||||
final start = await remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
);
|
||||
final code = simpleLogin.authorize(
|
||||
start.authorizationUrl,
|
||||
subject: 'sl-carla',
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: start.attempt,
|
||||
code: code,
|
||||
caller: const Uuid().v7obj(),
|
||||
),
|
||||
throwsA(
|
||||
isA<OidcRefusedException>().having(
|
||||
(error) => error.reason,
|
||||
'reason',
|
||||
OidcRefusal.wrongCaller,
|
||||
),
|
||||
),
|
||||
);
|
||||
await expectLater(
|
||||
remote.completeSimpleLogin(
|
||||
session,
|
||||
attempt: const Uuid().v7obj(),
|
||||
code: code,
|
||||
),
|
||||
refused(OidcRefusal.unknownAttempt),
|
||||
);
|
||||
});
|
||||
|
||||
test('refuses where the register has no SimpleLogin app', () async {
|
||||
useSimpleLogin(sessionBuilder, null);
|
||||
|
||||
await expectLater(
|
||||
remote.beginSimpleLogin(
|
||||
session,
|
||||
marker: 'todo-desktop',
|
||||
network: '203.0.113.0/24',
|
||||
),
|
||||
refused(OidcRefusal.unknownProvider),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('passwords (ADR 0017)', () {
|
||||
late DateTime now;
|
||||
Future<T> atNow<T>(Future<T> Function() body) =>
|
||||
|
|
@ -1242,6 +1400,10 @@ final class _NoSession implements Session {
|
|||
}
|
||||
|
||||
Future<void> _deleteAll(Session session) async {
|
||||
await OidcAttempt.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
);
|
||||
await SignInChallenge.db.deleteWhere(
|
||||
session,
|
||||
where: (t) => t.id.notEquals(null),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:halkyon_oidc_server/testing.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:serverpod_auth_idp_server/core.dart';
|
||||
|
||||
|
|
@ -17,13 +18,14 @@ const testUpstreamSourceCodeUrl = 'https://code.example.org/halkyon_test';
|
|||
const testMinimumAppBuild = 7;
|
||||
|
||||
/// Sets the module up on the pod [sessionBuilder]'s sessions run on, as a
|
||||
/// host does before its pod starts, with [deletePersonData] as its
|
||||
/// deletions and [profilePageNote] at the foot of profile pages, and
|
||||
/// host does before its pod starts, for [app], with [deletePersonData] as
|
||||
/// its deletions and [profilePageNote] at the foot of profile pages, and
|
||||
/// authentication exactly as hosts run it
|
||||
/// (`auth_config.dart`). Not on `Serverpod.instance`: that is the pod made
|
||||
/// last, and a test file with several `withServerpod` groups has a pod each.
|
||||
void useBase(
|
||||
TestSessionBuilder sessionBuilder, {
|
||||
String app = testApp,
|
||||
List<PersonDataDeletion> deletePersonData = const [],
|
||||
ProfilePageNote? profilePageNote,
|
||||
AccountsMode accounts = const HoldsRegister(),
|
||||
|
|
@ -32,7 +34,7 @@ void useBase(
|
|||
HalkyonBase.configure(
|
||||
pod,
|
||||
HalkyonBase(
|
||||
app: testApp,
|
||||
app: app,
|
||||
appName: testAppName,
|
||||
upstreamSourceCodeUrl: testUpstreamSourceCodeUrl,
|
||||
minimumAppBuild: testMinimumAppBuild,
|
||||
|
|
@ -41,13 +43,38 @@ void useBase(
|
|||
accounts: accounts,
|
||||
),
|
||||
);
|
||||
// No password sign-in unless a test gives it keys, and no SimpleLogin
|
||||
// unless a test gives it an app, whatever the test run's passwords and
|
||||
// environment hold.
|
||||
usePasswordKeys(pod, null);
|
||||
useOfferedWays(pod, null);
|
||||
useSimpleLoginApp(pod, null);
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
);
|
||||
}
|
||||
|
||||
/// SimpleLogin on the pod [sessionBuilder]'s sessions run on (ADR 0020), the
|
||||
/// instance's app answered by [fake] (none for null), and authentication set
|
||||
/// up anew as hosts run it.
|
||||
void useSimpleLogin(TestSessionBuilder sessionBuilder, FakeProvider? fake) {
|
||||
final pod = sessionBuilder.build().serverpod;
|
||||
useSimpleLoginApp(
|
||||
pod,
|
||||
fake == null
|
||||
? null
|
||||
: (
|
||||
clientId: fake.clientId,
|
||||
clientSecret: fake.clientSecret,
|
||||
callbackUrl: fake.callbackUrl,
|
||||
),
|
||||
client: fake?.client,
|
||||
);
|
||||
AuthServices.set(
|
||||
tokenManagerBuilders: tokenManagerBuilders,
|
||||
identityProviderBuilders: identityProviderBuilders(pod),
|
||||
);
|
||||
// No password sign-in unless a test gives it keys, whatever the test
|
||||
// run's passwords hold.
|
||||
usePasswordKeys(pod, null);
|
||||
}
|
||||
|
||||
/// Signs [person] in on a new device and returns the new session's id.
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ import 'package:halkyon_base_server/src/generated/profile/profile.dart'
|
|||
as _it4tom37;
|
||||
import 'package:halkyon_base_server/src/generated/profile/profile_refusal.dart'
|
||||
as _idszjcpz;
|
||||
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_completed.dart'
|
||||
as _i8g1l2iv;
|
||||
import 'package:halkyon_base_server/src/generated/simplelogin/simplelogin_platform.dart'
|
||||
as _ixrtiuh0;
|
||||
import 'package:halkyon_oidc_server/halkyon_oidc_server.dart' as _ipoqfiqr;
|
||||
import 'package:serverpod/serverpod.dart' as _is;
|
||||
import 'package:serverpod_auth_core_server/serverpod_auth_core_server.dart'
|
||||
as _iacs;
|
||||
|
|
@ -206,6 +211,8 @@ class TestEndpoints {
|
|||
late final _ProfileEndpoint profile;
|
||||
|
||||
late final _ProfilesEndpoint profiles;
|
||||
|
||||
late final _SimpleLoginEndpoint simpleLogin;
|
||||
}
|
||||
|
||||
class _InternalTestEndpoints extends TestEndpoints
|
||||
|
|
@ -263,6 +270,10 @@ class _InternalTestEndpoints extends TestEndpoints
|
|||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
simpleLogin = _SimpleLoginEndpoint(
|
||||
endpoints,
|
||||
serializationManager,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1574,3 +1585,80 @@ class _ProfilesEndpoint {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
class _SimpleLoginEndpoint {
|
||||
_SimpleLoginEndpoint(
|
||||
this._endpointDispatch,
|
||||
this._serializationManager,
|
||||
);
|
||||
|
||||
final _is.EndpointDispatch _endpointDispatch;
|
||||
|
||||
final _is.SerializationManager _serializationManager;
|
||||
|
||||
_ida.Future<_ipoqfiqr.OidcStart> begin(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _ixrtiuh0.SimpleLoginPlatform platform,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'simpleLogin',
|
||||
method: 'begin',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'simpleLogin',
|
||||
methodName: 'begin',
|
||||
parameters: _ist.testObjectToJson({'platform': platform}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_ipoqfiqr.OidcStart>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_ida.Future<_i8g1l2iv.SimpleLoginCompleted> complete(
|
||||
_ist.TestSessionBuilder sessionBuilder, {
|
||||
required _is.UuidValue attempt,
|
||||
required String code,
|
||||
}) async {
|
||||
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
|
||||
var _localUniqueSession =
|
||||
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
|
||||
endpoint: 'simpleLogin',
|
||||
method: 'complete',
|
||||
);
|
||||
try {
|
||||
var _localCallContext = await _endpointDispatch.getMethodCallContext(
|
||||
createSessionCallback: (_) => _localUniqueSession,
|
||||
endpointPath: 'simpleLogin',
|
||||
methodName: 'complete',
|
||||
parameters: _ist.testObjectToJson({
|
||||
'attempt': attempt,
|
||||
'code': code,
|
||||
}),
|
||||
serializationManager: _serializationManager,
|
||||
);
|
||||
var _localReturnValue =
|
||||
await (_localCallContext.method.call(
|
||||
_localUniqueSession,
|
||||
_localCallContext.arguments,
|
||||
)
|
||||
as _ida.Future<_i8g1l2iv.SimpleLoginCompleted>);
|
||||
return _localReturnValue;
|
||||
} finally {
|
||||
await _localUniqueSession.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// SIGN_IN_WAYS (ADR 0020, 1): the ways in an operator offers.
|
||||
|
||||
void main() {
|
||||
test('unset or empty, every way whose keys are there', () {
|
||||
expect(offeredWaysFrom(null), isNull);
|
||||
expect(offeredWaysFrom(''), isNull);
|
||||
expect(offeredWaysFrom(' '), isNull);
|
||||
});
|
||||
|
||||
test('names one way or both, by commas or spaces, whatever their case', () {
|
||||
expect(offeredWaysFrom('simplelogin'), {OfferedWay.simpleLogin});
|
||||
expect(offeredWaysFrom('password'), {OfferedWay.password});
|
||||
expect(offeredWaysFrom('password, SimpleLogin'), {
|
||||
OfferedWay.password,
|
||||
OfferedWay.simpleLogin,
|
||||
});
|
||||
expect(offeredWaysFrom('simplelogin password'), hasLength(2));
|
||||
});
|
||||
|
||||
test('refuses a way it does not know, so the server stops as it starts', () {
|
||||
for (final value in ['github', 'simplelogin,email', 'passwords']) {
|
||||
expect(
|
||||
() => offeredWaysFrom(value),
|
||||
throwsA(isA<StateError>()),
|
||||
reason: value,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// The handle suggested from the name SimpleLogin shares (ADR 0020, D5 and
|
||||
// D17): `anna_mueller` for Anna Müller, which the person confirms or changes.
|
||||
|
||||
void main() {
|
||||
group('A handle made of a name', () {
|
||||
test('is in lower case, its words joined by underscores', () {
|
||||
expect(handleFromName('Anna Mueller'), 'anna_mueller');
|
||||
expect(handleFromName(' Ben de la Cruz '), 'ben_de_la_cruz');
|
||||
expect(handleFromName('Dr. Carla Weiß-Berg'), 'dr_carla_weiss_berg');
|
||||
expect(handleFromName('R2 D2'), 'r2_d2');
|
||||
});
|
||||
|
||||
test("writes German's letters as German writes them without, ligatures "
|
||||
'and thorn as their letters, and every other letter without its '
|
||||
'mark', () {
|
||||
expect(handleFromName('Anna Müller'), 'anna_mueller');
|
||||
expect(handleFromName('Jürgen Größe'), 'juergen_groesse');
|
||||
expect(handleFromName('ÄÖÜ ẞ'), 'aeoeue_ss');
|
||||
expect(handleFromName('José Núñez'), 'jose_nunez');
|
||||
expect(handleFromName('Françoise Lefèvre'), 'francoise_lefevre');
|
||||
expect(handleFromName('Ægir Þórsson'), 'aegir_thorsson');
|
||||
expect(handleFromName('Søren Ångström'), 'soren_angstroem');
|
||||
expect(handleFromName('Łucja Đorđević'), 'lucja_dordevic');
|
||||
});
|
||||
|
||||
test('drops apostrophes inside a word', () {
|
||||
expect(handleFromName("Seán O'Brien"), 'sean_obrien');
|
||||
expect(handleFromName('D’Angelo'), 'dangelo');
|
||||
});
|
||||
|
||||
test('is cut to 30 characters, never ending in an underscore', () {
|
||||
final handle = handleFromName(
|
||||
'Maximiliane Theodora von Hohenberg-Stauffen',
|
||||
)!;
|
||||
expect(handle, 'maximiliane_theodora_von_hohen');
|
||||
expect(isHandle(handle), isTrue);
|
||||
expect(handleFromName('${'a' * 29} b'), 'a' * 29);
|
||||
});
|
||||
|
||||
test('is none where nothing of the name can be written so', () {
|
||||
expect(handleFromName(null), isNull);
|
||||
expect(handleFromName(''), isNull);
|
||||
expect(handleFromName(' '), isNull);
|
||||
expect(handleFromName('山田太郎'), isNull);
|
||||
expect(handleFromName('Αλέξης'), isNull);
|
||||
expect(handleFromName('!!! ???'), isNull);
|
||||
});
|
||||
|
||||
test('keeps the Latin part of a name in two scripts', () {
|
||||
expect(handleFromName('Yamada 山田'), 'yamada');
|
||||
});
|
||||
});
|
||||
|
||||
group('The handles tried for a name', () {
|
||||
test('are the name, then with a number, the base cut to make room', () {
|
||||
final tried = handlesToTry('anna_mueller').take(3).toList();
|
||||
expect(tried, ['anna_mueller', 'anna_mueller2', 'anna_mueller3']);
|
||||
final long = 'a' * 30;
|
||||
expect(handlesToTry(long).elementAt(1), '${'a' * 29}2');
|
||||
expect(handlesToTry(long).elementAt(9), '${'a' * 28}10');
|
||||
expect(handlesToTry('${'a' * 28}_b').elementAt(1), '${'a' * 28}2');
|
||||
});
|
||||
|
||||
test('are 99 at most, every one a handle', () {
|
||||
final tried = handlesToTry('b' * 30).toList();
|
||||
expect(tried, hasLength(99));
|
||||
expect(tried.every(isHandle), isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:halkyon_base_server/base.dart';
|
||||
import 'package:serverpod/serverpod.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// The instance's SimpleLogin app (ADR 0020, 1): its client from the
|
||||
// passwords, the callback SimpleLogin sends sign-ins back to.
|
||||
|
||||
ServerConfig _webServer({
|
||||
String scheme = 'https',
|
||||
String host = 'learn.example',
|
||||
int port = 443,
|
||||
}) => ServerConfig(
|
||||
port: 8082,
|
||||
publicScheme: scheme,
|
||||
publicHost: host,
|
||||
publicPort: port,
|
||||
);
|
||||
|
||||
void main() {
|
||||
final passwords = {
|
||||
'simpleLoginClientId': 'halkyon-abc',
|
||||
'simpleLoginClientSecret': 'secret-xyz',
|
||||
};
|
||||
|
||||
test('is read from the passwords, both or none', () {
|
||||
expect(
|
||||
simpleLoginAppFrom((name) => passwords[name], webServer: _webServer()),
|
||||
(
|
||||
clientId: 'halkyon-abc',
|
||||
clientSecret: 'secret-xyz',
|
||||
callbackUrl: Uri.parse('https://learn.example/simplelogin-callback'),
|
||||
),
|
||||
);
|
||||
for (final missing in passwords.keys) {
|
||||
final some = {...passwords}..remove(missing);
|
||||
expect(
|
||||
simpleLoginAppFrom((name) => some[name], webServer: _webServer()),
|
||||
isNull,
|
||||
reason: missing,
|
||||
);
|
||||
final empty = {...passwords, missing: ''};
|
||||
expect(
|
||||
simpleLoginAppFrom((name) => empty[name], webServer: _webServer()),
|
||||
isNull,
|
||||
reason: '$missing empty, as Compose passes an unset one',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("calls back to the web server's own page, its port where it is "
|
||||
'not the usual one', () {
|
||||
expect(
|
||||
simpleLoginAppFrom(
|
||||
(name) => passwords[name],
|
||||
webServer: _webServer(scheme: 'http', host: 'localhost', port: 8082),
|
||||
)!.callbackUrl,
|
||||
Uri.parse('http://localhost:8082/simplelogin-callback'),
|
||||
);
|
||||
});
|
||||
|
||||
test('calls back where SIMPLELOGIN_CALLBACK_URL says, as development '
|
||||
"does to learn.halkyon.eu's page", () {
|
||||
expect(
|
||||
simpleLoginAppFrom(
|
||||
(name) => passwords[name],
|
||||
callbackUrl: 'https://learn.halkyon.eu/simplelogin-callback',
|
||||
webServer: _webServer(scheme: 'http', host: 'localhost', port: 8082),
|
||||
)!.callbackUrl,
|
||||
Uri.parse('https://learn.halkyon.eu/simplelogin-callback'),
|
||||
);
|
||||
expect(
|
||||
simpleLoginAppFrom(
|
||||
(name) => passwords[name],
|
||||
callbackUrl: '',
|
||||
webServer: _webServer(),
|
||||
)!.callbackUrl,
|
||||
Uri.parse('https://learn.example/simplelogin-callback'),
|
||||
);
|
||||
});
|
||||
|
||||
test('is refused, so the server stops, with a callback SimpleLogin would '
|
||||
'refuse or none at all', () {
|
||||
for (final callback in [
|
||||
'http://learn.example/simplelogin-callback',
|
||||
'learn.example/simplelogin-callback',
|
||||
'https:///simplelogin-callback',
|
||||
]) {
|
||||
expect(
|
||||
() => simpleLoginAppFrom(
|
||||
(name) => passwords[name],
|
||||
callbackUrl: callback,
|
||||
webServer: _webServer(),
|
||||
),
|
||||
throwsA(isA<StateError>()),
|
||||
reason: callback,
|
||||
);
|
||||
}
|
||||
expect(
|
||||
() => simpleLoginAppFrom((name) => passwords[name], webServer: null),
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
expect(simpleLoginAppFrom((name) => null, webServer: null), isNull);
|
||||
});
|
||||
|
||||
test('marks a sign-in with the app and the platform, which the forwarding '
|
||||
'page knows', () {
|
||||
expect(
|
||||
simpleLoginMarker('learn', SimpleLoginPlatform.desktop),
|
||||
'learn-desktop',
|
||||
);
|
||||
expect(simpleLoginMarker('todo', SimpleLoginPlatform.phone), 'todo-phone');
|
||||
for (final app in ['learn', 'todo']) {
|
||||
for (final platform in SimpleLoginPlatform.values) {
|
||||
expect(
|
||||
simpleLoginReturns,
|
||||
contains(simpleLoginMarker(app, platform)),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue