@_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,106 @@
1
+ /**
2
+ * `GET|POST /end_session` — OIDC RP-Initiated Logout 1.0 §2.
3
+ *
4
+ * Per spec, both GET (query) and POST (form body) are supported. The
5
+ * tenant is resolved via the standard `tenantMiddleware`; the issuer URL
6
+ * is taken from the per-request context.
7
+ *
8
+ * Outputs follow `domain/logout.ts`:
9
+ * - `redirect` → 302 to the validated `post_logout_redirect_uri` with
10
+ * `state` echoed when present.
11
+ * - `ok` → 200 with a minimal plain-text body. Hosts that need a styled
12
+ * "logged out" page can layer their own handler over `/end_session`.
13
+ * - errors → 400 with a JSON OAuth-style body (never 302; an unregistered
14
+ * `post_logout_redirect_uri` would otherwise be an open redirector).
15
+ */
16
+ import { endSession } from "../../domain/logout"
17
+ import { authError } from "../../types/error"
18
+ import { isErr } from "../../types/result"
19
+
20
+ import { applyResponsePolicy } from "../cookies"
21
+ import type { HttpContext, HttpDeps } from "../context"
22
+ import { tokenEndpointErrorResponse } from "../errors"
23
+ import { endSessionParamsSchema } from "../schemas/end-session"
24
+
25
+ export function makeEndSessionHandler(deps: HttpDeps) {
26
+ return async (c: HttpContext): Promise<Response> => {
27
+ const tenant = c.get("tenant")
28
+ if (!tenant) {
29
+ return tokenEndpointErrorResponse(
30
+ authError.invalidRequest("tenant unresolved"),
31
+ )
32
+ }
33
+
34
+ // GET → query; POST → form body. Anything else → 400.
35
+ let raw: Record<string, string>
36
+ const httpMethod = c.req.method.toUpperCase()
37
+ if (httpMethod === "GET") {
38
+ raw = Object.fromEntries(new URL(c.req.url).searchParams.entries())
39
+ } else if (httpMethod === "POST") {
40
+ const ct = (c.req.header("content-type") ?? "").toLowerCase()
41
+ if (!ct.startsWith("application/x-www-form-urlencoded")) {
42
+ return tokenEndpointErrorResponse(
43
+ authError.invalidRequest(
44
+ "POST /end_session requires application/x-www-form-urlencoded body",
45
+ ),
46
+ )
47
+ }
48
+ raw = Object.fromEntries(new URLSearchParams(await c.req.raw.text()))
49
+ } else {
50
+ return tokenEndpointErrorResponse(
51
+ authError.invalidRequest(`unsupported method "${httpMethod}"`),
52
+ )
53
+ }
54
+
55
+ const parsed = endSessionParamsSchema.safeParse(raw)
56
+ if (!parsed.success) {
57
+ return tokenEndpointErrorResponse(
58
+ authError.invalidRequest(
59
+ parsed.error.issues[0]?.message ?? "invalid request",
60
+ ),
61
+ )
62
+ }
63
+ const q = parsed.data
64
+
65
+ const res = await endSession(
66
+ {
67
+ ...(q.id_token_hint !== undefined
68
+ ? { idTokenHint: q.id_token_hint }
69
+ : {}),
70
+ ...(q.client_id !== undefined ? { clientId: q.client_id } : {}),
71
+ ...(q.post_logout_redirect_uri !== undefined
72
+ ? { postLogoutRedirectUri: q.post_logout_redirect_uri }
73
+ : {}),
74
+ ...(q.state !== undefined ? { state: q.state } : {}),
75
+ },
76
+ tenant,
77
+ {
78
+ configStore: deps.configStore,
79
+ tokenStore: deps.tokenStore,
80
+ keyStore: deps.keyStore,
81
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
82
+ issuerUrl: c.get("issuerUrl"),
83
+ clock: deps.clock,
84
+ },
85
+ )
86
+
87
+ if (isErr(res)) return tokenEndpointErrorResponse(res.error)
88
+
89
+ if (res.value.kind === "redirect") {
90
+ return applyResponsePolicy(
91
+ new Response(null, {
92
+ status: 302,
93
+ headers: { location: res.value.url },
94
+ }),
95
+ { setCookies: [], cookieDefaults: deps.cookieDefaults },
96
+ )
97
+ }
98
+ return new Response("Logged out.", {
99
+ status: 200,
100
+ headers: {
101
+ "content-type": "text/plain; charset=utf-8",
102
+ "cache-control": "no-store",
103
+ },
104
+ })
105
+ }
106
+ }
@@ -16,9 +16,14 @@ import { isErr } from "../../types/result"
16
16
  import type { HttpContext, HttpDeps } from "../context"
17
17
  import { tokenEndpointErrorResponse } from "../errors"
18
18
 
19
- export function makeDiscoveryHandler(_deps: HttpDeps) {
19
+ export function makeDiscoveryHandler(deps: HttpDeps) {
20
20
  return async (c: HttpContext): Promise<Response> => {
21
- const doc = buildDiscoveryDocument({ issuerUrl: c.get("issuerUrl") })
21
+ const doc = buildDiscoveryDocument({
22
+ issuerUrl: c.get("issuerUrl"),
23
+ ...(deps.customScopeClaims !== undefined
24
+ ? { customScopeClaims: deps.customScopeClaims }
25
+ : {}),
26
+ })
22
27
  return new Response(JSON.stringify(doc), {
23
28
  status: 200,
24
29
  headers: {
@@ -0,0 +1,84 @@
1
+ /**
2
+ * `POST /par` — Pushed Authorization Requests (RFC 9126 §2).
3
+ *
4
+ * Body: `application/x-www-form-urlencoded` carrying the same parameter
5
+ * set the RP would otherwise put on `GET /authorize`, plus `client_id`
6
+ * and optionally `client_secret`. Client credentials may alternatively
7
+ * ride in `Authorization: Basic` (RFC 6749 §2.3.1).
8
+ *
9
+ * Response: HTTP 201, JSON `{request_uri, expires_in}` on success.
10
+ */
11
+ import { resolveClientCredentials } from "../../domain/client-auth"
12
+ import { pushAuthorizationRequest } from "../../domain/par"
13
+ import { authError } from "../../types/error"
14
+ import { isErr } from "../../types/result"
15
+
16
+ import type { HttpContext, HttpDeps } from "../context"
17
+ import { tokenEndpointErrorResponse } from "../errors"
18
+ import { parBodySchema } from "../schemas/par"
19
+
20
+ export function makeParHandler(deps: HttpDeps) {
21
+ return async (c: HttpContext): Promise<Response> => {
22
+ const tenant = c.get("tenant")
23
+ if (!tenant) {
24
+ return tokenEndpointErrorResponse(
25
+ authError.invalidRequest("tenant unresolved"),
26
+ )
27
+ }
28
+ const ct = (c.req.header("content-type") ?? "").toLowerCase()
29
+ if (!ct.startsWith("application/x-www-form-urlencoded")) {
30
+ return tokenEndpointErrorResponse(
31
+ authError.invalidRequest(
32
+ "body must be application/x-www-form-urlencoded",
33
+ ),
34
+ )
35
+ }
36
+ const raw = Object.fromEntries(
37
+ new URLSearchParams(await c.req.raw.text()).entries(),
38
+ )
39
+ const parsed = parBodySchema.safeParse(raw)
40
+ if (!parsed.success) {
41
+ return tokenEndpointErrorResponse(
42
+ authError.invalidRequest(
43
+ parsed.error.issues[0]?.message ?? "invalid request",
44
+ ),
45
+ )
46
+ }
47
+ const creds = resolveClientCredentials({
48
+ authorizationHeader: c.req.header("authorization") ?? null,
49
+ bodyClientId: parsed.data.client_id,
50
+ bodyClientSecret: parsed.data.client_secret,
51
+ })
52
+ const clientId = creds?.clientId ?? parsed.data.client_id
53
+
54
+ // Strip auth-only fields from the param record we persist — they
55
+ // belong to `/par`, not to the rehydrated `/authorize` request.
56
+ const { client_secret: _strip, ...paramsForAuthorize } = parsed.data
57
+ void _strip
58
+
59
+ const res = await pushAuthorizationRequest(
60
+ {
61
+ clientId,
62
+ ...(creds?.clientSecret !== undefined
63
+ ? { clientSecret: creds.clientSecret }
64
+ : {}),
65
+ params: paramsForAuthorize,
66
+ },
67
+ tenant,
68
+ {
69
+ configStore: deps.configStore,
70
+ sessionStore: deps.sessionStore,
71
+ ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
72
+ clock: deps.clock,
73
+ },
74
+ )
75
+ if (isErr(res)) return tokenEndpointErrorResponse(res.error)
76
+ return new Response(JSON.stringify(res.value), {
77
+ status: 201,
78
+ headers: {
79
+ "content-type": "application/json",
80
+ "cache-control": "no-store",
81
+ },
82
+ })
83
+ }
84
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `POST /register` — RFC 7591 Dynamic Client Registration.
3
+ *
4
+ * Body: `application/json`. Tenant resolved via the standard middleware.
5
+ * The framework validates structure here and defers persistence to the
6
+ * `IdPOptions.registerClient` hook (in `domain/register.ts`).
7
+ *
8
+ * Per RFC 7591 §3.2.1, success returns HTTP 201 with JSON body.
9
+ */
10
+ import { registerNewClient } from "../../domain/register"
11
+ import { authError } from "../../types/error"
12
+ import type { RegisterClientRequest } from "../../types/idp"
13
+ import { isErr } from "../../types/result"
14
+
15
+ import type { HttpContext, HttpDeps } from "../context"
16
+ import { tokenEndpointErrorResponse } from "../errors"
17
+ import { registerBodySchema } from "../schemas/register"
18
+
19
+ export function makeRegisterHandler(deps: HttpDeps) {
20
+ return async (c: HttpContext): Promise<Response> => {
21
+ const tenant = c.get("tenant")
22
+ if (!tenant) {
23
+ return tokenEndpointErrorResponse(
24
+ authError.invalidRequest("tenant unresolved"),
25
+ )
26
+ }
27
+ const ct = (c.req.header("content-type") ?? "").toLowerCase()
28
+ if (!ct.startsWith("application/json")) {
29
+ return tokenEndpointErrorResponse(
30
+ authError.invalidRequest("body must be application/json"),
31
+ )
32
+ }
33
+ let raw: unknown
34
+ try {
35
+ raw = await c.req.raw.json()
36
+ } catch (e) {
37
+ const reason = e instanceof Error ? e.message : String(e)
38
+ return tokenEndpointErrorResponse(
39
+ authError.invalidRequest(`malformed JSON: ${reason}`),
40
+ )
41
+ }
42
+ const parsed = registerBodySchema.safeParse(raw)
43
+ if (!parsed.success) {
44
+ return tokenEndpointErrorResponse(
45
+ authError.invalidRequest(
46
+ parsed.error.issues[0]?.message ?? "invalid request",
47
+ ),
48
+ )
49
+ }
50
+ // Schema is `.passthrough()` (RFC 7591 allows extension metadata
51
+ // fields); the declared `RegisterClientRequest` fields are all
52
+ // statically present in `parsed.data`. This cast just drops the
53
+ // passthrough's `[k: string]: unknown` index signature so the
54
+ // downstream domain function sees the typed contract.
55
+ const request = parsed.data as RegisterClientRequest
56
+
57
+ const res = await registerNewClient(request, tenant, {
58
+ ...(deps.registerClient ? { registerClient: deps.registerClient } : {}),
59
+ clock: deps.clock,
60
+ })
61
+ if (isErr(res)) return tokenEndpointErrorResponse(res.error)
62
+ return new Response(JSON.stringify(res.value), {
63
+ status: 201,
64
+ headers: {
65
+ "content-type": "application/json",
66
+ "cache-control": "no-store",
67
+ },
68
+ })
69
+ }
70
+ }
@@ -17,6 +17,7 @@
17
17
  * refresh-token snapshot is authoritative.
18
18
  */
19
19
  import { clientCredentialsGrant } from "../../domain/client-credentials"
20
+ import { canonicalHtu, verifyDpopProof } from "../../domain/dpop"
20
21
  import { exchangeCode } from "../../domain/token"
21
22
  import { exchangeToken } from "../../domain/token-exchange"
22
23
  import { refreshTokens } from "../../domain/refresh"
@@ -39,12 +40,53 @@ export function makeTokenHandler(deps: HttpDeps) {
39
40
  )
40
41
  }
41
42
 
43
+ // `Authorization` carries Basic client auth on /token. DPoP travels in
44
+ // its own `DPoP:` header (RFC 9449 §5) — never inside `Authorization`.
45
+ // Parsing Basic first lets a confidential client present its secret
46
+ // alongside a DPoP proof.
42
47
  const basic = parseBasicAuth(c.req.header("authorization") ?? null)
43
48
  if (basic) {
44
49
  body.set("client_id", basic.id)
45
50
  body.set("client_secret", basic.secret)
46
51
  }
47
52
 
53
+ // RFC 9449 §5.1 — verify any presented DPoP proof against this
54
+ // request's actual method + URI. Absent header is fine here (the
55
+ // grant-specific path enforces `dpopRequired`).
56
+ const dpopHeader = c.req.header("dpop") ?? null
57
+ let dpopJkt: string | undefined
58
+ if (dpopHeader) {
59
+ const dpopRes = await verifyDpopProof(
60
+ {
61
+ proofJwt: dpopHeader,
62
+ htu: canonicalHtu(c.req.url),
63
+ htm: "POST",
64
+ nowSec: Math.floor(deps.clock() / 1000),
65
+ },
66
+ { tokenStore: deps.tokenStore },
67
+ )
68
+ if (isErr(dpopRes)) {
69
+ // RFC 9449 §11.1 replay → first-class audit event so operators /
70
+ // SIEM can spot stolen-key probing distinct from generic proof
71
+ // verification failures.
72
+ const errVal = dpopRes.error
73
+ if (
74
+ errVal.code === "invalid_dpop_proof" &&
75
+ errVal.replaySignal &&
76
+ deps.auditLog
77
+ ) {
78
+ await deps.auditLog.log({
79
+ kind: "dpop_replay_detected",
80
+ tenantId: null,
81
+ jtiPrefix: errVal.replaySignal.jti.slice(0, 16),
82
+ timestamp: deps.clock(),
83
+ })
84
+ }
85
+ return tokenEndpointErrorResponse(errVal)
86
+ }
87
+ dpopJkt = dpopRes.value.jkt
88
+ }
89
+
48
90
  const parsed = tokenRequestSchema.safeParse(
49
91
  Object.fromEntries(body.entries()),
50
92
  )
@@ -77,6 +119,7 @@ export function makeTokenHandler(deps: HttpDeps) {
77
119
  ...(req.code_verifier !== undefined
78
120
  ? { codeVerifier: req.code_verifier }
79
121
  : {}),
122
+ ...(dpopJkt !== undefined ? { dpopJkt } : {}),
80
123
  },
81
124
  {
82
125
  configStore: deps.configStore,
@@ -89,6 +132,9 @@ export function makeTokenHandler(deps: HttpDeps) {
89
132
  : {}),
90
133
  issuerUrl: c.get("issuerUrl"),
91
134
  clock: deps.clock,
135
+ ...(deps.customScopeClaims !== undefined
136
+ ? { customScopeClaims: deps.customScopeClaims }
137
+ : {}),
92
138
  },
93
139
  )
94
140
  if (isErr(result)) return tokenEndpointErrorResponse(result.error)
@@ -133,6 +179,9 @@ export function makeTokenHandler(deps: HttpDeps) {
133
179
  : {}),
134
180
  issuerUrl: c.get("issuerUrl"),
135
181
  clock: deps.clock,
182
+ ...(deps.customScopeClaims !== undefined
183
+ ? { customScopeClaims: deps.customScopeClaims }
184
+ : {}),
136
185
  },
137
186
  tenantRes.value,
138
187
  )
@@ -150,6 +199,7 @@ export function makeTokenHandler(deps: HttpDeps) {
150
199
  ...(req.client_secret !== undefined
151
200
  ? { clientSecret: req.client_secret }
152
201
  : {}),
202
+ ...(dpopJkt !== undefined ? { dpopJkt } : {}),
153
203
  },
154
204
  {
155
205
  configStore: deps.configStore,
@@ -158,6 +208,9 @@ export function makeTokenHandler(deps: HttpDeps) {
158
208
  ...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
159
209
  issuerUrl: c.get("issuerUrl"),
160
210
  clock: deps.clock,
211
+ ...(deps.customScopeClaims !== undefined
212
+ ? { customScopeClaims: deps.customScopeClaims }
213
+ : {}),
161
214
  },
162
215
  )
163
216
  if (isErr(refreshResult))
@@ -195,6 +248,9 @@ export function makeTokenHandler(deps: HttpDeps) {
195
248
  : {}),
196
249
  issuerUrl: c.get("issuerUrl"),
197
250
  clock: deps.clock,
251
+ ...(deps.customScopeClaims !== undefined
252
+ ? { customScopeClaims: deps.customScopeClaims }
253
+ : {}),
198
254
  },
199
255
  )
200
256
  if (isErr(exchangeResult)) {
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * OIDC `/userinfo` handler (OIDC Core §5.3). Accepts `GET` or `POST` per
3
- * the spec.
3
+ * the spec. Supports both `Authorization: Bearer ...` (RFC 6750) and
4
+ * `Authorization: DPoP ...` (RFC 9449) — the domain layer enforces the
5
+ * proof match against the access token's `cnf.jkt` when bound.
4
6
  */
7
+ import { canonicalHtu } from "../../domain/dpop"
5
8
  import { userinfo } from "../../domain/userinfo"
6
9
  import { isErr } from "../../types/result"
7
10
 
@@ -9,44 +12,43 @@ import type { HttpContext, HttpDeps } from "../context"
9
12
 
10
13
  export function makeUserinfoHandler(deps: HttpDeps) {
11
14
  return async (c: HttpContext): Promise<Response> => {
12
- const bearer = extractBearer(c.req.header("authorization") ?? null)
13
- if (!bearer) {
14
- const r = new Response(
15
- JSON.stringify({
16
- error: "invalid_token",
17
- error_description: "missing bearer token",
18
- }),
19
- {
20
- status: 401,
21
- headers: {
22
- "content-type": "application/json",
23
- "www-authenticate": 'Bearer realm="userinfo"',
24
- },
25
- },
15
+ const auth = extractAuthorization(c.req.header("authorization") ?? null)
16
+ if (!auth) {
17
+ return wwwAuthenticateResponse(
18
+ "Bearer",
19
+ "invalid_token",
20
+ "missing bearer / DPoP token",
21
+ 401,
26
22
  )
27
- return r
28
23
  }
29
24
 
30
- const res = await userinfo(bearer, {
31
- keyStore: deps.keyStore,
32
- issuerUrl: c.get("issuerUrl"),
33
- })
25
+ const dpopProof = c.req.header("dpop") ?? undefined
26
+ const res = await userinfo(
27
+ {
28
+ accessToken: auth.token,
29
+ scheme: auth.scheme,
30
+ ...(dpopProof !== undefined ? { dpopProof } : {}),
31
+ htu: canonicalHtu(c.req.url),
32
+ htm: c.req.method.toUpperCase(),
33
+ nowSec: Math.floor(deps.clock() / 1000),
34
+ },
35
+ {
36
+ keyStore: deps.keyStore,
37
+ tokenStore: deps.tokenStore,
38
+ issuerUrl: c.get("issuerUrl"),
39
+ ...(deps.customScopeClaims !== undefined
40
+ ? { customScopeClaims: deps.customScopeClaims }
41
+ : {}),
42
+ },
43
+ )
34
44
  if (isErr(res)) {
35
- // OIDC userinfo returns 401 on invalid token (vs. 400 elsewhere).
36
- const err = res.error
37
- return new Response(
38
- JSON.stringify({
39
- error: "invalid_token",
40
- error_description: err.description,
41
- }),
42
- {
43
- status: 401,
44
- headers: {
45
- "content-type": "application/json",
46
- "www-authenticate": `Bearer error="invalid_token"`,
47
- },
48
- },
49
- )
45
+ const error = res.error
46
+ const scheme = auth.scheme
47
+ const wireError =
48
+ error.code === "invalid_dpop_proof"
49
+ ? "invalid_dpop_proof"
50
+ : "invalid_token"
51
+ return wwwAuthenticateResponse(scheme, wireError, error.description, 401)
50
52
  }
51
53
 
52
54
  return new Response(JSON.stringify(res.value), {
@@ -59,8 +61,30 @@ export function makeUserinfoHandler(deps: HttpDeps) {
59
61
  }
60
62
  }
61
63
 
62
- function extractBearer(header: string | null): string | null {
64
+ function extractAuthorization(
65
+ header: string | null,
66
+ ): { scheme: "Bearer" | "DPoP"; token: string } | null {
63
67
  if (!header) return null
64
- const m = /^Bearer\s+(.+)$/i.exec(header)
65
- return m?.[1] ?? null
68
+ const m = /^(Bearer|DPoP)\s+(.+)$/i.exec(header)
69
+ if (!m || !m[1] || !m[2]) return null
70
+ const scheme = m[1].toLowerCase() === "dpop" ? "DPoP" : "Bearer"
71
+ return { scheme, token: m[2] }
72
+ }
73
+
74
+ function wwwAuthenticateResponse(
75
+ scheme: "Bearer" | "DPoP",
76
+ error: string,
77
+ description: string,
78
+ status: number,
79
+ ): Response {
80
+ return new Response(
81
+ JSON.stringify({ error, error_description: description }),
82
+ {
83
+ status,
84
+ headers: {
85
+ "content-type": "application/json",
86
+ "www-authenticate": `${scheme} error="${error}"`,
87
+ },
88
+ },
89
+ )
66
90
  }
@@ -19,8 +19,11 @@ import { Hono } from "hono"
19
19
 
20
20
  import { makeAuthorizeHandler } from "./handlers/authorize"
21
21
  import { makeCallbackHandler } from "./handlers/callback"
22
+ import { makeEndSessionHandler } from "./handlers/end-session"
22
23
  import { makeDiscoveryHandler, makeJwksHandler } from "./handlers/metadata"
23
24
  import { makeMethodRouteHandler } from "./handlers/method-route"
25
+ import { makeParHandler } from "./handlers/par"
26
+ import { makeRegisterHandler } from "./handlers/register"
24
27
  import { makeIntrospectHandler, makeRevokeHandler } from "./handlers/revocation"
25
28
  import { makeTokenHandler } from "./handlers/token"
26
29
  import { makeUserinfoHandler } from "./handlers/userinfo"
@@ -62,5 +65,21 @@ export function buildRouter(deps: HttpDeps): Hono<HttpEnv> {
62
65
  app.post("/revoke", makeRevokeHandler(deps))
63
66
  app.post("/introspect", makeIntrospectHandler(deps))
64
67
 
68
+ // OIDC RP-Initiated Logout 1.0. Tenant resolved via the standard
69
+ // middleware — same partitioning rules as `/authorize`.
70
+ app.use("/end_session", tenantMiddleware(deps))
71
+ app.get("/end_session", makeEndSessionHandler(deps))
72
+ app.post("/end_session", makeEndSessionHandler(deps))
73
+
74
+ // RFC 9126 Pushed Authorization Requests.
75
+ app.use("/par", tenantMiddleware(deps))
76
+ app.post("/par", makeParHandler(deps))
77
+
78
+ // RFC 7591 Dynamic Client Registration. Tenant resolution uses the
79
+ // standard middleware — partitioned hosts may carve out per-tenant
80
+ // registration endpoints transparently.
81
+ app.use("/register", tenantMiddleware(deps))
82
+ app.post("/register", makeRegisterHandler(deps))
83
+
65
84
  return app
66
85
  }
@@ -32,6 +32,12 @@ const scopeParam = z
32
32
  )
33
33
  .pipe(z.array(z.string().regex(SCOPE_TOKEN, "invalid scope token")))
34
34
 
35
+ /**
36
+ * Standard `/authorize` query schema. Used both for direct calls and as
37
+ * the validation target after a PAR record is rehydrated — the PAR'd
38
+ * params are passed through this same parser so semantic checks stay in
39
+ * a single place.
40
+ */
35
41
  export const authorizeQuerySchema = z
36
42
  .object({
37
43
  response_type: z.string({
@@ -52,6 +58,27 @@ export const authorizeQuerySchema = z
52
58
  prompt: csv.optional(),
53
59
  ui_locales: csv.optional(),
54
60
  nonce: z.string().optional(),
61
+ /**
62
+ * OIDC Core §5.5 — JSON-encoded claims request. Parsing + structural
63
+ * validation happens in the domain layer; here we just accept any
64
+ * non-empty string and let the handler parse + reject malformed.
65
+ */
66
+ claims: z.string().min(1).optional(),
67
+ })
68
+ .passthrough()
69
+
70
+ /**
71
+ * RFC 9126 §4: when `request_uri` is presented, the user-agent's
72
+ * `/authorize` URL is allowed to carry only `client_id` and
73
+ * `request_uri`; the rest of the parameter set must come from the
74
+ * stored PAR record.
75
+ */
76
+ export const authorizeRequestUriQuerySchema = z
77
+ .object({
78
+ client_id: z
79
+ .string({ required_error: "missing client_id" })
80
+ .min(1, "empty client_id"),
81
+ request_uri: z.string({ required_error: "missing request_uri" }).min(1),
55
82
  })
56
83
  .passthrough()
57
84
 
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Zod schema for `/end_session` (OIDC RP-Initiated Logout 1.0 §2).
3
+ *
4
+ * Every parameter is optional per spec. The domain layer enforces the
5
+ * "needs identifying client to honor post_logout_redirect_uri" rule.
6
+ */
7
+ import { z } from "zod"
8
+
9
+ export const endSessionParamsSchema = z
10
+ .object({
11
+ id_token_hint: z.string().optional(),
12
+ client_id: z.string().min(1).optional(),
13
+ post_logout_redirect_uri: z
14
+ .string()
15
+ .url("post_logout_redirect_uri must be a valid URL")
16
+ .optional(),
17
+ state: z.string().optional(),
18
+ logout_hint: z.string().optional(),
19
+ ui_locales: z.string().optional(),
20
+ })
21
+ .passthrough()
22
+
23
+ export type EndSessionParams = z.infer<typeof endSessionParamsSchema>
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Zod schema for the `POST /par` body (RFC 9126 §2).
3
+ *
4
+ * The shape is identical to `/authorize` query parameters; we just need
5
+ * `client_id` to identify the authenticating client. `client_secret` is
6
+ * accepted in-body for `client_secret_post` auth.
7
+ */
8
+ import { z } from "zod"
9
+
10
+ export const parBodySchema = z
11
+ .object({
12
+ client_id: z
13
+ .string({ required_error: "missing client_id" })
14
+ .min(1, "empty client_id"),
15
+ client_secret: z.string().optional(),
16
+ })
17
+ // `.catchall(z.string())` validates AND types passthrough keys as
18
+ // strings — `application/x-www-form-urlencoded` values are always
19
+ // strings at the wire level, so this also rejects malformed bodies
20
+ // where a key somehow carries a non-string value.
21
+ .catchall(z.string())
22
+
23
+ export type ParBody = z.infer<typeof parBodySchema>
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Zod schema for `POST /register` (RFC 7591 §2).
3
+ *
4
+ * The body is `application/json`. We surface invalid structure as
5
+ * `invalid_request` rather than the spec's `invalid_client_metadata` /
6
+ * `invalid_redirect_uri` codes — the library collapses all wire-format
7
+ * problems to the OAuth-2.0 `invalid_request` family for consistency.
8
+ */
9
+ import { z } from "zod"
10
+
11
+ export const registerBodySchema = z
12
+ .object({
13
+ client_name: z.string().min(1).optional(),
14
+ redirect_uris: z
15
+ .array(z.string().url("redirect_uri must be a valid URL"))
16
+ .min(1, "redirect_uris must be a non-empty array"),
17
+ grant_types: z.array(z.string()).optional(),
18
+ response_types: z.array(z.string()).optional(),
19
+ token_endpoint_auth_method: z
20
+ .enum(["none", "client_secret_basic", "client_secret_post"])
21
+ .optional(),
22
+ scope: z.string().optional(),
23
+ post_logout_redirect_uris: z.array(z.string().url()).optional(),
24
+ sector_identifier_uri: z.string().url().optional(),
25
+ contacts: z.array(z.string()).optional(),
26
+ })
27
+ .passthrough()
28
+
29
+ export type RegisterBody = z.infer<typeof registerBodySchema>