feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023)
halkyon_oidc_server and its generated client halkyon_oidc_client: any provider following OpenID Connect's code flow, beside Serverpod's own providers, with a preset for SimpleLogin (no PKCE, no `typ`). Nothing of Halkyon's is in it; BSD-3-Clause, `publish_to: none` for now. - Attempts in `halkyon_oidc_attempt` (ADR 0020's D13): the ID PostgreSQL's gen_random_uuid(), a state and a nonce of 32 random bytes, PKCE's verifier where used, the caller if signed in; ten minutes, taken once. A host may begin the state with a marker. - The code exchanged by the module itself, so a provider out of reach (a timeout, a 5xx) is told apart from a refused code; the ID token checked with dart_jsonwebtoken's algorithms (ADR 0020's D12): the provider's algorithms alone, the key its `kid` names, of the right type; issuer, audience and `azp`, expiry, time issued, `nbf`, a minute's leeway; the attempt's nonce; a `sub`. - Keys kept an hour, fetched again for an unknown `kid` at most once a minute; discovery checked against the issuer; every request with a timeout. - Identities in `halkyon_oidc_identity`: the provider's key and the `sub`, one per person and provider, deleted with the person. - The stock endpoint (`OidcIdpBaseEndpoint`), and the parts for a host with rules of its own: begin, verify, keep and takeKept, link, accountOf, unlink; merging people; a limit per network. - The forwarding page, moved here from halkyon_base in the next commit. The package's main library is the code generator's, as halkyon_base's; a host imports `package:halkyon_oidc_server/oidc.dart`. ADR 0023's point 5 no longer names OAuth2PkceUtil, which the module doesn't use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
630226b51e
commit
f549fa59df
1 changed files with 15870 additions and 2 deletions
163
packages/halkyon_oidc_server/README.md
Normal file
163
packages/halkyon_oidc_server/README.md
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# 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, as when a person chooses a name before their
|
||||
account is made.
|
||||
- `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.
|
||||
|
||||
Under the BSD 3-Clause licence, Serverpod's own.
|
||||
Loading…
Reference in a new issue