| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .. | ||
| config | ||
| lib | ||
| migrations | ||
| test | ||
| .gitignore | ||
| analysis_options.yaml | ||
| CHANGELOG.md | ||
| dart_test.yaml | ||
| LICENSE | ||
| pubspec.yaml | ||
| README.md | ||
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 inhalkyon_oidc_attempt, its ID PostgreSQL'sgen_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, notypasked for, as SimpleLogin sends none), the key thekidnames among the provider's keys, of the algorithm's type; the signature; the issuer, the audience (andazpwith more than one, or wherever it is given), the expiry, the time issued andnbf, each with a minute of clocks' difference; the attempt's nonce; asub. What it proves is the provider's key and thesub, 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 thesub, nothing else. Eachsubis one person's at a provider, and a person has one per provider. It goes with the person (a foreign key to Serverpod'sAuthUser, 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
kidat most once a minute; every request with a timeout (10 s unless said), a provider out of reach refused asproviderUnavailable, never as a refused sign-in. - Limits the sign-ins one network begins (30 an hour unless said, Serverpod's
DatabaseRateLimiter, domainhalkyon_oidc), counting by the address the host gives.
Refusals are OidcRefusedExceptions 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
-
Depend on it (
halkyon_oidc_serverin the server,halkyon_oidc_clientin the client), and name it in the server'sconfig/generator.yaml:modules: halkyon_oidc: nickname: oidc -
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: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'], ), ], ), ], );OidcProvidertakes the scopes (openidalone unless said), PKCE (on unless said), the algorithms its tokens may be signed with (RS256unless said), the endpoints where it has no discovery document, and how the client gives its secret.OidcIdpConfigtakes how long attempts last, the limit per network, the timeout and the HTTP client. -
Add the endpoint, either way below, then
serverpod generateandserverpod 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,
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, thesub, the claims, the caller), touching no account.keep(session, attempt:, identity:, keepFor:): the identity kept that long under the attempt's ID, whichtakeKepttakes once, as when a person chooses a name before their account is made.accountOf,hasIdentity,link,unlink: the identities.forgetExpiredAttempts:beginforgets 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:
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.