@seamless-auth/oidc-provider 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +95 -16
  3. package/dist/accessTokenClaims.d.ts +29 -0
  4. package/dist/accessTokenClaims.js +67 -0
  5. package/dist/accessTokenClaims.js.map +1 -0
  6. package/dist/accountClaims.d.ts +51 -0
  7. package/dist/accountClaims.js +102 -0
  8. package/dist/accountClaims.js.map +1 -0
  9. package/dist/adapter.js +26 -5
  10. package/dist/adapter.js.map +1 -1
  11. package/dist/apiClient.d.ts +3 -2
  12. package/dist/apiClient.js +3 -2
  13. package/dist/apiClient.js.map +1 -1
  14. package/dist/brandingContrast.d.ts +4 -0
  15. package/dist/brandingContrast.js +59 -0
  16. package/dist/brandingContrast.js.map +1 -0
  17. package/dist/claimsMismatch.d.ts +15 -0
  18. package/dist/claimsMismatch.js +90 -0
  19. package/dist/claimsMismatch.js.map +1 -0
  20. package/dist/createOidcEdge.d.ts +5 -0
  21. package/dist/createOidcEdge.js +43 -7
  22. package/dist/createOidcEdge.js.map +1 -1
  23. package/dist/deviceFlow.d.ts +2 -6
  24. package/dist/deviceFlow.js +2 -10
  25. package/dist/deviceFlow.js.map +1 -1
  26. package/dist/env.d.ts +1 -4
  27. package/dist/env.js +46 -4
  28. package/dist/env.js.map +1 -1
  29. package/dist/errorPage.d.ts +10 -0
  30. package/dist/errorPage.js +24 -0
  31. package/dist/errorPage.js.map +1 -0
  32. package/dist/hostedPages.d.ts +1 -0
  33. package/dist/hostedPages.js +24 -1
  34. package/dist/hostedPages.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/interactions.js +83 -30
  39. package/dist/interactions.js.map +1 -1
  40. package/dist/jwtAccessTokens.d.ts +1 -1
  41. package/dist/jwtAccessTokens.js +2 -1
  42. package/dist/jwtAccessTokens.js.map +1 -1
  43. package/dist/logout.d.ts +4 -0
  44. package/dist/logout.js +8 -1
  45. package/dist/logout.js.map +1 -1
  46. package/dist/providerConfig.d.ts +3 -3
  47. package/dist/providerConfig.js +22 -6
  48. package/dist/providerConfig.js.map +1 -1
  49. package/dist/requestContext.d.ts +1 -2
  50. package/dist/requestContext.js.map +1 -1
  51. package/dist/requestObjectErrors.d.ts +12 -0
  52. package/dist/requestObjectErrors.js +47 -0
  53. package/dist/requestObjectErrors.js.map +1 -0
  54. package/dist/resumeCookie.js +3 -1
  55. package/dist/resumeCookie.js.map +1 -1
  56. package/dist/server.js +2 -2
  57. package/dist/server.js.map +1 -1
  58. package/dist/signingKey.d.ts +31 -3
  59. package/dist/signingKey.js +104 -4
  60. package/dist/signingKey.js.map +1 -1
  61. package/dist/ui/assets/index-BLikvo1E.js +18 -0
  62. package/dist/ui/assets/index-DKah8G4b.css +1 -0
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +11 -8
  65. package/dist/ui/assets/index-CydLXRvo.js +0 -18
  66. package/dist/ui/assets/index-y3LME-Gv.css +0 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # @seamless-auth/oidc-provider
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 307f05d: Carry roles, permissions and custom claims in provider tokens (#47).
8
+
9
+ - New scopes `permissions` (`roles`, `permissions`, `org_id`, `org_roles`, `org_permissions`, or `permissions_omitted`) and `custom_claims` (every custom claim the API resolves for the user and the application). They appear in ID tokens, `/userinfo`, and a user's RFC 9068 access token for a resource when the grant holds the scope. The consent page describes both.
10
+ - The values come from the API's `/oidc/accounts/:id` and the API signs only what it computes again, so the edge cannot add or change one. Reserved claim names are never released, and the permission claims are released whole or not at all.
11
+ - When the API refuses a sign with `claims_mismatch` (a role or membership changed between the account read and the sign), the edge reads the account again and signs once more, so the token request succeeds with the new values. A second mismatch answers `server_error` (500) with a description. Other refusals are not retried.
12
+ - `org_id` now names the session's active organization rather than the user's oldest membership. This changes `org_id` under the `seamless` scope too.
13
+ - Needs a seamless-auth-api release that returns these claims and signs them. Against an older API none are released.
14
+ - Depend on `@seamless-auth/types` `^0.40.0` for the shared reserved claim list, the account claims schema and the `claims_mismatch` refusal code.
15
+ - 18b472c: The edge now follows the API's provider key rotation without a restart. It used to load the signing keys once at startup, so after the API rotated its active key every token request failed with `unknown_key` until the edge restarted, and `/jwks` and JWT access token introspection kept the startup keys. It now re-reads the keys every five minutes and at once when the API refuses a key as unknown (at most the request that meets the rotation fails), and `/jwks` serves the current set. Embedders can set the interval with the new `signingKeyRefreshMs` option (`0` turns the timer off). A rotation to a key with a different algorithm is refused with a logged error and still needs a restart. `SigningKeyRing` is exported for custom setups.
16
+ - 18b472c: `TRUST_PROXY` now takes a hop count (`2` for a CDN in front of a load balancer) or a comma-separated list of proxy addresses and subnets (`10.0.0.0/8, 172.16.0.5`, or Express's `loopback`, `linklocal` and `uniquelocal`), and is passed to Express's `trust proxy` as is. Behind more than one proxy the edge used to forward the last proxy's address as the client IP, so users shared the API's per-IP rate limits and lockouts. `true` still means one hop and `false` none, so existing deployments are unchanged; a value that is neither a hop count nor valid addresses now stops the service at startup. Keep the container port reachable only through the proxy.
17
+
18
+ ### Patch Changes
19
+
20
+ - bfff063: Depend on `@seamless-auth/core` and `@seamless-auth/express` `^0.26.0`. On 0.x, `^0.23.0` stopped at 0.23, so the edge ran on 0.23 and an application installing the provider got a second, older copy of both. The edge now has the adapter fixes from 0.25: the CSRF origin check on form posts to the auth routes (seamless-auth-server#256), no replay of a completed bearer refresh rotation (#258), no client-chosen client IP under a `trust proxy` that trusts every address (#257), and a bundled adapter manifest with the v0.23 routes.
21
+
22
+ The hosted pages build against `@seamless-auth/react` 0.20 and `@seamless-auth/client` 0.9. Client 0.8.2 sent body-less requests such as the passkey step-up start without a JSON content type or the transport header, and adapters from 0.25 refuse those with 415, so step-up needs the newer client.
23
+ - 216b785: The confirmation for a native client now names the account as well as the client. `GET /interaction/:uid/details` returns `account: { identifier }` (the account's email, or its phone number if it has none), or `account: null` unless the browser is signed in as the interaction's account and the API can describe it. The hosted "Continue to <app>?" card shows the identifier, and with no account to name it offers only Cancel. If you serve your own pages through `uiDir`, show `account.identifier` beside the client name, send `{ "confirmed": true }` only from a real click, and do not offer to continue when `account` is `null`.
24
+ - 7858f18: A device client polling an expired device code faster than its interval now gets `expired_token`, so it stops, rather than `slow_down` on every poll. The poll throttle only tracks codes that have not expired. With an API that keeps expired device codes readable for a while (seamless-auth-api#542), the token endpoint answers `expired_token` for them; a code the API does not know still gets `invalid_grant`.
25
+ - 7858f18: Errors the provider cannot send back to the app now render on the hosted pages, with your branding, instead of the library's default "oops! something went wrong" page. That covers an unknown `client_id`, an unregistered `redirect_uri` at `/authorize`, and a refused `/session/end` (an unregistered `post_logout_redirect_uri`, or an `id_token_hint` that cannot be read). The page shows the error code, the provider's description and a plain explanation, and answers with the hosted pages' CSP and `no-store`. A server error shows a generic description.
26
+ - 7858f18: The hosted pages meet WCAG 2.2 AA with the default palette and a sound custom one (see `docs/accessibility-audit.md`).
27
+
28
+ - The sign-in screens' mode toggle, error text and hovered button now take this service's palette, and follow `hosted_ui` branding, instead of fallback colors that fell below 4.5:1.
29
+ - Text field edges are darker (`#7a828e` light, `#8a93a0` dark) so they meet 3:1, which also darkens card borders.
30
+ - Sign-out, device and error pages are titled for what they do rather than "Sign in to <app>".
31
+ - The device code error is tied to the code field.
32
+ - When the hosted pages render with a `hosted_ui` palette whose pairs fall below AA contrast, the service logs one warning naming each pair. Nothing is refused.
33
+ - 18b472c: A failure while finishing a login no longer leaves a grant behind, and unexpected errors are answered as JSON. `POST /interaction/:uid/login` saves the grant before it reads the auth context; when that read failed (the API unreachable, say), the grant stayed and bound the first-party session to the client without a finished sign-in. The edge router also had no error handler, so such failures reached Express's default handler, which answers HTML with a stack trace whenever `NODE_ENV` is not `production`. They now answer `500 {"error":"server_error"}` (logged by name and message), and a malformed request body answers its 4xx status with `{"error":"invalid_request"}`.
34
+ - 18b472c: `/session/end` no longer signs a browser out of the hosted pages when it has no provider sign-in. The library ends an empty provider session without a confirmation page, and the edge used to clear the first-party adapter cookies on that automatic post, so any site that sent a browser to `/session/end` signed it out. The first-party session and its cookies are now ended only when the provider session had an account and the user confirmed on the hosted page.
35
+ - 18b472c: A first-party native client (`application_type: native`) no longer gets a code without a click. Any program on the device can listen on a loopback port or claim a private-use scheme, so a signed-in user's browser could otherwise hand a code to it with no interaction (RFC 8252 section 8.6). The hosted pages now show a "Continue to <app>" confirmation for every native client request, after sign-in and on later requests that ride the provider session, and `POST /interaction/:uid/consent` answers `403 confirmation_required` for a native client's prompt unless the body is `{ "confirmed": true }`. A custom hosted UI must send that body from the user's click. Web clients and device-flow approvals are unchanged.
36
+ - 7858f18: A request object that asks for `response_mode=form_post` now gets its `request_not_supported` refusal by form post rather than in the query string. Request objects are still not supported. Only `response_mode` is read from the refused object, without verifying it, and only the value `form_post` changes anything: the error still goes only to the request's own `redirect_uri` once it is checked against the client's registered URIs, and no other value from the object is used or shown. This lets the Form Post OP conformance profile pass `oidcc-unsigned-request-object-supported-correctly-or-rejected-as-unsupported`.
37
+ - 18b472c: The device poll throttle and the resume cookie check now match paths the way the provider's router does. A device client polling `/token/` was never told `slow_down`, because the throttle only recognised the exact path `/token`; it now follows the provider's own routing, so every spelling the provider accepts is throttled. The check that a resumed authorization is the one the resume cookie names now also covers `/authorize/<uid>/` and a differently cased `/AUTHORIZE/<uid>`.
38
+ - abb4834: Depend on `@seamless-auth/types` `^0.36.0`, which adds the session exchange contract.
39
+
40
+ ## 0.3.0
41
+
42
+ ### Minor Changes
43
+
44
+ - 047779c: A user's JWT access token for a resource (RFC 9068) now carries the session's `acr`, `amr` and `auth_time`, the same values the ID token states, and introspection returns them. A resource server can require a recent phishing-resistant sign-in on a route from the token alone (RFC 9470), and a refreshed token carries the session's current values, so a step-up shows on the next refresh. Device-flow and client credentials tokens carry none of the three, and opaque `/userinfo` tokens are unchanged. Needs no API change: Seamless Auth API 0.22.0 signs these claims and checks them against the session.
45
+
46
+ ### Patch Changes
47
+
48
+ - 7c86020: Device-flow ID tokens no longer carry the approving session's `amr` and `auth_time`, first or after a refresh. Seamless Auth API 0.22.0 refuses to sign any device-flow token that states `acr`, `amr` or `auth_time` (ADR 0001 D10), so a device-flow request asking for `openid` ended in `server_error` against it.
49
+
3
50
  ## 0.2.0
4
51
 
5
52
  ### Minor Changes
package/README.md CHANGED
@@ -16,6 +16,10 @@ any OIDC app ──► seamless-auth-oidc-provider (public: OIDC endpoints, host
16
16
  - Built on [`oidc-provider`](https://github.com/panva/node-oidc-provider) 9.x.
17
17
  - Holds no private keys and no database. The API signs every token after checking its claims,
18
18
  and stores all provider state.
19
+ - Follows the API's key rotation without a restart. It re-reads the API's keys every five
20
+ minutes (`signingKeyRefreshMs` when embedded), and at once when the API refuses a key as
21
+ unknown, so at most the request that meets a rotation fails. `/jwks` serves the current set.
22
+ Rotating to a key with another algorithm still needs a restart.
19
23
  - Code flow with PKCE (a confidential client can be opted out, and then sends `nonce` unless that is waived too), refresh rotation always on, `private_key_jwt` for confidential
20
24
  clients, and shared secrets only for clients opted in on the API.
21
25
 
@@ -35,6 +39,13 @@ The design is in
35
39
  hosted pages.
36
40
  - HTTPS. Provider cookies are `__Host-` cookies, which browsers only accept over TLS.
37
41
 
42
+ ## Run it on your laptop
43
+
44
+ [`local/README.md`](local/README.md) goes from nothing to a passkey sign-in on the hosted page and a
45
+ JWT access token for your own API on one page: a compose file with the API, Postgres and this
46
+ service behind local TLS, generated keys, and a seed for the clients and the resource. The
47
+ production checks stay on.
48
+
38
49
  ## Run the image
39
50
 
40
51
  ```bash
@@ -43,16 +54,28 @@ docker run -p 5314:5314 --env-file .env ghcr.io/fells-code/seamless-auth-oidc-pr
43
54
 
44
55
  ## Configuration
45
56
 
46
- | Variable | Required | Description |
47
- | ----------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
48
- | `SEAMLESS_API_URL` | yes | The private API, as this service reaches it. |
49
- | `SEAMLESS_API_ISSUER` | no | The API's `ISSUER`, when it differs from `SEAMLESS_API_URL`. |
50
- | `SEAMLESS_OIDC_SERVICE_TOKEN` | yes | This service's own service token secret, `SEAMLESS_OIDC_SERVICE_TOKEN` on the API. It must differ from the adapters' `SEAMLESS_SERVICE_TOKEN`, which this service never holds. At least 32 characters. |
51
- | `SEAMLESS_JWKS_KID` | no | The `kid` on the service tokens this service signs. |
52
- | `SEAMLESS_COOKIE_SECRET` | yes | Signs the first-party session cookies. At least 32 characters. |
53
- | `OIDC_COOKIE_KEYS` | yes | Comma-separated provider cookie keys. The first signs, the rest verify. |
54
- | `TRUST_PROXY` | no | `true` when TLS ends at a proxy in front of this service. |
55
- | `PORT` | no | Defaults to `5314`. |
57
+ | Variable | Required | Description |
58
+ | ----------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
59
+ | `SEAMLESS_API_URL` | yes | The private API, as this service reaches it. |
60
+ | `SEAMLESS_API_ISSUER` | no | The API's `ISSUER`, when it differs from `SEAMLESS_API_URL`. |
61
+ | `SEAMLESS_OIDC_SERVICE_TOKEN` | yes | This service's own service token secret, `SEAMLESS_OIDC_SERVICE_TOKEN` on the API. It must differ from the adapters' `SEAMLESS_SERVICE_TOKEN`, which this service never holds. At least 32 characters. |
62
+ | `SEAMLESS_JWKS_KID` | no | The `kid` on the service tokens this service signs. |
63
+ | `SEAMLESS_COOKIE_SECRET` | yes | Signs the first-party session cookies. At least 32 characters. |
64
+ | `OIDC_COOKIE_KEYS` | yes | Comma-separated provider cookie keys. The first signs, the rest verify. |
65
+ | `TRUST_PROXY` | no | The proxies in front of this service, as Express's `trust proxy`: the number of hops (`2` for a CDN in front of a load balancer), or a comma-separated list of proxy addresses and subnets (`10.0.0.0/8, 172.16.0.5`). `true` means one hop. Defaults to `false`. See [Behind a proxy](#behind-a-proxy). |
66
+ | `PORT` | no | Defaults to `5314`. |
67
+
68
+ ### Behind a proxy
69
+
70
+ The client IP this service forwards to the API decides who shares its per-IP rate limits and
71
+ lockouts, and Express reads it from `X-Forwarded-For` through `TRUST_PROXY`. Set it to the
72
+ proxies actually in front of this service: with too few hops every user arrives as the CDN
73
+ node, and with too many the client picks its own address. A list of addresses and subnets
74
+ holds even when the number of hops varies.
75
+
76
+ Make the container port reachable only through the proxy. A client that can reach it directly
77
+ writes the last `X-Forwarded-For` entry and `X-Forwarded-Proto` and `X-Forwarded-Host`
78
+ itself, and that address goes to the API under this service's token.
56
79
 
57
80
  The issuer comes from the API (`oidc_issuer`), not from this service's environment. The service
58
81
  answers only requests for the issuer's own scheme and host, and refuses to start when the API
@@ -105,6 +128,14 @@ and `jti`, with only the scopes the application is allowed there and the resourc
105
128
  lifetime. The API checks the same links again before it signs, so an API configured for one
106
129
  identifier can reject every token minted for another.
107
130
 
131
+ A user's token for a resource also carries the session's `acr`, `amr` and `auth_time` (RFC
132
+ 9068 section 2.2.1), the values the ID token states, and introspection returns them. An API can
133
+ then require a recent phishing-resistant sign-in on a route from the token alone, and answer
134
+ `insufficient_user_authentication` (RFC 9470) when it falls short. A refreshed token carries the
135
+ session's current values, so a step-up shows on the next refresh. The API signs only the values
136
+ the session holds, so the edge cannot raise them. Device-flow and client credentials tokens
137
+ carry none of the three, and an opaque `/userinfo` token gains nothing.
138
+
108
139
  - A resource the application is not linked to is refused with `invalid_target`.
109
140
  - An app that named one resource at authorization gets its token without repeating
110
141
  `resource` at the token endpoint.
@@ -113,6 +144,35 @@ identifier can reject every token minted for another.
113
144
  - First-party apps have the scopes granted with the sign-in. Third-party apps list them on the
114
145
  consent page.
115
146
 
147
+ ## Roles, permissions and custom claims
148
+
149
+ Two scopes release the authorization claims a first-party Seamless token carries (API ADR 0004
150
+ and ADR 0005), under the same names:
151
+
152
+ | Scope | Claims |
153
+ | --------------- | ------------------------------------------------------------------------------------------- |
154
+ | `permissions` | `roles`, `permissions`, `org_id`, `org_roles`, `org_permissions` (or `permissions_omitted`) |
155
+ | `custom_claims` | every custom claim the API resolves for the user and this application |
156
+
157
+ They appear in the ID token and `/userinfo`, and in a user's access token for a resource when the
158
+ grant holds the scope (the access token's own `scope` lists only the resource's scopes). The older
159
+ `seamless` scope still releases `roles` and `org_id` alone.
160
+
161
+ - `org_id` and the `org_*` claims name the session's active organization, while the user is still
162
+ a member of it.
163
+ - Custom claims come from the user's instance roles, then their roles in the active organization,
164
+ then the application's own set (`/admin/custom-claims/oidc_client/:clientId`), then the
165
+ `pre_token` login hook, a later source winning per name. Reserved names are never released.
166
+ - Permission and custom claims share one 1 KiB budget. Over it, the permission claims become
167
+ `"permissions_omitted": true` first (read `GET /users/me/permissions` then), and custom claims
168
+ are dropped whole only when they do not fit beside that marker. Nothing is trimmed in part.
169
+ - The API computes every value and signs only what matches its own computation for the session
170
+ and application, so this service cannot add, raise or forge one. A role change shows on the
171
+ next token or refresh. If it lands between this service's read and the API's sign, the API
172
+ refuses with `claims_mismatch` and this service reads the account again and signs once more; a
173
+ second mismatch answers `server_error`.
174
+ - Client credentials tokens carry none of them.
175
+
116
176
  ## Token lifetimes
117
177
 
118
178
  An application's own `accessTokenTtlSeconds`, `idTokenTtlSeconds` and `refreshTokenTtlSeconds`
@@ -130,9 +190,9 @@ ends every record when the first-party session behind it ends.
130
190
  tokens rotate on every use. A used one presented again revokes the grant and every token
131
191
  under it, and the API ends the first-party session it was issued under. Two refreshes
132
192
  racing with the same token cannot both rotate: the API's consume says which one marked it
133
- used, and the other is handled as that same reuse. A refreshed ID
134
- token carries the session's current `acr`, `amr` and `auth_time`, so a step-up since the
135
- sign-in shows up on the next refresh.
193
+ used, and the other is handled as that same reuse. A refreshed ID token, and a refreshed
194
+ access token for a resource, carry the session's current `acr`, `amr` and `auth_time`, so a
195
+ step-up since the sign-in shows up on the next refresh.
136
196
  - **Client credentials.** A confidential client registered for `client_credentials` gets an
137
197
  RFC 9068 JWT whose `sub` and `client_id` are the client itself, only ever for a resource:
138
198
  the one named with `resource`, or the client's only linked resource. Scopes are cut to what
@@ -171,6 +231,11 @@ ends:
171
231
  adapter cookies. Every other app the user signed in to with it is signed out too.
172
232
  - **Only sign out of** the asking app ends that app's grant and keeps the session.
173
233
 
234
+ When the browser has no provider sign-in there is nothing to confirm, and the library ends its
235
+ empty provider session without a page. The first-party session and the adapter cookies are
236
+ left alone then, so a site that sends a browser to `/session/end` cannot sign it out of the
237
+ hosted pages.
238
+
174
239
  Back-channel logout (OpenID Back-Channel Logout 1.0) is sent by the API, not by this service.
175
240
  The API holds the keys and sees every way a session ends (this logout, admin revoke, sign-out
176
241
  everywhere, eviction), so a client with a `backchannelLogoutUri` gets a `logout+jwt` token
@@ -192,9 +257,10 @@ off unless an admin enables the device flow on a first-party application (ADR D1
192
257
  section 5.4). Every approval signs in again, so a browser that is already signed in still
193
258
  confirms with the passkey. Code entry is limited to 10 a minute per address and 600 a
194
259
  minute per instance.
195
- - **Assurance.** Device-flow tokens never carry `acr`, after a refresh too, and device requests
196
- with `acr_values` or `max_age` are refused. The passkey proves who approved, not who holds
197
- the device. The API refuses `acr` on any token whose sign request names the device grant.
260
+ - **Assurance.** Device-flow tokens never carry `acr`, `amr` or `auth_time`, after a refresh
261
+ too, and device requests with `acr_values` or `max_age` are refused. The passkey proves who
262
+ approved, not who holds the device. The API refuses all three on any token whose sign request
263
+ names the device grant.
198
264
 
199
265
  ## Security defaults
200
266
 
@@ -228,6 +294,19 @@ same way any Seamless app does, then finish the OIDC interaction.
228
294
  screens. A recovery session goes to enrollment before it can finish a sign-in.
229
295
  - **Consent.** First-party clients have consent implied. Third-party clients see the client
230
296
  name, the host they are sent back to and the requested scopes, on every authorization.
297
+ - **Native apps.** Any program on a device can listen on a loopback port or claim a private-use
298
+ scheme, so every request from a native client (`application_type: native`), first-party
299
+ included, waits for the user to click "Continue to <app>" (RFC 8252 section 8.6). The card names
300
+ the client and the account, as `account.identifier` (an email or phone number) from
301
+ `GET /interaction/:uid/details`, and that field is `null` unless this browser is signed in as the
302
+ interaction's account and the API can describe it. With no account to name, the page offers only
303
+ Cancel. The consent route refuses a native prompt with `403 confirmation_required` unless the
304
+ page sends `{ "confirmed": true }` from that click. Device-flow approvals are confirmed on the
305
+ device pages instead, before sign-in, so those name the client and the user code only.
306
+ - **Custom pages.** `hosted_ui` only brands these pages. If you replace them with your own (the
307
+ `uiDir` option), your confirmation must do the same: show `account.identifier` beside the client
308
+ name as text, send `{ "confirmed": true }` only from a real click, and not offer to continue when
309
+ `account` is `null`. The edge cannot check any of this for you.
231
310
  - **Branding.** The app name comes from the API's config each time a page is served, so a
232
311
  config change needs no build and no restart.
233
312
  - **Headers.** A strict CSP (`script-src 'self'`, styles from this origin or with the
@@ -0,0 +1,29 @@
1
+ import type { KoaContextWithOIDC } from 'oidc-provider';
2
+ interface IssuedToken {
3
+ kind?: unknown;
4
+ gty?: unknown;
5
+ scope?: unknown;
6
+ resourceServer?: unknown;
7
+ }
8
+ /**
9
+ * ADR 0002 N7: a user's access token for a resource carries `acr`, `amr` and `auth_time`
10
+ * (RFC 9068 section 2.2.1), so a resource server can require step-up from the token alone.
11
+ * They come from the code or refresh token the grant redeems, the same values the ID token
12
+ * states, which the adapter took from the API's auth context. Introspection returns them too,
13
+ * because the library stores these claims with the token.
14
+ *
15
+ * A device-flow token states none (ADR 0001 D10), a client credentials token has no user, and
16
+ * an opaque token for userinfo gains nothing.
17
+ */
18
+ export declare function accessTokenAuthContext(ctx: KoaContextWithOIDC | undefined, token: IssuedToken): Record<string, unknown> | undefined;
19
+ /**
20
+ * seamless-auth-oidc-provider#47: a user's access token for a resource carries the roles,
21
+ * permissions and custom claims a first-party token does, when the client was granted
22
+ * `permissions` or `custom_claims`. The token's own `scope` lists only the resource's scopes, so
23
+ * the grant is read from the code or refresh token it redeems. The values are the ones the
24
+ * library loaded for this request, which `/oidc/sign` computes again before it signs. A device
25
+ * flow token carries them too: they describe the user, not how the sign-in was made.
26
+ */
27
+ export declare function accessTokenAuthorization(ctx: KoaContextWithOIDC | undefined, token: IssuedToken): Record<string, unknown> | undefined;
28
+ export declare function accessTokenClaims(ctx: KoaContextWithOIDC | undefined, token: IssuedToken): Record<string, unknown> | undefined;
29
+ export {};
@@ -0,0 +1,67 @@
1
+ /*
2
+ * Copyright © 2026 Fells Code, LLC
3
+ * Licensed under the Apache License, Version 2.0
4
+ * See LICENSE file in the project root for full license information
5
+ */
6
+ import { SESSION_EXCHANGE_SCOPE } from '@seamless-auth/types';
7
+ import { authorizationClaimsFor, providerAccountOf } from './accountClaims.js';
8
+ import { isDeviceGrant } from './deviceFlow.js';
9
+ import { currentRequestContext } from './requestContext.js';
10
+ /**
11
+ * ADR 0002 N7: a user's access token for a resource carries `acr`, `amr` and `auth_time`
12
+ * (RFC 9068 section 2.2.1), so a resource server can require step-up from the token alone.
13
+ * They come from the code or refresh token the grant redeems, the same values the ID token
14
+ * states, which the adapter took from the API's auth context. Introspection returns them too,
15
+ * because the library stores these claims with the token.
16
+ *
17
+ * A device-flow token states none (ADR 0001 D10), a client credentials token has no user, and
18
+ * an opaque token for userinfo gains nothing.
19
+ */
20
+ export function accessTokenAuthContext(ctx, token) {
21
+ if (!ctx?.oidc ||
22
+ token.kind !== 'AccessToken' ||
23
+ !token.resourceServer ||
24
+ isDeviceGrant(token) ||
25
+ currentRequestContext()?.deviceFlow) {
26
+ return undefined;
27
+ }
28
+ const entities = ctx.oidc.entities;
29
+ const source = entities.RefreshToken ?? entities.AuthorizationCode;
30
+ if (!source) {
31
+ return undefined;
32
+ }
33
+ return {
34
+ ...(source.acr ? { acr: source.acr } : {}),
35
+ ...(source.amr && source.amr.length > 0 ? { amr: [...source.amr] } : {}),
36
+ ...(typeof source.authTime === 'number' ? { auth_time: source.authTime } : {}),
37
+ };
38
+ }
39
+ /**
40
+ * seamless-auth-oidc-provider#47: a user's access token for a resource carries the roles,
41
+ * permissions and custom claims a first-party token does, when the client was granted
42
+ * `permissions` or `custom_claims`. The token's own `scope` lists only the resource's scopes, so
43
+ * the grant is read from the code or refresh token it redeems. The values are the ones the
44
+ * library loaded for this request, which `/oidc/sign` computes again before it signs. A device
45
+ * flow token carries them too: they describe the user, not how the sign-in was made.
46
+ */
47
+ export function accessTokenAuthorization(ctx, token) {
48
+ if (!ctx?.oidc || token.kind !== 'AccessToken' || !token.resourceServer) {
49
+ return undefined;
50
+ }
51
+ if (typeof token.scope === 'string' && token.scope.split(' ').includes(SESSION_EXCHANGE_SCOPE)) {
52
+ return undefined;
53
+ }
54
+ const account = providerAccountOf(ctx.oidc.account);
55
+ const entities = ctx.oidc.entities;
56
+ const grant = entities.RefreshToken ?? entities.AuthorizationCode ?? entities.DeviceCode;
57
+ if (!account || !grant) {
58
+ return undefined;
59
+ }
60
+ return authorizationClaimsFor(account, grant.scope);
61
+ }
62
+ export function accessTokenClaims(ctx, token) {
63
+ const authorization = accessTokenAuthorization(ctx, token);
64
+ const context = accessTokenAuthContext(ctx, token);
65
+ return authorization || context ? { ...authorization, ...context } : undefined;
66
+ }
67
+ //# sourceMappingURL=accessTokenClaims.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accessTokenClaims.js","sourceRoot":"","sources":["../src/accessTokenClaims.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAG9D,OAAO,EAAE,sBAAsB,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC/E,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAChD,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAgB5D;;;;;;;;;GASG;AACH,MAAM,UAAU,sBAAsB,CACpC,GAAmC,EACnC,KAAkB;IAElB,IACE,CAAC,GAAG,EAAE,IAAI;QACV,KAAK,CAAC,IAAI,KAAK,aAAa;QAC5B,CAAC,KAAK,CAAC,cAAc;QACrB,aAAa,CAAC,KAAK,CAAC;QACpB,qBAAqB,EAAE,EAAE,UAAU,EACnC,CAAC;QACD,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,CAAC,QAGzB,CAAC;IACF,MAAM,MAAM,GAAG,QAAQ,CAAC,YAAY,IAAI,QAAQ,CAAC,iBAAiB,CAAC;IACnE,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO;QACL,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,MAAM,CAAC,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,GAAG,MAAM,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QACxE,GAAG,CAAC,OAAO,MAAM,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KAC/E,CAAC;AACJ,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,wBAAwB,CACtC,GAAmC,EACnC,KAAkB;IAElB,IAAI,CAAC,GAAG,EAAE,IAAI,IAAI,KAAK,CAAC,IAAI,KAAK,aAAa,IAAI,CAAC,KAAK,CAAC,cAAc,EAAE,CAAC;QACxE,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,IAAI,OAAO,KAAK,CAAC,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,sBAAsB,CAAC,EAAE,CAAC;QAC/F,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,MAAM,OAAO,GAAG,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;IACpD,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,CAAC,QAIzB,CAAC;IACF,MAAM,KAAK,GAAG,QAAQ,CAAC,YAAY,IAAI,QAAQ,CAAC,iBAAiB,IAAI,QAAQ,CAAC,UAAU,CAAC;IACzF,IAAI,CAAC,OAAO,IAAI,CAAC,KAAK,EAAE,CAAC;QACvB,OAAO,SAAS,CAAC;IACnB,CAAC;IACD,OAAO,sBAAsB,CAAC,OAAO,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,MAAM,UAAU,iBAAiB,CAC/B,GAAmC,EACnC,KAAkB;IAElB,MAAM,aAAa,GAAG,wBAAwB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IAC3D,MAAM,OAAO,GAAG,sBAAsB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACnD,OAAO,aAAa,IAAI,OAAO,CAAC,CAAC,CAAC,EAAE,GAAG,aAAa,EAAE,GAAG,OAAO,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC;AACjF,CAAC"}
@@ -0,0 +1,51 @@
1
+ import { type OidcAccountClaims } from '@seamless-auth/types';
2
+ import type { Account, Provider } from 'oidc-provider';
3
+ /**
4
+ * What `/oidc/accounts/:id` returns since seamless-auth-oidc-provider#47: the ADR 0004
5
+ * permission claims under their token names, and the merged custom claims, all computed by the
6
+ * API for this session and client and already held to the shared budget.
7
+ */
8
+ export declare const OidcProviderAccountSchema: import("zod").ZodObject<{
9
+ sub: import("zod").ZodString;
10
+ email: import("zod").ZodOptional<import("zod").ZodString>;
11
+ email_verified: import("zod").ZodOptional<import("zod").ZodBoolean>;
12
+ phone_number: import("zod").ZodOptional<import("zod").ZodString>;
13
+ phone_number_verified: import("zod").ZodOptional<import("zod").ZodBoolean>;
14
+ roles: import("zod").ZodArray<import("zod").ZodString>;
15
+ org_id: import("zod").ZodNullable<import("zod").ZodString>;
16
+ permissions: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
17
+ permissions_omitted: import("zod").ZodOptional<import("zod").ZodLiteral<true>>;
18
+ org_roles: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
19
+ org_permissions: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
20
+ customClaims: import("zod").ZodDefault<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>]>>]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>, import("zod").ZodArray<import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>, import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnion<readonly [import("zod").ZodString, import("zod").ZodNumber, import("zod").ZodBoolean, import("zod").ZodNull]>>]>>]>>]>>>;
21
+ }, import("zod/v4/core").$strip>;
22
+ export type OidcProviderAccount = OidcAccountClaims;
23
+ /** Roles, permissions and the active organization's roles, as a first-party token has them. */
24
+ export declare const PERMISSIONS_SCOPE = "permissions";
25
+ /** Every custom claim the API resolved for the user and this client. */
26
+ export declare const CUSTOM_CLAIMS_SCOPE = "custom_claims";
27
+ /** `/oidc/sign` signs these together or not at all, since a partial list looks complete. */
28
+ export declare const PERMISSION_CLAIMS: readonly ["permissions", "permissions_omitted", "org_roles", "org_permissions"];
29
+ export declare const PERMISSIONS_SCOPE_CLAIMS: string[];
30
+ declare const CUSTOM_CLAIMS: unique symbol;
31
+ /** Reserved names are dropped here too, whatever the API sent, so none can replace a claim. */
32
+ export declare function customClaimsOf(account: OidcProviderAccount): Record<string, unknown>;
33
+ /** The claims the library filters by scope, with the custom claims kept aside for after it. */
34
+ export declare function accountClaimsOf(account: OidcProviderAccount): {
35
+ sub: string;
36
+ [CUSTOM_CLAIMS]: Record<string, unknown>;
37
+ };
38
+ export declare function withAccount(account: Account, claims: OidcProviderAccount): Account;
39
+ export declare function providerAccountOf(account: unknown): OidcProviderAccount | undefined;
40
+ /** The authorization claims a scope set releases, by the names `/oidc/sign` expects. */
41
+ export declare function authorizationClaimsFor(account: OidcProviderAccount, scope: unknown): Record<string, unknown>;
42
+ /**
43
+ * The library releases only claims named in its static `claims` map, and custom claim names are
44
+ * the adopter's. ID tokens and userinfo both build their payload through `provider.Claims`, so
45
+ * this adds the account's custom claims there when `custom_claims` was granted, under every
46
+ * claim the library already chose. It also keeps the permission claims whole: a `claims`
47
+ * request or a rejection that asks for part of them gets none, rather than a token the API
48
+ * refuses to sign.
49
+ */
50
+ export declare function releaseAuthorizationClaims(provider: Provider): void;
51
+ export {};
@@ -0,0 +1,102 @@
1
+ /*
2
+ * Copyright © 2026 Fells Code, LLC
3
+ * Licensed under the Apache License, Version 2.0
4
+ * See LICENSE file in the project root for full license information
5
+ */
6
+ import { isReservedClaimName, OidcAccountClaimsSchema, } from '@seamless-auth/types';
7
+ /**
8
+ * What `/oidc/accounts/:id` returns since seamless-auth-oidc-provider#47: the ADR 0004
9
+ * permission claims under their token names, and the merged custom claims, all computed by the
10
+ * API for this session and client and already held to the shared budget.
11
+ */
12
+ export const OidcProviderAccountSchema = OidcAccountClaimsSchema;
13
+ /** Roles, permissions and the active organization's roles, as a first-party token has them. */
14
+ export const PERMISSIONS_SCOPE = 'permissions';
15
+ /** Every custom claim the API resolved for the user and this client. */
16
+ export const CUSTOM_CLAIMS_SCOPE = 'custom_claims';
17
+ /** `/oidc/sign` signs these together or not at all, since a partial list looks complete. */
18
+ export const PERMISSION_CLAIMS = [
19
+ 'permissions',
20
+ 'permissions_omitted',
21
+ 'org_roles',
22
+ 'org_permissions',
23
+ ];
24
+ export const PERMISSIONS_SCOPE_CLAIMS = ['roles', 'org_id', ...PERMISSION_CLAIMS];
25
+ // A symbol, so the library's own copies of the claims carry it while no claim filter can
26
+ // release it under a name.
27
+ const CUSTOM_CLAIMS = Symbol('seamless.customClaims');
28
+ const ACCOUNT_PROPERTY = 'seamlessAccount';
29
+ /** Reserved names are dropped here too, whatever the API sent, so none can replace a claim. */
30
+ export function customClaimsOf(account) {
31
+ return Object.fromEntries(Object.entries(account.customClaims).filter(([name]) => !isReservedClaimName(name)));
32
+ }
33
+ function standardClaimsOf(account) {
34
+ const { org_id, ...rest } = account;
35
+ delete rest.customClaims;
36
+ return { ...rest, ...(org_id ? { org_id } : {}) };
37
+ }
38
+ /** The claims the library filters by scope, with the custom claims kept aside for after it. */
39
+ export function accountClaimsOf(account) {
40
+ return {
41
+ ...standardClaimsOf(account),
42
+ sub: account.sub,
43
+ [CUSTOM_CLAIMS]: customClaimsOf(account),
44
+ };
45
+ }
46
+ export function withAccount(account, claims) {
47
+ return Object.assign(account, { [ACCOUNT_PROPERTY]: claims });
48
+ }
49
+ export function providerAccountOf(account) {
50
+ const value = account?.[ACCOUNT_PROPERTY];
51
+ return value ? value : undefined;
52
+ }
53
+ function scopesOf(scope) {
54
+ return new Set(typeof scope === 'string' ? scope.split(' ') : []);
55
+ }
56
+ /** The authorization claims a scope set releases, by the names `/oidc/sign` expects. */
57
+ export function authorizationClaimsFor(account, scope) {
58
+ const scopes = scopesOf(scope);
59
+ const standard = standardClaimsOf(account);
60
+ return {
61
+ ...(scopes.has(CUSTOM_CLAIMS_SCOPE) ? customClaimsOf(account) : {}),
62
+ ...(scopes.has(PERMISSIONS_SCOPE)
63
+ ? Object.fromEntries(PERMISSIONS_SCOPE_CLAIMS.filter((name) => name in standard).map((name) => [
64
+ name,
65
+ standard[name],
66
+ ]))
67
+ : {}),
68
+ };
69
+ }
70
+ const maskScopes = new WeakMap();
71
+ /**
72
+ * The library releases only claims named in its static `claims` map, and custom claim names are
73
+ * the adopter's. ID tokens and userinfo both build their payload through `provider.Claims`, so
74
+ * this adds the account's custom claims there when `custom_claims` was granted, under every
75
+ * claim the library already chose. It also keeps the permission claims whole: a `claims`
76
+ * request or a rejection that asks for part of them gets none, rather than a token the API
77
+ * refuses to sign.
78
+ */
79
+ export function releaseAuthorizationClaims(provider) {
80
+ const prototype = provider.Claims.prototype;
81
+ const scope = prototype.scope;
82
+ const result = prototype.result;
83
+ prototype.scope = function (value) {
84
+ maskScopes.set(this, value ?? '');
85
+ return scope.call(this, value);
86
+ };
87
+ prototype.result = async function () {
88
+ const claims = await result.call(this);
89
+ const present = PERMISSION_CLAIMS.filter((name) => name in this.available);
90
+ if (present.some((name) => !(name in claims))) {
91
+ for (const name of present) {
92
+ delete claims[name];
93
+ }
94
+ }
95
+ const custom = this.available[CUSTOM_CLAIMS];
96
+ if (!custom || !scopesOf(maskScopes.get(this)).has(CUSTOM_CLAIMS_SCOPE)) {
97
+ return claims;
98
+ }
99
+ return { ...custom, ...claims };
100
+ };
101
+ }
102
+ //# sourceMappingURL=accountClaims.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"accountClaims.js","sourceRoot":"","sources":["../src/accountClaims.ts"],"names":[],"mappings":"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"}
package/dist/adapter.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { randomBytes } from 'node:crypto';
7
7
  import { OidcPayloadModelSchema, } from '@seamless-auth/types';
8
- import { errors } from 'oidc-provider';
8
+ import Provider, { errors } from 'oidc-provider';
9
9
  import { checkDeviceRequest, DEVICE_CODE_GRANT, DevicePollThrottle, isDeviceGrant, withLoginPrompt, } from './deviceFlow.js';
10
10
  import { adoptSession, currentRequestContext, markDeviceFlow, } from './requestContext.js';
11
11
  /** A client assertion or proof presented twice. Answered as `invalid_client`, not a 500. */
@@ -31,6 +31,11 @@ export class UnsupportedModelError extends Error {
31
31
  // made under, so they only fill an empty context. Every other record is the thing the request
32
32
  // is acting on.
33
33
  const CONTEXTUAL_MODELS = new Set(['Session', 'Grant']);
34
+ /** The library's own test (`BaseToken#isExpired`): `exp` at or before now, in seconds. */
35
+ function isExpired(record) {
36
+ const { exp } = record.payload;
37
+ return typeof exp === 'number' && exp <= Math.floor(Date.now() / 1000);
38
+ }
34
39
  function toAdapterPayload(model, record) {
35
40
  if (!record) {
36
41
  return undefined;
@@ -82,6 +87,8 @@ export class ApiPayloadAdapter {
82
87
  }
83
88
  // ADR D10: never carried into the device's tokens, whatever the approver earned.
84
89
  delete stored.acr;
90
+ delete stored.amr;
91
+ delete stored.authTime;
85
92
  }
86
93
  const { created } = await this.#client.upsertPayload(model, id, {
87
94
  payload: stored,
@@ -99,8 +106,16 @@ export class ApiPayloadAdapter {
99
106
  const context = currentRequestContext();
100
107
  const record = await this.#client.findPayload(model, id);
101
108
  // The grant loads the code more than once per request, and only the first is the poll.
102
- // Only a code that exists is tracked, so made-up ones cannot fill the throttle.
103
- if (model === 'DeviceCode' && record && context?.tokenRequest && !context.devicePolled) {
109
+ // Only a live code that exists is tracked, so made-up ones cannot fill the throttle, and
110
+ // an expired one the API still returns gets the library's expired_token, not slow_down.
111
+ // The library's own routing says whether this is the token endpoint, for every spelling
112
+ // of the path it accepts.
113
+ if (model === 'DeviceCode' &&
114
+ record &&
115
+ !isExpired(record) &&
116
+ context &&
117
+ !context.devicePolled &&
118
+ Provider.ctx?.oidc?.route === 'token') {
104
119
  context.devicePolled = true;
105
120
  this.#polls.check(id);
106
121
  }
@@ -124,9 +139,15 @@ export class ApiPayloadAdapter {
124
139
  /**
125
140
  * A refresh carries the acr, amr and auth_time of the session's current row, not the ones
126
141
  * the token was first issued with: a step-up since then moved them, and `/oidc/sign`
127
- * refuses any value that differs from the row.
142
+ * refuses any value that differs from the row. A device grant carries none (ADR D10).
128
143
  */
129
144
  async #adoptCurrentAuthContext(payload, sessionId) {
145
+ if (isDeviceGrant(payload)) {
146
+ delete payload.acr;
147
+ delete payload.amr;
148
+ delete payload.authTime;
149
+ return;
150
+ }
130
151
  const { clientId, accountId } = payload;
131
152
  if (typeof clientId !== 'string') {
132
153
  return;
@@ -136,7 +157,7 @@ export class ApiPayloadAdapter {
136
157
  return;
137
158
  }
138
159
  payload.authTime = context.authTime;
139
- if (context.acr && !isDeviceGrant(payload)) {
160
+ if (context.acr) {
140
161
  payload.acr = context.acr;
141
162
  }
142
163
  else {