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:
parent
e8953d1560
commit
c6831b0b7b
3 changed files with 125 additions and 6 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue