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),
|
(`VerifiedOidcIdentity`: the provider's key, the `sub`, the claims, the caller),
|
||||||
touching no account.
|
touching no account.
|
||||||
- `keep(session, attempt:, identity:, keepFor:)`: the identity kept that long under the
|
- `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
|
attempt's ID, which `takeKept` takes once (in the host's transaction if given, so a
|
||||||
account is made.
|
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.
|
- `accountOf`, `hasIdentity`, `link`, `unlink`: the identities.
|
||||||
- `forgetExpiredAttempts`: `begin` forgets them on the way; call it daily as well.
|
- `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
|
/// 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(
|
Future<VerifiedOidcIdentity> takeKept(
|
||||||
Session session, {
|
Session session, {
|
||||||
required UuidValue attempt,
|
required UuidValue attempt,
|
||||||
UuidValue? caller,
|
UuidValue? caller,
|
||||||
|
Transaction? transaction,
|
||||||
}) async {
|
}) 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(
|
return VerifiedOidcIdentity(
|
||||||
provider: taken.provider,
|
provider: taken.provider,
|
||||||
subject: taken.subject!,
|
subject: taken.subject!,
|
||||||
|
|
@ -277,13 +285,39 @@ final class OidcIdp implements IdentityProvider {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The attempt [id], taken out: verified ([OidcAttempt.subject] set) or
|
/// Whether [keep] keeps an identity under [attempt] for [caller], its time
|
||||||
/// not as asked, its time not run out, begun by [caller].
|
/// 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(
|
Future<OidcAttempt> _take(
|
||||||
Session session,
|
Session session,
|
||||||
UuidValue id, {
|
UuidValue id, {
|
||||||
required UuidValue? caller,
|
required UuidValue? caller,
|
||||||
required bool verified,
|
required bool verified,
|
||||||
|
Transaction? transaction,
|
||||||
}) async {
|
}) async {
|
||||||
// DELETE FROM "halkyon_oidc_attempt" WHERE "id" = $1
|
// DELETE FROM "halkyon_oidc_attempt" WHERE "id" = $1
|
||||||
// AND "subject" IS [NOT] NULL RETURNING *
|
// AND "subject" IS [NOT] NULL RETURNING *
|
||||||
|
|
@ -292,6 +326,7 @@ final class OidcIdp implements IdentityProvider {
|
||||||
where: (t) =>
|
where: (t) =>
|
||||||
t.id.equals(id) &
|
t.id.equals(id) &
|
||||||
(verified ? t.subject.notEquals(null) : t.subject.equals(null)),
|
(verified ? t.subject.notEquals(null) : t.subject.equals(null)),
|
||||||
|
transaction: transaction,
|
||||||
);
|
);
|
||||||
final attempt = taken.singleOrNull;
|
final attempt = taken.singleOrNull;
|
||||||
if (attempt == null || !clock.now().isBefore(attempt.expiresAt)) {
|
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 {
|
test('an attempt not verified yet is not taken', () async {
|
||||||
final (:attempt, code: _) = await allowed('42');
|
final (:attempt, code: _) = await allowed('42');
|
||||||
await expectLater(
|
await expectLater(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue