The OpenID Provider for Seamless Auth. Any app that speaks OpenID Connect (Grafana, GitLab, Argo CD, Auth.js and the rest) can sign users in with Seamless Auth through this service, with no Seamless SDK.
It is the public "login edge" in front of a private Seamless Auth API:
any OIDC app ──► seamless-auth-oidc-provider (public: OIDC endpoints, hosted pages, cookies)
│ service token, like any adapter
▼
Seamless Auth API (private, no cookies) ── Postgres
- Built on
oidc-provider9.x. - Holds no private keys and no database. The API signs every token after checking its claims, and stores all provider state.
- Code flow with PKCE (a confidential client can be opted out, and then sends
nonceunless that is waived too), refresh rotation always on,private_key_jwtfor confidential clients, and shared secrets only for clients opted in on the API.
The design is in ADR 0001.
Status: under construction for Seamless Auth v0.21. Discovery, the authorization code flow with PKCE, userinfo and the hosted login and consent pages work. The remaining grants land in the open issues.
- Node 22 or newer.
- A Seamless Auth API with the provider enabled (
oidc_provider_enabled),oidc_issuerset to this service's public URL, and the provider signing keys (OIDC_JWKS_*) configured. - This service's hostname must be the API's
rpidor a subdomain of it, so passkeys work on the hosted pages. - HTTPS. Provider cookies are
__Host-cookies, which browsers only accept over TLS.
docker run -p 5314:5314 --env-file .env ghcr.io/fells-code/seamless-auth-oidc-provider:latest| Variable | Required | Description |
|---|---|---|
SEAMLESS_API_URL |
yes | The private API, as this service reaches it. |
SEAMLESS_API_ISSUER |
no | The API's ISSUER, when it differs from SEAMLESS_API_URL. |
SEAMLESS_OIDC_SERVICE_TOKEN |
yes | This service's own service token secret, SEAMLESS_OIDC_SERVICE_TOKEN on the API. It must differ from the adapters' SEAMLESS_SERVICE_TOKEN, which this service never holds. At least 32 characters. |
SEAMLESS_JWKS_KID |
no | The kid on the service tokens this service signs. |
SEAMLESS_COOKIE_SECRET |
yes | Signs the first-party session cookies. At least 32 characters. |
OIDC_COOKIE_KEYS |
yes | Comma-separated provider cookie keys. The first signs, the rest verify. |
TRUST_PROXY |
no | true when TLS ends at a proxy in front of this service. |
PORT |
no | Defaults to 5314. |
The issuer comes from the API (oidc_issuer), not from this service's environment. The service
answers only requests for the issuer's own scheme and host, and refuses to start when the API
has the provider disabled or speaks a different version of the /oidc/* contract.
| Path | What |
|---|---|
/.well-known/openid-configuration |
Discovery |
/authorize, /token, /userinfo |
Authorization code with PKCE, and userinfo |
/jwks |
The provider's public keys, read from the API |
/auth/* |
This service's first-party adapter, for the pages |
GET /interaction/:uid/details |
What the current interaction needs, for the pages |
POST /interaction/:uid/login |
Finishes login, or answers step_up_required |
POST /interaction/:uid/consent |
Grants what the client asked for |
POST /interaction/:uid/abort |
Returns access_denied to the client |
/session/end |
RP-initiated logout, confirmed on a hosted page |
/device/auth, /device |
Device authorization grant and its hosted page |
The interaction routes are for the hosted pages only. A state change needs Sec-Fetch-Site: same-origin or an Origin equal to the issuer's, so a page on a sibling subdomain cannot
drive them.
Discovery lists acr_values_supported: ["phr", "phrh"]. phrh (a device-bound passkey with
user verification) satisfies a request for phr. On every authorization from an existing
session this service reads the session's auth context from the API and decides from what the
API reports, never from its own copy:
acr_valuesis met when the session's acr ranks at least the weakest value asked for.max_ageis met when the API'sauth_timeis within it, or the user authenticated during this request.prompt=loginneeds the latter.
When either falls short, the hosted pages ask the user to confirm it is them on the same
session: a passkey, or an authenticator app code when only freshness is missing on a session
with no acr (an app code never earns an acr). The pages then read the context again and copy
acr, amr and auth_time into the tokens unchanged. If the step-up still falls short, for
example a synced passkey when phrh was asked for, the app gets
unmet_authentication_requirements rather than a token with a lower acr.
An app that calls an API of its own asks for a token for it with resource=<identifier>
(RFC 8707), using an identifier the API's registry lists under /admin/resources and links to
the application under /admin/applications/:id/resources. The access token is then an RFC
9068 JWT (typ: at+jwt) whose aud is that identifier, carrying client_id, sub, scope
and jti, with only the scopes the application is allowed there and the resource's own
lifetime. The API checks the same links again before it signs, so an API configured for one
identifier can reject every token minted for another.
- A resource the application is not linked to is refused with
invalid_target. - An app that named one resource at authorization gets its token without repeating
resourceat the token endpoint. - Without
resourcethe access token stays an opaque one for/userinfo, as before. A token for a resource is not accepted at/userinfo. - First-party apps have the scopes granted with the sign-in. Third-party apps list them on the consent page.
An application's own accessTokenTtlSeconds, idTokenTtlSeconds and refreshTokenTtlSeconds
(admin routes) apply to its tokens. Unset ones use the defaults: 600 seconds for access and ID
tokens, 14 days for refresh tokens. An access token for a resource with its own lifetime gets
the shorter of the two. A rotated refresh token for a browser app that cannot bind it to a key
keeps what was left of the one it replaced, whatever the application's lifetime. The grant
behind them lives at least as long as the application's refresh tokens. The API also
ends every record when the first-party session behind it ends.
- Refresh. A client registered for
refresh_tokengets one withoffline_access, which the library honours only on a request withprompt=consent(OpenID Connect Core 11). Refresh tokens rotate on every use. A used one presented again revokes the grant and every token under it, and the API ends the first-party session it was issued under. Two refreshes racing with the same token cannot both rotate: the API's consume says which one marked it used, and the other is handled as that same reuse. A refreshed ID token carries the session's currentacr,amrandauth_time, so a step-up since the sign-in shows up on the next refresh. - Client credentials. A confidential client registered for
client_credentialsgets an RFC 9068 JWT whosesubandclient_idare the client itself, only ever for a resource: the one named withresource, or the client's only linked resource. Scopes are cut to what the client is allowed there. An application with account notices turned on also gets the built-in<issuer>/seamlessresource withnotices:send, which the API lists for it, forPOST /applications/account-noticeson the API.
/token/introspection (RFC 7662) and /token/revocation (RFC 7009) are listed in discovery
and take the same client authentication as /token.
- Introspection. A client may introspect its own tokens. A resource server registers as a
confidential application linked to its resource, and may then introspect tokens whose
audis that resource. Any other caller gets{ "active": false }. Every answer is read from the API's store, so a token whose first-party session has ended is inactive at once. - JWT access tokens. The library keeps no record of a JWT, so this service stores one
under its
jtiwhen it is issued and maps a presented JWT back to it, after checking the signature against the provider's own keys. Thejtialone is never accepted as a token. A resource server that verifies JWTs locally still accepts one until it expires; one that introspects sees revocation immediately. - Revocation. A client revokes its own refresh or access token. Revoking a refresh token
ends the grant and every token under it, not the user's first-party session, so the user's
other apps stay signed in. Each revocation is recorded in the API's audit log
(
token_revoked). - Rate limit. 1200 requests a minute per client IP across both endpoints, per instance.
/session/end (OpenID RP-Initiated Logout 1.0) takes id_token_hint or client_id, and a
post_logout_redirect_uri registered for the client. The hosted page asks before anything
ends:
- Sign out ends the provider session and the first-party session it was made under
(through the API's
/oidc/sessions/:id/revoke, reasonprovider_logout), and clears the adapter cookies. Every other app the user signed in to with it is signed out too. - Only sign out of the asking app ends that app's grant and keeps the session.
Back-channel logout (OpenID Back-Channel Logout 1.0) is sent by the API, not by this service.
The API holds the keys and sees every way a session ends (this logout, admin revoke, sign-out
everywhere, eviction), so a client with a backchannelLogoutUri gets a logout+jwt token
whichever one it was. Discovery lists backchannel_logout_supported and
backchannel_logout_session_supported, and ID tokens for such a client carry sid.
/device/auth (RFC 8628) is for apps that cannot open a browser: TVs, consoles, CLIs. It is
off unless an admin enables the device flow on a first-party application (ADR D10).
- Codes. User codes are 8 characters from the RFC's base-20 set (
BCDF-GHJK), about 34.5 bits, typed case-insensitively and with or without the dash. They live 10 minutes. The device code works once. - Polling. The response carries
interval: 5. A poll sooner than that getsslow_downand five more seconds, tracked per device code on each instance. - Approval.
/deviceis a hosted page. Before any sign-in, the confirmation page names the app, shows the code, and says to continue only on a device you started yourself (RFC 8628 section 5.4). Every approval signs in again, so a browser that is already signed in still confirms with the passkey. Code entry is limited to 10 a minute per address and 600 a minute per instance. - Assurance. Device-flow tokens never carry
acr, after a refresh too, and device requests withacr_valuesormax_ageare refused. The passkey proves who approved, not who holds the device. The API refusesacron any token whose sign request names the device grant.
The configuration is locked to OAuth 2.1 and RFC 9700: the code flow with S256 PKCE for
every client (unless an admin opts a confidential client out, RFC 9700 section 2.1.1, in
which case it must send nonce whenever it asks for an ID token, unless an admin waives
that as well), exact redirect matching (with RFC 8252 loopback ports for native apps), refresh
rotation, iss on authorization responses and audience-restricted access tokens. The full
matrix, with the test behind each row, is in docs/oauth-security.md.
| Client | Authentication |
|---|---|
| Confidential (server-side apps, services) | private_key_jwt, each assertion used once |
| Public (SPA, native, CLI) | none, with PKCE |
| Confidential, opted in to a shared secret | client_secret_basic or client_secret_post |
Shared secrets are for apps that support nothing else. They need allow_client_secrets on
the API (read when this service starts) and a per-client opt-in there. This service never
holds a client's secret: it asks the API to check a presented one, then hands the library a
one-time stand-in for that request. client_secret_jwt and HS* algorithms are never offered.
The login and consent pages are a SPA in ui/, built on @seamless-auth/react and shipped
in dist/ui. They sign users in through this service's own adapter routes (/auth/*), the
same way any Seamless app does, then finish the OIDC interaction.
- Methods. Every method the API has enabled in
login_methods, through the SDK's bundled screens. A recovery session goes to enrollment before it can finish a sign-in. - Consent. First-party clients have consent implied. Third-party clients see the client name, the host they are sent back to and the requested scopes, on every authorization.
- Branding. The app name comes from the API's config each time a page is served, so a config change needs no build and no restart.
- Headers. A strict CSP (
script-src 'self', styles from this origin or with the response's nonce,frame-ancestors 'none'),no-store,nosniffandno-referrer.
Every cookie this service sets is __Host- (host-only, Secure, Path=/), so serve it over
HTTPS, including in development: the library refuses to set a Secure cookie on a plain
HTTP request.
import express from 'express';
import { createOidcEdge } from '@seamless-auth/oidc-provider';
const edge = await createOidcEdge({
apiUrl: process.env.SEAMLESS_API_URL!,
serviceSecret: process.env.SEAMLESS_OIDC_SERVICE_TOKEN!,
cookieKeys: process.env.OIDC_COOKIE_KEYS!.split(','),
adapterCookieSecret: process.env.SEAMLESS_COOKIE_SECRET!,
trustProxy: true,
});
const app = express();
app.set('trust proxy', 1);
app.use(edge.router);
app.listen(5314);Mount edge.router at the root of the app that serves the issuer's origin.
nvm use
npm ci
npm run typecheck && npm run lint && npm run format:check && npm run coverage && npm run buildSee AGENTS.md for the repo's standards and RELEASES.md for releases.
Apache-2.0. See LICENSE.