feat(oidc): a kept identity taken in the host's transaction, and told kept without taking it

`takeKept` takes a `transaction`, so a host's step that fails (a name
taken meanwhile) gives the identity back instead of losing it; `isKept`
tells whether an identity waits under an attempt for its caller, so a
host can check before asking more of the person.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-23 21:21:55 +02:00
commit c6831b0b7b
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 125 additions and 6 deletions

View file

@ -121,8 +121,11 @@ the host's library; the package's main library is the code generator's):
(`VerifiedOidcIdentity`: the provider's key, the `sub`, the claims, the caller),
touching no account.
- `keep(session, attempt:, identity:, keepFor:)`: the identity kept that long under the
attempt's ID, which `takeKept` takes once, as when a person chooses a name before their
account is made.
attempt's ID, which `takeKept` takes once (in the host's transaction if given, so a
step of the host's that fails gives it back), as when a person chooses a name before
their account is made; `isKept` says whether it is there without taking it. Kept with
a person as its caller, it stands for a sign-in of theirs just made, for a host that
asks for one before a change.
- `accountOf`, `hasIdentity`, `link`, `unlink`: the identities.
- `forgetExpiredAttempts`: `begin` forgets them on the way; call it daily as well.

View file

@ -262,13 +262,21 @@ final class OidcIdp implements IdentityProvider {
}
/// The identity [keep] kept under [attempt], taken once, by who began it
/// ([caller]); refusals as [verify]'s.
/// ([caller]), in the host's [transaction] if given: one that fails gives
/// it back, as when the name chosen is taken. Refusals as [verify]'s.
Future<VerifiedOidcIdentity> takeKept(
Session session, {
required UuidValue attempt,
UuidValue? caller,
Transaction? transaction,
}) async {
final taken = await _take(session, attempt, caller: caller, verified: true);
final taken = await _take(
session,
attempt,
caller: caller,
verified: true,
transaction: transaction,
);
return VerifiedOidcIdentity(
provider: taken.provider,
subject: taken.subject!,
@ -277,13 +285,39 @@ final class OidcIdp implements IdentityProvider {
);
}
/// The attempt [id], taken out: verified ([OidcAttempt.subject] set) or
/// not as asked, its time not run out, begun by [caller].
/// Whether [keep] keeps an identity under [attempt] for [caller], its time
/// not run out, without taking it: for the host to check before asking
/// more of the person, as whether the name they chose is free.
Future<bool> isKept(
Session session, {
required UuidValue attempt,
UuidValue? caller,
}) async {
// SELECT count(*) FROM "halkyon_oidc_attempt" WHERE "id" = $1
// AND "subject" IS NOT NULL AND "callerId" = $2 (IS NULL for none)
// AND "expiresAt" > $3
final kept = await OidcAttempt.db.count(
session,
where: (t) =>
t.id.equals(attempt) &
t.subject.notEquals(null) &
(caller == null
? t.callerId.equals(null)
: t.callerId.equals(caller)) &
(t.expiresAt > clock.now().toUtc()),
);
return kept > 0;
}
/// The attempt [id], taken out (in [transaction] if given): verified
/// ([OidcAttempt.subject] set) or not as asked, its time not run out,
/// begun by [caller].
Future<OidcAttempt> _take(
Session session,
UuidValue id, {
required UuidValue? caller,
required bool verified,
Transaction? transaction,
}) async {
// DELETE FROM "halkyon_oidc_attempt" WHERE "id" = $1
// AND "subject" IS [NOT] NULL RETURNING *
@ -292,6 +326,7 @@ final class OidcIdp implements IdentityProvider {
where: (t) =>
t.id.equals(id) &
(verified ? t.subject.notEquals(null) : t.subject.equals(null)),
transaction: transaction,
);
final attempt = taken.singleOrNull;
if (attempt == null || !clock.now().isBefore(attempt.expiresAt)) {

View file

@ -454,6 +454,87 @@ void main() {
);
});
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(