diff --git a/src/content/docs/build/oidc-authjs.mdx b/src/content/docs/build/oidc-authjs.mdx
new file mode 100644
index 0000000..6548cba
--- /dev/null
+++ b/src/content/docs/build/oidc-authjs.mdx
@@ -0,0 +1,226 @@
+---
+title: 'Sign In With Auth.js And The OpenID Provider'
+description: 'Add the Seamless Auth OpenID Provider to a Next.js app as a custom Auth.js provider, tested end to end with next-auth 5.0.0-beta.32 and Next.js 16.4.5: shared secret and private_key_jwt, a private CA, why Next.js cannot use 127.0.0.1 here, sign-out, and the errors hit while testing.'
+sidebar:
+ order: 13.3
+---
+
+import { Aside } from '@astrojs/starlight/components'
+
+[Auth.js](https://authjs.dev) signs users in through the [OpenID Provider](/build/openid-provider)
+with a [custom OIDC provider](https://authjs.dev/guides/configuring-oauth-providers). Discovery
+supplies the endpoints. In the tested version, an `oidc` provider sent PKCE with no extra setting.
+
+
+
+---
+
+## 1. Give The App A Real Hostname, Over HTTPS
+
+
+
+So even locally, this app needs `https` on a name that is not `localhost`. The test used
+`https://app.guides.test:13443`, with Caddy terminating TLS:
+
+```caddyfile
+{
+ local_certs
+ skip_install_trust
+ admin off
+ auto_https disable_redirects
+}
+
+https://app.guides.test:13443 {
+ reverse_proxy authjs:13001
+}
+```
+
+On your own machine that name needs to resolve to `127.0.0.1`, for example through your hosts file.
+The test instead gave Chromium `--host-resolver-rules=MAP *.guides.test 127.0.0.1`.
+
+---
+
+## 2. Register The Client
+
+`POST /admin/applications`. Prefer `private_key_jwt`, which Auth.js supports:
+
+```json
+{
+ "name": "My Next.js app",
+ "clientType": "confidential",
+ "firstParty": true,
+ "tokenEndpointAuthMethod": "private_key_jwt",
+ "jwks": { "keys": [{ "kty": "RSA", "n": "...", "e": "AQAB", "kid": "my-app-1", "alg": "RS256", "use": "sig" }] },
+ "redirectUris": ["https://app.guides.test:13443/api/auth/callback/seamless"],
+ "postLogoutRedirectUris": ["https://app.guides.test:13443/"],
+ "grantTypes": ["authorization_code", "refresh_token"]
+}
+```
+
+The redirect path is `/api/auth/callback/`. For a shared secret, use
+`client_secret_basic` without `jwks`, after
+[turning on `allow_client_secrets`](/build/openid-provider-connect-an-app#register-the-client).
+
+---
+
+## 3. Configure Auth.js
+
+`auth.ts`, with `private_key_jwt`:
+
+```ts
+import NextAuth from 'next-auth'
+
+const jwk = JSON.parse(process.env.AUTH_SEAMLESS_PRIVATE_JWK ?? '{}')
+const key = await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
+ false,
+ ['sign'],
+)
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers: [
+ {
+ id: 'seamless',
+ name: 'Seamless',
+ type: 'oidc',
+ issuer: process.env.AUTH_SEAMLESS_ISSUER,
+ clientId: process.env.AUTH_SEAMLESS_ID,
+ client: { token_endpoint_auth_method: 'private_key_jwt' },
+ token: { clientPrivateKey: { key, kid: jwk.kid } },
+ authorization: { params: { scope: 'openid email' } },
+ },
+ ],
+ callbacks: {
+ jwt({ token, account, profile }) {
+ if (account && profile) {
+ token.sub = profile.sub ?? token.sub
+ token.acr = profile.acr
+ }
+ return token
+ },
+ session({ session, token }) {
+ session.user.id = token.sub!
+ return session
+ },
+ },
+})
+```
+
+`token.clientPrivateKey` comes from the
+[`@auth/core` source](https://github.com/nextauthjs/next-auth/blob/main/packages/core/src/lib/actions/callback/oauth/callback.ts),
+not from the Auth.js guides, so check it again when you upgrade.
+
+With a shared secret, drop the key import, `client` and `token`, and add
+`clientSecret: process.env.AUTH_SEAMLESS_SECRET`.
+
+`app/api/auth/[...nextauth]/route.ts`:
+
+```ts
+import { handlers } from '@/auth'
+export const { GET, POST } = handlers
+```
+
+Read the session and sign in from a server component:
+
+```tsx
+import { auth, signIn, signOut } from '@/auth'
+
+export default async function Page() {
+ const session = await auth()
+ if (!session?.user) {
+ return (
+
+ )
+ }
+ return
Signed in as {session.user.email}
+}
+```
+
+The provider issues no `name` or `picture` claim, so `session.user.name` and `image` stay empty.
+
+---
+
+## 4. Run It
+
+| Variable | Value |
+| --------------------------- | --------------------------------------------------------------------------- |
+| `AUTH_URL` | `https://app.guides.test:13443`, the app's public origin |
+| `AUTH_SECRET` | A random secret for Auth.js's own cookies |
+| `AUTH_SEAMLESS_ISSUER` | The issuer, for example `https://auth.guides.localhost:19443` |
+| `AUTH_SEAMLESS_ID` | `application.id` |
+| `AUTH_SEAMLESS_PRIVATE_JWK` | The private JWK as JSON, including its `kid`. Or `AUTH_SEAMLESS_SECRET`. |
+| `NODE_EXTRA_CA_CERTS` | Private CA only: the path to the CA file |
+
+See [Auth.js environment variables](https://authjs.dev/getting-started/deployment#environment-variables)
+and [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile). The test ran
+`next build` and `next start -H 0.0.0.0 -p 13001` in a `node:24-alpine` container on the recipe's
+compose network.
+
+---
+
+## 5. Check It
+
+1. Open the app and choose **Sign in with Seamless**.
+2. Sign in on the hosted page. A first-party client shows no consent page.
+3. The app shows `Signed in as `. In the test, `session.user.id` held the `sub`, and `acr` was
+ empty after the first email-code sign-up and `phrh` after a passkey sign-in on the virtual
+ authenticator.
+
+---
+
+## Sign-Out
+
+`signOut()` ends only the Auth.js session. In the test, the next **Sign in with Seamless** went straight
+back to the app without showing the hosted page, because the user's Seamless session was still live.
+If your app needs to end that session, send the browser to the provider's
+[sign-out endpoint](/build/openid-provider#sign-out) as well. That part was not run with Auth.js. It
+was run in the [generic guide](/build/openid-provider-connect-an-app#sign-out).
+
+---
+
+## Troubleshooting
+
+Each of these was hit while testing. Auth.js's detail is in the server log, under `[auth][cause]` and
+`[auth][details]`.
+
+| What you see | Log line | Cause and fix |
+| ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `Server error`, at `http://localhost:/api/auth/error?error=Configuration`, after the hosted page | `CallbackRouteError` with `"error": "invalid_grant"`, `"grant request is invalid"` | The app runs on `http://127.0.0.1`. See [step 1](#1-give-the-app-a-real-hostname-over-https). `/api/auth/providers` shows the callback URL it uses. |
+| Registering returns `localhost is not a redirect host`, for `app.guides.localhost` | None, it is the registration response | `*.localhost` is refused even over `https`. Use another name, such as one under `.test`. |
+| `Server error`, `error=Configuration`, on choosing sign-in | `[auth][error] TypeError: fetch failed` | The app does not trust the issuer's CA. Set `NODE_EXTRA_CA_CERTS`. |
+| An error page on `/authorize`, `missing required parameter 'client_id'` | None | `AUTH_SEAMLESS_ID` is empty. |
+
+Registration and shared-secret errors are in the
+[generic guide](/build/openid-provider-connect-an-app#troubleshooting).
+
+---
+
+## What Was And Was Not Tested
+
+**Tested.** The configuration above, built and started in production mode, once with `private_key_jwt`
+and once with `client_secret_basic`, with Playwright's Chromium and a virtual WebAuthn authenticator: a
+new user registering and a passkey sign-in from a clean browser, ending on a page showing the user. The
+stack was the local recipe from
+[seamless-auth-oidc-provider](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local), adjusted as described in the
+[generic guide](/build/openid-provider-connect-an-app#what-was-and-was-not-tested).
+
+**Not tested.** A browser that trusts the CA normally, a real authenticator, a publicly trusted
+certificate, a production deployment, `next dev`, refresh tokens or access token rotation in Auth.js,
+database sessions, Auth.js middleware, `next-auth` 4, or other Auth.js integrations.
diff --git a/src/content/docs/build/oidc-grafana.mdx b/src/content/docs/build/oidc-grafana.mdx
new file mode 100644
index 0000000..234e9a4
--- /dev/null
+++ b/src/content/docs/build/oidc-grafana.mdx
@@ -0,0 +1,156 @@
+---
+title: 'Sign In To Grafana With The OpenID Provider'
+description: 'Configure Grafana generic OAuth against the Seamless Auth OpenID Provider, tested end to end with Grafana 13.3.0: client registration, PKCE, role mapping from the roles claim, ID token validation, sign-out, a private CA, and the errors hit while testing.'
+sidebar:
+ order: 13.2
+---
+
+import { Aside } from '@astrojs/starlight/components'
+
+Grafana signs users in through the [OpenID Provider](/build/openid-provider) with its
+[generic OAuth](https://grafana.com/docs/grafana/latest/setup-grafana/configure-access/configure-authentication/generic-oauth/)
+integration. Users choose **Sign in with Seamless** on Grafana's login page, sign in on the hosted
+page, and arrive in Grafana with a role mapped from their Seamless roles.
+
+
+
+---
+
+## 1. Register The Client
+
+Grafana's documentation lists `client_secret` as required, so this guide uses a shared secret, which
+needs `allow_client_secrets` on the instance and an edge restart. See
+[Register the client](/build/openid-provider-connect-an-app#register-the-client).
+
+`POST /admin/applications`:
+
+```json
+{
+ "name": "Grafana",
+ "clientType": "confidential",
+ "firstParty": true,
+ "tokenEndpointAuthMethod": "client_secret_basic",
+ "redirectUris": ["http://127.0.0.1:13000/login/generic_oauth"],
+ "postLogoutRedirectUris": ["http://127.0.0.1:13000/login"],
+ "grantTypes": ["authorization_code", "refresh_token"]
+}
+```
+
+The redirect is Grafana's root URL plus `/login/generic_oauth`. Keep `application.id` and
+`clientSecret` from the response.
+
+---
+
+## 2. Run Grafana
+
+This is the command that was run. `--network` puts Grafana on the local recipe's compose network so
+it reaches the issuer by name, and the CA mount is only for a private CA.
+
+```bash
+export ISSUER=https://auth.guides.localhost:19443
+export CLIENT_ID=...
+export CLIENT_SECRET=...
+
+docker run -d --name grafana \
+ --network provider-guides_default \
+ -p 127.0.0.1:13000:3000 \
+ -v "$PWD/local/.generated/ca.crt:/etc/grafana/seamless-ca.crt:ro" \
+ -e GF_SERVER_ROOT_URL=http://127.0.0.1:13000 \
+ -e GF_AUTH_GENERIC_OAUTH_ENABLED=true \
+ -e GF_AUTH_GENERIC_OAUTH_NAME=Seamless \
+ -e GF_AUTH_GENERIC_OAUTH_CLIENT_ID="$CLIENT_ID" \
+ -e GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET="$CLIENT_SECRET" \
+ -e GF_AUTH_GENERIC_OAUTH_AUTH_STYLE=InHeader \
+ -e GF_AUTH_GENERIC_OAUTH_USE_PKCE=true \
+ -e 'GF_AUTH_GENERIC_OAUTH_SCOPES=openid email seamless' \
+ -e GF_AUTH_GENERIC_OAUTH_AUTH_URL="$ISSUER/authorize" \
+ -e GF_AUTH_GENERIC_OAUTH_TOKEN_URL="$ISSUER/token" \
+ -e GF_AUTH_GENERIC_OAUTH_API_URL="$ISSUER/userinfo" \
+ -e GF_AUTH_GENERIC_OAUTH_VALIDATE_ID_TOKEN=true \
+ -e GF_AUTH_GENERIC_OAUTH_JWK_SET_URL="$ISSUER/jwks" \
+ -e GF_AUTH_GENERIC_OAUTH_LOGIN_ATTRIBUTE_PATH=email \
+ -e GF_AUTH_GENERIC_OAUTH_NAME_ATTRIBUTE_PATH=email \
+ -e "GF_AUTH_GENERIC_OAUTH_ROLE_ATTRIBUTE_PATH=contains(roles[*], 'user') && 'Editor' || 'Viewer'" \
+ -e "GF_AUTH_SIGNOUT_REDIRECT_URL=$ISSUER/session/end?client_id=$CLIENT_ID&post_logout_redirect_uri=http%3A%2F%2F127.0.0.1%3A13000%2Flogin" \
+ -e GF_AUTH_GENERIC_OAUTH_TLS_CLIENT_CA=/etc/grafana/seamless-ca.crt \
+ -e SSL_CERT_FILE=/etc/grafana/seamless-ca.crt \
+ grafana/grafana:13.3.0
+```
+
+Grafana maps `GF__` environment variables to its
+[configuration file](https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/).
+
+### What each setting does
+
+| Setting | Value | Why |
+| ------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
+| `use_pkce` | `true` | The default is `false`, and the provider refuses a request without PKCE. |
+| `auth_style` | `InHeader` | Sends the secret the way `client_secret_basic` expects. |
+| `scopes` | `openid email seamless` | `seamless` adds the `roles` claim. Without it, leave out `role_attribute_path`. |
+| `validate_id_token`, `jwk_set_url` | `true`, `/jwks` | Off by default in Grafana. Verifies the ID token's signature. |
+| `login_attribute_path`, `name_attribute_path` | `email` | The provider issues no `name` or `preferred_username` claim. |
+| `role_attribute_path` | A JMESPath over `roles` | The tested mapping gave a user with the default `user` role `Editor`. Write yours for your own roles. |
+| `[auth] signout_redirect_url` | `/session/end` URL | Passes `client_id`, which the provider accepts in place of `id_token_hint`. Encode the inner URL. |
+| `tls_client_ca`, `SSL_CERT_FILE` | The CA path | Private CA only. See below. |
+
+### For your deployment
+
+Replace the issuer, `root_url` and the redirect URI with your own `https` URLs, drop `--network`, and
+with a publicly trusted certificate drop the CA mount, `tls_client_ca` and `SSL_CERT_FILE`. These
+`https` values were not run.
+
+### A private CA
+
+Two settings, because Grafana 13.3.0 does not use `tls_client_ca` for everything:
+
+- `tls_client_ca` covered the token and UserInfo calls.
+- `validate_id_token` fetches the JWKS without it, so with only `tls_client_ca` sign-in fails. See
+ [Troubleshooting](#troubleshooting). `SSL_CERT_FILE`, which Go reads, fixes that.
+
+`SSL_CERT_FILE` alone also worked for the whole sign-in.
+
+---
+
+## 3. Check It
+
+1. Open `http://127.0.0.1:13000/login` and choose **Sign in with Seamless**.
+2. Sign in on the hosted page. A first-party client shows no consent page.
+3. Open `/profile`. Name, email and username show the user's email and say **Synced via Generic
+ OAuth**, and **Main Org.** shows the mapped role.
+4. Sign out. The hosted page asks whether to sign out of everything or only Grafana. After **Sign
+ out**, the browser returns to Grafana's login page.
+
+---
+
+## Troubleshooting
+
+Each of these was hit while testing. Grafana's detail is in its log.
+
+| What you see | Log line | Cause and fix |
+| ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
+| `Login failed`, `Login provider denied login request` | None useful. The provider answers `invalid_request`, `Authorization Server policy requires PKCE`. | Set `use_pkce=true`. |
+| `Login failed`, `Failed to get token from provider`, after the hosted page | `failed to exchange code to token ... x509: certificate signed by unknown authority` | Grafana does not trust the issuer's CA. Set `tls_client_ca` or `SSL_CERT_FILE`. |
+| Back on the login page after the hosted page, not signed in | `Error retrieving JWKS from URL ... x509: certificate signed by unknown authority`, then `signing key not found for kid` | `validate_id_token` fetched the JWKS without `tls_client_ca`. Set `SSL_CERT_FILE`. |
+| Signed in normally | `Error retrieving access token payload ... token is not in JWT format`, a warning on every sign-in | Harmless. Without `resource`, the access token is opaque by design, and Grafana reads the ID token and UserInfo instead. |
+
+Registration and shared-secret errors are in the
+[generic guide](/build/openid-provider-connect-an-app#troubleshooting).
+
+---
+
+## What Was And Was Not Tested
+
+**Tested.** The command above, with Playwright's Chromium and a virtual WebAuthn authenticator: a new
+user registering, a passkey sign-in from a clean browser, the profile page, the mapped role through
+Grafana's API, and sign-out. The stack was the local recipe from
+[seamless-auth-oidc-provider](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local), adjusted as described in the
+[generic guide](/build/openid-provider-connect-an-app#what-was-and-was-not-tested).
+
+**Not tested.** A browser that trusts the CA normally, a real authenticator, Grafana over `https`, a
+production deployment, `use_refresh_token` and what Grafana does when the provider's token expires,
+`allow_assign_grafana_admin`, or mapping any role other than the default `user`.
diff --git a/src/content/docs/build/oidc-oauth2-proxy.mdx b/src/content/docs/build/oidc-oauth2-proxy.mdx
new file mode 100644
index 0000000..217278f
--- /dev/null
+++ b/src/content/docs/build/oidc-oauth2-proxy.mdx
@@ -0,0 +1,187 @@
+---
+title: 'Protect An App With oauth2-proxy And The OpenID Provider'
+description: 'Put oauth2-proxy in front of an app with no OIDC support so users sign in through the Seamless Auth OpenID Provider, tested end to end with oauth2-proxy 7.15.5: why oauth2-proxy, PKCE, a private CA, restricting by role, the headers the app receives, sign-out, and the errors hit while testing.'
+sidebar:
+ order: 13.5
+---
+
+import { Aside } from '@astrojs/starlight/components'
+
+[oauth2-proxy](https://oauth2-proxy.github.io/oauth2-proxy/) is a reverse proxy that signs users in
+with OIDC before passing requests on. Use it for an app that has no OIDC support of its own, such as an
+internal tool: the app needs no code change, and receives the user in request headers.
+
+**Why oauth2-proxy.** The guide needed a proxy that works with any HTTP app, on its own or alongside
+whichever web server you already run. oauth2-proxy has a generic
+[OIDC provider](https://oauth2-proxy.github.io/oauth2-proxy/configuration/providers/openid_connect), and
+[documented options](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/) for the two
+things this provider asks of a client: `S256` PKCE (`code_challenge_method`) and a private CA
+(`provider_ca_files`). Other proxies were not compared by running them.
+
+
+
+---
+
+## 1. Register The Client
+
+The `--help` of oauth2-proxy 7.15.5 shows client secret options and none for `private_key_jwt`, so this
+needs
+[`allow_client_secrets`](/build/openid-provider-connect-an-app#register-the-client) and an edge restart.
+
+`POST /admin/applications`:
+
+```json
+{
+ "name": "oauth2-proxy",
+ "clientType": "confidential",
+ "firstParty": true,
+ "tokenEndpointAuthMethod": "client_secret_basic",
+ "redirectUris": ["http://127.0.0.1:13003/oauth2/callback"],
+ "postLogoutRedirectUris": ["http://127.0.0.1:13003/"],
+ "grantTypes": ["authorization_code", "refresh_token"]
+}
+```
+
+---
+
+## 2. Run It
+
+This is the command that was run. `--network` puts it on the local recipe's compose network, where
+`my-upstream` stands for your app. The CA mount is only for a private CA.
+
+```bash
+export ISSUER=https://auth.guides.localhost:19443
+export CLIENT_ID=...
+export CLIENT_SECRET=...
+
+docker run -d --name oauth2-proxy \
+ --network provider-guides_default \
+ -p 127.0.0.1:13003:4180 \
+ -v "$PWD/local/.generated/ca.crt:/etc/oauth2-proxy/seamless-ca.crt:ro" \
+ -e OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180 \
+ -e OAUTH2_PROXY_PROVIDER=oidc \
+ -e OAUTH2_PROXY_PROVIDER_DISPLAY_NAME=Seamless \
+ -e OAUTH2_PROXY_OIDC_ISSUER_URL="$ISSUER" \
+ -e OAUTH2_PROXY_CLIENT_ID="$CLIENT_ID" \
+ -e OAUTH2_PROXY_CLIENT_SECRET="$CLIENT_SECRET" \
+ -e OAUTH2_PROXY_REDIRECT_URL=http://127.0.0.1:13003/oauth2/callback \
+ -e OAUTH2_PROXY_UPSTREAMS=http://my-upstream:8080/ \
+ -e OAUTH2_PROXY_CODE_CHALLENGE_METHOD=S256 \
+ -e 'OAUTH2_PROXY_SCOPE=openid email seamless' \
+ -e OAUTH2_PROXY_OIDC_GROUPS_CLAIM=roles \
+ -e OAUTH2_PROXY_ALLOWED_GROUPS=user \
+ -e 'OAUTH2_PROXY_EMAIL_DOMAINS=*' \
+ -e OAUTH2_PROXY_INSECURE_OIDC_SKIP_NONCE=false \
+ -e OAUTH2_PROXY_COOKIE_SECRET="$(openssl rand -hex 16)" \
+ -e OAUTH2_PROXY_COOKIE_SECURE=false \
+ -e OAUTH2_PROXY_SKIP_PROVIDER_BUTTON=true \
+ -e OAUTH2_PROXY_WHITELIST_DOMAINS=auth.guides.localhost:19443 \
+ -e OAUTH2_PROXY_PROVIDER_CA_FILES=/etc/oauth2-proxy/seamless-ca.crt \
+ quay.io/oauth2-proxy/oauth2-proxy:v7.15.5
+```
+
+### What each setting does
+
+| Setting | Value | Why |
+| -------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
+| `CODE_CHALLENGE_METHOD` | `S256` | Off by default, and the provider refuses a request without PKCE. |
+| `SCOPE` | `openid email seamless` | `seamless` adds the `roles` claim. oauth2-proxy's default scopes also worked, because the provider ignores `profile`, but give no roles. |
+| `OIDC_GROUPS_CLAIM`, `ALLOWED_GROUPS` | `roles`, your role | The provider has no `groups` claim, so read `roles` as groups. A user without the role gets `403` after signing in. |
+| `EMAIL_DOMAINS` | `*`, or your domain | `*` lets any signed-in user through this check, so restrict by role or domain. |
+| `INSECURE_OIDC_SKIP_NONCE` | `false` | Its default is `true`. The provider returned the nonce, so verification passes. |
+| `COOKIE_SECRET` | 32 random characters | Encrypts oauth2-proxy's session cookie. |
+| `COOKIE_SECURE` | `false` on loopback only | Needed for plain `http` here. **Remove it in a deployment**: the default is `true`. |
+| `WHITELIST_DOMAINS` | The issuer's host and port | Allows the sign-out redirect to the issuer. |
+| `PROVIDER_CA_FILES` | The CA path | Private CA only. |
+
+The environment names follow oauth2-proxy's
+[flag to environment variable rule](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/).
+
+### For your deployment
+
+Replace the issuer, redirect URL and whitelist with your own `https` values, remove
+`OAUTH2_PROXY_COOKIE_SECURE`, and with a publicly trusted certificate remove the CA mount and
+`PROVIDER_CA_FILES`. Those values were not run.
+
+**Make the app reachable only through the proxy.** It trusts the headers below, so anyone who can reach
+it directly can send their own.
+
+---
+
+## 3. What The App Receives
+
+The test's upstream printed these headers:
+
+| Header | Value |
+| -------------------- | --------------------------------------------------------------------------- |
+| `X-Forwarded-User` | The `sub`, the Seamless user ID. There is no username claim to use instead. |
+| `X-Forwarded-Email` | The `email` claim |
+| `X-Forwarded-Groups` | The `roles` claim, with the settings above. Absent otherwise. |
+
+---
+
+## 4. Check It
+
+1. Open `http://127.0.0.1:13003/`. A request with no session gets a `302`, and the browser goes to the
+ hosted page.
+2. Sign in. A first-party client shows no consent page.
+3. The app's response shows the headers above.
+
+---
+
+## 5. Sign Out
+
+oauth2-proxy's `/oauth2/sign_out` clears its own cookie and follows `rd`. To end the Seamless session as
+well, set `rd` to the provider's end session URL. Each level is URL encoded:
+
+```text
+/oauth2/sign_out?rd=/session/end?client_id=&post_logout_redirect_uri=>
+```
+
+In Python:
+
+```python
+import urllib.parse as u
+inner = u.quote('http://127.0.0.1:13003/', safe='')
+print('/oauth2/sign_out?rd=' + u.quote(f'{issuer}/session/end?client_id={client_id}&post_logout_redirect_uri={inner}', safe=''))
+```
+
+The hosted page asks whether to sign out of everything or only this app. After **Sign out**, the browser
+returns to the app, whose request has no session, so it lands on the hosted sign-in page.
+
+---
+
+## Troubleshooting
+
+Each of these was hit while testing.
+
+| What you see | Cause and fix |
+| ----------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
+| The container exits at startup: `error while discovery OIDC configuration ... tls: failed to verify certificate` | It does not trust the issuer's CA. Set `PROVIDER_CA_FILES`. |
+| At startup, `Warning: Your provider supports PKCE methods ["S256"], but you have not enabled one with --code-challenge-method`, then an error page on sign-in | Set `CODE_CHALLENGE_METHOD=S256`. |
+| `403 Forbidden` on `/oauth2/callback`, after the hosted page | The user's roles do not include `ALLOWED_GROUPS`. This is the check working. |
+| `X-Forwarded-Groups` is missing | `OIDC_GROUPS_CLAIM=roles` and the `seamless` scope are both needed. |
+
+Registration and shared-secret errors are in the
+[generic guide](/build/openid-provider-connect-an-app#troubleshooting).
+
+---
+
+## What Was And Was Not Tested
+
+**Tested.** The command above, in front of a small upstream that printed its request headers, with
+Playwright's Chromium and a virtual WebAuthn authenticator: a new user registering, a passkey sign-in
+from a clean browser, the headers reaching the upstream, sign-out, and a `403` for a user lacking the
+allowed role. The stack was the local recipe from
+[seamless-auth-oidc-provider](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local), adjusted as described in the
+[generic guide](/build/openid-provider-connect-an-app#what-was-and-was-not-tested).
+
+**Not tested.** A browser that trusts the CA normally, a real authenticator, `https` or
+`COOKIE_SECURE=true`, a production deployment, oauth2-proxy behind nginx, Traefik or Caddy
+(`auth_request` or forward-auth mode), session refresh (`--cookie-refresh`), Redis session storage, or
+any other reverse proxy.
diff --git a/src/content/docs/build/oidc-spring-security.mdx b/src/content/docs/build/oidc-spring-security.mdx
new file mode 100644
index 0000000..3ded625
--- /dev/null
+++ b/src/content/docs/build/oidc-spring-security.mdx
@@ -0,0 +1,227 @@
+---
+title: 'Sign In With Spring Security And The OpenID Provider'
+description: 'Configure Spring Boot and Spring Security oauth2Login against the Seamless Auth OpenID Provider, tested end to end with Spring Boot 4.1.2 and Spring Security 7.1.2: a shared secret or private_key_jwt, a Java truststore for a private CA, RP-initiated logout, and the errors hit while testing.'
+sidebar:
+ order: 13.4
+---
+
+import { Aside } from '@astrojs/starlight/components'
+
+A Spring Boot app signs users in through the [OpenID Provider](/build/openid-provider) with
+[`oauth2Login`](https://docs.spring.io/spring-boot/reference/web/spring-security.html). Spring reads
+discovery from `issuer-uri`.
+
+
+
+---
+
+## 1. Register The Client
+
+`POST /admin/applications`:
+
+```json
+{
+ "name": "Spring app",
+ "clientType": "confidential",
+ "firstParty": true,
+ "tokenEndpointAuthMethod": "client_secret_basic",
+ "redirectUris": ["http://127.0.0.1:13002/login/oauth2/code/seamless"],
+ "postLogoutRedirectUris": ["http://127.0.0.1:13002/"],
+ "grantTypes": ["authorization_code", "refresh_token"]
+}
+```
+
+Spring's default redirect is `{baseUrl}/login/oauth2/code/{registrationId}`. A shared secret needs
+[`allow_client_secrets`](/build/openid-provider-connect-an-app#register-the-client). To avoid one, see
+[Use `private_key_jwt`](#use-private_key_jwt).
+
+---
+
+## 2. Add The Dependencies
+
+```xml
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.2
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-webmvc
+
+
+ org.springframework.boot
+ spring-boot-starter-security-oauth2-client
+
+
+```
+
+---
+
+## 3. Configure It
+
+`application.yaml`:
+
+```yaml
+server:
+ port: 13002
+spring:
+ security:
+ oauth2:
+ client:
+ registration:
+ seamless:
+ client-id: ${SEAMLESS_CLIENT_ID}
+ client-secret: ${SEAMLESS_CLIENT_SECRET}
+ client-authentication-method: client_secret_basic
+ authorization-grant-type: authorization_code
+ scope: openid,email
+ provider:
+ seamless:
+ issuer-uri: ${SEAMLESS_ISSUER}
+```
+
+**PKCE needs no setting in this version.** With the configuration above, Spring Security 7.1.2 sent
+PKCE from a confidential client, which the provider requires. The Spring documentation covers this under
+[`requireProofKey`](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/authorization-grants.html).
+Do not set it to `false`. Older versions were not tested. If yours does not send PKCE, the provider
+answers `Authorization Server policy requires PKCE`.
+
+Sign-out, so that it also ends the Seamless session, uses Spring's
+[RP-initiated logout handler](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/logout.html):
+
+```java
+@Configuration
+public class SecurityConfig {
+ @Bean
+ SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository registrations)
+ throws Exception {
+ var logout = new OidcClientInitiatedLogoutSuccessHandler(registrations);
+ logout.setPostLogoutRedirectUri("{baseUrl}/");
+ return http
+ .authorizeHttpRequests((a) -> a.anyRequest().authenticated())
+ .oauth2Login(Customizer.withDefaults())
+ .logout((l) -> l.logoutSuccessHandler(logout))
+ .build();
+ }
+}
+```
+
+Read the user from `OidcUser`:
+
+```java
+@GetMapping("/")
+String home(@AuthenticationPrincipal OidcUser user) {
+ return "Signed in as " + user.getEmail() + ", subject " + user.getSubject() + ", acr "
+ + user.getClaimAsString("acr");
+}
+```
+
+The provider issues no `name` or `preferred_username` claim, so identify users by `getSubject()` or
+`getEmail()`. Add the `seamless` scope for the `roles`
+claim. Mapping `roles` to Spring authorities was not tested.
+
+### Use `private_key_jwt`
+
+Register the client with `"tokenEndpointAuthMethod": "private_key_jwt"` and your public `jwks`, as in
+the [generic guide](/build/openid-provider-connect-an-app#register-the-client). Then set the method and
+leave the secret empty:
+
+```yaml
+spring:
+ security:
+ oauth2:
+ client:
+ registration:
+ seamless:
+ client-secret: ''
+ client-authentication-method: private_key_jwt
+```
+
+Add a token client that signs the assertion, following
+[JWT Bearer client authentication](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/client-authentication.html).
+`oauth2Login` picks this bean up:
+
+```java
+@Bean
+OAuth2AccessTokenResponseClient tokenClient()
+ throws Exception {
+ JWK key = JWK.parse(Files.readString(Path.of(System.getenv("SEAMLESS_PRIVATE_JWK_FILE"))));
+ var client = new RestClientAuthorizationCodeTokenResponseClient();
+ client.addParametersConverter(
+ new NimbusJwtClientAuthenticationParametersConverter<>((registration) -> key));
+ return client;
+}
+```
+
+The file holds the private JWK with its `kid`, which must match the registered key.
+
+---
+
+## 4. Trust A Private CA
+
+A publicly trusted certificate needs none of this. For a private CA, copy the JVM's trust store, import
+your CA, and point the JVM at the copy, so the public CAs stay trusted:
+
+```bash
+cp "$JAVA_HOME/lib/security/cacerts" /tmp/truststore.jks
+keytool -importcert -noprompt -alias seamless-local -file /ca.crt \
+ -keystore /tmp/truststore.jks -storepass changeit
+export JAVA_TOOL_OPTIONS="-Djavax.net.ssl.trustStore=/tmp/truststore.jks -Djavax.net.ssl.trustStorePassword=changeit"
+java -jar target/demo-0.0.1.jar
+```
+
+This covered discovery, the token call and the JWKS. `changeit` is the JDK's default store password.
+Use your own for anything longer lived than a test.
+
+The test ran this in a `maven:3.9-eclipse-temurin-21` container on the recipe's compose network, with
+`SEAMLESS_ISSUER`, `SEAMLESS_CLIENT_ID` and `SEAMLESS_CLIENT_SECRET` set.
+
+---
+
+## 5. Check It
+
+1. Open `http://127.0.0.1:13002/`. Every route needs a user, so the browser goes to the hosted page
+ without an intermediate login page.
+2. Sign in. A first-party client shows no consent page.
+3. The page shows the user's email and `sub`. `acr` is `null` after a first email-code sign-up and was
+ `phrh` after a passkey sign-in on the virtual authenticator.
+4. Open `/logout` and confirm. The hosted page asks whether to sign out of everything or only this app.
+ After **Sign out** the browser returns to `/`, which needs a user again, so it lands on the hosted
+ sign-in page.
+
+---
+
+## Troubleshooting
+
+Each of these was hit while testing.
+
+| What you see | Cause and fix |
+| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| The app exits at startup: `Unable to resolve Configuration with the provided Issuer of "https://..."` | Spring fetches discovery at startup. In the test the cause was the JVM not trusting the CA. See [step 4](#4-trust-a-private-ca). |
+
+Registration and shared-secret errors are in the
+[generic guide](/build/openid-provider-connect-an-app#troubleshooting).
+
+---
+
+## What Was And Was Not Tested
+
+**Tested.** The configuration above, with both `client_secret_basic` and `private_key_jwt`, with
+Playwright's Chromium and a virtual WebAuthn authenticator: a new user registering and a passkey sign-in
+from a clean browser, ending on a page showing the user. Sign-out was run with `client_secret_basic`.
+The stack was the local recipe from
+[seamless-auth-oidc-provider](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local), adjusted as described in the
+[generic guide](/build/openid-provider-connect-an-app#what-was-and-was-not-tested).
+
+**Not tested.** A browser that trusts the CA normally, a real authenticator, a publicly trusted
+certificate, a production deployment, `https` or running behind a proxy (`server.forward-headers-strategy`),
+WebFlux, mapping `roles` to authorities, refresh tokens, older Spring versions, or Spring as a resource
+server.
diff --git a/src/content/docs/build/openid-provider-connect-an-app.mdx b/src/content/docs/build/openid-provider-connect-an-app.mdx
new file mode 100644
index 0000000..f66225f
--- /dev/null
+++ b/src/content/docs/build/openid-provider-connect-an-app.mdx
@@ -0,0 +1,278 @@
+---
+title: 'Connect Any OIDC App To The OpenID Provider'
+description: 'The generic steps to sign users in to any OpenID Connect app through the Seamless Auth OpenID Provider: what the app must support, registering a client, the values to give it, the claims it receives, private CAs, sign-out, what the provider does not offer, and the errors you are likely to see.'
+sidebar:
+ order: 13.1
+---
+
+import { Aside } from '@astrojs/starlight/components'
+
+Use this page when an app you did not write, or one you are writing without a Seamless SDK, should
+sign users in through the [OpenID Provider](/build/openid-provider). It is the checklist the
+app-specific guides follow. [Use Seamless Auth As An OpenID Provider](/build/openid-provider) is the
+reference for deploying the edge and for every setting.
+
+App-specific guides: [Grafana](/build/oidc-grafana), [Auth.js](/build/oidc-authjs),
+[Spring Security](/build/oidc-spring-security), and [oauth2-proxy](/build/oidc-oauth2-proxy) for an
+app with no OIDC support of its own.
+
+
+
+---
+
+## What The App Must Support
+
+| Requirement | Why |
+| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
+| Authorization code flow with PKCE (`S256`) | The only sign-in flow. Without PKCE the app gets `invalid_request`, `Authorization Server policy requires PKCE`. |
+| Discovery, or fields for each endpoint | The issuer's `/.well-known/openid-configuration` lists them. |
+| `private_key_jwt`, or a shared secret | Shared secrets need an opt-in. A public client uses PKCE alone: see [native apps](/build/native-apps-oidc). |
+| A way to trust your CA, if yours is private | See [Trust a private CA](#trust-a-private-ca). |
+| RS256 ID tokens | `id_token_signing_alg_values_supported` lists only `RS256`. |
+| Identifying users by `sub` or `email` | The provider issues no `name`, `preferred_username` or `groups` claim. See [Claims the app receives](#claims-the-app-receives). |
+
+An app that cannot send PKCE can be opted out with `pkceRequired: false`, but that setting exists for
+OpenID certification test clients and [is not recommended](/build/openid-provider#pkce-and-nonce-opt-outs).
+No guide here uses it.
+
+---
+
+## Register The Client
+
+Applications are created under `/admin/applications`, which needs `admin:write` and a fresh step-up.
+The tests here used that route directly. The [CLI and dashboard](/build/openid-provider#register-applications-and-apis)
+manage the same records, but were not part of these runs.
+
+Prefer `private_key_jwt` when the app supports it, so the provider never holds a secret of yours:
+
+```json
+{
+ "name": "My app",
+ "clientType": "confidential",
+ "firstParty": true,
+ "tokenEndpointAuthMethod": "private_key_jwt",
+ "jwks": {
+ "keys": [{ "kty": "RSA", "n": "...", "e": "AQAB", "kid": "my-app-1", "alg": "RS256", "use": "sig" }]
+ },
+ "redirectUris": ["https://app.example.com/callback"],
+ "postLogoutRedirectUris": ["https://app.example.com/"],
+ "grantTypes": ["authorization_code", "refresh_token"]
+}
+```
+
+The response's `application.id` is the `client_id`.
+
+For an app that only supports a shared secret, turn the instance switch on, restart the edge, and
+then register with `client_secret_basic` or `client_secret_post` instead of `jwks`:
+
+```http
+PATCH /system-config/admin
+Content-Type: application/json
+
+{ "allow_client_secrets": true }
+```
+
+The response then also has `clientSecret`, shown only once. The edge reads this switch when it starts,
+so both steps are required: see [Troubleshooting](#troubleshooting).
+
+### Redirect URI rules
+
+- `https` URIs match exactly.
+- Plain `http` is accepted only on `127.0.0.1` or `[::1]`. The port is ignored there (RFC 8252), so a
+ client registered on port 13000 was also accepted on port 13001. Scheme, host and path must match.
+- `localhost` and every `*.localhost` name are refused as redirect hosts, even over `https`:
+ `localhost is not a redirect host, use 127.0.0.1 or [::1] (RFC 8252 section 8.3)`. The issuer may
+ use `*.localhost` in a local stack, but the app's redirect cannot.
+
+---
+
+## The Values To Give The App
+
+| App setting | Value |
+| ----------------------------- | -------------------------------------------------------------- |
+| Issuer, or discovery URL | `https://login.example.com`, with no trailing slash |
+| Authorization endpoint | `/authorize` |
+| Token endpoint | `/token` |
+| UserInfo endpoint | `/userinfo` |
+| JWKS URI | `/jwks` |
+| End session endpoint | `/session/end` |
+| Client ID | `application.id` from registration |
+| Scopes | `openid email`, plus `seamless` for roles |
+| PKCE | On, `S256` |
+
+Use discovery when the app supports it.
+
+---
+
+## Claims The App Receives
+
+Claims in an ID token for a user registered on the test stack, with `openid email seamless
+offline_access`:
+
+```text
+sub, acr, amr, auth_time, email, email_verified, roles, nonce, aud, exp, iat, iss
+```
+
+- **There is no profile data.** Discovery does not list a `profile` scope, and the edge drops `profile`
+ and any other unknown scope rather than refusing the request. Asking for `profile` is harmless, and
+ several apps do by default, but no `name`, `preferred_username` or `picture` comes back. Map the
+ app's display name and username to `email`.
+- **Roles are in `roles`, not `groups`.** They need the `seamless` scope. Point the app's group or role
+ mapping at `roles`.
+- **`acr` is absent for an email code.** A first sign-in that registered with an email code had
+ `amr: ["otp"]` and no `acr`. A later passkey sign-in on the virtual authenticator had `acr: "phrh"`
+ and `amr: ["hwk", "user", "mfa"]`. Do not require `acr` unless your users have passkeys.
+- `org_id` was not present for these test users, who had no organization.
+
+---
+
+## A Minimal App
+
+This is the sign-in the tests ran with `openid-client`, reduced to the provider-relevant calls.
+`oidc.PrivateKeyJwt` takes the imported private key and its `kid`:
+
+```ts
+import * as oidc from 'openid-client'
+
+const jwk = JSON.parse(readFileSync(process.env.PRIVATE_JWK_FILE, 'utf8'))
+const key = await crypto.subtle.importKey(
+ 'jwk',
+ jwk,
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
+ false,
+ ['sign'],
+)
+const config = await oidc.discovery(
+ new URL(process.env.ISSUER),
+ process.env.CLIENT_ID,
+ {},
+ oidc.PrivateKeyJwt({ key, kid: jwk.kid }),
+)
+
+// Sign in. Keep code_verifier, state and nonce for the callback.
+const url = oidc.buildAuthorizationUrl(config, {
+ redirect_uri,
+ scope: 'openid email seamless offline_access',
+ prompt: 'consent', // offline_access is honoured only with prompt=consent
+ state,
+ nonce,
+ code_challenge: await oidc.calculatePKCECodeChallenge(code_verifier),
+ code_challenge_method: 'S256',
+})
+
+// Callback.
+const tokens = await oidc.authorizationCodeGrant(config, currentUrl, {
+ pkceCodeVerifier: code_verifier,
+ expectedState: state,
+ expectedNonce: nonce,
+ idTokenExpected: true,
+})
+const claims = tokens.claims()
+const userinfo = await oidc.fetchUserInfo(config, tokens.access_token, claims.sub)
+
+// Later. Every refresh returns a new refresh token.
+const refreshed = await oidc.refreshTokenGrant(config, tokens.refresh_token)
+
+// Sign out.
+const signOutUrl = oidc.buildEndSessionUrl(config, {
+ id_token_hint: tokens.id_token,
+ post_logout_redirect_uri,
+})
+```
+
+With a shared secret the only change is `oidc.ClientSecretPost(secret)` in place of
+`oidc.PrivateKeyJwt(...)`. Both were run. See the
+[`openid-client` documentation](https://github.com/panva/openid-client).
+
+---
+
+## Trust A Private CA
+
+A publicly trusted certificate on the issuer needs none of this. A private CA, such as the local
+recipe's Caddy CA, must be given to the app, which calls the token, UserInfo and JWKS endpoints itself.
+The browser needs it too, separately.
+
+| Runtime | What worked | Guide |
+| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------- |
+| Node | [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile)`=/path/ca.crt` | [Auth.js](/build/oidc-authjs) |
+| Go | `SSL_CERT_FILE=/path/ca.crt`, or the app's own setting | [Grafana](/build/oidc-grafana), [oauth2-proxy](/build/oidc-oauth2-proxy) |
+| Java | A copy of `cacerts` with the CA imported, passed as `-Djavax.net.ssl.trustStore` | [Spring Security](/build/oidc-spring-security) |
+
+Do not turn certificate verification off to get past this.
+
+---
+
+## Sign-Out
+
+Send the browser to `end_session_endpoint` with `id_token_hint` (or `client_id`) and a registered
+`post_logout_redirect_uri`. A hosted page asks before anything ends, offering **Sign out** (the whole
+Seamless session) and **Only sign out of** the app. After **Sign out**, the browser returned to the
+app's `post_logout_redirect_uri` and the next sign-in needed the hosted page again. See
+[Sign-Out](/build/openid-provider#sign-out).
+
+An app that only clears its own cookies does not sign the user out of Seamless, so its next sign-in
+can complete silently. The [Auth.js guide](/build/oidc-authjs#sign-out) shows this happening.
+
+---
+
+## What The Provider Does Not Offer
+
+If an app needs one of these, it cannot use the provider that way today:
+
+| Feature | What happens |
+| -------------------------------------- | ------------------------------------------------------------------------------- |
+| Request objects (`request=`) | `error=request_not_supported`. Discovery has `request_uri_parameter_supported: false`. |
+| The `claims` request parameter | Discovery has `claims_parameter_supported: false`. Use `acr_values` for `acr`. |
+| `profile` claims or a `groups` claim | Not issued. See [Claims the app receives](#claims-the-app-receives). |
+| ID tokens signed with anything but RS256 | Discovery lists only `RS256`. |
+| `client_secret_jwt`, implicit or hybrid flows, plain PKCE | [Refused by design](/build/openid-provider#security-defaults). |
+
+---
+
+## Troubleshooting
+
+Each of these was hit while testing.
+
+| What you see | Cause and fix |
+| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `invalid_request`, `Authorization Server policy requires PKCE to be used for this request` | The app did not send PKCE. Turn it on with `S256`. |
+| Registering returns `400`, `Shared client secrets are off on this instance (allow_client_secrets)` | Turn on `allow_client_secrets`. |
+| `invalid_client_metadata`, `token_endpoint_auth_method must be 'private_key_jwt' or 'none'` on `/authorize` | `allow_client_secrets` was turned on but the edge was not restarted. Discovery also omits the secret methods until it is. Restart the edge. |
+| `invalid_client`, `client authentication failed` at the token endpoint | Wrong secret, or the wrong key. The browser gets back to the app first, so this shows up as an error on the app's callback. |
+| `invalid_redirect_uri`, `redirect_uri did not match any of the client's registered redirect_uris` | The app's redirect URI differs from the registered one. Compare scheme, host and path. |
+| Registering returns `localhost is not a redirect host` | See [Redirect URI rules](#redirect-uri-rules). Use `127.0.0.1`, or a real hostname over `https`. |
+| TLS or fetch errors from the app (`x509`, `fetch failed`, `Unable to resolve Configuration`) | The app does not trust the issuer's CA. See [Trust a private CA](#trust-a-private-ca). |
+| The hosted page says `Failed to register. Please try again.` and the network response is `429` | The sign-in flow limit, `flow_rate_limits.otp.perIp` (10 per 15 minutes by default). Repeated test sign-ups from one address reach it. Wait, or use fewer. |
+
+---
+
+## What Was And Was Not Tested
+
+**Tested.** A small Node server using `openid-client` 6.8.9, in a `node:24-alpine` container, signed in
+with Playwright's Chromium and a virtual WebAuthn authenticator: a new user registering, then the same
+user signing in with the passkey from a clean browser. Each run covered ID token validation including
+nonce, UserInfo, refresh rotation and sign-out, with `private_key_jwt`. The sign-in and refresh were
+repeated with `client_secret_post`. The provider stack was the local recipe in
+[seamless-auth-oidc-provider](https://github.com/fells-code/seamless-auth-oidc-provider/tree/main/local), changed for this run in three ways: it used the published API
+v0.22.0 image, skipped the recipe's client link step (those routes arrive in API v0.23.0, and these
+guides do not use them), and raised `flow_rate_limits.otp.perIp` so repeated test sign-ups were not
+refused.
+
+**Not tested.** A browser that trusts the CA normally (Chromium was told to accept the served
+certificate by its key hash, after a script checked it against the CA), a real authenticator, a
+publicly trusted certificate, a production deployment, the CLI or dashboard for registration, or API
+v0.23.0.
+
+---
+
+## Related Docs
+
+- [Use Seamless Auth As An OpenID Provider](/build/openid-provider)
+- [Sign-In For Native, Desktop And CLI Apps](/build/native-apps-oidc)
+- [Grafana](/build/oidc-grafana), [Auth.js](/build/oidc-authjs),
+ [Spring Security](/build/oidc-spring-security), [oauth2-proxy](/build/oidc-oauth2-proxy)
diff --git a/src/content/docs/build/openid-provider.mdx b/src/content/docs/build/openid-provider.mdx
index 1175f60..8ead9df 100644
--- a/src/content/docs/build/openid-provider.mdx
+++ b/src/content/docs/build/openid-provider.mdx
@@ -18,6 +18,11 @@ accept tokens the provider issued. For apps you build yourself on a supported fr
[server adapters](/build/server-sdks) are still the shorter path. For native, desktop and CLI apps,
read this page first and then [Sign-In For Native, Desktop And CLI Apps](/build/native-apps-oidc).
+For step-by-step setup, each run end to end against a local stack, see
+[Connect Any OIDC App](/build/openid-provider-connect-an-app) and the guides for
+[Grafana](/build/oidc-grafana), [Auth.js](/build/oidc-authjs),
+[Spring Security](/build/oidc-spring-security) and [oauth2-proxy](/build/oidc-oauth2-proxy).
+