@_mustachio/openauth 0.10.0 → 0.11.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 (151) hide show
  1. package/dist/esm/adapters/memory/session-store.js +22 -0
  2. package/dist/esm/adapters/memory/token-store.js +13 -0
  3. package/dist/esm/client.js +9 -2
  4. package/dist/esm/domain/authorize.js +5 -0
  5. package/dist/esm/domain/callback.js +3 -0
  6. package/dist/esm/domain/discovery.js +44 -2
  7. package/dist/esm/domain/dpop.js +122 -0
  8. package/dist/esm/domain/id-token.js +94 -0
  9. package/dist/esm/domain/introspect.js +5 -1
  10. package/dist/esm/domain/jwt.js +28 -0
  11. package/dist/esm/domain/logout.js +75 -0
  12. package/dist/esm/domain/method-route.js +3 -0
  13. package/dist/esm/domain/par.js +51 -0
  14. package/dist/esm/domain/refresh.js +12 -1
  15. package/dist/esm/domain/register.js +74 -0
  16. package/dist/esm/domain/token-exchange.js +1 -0
  17. package/dist/esm/domain/token.js +48 -7
  18. package/dist/esm/domain/userinfo.js +38 -3
  19. package/dist/esm/http/handlers/authorize.js +90 -3
  20. package/dist/esm/http/handlers/end-session.js +64 -0
  21. package/dist/esm/http/handlers/metadata.js +5 -2
  22. package/dist/esm/http/handlers/par.js +53 -0
  23. package/dist/esm/http/handlers/register.js +46 -0
  24. package/dist/esm/http/handlers/token.js +36 -6
  25. package/dist/esm/http/handlers/userinfo.js +35 -29
  26. package/dist/esm/http/router.js +10 -0
  27. package/dist/esm/http/schemas/authorize.js +7 -1
  28. package/dist/esm/http/schemas/end-session.js +13 -0
  29. package/dist/esm/http/schemas/par.js +9 -0
  30. package/dist/esm/http/schemas/register.js +16 -0
  31. package/dist/esm/index.js +7 -2
  32. package/dist/esm/types/error.js +5 -0
  33. package/dist/types/adapters/memory/session-store.d.ts +3 -1
  34. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  35. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  36. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  37. package/dist/types/client.d.ts +19 -0
  38. package/dist/types/client.d.ts.map +1 -1
  39. package/dist/types/domain/authorize.d.ts.map +1 -1
  40. package/dist/types/domain/client-credentials.d.ts +2 -0
  41. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  42. package/dist/types/domain/discovery.d.ts +37 -0
  43. package/dist/types/domain/discovery.d.ts.map +1 -1
  44. package/dist/types/domain/dpop.d.ts +61 -0
  45. package/dist/types/domain/dpop.d.ts.map +1 -0
  46. package/dist/types/domain/id-token.d.ts +83 -0
  47. package/dist/types/domain/id-token.d.ts.map +1 -0
  48. package/dist/types/domain/introspect.d.ts +8 -0
  49. package/dist/types/domain/introspect.d.ts.map +1 -1
  50. package/dist/types/domain/jwt.d.ts +27 -1
  51. package/dist/types/domain/jwt.d.ts.map +1 -1
  52. package/dist/types/domain/logout.d.ts +69 -0
  53. package/dist/types/domain/logout.d.ts.map +1 -0
  54. package/dist/types/domain/par.d.ts +62 -0
  55. package/dist/types/domain/par.d.ts.map +1 -0
  56. package/dist/types/domain/refresh.d.ts +9 -0
  57. package/dist/types/domain/refresh.d.ts.map +1 -1
  58. package/dist/types/domain/register.d.ts +33 -0
  59. package/dist/types/domain/register.d.ts.map +1 -0
  60. package/dist/types/domain/token-exchange.d.ts +2 -0
  61. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  62. package/dist/types/domain/token.d.ts +41 -1
  63. package/dist/types/domain/token.d.ts.map +1 -1
  64. package/dist/types/domain/userinfo.d.ts +44 -5
  65. package/dist/types/domain/userinfo.d.ts.map +1 -1
  66. package/dist/types/http/context.d.ts +4 -1
  67. package/dist/types/http/context.d.ts.map +1 -1
  68. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  69. package/dist/types/http/handlers/end-session.d.ts +3 -0
  70. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  71. package/dist/types/http/handlers/metadata.d.ts +1 -1
  72. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  73. package/dist/types/http/handlers/par.d.ts +3 -0
  74. package/dist/types/http/handlers/par.d.ts.map +1 -0
  75. package/dist/types/http/handlers/register.d.ts +3 -0
  76. package/dist/types/http/handlers/register.d.ts.map +1 -0
  77. package/dist/types/http/handlers/token.d.ts.map +1 -1
  78. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  79. package/dist/types/http/router.d.ts.map +1 -1
  80. package/dist/types/http/schemas/authorize.d.ts +40 -0
  81. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  82. package/dist/types/http/schemas/end-session.d.ts +31 -0
  83. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  84. package/dist/types/http/schemas/par.d.ts +20 -0
  85. package/dist/types/http/schemas/par.d.ts.map +1 -0
  86. package/dist/types/http/schemas/register.d.ts +42 -0
  87. package/dist/types/http/schemas/register.d.ts.map +1 -0
  88. package/dist/types/index.d.ts +3 -3
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/types/ports/audit-log.d.ts +27 -0
  91. package/dist/types/ports/audit-log.d.ts.map +1 -1
  92. package/dist/types/ports/session-store.d.ts +27 -0
  93. package/dist/types/ports/session-store.d.ts.map +1 -1
  94. package/dist/types/ports/token-store.d.ts +12 -0
  95. package/dist/types/ports/token-store.d.ts.map +1 -1
  96. package/dist/types/types/authorization.d.ts +23 -0
  97. package/dist/types/types/authorization.d.ts.map +1 -1
  98. package/dist/types/types/error.d.ts +9 -0
  99. package/dist/types/types/error.d.ts.map +1 -1
  100. package/dist/types/types/flow.d.ts +10 -0
  101. package/dist/types/types/flow.d.ts.map +1 -1
  102. package/dist/types/types/idp.d.ts +91 -1
  103. package/dist/types/types/idp.d.ts.map +1 -1
  104. package/dist/types/types/tenant.d.ts +37 -0
  105. package/dist/types/types/tenant.d.ts.map +1 -1
  106. package/dist/types/types/token.d.ts +128 -0
  107. package/dist/types/types/token.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/adapters/memory/session-store.ts +44 -1
  110. package/src/adapters/memory/token-store.ts +16 -0
  111. package/src/client.ts +32 -0
  112. package/src/domain/authorize.ts +9 -0
  113. package/src/domain/callback.ts +5 -0
  114. package/src/domain/client-credentials.ts +2 -0
  115. package/src/domain/discovery.ts +80 -2
  116. package/src/domain/dpop.ts +293 -0
  117. package/src/domain/id-token.ts +213 -0
  118. package/src/domain/introspect.ts +10 -0
  119. package/src/domain/jwt.ts +80 -1
  120. package/src/domain/logout.ts +180 -0
  121. package/src/domain/method-route.ts +5 -0
  122. package/src/domain/par.ts +141 -0
  123. package/src/domain/refresh.ts +41 -0
  124. package/src/domain/register.ts +168 -0
  125. package/src/domain/token-exchange.ts +11 -0
  126. package/src/domain/token.ts +134 -8
  127. package/src/domain/userinfo.ts +121 -6
  128. package/src/http/context.ts +4 -0
  129. package/src/http/handlers/authorize.ts +144 -3
  130. package/src/http/handlers/end-session.ts +106 -0
  131. package/src/http/handlers/metadata.ts +7 -2
  132. package/src/http/handlers/par.ts +84 -0
  133. package/src/http/handlers/register.ts +70 -0
  134. package/src/http/handlers/token.ts +56 -0
  135. package/src/http/handlers/userinfo.ts +62 -38
  136. package/src/http/router.ts +19 -0
  137. package/src/http/schemas/authorize.ts +27 -0
  138. package/src/http/schemas/end-session.ts +23 -0
  139. package/src/http/schemas/par.ts +23 -0
  140. package/src/http/schemas/register.ts +29 -0
  141. package/src/index.ts +18 -1
  142. package/src/ports/CONSISTENCY.md +52 -21
  143. package/src/ports/audit-log.ts +29 -0
  144. package/src/ports/session-store.ts +33 -0
  145. package/src/ports/token-store.ts +13 -0
  146. package/src/types/authorization.ts +25 -0
  147. package/src/types/error.ts +23 -0
  148. package/src/types/flow.ts +10 -0
  149. package/src/types/idp.ts +96 -0
  150. package/src/types/tenant.ts +37 -0
  151. package/src/types/token.ts +135 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * `/end_session` endpoint logic — OIDC RP-Initiated Logout 1.0.
3
+ *
4
+ * The OP validates the optional `id_token_hint`, validates the optional
5
+ * `post_logout_redirect_uri` against the client's registered list,
6
+ * revokes the subject's refresh tokens (when a subject is identified),
7
+ * and either redirects the user-agent back to the RP with `state` echoed
8
+ * or returns a generic "logged out" acknowledgement.
9
+ *
10
+ * Per §2:
11
+ * - `id_token_hint` (RECOMMENDED): a previously-issued id_token. The
12
+ * OP MUST verify the signature; expiry MAY be tolerated because
13
+ * logout commonly follows token expiry. We accept expired hints but
14
+ * enforce signature + issuer.
15
+ * - `client_id` (OPTIONAL): when both this and `id_token_hint` are
16
+ * present, the OP MUST verify the client_id matches the hint's `aud`.
17
+ * - `post_logout_redirect_uri` (OPTIONAL): MUST be exact-match against
18
+ * the resolved client's `postLogoutRedirectUris`. If the URI is not
19
+ * registered, the OP MUST NOT redirect — surface a non-redirecting
20
+ * error instead, otherwise the endpoint becomes an open redirector.
21
+ * - `state` (OPTIONAL): echoed back in the redirect.
22
+ *
23
+ * Front-channel / back-channel logout (RP notification of OP-initiated
24
+ * logout) is out of scope for this minimal endpoint. Hosts that need it
25
+ * can layer their own notification fan-out on top of `session_logout`
26
+ * audit events.
27
+ */
28
+ import type { AuditLog } from "../ports/audit-log"
29
+ import type { ConfigStore } from "../ports/config-store"
30
+ import type { KeyStore } from "../ports/key-store"
31
+ import type { TokenStore } from "../ports/token-store"
32
+ import { authError, type AuthError } from "../types/error"
33
+ import type { Result } from "../types/result"
34
+ import { err, isErr, ok } from "../types/result"
35
+ import type { ClientConfig, TenantContext } from "../types/tenant"
36
+
37
+ import { safeAudit } from "./audit"
38
+ import { verifyIdToken } from "./jwt"
39
+ import { revokeAllForSubject } from "./revoke"
40
+
41
+ export type EndSessionRequest = {
42
+ idTokenHint?: string
43
+ clientId?: string
44
+ postLogoutRedirectUri?: string
45
+ state?: string
46
+ }
47
+
48
+ export type EndSessionDeps = {
49
+ configStore: ConfigStore
50
+ tokenStore: TokenStore
51
+ keyStore: KeyStore
52
+ auditLog?: AuditLog
53
+ issuerUrl: string
54
+ clock: () => number
55
+ }
56
+
57
+ export type EndSessionOutput =
58
+ /** Redirect the user-agent back to the RP. */
59
+ | { kind: "redirect"; url: string }
60
+ /**
61
+ * No `post_logout_redirect_uri` was supplied (or it wasn't registered).
62
+ * Caller should render a minimal "logged out" page or return 200.
63
+ */
64
+ | { kind: "ok"; subjectId?: string }
65
+
66
+ /**
67
+ * Process an `/end_session` request and return an outcome the HTTP
68
+ * adapter can translate into a `Response`. Pure domain — no Hono types,
69
+ * no `Response` construction here.
70
+ */
71
+ export async function endSession(
72
+ req: EndSessionRequest,
73
+ tenant: TenantContext,
74
+ deps: EndSessionDeps,
75
+ ): Promise<Result<EndSessionOutput, AuthError>> {
76
+ // 1. Verify the id_token_hint (if present). Tolerate expiry per §2.
77
+ let hintSubject: string | undefined
78
+ let hintAud: string | undefined
79
+ if (req.idTokenHint) {
80
+ const keysRes = await deps.keyStore.signingKeys()
81
+ if (isErr(keysRes)) return err(keysRes.error)
82
+ try {
83
+ const claims = await verifyIdToken(req.idTokenHint, keysRes.value, {
84
+ issuer: deps.issuerUrl,
85
+ acceptExpired: true,
86
+ })
87
+ hintSubject = claims.sub
88
+ hintAud = claims.aud
89
+ } catch (e) {
90
+ const reason = e instanceof Error ? e.message : String(e)
91
+ return err(
92
+ authError.invalidRequest(
93
+ `id_token_hint failed signature/issuer verification: ${reason}`,
94
+ "id_token_hint",
95
+ ),
96
+ )
97
+ }
98
+ }
99
+
100
+ // 2. If both client_id and id_token_hint are present, they MUST agree
101
+ // (§2). Disagreement is `invalid_request` — never a redirect.
102
+ if (req.clientId && hintAud && req.clientId !== hintAud) {
103
+ return err(
104
+ authError.invalidRequest(
105
+ "client_id does not match id_token_hint.aud",
106
+ "client_id",
107
+ ),
108
+ )
109
+ }
110
+
111
+ // 3. Resolve the client for redirect-URI validation. Prefer explicit
112
+ // client_id, fall back to the hint's aud.
113
+ const resolvedClientId = req.clientId ?? hintAud
114
+ let client: ClientConfig | undefined
115
+ if (resolvedClientId) {
116
+ client = tenant.config.clients.find((c) => c.id === resolvedClientId)
117
+ if (!client) {
118
+ return err(
119
+ authError.invalidRequest(
120
+ `unknown client "${resolvedClientId}"`,
121
+ "client_id",
122
+ ),
123
+ )
124
+ }
125
+ }
126
+
127
+ // 4. Validate post_logout_redirect_uri. MUST be exact-match against
128
+ // the client's registered list (§2). No client → no validatable URI.
129
+ if (req.postLogoutRedirectUri) {
130
+ if (!client) {
131
+ return err(
132
+ authError.invalidRequest(
133
+ "post_logout_redirect_uri requires identifying the client via client_id or id_token_hint",
134
+ "post_logout_redirect_uri",
135
+ ),
136
+ )
137
+ }
138
+ const registered = client.postLogoutRedirectUris ?? []
139
+ if (!registered.includes(req.postLogoutRedirectUri)) {
140
+ return err(
141
+ authError.invalidRequest(
142
+ "post_logout_redirect_uri is not registered for this client",
143
+ "post_logout_redirect_uri",
144
+ ),
145
+ )
146
+ }
147
+ }
148
+
149
+ // 5. Revoke the subject's tokens. Only possible when an id_token_hint
150
+ // identified a subject. Without a hint, /end_session is essentially
151
+ // a session-cookie clear (which the framework doesn't yet own); we
152
+ // still return a successful redirect/ok so RPs can complete UX.
153
+ if (hintSubject) {
154
+ const revoked = await revokeAllForSubject(tenant.id, hintSubject, {
155
+ tokenStore: deps.tokenStore,
156
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
157
+ clock: deps.clock,
158
+ })
159
+ if (isErr(revoked)) return err(revoked.error)
160
+ }
161
+
162
+ await safeAudit(deps, {
163
+ kind: "session_logout",
164
+ tenantId: tenant.id,
165
+ ...(resolvedClientId ? { clientId: resolvedClientId } : {}),
166
+ ...(hintSubject ? { subjectId: hintSubject } : {}),
167
+ timestamp: deps.clock(),
168
+ })
169
+
170
+ // 6. Build the redirect or signal "ok" for the caller to render.
171
+ if (req.postLogoutRedirectUri) {
172
+ const url = new URL(req.postLogoutRedirectUri)
173
+ if (req.state !== undefined) url.searchParams.set("state", req.state)
174
+ return ok({ kind: "redirect", url: url.toString() })
175
+ }
176
+ return ok({
177
+ kind: "ok",
178
+ ...(hintSubject ? { subjectId: hintSubject } : {}),
179
+ })
180
+ }
@@ -166,6 +166,11 @@ async function translate(
166
166
  context: final.context ?? null,
167
167
  providerSubject: result.providerSubject,
168
168
  properties: result.properties,
169
+ ...(final.appNonce !== undefined ? { appNonce: final.appNonce } : {}),
170
+ ...(final.claimsRequest !== undefined
171
+ ? { claimsRequest: final.claimsRequest }
172
+ : {}),
173
+ authTime: Math.floor(now / 1000),
169
174
  expiresAt: now + AUTH_CODE_TTL_MS,
170
175
  },
171
176
  AUTH_CODE_TTL_MS,
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Pushed Authorization Requests (RFC 9126).
3
+ *
4
+ * The RP submits the full `/authorize` parameter set to `POST /par` along
5
+ * with its client credentials. The OP authenticates the client, stores
6
+ * the payload under an opaque `request_uri`, and returns it with an
7
+ * `expires_in`. The RP then redirects the user-agent to
8
+ * `GET /authorize?client_id=...&request_uri=...` and the OP rehydrates
9
+ * the original parameter set.
10
+ *
11
+ * Per RFC 9126 §3:
12
+ * - Response is HTTP 201 with `application/json` body
13
+ * `{request_uri, expires_in}`.
14
+ * - `request_uri` MUST be of the form
15
+ * `urn:ietf:params:oauth:request_uri:<opaque>` (§2.2).
16
+ * - Default TTL is 60 seconds — same as auth-code TTL.
17
+ *
18
+ * Per RFC 9126 §4:
19
+ * - At `/authorize`, `request_uri` is one-shot — consumed and discarded
20
+ * on first use.
21
+ * - Parameters in the PAR record take precedence; additional `/authorize`
22
+ * parameters MAY be merged but MUST NOT contradict (we strictly use
23
+ * the PAR'd set and ignore any additional ones except `client_id`).
24
+ *
25
+ * Client authentication (§2):
26
+ * - Public clients: `client_id` only.
27
+ * - Confidential clients: `client_id` + `client_secret` (Basic or form).
28
+ */
29
+ import type { AuditLog } from "../ports/audit-log"
30
+ import type { ConfigStore } from "../ports/config-store"
31
+ import type { SessionStore } from "../ports/session-store"
32
+ import { authError, type AuthError } from "../types/error"
33
+ import type { Result } from "../types/result"
34
+ import { err, isErr, ok } from "../types/result"
35
+ import type { TenantContext } from "../types/tenant"
36
+
37
+ import { verifyClientCredentials } from "./client-auth"
38
+ import { randomToken } from "./crypto"
39
+
40
+ export const PAR_URI_PREFIX = "urn:ietf:params:oauth:request_uri:"
41
+ export const DEFAULT_PAR_TTL_MS = 60 * 1000
42
+
43
+ export type ParRequest = {
44
+ clientId: string
45
+ /** Confidential-client secret. */
46
+ clientSecret?: string
47
+ /** Raw `/authorize` parameter record, posted by the RP. */
48
+ params: Record<string, string>
49
+ }
50
+
51
+ export type ParResponse = {
52
+ request_uri: string
53
+ expires_in: number
54
+ }
55
+
56
+ export type ParDeps = {
57
+ configStore: ConfigStore
58
+ sessionStore: SessionStore
59
+ auditLog?: AuditLog
60
+ clock: () => number
61
+ /** Test override. */
62
+ newRequestUriSuffix?: () => string
63
+ ttlMs?: number
64
+ }
65
+
66
+ /**
67
+ * Process a `POST /par` request. Authenticates the client, validates the
68
+ * `client_id` consistency, and persists the parameter set.
69
+ */
70
+ export async function pushAuthorizationRequest(
71
+ req: ParRequest,
72
+ tenant: TenantContext,
73
+ deps: ParDeps,
74
+ ): Promise<Result<ParResponse, AuthError>> {
75
+ if (!deps.sessionStore.savePar || !deps.sessionStore.consumePar) {
76
+ return err(
77
+ authError.invalidRequest(
78
+ "session adapter does not implement PAR storage",
79
+ ),
80
+ )
81
+ }
82
+
83
+ // 1. Client authentication. The `client_id` in the body must match the
84
+ // authenticating client.
85
+ const client = tenant.config.clients.find((c) => c.id === req.clientId)
86
+ if (!client) {
87
+ return err(authError.invalidClient(`unknown client "${req.clientId}"`))
88
+ }
89
+ if (client.type === "confidential") {
90
+ const authErr = await verifyClientCredentials(client, req.clientSecret)
91
+ if (authErr) return err(authErr)
92
+ } else if (req.clientSecret !== undefined) {
93
+ return err(
94
+ authError.invalidClient("public client must not present client_secret"),
95
+ )
96
+ }
97
+
98
+ // 2. Sanity-check the body: client_id in body must match — the RFC
99
+ // requires it (§2). RP shouldn't be authenticating one client and
100
+ // pushing params for a different one.
101
+ if (req.params.client_id && req.params.client_id !== req.clientId) {
102
+ return err(
103
+ authError.invalidRequest(
104
+ "client_id in body conflicts with authenticated client",
105
+ "client_id",
106
+ ),
107
+ )
108
+ }
109
+ // RFC 9126 §2.1: `request_uri` MUST NOT appear in a PAR request body.
110
+ if ("request_uri" in req.params) {
111
+ return err(
112
+ authError.invalidRequest(
113
+ "request_uri MUST NOT be present in a PAR request",
114
+ "request_uri",
115
+ ),
116
+ )
117
+ }
118
+
119
+ // 3. Mint request_uri + persist.
120
+ const suffix = (deps.newRequestUriSuffix ?? randomToken)()
121
+ const requestUri = PAR_URI_PREFIX + suffix
122
+ const ttl = deps.ttlMs ?? DEFAULT_PAR_TTL_MS
123
+ const now = deps.clock()
124
+ const saved = await deps.sessionStore.savePar(
125
+ requestUri,
126
+ {
127
+ requestUri,
128
+ params: { ...req.params, client_id: req.clientId },
129
+ clientId: req.clientId,
130
+ issuedAt: now,
131
+ expiresAt: now + ttl,
132
+ },
133
+ ttl,
134
+ )
135
+ if (isErr(saved)) return err(saved.error)
136
+
137
+ return ok({
138
+ request_uri: requestUri,
139
+ expires_in: Math.floor(ttl / 1000),
140
+ })
141
+ }
@@ -34,6 +34,13 @@ export type RefreshGrantRequest = {
34
34
  /** Confidential clients authenticate at /token. */
35
35
  clientId?: string
36
36
  clientSecret?: string
37
+ /**
38
+ * RFC 9449 §6 — JWK thumbprint of the DPoP proof presented on this
39
+ * refresh request. MUST match `RefreshTokenPayload.dpopJkt` when the
40
+ * original grant was DPoP-bound; presence is REQUIRED for bound
41
+ * tokens, OPTIONAL otherwise.
42
+ */
43
+ dpopJkt?: string
37
44
  }
38
45
 
39
46
  export type RefreshTokensDeps = {
@@ -46,6 +53,8 @@ export type RefreshTokensDeps = {
46
53
  /** Reuse-detection window (ms). Default 60 s. */
47
54
  reuseWindowMs?: number
48
55
  newRefreshToken?: () => string
56
+ /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
57
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
49
58
  }
50
59
 
51
60
  export async function refreshTokens(
@@ -93,6 +102,27 @@ export async function refreshTokens(
93
102
  return err(authError.invalidGrant(INVALID_REFRESH_DESC))
94
103
  }
95
104
 
105
+ // RFC 9449 §5 — when the refresh token is DPoP-bound, verify the
106
+ // presented DPoP thumbprint matches BEFORE consuming. Consuming first
107
+ // would burn the token on a wrong-key probe AND trip reuse-detection
108
+ // on a subsequent legitimate retry with the right key.
109
+ if (peekedPayload.dpopJkt !== undefined) {
110
+ if (req.dpopJkt === undefined) {
111
+ return err(
112
+ authError.invalidDpopProof(
113
+ "refresh token is DPoP-bound; request is missing a DPoP proof",
114
+ ),
115
+ )
116
+ }
117
+ if (req.dpopJkt !== peekedPayload.dpopJkt) {
118
+ return err(
119
+ authError.invalidDpopProof(
120
+ "refresh token DPoP jkt does not match the original binding",
121
+ ),
122
+ )
123
+ }
124
+ }
125
+
96
126
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
97
127
  reuseWindowMs: deps.reuseWindowMs,
98
128
  })
@@ -150,6 +180,17 @@ export async function refreshTokens(
150
180
  methodKind: payload.methodKind,
151
181
  scopes: requestedScopes,
152
182
  audience: payload.audience,
183
+ // `auth_time` is stable across refresh rotations per OIDC Core §12;
184
+ // refresh does not re-authenticate the user. `nonce` is deliberately
185
+ // NOT carried forward — the nonce was bound to the original
186
+ // `/authorize` request and should not reappear on rotated id_tokens.
187
+ authTime: payload.authTime,
188
+ // Preserve DPoP binding across refresh rotation (§6.1).
189
+ ...(payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {}),
190
+ // Preserve OIDC §5.5 claims-parameter intent across refresh (§12).
191
+ ...(payload.claimsRequest !== undefined
192
+ ? { claimsRequest: payload.claimsRequest }
193
+ : {}),
153
194
  },
154
195
  family: payload.family,
155
196
  deps,
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Dynamic Client Registration (RFC 7591).
3
+ *
4
+ * The host owns persistence — this module validates the wire request,
5
+ * mints a fresh `client_id` (and `client_secret` for confidential
6
+ * clients), invokes the host's `registerClient` hook with a structured
7
+ * `ClientConfig`, and builds the §3.2.1 response.
8
+ *
9
+ * Per RFC 7591:
10
+ * - §2: registration request body is JSON, fields include
11
+ * `client_name`, `redirect_uris`, `grant_types`, `response_types`,
12
+ * `token_endpoint_auth_method`, `scope`, etc.
13
+ * - §3.2.1: response is JSON with `client_id`, optional
14
+ * `client_secret`, `client_id_issued_at`, optional
15
+ * `client_secret_expires_at`, and the registered metadata.
16
+ * - §3.2.2: structural problems → `invalid_client_metadata` /
17
+ * `invalid_redirect_uri`. We collapse to `invalid_request` for
18
+ * consistency with the rest of the library's error mapping.
19
+ */
20
+ import { authError, type AuthError } from "../types/error"
21
+ import type {
22
+ RegisterClient,
23
+ RegisterClientRequest,
24
+ RegisterClientResponse,
25
+ } from "../types/idp"
26
+ import type { Result } from "../types/result"
27
+ import { err, isErr, ok } from "../types/result"
28
+ import type { TenantContext } from "../types/tenant"
29
+
30
+ import { randomId, randomToken } from "./crypto"
31
+ import { hashClientSecret } from "./token"
32
+
33
+ export type RegisterDeps = {
34
+ registerClient?: RegisterClient
35
+ clock: () => number
36
+ /** Test override for deterministic client_id. */
37
+ newClientId?: () => string
38
+ /** Test override for deterministic client_secret. */
39
+ newClientSecret?: () => string
40
+ }
41
+
42
+ export async function registerNewClient(
43
+ request: RegisterClientRequest,
44
+ tenant: TenantContext,
45
+ deps: RegisterDeps,
46
+ ): Promise<Result<RegisterClientResponse, AuthError>> {
47
+ if (!deps.registerClient) {
48
+ return err(
49
+ authError.invalidRequest(
50
+ "dynamic client registration is not enabled on this deployment",
51
+ ),
52
+ )
53
+ }
54
+
55
+ // RFC 7591 §2 — redirect_uris is REQUIRED for any client that uses a
56
+ // redirect-based grant. We accept the field as required at the wire
57
+ // level (schema enforces) and validate non-empty here.
58
+ if (
59
+ !Array.isArray(request.redirect_uris) ||
60
+ request.redirect_uris.length === 0
61
+ ) {
62
+ return err(
63
+ authError.invalidRequest(
64
+ "redirect_uris must be a non-empty array",
65
+ "redirect_uris",
66
+ ),
67
+ )
68
+ }
69
+ for (const uri of request.redirect_uris) {
70
+ try {
71
+ // Reject relative / opaque URIs eagerly so the host gets a clean
72
+ // structural rejection instead of a runtime surprise at /authorize.
73
+ // RFC 7591 §2 leaves URI scheme to grant-type semantics; we follow
74
+ // the OAuth 2.1 BCP and require absolute URIs.
75
+ void new URL(uri)
76
+ } catch {
77
+ return err(
78
+ authError.invalidRequest(
79
+ `redirect_uri "${uri}" is not a valid absolute URI`,
80
+ "redirect_uris",
81
+ ),
82
+ )
83
+ }
84
+ }
85
+
86
+ const authMethod = request.token_endpoint_auth_method ?? "client_secret_basic"
87
+ const isPublic = authMethod === "none"
88
+ const clientId = (deps.newClientId ?? randomId)()
89
+ const grantTypes = request.grant_types ?? ["authorization_code"]
90
+ const responseTypes = request.response_types ?? ["code"]
91
+ const scopes = request.scope
92
+ ? request.scope.split(/\s+/).filter(Boolean)
93
+ : ["openid"]
94
+
95
+ let secret: string | undefined
96
+ let secretHash: string | undefined
97
+ if (!isPublic) {
98
+ secret = (deps.newClientSecret ?? randomToken)()
99
+ secretHash = await hashClientSecret(secret)
100
+ }
101
+
102
+ const clientPartial = {
103
+ id: clientId,
104
+ name: request.client_name ?? clientId,
105
+ redirectUris: request.redirect_uris,
106
+ // Default scopes overlap with what the IdP advertises; hosts may
107
+ // override during the hook call before persisting.
108
+ scopes,
109
+ grantTypes: grantTypes.filter(
110
+ (g): g is "authorization_code" | "refresh_token" | "client_credentials" =>
111
+ g === "authorization_code" ||
112
+ g === "refresh_token" ||
113
+ g === "client_credentials",
114
+ ),
115
+ ...(request.post_logout_redirect_uris !== undefined
116
+ ? { postLogoutRedirectUris: request.post_logout_redirect_uris }
117
+ : {}),
118
+ ...(request.sector_identifier_uri !== undefined
119
+ ? { sectorIdentifier: request.sector_identifier_uri }
120
+ : {}),
121
+ }
122
+
123
+ const clientConfig = isPublic
124
+ ? {
125
+ ...clientPartial,
126
+ type: "public" as const,
127
+ pkceRequired: true as const,
128
+ }
129
+ : {
130
+ ...clientPartial,
131
+ type: "confidential" as const,
132
+ secretHash: secretHash!,
133
+ pkceRequired: true,
134
+ }
135
+
136
+ const hookResult = await deps.registerClient({
137
+ tenant,
138
+ request,
139
+ })
140
+ // The hook may either:
141
+ // - persist `clientConfig` as-is (most common) and return it,
142
+ // - synthesize its own (host-owned id generation, custom scopes) and
143
+ // return that — in which case the secret we minted is moot.
144
+ // We trust the hook's returned client + secret as authoritative.
145
+ void clientConfig
146
+
147
+ if (isErr(hookResult)) return err(hookResult.error)
148
+ const persisted = hookResult.value.client
149
+ const persistedSecret = hookResult.value.secret
150
+
151
+ const issuedAt = Math.floor(deps.clock() / 1000)
152
+ return ok({
153
+ client_id: persisted.id,
154
+ ...(persistedSecret !== undefined
155
+ ? { client_secret: persistedSecret }
156
+ : {}),
157
+ client_id_issued_at: issuedAt,
158
+ // RFC 7591 §3.2.1: `0` = no expiry.
159
+ client_secret_expires_at: 0,
160
+ redirect_uris: persisted.redirectUris,
161
+ grant_types: persisted.grantTypes,
162
+ response_types: responseTypes,
163
+ token_endpoint_auth_method: authMethod,
164
+ ...(request.client_name !== undefined
165
+ ? { client_name: request.client_name }
166
+ : {}),
167
+ })
168
+ }
@@ -74,6 +74,8 @@ export type ExchangeTokenDeps = {
74
74
  issuerUrl: string
75
75
  clock: () => number
76
76
  newRefreshFamily?: () => string
77
+ /** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
78
+ customScopeClaims?: Record<string, ReadonlyArray<string>>
77
79
  }
78
80
 
79
81
  export async function exchangeToken(
@@ -203,6 +205,15 @@ export async function exchangeToken(
203
205
  methodId: subjectClaims.mid ?? "token_exchange",
204
206
  methodKind: subjectClaims.mkind ?? "token_exchange",
205
207
  scopes: requestedScopes,
208
+ // Carry the original end-user auth_time across the audience swap.
209
+ // Per OIDC Core §12, switching audience is NOT a re-authentication
210
+ // — the user logged in once, and downstream id_token consumers rely
211
+ // on auth_time being stable to detect actual re-auth events. When
212
+ // absent (subject was a client_credentials / m2m token), mintTokens
213
+ // correctly skips id_token issuance.
214
+ ...(subjectClaims.auth_time !== undefined
215
+ ? { authTime: subjectClaims.auth_time }
216
+ : {}),
206
217
  ...(subjectClaims.aud !== undefined &&
207
218
  subjectClaims.aud !== callerClient.id
208
219
  ? { audience: subjectClaims.aud }