@_mustachio/openauth 0.10.0 → 0.12.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/d1/index.js +18 -9
- package/dist/esm/adapters/d1/migrations.js +7 -2
- package/dist/esm/adapters/d1/session-store.js +39 -0
- package/dist/esm/adapters/d1/session.js +2 -2
- package/dist/esm/adapters/durable-object/index.js +4 -0
- package/dist/esm/adapters/durable-object/session-store.js +42 -0
- package/dist/esm/adapters/dynamo/index.js +24 -7
- package/dist/esm/adapters/dynamo/session-store.js +49 -0
- package/dist/esm/adapters/kms/index.js +6 -2
- package/dist/esm/adapters/kv/index.js +6 -2
- package/dist/esm/adapters/memory/index.js +24 -7
- package/dist/esm/adapters/memory/session-store.js +45 -0
- package/dist/esm/adapters/memory/token-store.js +13 -0
- package/dist/esm/adapters/postgres/executor.js +2 -2
- package/dist/esm/adapters/postgres/index.js +33 -10
- package/dist/esm/adapters/postgres/migrations.js +8 -2
- package/dist/esm/adapters/postgres/session-store.js +42 -0
- package/dist/esm/client.js +35 -28
- package/dist/esm/domain/authorize.js +7 -2
- package/dist/esm/domain/callback.js +124 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +46 -4
- 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 +30 -2
- package/dist/esm/domain/logout.js +75 -0
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +83 -1
- package/dist/esm/domain/par.js +51 -0
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/refresh.js +12 -1
- package/dist/esm/domain/register.js +74 -0
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token-exchange.js +1 -0
- package/dist/esm/domain/token.js +53 -12
- package/dist/esm/domain/userinfo.js +38 -3
- package/dist/esm/error.js +8 -8
- package/dist/esm/http/cookies.js +19 -2
- package/dist/esm/http/errors.js +4 -4
- package/dist/esm/http/handlers/authorize.js +92 -21
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/end-session.js +64 -0
- package/dist/esm/http/handlers/metadata.js +7 -4
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/par.js +53 -0
- package/dist/esm/http/handlers/register.js +46 -0
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/handlers/token.js +36 -6
- package/dist/esm/http/handlers/userinfo.js +35 -29
- package/dist/esm/http/middleware/tenant.js +6 -6
- package/dist/esm/http/router.js +10 -0
- package/dist/esm/http/schemas/authorize.js +8 -2
- 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/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +40 -34
- package/dist/esm/methods/oauth2-factory.js +2 -2
- package/dist/esm/methods/password-hash.js +2 -2
- package/dist/esm/methods/providers/index.js +30 -14
- package/dist/esm/methods/saml-sp/acs.js +208 -0
- package/dist/esm/methods/saml-sp/attributes.js +75 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +63 -0
- package/dist/esm/methods/saml-sp/cache-provider.js +33 -0
- package/dist/esm/methods/saml-sp/cert-rotation.js +13 -0
- package/dist/esm/methods/saml-sp/factory.js +70 -0
- package/dist/esm/methods/saml-sp/index.js +7 -0
- package/dist/esm/methods/saml-sp/metadata.js +69 -0
- package/dist/esm/methods/saml-sp/method.js +29 -0
- package/dist/esm/methods/saml-sp/parse-idp-metadata.js +102 -0
- package/dist/esm/methods/saml-sp/saml-instance.js +49 -0
- package/dist/esm/methods/saml-sp/slo-initiate.js +84 -0
- package/dist/esm/methods/saml-sp/sls.js +154 -0
- package/dist/esm/methods/saml-sp/types.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/result.js +3 -3
- package/dist/esm/ui/forms.js +3 -3
- package/dist/esm/ui/index.js +8 -5
- package/dist/esm/ui/picker.js +2 -2
- package/dist/types/adapters/d1/migrations.d.ts.map +1 -1
- package/dist/types/adapters/d1/session-store.d.ts +3 -0
- package/dist/types/adapters/d1/session-store.d.ts.map +1 -1
- package/dist/types/adapters/durable-object/session-store.d.ts +3 -0
- package/dist/types/adapters/durable-object/session-store.d.ts.map +1 -1
- package/dist/types/adapters/dynamo/session-store.d.ts +3 -0
- package/dist/types/adapters/dynamo/session-store.d.ts.map +1 -1
- package/dist/types/adapters/memory/session-store.d.ts +6 -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/adapters/postgres/migrations.d.ts +1 -1
- package/dist/types/adapters/postgres/migrations.d.ts.map +1 -1
- package/dist/types/adapters/postgres/session-store.d.ts +3 -0
- package/dist/types/adapters/postgres/session-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/callback.d.ts +16 -1
- package/dist/types/domain/callback.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/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +57 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- 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 +6 -1
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/cookies.d.ts +9 -1
- package/dist/types/http/cookies.d.ts.map +1 -1
- package/dist/types/http/handlers/authorize.d.ts.map +1 -1
- package/dist/types/http/handlers/callback.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/method-route.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/methods/saml-sp/acs.d.ts +4 -0
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/attributes.d.ts +40 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts +4 -0
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts +38 -0
- package/dist/types/methods/saml-sp/cache-provider.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts +23 -0
- package/dist/types/methods/saml-sp/cert-rotation.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/factory.d.ts +704 -0
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/index.d.ts +24 -0
- package/dist/types/methods/saml-sp/index.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/metadata.d.ts +42 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/method.d.ts +17 -0
- package/dist/types/methods/saml-sp/method.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts +4 -0
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts +81 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts +4 -0
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/sls.d.ts +4 -0
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -0
- package/dist/types/methods/saml-sp/types.d.ts +204 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -0
- package/dist/types/ports/audit-log.d.ts +40 -0
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +50 -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 +162 -1
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +109 -0
- package/dist/types/types/method.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 +9 -1
- package/src/adapters/d1/migrations.ts +5 -0
- package/src/adapters/d1/session-store.ts +68 -0
- package/src/adapters/durable-object/session-store.ts +61 -0
- package/src/adapters/dynamo/session-store.ts +63 -0
- package/src/adapters/memory/session-store.ts +83 -1
- package/src/adapters/memory/token-store.ts +16 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/client.ts +32 -0
- package/src/domain/authorize.ts +9 -0
- package/src/domain/callback.ts +220 -7
- 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-dispatch.ts +53 -1
- package/src/domain/method-route.ts +195 -1
- 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 +10 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +146 -24
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/end-session.ts +106 -0
- package/src/http/handlers/metadata.ts +7 -2
- package/src/http/handlers/method-route.ts +75 -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 +21 -1
- package/src/methods/saml-sp/acs.ts +399 -0
- package/src/methods/saml-sp/attributes.ts +137 -0
- package/src/methods/saml-sp/authnrequest.ts +128 -0
- package/src/methods/saml-sp/cache-provider.ts +76 -0
- package/src/methods/saml-sp/cert-rotation.ts +34 -0
- package/src/methods/saml-sp/factory.ts +118 -0
- package/src/methods/saml-sp/index.ts +34 -0
- package/src/methods/saml-sp/metadata.ts +204 -0
- package/src/methods/saml-sp/method.ts +65 -0
- package/src/methods/saml-sp/parse-idp-metadata.ts +164 -0
- package/src/methods/saml-sp/saml-instance.ts +173 -0
- package/src/methods/saml-sp/slo-initiate.ts +177 -0
- package/src/methods/saml-sp/sls.ts +306 -0
- package/src/methods/saml-sp/types.ts +203 -0
- package/src/ports/CONSISTENCY.md +77 -21
- package/src/ports/audit-log.ts +42 -0
- package/src/ports/session-store.ts +61 -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 +170 -0
- package/src/types/method.ts +110 -0
- package/src/types/tenant.ts +37 -0
- 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
|
+
}
|
|
@@ -16,10 +16,11 @@ import type {
|
|
|
16
16
|
MethodContext,
|
|
17
17
|
MethodDispatchData,
|
|
18
18
|
MethodResult,
|
|
19
|
+
MethodScratch,
|
|
19
20
|
} from "../types/method"
|
|
20
21
|
import type { Result } from "../types/result"
|
|
21
22
|
import { err, isErr, ok } from "../types/result"
|
|
22
|
-
import type { TenantContext } from "../types/tenant"
|
|
23
|
+
import type { TenantContext, TenantId } from "../types/tenant"
|
|
23
24
|
|
|
24
25
|
export type RouteKey = `${"GET" | "POST"} ${string}`
|
|
25
26
|
|
|
@@ -62,6 +63,11 @@ export async function dispatchMethod(
|
|
|
62
63
|
methodState: input.flow?.methodState ?? null,
|
|
63
64
|
cookies: input.cookies,
|
|
64
65
|
dispatch: input.dispatch,
|
|
66
|
+
methodScratch: buildMethodScratch(
|
|
67
|
+
input.sessionStore,
|
|
68
|
+
input.tenant.id,
|
|
69
|
+
input.method.id,
|
|
70
|
+
),
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
let result: MethodResult<unknown, unknown>
|
|
@@ -93,3 +99,49 @@ export async function dispatchMethod(
|
|
|
93
99
|
|
|
94
100
|
return ok(result)
|
|
95
101
|
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Construct the scoped `MethodScratch` handed to a method handler. Keys
|
|
105
|
+
* are namespaced `scratch:<tenantId>:<methodId>:<userKey>` so a method
|
|
106
|
+
* instance can never read or clobber another instance's data — even
|
|
107
|
+
* across tenants on a shared store.
|
|
108
|
+
*
|
|
109
|
+
* When the underlying `SessionStore` doesn't implement
|
|
110
|
+
* `saveScratch` / `readScratch` / `deleteScratch`, every operation
|
|
111
|
+
* returns `unsupportedAdapter` so methods can surface a clean error
|
|
112
|
+
* rather than silently no-op.
|
|
113
|
+
*/
|
|
114
|
+
function buildMethodScratch(
|
|
115
|
+
store: SessionStore,
|
|
116
|
+
tenantId: TenantId,
|
|
117
|
+
methodId: string,
|
|
118
|
+
): MethodScratch {
|
|
119
|
+
const prefix = `scratch:${tenantId}:${methodId}:`
|
|
120
|
+
const scope = (key: string): string => `${prefix}${key}`
|
|
121
|
+
const unsupported = (op: string): AuthError =>
|
|
122
|
+
authError.internalError(
|
|
123
|
+
`SessionStore does not implement ${op}; methodScratch is unavailable on this adapter`,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
put: async (key, value, ttlMs) => {
|
|
128
|
+
if (!store.saveScratch) return err(unsupported("saveScratch"))
|
|
129
|
+
if (ttlMs <= 0) {
|
|
130
|
+
return err(
|
|
131
|
+
authError.internalError(
|
|
132
|
+
`methodScratch.put: ttlMs must be positive, got ${ttlMs}`,
|
|
133
|
+
),
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
return store.saveScratch(scope(key), value, ttlMs)
|
|
137
|
+
},
|
|
138
|
+
get: async (key) => {
|
|
139
|
+
if (!store.readScratch) return err(unsupported("readScratch"))
|
|
140
|
+
return store.readScratch(scope(key))
|
|
141
|
+
},
|
|
142
|
+
delete: async (key) => {
|
|
143
|
+
if (!store.deleteScratch) return err(unsupported("deleteScratch"))
|
|
144
|
+
return store.deleteScratch(scope(key))
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -32,7 +32,14 @@ import type { SessionStore } from "../ports/session-store"
|
|
|
32
32
|
import type { TokenStore } from "../ports/token-store"
|
|
33
33
|
import { authError, type AuthError } from "../types/error"
|
|
34
34
|
import type { FlowRecord } from "../types/flow"
|
|
35
|
-
import type {
|
|
35
|
+
import type { LogoutEventInput, LogoutHookResult } from "../types/idp"
|
|
36
|
+
import type {
|
|
37
|
+
AuthMethod,
|
|
38
|
+
CachePolicy,
|
|
39
|
+
MethodDispatchData,
|
|
40
|
+
MethodResult,
|
|
41
|
+
SetCookie,
|
|
42
|
+
} from "../types/method"
|
|
36
43
|
import type { Result } from "../types/result"
|
|
37
44
|
import { err, isErr, ok } from "../types/result"
|
|
38
45
|
import type { TenantContext } from "../types/tenant"
|
|
@@ -42,6 +49,7 @@ import { AUTH_CODE_TTL_MS } from "./authorize"
|
|
|
42
49
|
import { randomToken } from "./crypto"
|
|
43
50
|
import { MethodCache } from "./method-cache"
|
|
44
51
|
import { dispatchMethod, type RouteKey } from "./method-dispatch"
|
|
52
|
+
import { revokeAllForSubject } from "./revoke"
|
|
45
53
|
import { saveEncryptedCode } from "./token"
|
|
46
54
|
|
|
47
55
|
export type MethodRouteOutput =
|
|
@@ -166,6 +174,11 @@ async function translate(
|
|
|
166
174
|
context: final.context ?? null,
|
|
167
175
|
providerSubject: result.providerSubject,
|
|
168
176
|
properties: result.properties,
|
|
177
|
+
...(final.appNonce !== undefined ? { appNonce: final.appNonce } : {}),
|
|
178
|
+
...(final.claimsRequest !== undefined
|
|
179
|
+
? { claimsRequest: final.claimsRequest }
|
|
180
|
+
: {}),
|
|
181
|
+
authTime: Math.floor(now / 1000),
|
|
169
182
|
expiresAt: now + AUTH_CODE_TTL_MS,
|
|
170
183
|
},
|
|
171
184
|
AUTH_CODE_TTL_MS,
|
|
@@ -206,3 +219,184 @@ async function translate(
|
|
|
206
219
|
return err(result.error)
|
|
207
220
|
}
|
|
208
221
|
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Anonymous public method route — the fourth, deliberately narrow,
|
|
225
|
+
* pipeline. Used only for route keys a method explicitly lists in
|
|
226
|
+
* `AuthMethod.publicRoutes` (today: SAML SP `GET /metadata`). No flow
|
|
227
|
+
* cookie, no flow record, no `SessionStore` read: the handler is a pure
|
|
228
|
+
* function of `tenant` + `dispatch` + captured config.
|
|
229
|
+
*
|
|
230
|
+
* Security: this re-checks `publicRoutes` membership against the
|
|
231
|
+
* resolved method. The HTTP layer also checks before routing here, but
|
|
232
|
+
* a domain function guarding a no-auth path must not trust its caller —
|
|
233
|
+
* the gate is enforced here too, fail-closed.
|
|
234
|
+
*
|
|
235
|
+
* Only `challenge` (the metadata document) and `denied` are sensible
|
|
236
|
+
* outcomes; `success` from a flowless route is a programming error
|
|
237
|
+
* (there is no flow to consume into an auth code) and surfaces as an
|
|
238
|
+
* internal error rather than silently authenticating.
|
|
239
|
+
*/
|
|
240
|
+
export type HandlePublicMethodRouteInput = {
|
|
241
|
+
rawRequest: Request
|
|
242
|
+
tenant: TenantContext
|
|
243
|
+
/** Pre-resolved by the HTTP layer (it needed it to detect the public route). */
|
|
244
|
+
method: AuthMethod
|
|
245
|
+
route: RouteKey
|
|
246
|
+
subPath: string
|
|
247
|
+
/** Issuer/callback context so the handler can derive stable URLs. */
|
|
248
|
+
dispatch: MethodDispatchData
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export type HandlePublicMethodRouteDeps = {
|
|
252
|
+
sessionStore: SessionStore
|
|
253
|
+
/**
|
|
254
|
+
* Required for the upstream-logout path: when a public route returns a
|
|
255
|
+
* `challenge` carrying `logout`, the framework runs
|
|
256
|
+
* `revokeAllForSubject` for the host-named subject. Anonymous read-only
|
|
257
|
+
* public routes (`/metadata`-style) never touch this.
|
|
258
|
+
*/
|
|
259
|
+
tokenStore: TokenStore
|
|
260
|
+
clock: () => number
|
|
261
|
+
auditLog?: AuditLog
|
|
262
|
+
/**
|
|
263
|
+
* See `IdPOptions.onLogout`. Absent ⇒ the framework still verifies +
|
|
264
|
+
* acknowledges the logout and audits it, but revokes nothing (it
|
|
265
|
+
* cannot resolve the upstream id to a subject without the host).
|
|
266
|
+
*/
|
|
267
|
+
onLogout?: (
|
|
268
|
+
input: LogoutEventInput,
|
|
269
|
+
) => Promise<LogoutHookResult> | LogoutHookResult
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type PublicMethodRouteOutput =
|
|
273
|
+
| { kind: "challenge"; response: Response; cache?: CachePolicy }
|
|
274
|
+
| { kind: "denied"; reason: string }
|
|
275
|
+
|
|
276
|
+
export async function handlePublicMethodRoute(
|
|
277
|
+
input: HandlePublicMethodRouteInput,
|
|
278
|
+
deps: HandlePublicMethodRouteDeps,
|
|
279
|
+
): Promise<Result<PublicMethodRouteOutput, AuthError>> {
|
|
280
|
+
if (!input.method.publicRoutes?.includes(input.route)) {
|
|
281
|
+
return err(
|
|
282
|
+
authError.invalidRequest(
|
|
283
|
+
`route "${input.route}" is not public on method "${input.method.id}"`,
|
|
284
|
+
"path",
|
|
285
|
+
),
|
|
286
|
+
)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const dispatched = await dispatchMethod({
|
|
290
|
+
method: input.method,
|
|
291
|
+
route: input.route,
|
|
292
|
+
tenant: input.tenant,
|
|
293
|
+
request: input.rawRequest,
|
|
294
|
+
subPath: input.subPath,
|
|
295
|
+
flow: null,
|
|
296
|
+
cookies: new Map(),
|
|
297
|
+
sessionStore: deps.sessionStore,
|
|
298
|
+
dispatch: input.dispatch,
|
|
299
|
+
})
|
|
300
|
+
if (isErr(dispatched)) return err(dispatched.error)
|
|
301
|
+
|
|
302
|
+
const r = dispatched.value
|
|
303
|
+
switch (r.kind) {
|
|
304
|
+
case "challenge": {
|
|
305
|
+
// A verified upstream logout (SAML front-channel SLS): the method
|
|
306
|
+
// proved authenticity and built the `LogoutResponse`; the
|
|
307
|
+
// privileged side effect (host teardown + token revocation) runs
|
|
308
|
+
// here, where the ports live. Fail closed — if the side effect
|
|
309
|
+
// errors we do NOT hand back a `LogoutResponse` that would tell
|
|
310
|
+
// the IdP the user is logged out when they may not be.
|
|
311
|
+
if (r.logout) {
|
|
312
|
+
const sideEffect = await runUpstreamLogout(input, deps, r.logout)
|
|
313
|
+
if (isErr(sideEffect)) return err(sideEffect.error)
|
|
314
|
+
}
|
|
315
|
+
return ok({
|
|
316
|
+
kind: "challenge",
|
|
317
|
+
response: r.response,
|
|
318
|
+
...(r.cache !== undefined ? { cache: r.cache } : {}),
|
|
319
|
+
})
|
|
320
|
+
}
|
|
321
|
+
case "denied":
|
|
322
|
+
return ok({ kind: "denied", reason: r.reason })
|
|
323
|
+
case "success":
|
|
324
|
+
return err(
|
|
325
|
+
authError.internalError(
|
|
326
|
+
`public route "${input.route}" on method "${input.method.id}" ` +
|
|
327
|
+
`returned success — a flowless route cannot authenticate`,
|
|
328
|
+
),
|
|
329
|
+
)
|
|
330
|
+
case "error":
|
|
331
|
+
return err(r.error)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Run the privileged side effect for a verified upstream logout: fire
|
|
337
|
+
* the host's `onLogout` hook, then — if the host named a subject —
|
|
338
|
+
* revoke that subject's library-issued tokens via the same
|
|
339
|
+
* `revokeAllForSubject` primitive `/end_session` uses. Always emits a
|
|
340
|
+
* `session_logout` audit event (`via: "upstream_slo"`).
|
|
341
|
+
*
|
|
342
|
+
* The library cannot map the upstream `nameId` to an OIDC subject
|
|
343
|
+
* itself (that mapping lives in the host's `success` callback), so
|
|
344
|
+
* revocation is host-directed by design. A throwing hook or a failed
|
|
345
|
+
* revoke fails closed (`internal_error`) — the caller then withholds
|
|
346
|
+
* the `LogoutResponse`.
|
|
347
|
+
*/
|
|
348
|
+
async function runUpstreamLogout(
|
|
349
|
+
input: HandlePublicMethodRouteInput,
|
|
350
|
+
deps: HandlePublicMethodRouteDeps,
|
|
351
|
+
logout: { nameId?: string; sessionIndex?: string },
|
|
352
|
+
): Promise<Result<void, AuthError>> {
|
|
353
|
+
let hookResult: LogoutHookResult = undefined
|
|
354
|
+
if (deps.onLogout) {
|
|
355
|
+
const evt: LogoutEventInput = {
|
|
356
|
+
tenant: input.tenant,
|
|
357
|
+
methodId: input.method.id,
|
|
358
|
+
methodKind: input.method.kind,
|
|
359
|
+
reason: "upstream_slo",
|
|
360
|
+
...(logout.nameId !== undefined ? { nameId: logout.nameId } : {}),
|
|
361
|
+
...(logout.sessionIndex !== undefined
|
|
362
|
+
? { sessionIndex: logout.sessionIndex }
|
|
363
|
+
: {}),
|
|
364
|
+
}
|
|
365
|
+
try {
|
|
366
|
+
hookResult = await deps.onLogout(evt)
|
|
367
|
+
} catch (e) {
|
|
368
|
+
return err(
|
|
369
|
+
authError.internalError(
|
|
370
|
+
`onLogout hook threw during upstream Single Logout for method ` +
|
|
371
|
+
`"${input.method.id}"`,
|
|
372
|
+
e,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const revokeSubject =
|
|
379
|
+
hookResult && typeof hookResult === "object"
|
|
380
|
+
? hookResult.revokeSubject
|
|
381
|
+
: undefined
|
|
382
|
+
|
|
383
|
+
if (revokeSubject) {
|
|
384
|
+
const revoked = await revokeAllForSubject(input.tenant.id, revokeSubject, {
|
|
385
|
+
tokenStore: deps.tokenStore,
|
|
386
|
+
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
387
|
+
clock: deps.clock,
|
|
388
|
+
})
|
|
389
|
+
if (isErr(revoked)) return err(revoked.error)
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
await safeAudit(deps, {
|
|
393
|
+
kind: "session_logout",
|
|
394
|
+
tenantId: input.tenant.id,
|
|
395
|
+
via: "upstream_slo",
|
|
396
|
+
methodId: input.method.id,
|
|
397
|
+
methodKind: input.method.kind,
|
|
398
|
+
...(revokeSubject ? { subjectId: revokeSubject } : {}),
|
|
399
|
+
timestamp: deps.clock(),
|
|
400
|
+
})
|
|
401
|
+
return ok(undefined)
|
|
402
|
+
}
|
|
@@ -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
|
+
}
|
package/src/domain/refresh.ts
CHANGED
|
@@ -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,
|