halkyon/packages/halkyon_oidc_server/README.md
Tizian 「ティツィアーン」 c6831b0b7b
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>
2026-10-24 09:17:48 +02:00

172 lines
8.4 KiB
Markdown

# halkyon_oidc
Signing in with OpenID Connect for Serverpod v4, as a Serverpod module
(`halkyon_oidc_server`, with its generated client `halkyon_oidc_client`): any provider
following OpenID Connect's code flow (Keycloak, Authentik, Zitadel, Authelia, Pocket ID,
…) beside Serverpod's own providers, with a preset for SimpleLogin, the one tried live.
Halkyon signs in with it (ADR 0020, ADR 0023); nothing of Halkyon's is in it.
## What it does
- **Begins a sign-in** (`OidcIdp.begin`): an attempt in `halkyon_oidc_attempt`, its ID
PostgreSQL's `gen_random_uuid()` (122 random bits), the secret only the device holds,
with a state and a nonce of 32 random bytes each, PKCE's verifier where the provider
takes PKCE, and who began it, if signed in. It answers with the attempt, the address to
send the person to, and the state. Attempts last ten minutes and are ended once.
- **Ends it** (`OidcIdp.verify`): the attempt taken out first, so it ends once whatever
follows; the code exchanged with the client's secret (HTTP Basic, or in the form); the
ID token checked: three parts, the header's algorithm one of the provider's
(asymmetric only, no `typ` asked for, as SimpleLogin sends none), the key the `kid`
names among the provider's keys, of the algorithm's type; the signature; the issuer,
the audience (and `azp` with more than one, or wherever it is given), the expiry, the
time issued and `nbf`, each with a minute of clocks' difference; the attempt's nonce;
a `sub`. What it proves is the provider's key and the `sub`, with the token's other
claims for the host's code, which nothing keeps.
- **Keeps a person's identity** at a provider (`halkyon_oidc_identity`): the provider's
key and the `sub`, nothing else. Each `sub` is one person's at a provider, and a
person has one per provider. It goes with the person (a foreign key to Serverpod's
`AuthUser`, deleted with it), as do their unfinished attempts.
- **Asks providers sparingly**: the discovery document once (checked to name the
provider's issuer and its three endpoints, asked again after a failure), the keys
for an hour, fetched again for an unknown `kid` at most once a minute; every request
with a timeout (10 s unless said), a provider out of reach refused as
`providerUnavailable`, never as a refused sign-in.
- **Limits the sign-ins one network begins** (30 an hour unless said, Serverpod's
`DatabaseRateLimiter`, domain `halkyon_oidc`), counting by the address the host gives.
Refusals are `OidcRefusedException`s with an `OidcRefusal`: `unknownProvider`,
`unknownAttempt` (nobody began it, it ended, or its time ran out), `wrongCaller`,
`rejected` (the provider refused the code, or the token proves nothing; the server
logs why), `providerUnavailable`, `takenByAnotherPerson`, `otherIdentityLinked`,
`tooManyAttempts`.
## Setting it up
1. **Depend on it** (`halkyon_oidc_server` in the server, `halkyon_oidc_client` in the
client), and name it in the server's `config/generator.yaml`:
```yaml
modules:
halkyon_oidc:
nickname: oidc
```
2. **Name the providers** where the server sets up its sign-in
(`package:halkyon_oidc_server/oidc.dart`), each under a key of its own that never
changes, as it is kept with every identity:
```dart
pod.initializeAuthServices(
tokenManagerBuilders: [ServerSideSessionsConfigFromPasswords()],
identityProviderBuilders: [
OidcIdpConfig(
providers: [
// Issuer, endpoints, no PKCE, RS256: what SimpleLogin does.
OidcProvider.simpleLogin(
clientId: pod.getPassword('simpleLoginClientId')!,
clientSecret: pod.getPassword('simpleLoginClientSecret')!,
callbackUrl: Uri.parse('https://example.org/simplelogin-callback'),
),
// Endpoints from the issuer's discovery document.
OidcProvider(
key: 'keycloak',
issuer: 'https://id.example.org/realms/main',
clientId: 'my-app',
clientSecret: pod.getPassword('keycloakClientSecret')!,
callbackUrl: Uri.parse('my.app://oidc-callback'),
scopes: const ['openid', 'email'],
),
],
),
],
);
```
`OidcProvider` takes the scopes (`openid` alone unless said), PKCE (on unless said),
the algorithms its tokens may be signed with (`RS256` unless said), the endpoints
where it has no discovery document, and how the client gives its secret.
`OidcIdpConfig` takes how long attempts last, the limit per network, the timeout and
the HTTP client.
3. **Add the endpoint**, either way below, then `serverpod generate` and
`serverpod create-migration`, which adds the module's two tables and its version
row to the server's migrations.
### The stock way
As Serverpod's own providers: one endpoint in the host's server,
```dart
class OidcIdpEndpoint extends OidcIdpBaseEndpoint {}
```
with `begin(provider)`, `login(attempt, code)` (a session for the person with that
identity, a new person for one nobody has), `link(attempt, code)` (the identity added to
the signed-in caller, who began the attempt signed in) and `hasAccount`. Sessions it
issues name the method `oidc`.
`begin` counts its limit by the request's `remoteInfo`: the connection's address, or the
first address of a `Forwarded` or `X-Forwarded-For` header, whoever wrote it. Behind a
proxy that passes either on as the visitor sent it, override `begin` and pass the
address the proxy saw as `network`.
### The parts
For a host with rules of its own (who may have an account, a name chosen first), its own
endpoints call `AuthServices.instance.oidcIdp` (`package:halkyon_oidc_server/oidc.dart`,
the host's library; the package's main library is the code generator's):
- `begin(session, provider:, caller:, statePrefix:, network:)`: an attempt.
- `verify(session, attempt:, code:, caller:)`: the identity proven
(`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 (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.
`mergeAuthUsers` moves one person's identities to another, refusing where both have one
at the same provider.
## The forwarding page
Some providers take only https callbacks, each registered exactly (SimpleLogin), so a
desktop app's loopback address or a phone's scheme can't be one. The host then registers
one page of its web server, and the page sends each sign-in on to the app that began it:
```dart
addOidcCallbackForwarding(
pod,
path: '/simplelogin-callback',
returns: {
'desktop': Uri.parse('http://127.0.0.1:43823/oidc-callback'),
'phone': Uri.parse('my.app://oidc-callback'),
},
);
```
The app begins with `statePrefix: 'desktop.'` (or `'phone.'`); the page answers a 303
to the address the marker names, with the provider's `code` (or its `error`) and the
`state`, each exactly once and well formed, and refuses anything else with a page that
repeats nothing it was given (`refusalPage` for the host's own, in its look and
languages). It keeps and logs nothing: a code caught on its way is worthless without the
attempt the app holds. `oidcCallbackForwarding` is the page itself, for tests.
## Tests
`dart test` here starts its own PostgreSQL (`config/test.yaml`), with throwaway
`database`, `serviceSecret` and `serverSideSessionKeyHashPepper` in a git-ignored
`config/passwords.yaml`. The providers in the tests are fakes that keep to the
specification, keys generated per run; the ID token's checks are tested one by one.
A host tests its own sign-in the same way, with `package:halkyon_oidc_server/testing.dart`:
`TestKey.generate` makes a provider's RSA key, and `FakeProvider` (or
`FakeProvider.simpleLogin`, SimpleLogin's endpoints without PKCE) answers the module
through its `client`, passed as `OidcIdpConfig`'s `httpClient`, and gives the code a
person's consent would (`authorize`), with the claims the test names.
Under the BSD 3-Clause licence, Serverpod's own.