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