OpenID Connect Provider
Be an identity provider, not just talk to one. One command scaffolds a working OpenID Connect provider so other applications can “Sign in with your app”.
soli generate auth # the provider signs tokens about a signed-in user
soli generate oidc_provider
The second command refuses to run without the first: the provider needs a User
model and the current_user middleware to know who is being authenticated.
The generated provider implements the Authorization Code flow with PKCE —
the only flow OAuth 2.1 still recommends.
1 Endpoints
| Route | Purpose |
|---|---|
GET /.well-known/openid-configuration | Discovery document |
GET /.well-known/jwks.json | Public signing keys |
GET /oauth/authorize | Authorization + consent screen |
POST /oauth/authorize | Consent decision |
POST /oauth/token | Code and refresh grants |
GET|POST /oauth/userinfo | Claims for a bearer token |
POST /oauth/revoke | RFC 7009 revocation |
GET /oauth/logout | RP-initiated logout |
The generated routes call skip_csrf on /oauth/token
and /oauth/revoke. Those are server-to-server calls with no browser
Origin, so the same-origin gate would reject every legitimate exchange; the client is
authenticated instead. The consent POST is a real browser form and keeps CSRF protection.
2 Setup
Signing keys
The generator deliberately does not create keys — a private key written
into config/ ends up committed. Generate them yourself:
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out oidc.pem
openssl rsa -in oidc.pem -pubout -out oidc.pub.pem
SOLI_OIDC_ISSUER=https://id.example.com
SOLI_OIDC_PRIVATE_KEY="$(cat oidc.pem)"
SOLI_OIDC_PUBLIC_KEY="$(cat oidc.pub.pem)"
Both halves are configured because signing needs the private PEM and JWKS publication needs the public one.
SOLI_OIDC_ISSUER must match your public
origin exactly, with no trailing slash — relying parties compare the iss claim byte for byte.
Migrations
soli db:migrate up. SoliDB creates a collection on first model access, so the
migration exists for the indexes. The unique ones are not an optimisation:
code_digest unique is the database-level backstop that keeps an authorization
code single-use even if the application-level burn ever raced.
Register a relying party
result = OauthClient.register("My App", ["https://app.example/callback"], {})
print(result["client_id"])
print(result["client_secret"]) # shown once — only its Argon2 digest is stored
A public client (SPA, mobile) has no secret, so PKCE is forced on and cannot be switched off — it is the only thing binding the code to the requester.
Two edits to sessions_controller.sl
The generator does not modify files it did not create, so make these yourself:
# in `create`, next to session_regenerate():
session_set("auth_time", DateTime.utc().to_unix())
# and replace the final `return redirect("/")` with:
destination = session_get("oidc_return_to") ?? "/"
session_delete("oidc_return_to")
return redirect(destination)
Without the first, auth_time falls back to the authorization instant. Without
the second, a user who signs in mid-flow lands on the home page instead of completing the authorization.
3 Tokens & key rotation
Access tokens are signed JWTs (typ: "at+jwt",
RFC 9068), so a resource server verifies them offline against the JWKS with no call back to the provider. The cost
is that revoking a grant kills the refresh token immediately while an already-issued access token lives out
its TTL. That TTL is deliberately short (10 minutes) because it, not the denylist, is what bounds the exposure.
Refresh tokens are opaque and rotate. Every use issues a new one and retires the
old. Presenting a retired token means it leaked, so the entire family is revoked — the legitimate holder loses
access too, which is correct, because the provider cannot tell the two apart. A refresh token is only issued when the
client asked for offline_access.
The id_token carries iss, sub, aud,
exp, iat, the echoed nonce, auth_time and at_hash.
Scope-gated claims come from the oidc_user_claims hook — the one place that
needs to know what a User looks like.
# 1. keep the outgoing key published
SOLI_OIDC_PREVIOUS_PUBLIC_KEY="$(cat old.pub.pem)"
# 2. promote the new pair, deploy
SOLI_OIDC_PRIVATE_KEY="$(cat new.pem)"
SOLI_OIDC_PUBLIC_KEY="$(cat new.pub.pem)"
# 3. once OIDC_ID_TOKEN_TTL has elapsed, unset the previous key
The JWKS lists both keys during the overlap; signing always uses the active one alone. This only works because tokens
carry a kid header.
4 Security decisions
These are worth knowing about, because several are places where a looser implementation would still appear to work.
Exact redirect_uri matching
Byte for byte against a registered URI — no prefix matching, no wildcards, no normalization. Anything looser lets an attacker who can register a lookalike path harvest authorization codes.
Two errors never redirect
An unknown client_id or an unregistered redirect_uri renders a 400 page instead of bouncing the browser. Redirecting on an unvalidated redirect_uri is the open redirect that RFC 6749 §4.1.2.1 carves these two cases out to prevent. Every other authorization error does redirect, with error, error_description and the echoed state.
PKCE S256 only
plain is rejected for every client type. It offers no protection against an attacker who intercepted the authorization request, and accepting it only adds a downgrade path.
Codes are single-use and bound
60-second TTL, tied to client_id, redirect_uri and code_challenge — all re-checked at exchange. The burn is a single atomic statement, so two simultaneous exchanges cannot both win. A replayed code revokes every token the first exchange produced.
Client authentication
client_secret_basic or client_secret_post, never both (RFC 6749 §2.3). Secrets are Argon2 digests. A failed Basic authentication returns 401 with WWW-Authenticate; the same failure via form parameters returns 400 — a distinction from RFC 6749 §5.2 that is routinely gotten wrong.
Token responses are Cache-Control: no-store
They carry bearer credentials and must not sit in a proxy cache. This is why the generated code returns a raw response hash rather than render_json, whose fast-path headers cannot be extended.
5 Caveats & what is not implemented
- Registered
redirect_urismust be absolutehttp/httpswith no fragment. Custom-scheme native redirects (com.example.app:/cb) are rejected at registration, because the redirect helper only accepts http(s); supporting them would mean bypassing the guard that closes the open redirect. - An app serving files from
public/.well-known/(ACME HTTP-01, for example) can shadow the discovery document, since static files take precedence forGET. - Not implemented: dynamic client registration (RFC 7591), request objects (JAR), implicit and hybrid flows, client-credentials and device-code grants, front/back-channel logout, DPoP or mTLS sender constraining, and token introspection (RFC 7662).