- name
- thunder-authentication
- description
- Apply when a component sits on the project's sign-in — a SPA whose users authenticate through the auth platform-resource dependency, or a protected backend those signed-in users call.
- metadata
- {"aep":{"kind":"org","audience":["coding"]}}
# Thunder Authentication
End-user identity is delegated to Thunder, the platform's Identity Provider,
which the API gateway is wired to as its external IDP. **What a caller may do is
the access token's `scope` — one authority, nothing else.**
- **The protected backend** never sees a token: the gateway validates it,
enforces the scope each operation declares in `openapi.yaml`, and hands the
service a **gateway-signed assertion** carrying the same `sub` and `scope`.
See `api-management` for the gateway's side of that contract.
- **The SPA** signs the user in with OIDC Authorization Code + PKCE, asking for
a **resource indicator** so the token is minted for this project, reads the
granted permissions from `user.scope`, and gates every screen on the scope of
the **operation that screen loads**.
**Nothing reads the groups claim to decide anything** — not the SPA, not the
service, not for display. Permissions reach code as scopes. The invariants
behind every rule here are stated once in `authorization-model`; this skill is
the mechanics.
The OAuth client itself is **platform-owned**: you never create, compute, or
hardcode any part of it.
## Where permissions come from
`specs/design/security.json` is the project's permission catalog. Read it before
writing any authorization code:
| Field | What you do with it |
|---|---|
| `permissions[].resource` + `actions[].handle` | joined by `:` these are the **scope handles** — `claims:read`, `reports:export`. This is the closed set. |
| `roles[].grants` | which handles a role holds. Drives the SPA's header badge and the "which role unlocks this" wording; `src/authz/roles.gen.ts` is generated from it. |
| `roles[].assignTo`, `groups[]` | provisioning only. **No application code reads these.** |
Two things are **not** in this file, on purpose. Which **rows** an operation
reaches is the operation's path in `openapi.yaml`: under `/me/` the caller's
(or a relation's — `/me/team/claims`), anywhere else every row.
`openapi-conventions` owns that rule; here it means a `/me/…` handler resolves
rows through the assertion's `sub`, and nothing else ever filters. And which
**screens** a caller may open is the scope of the operation each screen
**loads** — the one handle that operation already names in `openapi.yaml`. No
screen table exists anywhere; the generator projects every contract into
`src/authz/operations.gen.ts` and you bind each screen to its load operation
once, in `src/authz/screens.ts` (§5).
The platform creates the resource server, the roles and the test users when the
user clicks Build. Never write user-, group- or role-provisioning code, and
never seed a roster: an account you create is not one the platform can hand to
the validation agent. No code names a test user or gives one rows or a
relation.
**Dev clusters** ship a default Thunder admin: `admin` / `admin`, in the
`Administrators` group. That group administers the PLATFORM — it is not one of
your app's roles, and the platform will not grant it your app's scopes.
---
# SPA sign-in and screen gating
## Order of work
`src/env.ts` (add the four `<DEP>_*` keys the SPA reads to the `react-webapp` shim) →
copy the `assets/app/` tree (below) → `scripts/gen-authz.mjs` wired into the
build and run once, so the three generated files exist → the `USER_AUTH_`
prefix in `src/authz/session.ts` → **`src/api.ts` stripped of every
authorization rule of its own** (§4) → a **`/callback` route** that calls
`handleCallback()` once on mount → `src/authz/screens.ts` and the route guards
in `src/App.tsx`. Verify with the `react-webapp` build check.
## What you copy
The assets are laid out as the app is, so the install is **one command** and
nothing is renamed on the way in. **Copy them; do not write your own.** The
rules they carry were measured against this IdP and this gateway, and every one
of them has already been got wrong by a real run.
```bash
cp -r "$AEP_SKILLS_DIR/thunder-authentication/assets/app/." .
```
That puts eight files in place, all verbatim:
| Path in the app | What it is |
|---|---|
| `scripts/gen-authz.mjs` | the generator — run by `npm run gen`, emits the three `*.gen.ts` files |
| `src/authz/session.ts` | sign-in, the session, `tokenIsValid()` — the ONE edit: the `USER_AUTH_` prefix becomes YOUR dependency's |
| `src/authz/core.ts` | the pure rules, no imports — testable in plain node |
| `src/authz/gates.tsx` | `AuthzProvider`, `Can`, `RequireOperation`, `Forbidden`, `NoAccess` — restyle the markup with the pinned design system, keep the words and every export name |
| `src/authz/client.ts` | the 401 rule and the `openapi-fetch` middleware |
| `mock/authz/session.ts` | mock mode's substitute for `src/authz/session.ts` (`?role=`, `?auth=out`) |
| `mock/authz/gateway.ts`, `mock/authz/contract.ts` | mock mode's API gateway: the contract projected into a table, and the 401s |
Two more ship as **patterns** beside `app/`, and are adapted rather than copied:
| Asset | Becomes | How |
|---|---|---|
| `assets/screens.example.ts` | `src/authz/screens.ts` | replace `SCREEN_ROUTES` with YOUR screens — key, label, path and the operation each **loads** — in rail order; keep the rest |
| `assets/App.example.tsx` | `src/App.tsx` | replace `PAGE_BY_KEY` *and* `APP_NAME` with yours; the ROUTING STRUCTURE is prescribed |
One file is not copied and is not optional: **`src/api.ts`, the per-service
client you already have.** Whatever it carried about authorization comes OUT —
the bearer it attached, any `WWW-Authenticate` read, and above all
`if (res.status === 401) signIn()` — and it calls `src/authz/client.ts`'s
`authorizationHeader()` and `classifyResponse()` instead (§4 has the
`openapi-fetch` middleware to paste). Two modules that both decide what a 401
means is the same bug as one module deciding it wrong, and on an app that
already exists this is the only edit the copy cannot make for you.
The two example files are written against the Expense Tracker — its
`SCREEN_ROUTES`, `PAGE_BY_KEY` and `APP_NAME` all name ITS screens and ITS
operations. They are a shape to follow, not a fixture to ship. If your app
already holds its name somewhere (the wireframes' `navbar` title, usually
`src/appName.ts`), import it rather than declaring a second copy.
If `$AEP_SKILLS_DIR` is unset, copy from `assets/` next to this skill's
`SKILL.md`. The two `*.example.*` files and everything under `assets/__tests__/`
are **not** copied into the app: the examples are patterns you adapt, and the
tests are this skill's own regression suite. `react-webapp`'s mock harness
(`mock/plugin.ts`, `mock/browser.ts`) is copied separately and imports the
`mock/authz/` files above by those paths.
## Constraints
**Keys are derived from YOUR dependency name.** The platform emits every
platform-resource dependency's outputs into `window._env_` as
`<UPPER_SNAKE(depName)>_<UPPER_SNAKE(outputName)>`. There is **no fixed prefix** —
it is the UPPER_SNAKE of the dependency `name` the architect chose. The auth
resource type outputs `client_id`, `issuer`, `jwks_url`, `scopes` and
`resource`, so a dependency named `user-auth` yields:
| Key (dep `user-auth`) | Generic form | Meaning |
|---|---|---|
| `USER_AUTH_CLIENT_ID` | `<DEP>_CLIENT_ID` | this app's platform-owned OAuth client id |
| `USER_AUTH_ISSUER` | `<DEP>_ISSUER` | OIDC issuer / authority for `oidc-client-ts` |
| `USER_AUTH_JWKS_URL` | `<DEP>_JWKS_URL` | JWKS endpoint. Emitted, and **not** in the SPA's `Env`: the browser never validates a token — the API gateway does — so no asset reads it. Leave it out of `src/env.ts` and out of `mock/env.ts` |
| `USER_AUTH_SCOPES` | `<DEP>_SCOPES` | space-separated scopes to request: the OIDC ones (`openid profile email group ou`) plus the project's catalog handles |
| `USER_AUTH_RESOURCE` | `<DEP>_RESOURCE` | the project's **resource-server identifier** — the RFC 8707 `resource` indicator, and the `aud` the gateway pins |
Hardcoding a fixed prefix — or any prefix other than YOUR dependency's name —
gives `undefined` at module load and a redirect to `undefined/oauth2/authorize`.
The access token lives `86400` seconds by default: the `validityPeriod`
parameter of the auth resource type, which no `workload.yaml` sets. Do not
assume a shorter lifetime. A short value is only for a fixture app that tests
the silent renew.
**Ask for the resource indicator on all three legs, or every API call 401s.**
The indicator is what makes the IdP mint an access token whose `aud` is this
project's resource server and whose `scope` is narrowed to what this user's
roles grant there. Without it the token carries the IdP's default audience and
the gateway rejects it **before it reads a scope** — while sign-in itself looks
perfectly healthy.
`oidc-client-ts` 3.5.0 carries `resource` on exactly ONE of the three legs by
itself. Measured against the library, not assumed:
| Leg | Does `settings.resource` reach it? | What you set |
|---|---|---|
| `/authorize` redirect | **yes** — appended to the authorize URL | `resource: env.<DEP>_RESOURCE` in the `UserManager` settings |
| code → token exchange | **no** — `_processCode` sends only its own fields plus `...extraTokenParams` | `extraTokenParams: { resource: env.<DEP>_RESOURCE }` |
| refresh / silent renew | **no** — `signinSilent(args)` forwards `args` and never falls back to `this.settings` | pass `{ resource, extraTokenParams }` to **every** `signinSilent()` call |
`src/authz/session.ts` sets all three. A run that copied a settings-only
snippet got a green build, a healthy-looking sign-in, and a wrong `aud` on every
token.
On **this** IdP leg 3 is survivable: Thunder binds the refresh token to the
resource server it was issued for, so an argument-less renew keeps the same
`aud` (and asking for a *different* resource is refused with `invalid_target`).
Set all three anyway — it is what makes the app portable to an IdP that
re-derives the audience per request, where the omission silently downgrades
every renewed token one access-token lifetime after sign-in.
**Two settings that do not exist in 3.5.0.** Both are type errors, and both look
plausible enough that they get written:
- **`useRefreshToken`** is a method on `OidcClient`, not a member of
`UserManagerSettings`. `automaticSilentRenew: true` plus a refresh token in
the response is what makes renewal use the refresh grant.
- **`clockSkewInSeconds`** is not a member either. The settings that do exist
are `accessTokenExpiringNotificationTimeInSeconds` (default 60 — the lead time
the silent renew fires at) and `staleStateAgeInSeconds`. So the grace period
`tokenIsValid()` allows past `expires_at` is **ours**: `src/authz/session.ts`
holds a private `CLOCK_SKEW_SECONDS = 60`, the same width as that renew window.
**`client_id` is platform-owned.** It is a platform-derived opaque identifier,
**not** the dependency's `name`; the platform delivers it in
`window._env_.<DEP>_CLIENT_ID`. Same for the client secret and the registered
redirect URIs. Never add Thunder client-provisioning code anywhere — the
platform's Thunder Application operator does it when the dependency provisions —
and never write a `/login` form that POSTs credentials to your own API.
**Compute the redirect URI; there is no key for it.** The platform registers the
SPA's served callback URL once its public URL resolves, and the SPA is served at
its host root (see `react-webapp`), so that URL is `<origin>/callback`.
Reconstruct exactly that in the browser: `window.location.origin + '/callback'`,
and serve the route at `/callback`. Post-sign-in landing is
`window.location.origin`. Neither is an env key.
**That ONE registered URI serves BOTH legs, so the route must dispatch.**
`silent_redirect_uri` defaults to `redirect_uri` in oidc-client-ts 3.5.0, so a
silent renew's hidden iframe lands on `/callback` too — and a second, dedicated
URI is not an option, because the platform registers only this one and the IdP
answers `invalid_request: Invalid redirect URI` for anything else. So the route
calls **`signinCallback()`**, which reads `request_type` off the stored state
and dispatches (`si:r` → redirect, `si:s` → silent). `signinRedirectCallback()`
finishes only the redirect leg: on the silent leg it never calls the iframe
navigator's `callback()`, so the parent window is never notified and waits out
the full 10s `silentRequestTimeoutInSeconds` before the renew rejects. MEASURED
on a deployed app: **11.1s** from first paint to the sign-in page, on every
signed-out visit, 10.0s of it that timeout. `react-oidc-context`, which the
platform's own console is built on, calls the same dispatching function.
**Token endpoint is cross-origin.** The browser posts straight to
`<DEP>_ISSUER/oauth2/token`; discovery is
`<DEP>_ISSUER/.well-known/openid-configuration`. Nothing is proxied same-origin,
which is why `react-webapp` does not proxy `/oidc/` — nginx only reverse-proxies
sibling APIs under `/api`.
**Persist the session and renew silently.** The OAuth client is provisioned with
the `refresh_token` grant alongside `authorization_code` + PKCE, so an expiring
access token is renewed by posting the refresh token — no hidden iframe, no
third-party-cookie dependency. The session lives in `localStorage` (a
`WebStorageStateStore`) with `automaticSilentRenew: true`. `sessionStorage` is
per-tab and wiped on close, which forces a re-login on every visit; and without
persistent web storage the PKCE verifier does not survive the redirect at all.
**Renew what EXISTS; sign in when nothing does.** `signinSilent()` is for a
stored session that has expired — it posts the refresh token and nobody sees it.
With no stored session there is nothing to refresh, so the library falls back to
a hidden iframe whose only possible answer is `login_required`, and the app
waits behind its splash for it: measured at 0.8s of actual round trip inside a
10s wait, plus a second full boot of the SPA inside that iframe. That fork is
`sessionAction` in `assets/app/src/authz/core.ts` — `use` / `renew` / `none` —
kept out of `session.ts` so it can be tested; `none` goes straight to `signIn()`.
What it costs: a visitor holding an IdP session from a sibling app is signed in
by a visible redirect rather than a hidden iframe. Same destination, one visible
hop — and the iframe leg is cross-site (the app and the IdP are separate hosts),
so a browser blocking third-party cookies answers `login_required` anyway.
**A refresh narrows, never widens.** Permission scopes are re-evaluated on
renewal, so a grant REMOVED from a role disappears at the next silent renew —
but a grant ADDED never appears on a refresh at all (RFC 6749 §6: a refresh may
not exceed the original grant, and the narrowing sticks to the refresh token).
A new permission needs a full sign-out and sign-in. Say that in the UI where a
user could plausibly wait for a grant to arrive.
**There is no sign-out endpoint.** Thunder's discovery document advertises only
issuer, authorize and token — no `end_session_endpoint` — so `signoutRedirect()`
rejects. Sign-out drops the local session (`removeUser()`) and reloads;
`src/authz/session.ts` already wraps it in that fallback.
**Permissions ride in the access token's `scope`.** `oidc-client-ts` surfaces the
token response's scope string as `user.scope`, and `src/authz/gates.tsx` is the
only module that reads it. Never decode the access token, never hand-parse a JWT, and
**never read `user.profile.groups`** — the groups claim is still issued and
nothing in your app may consume it. There is no `getRoles()`, no role table of
your own, and no default role for a user who matches nothing: a caller whose
token grants nothing sees `NoAccess`.
**`userManager` is never exported.** Every other module reaches the session
through `src/authz/session.ts`'s functions — `signIn`, `handleCallback`,
`signOut`, `currentUser`, `accessToken`, `tokenIsValid`. That list is the
module's whole surface, and it is what lets mock mode substitute the module
wholesale with `mock/authz/session.ts` (`react-webapp`'s
`references/mock-mode.md` owns the wiring). `userManager` is
`oidc-client-ts`'s own object and has no mock substitute, so a single
`export const userManager` compiles in production and breaks the app the moment
anybody opens it without a cluster.
## Implementation
### 1 · The three generated files
`scripts/gen-authz.mjs` reads `specs/design/security.json` **and every contract
under `specs/design/components/`** (`*/openapi.yaml` and
`*/dependencies/*.openapi.yaml`) and emits the files that carry the design into
the bundle. Wire it into `package.json`:
```json
"scripts": {
"gen": "node scripts/gen-authz.mjs",
"build": "npm run gen && tsc --noEmit && vite build"
}
```
**`gen` before `tsc` is not optional.** The whole point of a generated string
literal union is that a handle the design dropped, or an operation the contract
renamed, fails the type check — and a **stale** generated file type-checks
GitHub에서 보기