# auth — sign-in for apps (guide for AI agents) `https://auth.brvo.app` is a private **OpenID Connect provider**. An app sends the user there. The user signs in with Google, Microsoft, GitHub, a password or an email link, and comes back with a code. The app swaps the code for tokens. Use it whenever an app here needs sign-in. **Never build your own password login.** Only standard OpenID Connect (authorization code flow + PKCE) is used, so use a maintained OIDC library. Do not hand-roll the protocol. ## 1. Get a client (a person does this) In the admin (`/admin/#/apps`), make one client per app: - **Confidential client** (has a server: Hono, Next.js, Go…). - You get `client_id` and `client_secret`. The secret is shown once. - Store them as `AUTH_CLIENT_ID` and `AUTH_CLIENT_SECRET`. Never commit them or send them to a browser. - **Public client** (SPA or mobile app without a backend). - You get only `client_id`. PKCE replaces the secret. - **Redirect URIs** must match exactly: no wildcards, no prefix matching. Register one per environment, e.g. `https://arc.brvo.app/auth/callback` and `http://localhost:5173/auth/callback`. - Native apps may use a private scheme with a dot, e.g. `se.brvo.arc:/callback`. - **Optional settings:** - post-logout redirect URI(s) - back-channel logout URI (strongly recommended for server apps, see §6) - which login methods to offer - access: `open` (anyone who signs in) or `allowlist` (listed emails or `@domain`) Env for the app: ``` AUTH_ISSUER=https://auth.brvo.app AUTH_CLIENT_ID=… AUTH_CLIENT_SECRET=… # confidential clients only AUTH_REDIRECT_URI=https:///auth/callback ``` ## 2. Endpoints All endpoints are in `GET https://auth.brvo.app/.well-known/openid-configuration`. Let the library read it. | what | URL | |---|---| | authorize | `GET /authorize` | | token | `POST /token` (form-encoded) | | userinfo | `GET /userinfo` (Bearer access token) | | keys | `GET /jwks` (RS256) | | revoke | `POST /revoke` (refresh tokens) | | logout | `GET /logout` (RP-initiated) | ## 3. The flow (what the library must do) 1. **Create values per login.** - Make a random `state`, a random `nonce` and a PKCE `code_verifier` (43–128 chars). - Keep all three server-side, or in a short-lived HttpOnly cookie bound to this browser. 2. **Redirect to `/authorize`** with: - `response_type=code`, `client_id`, `redirect_uri` - `scope=openid email profile`, plus `offline_access` only if you need refresh tokens - `state`, `nonce`, `code_challenge=BASE64URL(SHA256(verifier))`, `code_challenge_method=S256` 3. **On the callback:** - Check `state` equals the saved value, and `iss` equals `https://auth.brvo.app` (RFC 9207). - If `error` is present, show a friendly message: `access_denied` means a blocked user or one not on the app's allowlist; `login_required` comes from `prompt=none`. 4. **POST `/token`** with `grant_type=authorization_code`, `code`, `redirect_uri` and `code_verifier`. - Confidential clients authenticate with HTTP Basic `client_id:client_secret`. - Public clients send `client_id` in the body. 5. **Verify the `id_token`.** Use the library, which checks against JWKS: - signature RS256, `iss`, `aud == client_id`, `exp` - `nonce` == the saved nonce 6. **Create your own app session** (HttpOnly, Secure, SameSite=Lax cookie). Do not keep tokens in localStorage. Codes live 60 s and work once. Reusing a code revokes what it produced. ### Hard rules (the server enforces them) - PKCE S256 is required for every client. - Only `response_type=code`. No implicit or hybrid flows. `response_mode` is `query` only. - `redirect_uri` must be one of the registered URIs, character for character. - Each parameter may appear only once. Request objects (`request`, `request_uri`) are refused. ## 4. Tokens and claims The token response: ```json {"access_token":"…","token_type":"Bearer","expires_in":600,"id_token":"…","scope":"openid email profile", "refresh_token":"…"} ``` `refresh_token` is only included with `offline_access`. ID token claims: | claim | meaning | |---|---| | `sub` | **the stable user id** (`usr_…`), the same for every app. Key your users on this. | | `email`, `email_verified` | only with scope `email`. Trust `email` for anything only when `email_verified` is `true`. | | `name`, `picture` | with scope `profile` | | `sid` | the auth session id (used by back-channel logout) | | `amr` | how they signed in: `["pwd"]`, `["otp","email"]` (email link), `["fed","google"]` (or `microsoft`, `github`) | | `auth_time`, `iat`, `exp`, `nonce`, `azp`, `aud`, `iss` | standard | - **Access token:** a JWT (`typ: at+jwt`, RS256) that lives 10 minutes by default. Use it for `/userinfo`. `/userinfo` checks live state, so a blocked user or ended session gets 401 at once. - **Refresh tokens:** - They rotate. Every refresh returns a new one, and the old one is dead. - Presenting an old one again revokes the whole chain. Always store the newest. - They stop working when the user is blocked, signs out or resets their password. ## 5. Mapping users in your app - Store `sub` (e.g. column `auth_sub`, unique). Look users up by `sub` only. - Migrating an app that already has users: on the first sign-in, if no row has this `sub` and `email_verified` is `true`, link the existing row with the same email and save `sub`. After that, use `sub` only. - Never link on an unverified email. - Do not copy the user's password or tokens anywhere. ## 6. Logout - **From the app (RP-initiated):** clear your own session, then redirect to `https://auth.brvo.app/logout?id_token_hint=&post_logout_redirect_uri=&state=`. - With a valid `id_token_hint` for the current session, the user is signed out at once. Without it, they get a confirm button. - **Back-channel (from auth to the app):** register `backchannel_logout_uri`. When a user is blocked, signs out everywhere or resets their password, auth POSTs `logout_token=` (form-encoded) to it. The app must: 1. Verify the token: signature via JWKS, `typ` `logout+jwt`, `iss`, `aud == client_id`, `iat` within a few minutes, `events` containing `http://schemas.openid.net/event/backchannel-logout`, and no `nonce` claim. 2. Delete every app session for `sub` (or only the one created from `sid`). 3. Answer 200. This is how a block takes effect in your app immediately. The URI may be an internal Docker hostname, e.g. `http://arc:3000/auth/backchannel-logout`. ## 7. Errors - Errors from `/authorize` come back on your redirect URI as `?error=…&error_description=…&state=…&iss=…`. - The exception: if `client_id` or `redirect_uri` is wrong, auth shows its own error page and never redirects. - Errors from `/token` and `/userinfo` are JSON: `{"error":"invalid_grant","error_description":"…"}`. - `invalid_grant`: the code or refresh token is bad, used or expired. Start a new login. - `invalid_client`: wrong id or secret (401). - `429 slow_down`: back off. ## 8. Recipes ### Node / Hono / Express (confidential): `openid-client` v6 ```ts import * as client from 'openid-client' const config = await client.discovery(new URL(process.env.AUTH_ISSUER!), process.env.AUTH_CLIENT_ID!, process.env.AUTH_CLIENT_SECRET!) // GET /auth/login const verifier = client.randomPKCECodeVerifier() const state = client.randomState(), nonce = client.randomNonce() // save {verifier, state, nonce} in a signed HttpOnly cookie (10 min) or server-side const url = client.buildAuthorizationUrl(config, { redirect_uri: process.env.AUTH_REDIRECT_URI!, scope: 'openid email profile', code_challenge: await client.calculatePKCECodeChallenge(verifier), code_challenge_method: 'S256', state, nonce, }) // redirect to url.href // GET /auth/callback const tokens = await client.authorizationCodeGrant(config, new URL(req.url), { pkceCodeVerifier: verifier, expectedState: state, expectedNonce: nonce, idTokenExpected: true, }) const claims = tokens.claims()! // verified: iss, aud, exp, nonce, signature // claims.sub → your user; claims.email only if claims.email_verified ``` Back-channel logout in Node, using `jose`: ```ts import { createRemoteJWKSet, jwtVerify } from 'jose' const JWKS = createRemoteJWKSet(new URL(process.env.AUTH_ISSUER + '/jwks')) // POST /auth/backchannel-logout (application/x-www-form-urlencoded) const { payload } = await jwtVerify(form.logout_token, JWKS, { issuer: process.env.AUTH_ISSUER, audience: process.env.AUTH_CLIENT_ID, typ: 'logout+jwt', maxTokenAge: '5m', }) if (!payload.events?.['http://schemas.openid.net/event/backchannel-logout'] || 'nonce' in payload) throw new Error('bad logout token') // delete sessions for payload.sub (or payload.sid); return 200 ``` ### Next.js: Auth.js ```ts providers: [{ id: 'brvo', name: 'brvo', type: 'oidc', issuer: process.env.AUTH_ISSUER, clientId: process.env.AUTH_CLIENT_ID, clientSecret: process.env.AUTH_CLIENT_SECRET, checks: ['pkce', 'state', 'nonce'], }] ``` ### Expo / React Native (public client): `expo-auth-session` ```ts const discovery = AuthSession.useAutoDiscovery('https://auth.brvo.app') const [request, result, prompt] = AuthSession.useAuthRequest( { clientId, redirectUri: AuthSession.makeRedirectUri({ scheme: 'se.brvo.app', path: 'callback' }), scopes: ['openid', 'email', 'profile', 'offline_access'], usePKCE: true }, discovery) // exchange with AuthSession.exchangeCodeAsync({ clientId, code, redirectUri, extraParams: { code_verifier: request.codeVerifier } }, discovery) // keep the refresh token in expo-secure-store, never AsyncStorage ``` ### Go: `golang.org/x/oauth2` + `github.com/coreos/go-oidc/v3` ```go p, _ := oidc.NewProvider(ctx, "https://auth.brvo.app") cfg := oauth2.Config{ClientID: id, ClientSecret: secret, Endpoint: p.Endpoint(), RedirectURL: cb, Scopes: []string{oidc.ScopeOpenID, "email", "profile"}} v := p.Verifier(&oidc.Config{ClientID: id}) // login: verifier := oauth2.GenerateVerifier(); cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier), oidc.Nonce(nonce)) // callback: check state and r.URL.Query().Get("iss"); tok, _ := cfg.Exchange(ctx, code, oauth2.VerifierOption(verifier)) // idt, _ := v.Verify(ctx, tok.Extra("id_token").(string)); check idt.Nonce == nonce ``` ## 9. Checklist before you ship - [ ] Discovery via the library. `state`, `nonce` and PKCE are all on, and `iss` is checked on the callback. - [ ] Users keyed on `sub`. Email only trusted with `email_verified: true`. - [ ] The client secret is server-only. Tokens are not in localStorage. The app session cookie is HttpOnly, Secure and SameSite=Lax. - [ ] Back-channel logout endpoint implemented and registered (server apps). - [ ] Logout clears the app session, then redirects to `/logout` with `id_token_hint`. - [ ] `access_denied` on the callback shows a clear message ("You don't have access to this app").