@_mustachio/openauth 0.11.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 +23 -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 +27 -27
- package/dist/esm/domain/authorize.js +2 -2
- package/dist/esm/domain/callback.js +121 -7
- package/dist/esm/domain/client-auth.js +2 -2
- package/dist/esm/domain/crypto.js +14 -14
- package/dist/esm/domain/discovery.js +2 -2
- package/dist/esm/domain/dpop.js +4 -4
- package/dist/esm/domain/id-token.js +5 -5
- package/dist/esm/domain/jwt.js +4 -4
- package/dist/esm/domain/method-dispatch.js +27 -1
- package/dist/esm/domain/method-route.js +80 -1
- package/dist/esm/domain/par.js +2 -2
- package/dist/esm/domain/pkce.js +2 -2
- package/dist/esm/domain/revoke.js +2 -2
- package/dist/esm/domain/state-envelope.js +2 -2
- package/dist/esm/domain/token.js +5 -5
- 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 +3 -19
- package/dist/esm/http/handlers/callback.js +7 -1
- package/dist/esm/http/handlers/metadata.js +2 -2
- package/dist/esm/http/handlers/method-route.js +47 -2
- package/dist/esm/http/handlers/revocation.js +2 -2
- package/dist/esm/http/middleware/tenant.js +6 -6
- package/dist/esm/http/schemas/authorize.js +2 -2
- package/dist/esm/http/schemas/revocation.js +2 -2
- package/dist/esm/http/schemas/token.js +7 -7
- package/dist/esm/index.js +33 -32
- 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/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 +3 -0
- package/dist/types/adapters/memory/session-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/domain/callback.d.ts +16 -1
- package/dist/types/domain/callback.d.ts.map +1 -1
- 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/http/context.d.ts +3 -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/callback.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -1
- 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 +18 -5
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/ports/session-store.d.ts +23 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +71 -0
- 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/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 +39 -0
- package/src/adapters/postgres/migrations.ts +6 -0
- package/src/adapters/postgres/session-store.ts +69 -0
- package/src/domain/callback.ts +215 -7
- package/src/domain/method-dispatch.ts +53 -1
- package/src/domain/method-route.ts +190 -1
- package/src/http/context.ts +6 -0
- package/src/http/cookies.ts +19 -1
- package/src/http/handlers/authorize.ts +2 -21
- package/src/http/handlers/callback.ts +9 -1
- package/src/http/handlers/method-route.ts +75 -2
- package/src/index.ts +3 -0
- 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 +25 -0
- package/src/ports/audit-log.ts +18 -5
- package/src/ports/session-store.ts +28 -0
- package/src/types/idp.ts +74 -0
- package/src/types/method.ts +110 -0
package/src/domain/callback.ts
CHANGED
|
@@ -58,6 +58,21 @@ export type HandleCallbackInput = {
|
|
|
58
58
|
/** The raw `Request` for the upstream provider's redirect. */
|
|
59
59
|
rawRequest: Request
|
|
60
60
|
cookies: ReadonlyMap<string, string>
|
|
61
|
+
/**
|
|
62
|
+
* Resolved tenant for this request. The SP-initiated path derives
|
|
63
|
+
* the tenant from the consumed flow and ignores this. The
|
|
64
|
+
* **IdP-initiated** path (unsolicited POST, no flow) has nothing to
|
|
65
|
+
* derive from, so it uses this — populated by the HTTP layer from
|
|
66
|
+
* the tenant middleware. Absent ⇒ IdP-initiated is not attempted.
|
|
67
|
+
*/
|
|
68
|
+
tenant?: TenantContext
|
|
69
|
+
/**
|
|
70
|
+
* Per-request issuer URL (HTTP layer). Used only by the
|
|
71
|
+
* IdP-initiated path to derive the SP entityID / ACS — the same
|
|
72
|
+
* derivation the AuthnRequest and metadata paths use, so the values
|
|
73
|
+
* cannot drift.
|
|
74
|
+
*/
|
|
75
|
+
issuerUrl?: string
|
|
61
76
|
}
|
|
62
77
|
|
|
63
78
|
export type HandleCallbackDeps = {
|
|
@@ -81,24 +96,73 @@ export type HandleCallbackDeps = {
|
|
|
81
96
|
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The framework's MAC state envelope normally rides `?state=` on the
|
|
101
|
+
* upstream redirect. POST-binding callbacks carry it in the form body
|
|
102
|
+
* instead: OAuth `response_mode=form_post` uses `state`, SAML's
|
|
103
|
+
* HTTP-POST binding uses `RelayState`. Read the query first (cheap,
|
|
104
|
+
* the common case) and fall back to a **cloned** body read so the
|
|
105
|
+
* downstream method handler still gets an unconsumed request body
|
|
106
|
+
* (it needs it for `code` / `SAMLResponse`).
|
|
107
|
+
*
|
|
108
|
+
* Any body-parse failure degrades to "no state" — identical to the
|
|
109
|
+
* pre-existing behaviour when the query param is absent.
|
|
110
|
+
*/
|
|
111
|
+
async function extractCallbackState(req: Request): Promise<string | null> {
|
|
112
|
+
const fromQuery = new URL(req.url).searchParams.get("state")
|
|
113
|
+
if (fromQuery) return fromQuery
|
|
114
|
+
if (req.method !== "POST") return null
|
|
115
|
+
const ct = req.headers.get("content-type") ?? ""
|
|
116
|
+
if (!ct.includes("application/x-www-form-urlencoded")) return null
|
|
117
|
+
try {
|
|
118
|
+
const body = await req.clone().text()
|
|
119
|
+
const form = new URLSearchParams(body)
|
|
120
|
+
const v = form.get("state") ?? form.get("RelayState")
|
|
121
|
+
return v && v.length > 0 ? v : null
|
|
122
|
+
} catch {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
84
127
|
export async function handleCallback(
|
|
85
128
|
input: HandleCallbackInput,
|
|
86
129
|
deps: HandleCallbackDeps,
|
|
87
130
|
): Promise<Result<CallbackOutput, AuthError>> {
|
|
88
131
|
const url = new URL(input.rawRequest.url)
|
|
89
|
-
const state =
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
132
|
+
const state = await extractCallbackState(input.rawRequest)
|
|
133
|
+
// A SAML IdP-initiated POST may legitimately carry a `RelayState`
|
|
134
|
+
// (Okta/Entra deep-link tokens), so "RelayState present" does NOT
|
|
135
|
+
// imply "framework state envelope present". The envelope is only
|
|
136
|
+
// real if it MAC-verifies. When there is no verifiable envelope —
|
|
137
|
+
// none at all, or a value that fails verification — first try
|
|
138
|
+
// IdP-initiated (the only flowless path). Only if that is not a
|
|
139
|
+
// clean candidate do we emit the original error/audit, unchanged
|
|
140
|
+
// for every method that did not opt into `unsolicitedCallback`.
|
|
141
|
+
const envelopeRes = state
|
|
142
|
+
? await verifyStateEnvelope(state, deps.stateKeys)
|
|
143
|
+
: null
|
|
144
|
+
if (envelopeRes === null || isErr(envelopeRes)) {
|
|
145
|
+
const idp = await tryIdpInitiated(input, deps, url)
|
|
146
|
+
if (idp) return idp
|
|
147
|
+
if (!state) {
|
|
148
|
+
return err(authError.invalidRequest("missing state parameter", "state"))
|
|
149
|
+
}
|
|
150
|
+
// State present but no valid envelope and not an IdP-init
|
|
151
|
+
// candidate — the original tampered/expired-envelope behaviour.
|
|
95
152
|
await safeAudit(deps, {
|
|
96
153
|
kind: "flow_replay_attempt",
|
|
97
154
|
tenantId: null,
|
|
98
155
|
flowId: "unknown",
|
|
99
156
|
timestamp: deps.clock(),
|
|
100
157
|
})
|
|
101
|
-
return err(
|
|
158
|
+
return err(
|
|
159
|
+
authError.invalidRequest(
|
|
160
|
+
envelopeRes && isErr(envelopeRes)
|
|
161
|
+
? envelopeRes.error.description
|
|
162
|
+
: "invalid state",
|
|
163
|
+
"state",
|
|
164
|
+
),
|
|
165
|
+
)
|
|
102
166
|
}
|
|
103
167
|
const envelope = envelopeRes.value
|
|
104
168
|
|
|
@@ -263,6 +327,150 @@ async function translate(
|
|
|
263
327
|
}
|
|
264
328
|
}
|
|
265
329
|
|
|
330
|
+
/**
|
|
331
|
+
* IdP-initiated (unsolicited) SAML SSO — SAML-AD7, the one carve-out
|
|
332
|
+
* vs. OAuth/OIDC methods. There is no AuthnRequest, no MAC state
|
|
333
|
+
* envelope, and no flow record, so none of `handleCallback`'s gates
|
|
334
|
+
* apply. Returns `null` when this is **not** a clean IdP-init candidate
|
|
335
|
+
* (so the caller emits the normal "missing state" error — the
|
|
336
|
+
* conservative default); otherwise a final `CallbackOutput`.
|
|
337
|
+
*
|
|
338
|
+
* Trust chain (no flow to lean on):
|
|
339
|
+
* - the assertion itself is signature/issuer/audience/conditions
|
|
340
|
+
* verified by the method (`consumeAssertion`, IdP-init mode) plus
|
|
341
|
+
* explicit assertion-ID replay dedup;
|
|
342
|
+
* - the RP binding is NOT attacker-influenced — it comes from the
|
|
343
|
+
* method's `unsolicitedBinding` (operator config), and is then
|
|
344
|
+
* re-validated here against the tenant's registered client +
|
|
345
|
+
* redirect URIs (open-redirect defence-in-depth). `RelayState` is
|
|
346
|
+
* never interpreted as a redirect.
|
|
347
|
+
*/
|
|
348
|
+
async function tryIdpInitiated(
|
|
349
|
+
input: HandleCallbackInput,
|
|
350
|
+
deps: HandleCallbackDeps,
|
|
351
|
+
url: URL,
|
|
352
|
+
): Promise<Result<CallbackOutput, AuthError> | null> {
|
|
353
|
+
if (input.rawRequest.method !== "POST") return null
|
|
354
|
+
if (!input.tenant || !input.issuerUrl) return null
|
|
355
|
+
|
|
356
|
+
const segments = url.pathname.split("/").filter(Boolean)
|
|
357
|
+
if (segments.length < 2 || segments[0] !== "cb") return null
|
|
358
|
+
const methodId = segments[1]!
|
|
359
|
+
|
|
360
|
+
const methodRes = await deps.methodCache.resolve(
|
|
361
|
+
input.tenant.config,
|
|
362
|
+
methodId,
|
|
363
|
+
)
|
|
364
|
+
if (isErr(methodRes)) return null
|
|
365
|
+
const method = methodRes.value
|
|
366
|
+
if (method.unsolicitedCallback !== true) return null
|
|
367
|
+
|
|
368
|
+
const callbackUrl = `${url.protocol}//${url.host}/cb/${methodId}`
|
|
369
|
+
const dispatched = await dispatchMethod({
|
|
370
|
+
method,
|
|
371
|
+
route: "GET /callback",
|
|
372
|
+
tenant: input.tenant,
|
|
373
|
+
request: input.rawRequest,
|
|
374
|
+
subPath: "/callback",
|
|
375
|
+
flow: null,
|
|
376
|
+
cookies: input.cookies,
|
|
377
|
+
sessionStore: deps.sessionStore,
|
|
378
|
+
dispatch: { state: "", callbackUrl, issuerUrl: input.issuerUrl },
|
|
379
|
+
})
|
|
380
|
+
if (isErr(dispatched)) return err(dispatched.error)
|
|
381
|
+
|
|
382
|
+
const result = dispatched.value
|
|
383
|
+
if (result.kind === "challenge") {
|
|
384
|
+
return ok({
|
|
385
|
+
kind: "challenge",
|
|
386
|
+
response: result.response,
|
|
387
|
+
setCookies: result.setCookies ?? [],
|
|
388
|
+
...(result.cache !== undefined ? { cache: result.cache } : {}),
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
if (result.kind === "denied") {
|
|
392
|
+
await safeAudit(deps, {
|
|
393
|
+
kind: "authorize_failed",
|
|
394
|
+
tenantId: input.tenant.id,
|
|
395
|
+
clientId: "idp-initiated",
|
|
396
|
+
methodId,
|
|
397
|
+
methodKind: method.kind,
|
|
398
|
+
flowId: "idp-initiated",
|
|
399
|
+
reason: result.reason,
|
|
400
|
+
timestamp: deps.clock(),
|
|
401
|
+
})
|
|
402
|
+
return ok({
|
|
403
|
+
kind: "denied",
|
|
404
|
+
reason: result.reason,
|
|
405
|
+
setCookies: result.setCookies ?? [],
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
if (result.kind === "error") return err(result.error)
|
|
409
|
+
|
|
410
|
+
// success — must carry the operator-configured RP binding.
|
|
411
|
+
const binding = result.unsolicitedBinding
|
|
412
|
+
if (!binding) {
|
|
413
|
+
return err(
|
|
414
|
+
authError.internalError(
|
|
415
|
+
`method "${methodId}" handled an unsolicited callback but returned ` +
|
|
416
|
+
`no unsolicitedBinding`,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
// Open-redirect defence-in-depth: the binding is operator config, but
|
|
421
|
+
// still validate it against the tenant's registered client exactly
|
|
422
|
+
// as /authorize validates an RP's redirect_uri.
|
|
423
|
+
const client = input.tenant.config.clients.find(
|
|
424
|
+
(c) => c.id === binding.clientId,
|
|
425
|
+
)
|
|
426
|
+
if (!client) {
|
|
427
|
+
return err(
|
|
428
|
+
authError.invalidRequest(
|
|
429
|
+
`IdP-initiated binding references unknown client "${binding.clientId}"`,
|
|
430
|
+
),
|
|
431
|
+
)
|
|
432
|
+
}
|
|
433
|
+
if (!client.redirectUris.includes(binding.redirectUri)) {
|
|
434
|
+
return err(
|
|
435
|
+
authError.invalidRequest(
|
|
436
|
+
`IdP-initiated redirect_uri "${binding.redirectUri}" is not ` +
|
|
437
|
+
`registered for client "${binding.clientId}"`,
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const code = (deps.newCodeId ?? randomToken)()
|
|
443
|
+
const now = deps.clock()
|
|
444
|
+
const saved = await saveEncryptedCode(
|
|
445
|
+
code,
|
|
446
|
+
{
|
|
447
|
+
tenantId: input.tenant.id,
|
|
448
|
+
clientId: binding.clientId,
|
|
449
|
+
appRedirectUri: binding.redirectUri,
|
|
450
|
+
// No RP-supplied OAuth state in an unsolicited flow; RelayState
|
|
451
|
+
// is opaque and intentionally NOT echoed as `state`.
|
|
452
|
+
appState: null,
|
|
453
|
+
scopes: binding.scopes,
|
|
454
|
+
methodId,
|
|
455
|
+
methodKind: method.kind,
|
|
456
|
+
context: input.tenant.request.custom ?? null,
|
|
457
|
+
providerSubject: result.providerSubject,
|
|
458
|
+
properties: result.properties,
|
|
459
|
+
authTime: Math.floor(now / 1000),
|
|
460
|
+
expiresAt: now + AUTH_CODE_TTL_MS,
|
|
461
|
+
},
|
|
462
|
+
AUTH_CODE_TTL_MS,
|
|
463
|
+
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
464
|
+
)
|
|
465
|
+
if (isErr(saved)) return err(saved.error)
|
|
466
|
+
return ok({
|
|
467
|
+
kind: "issue-code",
|
|
468
|
+
code,
|
|
469
|
+
appRedirectUri: binding.redirectUri,
|
|
470
|
+
appState: null,
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
266
474
|
function normalizePath(p: string): string {
|
|
267
475
|
return p.replace(/\/+$/, "")
|
|
268
476
|
}
|
|
@@ -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 =
|
|
@@ -211,3 +219,184 @@ async function translate(
|
|
|
211
219
|
return err(result.error)
|
|
212
220
|
}
|
|
213
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
|
+
}
|
package/src/http/context.ts
CHANGED
|
@@ -17,6 +17,8 @@ import type { MethodCache } from "../domain/method-cache"
|
|
|
17
17
|
import type { AuthError } from "../types/error"
|
|
18
18
|
import type {
|
|
19
19
|
ExchangeAudience,
|
|
20
|
+
LogoutEventInput,
|
|
21
|
+
LogoutHookResult,
|
|
20
22
|
PersistUpstreamTokens,
|
|
21
23
|
RegisterClient,
|
|
22
24
|
RenderPicker,
|
|
@@ -49,6 +51,10 @@ export type HttpDeps = {
|
|
|
49
51
|
callbackHostFor?: (tenantId: TenantId) => string
|
|
50
52
|
resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
|
|
51
53
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
54
|
+
/** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
|
|
55
|
+
onLogout?: (
|
|
56
|
+
input: LogoutEventInput,
|
|
57
|
+
) => Promise<LogoutHookResult> | LogoutHookResult
|
|
52
58
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
53
59
|
exchangeAudience?: ExchangeAudience
|
|
54
60
|
renderPicker?: RenderPicker
|
package/src/http/cookies.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* strip method-returned `Set-Cookie` / security / `Cache-Control` headers so
|
|
8
8
|
* methods cannot bypass policy.
|
|
9
9
|
*/
|
|
10
|
-
import type { SetCookie } from "../types/method"
|
|
10
|
+
import type { CachePolicy, SetCookie } from "../types/method"
|
|
11
11
|
|
|
12
12
|
/** Parse an incoming `Cookie:` header into a read-only map. */
|
|
13
13
|
export function parseCookieHeader(header: string | null): Map<string, string> {
|
|
@@ -117,6 +117,24 @@ const STRIPPED_HEADERS = [
|
|
|
117
117
|
"cache-control",
|
|
118
118
|
] as const
|
|
119
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Serialize a `MethodResult.challenge`'s `CachePolicy` into a
|
|
122
|
+
* `Cache-Control` header value. The framework owns this header (methods
|
|
123
|
+
* cannot set it directly — see `STRIPPED_HEADERS`); a method opts into
|
|
124
|
+
* caching via the typed `cache` field and this renders it. Absent /
|
|
125
|
+
* `maxAge: 0` ⇒ `no-store` (the safe default for auth UI).
|
|
126
|
+
*/
|
|
127
|
+
export function cacheControlHeader(cache: CachePolicy | undefined): string {
|
|
128
|
+
if (!cache) return "no-store"
|
|
129
|
+
if ((cache.maxAge ?? -1) === 0) return "no-store"
|
|
130
|
+
const parts: string[] = []
|
|
131
|
+
if (cache.isPrivate) parts.push("private")
|
|
132
|
+
if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
|
|
133
|
+
if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
|
|
134
|
+
if (cache.immutable) parts.push("immutable")
|
|
135
|
+
return parts.length ? parts.join(", ") : "no-store"
|
|
136
|
+
}
|
|
137
|
+
|
|
120
138
|
export type ApplyOptions = {
|
|
121
139
|
setCookies?: SetCookie[]
|
|
122
140
|
cookieDefaults?: CookieDefaults
|
|
@@ -24,7 +24,7 @@ import type {
|
|
|
24
24
|
import type { PickerContext, PickerMethod } from "../../types/picker"
|
|
25
25
|
import { renderPicker as renderDefaultPicker } from "../../ui/picker"
|
|
26
26
|
|
|
27
|
-
import { applyResponsePolicy } from "../cookies"
|
|
27
|
+
import { applyResponsePolicy, cacheControlHeader } from "../cookies"
|
|
28
28
|
import type { HttpContext, HttpDeps } from "../context"
|
|
29
29
|
import {
|
|
30
30
|
authorizeDirectErrorResponse,
|
|
@@ -209,7 +209,7 @@ export function makeAuthorizeHandler(deps: HttpDeps) {
|
|
|
209
209
|
return applyResponsePolicy(out.response, {
|
|
210
210
|
setCookies: [flowCookie, ...out.setCookies],
|
|
211
211
|
cookieDefaults: deps.cookieDefaults,
|
|
212
|
-
cacheControl:
|
|
212
|
+
cacheControl: cacheControlHeader(out.cache),
|
|
213
213
|
})
|
|
214
214
|
}
|
|
215
215
|
case "issue-code": {
|
|
@@ -341,22 +341,3 @@ function clearFlowCookie() {
|
|
|
341
341
|
}
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
-
function cacheControlFor(
|
|
345
|
-
cache:
|
|
346
|
-
| {
|
|
347
|
-
maxAge?: number
|
|
348
|
-
sMaxAge?: number
|
|
349
|
-
isPrivate?: boolean
|
|
350
|
-
immutable?: boolean
|
|
351
|
-
}
|
|
352
|
-
| undefined,
|
|
353
|
-
): string {
|
|
354
|
-
if (!cache) return "no-store"
|
|
355
|
-
if ((cache.maxAge ?? -1) === 0) return "no-store"
|
|
356
|
-
const parts: string[] = []
|
|
357
|
-
if (cache.isPrivate) parts.push("private")
|
|
358
|
-
if (cache.maxAge !== undefined) parts.push(`max-age=${cache.maxAge}`)
|
|
359
|
-
if (cache.sMaxAge !== undefined) parts.push(`s-maxage=${cache.sMaxAge}`)
|
|
360
|
-
if (cache.immutable) parts.push("immutable")
|
|
361
|
-
return parts.length ? parts.join(", ") : "no-store"
|
|
362
|
-
}
|
|
@@ -16,8 +16,16 @@ import { authorizeDirectErrorResponse } from "../errors"
|
|
|
16
16
|
|
|
17
17
|
export function makeCallbackHandler(deps: HttpDeps) {
|
|
18
18
|
return async (c: HttpContext): Promise<Response> => {
|
|
19
|
+
const tenant = c.get("tenant")
|
|
19
20
|
const result = await handleCallback(
|
|
20
|
-
{
|
|
21
|
+
{
|
|
22
|
+
rawRequest: c.req.raw,
|
|
23
|
+
cookies: c.get("cookies"),
|
|
24
|
+
// Only the IdP-initiated (no-state) path reads these; the
|
|
25
|
+
// SP-initiated path derives tenant from the consumed flow.
|
|
26
|
+
...(tenant ? { tenant } : {}),
|
|
27
|
+
issuerUrl: c.get("issuerUrl"),
|
|
28
|
+
},
|
|
21
29
|
{
|
|
22
30
|
configStore: deps.configStore,
|
|
23
31
|
sessionStore: deps.sessionStore,
|