---
name: login-authority-client-integration
description: 'Implement sign-in/sign-out and permission checks in a NEW client application, in ANY language or framework, that uses admin-hub as its central Login Authority (OIDC/Entra ID) and Permission Authority. Use when building a new app that needs "login with admin-hub", a signed-session cookie/token, JWKS token validation, or permission-catalog sync with admin-hub. Covers app registration, the OIDC redirect flow, app_code token exchange, JWT/JWKS verification, and permission sync — described as a plain HTTP/JWT protocol, not tied to any particular stack.'
---

# Login Authority Client Integration

admin-hub (`admin-hub/`) is the **Login Authority** (OIDC broker in front of Microsoft Entra
ID) and **Permission Authority** for this workspace. The integration it exposes is a plain
HTTP + redirect + JWT protocol — **any client app, in any language or framework, that can issue
HTTP requests, set a cookie, and verify an RS256 JWT can integrate with it.** Nothing described
here requires Node, SvelteKit, or JavaScript on the client side.

admin-hub itself happens to be implemented in SvelteKit/TypeScript, and `app-a/` and `app-b/` are
reference client apps in that same stack. They're useful as a **concrete worked example** of the
flow below — read them if a runnable reference helps, but treat every step in this skill as the
source of truth and implement it with whatever HTTP client, cookie/session mechanism, and
JWT/JWKS library are idiomatic for your own app's stack. Full background specs:
[ENTRA_ID_INTEGRATION_SETUP.md](../../../ENTRA_ID_INTEGRATION_SETUP.md),
[PERMISSION_AUTHORITY_IMPLEMENTATION.md](../../../PERMISSION_AUTHORITY_IMPLEMENTATION.md).

## When to use this skill
- Scaffolding a brand-new app, in any language/framework, that must let users "sign in via
  admin-hub".
- Adding permission-gated features to a client app that authenticates through admin-hub.
- Debugging OIDC callback errors, invalid/expired tokens, or permission-sync failures between a
  client app and admin-hub.

## Architecture overview

admin-hub never hands its own Entra ID session to a client app directly. Instead it issues
**short-lived, app-scoped RS256 JWTs** (`aud` = the client app's registered id) via an
authorization-code-style handoff, and the client app verifies those JWTs itself against
admin-hub's public JWKS endpoint — the client app never needs admin-hub's client secret or a
network round-trip per request.

```mermaid
sequenceDiagram
    participant Browser
    participant ClientApp as Client app (e.g. app-c)
    participant IDP as admin-hub (Login Authority)
    participant Entra as Microsoft Entra ID (CIAM)

    Browser->>ClientApp: GET /signin
    ClientApp->>Browser: 302 IDP /login?redirectUrl=<clientApp>/oidc-callback&appId=<ADMIN_HUB_APP_ID>
    Browser->>IDP: GET /login?redirectUrl&appId
    IDP->>IDP: resolveApplicationByAppId(appId) -> 400 if unregistered
    IDP->>Browser: 302 Entra authorize URL (PKCE, state stored in httpOnly cookies)
    Browser->>Entra: sign in
    Entra->>IDP: 302 /signin-oidc?code&state
    IDP->>Entra: exchange code (validateAuthorizationCode)
    IDP->>IDP: findOrProvisionUser(entraId, tenantId, email, name)
    IDP->>IDP: generateAppCode({sub,tid,aud=appId}) -> Redis, 2 min TTL, one-time use
    IDP->>Browser: 302 <redirectUrl>?app_code=...&aud=<ADMIN_HUB_APP_ID>
    Browser->>ClientApp: GET /oidc-callback?app_code&aud
    ClientApp->>IDP: POST /token {grant_type=authorization_code, code=app_code, aud}
    IDP->>IDP: redeemAppCode (one-time), look up scoped roles/permissions for aud
    IDP->>ClientApp: {access_token: <RS256 JWT aud=ADMIN_HUB_APP_ID>}
    ClientApp->>Browser: Set-Cookie app_jwt (httpOnly, secure, sameSite=lax); 302 /
    Browser->>ClientApp: subsequent requests carry app_jwt cookie
    ClientApp->>ClientApp: verifyJWT(app_jwt, JWKS(IDP/.well-known/jwks.json), {issuer, audience: ADMIN_HUB_APP_ID})
```

Key properties:
- `/login`'s `appId` is validated against registered applications (`resolveApplicationByAppId`,
  matching either `ClientId` or the legacy `DisplayName` convention) — an unregistered `appId`
  gets a 400 before any Entra redirect happens, it is not accepted as an arbitrary `aud`.
- The `app_code` is single-use and audience-bound (`redeemAppCode` deletes it from Redis on
  first read and rejects if `aud` doesn't match) — it cannot be replayed or used by a different
  app even if intercepted in the redirect URL.
- The `/token` exchange is server-to-server (client app's backend calling admin-hub), so the
  client app's own `Set-Cookie` for `app_jwt` is what actually reaches the browser — admin-hub's
  session cookie is separate and scoped to admin-hub's own origin only.
- The JWT's `perms` claim is a space-separated string of permission keys already resolved for
  that specific `aud` (app) at token-issue time — the client app does **not** call back to
  admin-hub to check permissions per-request, it just reads the claim.

## Step-by-step: wiring up a new client app

These steps describe an HTTP/JWT protocol — implement each one using whatever router, HTTP
client, cookie/session API, and JWT library are idiomatic in your app's own language/framework.
Nothing below assumes Node or SvelteKit; where app-a's TypeScript source is linked, it's an
optional worked example, not a dependency.

### 1. Register the app in admin-hub
As an admin, go to `/management/applications/create` in admin-hub and register the new app
(`DisplayName`, `ClientUri`). This generates and shows **once**:
- `ClientId` (GUID) — this is the app's identity, used both as the OIDC `appId`/`aud` param and
  the JWT `audience`.
- `ClientSecret` — used as the `Authorization: Bearer` credential for server-to-server calls
  (permission sync).

Store both in the new app's own configuration (environment variables, secrets manager, or
whatever your stack's convention is — never commit them). If the secret is ever lost or
compromised, use the "Rotate Secret" button on the app's detail page
(`/management/applications/[id]`) to generate a new one (also shown once).

Check "Also create a real Entra App Registration for this app" only if the client will
authenticate to admin-hub using its own Entra-issued client-credentials token instead of the
shared secret. Everything from step 2 onward is identical either way — `validateCaller`
(admin-hub side) accepts both a shared-secret `ClientSecret` and a real Entra token transparently,
so your integration code never branches on which flavor was chosen.

### 2. Configuration values your app needs
Whatever your app's configuration mechanism is (env vars, a secrets manager, a config file), it
needs three values:
```
IDP_BASE_URL     # admin-hub's origin, e.g. https://loginauthority.local:5001
APP_ID           # the ClientId GUID from step 1 — OIDC appId/aud AND JWT audience, one identifier
API_KEY           # the ClientSecret from step 1 — Authorization: Bearer credential for permission sync
```
(The names above are illustrative — app-a/app-b call these `IDP_BASE_URL`/`ADMIN_HUB_APP_ID`/
`ADMIN_HUB_API_KEY`; use whatever naming convention fits your app.) A single app-id value covers
both the OIDC redirect's `appId` param and the JWT `audience` check — there's no separate
friendly name to keep in sync with it.

Your app must serve over **https with a trusted-enough certificate** (a local dev CA is fine) —
not plain HTTP. All cookies/tokens issued in this flow are meant to be set with `secure: true`,
which browsers silently refuse to persist over plain HTTP.

### 3. Implement three HTTP endpoints: sign-in, OIDC callback, sign-out
Add three routes to your app (naming and routing mechanism are entirely up to your framework):

- **Sign-in** (e.g. `GET /signin`): redirect the browser to
  `${IDP_BASE_URL}/login?redirectUrl=<your-app-origin>/oidc-callback&appId=<APP_ID>` (a `302`
  redirect, no body needed).
- **OIDC callback** (e.g. `GET /oidc-callback`): read the `app_code` and `aud` query params off the
  incoming request, then make a server-to-server `POST` to `${IDP_BASE_URL}/token` with
  `Content-Type: application/x-www-form-urlencoded` and body
  `grant_type=authorization_code&code=<app_code>&aud=<aud>`. The response is JSON:
  `{ "access_token": "<RS256 JWT>", "token_type": "Bearer" }`. Set that `access_token` as a
  session cookie (suggested name `app_jwt`; attributes: `httpOnly`, `secure`, `sameSite=lax`,
  `path=/`), then redirect the browser to your app's home page.
- **Sign-out** (e.g. `GET /signout`): delete your app's session cookie, then redirect the browser
  to `${IDP_BASE_URL}/logout?returnUrl=<your-app-origin>/`.

A concrete TypeScript/SvelteKit example of all three:
[`app-a/src/routes/signin/+server.ts`](../../../app-a/src/routes/signin/+server.ts),
[`app-a/src/routes/oidc-callback/+server.ts`](../../../app-a/src/routes/oidc-callback/+server.ts),
[`app-a/src/routes/signout/+server.ts`](../../../app-a/src/routes/signout/+server.ts).

### 4. Verify the JWT on every request
On each incoming request that needs authentication, read the session cookie/token your app set in
step 3 and verify it:
1. Fetch admin-hub's public key set from `${IDP_BASE_URL}/.well-known/jwks.json` (a standard JWK
   Set) — cache it, and re-fetch if you encounter a `kid` you don't recognize (key rotation). Most
   JWKS-aware JWT libraries do this caching/refresh for you automatically.
2. Verify the JWT's RS256 signature against that key set.
3. Check the `iss` claim equals `IDP_BASE_URL` and the `aud` claim equals your app's `APP_ID`
   (`ClientId`).
4. Read `sub` (user id), `tid` (tenant id), and `perms` — a space-separated string of permission
   keys already resolved for your app at token-issue time.

This is standard OIDC/JWKS verification, available in essentially every language: Node (`jose`),
Python (`PyJWT`/`python-jose` + a JWKS client), Java/Kotlin (`nimbus-jose-jwt`), .NET
(`System.IdentityModel.Tokens.Jwt` / `Microsoft.IdentityModel.Protocols.OpenIdConnect`), Go
(`github.com/golang-jwt/jwt` or `github.com/lestrrat-go/jwx`), Ruby (`jwt` gem + a JWKS fetcher),
PHP (`firebase/php-jwt`), etc. — use whichever is idiomatic for your stack; none of this requires
a specific library or runtime. A concrete TypeScript example (using `jose`'s
`createRemoteJWKSet`):
[`app-a/src/lib/auth.ts`](../../../app-a/src/lib/auth.ts).

### 5. Define this app's permission catalog and sync it
- Define a flat list of `{ key, description, parentKey? }` objects representing every permission
  your app can grant. **Namespace every key with the app's own registered `DisplayName`** (e.g.
  `"AppC:dashboard:view"`) — admin-hub's sync endpoint rejects keys that don't start with the
  caller's registered `DisplayName` (`PermissionNamespaceError` → 400). `parentKey` is optional and
  only used to express a display hierarchy (e.g. `reports:export` nested under `reports:view`) —
  omit it for flat/top-level permissions.
- POST that catalog as JSON (`{ "permissions": [...] }`) to
  `${IDP_BASE_URL}/api/perms/applications/me/permissions` with header
  `Authorization: Bearer ${API_KEY}` (or a real Entra client-credentials access token, for an
  Entra-backed app). The `me` endpoint resolves the target application entirely from the
  authenticated caller — no app-id route param needed at all. (app-a/app-b predate this endpoint
  and still post to the older `/api/perms/applications/${APP_ID}/permissions` route — both work,
  `me` is just simpler for new apps.)
- Run this sync once at your app's startup (or on a schedule, or behind a manual "sync now"
  action) so the catalog in admin-hub always reflects your current code — it's safe to call
  repeatedly, since the sync is purely additive (existing permissions are never removed). A
  concrete TypeScript example of both the catalog shape and the startup-time sync call:
  [`app-a/src/lib/permissions.ts`](../../../app-a/src/lib/permissions.ts),
  [`app-a/src/lib/permission-sync.ts`](../../../app-a/src/lib/permission-sync.ts),
  [`app-a/src/hooks.server.ts`](../../../app-a/src/hooks.server.ts).

### 6. Gate pages/actions on permissions
After verifying the JWT (step 4), check whether its resolved `perms` list contains the key
required for the page/action being accessed (e.g. `'AppC:feature:action'`) before
rendering/allowing it. There is no separate "check permission" network call — the permission list
is already in the verified token.

### 7. Optional: a self-service JWT debug page
A reusable, optional pattern: a page/endpoint, gated by its own permission key (e.g.
`"AppC:debug:view"`, following the same namespacing rule as step 5), that shows the signed-in user
their own decoded token (header, full payload, resolved permissions) — useful while wiring up a
new app, not required for integration to work. A concrete TypeScript example:
[`app-a/src/routes/debug/+page.server.ts`](../../../app-a/src/routes/debug/+page.server.ts), using
a second JWT-decoding helper (`decodeTokenForDebug`) alongside the step-4 verification helper in
[`app-a/src/lib/auth.ts`](../../../app-a/src/lib/auth.ts).

## Related capability: calling another registered app on a user's behalf
A registered app can also call *another* registered app's own API using the same JWT scheme, via
admin-hub's `/token/exchange` endpoint and `GET /api/auth/applications/:appId` (peer base-URL
discovery). Both are **default-deny** — an explicit grant row (`ApplicationTokenExchangeGrants`)
must be created first via the "Delegated Token Exchange" section of
`/management/applications/[id]` before either call succeeds; registering an app grants it no
implicit access to any other app. This is a separate, optional feature from the sign-in/permission-sync
flow documented above — see `PERMISSION_AUTHORITY_IMPLEMENTATION.md` §4.4 for the full grant/revoke
workflow if a new app needs it.

## Reference files (admin-hub side — read-only, do not need to change these for a new app)
admin-hub's own routes are implemented in SvelteKit/TypeScript, but that's an implementation
detail of the server you're integrating with — the HTTP contract described in the steps above
(URLs, query params, request/response bodies, cookie semantics) is what your app actually needs to
match. These files are useful if you want to read the exact server-side behavior rather than take
this document's word for it.

| File | Purpose |
|---|---|
| [`admin-hub/src/routes/login/+server.ts`](../../../admin-hub/src/routes/login/+server.ts) | Starts OIDC flow, stores `state`/PKCE verifier/`redirectUrl`/`appId` in httpOnly cookies |
| [`admin-hub/src/routes/signin-oidc/+server.ts`](../../../admin-hub/src/routes/signin-oidc/+server.ts) | Entra callback: exchanges code, provisions user, issues `app_code` (or session JWT if no `redirectUrl`/`appId`) |
| [`admin-hub/src/routes/token/+server.ts`](../../../admin-hub/src/routes/token/+server.ts) | Redeems `app_code`, resolves scoped roles/permissions for the calling `aud`, signs the RS256 JWT |
| [`admin-hub/src/lib/server/auth/entra.ts`](../../../admin-hub/src/lib/server/auth/entra.ts) | Entra **External ID (CIAM)** OAuth2 client (arctic `OAuth2Client`, not `MicrosoftEntraId` — CIAM uses `*.ciamlogin.com`, not `login.microsoftonline.com`) |
| [`admin-hub/src/lib/server/auth/jwt.ts`](../../../admin-hub/src/lib/server/auth/jwt.ts) | `signToken`/`verifyToken` — RS256, `kid` from `key-material.ts` |
| [`admin-hub/src/lib/server/auth/jwks.ts`](../../../admin-hub/src/lib/server/auth/jwks.ts) | Serves the public JWK set consumed by client apps |
| [`admin-hub/src/lib/server/auth/app-code.ts`](../../../admin-hub/src/lib/server/auth/app-code.ts) | One-time, audience-bound `app_code` in Redis (2 min TTL) |
| [`admin-hub/src/lib/server/auth/caller-validation.ts`](../../../admin-hub/src/lib/server/auth/caller-validation.ts) | `Authorization: Bearer` → `CallerApp` lookup for machine-to-machine endpoints (permission sync, etc.) — accepts either a shared-secret `ClientSecret` or a real Entra client-credentials token |
| [`admin-hub/src/lib/server/services/login/application.ts`](../../../admin-hub/src/lib/server/services/login/application.ts) | `resolveApplicationByAppId` — resolves `/login`'s `appId` against registered apps (`ClientId` or legacy `DisplayName`) |
| [`admin-hub/src/routes/api/perms/applications/me/permissions`](../../../admin-hub/src/routes/api/perms/applications/me/permissions/+server.ts) | Permission-catalog sync endpoint, target app derived from the caller — no `:appId` needed (preferred for new apps) |
| [`admin-hub/src/routes/api/perms/applications/[appId]`](../../../admin-hub/src/routes/api/perms/applications) | Legacy permission-catalog sync endpoint (additive, namespace-checked) — still used by app-a/app-b |
| [`admin-hub/src/routes/token/exchange/+server.ts`](../../../admin-hub/src/routes/token/exchange/+server.ts) | Delegated token exchange — default-deny, requires an `ApplicationTokenExchangeGrants` row |
| [`admin-hub/src/routes/api/auth/applications/[appId]/+server.ts`](../../../admin-hub/src/routes/api/auth/applications/%5BappId%5D/+server.ts) | Peer app base-URL discovery (`clientId`/`displayName`/`clientUri`), gated by the same token-exchange grant |
| [`admin-hub/src/routes/management/applications/create/+page.svelte`](../../../admin-hub/src/routes/management/applications/create/+page.svelte) | Admin UI to register a new app and get its `ClientId`/`ClientSecret` |
| [`admin-hub/src/routes/management/applications/[id]/+page.svelte`](../../../admin-hub/src/routes/management/applications/%5Bid%5D/+page.svelte) | App detail page; "Rotate Secret" action to regenerate `ClientSecret` |

## Example client implementation (optional — SvelteKit/TypeScript)
`app-a/` and `app-b/` are reference client apps that implement every step above in
SvelteKit/TypeScript. They're a convenience for seeing the flow run end-to-end, not a template
your app must follow — if you're integrating in another language/framework, the HTTP-level
description in the steps above is everything you need; skip this table entirely.

| File | Illustrates |
|---|---|
| [`app-a/src/routes/signin/+server.ts`](../../../app-a/src/routes/signin/+server.ts) | Step 3: sign-in redirect |
| [`app-a/src/routes/oidc-callback/+server.ts`](../../../app-a/src/routes/oidc-callback/+server.ts) | Step 3: callback → `/token` exchange → session cookie |
| [`app-a/src/routes/signout/+server.ts`](../../../app-a/src/routes/signout/+server.ts) | Step 3: sign-out |
| [`app-a/src/lib/auth.ts`](../../../app-a/src/lib/auth.ts) | Step 4: JWT/JWKS verification (+ `decodeTokenForDebug` for step 7) |
| [`app-a/src/lib/permissions.ts`](../../../app-a/src/lib/permissions.ts) | Step 5: permission catalog shape |
| [`app-a/src/lib/permission-sync.ts`](../../../app-a/src/lib/permission-sync.ts), [`app-a/src/hooks.server.ts`](../../../app-a/src/hooks.server.ts) | Step 5: startup-time catalog sync |
| [`app-a/src/routes/debug/+page.server.ts`](../../../app-a/src/routes/debug/+page.server.ts) | Step 7: optional JWT debug view |

## Gotchas (confirmed root causes, don't re-debug these)
- **Cookies/session tokens silently don't persist**: this flow's cookies are meant to be set with
  `secure: true`. Your app must be served over **https** (a local dev CA/self-signed cert is
  fine), not plain `http://localhost` — browsers silently drop secure cookies over plain HTTP.
- **`AADSTS500208` / generic "Invalid OAuth callback"**: admin-hub's Entra tenant is an Entra
  **External ID (CIAM)** tenant, not a standard workforce tenant — this is admin-hub-side
  behavior (its `entra.ts` uses a custom CIAM-authority OAuth2 client, not a standard
  workforce-tenant client), not something your client app needs to handle differently. If you hit
  this, check admin-hub's `signin-oidc` logs first — it surfaces Entra's real
  `error`/`error_description` query params, which is more informative than the generic callback
  error your app will otherwise see.
- **`/token` returns 401 "User not found"** even though login succeeds: this is an admin-hub-side
  user-provisioning edge case (matching an existing user record without backfilling its tenant
  id), not something caused by your client app. It self-heals on a subsequent login; if it
  persists, report it against admin-hub rather than your integration code.
- **Permission sync 400s with a namespace error**: the permission `key` prefix must equal your
  app's registered `DisplayName` in admin-hub, not its GUID `ClientId` and not an arbitrary string
  you chose.
- **Permission sync / other machine endpoints 403**: if you use the legacy
  `/api/perms/applications/:appId/permissions` route, admin-hub requires the authenticated
  caller's id to equal the `:appId` in the URL — prefer the `me/permissions` endpoint (no
  `:appId` in the URL at all) to avoid this class of mismatch entirely.
- **`/login` returns 400 "Unknown or unregistered application"**: the `appId` query param is
  validated against registered applications — pass the exact `ClientId` returned at registration
  (or, for app-a/app-b, their registered `DisplayName`), not an arbitrary string.
- **Dev-only admin bypass**: admin-hub's `/token` handler grants full permissions to a hardcoded
  dev-only admin email when running in its own dev mode — this is expected, admin-hub-side
  behavior you may observe while testing locally, not a bug in your client app, and it does not
  exist outside admin-hub's dev environment.
- **TLS errors against admin-hub's self-signed dev cert**: when running admin-hub locally over a
  self-signed certificate, your app's own HTTP client/JWKS fetch may need TLS verification relaxed
  *for local development only* (e.g. Node: `NODE_TLS_REJECT_UNAUTHORIZED=0`; Python `requests`:
  `verify=False`; curl: `-k`; most HTTP clients have an equivalent). Never disable TLS verification
  outside local development.

## Checklist for a new client app
- [ ] App registered in admin-hub `/management/applications/create` (local secret or, if needed,
      "Also create a real Entra App Registration"); `ClientId`/`ClientSecret` saved securely
- [ ] Configuration set: admin-hub's base URL, this app's `ClientId` (doubles as OIDC `appId` and
      JWT `audience`), and this app's `ClientSecret`
- [ ] App is served over https (dev cert is fine) so session cookies persist
- [ ] Sign-in, OIDC-callback, and sign-out endpoints implemented per step 3 (any router/framework)
- [ ] JWT verification implemented per step 4 (any JWT/JWKS library), checking `issuer` and
      `audience` against admin-hub's base URL and this app's `ClientId`
- [ ] Permission catalog defined with `<DisplayName>:`-prefixed keys
- [ ] Catalog sync posts to `/api/perms/applications/me/permissions` with
      `Authorization: Bearer <ClientSecret>`; wired into app startup (or a scheduled/manual trigger)
- [ ] Protected pages/actions check the verified JWT's `perms` list before rendering/allowing them
- [ ] Admin has bucketed the app's synced permissions into roles and assigned scoped roles to
      users under admin-hub `/management/roles` and `/management/user-permissions`
