This is the abridged developer documentation for IdP Urupema
# One identity. Every app of the Institute.
> Developer docs for IdP Urupema, the OpenID Connect identity provider of Instituto Urupema. The contract, tested integration code, and llms.txt for AI coding agents.
IdP Urupema is the single sign-in of Instituto Urupema. An app sends the person to the IdP, receives a signed proof of **who** the person is (OpenID Connect), and decides on its own **what** that person may do. Apps use only the standard protocol: no SDK of ours, no admin API, nothing specific to Keycloak, the engine underneath. The issuer is `https://id.institutourupema.com.br/realms/urupema`. Endpoints and signing keys come from its discovery document.
# Start here
> The path from zero to an app in production with IdP Urupema, and the architecture choice made first.
## App shape | Your app | Client profile | What to follow | | ----------------------------------------------------------------------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | Has a server (any web app with a backend, including a single-page app served by it) | `confidential` | [Node.js server (BFF)](https://docs.id.institutourupema.com.br/integrate/node/): the server does the login and keeps the tokens; the browser gets only a session cookie. **Recommended.** | | An API called by your own app | none of its own: it accepts the app’s tokens | [API: validate the token](https://docs.id.institutourupema.com.br/integrate/api/) | | A native or mobile app with no server of its own | `public` (no secret, PKCE only) | The platform’s OIDC library (AppAuth on iOS and Android); the [contract](https://docs.id.institutourupema.com.br/contract/oidc/) applies unchanged | A browser-only app holding tokens in JavaScript is allowed by the contract (`public` profile). Serve it instead from a small server that does the login: tokens never reach the browser. The review of a registration assumes that shape. Other server stacks: a maintained OpenID Connect client library that does Authorization Code with PKCE S256, sends a `nonce` and verifies the ID token signature, plus the [contract](https://docs.id.institutourupema.com.br/contract/). The Node.js server is the reference: every rule is visible in its code. ## Path 1. **Register two clients by pull request**: `my-app-dev` (localhost) now, `my-app` (production) at deploy. One descriptor per environment, in `clients/` of [urupema/idp](https://github.com/urupema/idp), or by e-mail to without access to it. See [Registering an app](https://docs.id.institutourupema.com.br/contract/registration/). 2. **Receive the client secret** over a secure channel once the pull request is merged and applied. It goes into the server’s environment only: never into the repository, the browser or a log. 3. **Implement the login** by copying the [Node.js server](https://docs.id.institutourupema.com.br/integrate/node/): discovery, login, callback, derived session, logout. With an API, add [token validation](https://docs.id.institutourupema.com.br/integrate/api/). 4. **Grant access in your app, not in the IdP.** A new account has zero permissions in the app; the app grants them, keyed by `(iss, sub)`. See [Identity and access](https://docs.id.institutourupema.com.br/contract/identity/). 5. **Prove it** with the [before production](https://docs.id.institutourupema.com.br/integrate/production/) list, then register the production client. ## Server configuration | Variable | Value | | ------------------- | ------------------------------------------------------------------------------------------------ | | `IDP_ISSUER` | `https://id.institutourupema.com.br/realms/urupema` | | `IDP_CLIENT_ID` | the descriptor’s `clientId`, e.g. `my-app-dev` | | `IDP_CLIENT_SECRET` | delivered after registration (confidential clients only) | | `APP_URL` | the app’s base URL; `${APP_URL}/callback` must be exactly one of the descriptor’s `redirectUris` | No other configuration: no endpoint URLs, no keys, no realm name. They come from discovery.
# What the IdP is
> The boundary between IdP Urupema and every app. The IdP answers who a person is; the app decides what that person may do.
IdP Urupema is the institutional authority for **identity and authentication** at Instituto Urupema: one stable identity per person, authentication, session management, and a standard signed proof of identity for every app, OpenID Connect. Keycloak is the current engine, **not the contract**: no app depends on a Keycloak API, object, role or database; the engine is replaceable without changing any app. These pages are the contract. **Your app must** marks an obligation of the app that the IdP cannot enforce. **The IdP guarantees** marks a rule the IdP enforces on every deploy and proves in its test suite. ## Who owns what | Meaning | Owner of the truth | | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Who the person is; credentials; verified email | **the IdP** | | Whether the account is still valid **now** | **the IdP** (the app session is derived from it) | | Which apps exist as clients | **the IdP**: one reviewed descriptor per app ([Registering an app](https://docs.id.institutourupema.com.br/contract/registration/)) | | Whether this person may do X in your app | **your app** | | Profiles, roles, groups, teams and relationships specific to your app | **your app** | | The audit trail of actions in your app | **your app** | | Technical authentication events (logins, admin actions) | **the IdP**, kept 30 days | Your app must **Authorization stays in your app.** An account proves identity only: it is not a relationship with the Institute, not an academic affiliation and **not a permission in any app**. A new account has zero permissions in your app until your app grants some. See [Identity and access](https://docs.id.institutourupema.com.br/contract/identity/). The IdP guarantees **A new account carries nothing.** Its tokens hold no role beyond the protocol defaults; the IdP’s admin API refuses it. Your app must **Speak only the protocol.** Discovery, Authorization Code with PKCE, token validation against the published keys, exact redirects, logout through the published endpoint. Never the Keycloak admin API, its database or an administrative credential. ## What the IdP offers * Sign-in with Authorization Code + PKCE S256 for confidential (server) and public (native) clients ([Issuer and OIDC profile](https://docs.id.institutourupema.com.br/contract/oidc/)). * A closed set of claims: `sub`, `name`, `preferred_username`, `email`, `email_verified` ([Claims](https://docs.id.institutourupema.com.br/contract/claims/)). * Single sign-on across every app of the Institute, a central session the app session derives from, revocation within 5 minutes, single logout of the current session ([Session and logout](https://docs.id.institutourupema.com.br/contract/session/)). * Accounts with verified email, password recovery and brute-force protection, run by the IdP ([Accounts](https://docs.id.institutourupema.com.br/contract/accounts/)). ## Not offered | Not offered | Do instead | | ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | An admin API for apps (list, create, disable or delete users; read sessions) | Keep your own records keyed by `(iss, sub)` and decide access there. Blocking someone from the whole Institute is an IdP administration act, not an app’s | | Roles, groups or app-specific claims in tokens | Keep roles in your app. A new claim enters the contract only with a real source of truth, a stable meaning and a need shared by several apps, never to carry authorization | | Scopes beyond `openid profile email` (`roles`, `phone`, `address`, `offline_access`…) | Request exactly `openid profile email`; any other scope gets `invalid_scope` | | Long-lived offline sessions (`offline_access`) | Re-authenticate; the central session lasts up to 10 hours | | Implicit flow, password grant, client credentials | Authorization Code + PKCE S256 | | A token for another app’s API (token exchange) | Not yet: arrives with the agents phase. A token serves only the client that requested it | | Sign-in for MCP servers and AI agents acting for a person | Not yet: planned as Gate 2 | | Login with CAFe or gov.br | Not yet. When added, it is brokered **inside the IdP**: same `(iss, sub)`, no change in your app | | Back-channel logout notifications | The app session ends at the next refresh, at most 5 minutes after the central session ends | ## Evolution rule One side changes without forcing the other: a new sign-in method without changing apps, a new app without changing the IdP, a new attribute without it becoming authorization, a new engine without rewriting apps. A change that forces apps to know Keycloak, carries business rules in the token or queries an administrative API breaks the architecture.
# Issuer and OIDC profile
> The issuer, discovery, Authorization Code with PKCE S256, exact redirects, state and nonce, scope openid profile email, access tokens addressed to the client itself.
## Issuer and discovery Issuer
```text
https://id.institutourupema.com.br/realms/urupema
```
| Item | Value | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | Issuer | permanent; the `iss` of every token | | Discovery | `https://id.institutourupema.com.br/realms/urupema/.well-known/openid-configuration`; read it at boot; its `issuer` must equal the configured one | | JWKS | the `jwks_uri` from discovery; no pinned key; follow rotation | Endpoint paths belong to the engine and change with it; discovery is the contract. See [Endpoints](https://docs.id.institutourupema.com.br/reference/endpoints/). ## Rules The IdP guarantees **Authorization Code only.** The implicit flow (`response_type=token`), the password grant, client credentials, device and CIBA grants and token exchange are disabled on every managed client. The IdP guarantees **PKCE S256, mandatory, also for confidential clients** (RFC 9700). A request without `code_challenge`, or with `plain`, is refused. The IdP guarantees **Exact redirects.** No wildcards; `https`, or `http` only on `localhost` and `127.0.0.1`. A `redirect_uri` that differs from a registered one by one character gets an IdP error page, not a return to the app. The IdP guarantees **Contract scopes only.** `openid profile email`. Any other scope (`phone`, `roles`, `offline_access`, `address`…) returns `invalid_scope`. The IdP guarantees **Access tokens with a destination.** The access token’s `aud` is the client itself, with no engine roles. An access token serves only the client that requested it. Your app must **Single-use `state`, bound to the request.** Generate one per login, keep it on the server, compare it on the callback, then discard it. Your app must **`nonce` and ID token validation.** Send a `nonce`; on the callback validate the ID token: signature against the JWKS, `iss` equals the issuer, `aud` contains your `client_id`, `exp` in the future, `nonce` equals the one sent. Your app must **The client secret stays on the server.** Never in the browser, the repository or the logs. An app that runs only in a browser or device is a public client and has no secret. Your app must **The ID token is not API authorization.** It identifies who signed in. An API validates the **access token**: signature, `iss`, `exp`, `aud`. See [API: validate the token](https://docs.id.institutourupema.com.br/integrate/api/). ## Authorization request Line-broken for reading; the endpoint is `authorization_endpoint` from discovery.
```http
GET
?client_id=my-app
&response_type=code
&scope=openid%20profile%20email
&redirect_uri=https%3A%2F%2Fmy-app.institutourupema.com.br%2Fcallback
&state=Q2hhbmdlTWU... ← random, single use
&nonce=bm9uY2UtMTIz... ← random, comes back inside the ID token
&code_challenge=E9Melhoa2Ow... ← BASE64URL(SHA-256(code_verifier))
&code_challenge_method=S256
```
The callback URL also carries `iss` (RFC 9207, `authorization_response_iss_parameter_supported`). openid-client checks it; a library that ignores it is safe if it validates the ID token’s `iss`. Code exchange at `token_endpoint`: `grant_type=authorization_code`, the `code`, the same `redirect_uri` and the `code_verifier`; a confidential client authenticates with its secret (`client_secret_basic` or `client_secret_post`). A `code` works once. ## Refused requests | Request | The IdP answers | | -------------------------------------------------------------- | --------------------------------------------------- | | no `code_challenge` | back to the app with `error=invalid_request` | | `code_challenge_method=plain` | back to the app with `error=invalid_request` | | `scope=openid phone` (or `roles`, `offline_access`, `address`) | back to the app with `error=invalid_scope` | | `response_type=token` (implicit) | back to the app with `error=unauthorized_client` | | a `redirect_uri` not registered exactly | an IdP error page (HTTP 400); never back to the app | | `grant_type=password` at the token endpoint | HTTP 400, `unauthorized_client` | Symptoms and fixes: [Errors](https://docs.id.institutourupema.com.br/reference/errors/).
# Claims
> The closed set of claims IdP Urupema issues, which token carries each one, real token payloads, and what is never issued.
The request `openid profile email` delivers a closed set. The realm’s `basic`, `profile`, `email` and `web-origins` scopes carry **exactly** the mappers of `contract-claims.json`, reimposed on every deploy: a mapper added through the console is removed; a configuration that grants anything outside the contract is reverted. The realm’s default scopes (what a client created in the console inherits) are the same contract, with no optional scope. ## Identity claims | Claim | Scope | ID token | Access token | UserInfo | What it is | | -------------------- | ---------------- | -------- | ------------ | -------- | ------------------------------------------------------------------ | | `sub` | `basic` (always) | yes | yes | yes | the person's stable identifier at this issuer; with `iss`, the key | | `name` | `profile` | yes | no | yes | full name, for display | | `preferred_username` | `profile` | yes | no | yes | username at the IdP, for display; not a key | | `email` | `email` | yes | no | yes | the account's email, unique at the IdP; an attribute, not a key | | `email_verified` | `email` | yes | no | yes | `true` only after real verification | `name`, `preferred_username`, `email` and `email_verified` go in the ID token and the UserInfo response, **not** in the access token, which carries only `sub`. An API that needs the name or email calls `userinfo_endpoint` with the access token it received. The IdP guarantees **`email_verified` is `true` only after real verification.** An expired link or a failed send verifies no one; a reused link opens no session. ## Protocol claims | Claim | ID token | Access token | What it is | | ----------------- | :------: | :----------: | --------------------------------------------------------------------------- | | `iss` | yes | yes | the issuer; must equal the configured one | | `aud` | yes | yes | your `client_id`; in the access token, the token’s destination | | `exp`, `iat` | yes | yes | expiry and issue time, seconds since 1970 | | `auth_time` | yes | yes | time of authentication | | `azp` | yes | yes | the client that requested the token | | `sid` | yes | yes | the central session the token belongs to | | `jti` | yes | yes | the token’s identifier | | `typ` | `ID` | `Bearer` | the token type, as the current engine emits it | | `nonce` | yes | no | the value sent in the request | | `at_hash` | yes | no | hash of the access token | | `scope` | no | yes | the granted scopes: `openid profile email` | | `allowed-origins` | no | yes | only for a client with `webOrigins`; the engine’s CORS; your API ignores it | ## Never issued Engine roles (`realm_access`, `resource_access`), `given_name`, `family_name`, `locale`, phone, address, groups, and any app-specific claim. No scope requests them; asking returns `invalid_scope`. ## Payloads Real payloads from the current engine with the contract applied (values replaced): ID token (payload)
```json
{
"iss": "https://id.institutourupema.com.br/realms/urupema",
"sub": "4281c997-dbc7-42e9-8446-726ba0fc2073",
"aud": "my-app",
"azp": "my-app",
"typ": "ID",
"exp": 1791166733,
"iat": 1791166433,
"auth_time": 1791166433,
"jti": "7d265562-7b17-b435-15c4-a23f2545e2bf",
"nonce": "n-0S6_WzA2Mj",
"sid": "ij7Sg1bwli75CIDE0zxoNBdR",
"at_hash": "0qPyHaIca4RxraaPhIkmXA",
"name": "Ana Souza",
"preferred_username": "ana.souza",
"email": "ana.souza@example.org",
"email_verified": true
}
```
Access token (payload)
```json
{
"iss": "https://id.institutourupema.com.br/realms/urupema",
"sub": "4281c997-dbc7-42e9-8446-726ba0fc2073",
"aud": "my-app",
"azp": "my-app",
"typ": "Bearer",
"exp": 1791166733,
"iat": 1791166433,
"auth_time": 1791166433,
"jti": "onrtac:3dc67726-7963-380c-7f2e-389661f85351",
"sid": "ij7Sg1bwli75CIDE0zxoNBdR",
"scope": "openid profile email"
}
```
The access token lives 300 seconds (`exp − iat`). The refresh token is opaque: store it and send it back to the IdP; never parse it. `sub` is an opaque string, not a UUID. ## A new claim A claim enters the contract only with an identifiable source of truth, a stable documented meaning, a need of more than one app, and no authorization rule moved into the IdP. A claim one app needs stays in that app.
# Identity and access
> Key every person by (iss, sub), never by email. A new account has zero permissions; your app grants access and can invite people by verified email before their first login.
The key of a person in your app is the pair **`(iss, sub)`**: the issuer that signed the token and the stable identifier it gave the person. The same `sub` is valid in every app of the same issuer. Your app must **Key by `(iss, sub)`, never by email.** Email changes, is reused and does not prove identity. Never link accounts because emails match. Your app must **Same `sub` from another issuer: another person.** Store both fields, even with one issuer today. Your app must **Attributes change; the person does not.** A new name or email at the IdP updates the attributes in your app at the next login; it never creates a new person. The IdP guarantees **`sub` is stable.** It survives attribute changes, engine upgrades and a rebuild from backup. ## Access The IdP says who the person is. What they may do (roles, projects, data, bans) lives in your app, keyed by `(iss, sub)`. Your app must **A new account has zero permissions.** Signing in creates or refreshes the person’s record and grants nothing. Each permission is an explicit act of your app, in your app’s audit trail. Your app must **Deny access in your app, not in the IdP.** Remove the person’s permissions or mark them banned in your records; they keep their Institute account for the other apps. Disabling an account at the IdP ends it for every app and is an IdP administration act. ## Invitations There is no IdP API to look up or create users. To grant access before the first login, record an **invitation** addressed to the email and bind it to the person at their first login. Your app must **An invitation binds once, to a verified email.** At login, if the ID token has `email_verified: true` and an open invitation matches its `email` (case-insensitive), grant the invitation’s permissions to that `(iss, sub)` and close the invitation. If `email_verified` is not `true`, grant nothing. After binding, the email is only an attribute: a later change of email moves no access, and the old address grants nothing to whoever takes it over. `email_verified` is `true` only after the person proved control of the address by link (self-service sign-up and accounts created by the IdP administration alike; never set by hand). An email belongs to at most one account. The invitation is the only place where an email decides anything, and only once. ## Schema people.sql (PostgreSQL)
```sql
create table person (
id bigint generated always as identity primary key,
issuer text not null,
subject text not null, -- opaque string, not a UUID type
email text, -- attribute: changes, identifies no one
name text,
unique (issuer, subject)
);
create table permission (
person_id bigint not null references person(id),
name text not null, -- your app's own vocabulary
primary key (person_id, name)
);
create table invitation (
email text primary key, -- lower-cased
permission text not null,
created_by bigint references person(id)
);
-- At every login: create the person or refresh their attributes. Grants nothing.
insert into person (issuer, subject, email, name)
values ($1, $2, $3, $4)
on conflict (issuer, subject)
do update set email = excluded.email, name = excluded.name
returning id;
-- Only when the ID token says email_verified = true: bind and close the invitation.
with claimed as (
delete from invitation where email = lower($3) returning permission
)
insert into permission (person_id, name)
select $5, permission from claimed
on conflict do nothing;
```
With this schema: a new email at the IdP updates the same row; the same `sub` from another issuer creates another row; two `sub`s with the same email stay two people; an account that signs in without an invitation has no `permission` rows. The [Node.js server](https://docs.id.institutourupema.com.br/integrate/node/#5-access-your-app-decides) does the same with in-memory maps. ## Federation CAFe and gov.br will come in as identity providers **of the IdP**, not of the apps. A person who signs in through CAFe is the same `(iss, sub)` of IdP Urupema; your app does not change. Linking an external identity to an Institute account takes an explicit, proven flow; a matching name or email is never enough. Not available yet.
# Session and logout
> Your app's session is derived from the IdP's. Lifetimes, refresh on expiry, 401 on refusal, fail closed, blocking within 5 minutes, and logout of both sessions.
**The local session is derived from the IdP’s.** When the access token expires, refresh at the IdP; a refused refresh ends the local session. ## Lifetimes Declared in the IdP’s `realm.json` and reapplied on every deploy; an engine upgrade does not change them. | Element | Value | Role | | ------------------------ | ------------------------------- | ------------------------------------------ | | `accessTokenLifespan` | **300 s (5 min)** | revocation limit | | `ssoSessionIdleTimeout` | 1800 s (30 min) | without activity, the central session ends | | `ssoSessionMaxLifespan` | 36000 s (10 h) | absolute ceiling of the central session | | your app’s local session | at most `ssoSessionMaxLifespan` | never outlives the central session | ## Storage and revalidation Your app must **Store tokens on the server, as credentials.** The `refresh_token`, the `id_token` and the access token’s expiry, bound to the session record. In the BFF shape the browser gets only the session id, in an `HttpOnly` cookie. Your app must **On expiry, refresh.** Exchange the refresh token for a new one. Success: continue. **Refused: destroy the local session and answer 401.** No call to the IdP on every request; no grace period after expiry. Your app must **IdP unreachable: fail closed.** The refresh fails, the local session ends, and the person signs in again when the IdP is back. “Blocked” and “unreachable” are indistinguishable to your app. The IdP guarantees **No `offline_access`.** No managed client gets a long-lived refresh token; every refresh goes through the central session. A block takes effect within 5 min, with no revocation bus, queue or worker. ## Blocking Blocking a person at the IdP is **disabling the account and ending all of its sessions** (disabling alone does not end a live session). The IdP then refuses every refresh; residual access ends within **5 min**, the lifetime of the access token already issued. | Moment | What happens | | -------------- | ----------------------------------------------------------------- | | t | the IdP administration disables the account and ends its sessions | | t to t + 5 min | the access token already issued is valid until its `exp` | | at `exp` | your app tries the refresh; the IdP answers `invalid_grant` | | right after | your app destroys the local session and answers 401 | To remove someone from **your app only**, change their permissions in your app: [Identity and access](https://docs.id.institutourupema.com.br/contract/identity/). ## Logout **Signing out is ending the local session and redirecting to `end_session_endpoint` with `id_token_hint`.** That ends the current central session: the next app the person opens asks for the password. Sessions on other devices stay. Your app must **Do both parts:** the local one and the central one (`post_logout_redirect_uri` must be in the descriptor’s `postLogoutRedirectUris`). Ending **all** of one’s own sessions: the person, in the account console (device activity) at `https://id.institutourupema.com.br/realms/urupema/account`, or the IdP administration. ## Restart Single instance, no high availability, no SLA. **A restart signs everyone out of every app within 5 min.** The rules above cover it; nothing else is required.
# Accounts
> How Institute accounts are created, verified and protected, who administers them, and the rules for your app.
## Creation **Public sign-up is on.** Anyone creates an account on the IdP’s login screen and verifies the email by link. The IdP administration can also create an account; the person then verifies the email and sets the password by link. The email counts as verified only once the link is opened. Having an account grants nothing in any app. Your app must **New account, zero permissions.** Authentication grants nothing. See [Identity and access](https://docs.id.institutourupema.com.br/contract/identity/). ## Email Email verification is on whenever the IdP has SMTP. Public sign-up **requires** it: without SMTP the deploy fails and the realm stays closed. * One email per account: two accounts never share an address (`duplicateEmailsAllowed` is `false`). * People sign in with their username or their email. * `email_verified`: set only by the link ([Claims](https://docs.id.institutourupema.com.br/contract/claims/)). * Password recovery goes through the IdP; the old password stops working. ## Passwords and attempts | Rule | Value | | ---------------------- | ----------------------------------------------------------------------- | | Minimum length | 12 characters | | Must differ from | the username and the email | | Brute-force protection | after 30 failures, a growing wait, 1 minute at a time, up to 15 minutes | | Permanent lockout | no; the wait is temporary | | Remember me | off | | Second factor (OTP) | optional for regular accounts; mandatory for administration | ## Events Login and admin events, with details, are kept for **30 days** at the IdP. The trail of actions inside your app (who approved what) belongs to your app. ## Administration * **One service administrator**, in an administrative account separate from their everyday one, with OTP. * **Support people** have named administrative accounts, with OTP, that only view and manage the Institute’s users and read login events: they reset passwords, unlock accounts and end sessions. They never change the realm, the clients or an administrator. * **Automation** (the deploy) uses its own restricted credential, never a person’s password or OTP. * A regular person cannot register a client or gain a role by having an account. Apps have no administrative credential and no admin API. A person who needs help with their account goes to the IdP support, not to your app’s admin screen.
# Registering an app
> An app exists for the IdP when its descriptor, reviewed in a pull request, lands in clients/ of urupema/idp. Format, validation rules, examples and steps.
`realm.json` is the realm’s posture and names no app. Each app is one descriptor, `clients/.json`, in the [urupema/idp](https://github.com/urupema/idp) repository. The same code validates descriptors in CI (`node reconcile.ts --check`) and applies them on the host. ## The descriptor | Key | Required | Rule | | ------------------------ | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clientId` | yes | 2 to 63 characters of `a-z`, `0-9` and `-`, starting with a letter or digit, **equal to the file name**. Not one of Keycloak’s own clients: `account`, `account-console`, `admin-cli`, `broker`, `realm-management`, `security-admin-console` | | `name` | no | display name; defaults to the `clientId` | | `enabled` | no (`true`) | `false` disables the app without deleting anything | | `profile` | yes | `confidential` (has a secret, runs on a server) or `public` (native, mobile or browser-only app: no secret, PKCE only) | | `redirectUris` | yes, at least 1 | exact URIs: `https://host/path` (a path, even if only `/`), or `http://localhost[:port]/…` and `http://127.0.0.1[:port]/…`. No `*`, no `#` | | `webOrigins` | yes (may be `[]`) | exact origins, `https://host[:port]`, no path or trailing slash; `http://` only on `localhost` and `127.0.0.1`. Needed only when a browser talks to the IdP directly; a BFF uses `[]` | | `postLogoutRedirectUris` | no | a list, same rule as the redirects: where the IdP may send the person after logout | | `pkce` | no (`S256`) | only `S256`; no exception | | `secretSsmPath` | if `confidential` | the path of the secret, **under `/idp/prod/`** (conventionally `/idp/prod/clients/`); forbidden on a `public` client | | `notes` | no | free text for the reviewers: what the app is and why it needs these addresses | * **Unknown keys are refused.** No `scopes`, `roles` or `claims` key: the claims contract is one, for every app. * **One client per environment.** A descriptor never mixes `https://` addresses with `http://localhost` (redirects, post-logout and origins alike). Local development has its own descriptor, e.g. `my-app-dev`, and its own secret. The secret **never** goes in the descriptor; the descriptor only points to where it is kept. A secret has at least 32 characters. ### Examples All three pass the validator; names and domains are examples. * Production clients/my-app.json
```json
{
"clientId": "my-app",
"name": "My App",
"profile": "confidential",
"redirectUris": ["https://my-app.institutourupema.com.br/callback"],
"webOrigins": [],
"postLogoutRedirectUris": ["https://my-app.institutourupema.com.br/"],
"secretSsmPath": "/idp/prod/clients/my-app",
"notes": "Server-side app (BFF): the server holds the tokens, the browser only a session cookie, so no web origins."
}
```
* Local development clients/my-app-dev.json
```json
{
"clientId": "my-app-dev",
"name": "My App (local development)",
"profile": "confidential",
"redirectUris": ["http://localhost:3000/callback"],
"webOrigins": [],
"postLogoutRedirectUris": ["http://localhost:3000/"],
"secretSsmPath": "/idp/prod/clients/my-app-dev",
"notes": "One client per environment: localhost never shares a descriptor with production."
}
```
* Native app (public) clients/my-mobile.json
```json
{
"clientId": "my-mobile",
"name": "My Mobile App",
"profile": "public",
"redirectUris": ["https://my-app.institutourupema.com.br/mobile/callback"],
"webOrigins": [],
"postLogoutRedirectUris": ["https://my-app.institutourupema.com.br/mobile/"],
"notes": "Public client: no secret, PKCE only. Use it only when there is no server to hold tokens."
}
```
## Steps 1. **A pull request adds `clients/.json`** to `urupema/idp`. CI runs the validator; the reviewer decides whether the app gets in. Without access to the repository, send the descriptor to the IdP administration at , which opens the pull request. 2. **For a confidential client, the IdP administration creates the secret** as a `SecureString` parameter at the descriptor’s `secretSsmPath`, generated with `openssl rand -hex 32`. The value reaches the app’s team over a secure channel, never by email or chat in clear text. 3. **After the merge, the administration applies the clients** on the host:
```sh
sudo bash /opt/idp/bootstrap.sh --clients
```
Takes seconds, restarts nothing, does not touch the realm or the claims contract. 4. **The app uses its `clientId` and secret** with the [integration guide](https://docs.id.institutourupema.com.br/integrate/). Changing a descriptor (a new redirect, disabling the app) follows the same path. Rotating a secret: a new value in SSM, then `--clients`; only the new value is valid afterwards. ### Guarantees of apply The IdP guarantees **Idempotent, per client.** Applying again changes nothing. With no descriptors, the IdP runs with zero apps. The IdP guarantees **A bad descriptor does not take down the others.** An invalid descriptor or a missing secret skips only that client, with a named error and without printing the secret; the rest are applied. A client whose contract fails halfway is disabled, not left half-configured; if disabling also fails, the deploy names it for the administration to disable by hand. The IdP guarantees **Nothing is deleted by omission.** The IdP never deletes a client or user it does not manage; removing the file from `clients/` does not delete the client. Disabling is declarative (`"enabled": false`); deleting is an explicit administration act. The IdP guarantees **The contract is reimposed.** Every managed client gets only the contract scopes and its own `aud`. Scopes, protocol mappers and grant switches added through the console are undone at the next apply. The IdP guarantees **Isolated rotation.** A new secret affects only its client. ## Review A registered client receives the identity of **any** person with an open session at the IdP, without a new password prompt: a request with `prompt=none` is enough. One extra redirect or a wrong domain exposes every Institute account. Registration happens only by reviewed pull request, behind the validator: never in the console, never through a credential the app holds to write to the IdP.
# Node.js server (BFF)
> A complete Express server with openid-client. Tokens stay on the server, the session is derived from the IdP, access is granted by (iss, sub). The suite runs this exact code against the IdP.
A confidential client in the backend-for-frontend shape: the server does the login, keeps every token in its own session store, and gives the browser only an `HttpOnly` cookie. One file, [`site/examples/node-bff/server.ts`](https://github.com/urupema/idp/blob/main/site/examples/node-bff/server.ts). The IdP’s test suite runs it against a real IdP on every change: login, refresh, a blocked account, logout, invitations. Stack: Node.js 24, Express 5, `express-session`, [`openid-client`](https://github.com/panva/openid-client) v6. ## 0. Register and configure Register a development client ([Registering an app](https://docs.id.institutourupema.com.br/contract/registration/)): clients/my-app-dev.json
```json
{
"clientId": "my-app-dev",
"name": "My App (local development)",
"profile": "confidential",
"redirectUris": ["http://localhost:3000/callback"],
"webOrigins": [],
"postLogoutRedirectUris": ["http://localhost:3000/"],
"secretSsmPath": "/idp/prod/clients/my-app-dev",
"notes": "One client per environment: localhost never shares a descriptor with production."
}
```
Install and configure: package.json
```json
{
"name": "urupema-idp-node-bff",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": { "start": "node --env-file=.env server.ts", "check": "tsc -p ." },
"dependencies": { "express": "5.2.1", "express-session": "1.19.0", "openid-client": "6.8.8" },
"devDependencies": { "@types/express": "5.0.6", "@types/express-session": "1.19.0", "@types/node": "26.6.4", "typescript": "7.0.2" }
}
```
.env
```ini
IDP_ISSUER=https://id.institutourupema.com.br/realms/urupema
IDP_CLIENT_ID=my-app-dev
# Delivered over a secure channel; the same value the IdP keeps in SSM. Never commit it.
IDP_CLIENT_SECRET=
APP_URL=http://localhost:3000
# openssl rand -hex 32
SESSION_SECRET=
# Demo only: access granted before first login, "email=permission,..."
APP_INVITES=
```
Run `node --env-file=.env server.ts` and open `http://localhost:3000/login`. ## 1. Discovery at boot The app knows one address: the issuer. `discovery` fetches the endpoints and the signing keys, and fails if the document’s `issuer` differs from the configured one. `enableNonRepudiationChecks` makes openid-client verify the ID token signature against the published keys (off by default). server.ts
```ts
import express from "express";
import session from "express-session";
import * as client from "openid-client";
const { IDP_ISSUER, IDP_CLIENT_ID, IDP_CLIENT_SECRET, APP_URL, SESSION_SECRET } =
process.env as Record;
// Discovery at boot: endpoints and keys come from the issuer, never written by hand.
// openid-client checks that the discovered `issuer` equals IDP_ISSUER.
const config = await client.discovery(
new URL(IDP_ISSUER),
IDP_CLIENT_ID,
IDP_CLIENT_SECRET,
undefined,
// Also verify the ID token signature against the discovered JWKS.
{ execute: [client.enableNonRepudiationChecks] },
);
```
## 2. A server-side session Tokens live in the session store on the server. The cookie carries only the session id. Its `maxAge` equals the IdP’s 10-hour session ceiling and is renewed whenever the session changes (each refresh); the ceiling is the IdP refusing the refresh once the central session reaches 10 hours, which ends the local session at the next request. Production: a persistent store (Redis, Postgres); `MemoryStore` loses every session on restart. server.ts
```ts
type Person = { iss: string; sub: string; name?: string; email?: string };
declare module "express-session" {
interface SessionData {
login?: { state: string; nonce: string; verifier: string };
tokens?: { access: string; refresh: string; idToken: string; expiresAt: number };
person?: Person;
}
}
const app = express();
app.set("trust proxy", 1); // behind a TLS proxy, so the `secure` cookie is set
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
// MemoryStore is for development only: in production use a store that survives
// restarts (Redis, Postgres). The tokens live there, never in the cookie.
cookie: {
httpOnly: true,
sameSite: "lax",
secure: APP_URL.startsWith("https://"),
maxAge: 36_000_000, // ceiling: 10 h, the IdP's ssoSessionMaxLifespan
},
}),
);
```
## 3. Login: PKCE S256, state, nonce Each login gets a fresh PKCE verifier, `state` and `nonce`, kept in the session. Scope exactly `openid profile email`; any other scope is answered `invalid_scope`. server.ts
```ts
app.get("/login", async (req, res) => {
const verifier = client.randomPKCECodeVerifier();
const state = client.randomState();
const nonce = client.randomNonce();
req.session.login = { state, nonce, verifier };
const url = client.buildAuthorizationUrl(config, {
redirect_uri: `${APP_URL}/callback`,
scope: "openid profile email", // exactly this; any other scope gets invalid_scope
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: "S256",
state,
nonce,
});
res.redirect(url.href);
});
```
## 4. Callback: validate, then sign in `authorizationCodeGrant` checks `state`, PKCE, the `iss` response parameter (RFC 9207) and the ID token: signature, `iss`, `aud`, `exp` and `nonce`. Any failure throws and nobody is signed in. After success the session id is regenerated (no session fixation). server.ts
```ts
app.get("/callback", async (req, res, next) => {
const login = req.session.login;
if (!login) return res.status(400).send("Login expired. Try again.");
delete req.session.login; // single-use state
let tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers;
try {
// Checks state, PKCE, the `iss` response parameter and the ID token (signature, iss,
// aud, exp, nonce). Any failure throws: never sign the person in.
tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, APP_URL), {
pkceCodeVerifier: login.verifier,
expectedState: login.state,
expectedNonce: login.nonce,
});
} catch (err) {
return next(err);
}
const person = signIn(tokens.claims()!);
// A new session id after login (prevents session fixation).
req.session.regenerate((err) => {
if (err) return next(err);
req.session.person = person;
req.session.tokens = {
access: tokens.access_token,
refresh: tokens.refresh_token!,
idToken: tokens.id_token!,
expiresAt: Date.now() + (tokens.expiresIn() ?? 0) * 1000,
};
res.redirect("/");
});
});
```
## 5. Access: your app decides Signing in creates or updates the person’s record, keyed by `(iss, sub)`, and grants **nothing**. Zero permissions and invitations by verified email: [Identity and access](https://docs.id.institutourupema.com.br/contract/identity/). server.ts
```ts
// Your app's own records. In a real app these are tables (see "Identity and access");
// Maps keep the example self-contained.
const people = new Map(); // key: (iss, sub)
// Access granted before a person's first login, addressed to an email. The seed comes from
// APP_INVITES ("email=permission,…") only to make the example runnable.
const invitations = new Map(
(process.env.APP_INVITES ?? "").split(",").filter(Boolean).map((pair) => {
const [email, permission] = pair.split("=");
return [email.toLowerCase(), [permission]];
}),
);
/** Called after every login. Authentication is not authorization: nothing is granted here. */
function signIn(claims: client.IDToken): Person {
const key = JSON.stringify([claims.iss, claims.sub]); // the person is (iss, sub), never the email
const person: Person = {
iss: claims.iss,
sub: claims.sub,
name: claims.name as string | undefined,
email: claims.email as string | undefined,
};
// A new person starts with zero permissions; a known one keeps theirs, with fresh attributes.
const record = { ...person, permissions: people.get(key)?.permissions ?? [] };
// An invitation binds to (iss, sub) once, and only to a verified email. After that the
// email is just an attribute: changing it later moves no access.
const email = person.email?.toLowerCase();
if (email && claims.email_verified === true && invitations.has(email)) {
record.permissions = [...new Set([...record.permissions, ...invitations.get(email)!])];
invitations.delete(email);
}
people.set(key, record);
return person;
}
```
## 6. The derived session The IdP decides whether a session is still valid. While the access token (300 s) is fresh, requests pass without calling the IdP. On expiry the server refreshes. A refused refresh (account blocked, session ended, idle or maximum lifetime reached) or an unreachable IdP destroys the local session and the request gets **401**. A block takes effect within 5 minutes. server.ts
```ts
// The derived session: the IdP decides whether it is still valid.
async function requireSession(req: express.Request, res: express.Response, next: express.NextFunction) {
const t = req.session.tokens;
if (!t) return res.status(401).json({ error: "login_required" });
if (Date.now() < t.expiresAt) return next();
try {
const fresh = await client.refreshTokenGrant(config, t.refresh);
req.session.tokens = {
access: fresh.access_token,
refresh: fresh.refresh_token ?? t.refresh,
idToken: fresh.id_token ?? t.idToken,
expiresAt: Date.now() + (fresh.expiresIn() ?? 0) * 1000,
};
next();
} catch {
// Refused (account blocked, session ended, idle or max lifetime) or the IdP is
// unreachable: fail closed. The person signs in again.
req.session.destroy(() => res.status(401).json({ error: "session_ended" }));
}
}
app.get("/api/me", requireSession, (req, res) => {
const { iss, sub } = req.session.person!;
const record = people.get(JSON.stringify([iss, sub]));
// A session that outlived the app's records (e.g. an in-memory store after a restart): sign in again.
if (!record) return req.session.destroy(() => res.status(401).json({ error: "login_required" }));
res.json({ iss, sub, name: record.name, email: record.email, permissions: record.permissions });
});
```
## 7. Logout: both sessions Destroy the local session, then redirect the browser to the IdP’s `end_session_endpoint` with `id_token_hint`, which ends the central session. See [Session and logout](https://docs.id.institutourupema.com.br/contract/session/). server.ts
```ts
app.get("/logout", (req, res) => {
const idToken = req.session.tokens?.idToken;
req.session.destroy(() => {
if (!idToken) return res.redirect("/");
// Ends the central (SSO) session too; the URI must be in postLogoutRedirectUris.
const url = client.buildEndSessionUrl(config, {
id_token_hint: idToken,
post_logout_redirect_uri: `${APP_URL}/`,
});
res.redirect(url.href);
});
});
```
Next: with an API, [validate the access token](https://docs.id.institutourupema.com.br/integrate/api/) there; then [Before production](https://docs.id.institutourupema.com.br/integrate/production/).
# API: validate the token
> How an API accepts IdP Urupema access tokens. Signature against the discovered JWKS, iss, exp, aud equal to the app's client_id, typ Bearer. The suite runs this exact code against the IdP.
An API trusts a request only after validating its **access token**, locally, against the IdP’s published keys. No call to the IdP per request, no introspection. | Check | Rule | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------- | | RS256 signature against the JWKS from discovery | the key set follows rotation | | `iss` equals the issuer | a token from another issuer is another identity | | `exp` in the future | access tokens live 300 s; no tolerance after expiry | | `aud` contains the app’s `client_id` | each access token serves only the client that requested it (today `aud` is exactly that one value) | | `typ` is `Bearer` | rejects the ID token, which has the same `aud` and signature | The access token carries `sub` but **not** the name or email: for those, call the `userinfo_endpoint` with the same token. Authorization: the API looks up `(iss, sub)` in its own records. The token says who, never what. Stack: Node.js 24, Express 5, [`jose`](https://github.com/panva/jose) v6. The file is [`site/examples/api-node/server.ts`](https://github.com/urupema/idp/blob/main/site/examples/api-node/server.ts). ## 1. Keys from discovery server.ts
```ts
import express from "express";
import type { NextFunction, Request, Response } from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = process.env.IDP_ISSUER!;
const CLIENT_ID = process.env.IDP_CLIENT_ID!; // the only `aud` this API accepts
// Discovery at boot; the JWKS comes from the issuer and follows key rotation.
const metadata = await fetch(`${ISSUER}/.well-known/openid-configuration`).then((r) => r.json());
if (metadata.issuer !== ISSUER) throw new Error(`unexpected issuer: ${metadata.issuer}`);
const JWKS = createRemoteJWKSet(new URL(metadata.jwks_uri));
```
## 2. The middleware server.ts
```ts
async function requireAccessToken(req: Request, res: Response, next: NextFunction) {
const [scheme, token] = (req.headers.authorization ?? "").split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).set("WWW-Authenticate", "Bearer").json({ error: "missing_token" });
}
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: CLIENT_ID, // a token issued to another client is refused
algorithms: ["RS256"],
});
// The ID token has the same aud: refuse it by typ ("ID" vs "Bearer").
if (payload.typ !== "Bearer") throw new Error("not an access token");
res.locals.person = { iss: payload.iss!, sub: payload.sub! }; // the person is (iss, sub)
next();
} catch {
res.status(401).set("WWW-Authenticate", 'Bearer error="invalid_token"').json({ error: "invalid_token" });
}
}
```
## 3. A protected route server.ts
```ts
app.get("/api/me", requireAccessToken, (_req, res) => {
// Authenticated is not authorized: what this person may do is this API's decision,
// looked up by (iss, sub) in its own records.
res.json(res.locals.person);
});
```
.env
```ini
IDP_ISSUER=https://id.institutourupema.com.br/realms/urupema
# The aud this API accepts: the client_id of the app it belongs to.
IDP_CLIENT_ID=my-app-dev
PORT=4000
```
## Calling the API from your server In the BFF shape the browser never holds the access token. The server calls the API with the token from its session, refreshed first when expired (the [derived session](https://docs.id.institutourupema.com.br/integrate/node/#6-the-derived-session) does this).
```ts
const r = await fetch(`${API_URL}/api/me`, {
headers: { Authorization: `Bearer ${req.session.tokens!.access}` },
});
```
## Refused requests | Request | Answer | | ---------------------------------------- | ------------------------------- | | no `Authorization` header | 401, `WWW-Authenticate: Bearer` | | an expired access token | 401, `invalid_token` | | an access token issued to another client | 401, `invalid_token` | | an ID token | 401, `invalid_token` | A token for a different API (one app calling another app’s API) is not available yet; see [What the IdP is](https://docs.id.institutourupema.com.br/contract/).
# Before production
> The checks the review of a production registration expects, each with its proof. The acceptance test list for an integration.
Each line is an obligation of the app and a test to run. The IdP enforces its side (scopes, PKCE, exact redirects, token audience, lifetimes) regardless of the app’s code; the lines below are the app’s part. Proofs that need the IdP administration (a test account, blocking it, a restart): request them at . ## Registration | Check | How to prove it | | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | A production descriptor separate from the development one: exact `https` redirects, `postLogoutRedirectUris` filled in, no `localhost` | `node reconcile.ts --check` passes in the pull request ([Registering an app](https://docs.id.institutourupema.com.br/contract/registration/)) | | The client secret only in the server’s environment or a vault | `git grep` finds no secret; logs never print it; the browser’s network tab never shows it | ## Protocol | Check | How to prove it | | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | Endpoints come from discovery; only the issuer is configured | no IdP URL in the code besides `IDP_ISSUER` | | Authorization Code + PKCE S256, `state`, `nonce`, scope `openid profile email` | the authorization request (browser devtools) carries `code_challenge_method=S256`, `state`, `nonce` and exactly that scope | | The ID token is fully validated: signature, `iss`, `aud`, `exp`, `nonce` | a callback with a tampered `state` is refused; the library’s configuration verifies the signature (openid-client: `enableNonRepudiationChecks`) | | No call to the Keycloak admin API, its database or any engine-specific path | only discovery, authorization, token, userinfo, JWKS and end-session URLs are used | ## Identity and access | Check | How to prove it | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | People are keyed by a unique `(iss, sub)`; email is an attribute | changing the email in the IdP updates the same record at the next login | | A new account has zero permissions in the app | sign in with a freshly created test account (from the administration): it sees and does only what a user with no grant may | | An invitation by email binds only when `email_verified` is `true`, once | an invited email that is not verified gets nothing; after binding, changing the email moves no access | ## Session | Check | How to prove it | | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Tokens stored on the server, in a store that survives restarts; the cookie is `HttpOnly`, `Secure`, `SameSite=Lax` | restart the app: people stay signed in; the cookie holds only an id | | Refresh on expiry; a refused or failed refresh destroys the local session and answers 401 | have the administration block the test account (disable + end sessions): within 5 minutes the app answers 401, and the session does not come back when the account is re-enabled | | The local session never outlives the central one (10 h) | the cookie’s `maxAge` is at most 36000 s; once the central session ends, the next request after the refresh answers 401 | | Logout ends the local session and redirects to `end_session_endpoint` with `id_token_hint` | after logout, opening any app of the Institute asks for the password | | The app survives an IdP restart | during a restart agreed with the administration, everyone is sent to the login within 5 minutes, without an error page | ## API | Check | How to prove it | | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | The API validates the access token: signature, `iss`, `exp`, `aud` containing the app’s `client_id`, `typ` = `Bearer` | requests with no token, an expired token, another client’s token or an ID token get 401 ([API: validate the token](https://docs.id.institutourupema.com.br/integrate/api/)) |
# Endpoints
> The discovery fields an app uses; the contract, not discovery's full list, defines the client.
An app configures one address: the issuer. Every other endpoint comes from `https://id.institutourupema.com.br/realms/urupema/.well-known/openid-configuration`, read at boot. ## Fields an app uses | Field | For | | ------------------------ | ------------------------------------------------------------- | | `issuer` | must equal the configured issuer; the `iss` of every token | | `authorization_endpoint` | sign-in redirect (`response_type=code`, PKCE S256) | | `token_endpoint` | code exchange and refresh | | `userinfo_endpoint` | name and email for a holder of only the access token (an API) | | `jwks_uri` | public keys that sign tokens; follow rotation | | `end_session_endpoint` | logout, with `id_token_hint` and `post_logout_redirect_uri` | Excerpt, production values: openid-configuration (excerpt)
```json
{
"issuer": "https://id.institutourupema.com.br/realms/urupema",
"authorization_endpoint": "https://id.institutourupema.com.br/realms/urupema/protocol/openid-connect/auth",
"token_endpoint": "https://id.institutourupema.com.br/realms/urupema/protocol/openid-connect/token",
"userinfo_endpoint": "https://id.institutourupema.com.br/realms/urupema/protocol/openid-connect/userinfo",
"end_session_endpoint": "https://id.institutourupema.com.br/realms/urupema/protocol/openid-connect/logout",
"jwks_uri": "https://id.institutourupema.com.br/realms/urupema/protocol/openid-connect/certs",
"authorization_response_iss_parameter_supported": true
}
```
## Discovery vs. the contract Discovery lists the engine’s capabilities for the whole realm: implicit flow, password grant, token exchange, scopes such as `phone` and `roles`, PKCE `plain`, claims such as `given_name`. **None of that applies to a managed client.** The contract applies: | Discovery advertises | Your client gets | | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------- | | `grant_types_supported`: `authorization_code`, `implicit`, `password`, `client_credentials`, token exchange… | `authorization_code` and `refresh_token` | | `code_challenge_methods_supported`: `plain`, `S256` | `S256` only | | `scopes_supported`: `openid`, `profile`, `email`, `phone`, `address`, `roles`, `offline_access`… | `openid profile email` | | `claims_supported`: includes `given_name`, `family_name` | the claims in [Claims](https://docs.id.institutourupema.com.br/contract/claims/) |
# Errors
> Symptom, cause and fix for each integration error, as observed on the current engine with the contract applied.
Match on the `error` code (standard OAuth). The `error_description` text belongs to the engine and can change. ## At login | Symptom | Cause | Fix | | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- | | Back at the app with `error=invalid_scope` | a scope outside the contract: `phone`, `roles`, `offline_access`, `address`… | request exactly `openid profile email` | | Back at the app with `error=invalid_request` about `code_challenge_method` | no PKCE, or `plain` | generate the PKCE pair; send `code_challenge_method=S256` | | IdP error page (HTTP 400), no return to the app | `redirect_uri` is not exactly a registered one: trailing slash, port, `http` for `https`, another path | use the descriptor’s exact value, or change the descriptor by pull request | | Error page: client not found | wrong `client_id`, or the descriptor is not applied yet | check the `client_id`; after the merge, the IdP administration applies the clients | | Back at the app with `error=unauthorized_client` | `response_type=token` (implicit flow) | use `response_type=code` | | Sign-in without a password prompt | an existing session at the IdP (single sign-on) | expected | ## At the code exchange and the refresh | Symptom | Cause | Fix | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | | Token endpoint answers **401** `unauthorized_client` | wrong secret, or the old one after a rotation | check the secret delivered for this environment | | **400** `invalid_grant` on the code exchange | `code` used or expired, `redirect_uri` differs from the request’s, or `code_verifier` does not match the challenge | a `code` works once; start the login again | | **400** `invalid_grant` on the refresh | the central session ended: account blocked, logout, 30 min idle, the 10 h ceiling, or an IdP restart | destroy the local session; answer **401**; the person signs in again | | **400** `unauthorized_client` for `grant_type=password` | the password grant is disabled | use Authorization Code with PKCE | | Many sessions end at once | the IdP restarted | expected: every session ends within 5 minutes | ## At logout | Symptom | Cause | Fix | | -------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------ | | IdP error page (HTTP 400), no return to the app | `post_logout_redirect_uri` is not in `postLogoutRedirectUris` | add it to the descriptor | | Next login after logout gets in without a password | the app ended only its local session | also redirect to `end_session_endpoint` with `id_token_hint` | ## In the app and the API | Symptom | Cause | Fix | | ------------------------------------------------ | --------------------------------------------------------------------- | --------------------------------------------------------------------------- | | ID token validation fails on `iss` | the app points to another issuer (a test environment, an old address) | use the permanent issuer | | `nonce` missing or mismatched | the library did not send a `nonce` | send one; some libraries do it only when asked | | API answers 401 to a token that works in the app | another client’s token (different `aud`), an ID token, or expired | send the app’s own access token; refresh it on expiry | | API finds no name and email claims | not in the access token | call `userinfo_endpoint` with the access token | | Invited person signs in and gets no access | `email_verified` is `false`, or the invitation’s email differs | the person verifies the email at the IdP; compare emails case-insensitively |
# For AI coding agents
> The contract as plain text for an assistant, and a ready prompt for an IdP Urupema integration.
The contract as text, generated on every build from the same pages: | File | Holds | Use | | ------------------------------------ | ----------------------------------------------------------------------- | ------------------------------------- | | [`/llms.txt`](https://docs.id.institutourupema.com.br/llms.txt) | an index in the llms.txt format, opening with the contract in ten lines | the assistant decides what to read | | [`/llms-full.txt`](https://docs.id.institutourupema.com.br/llms-full.txt) | every page in full, with the guides’ code | to integrate an app: **use this one** | | [`/llms-small.txt`](https://docs.id.institutourupema.com.br/llms-small.txt) | every page without asides and collapsed details | a short context window | ## The prompt Paste it into the assistant, fill in the app details, keep the rules as they are. prompt.txt
```text
You are integrating this app with IdP Urupema (OpenID Connect).
Before writing any code, read https://docs.id.institutourupema.com.br/llms-full.txt and follow it
exactly; where it disagrees with a library default or a tutorial,
it wins.
App details (fill in):
- stack: Node.js/Express | other: ...
- client_id: my-app-dev
- redirect_uri: http://localhost:3000/callback
- post_logout_redirect_uri: http://localhost:3000/
- has an API of its own: yes | no
- who gets access, and how it is granted today: ...
Rules that cannot be broken (issuer: https://id.institutourupema.com.br/realms/urupema):
1. Discover everything from https://id.institutourupema.com.br/realms/urupema/.well-known/openid-configuration at boot and check its issuer field. Never hard-code endpoints or keys.
2. Authorization Code + PKCE S256 only, with a single-use state and a nonce. Scope exactly openid profile email; any other scope fails with invalid_scope. No implicit, password, client-credentials or offline_access.
3. Validate the ID token: signature against the discovered JWKS, iss, aud contains your client_id, exp, nonce. Make sure your library really verifies the signature.
4. Key people by the pair (iss, sub). Never by email, and never link accounts because emails match.
5. Confidential client (has a server, the recommended setup): tokens stay on the server, the browser gets only an HttpOnly session cookie, the client secret never leaves the server (not in the browser, the repository or logs).
6. The local session is derived: when the 300 s access token expires, refresh. If the refresh is refused or the IdP is unreachable, destroy the local session and answer 401. Local session ceiling: 10 h.
7. Logout = destroy the local session and redirect to end_session_endpoint with id_token_hint and a registered post_logout_redirect_uri.
8. An API validates the access token: signature, iss, exp, aud containing the app's client_id, and typ Bearer (rejects ID tokens). Name and email are not in the access token: call userinfo_endpoint.
9. Authentication is not authorization: a new account has zero permissions in your app until your app grants them. To grant access before someone's first login, invite by email and bind the invitation to (iss, sub) at first login only when email_verified is true.
10. Never call the Keycloak admin API or database; there is no admin API for apps. An app is registered by a reviewed pull request (clients/.json in urupema/idp), one client per environment; without access to that repository, request it at suporte@institutourupema.com.br.
For Node.js, start from the server in "Node.js server (BFF)" and the
middleware in "API: validate the token"; change only what this app needs.
Deliver:
1. the code;
2. for each rule above, the file and line where it is met;
3. tests proving: a new account has zero permissions; an invitation
binds only with email_verified = true; a refused refresh answers 401
and destroys the session; logout redirects to end_session_endpoint
with id_token_hint; the API refuses an ID token and another client's
token;
4. the descriptor clients/.json for the pull request.
```
## Recurring misses Check the result against [Before production](https://docs.id.institutourupema.com.br/integrate/production/). * **ID token signature not verified.** Several libraries skip it by default (openid-client needs `enableNonRepudiationChecks`). * **Email as the key**, or accounts merged on matching emails. The key is `(iss, sub)`; the email binds only an invitation, once, when verified. * **Permissions on first login.** Generic code gives every authenticated user a default role. Here a new account gets nothing. * **A refused refresh does not end the session**: a retry, a grace period or a cached user. It must destroy the session and answer 401; a block then takes effect within 5 minutes. * **Logout only clears the cookie.** It must also redirect to `end_session_endpoint` with `id_token_hint`. * **Extra scopes** (`offline_access`, `roles`) copied from tutorials: the IdP refuses them with `invalid_scope`. * **An invented admin API** to list or create users. There is none; the app keeps its own records. Complete, tested code: [`site/examples/`](https://github.com/urupema/idp/tree/main/site/examples) in the IdP repository.
# Glossary
> The terms of these docs, in the contract's sense.
* Access token Authorizes calls to the app’s API. Lives 5 minutes; `aud` is the app’s `client_id`; carries no name or email. * `aud` (audience) A token’s destination. In an IdP access token, the `client_id` of the requesting app. An API refuses any other `aud`. * BFF (backend for frontend) The app’s server does the login and keeps the tokens; the browser gets only a session cookie. The recommended shape. * Claim A field inside a token, such as `sub` or `email`. The IdP issues a closed set: [Claims](https://docs.id.institutourupema.com.br/contract/claims/). * Confidential client An app with a server; holds a secret and authenticates at the token endpoint. Uses PKCE as well. * Derived session The app’s session, valid only while the central session is. Confirmed at every refresh. * Descriptor The file `clients/.json` that registers an app at the IdP. Enters by reviewed pull request. * Discovery The document at `/.well-known/openid-configuration`: endpoints and the JWKS location. Read at boot. * `end_session_endpoint` The IdP’s logout endpoint. The app redirects the person there, with `id_token_hint`, to end the central session. * Fail closed On error or unavailability, deny access. A failed refresh ends the local session, never extends it. * ID token Identifies who signed in: `sub`, name, email. Not an API credential. * Invitation Access the app records for an email before the person’s first login. Binds once to `(iss, sub)` at sign-in with that email verified. * `iss` (issuer) Who issued the token. The IdP’s issuer is permanent. * JWKS The IdP’s set of public keys for signature verification. Changes with key rotation. * `nonce` A random value sent in the login request and returned inside the ID token. Binds the token to the request. * PKCE Proof Key for Code Exchange: the SHA-256 hash of a single-use secret (S256) in the request, the secret at the code exchange. Mandatory for every client. * `prompt=none` A login request with no screen: with a session at the IdP, it returns the person’s identity. * Public client An app that runs only on a device or in a browser. No secret; PKCE only. * Refresh token An opaque credential that trades an expired access token for a new one while the central session is valid. In a BFF, stays on the server. * Central session (SSO) The person’s session at the IdP. Ends after 30 minutes idle or 10 hours, at logout, or when the account is blocked. * `state` A random single-use value sent in the request and checked on the callback. Rejects answers the app did not ask for. * `sub` (subject) The person’s stable identifier at this issuer. With `iss`, the person’s key in the app. * UserInfo The endpoint that returns the identity claims to a holder of a valid access token.