`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>
172 lines
8.4 KiB
Markdown
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.
|