halkyon/packages/halkyon_oidc_server
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Tizian 「ティツィアーン」 7ea9f16904
feat(base): SimpleLogin's sign-in comes back by the app's scheme or the loopback (ADR 0020, D9 corrected)
simpleLogin.begin takes the way back (SimpleLoginReturn: loopback,
scheme) rather than the platform, so macOS can come back through its
scheme as iOS and Android do; the markers become learn-loopback,
learn-scheme, todo-loopback and todo-scheme. Not breaking: the
endpoint has never been released.

D9 corrected by the developer after research: Android, iOS and macOS
come back through the app's scheme, Linux and Windows through the
loopback. Verified links protected nothing here: a code caught on its
way is worthless without the attempt's ID, and a hostile app beginning
an attempt of its own can ask for a way back that other platforms need
open. The ADR notes the residual risk and the check of prompt=consent
still to do. assetlinks.json stays served.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-24 09:17:49 +02:00
..
config feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
lib feat(base): SimpleLogin's sign-in comes back by the app's scheme or the loopback (ADR 0020, D9 corrected) 2026-10-24 09:17:49 +02:00
migrations feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
test feat(oidc): a kept identity taken in the host's transaction, and told kept without taking it 2026-10-24 09:17:48 +02:00
.gitignore feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
analysis_options.yaml feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
CHANGELOG.md feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
dart_test.yaml feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
LICENSE feat(oidc): a Serverpod module for signing in with OpenID Connect, SimpleLogin a preset (ADR 0023) 2026-10-24 09:17:47 +02:00
pubspec.yaml feat(oidc): a fake provider for hosts' tests (testing.dart) 2026-10-24 09:17:47 +02:00
README.md feat(oidc): a kept identity taken in the host's transaction, and told kept without taking it 2026-10-24 09:17:48 +02:00

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 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

  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:

    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:

    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,

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:

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.