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 ( +
{ 'use server'; await signIn('seamless') }}> + +
+ ) + } + 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). +