# Sign in with RODMENA ID — integration guide

**Issuer:** `https://identity.rodmena.co.uk`
**Discovery:** `https://identity.rodmena.co.uk/.well-known/openid-configuration`

RODMENA ID is the single sign-on service for RODMENA platforms. Your application
gets a *"Sign in with RODMENA ID"* button; the user authenticates here, and you
receive standard OpenID Connect tokens telling you who they are.

You do **not** integrate with Google. Identity does that on everyone's behalf, so
when we add GitHub, MFA or anything else, your app inherits it without a code
change.

---

## 1. Ask to be registered

Reach the identity team on the agent-mail bus (`identity@mail.rodmena.co.uk`)
with:

| What | Notes |
|---|---|
| **App name** | as users should see it on the sign-in page |
| **Redirect URI** | exact, `https`, on `rodmena.com` / `rodmena.app` / `rodmena.co.uk`. No wildcards, no trailing-slash flexibility |
| **Post-logout URI** | optional, where to land after signing out |
| **Back-channel logout URI** | **strongly recommended** — see §6; without it, signing out of identity will not sign the user out of your app |
| **Client type** | `confidential` if you have a backend that can hold a secret (almost always); `public` for pure browser/mobile |
| **Scopes** | default `openid profile email`; add `offline_access` only if you need refresh tokens (confidential only) |

You receive a `client_id` and, for confidential clients, a `client_secret` **shown
exactly once**. We store only its hash: it can be rotated, never recovered. Ask
for a `--dev` client if you need `http://localhost` URIs while developing — that
is the only case where non-`https` is accepted.

## 2. Point a standard library at the issuer

Do not hardcode endpoints; read discovery. Any conformant library works —
`authlib` (Python), `openid-client` (Node), `next-auth`, Spring Security.

```python
oauth.register(
    name="rodmena",
    server_metadata_url="https://identity.rodmena.co.uk/.well-known/openid-configuration",
    client_id=RODMENA_CLIENT_ID,
    client_secret=RODMENA_CLIENT_SECRET,
    client_kwargs={"scope": "openid profile email",
                    "code_challenge_method": "S256"},
)
```

**PKCE (S256) is mandatory** — for confidential clients too. A request without
`code_challenge_method=S256` is rejected. Always send `state` and `nonce`;
`state` is echoed back and `nonce` is bound into the ID token.

## 3. The flow

```
user clicks your button
  -> https://identity.rodmena.co.uk/oauth2/authorize?client_id=…&code_challenge=…&state=…&nonce=…
  -> user signs in here (password or Google — our problem, not yours)
  -> back to YOUR redirect_uri with ?code=…&state=…
  -> your backend POSTs https://identity.rodmena.co.uk/oauth2/token
  -> {"access_token": ..., "id_token": ..., "refresh_token": ...}
```

## 4. Tokens

| Token | Lifetime | Use |
|---|---|---|
| **ID token** (RS256 JWT) | 1 hour | who the user is; verify and read claims |
| **Access token** (RS256 JWT) | 15 minutes | calling `https://identity.rodmena.co.uk/oauth2/userinfo` |
| **Refresh token** (opaque) | 30d idle / 90d absolute | `offline_access`, confidential clients only |

Verify ID tokens against `https://identity.rodmena.co.uk/oauth2/jwks`. Check `iss`, `aud` (your
`client_id`), `exp`, and the `nonce` you sent. Allow **±60s** clock skew.

Claims: `sub` (stable user UUID), `email`, `email_verified`, `name`,
`given_name`, `family_name`, `picture`, `auth_time`, `amr` (`["pwd"]` or
`["google"]`), and `sid` (session reference — keep it, see §6).

> **Key your records on `sub`, never on `email`.** Users can change their email
> address here, with re-verification. `sub` is immutable. Keying on email will
> silently orphan anyone who changes theirs.

> **`email_verified` is currently always true** — an unverified account cannot
> obtain a session at all. **Gate on it anyway.** That invariant lives in our
> login path, not in the token contract, and later phases may add flows where it
> stops holding.

### Refresh tokens rotate

Every refresh returns a **new** refresh token and invalidates the old one. Store
the new one immediately. Presenting a superseded token is treated as theft and
**revokes the entire family** — every token descended from that sign-in dies and
the user must sign in again. The usual cause is storing refresh tokens in two
places, or retrying without persisting.

## 5. Silent re-authentication — read before building

To check "is this user still signed in?" without UI, send them to
`/oauth2/authorize` with **`prompt=none`**. You get a code immediately, or a
redirect back with `error=login_required`. A login page is never rendered.

**Do it as a top-level redirect, never in a hidden iframe.** Your app and
identity are on *different registrable domains*, so our session cookie is
third-party inside your iframe and Safari and Chrome will not send it. The
iframe pattern in older OIDC guides fails here, silently. A top-level bounce
works because the cookie is `SameSite=Lax` and first-party at that moment.

## 6. Logout — both directions

**Your app → identity (RP-initiated).** Send the user to:

```
https://identity.rodmena.co.uk/oauth2/logout?id_token_hint=<id_token>
    &post_logout_redirect_uri=<your registered URI>&state=<optional>
```

This ends the identity session. Clear your own too — we cannot do that for you.

**Identity → your app (back-channel).** *This is the one people miss.* When a
user signs out **at identity**, or any compromise-shaped event occurs (password
reset, password change, per-device revoke, account claim), we POST a signed
logout token to every client that registered a back-channel URI:

```
POST <your backchannel_logout_uri>
Content-Type: application/x-www-form-urlencoded

logout_token=<RS256 JWT>
```

The token carries `iss`, `aud` (your `client_id`), `iat`, `exp` (+120s), `jti`,
`sub`, `sid`, and `events` containing
`http://schemas.openid.net/event/backchannel-logout`. It deliberately carries
**no `nonce`**, so it cannot be confused with an ID token.

Your endpoint must:

1. Verify against our JWKS: RS256, `iss` = `https://identity.rodmena.co.uk`, `aud` = your `client_id`,
   `exp` in the future, the event claim present, and **reject anything carrying
   a `nonce`**.
2. End the session(s) for that `sid` (one device) or `sub` (all of the user's).
3. Return **200**. Return **400** on anything you cannot verify — never a blind
   200.

Back-channel is server-to-server on purpose. A front-channel (iframe)
implementation fails here for the same third-party-cookie reason as §5.

> **Without a registered back-channel URI, signing out of identity will NOT
> sign the user out of your app.** Clients with no URI are silently skipped, so
> nothing breaks — but nothing propagates either.

**Access tokens are stateless JWTs.** If you validate one offline you will still
accept it until its 15-minute expiry, even after a revocation. Our
`/oauth2/userinfo` refuses them immediately, but we cannot recall a token from a
resource server that never calls us. If that window matters, check `userinfo` or
keep your own sessions short.

## 7. Errors

| What you see | Meaning |
|---|---|
| A rendered error page instead of a redirect | Your `client_id` or `redirect_uri` is not registered. We deliberately never redirect to an unvalidated URI. |
| `error=invalid_request` on authorize | Missing or non-S256 PKCE. |
| `error=invalid_scope` | You asked for a scope not granted to your client (often `offline_access`). |
| `error=login_required` | `prompt=none` with no usable session. Expected. |
| `401 invalid_client` on token | Wrong or missing `client_id` / `client_secret`. |
| `invalid_grant` on token | Code already used, expired (60s), PKCE mismatch, or issued to a different client/redirect URI. |
| `invalid_grant` on refresh | Rotated token reused (family revoked), or the family expired. |
| `503` + `Retry-After` | A dependency of ours is unavailable. Retry; do not treat as a client error. |

## 8. Adoption checklist

- [ ] Registered; `client_id` and secret stored in a secret manager, not the repo
- [ ] Library configured from discovery, not hardcoded endpoints
- [ ] PKCE S256, `state` and `nonce` on every authorization request
- [ ] ID token verified against JWKS with ±60s leeway
- [ ] Records keyed on `sub`, not `email`
- [ ] Login gated on `email_verified`
- [ ] Refresh tokens (if used) stored in exactly one place and replaced on every rotation
- [ ] Back-channel logout endpoint implemented and URI registered
- [ ] Silent re-auth, if any, is a top-level redirect — never an iframe
- [ ] Verified end to end against production, including a negative (a forged or
      wrong-audience token must be REJECTED — a verifier that has never failed
      proves nothing)

## 9. Endpoints

| Endpoint | Purpose |
|---|---|
| `https://identity.rodmena.co.uk/.well-known/openid-configuration` | discovery — start here |
| `https://identity.rodmena.co.uk/oauth2/authorize` | authorization (code + PKCE S256) |
| `https://identity.rodmena.co.uk/oauth2/token` | code exchange and refresh |
| `https://identity.rodmena.co.uk/oauth2/userinfo` | claims for a Bearer access token |
| `https://identity.rodmena.co.uk/oauth2/jwks` | public signing keys (rotated; publish overlap honoured) |
| `https://identity.rodmena.co.uk/oauth2/logout` | RP-initiated logout |
| `https://identity.rodmena.co.uk/docs`, `https://identity.rodmena.co.uk/llms.txt` | this guide |
| `https://identity.rodmena.co.uk/health`, `https://identity.rodmena.co.uk/ping` | liveness |

Questions, registration requests and defect reports: `identity@mail.rodmena.co.uk`
on the agent-mail bus. Report defects with a reproduction; we will do the same
for you.
