@_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.
- package/dist/esm/adapters/memory/session-store.js +22 -0
- package/dist/esm/adapters/memory/token-store.js +13 -0
- package/dist/esm/client.js +9 -2
- package/dist/esm/domain/authorize.js +5 -0
- package/dist/esm/domain/callback.js +3 -0
- package/dist/esm/domain/discovery.js +44 -2
- package/dist/esm/domain/dpop.js +122 -0
- package/dist/esm/domain/id-token.js +94 -0
- package/dist/esm/domain/introspect.js +5 -1
- package/dist/esm/domain/jwt.js +28 -0
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-route.js +3 -0
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +48 -7
- package/dist/esm/domain/userinfo.js +38 -3
- package/dist/esm/http/handlers/authorize.js +90 -3
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +5 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +7 -1
- package/dist/esm/http/schemas/end-session.js +13 -0
- package/dist/esm/http/schemas/par.js +9 -0
- package/dist/esm/http/schemas/register.js +16 -0
- package/dist/esm/index.js +7 -2
- package/dist/esm/types/error.js +5 -0
- package/dist/types/adapters/memory/session-store.d.ts +3 -1
- package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/token-store.d.ts +1 -0
- package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
- package/dist/types/client.d.ts +19 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/domain/authorize.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +2 -0
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/discovery.d.ts +37 -0
- package/dist/types/domain/discovery.d.ts.map +1 -1
- package/dist/types/domain/dpop.d.ts +61 -0
- package/dist/types/domain/dpop.d.ts.map +1 -0
- package/dist/types/domain/id-token.d.ts +83 -0
- package/dist/types/domain/id-token.d.ts.map +1 -0
- package/dist/types/domain/introspect.d.ts +8 -0
- package/dist/types/domain/introspect.d.ts.map +1 -1
- package/dist/types/domain/jwt.d.ts +27 -1
- package/dist/types/domain/jwt.d.ts.map +1 -1
- package/dist/types/domain/logout.d.ts +69 -0
- package/dist/types/domain/logout.d.ts.map +1 -0
- package/dist/types/domain/par.d.ts +62 -0
- package/dist/types/domain/par.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts +9 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts +33 -0
- package/dist/types/domain/register.d.ts.map +1 -0
- package/dist/types/domain/token-exchange.d.ts +2 -0
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +41 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/domain/userinfo.d.ts +44 -5
- package/dist/types/domain/userinfo.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +4 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/end-session.d.ts +3 -0
- package/dist/types/http/handlers/end-session.d.ts.map +1 -0
- package/dist/types/http/handlers/metadata.d.ts +1 -1
- package/dist/types/http/handlers/metadata.d.ts.map +1 -1
- package/dist/types/http/handlers/par.d.ts +3 -0
- package/dist/types/http/handlers/par.d.ts.map +1 -0
- package/dist/types/http/handlers/register.d.ts +3 -0
- package/dist/types/http/handlers/register.d.ts.map +1 -0
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/http/schemas/authorize.d.ts +40 -0
- package/dist/types/http/schemas/authorize.d.ts.map +1 -1
- package/dist/types/http/schemas/end-session.d.ts +31 -0
- package/dist/types/http/schemas/end-session.d.ts.map +1 -0
- package/dist/types/http/schemas/par.d.ts +20 -0
- package/dist/types/http/schemas/par.d.ts.map +1 -0
- package/dist/types/http/schemas/register.d.ts +42 -0
- package/dist/types/http/schemas/register.d.ts.map +1 -0
- package/dist/types/index.d.ts +3 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/ports/audit-log.d.ts +27 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +27 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/ports/token-store.d.ts +12 -0
- package/dist/types/ports/token-store.d.ts.map +1 -1
- package/dist/types/types/authorization.d.ts +23 -0
- package/dist/types/types/authorization.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +9 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/flow.d.ts +10 -0
- package/dist/types/types/flow.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +91 -1
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/tenant.d.ts +37 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/dist/types/types/token.d.ts +128 -0
- package/dist/types/types/token.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/adapters/memory/session-store.ts +44 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +5 -0
- package/src/domain/client-credentials.ts +2 -0
- package/src/domain/discovery.ts +80 -2
- package/src/domain/dpop.ts +293 -0
- package/src/domain/id-token.ts +213 -0
- package/src/domain/introspect.ts +10 -0
- package/src/domain/jwt.ts +80 -1
- package/src/domain/logout.ts +180 -0
- package/src/domain/method-route.ts +5 -0
- package/src/domain/par.ts +141 -0
- package/src/domain/refresh.ts +41 -0
- package/src/domain/register.ts +168 -0
- package/src/domain/token-exchange.ts +11 -0
- package/src/domain/token.ts +134 -8
- package/src/domain/userinfo.ts +121 -6
- package/src/http/context.ts +4 -0
- package/src/http/handlers/authorize.ts +144 -3
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/par.ts +84 -0
- package/src/http/handlers/register.ts +70 -0
- package/src/http/handlers/token.ts +56 -0
- package/src/http/handlers/userinfo.ts +62 -38
- package/src/http/router.ts +19 -0
- package/src/http/schemas/authorize.ts +27 -0
- package/src/http/schemas/end-session.ts +23 -0
- package/src/http/schemas/par.ts +23 -0
- package/src/http/schemas/register.ts +29 -0
- package/src/index.ts +18 -1
- package/src/ports/CONSISTENCY.md +52 -21
- package/src/ports/audit-log.ts +29 -0
- package/src/ports/session-store.ts +33 -0
- package/src/ports/token-store.ts +13 -0
- package/src/types/authorization.ts +25 -0
- package/src/types/error.ts +23 -0
- package/src/types/flow.ts +10 -0
- package/src/types/idp.ts +96 -0
- package/src/types/tenant.ts +37 -0
- 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(
|
|
19
|
+
export function makeDiscoveryHandler(deps: HttpDeps) {
|
|
20
20
|
return async (c: HttpContext): Promise<Response> => {
|
|
21
|
-
const doc = buildDiscoveryDocument({
|
|
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
|
|
13
|
-
if (!
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/http/router.ts
CHANGED
|
@@ -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>
|