Skip to main content

thunder-authentication

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.

소스 정보

저장소
wso2/labs-agentic-engineer
최근 소스 활동
2026년 10월 7일 07:08
감지된 SKILL.md 언어
영어
스타
25
포크
24

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

파일 탐색기
21 개 파일

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
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에서 보기
이 SKILL.md는 매우 커서 SkillsMP가 여기에는 첫 섹션만 미리 보여줍니다. GitHub에서 보기