halkyon/docs/adr/0023-openid-connect-module.md
Tizian 「ティツィアーン」 f549fa59df
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>
2026-10-24 09:17:47 +02:00

89 lines
5.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR 0023: Signing in with OpenID Connect, a module others can use
- **Status:** decided 23 Oct 2026, by the developer (D1–D4); the design below is built
as ADR 0020's step 2, whose D12 and D13 it carries.
- **Builds on:** ADR 0014 (halkyon_base, the first Serverpod module), ADR 0020 (signing in
with SimpleLogin only).
## Context
ADR 0020 has learn.halkyon.eu sign people in with SimpleLogin, through OpenID Connect's
code flow. Asked on 23 Oct whether that could be "a separate module that others may use
as well at their convenience with Serverpod", what Serverpod v4.0.4 offers:
- **A contract for providers of one's own**: an `IdentityProvider`, built by an
`IdentityProviderBuilder` the host passes to `AuthServices` beside the stock ones, an
endpoint on `IdpBaseEndpoint`, an account model linking the provider's ID to
Serverpod's `AuthUser`, and a controller on the device ("Custom providers" in
Serverpod's documentation, still being written on 23 Oct). `OAuth2PkceUtil` exchanges
a code, PKCE optional, and is exported.
- **No OpenID Connect**: no provider for it, no discovery, and no exported check of ID
tokens (Google's and Firebase's `IdTokenVerifier` is internal and refuses tokens
without `typ`, ADR 0020's D12).
## Decisions (23 Oct 2026, the developer's)
- **D1 OpenID Connect, with a SimpleLogin preset** (recommended, over SimpleLogin alone):
any provider that follows OpenID Connect's code flow (Keycloak, Authentik, Zitadel,
Authelia, Pocket ID, …), which Serverpod lacks; SimpleLogin a preset that knows its
ways (no PKCE, no `typ`). Only SimpleLogin is tried live; the rest is tested to the
specification. The packages' names stay generic.
- **D2 BSD-3-Clause** (recommended, over Apache-2.0, MIT, and AGPL v3 as Halkyon's
other packages): Serverpod's own licence, so whoever builds on Serverpod can take it,
closed apps too.
- **D3 In this repository, published later** (recommended, over a repository of its own
now, or offering it to Serverpod): under `packages/` with `publish_to: none`, so a
change to it and to Halkyon is one commit; to pub.dev once it runs on
learn.halkyon.eu, asked again before, as publishing is public.
- **D4 Now, as ADR 0020's step 2** (recommended, over moving it out after v0.20.0): the
boundary drawn first costs less than pulling Halkyon's code apart later, a table
moved between modules included; v0.20.0 comes about a day later.
## Design
1. **Packages:** `halkyon_oidc_server`, a Serverpod module (nickname `oidc`), with its
generated client `halkyon_oidc_client`; `halkyon_oidc_flutter`, the device's half,
with ADR 0020's step 4. halkyon_base builds on the module; nothing of Halkyon's is in
it.
2. **Providers:** a host names one or more, each under a key of its own that never
changes (`simplelogin`): the issuer, the endpoints (from the provider's discovery
document, checked to name the same issuer, or given), the client's ID and secret,
the scopes, PKCE or not, the signing algorithms its tokens may use (RS256 unless
said), and the callback address the provider has registered. The SimpleLogin preset
fills in all but the client and the callback.
3. **What it keeps:** a person's identity at a provider, the provider's key and the
`sub` (`halkyon_oidc_identity`): each `sub` one person's at a provider, one per
person and provider. Nothing of what the token says besides; the claims go to the
host's code, which decides (Halkyon drops them, ADR 0020's 4).
4. **Attempts** (ADR 0020's D13): `halkyon_oidc_attempt`, the ID PostgreSQL's
`gen_random_uuid()` (122 random bits), the secret only the device holds; the state
and the nonce, 32 random bytes each, and PKCE's verifier where used; who began it,
if signed in; ten minutes, taken once. The host may begin the state with a marker of
its own (ADR 0020's forwarding page reads one).
5. **Checking a sign-in** (ADR 0020's D12): the code exchanged with the client's secret,
by the module itself rather than `OAuth2PkceUtil`, so that a provider out of reach (no
answer in time, a 5xx) is told apart from a refused code; the ID token's header without `typ` asked for, its algorithm one
of the provider's, its key the one its `kid` names in the provider's keys (kept an
hour, fetched again for an unknown `kid` at most once a minute, every fetch with a
timeout); the issuer, the audience (and `azp` with more than one), the expiry and the
time issued, a minute of clocks' difference allowed, the nonce the attempt's.
6. **Two ways to use it:**
- **The stock way**, as Serverpod's providers: the host's endpoint on
`OidcIdpBaseEndpoint`, `begin` and `login`: a known `sub` gets a session, an
unknown one a new person; linking to the signed-in caller where the attempt was
begun so.
- **The parts**, for a host with rules of its own (Halkyon: the register, a handle
before an account, ADR 0016 and 0020): `begin`, `verify` (the identity proven, and
its claims, touching no account), `link`, `accountOf`, `unlink`, called from its
own endpoints.
7. **The forwarding page** goes into the module (`addOidcCallbackForwarding`), with the
targets the host fixes: what ADR 0020's 3 has Halkyon need, a provider that takes
https callbacks alone, each registered exactly, others need alike.
## Consequences
- Halkyon's sign-in rests on a module of its own making, with its own tests and
README, kept usable by others: its interface changes with care once published.
- Serverpod hosts get OpenID Connect; a provider other than SimpleLogin may still need
a fix the specification's tests didn't foresee.
- The module's tables are part of every host's migrations, as halkyon_base's are.