@_mustachio/openauth 0.13.2 → 0.14.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/client.js +61 -62
- package/dist/esm/domain/authorize.js +21 -3
- package/dist/esm/domain/callback.js +30 -21
- package/dist/esm/domain/client-credentials.js +16 -0
- package/dist/esm/domain/method-dispatch.js +1 -0
- package/dist/esm/domain/method-route.js +12 -0
- package/dist/esm/domain/mount.js +29 -0
- package/dist/esm/domain/refresh.js +16 -5
- package/dist/esm/domain/register.js +8 -2
- package/dist/esm/domain/state-envelope.js +19 -0
- package/dist/esm/domain/subject.js +35 -0
- package/dist/esm/domain/token.js +15 -0
- package/dist/esm/http/handlers/method-route.js +8 -3
- package/dist/esm/http/handlers/token.js +2 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +4 -0
- package/dist/esm/methods/code.js +9 -8
- package/dist/esm/methods/oauth2-generic.js +2 -1
- package/dist/esm/methods/passkey.js +3 -2
- package/dist/esm/methods/password.js +9 -8
- package/dist/esm/methods/saml-sp/metadata.js +2 -1
- package/dist/types/client.d.ts +61 -40
- 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 +6 -5
- package/dist/types/domain/callback.d.ts.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +3 -1
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/method-dispatch.d.ts +5 -0
- package/dist/types/domain/method-dispatch.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts +6 -0
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/mount.d.ts +90 -0
- package/dist/types/domain/mount.d.ts.map +1 -0
- package/dist/types/domain/refresh.d.ts.map +1 -1
- package/dist/types/domain/register.d.ts.map +1 -1
- package/dist/types/domain/state-envelope.d.ts +22 -0
- package/dist/types/domain/state-envelope.d.ts.map +1 -1
- package/dist/types/domain/subject.d.ts +48 -0
- package/dist/types/domain/subject.d.ts.map +1 -0
- package/dist/types/domain/token.d.ts +7 -1
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/token.d.ts.map +1 -1
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/schemas/revocation.d.ts +4 -4
- package/dist/types/http/schemas/token.d.ts +12 -12
- package/dist/types/index.d.ts +17 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/code.d.ts.map +1 -1
- package/dist/types/methods/oauth2-generic.d.ts.map +1 -1
- package/dist/types/methods/passkey.d.ts.map +1 -1
- package/dist/types/methods/password.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
- package/dist/types/ports/audit-log.d.ts +25 -1
- package/dist/types/ports/audit-log.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +49 -39
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/method.d.ts +11 -0
- package/dist/types/types/method.d.ts.map +1 -1
- package/package.json +2 -1
- package/src/client.ts +145 -129
- package/src/domain/authorize.ts +25 -4
- package/src/domain/callback.ts +40 -36
- package/src/domain/client-credentials.ts +20 -1
- package/src/domain/method-dispatch.ts +6 -0
- package/src/domain/method-route.ts +18 -0
- package/src/domain/mount.ts +113 -0
- package/src/domain/refresh.ts +39 -10
- package/src/domain/register.ts +27 -8
- package/src/domain/state-envelope.ts +40 -0
- package/src/domain/subject.ts +103 -0
- package/src/domain/token.ts +25 -1
- package/src/http/context.ts +3 -0
- package/src/http/handlers/method-route.ts +9 -5
- package/src/http/handlers/token.ts +2 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +18 -2
- package/src/methods/code.ts +15 -6
- package/src/methods/oauth2-generic.ts +6 -1
- package/src/methods/passkey.ts +6 -2
- package/src/methods/password.ts +19 -4
- package/src/methods/saml-sp/metadata.ts +5 -1
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +49 -42
- package/src/types/method.ts +11 -0
|
@@ -33,6 +33,11 @@ export type DispatchInput = {
|
|
|
33
33
|
flow: FlowRecord | null
|
|
34
34
|
cookies: ReadonlyMap<string, string>
|
|
35
35
|
sessionStore: SessionStore
|
|
36
|
+
/**
|
|
37
|
+
* Issuer URL of this IdP. Always required — unlike `dispatch`, methods
|
|
38
|
+
* need it on every route to emit mount-prefixed URLs of their own.
|
|
39
|
+
*/
|
|
40
|
+
issuerUrl: string
|
|
36
41
|
/** Populated at `GET /authorize`; null on callbacks. */
|
|
37
42
|
dispatch: MethodDispatchData | null
|
|
38
43
|
}
|
|
@@ -57,6 +62,7 @@ export async function dispatchMethod(
|
|
|
57
62
|
|
|
58
63
|
const ctx: MethodContext<unknown> = {
|
|
59
64
|
request: input.request,
|
|
65
|
+
issuerUrl: input.issuerUrl,
|
|
60
66
|
subPath: input.subPath,
|
|
61
67
|
tenant: input.tenant,
|
|
62
68
|
flow: input.flow,
|
|
@@ -79,6 +79,12 @@ export type HandleMethodRouteInput = {
|
|
|
79
79
|
/** flowId from the `idp.flow` cookie. */
|
|
80
80
|
flowId: string
|
|
81
81
|
cookies: ReadonlyMap<string, string>
|
|
82
|
+
/**
|
|
83
|
+
* Issuer URL — passed to the method so it can emit mount-prefixed URLs
|
|
84
|
+
* (a re-rendered form action, say). The inbound request URL cannot
|
|
85
|
+
* supply this: a proxy has already stripped the mount prefix from it.
|
|
86
|
+
*/
|
|
87
|
+
issuerUrl: string
|
|
82
88
|
}
|
|
83
89
|
|
|
84
90
|
export type HandleMethodRouteDeps = {
|
|
@@ -139,6 +145,7 @@ export async function handleMethodRoute(
|
|
|
139
145
|
flow,
|
|
140
146
|
cookies: input.cookies,
|
|
141
147
|
sessionStore: deps.sessionStore,
|
|
148
|
+
issuerUrl: input.issuerUrl,
|
|
142
149
|
dispatch: null,
|
|
143
150
|
})
|
|
144
151
|
if (isErr(dispatched)) return err(dispatched.error)
|
|
@@ -185,6 +192,16 @@ async function translate(
|
|
|
185
192
|
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
186
193
|
)
|
|
187
194
|
if (isErr(saved)) return err(saved.error)
|
|
195
|
+
await safeAudit(deps, {
|
|
196
|
+
kind: "authorize_succeeded",
|
|
197
|
+
tenantId: final.tenantId,
|
|
198
|
+
clientId: final.clientId,
|
|
199
|
+
methodId: final.methodId,
|
|
200
|
+
methodKind: final.methodKind,
|
|
201
|
+
flowId: final.flowId,
|
|
202
|
+
providerSubject: result.providerSubject,
|
|
203
|
+
timestamp: now,
|
|
204
|
+
})
|
|
188
205
|
return ok({
|
|
189
206
|
kind: "issue-code",
|
|
190
207
|
code,
|
|
@@ -295,6 +312,7 @@ export async function handlePublicMethodRoute(
|
|
|
295
312
|
flow: null,
|
|
296
313
|
cookies: new Map(),
|
|
297
314
|
sessionStore: deps.sessionStore,
|
|
315
|
+
issuerUrl: input.dispatch.issuerUrl,
|
|
298
316
|
dispatch: input.dispatch,
|
|
299
317
|
})
|
|
300
318
|
if (isErr(dispatched)) return err(dispatched.error)
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount prefix — where this IdP sits under its origin.
|
|
3
|
+
*
|
|
4
|
+
* The library serves its routes at its own root (`/authorize`, `/m/*`,
|
|
5
|
+
* `/cb/*`); a deployment may sit behind a reverse proxy that strips a path
|
|
6
|
+
* prefix before forwarding. Routing is unaffected — but every URL the
|
|
7
|
+
* library *emits* (form actions, upstream `redirect_uri`s, SAML metadata)
|
|
8
|
+
* is consumed on the public side of that proxy and must carry the prefix.
|
|
9
|
+
*
|
|
10
|
+
* `issuerUrl` already carries it. It is the single source of truth: there
|
|
11
|
+
* is deliberately no `basePath` option, because two sources could disagree
|
|
12
|
+
* and the `iss` claim would then contradict the URLs we hand out.
|
|
13
|
+
*
|
|
14
|
+
* All emitted-URL construction goes through this module. `metadata.test.ts`
|
|
15
|
+
* is an anti-drift test asserting the SAML entityID/ACS equal what
|
|
16
|
+
* `buildAuthnRequestRedirect` derives; funnelling every site through
|
|
17
|
+
* `callbackTarget` is what keeps that true.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The normalised mount prefix of `issuerUrl`: `""` for a root-mounted
|
|
22
|
+
* deployment, otherwise a leading-slash, no-trailing-slash path.
|
|
23
|
+
*
|
|
24
|
+
* https://example.com -> ""
|
|
25
|
+
* https://example.com/ -> ""
|
|
26
|
+
* https://example.com/idp -> "/idp"
|
|
27
|
+
* https://example.com/idp/ -> "/idp"
|
|
28
|
+
* https://example.com//idp// -> "/idp"
|
|
29
|
+
*
|
|
30
|
+
* A root-mounted issuer must yield `""` and not `"/"`, so that existing
|
|
31
|
+
* deployments emit byte-identical URLs to before this existed.
|
|
32
|
+
*
|
|
33
|
+
* An unparseable `issuerUrl` yields `""` rather than throwing: emitting a
|
|
34
|
+
* root-relative URL degrades to the pre-existing behaviour, whereas
|
|
35
|
+
* throwing would turn a misconfiguration into a 500 from inside form
|
|
36
|
+
* rendering. Misconfiguration still fails loudly at `/authorize`, which
|
|
37
|
+
* parses `issuerUrl` for the callback host.
|
|
38
|
+
*/
|
|
39
|
+
export function mountPath(issuerUrl: string): string {
|
|
40
|
+
let pathname: string
|
|
41
|
+
try {
|
|
42
|
+
pathname = new URL(issuerUrl).pathname
|
|
43
|
+
} catch {
|
|
44
|
+
return ""
|
|
45
|
+
}
|
|
46
|
+
const segments = pathname.split("/").filter(Boolean)
|
|
47
|
+
return segments.length === 0 ? "" : `/${segments.join("/")}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A path-absolute URL for one of the library's own routes, carrying the
|
|
52
|
+
* deployment's mount prefix. `path` is the route as the service itself
|
|
53
|
+
* serves it, with a leading slash.
|
|
54
|
+
*
|
|
55
|
+
* mountedPath("https://x/idp", "/m/code/send") -> "/idp/m/code/send"
|
|
56
|
+
* mountedPath("https://x", "/m/code/send") -> "/m/code/send"
|
|
57
|
+
*/
|
|
58
|
+
export function mountedPath(issuerUrl: string, path: string): string {
|
|
59
|
+
return `${mountPath(issuerUrl)}${path}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Where a method's callback lives, in the two forms the framework needs.
|
|
64
|
+
*
|
|
65
|
+
* The distinction matters under a path-mounted deployment and is the
|
|
66
|
+
* reason these are derived together rather than one from the other:
|
|
67
|
+
* `url` is public (registered with upstream providers, advertised in SAML
|
|
68
|
+
* metadata) and carries the mount prefix, while `path` is what the
|
|
69
|
+
* inbound request actually looks like after the proxy has stripped that
|
|
70
|
+
* prefix, and so must not.
|
|
71
|
+
*/
|
|
72
|
+
export type CallbackTarget = {
|
|
73
|
+
/** Public, fully-qualified callback URL. Carries the mount prefix. */
|
|
74
|
+
url: string
|
|
75
|
+
/** Host the callback is expected to arrive on. */
|
|
76
|
+
host: string
|
|
77
|
+
/**
|
|
78
|
+
* Expected pathname of the inbound request — this service's own route,
|
|
79
|
+
* *without* the mount prefix, because the proxy has already stripped it.
|
|
80
|
+
* Persisted on `FlowRecord.callbackPath` and matched in `domain/callback.ts`.
|
|
81
|
+
*/
|
|
82
|
+
path: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The single derivation point for a method's callback URL.
|
|
87
|
+
*
|
|
88
|
+
* `callbackHost` is the optional per-tenant override from
|
|
89
|
+
* `IdPOptions.callbackHostFor`. **The issuer's mount prefix applies even
|
|
90
|
+
* when that override is in effect.** `callbackHostFor` exists to partition
|
|
91
|
+
* callbacks across hostnames so a tenant is recoverable from the `Host`
|
|
92
|
+
* header before the state envelope is verified (tenant-recovery mechanism
|
|
93
|
+
* #2) — it varies the authority of the *same* deployment, not the
|
|
94
|
+
* deployment itself. Those hosts are served by this service behind the
|
|
95
|
+
* same proxy, so they share its mount. A deployment needing partitioned
|
|
96
|
+
* hosts mounted differently from the issuer is describing two mounts,
|
|
97
|
+
* which one `issuerUrl` cannot express and a second option must not be
|
|
98
|
+
* introduced to paper over.
|
|
99
|
+
*/
|
|
100
|
+
export function callbackTarget(input: {
|
|
101
|
+
issuerUrl: string
|
|
102
|
+
methodId: string
|
|
103
|
+
callbackHost?: string | undefined
|
|
104
|
+
}): CallbackTarget {
|
|
105
|
+
const issuer = new URL(input.issuerUrl)
|
|
106
|
+
const host = input.callbackHost ?? issuer.host
|
|
107
|
+
const path = `/cb/${input.methodId}`
|
|
108
|
+
return {
|
|
109
|
+
host,
|
|
110
|
+
path,
|
|
111
|
+
url: `${issuer.protocol}//${host}${mountPath(input.issuerUrl)}${path}`,
|
|
112
|
+
}
|
|
113
|
+
}
|
package/src/domain/refresh.ts
CHANGED
|
@@ -57,6 +57,26 @@ export type RefreshTokensDeps = {
|
|
|
57
57
|
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Requested scopes must be a subset of what the grant carries (RFC 6749
|
|
62
|
+
* §6). Returns the error rather than throwing so both call sites -- the
|
|
63
|
+
* pre-consume gate and the post-consume re-check -- read identically.
|
|
64
|
+
*/
|
|
65
|
+
function validateRequestedScopes(
|
|
66
|
+
requested: string | undefined,
|
|
67
|
+
granted: string[],
|
|
68
|
+
): AuthError | null {
|
|
69
|
+
if (!requested) return null
|
|
70
|
+
for (const s of requested.split(" ").filter(Boolean)) {
|
|
71
|
+
if (!granted.includes(s)) {
|
|
72
|
+
return authError.invalidScope(
|
|
73
|
+
`requested scope "${s}" not granted by original refresh token`,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
60
80
|
export async function refreshTokens(
|
|
61
81
|
req: RefreshGrantRequest,
|
|
62
82
|
deps: RefreshTokensDeps,
|
|
@@ -123,6 +143,16 @@ export async function refreshTokens(
|
|
|
123
143
|
}
|
|
124
144
|
}
|
|
125
145
|
|
|
146
|
+
// Scope narrowing is validated against the *peeked* grant, before the
|
|
147
|
+
// token is consumed. Same reasoning as the client-auth and DPoP gates
|
|
148
|
+
// above: a request the grant cannot satisfy must not burn the token,
|
|
149
|
+
// or a client typo makes the next legitimate refresh look like theft
|
|
150
|
+
// and can revoke the whole family. `consumeRefresh` stays the
|
|
151
|
+
// authoritative atomic gate immediately below (TokenStore port:
|
|
152
|
+
// "callers race peekRefresh then consumeRefresh").
|
|
153
|
+
const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes)
|
|
154
|
+
if (scopeErr) return err(scopeErr)
|
|
155
|
+
|
|
126
156
|
const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
|
|
127
157
|
reuseWindowMs: deps.reuseWindowMs,
|
|
128
158
|
})
|
|
@@ -147,19 +177,18 @@ export async function refreshTokens(
|
|
|
147
177
|
}
|
|
148
178
|
const payload = consumed.value
|
|
149
179
|
|
|
150
|
-
//
|
|
180
|
+
// Re-check against the consumed payload, which the port declares
|
|
181
|
+
// authoritative. In practice this cannot diverge from the peeked
|
|
182
|
+
// grant -- rotation preserves scopes -- so this is defence in depth,
|
|
183
|
+
// not the user-facing gate; that already ran above without burning.
|
|
184
|
+
const authoritativeScopeErr = validateRequestedScopes(
|
|
185
|
+
req.scope,
|
|
186
|
+
payload.scopes,
|
|
187
|
+
)
|
|
188
|
+
if (authoritativeScopeErr) return err(authoritativeScopeErr)
|
|
151
189
|
const requestedScopes = req.scope
|
|
152
190
|
? req.scope.split(" ").filter(Boolean)
|
|
153
191
|
: payload.scopes
|
|
154
|
-
for (const s of requestedScopes) {
|
|
155
|
-
if (!payload.scopes.includes(s)) {
|
|
156
|
-
return err(
|
|
157
|
-
authError.invalidScope(
|
|
158
|
-
`requested scope "${s}" not granted by original refresh token`,
|
|
159
|
-
),
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
192
|
|
|
164
193
|
const tenant: TenantContext = {
|
|
165
194
|
id: payload.tenantId,
|
package/src/domain/register.ts
CHANGED
|
@@ -136,17 +136,36 @@ export async function registerNewClient(
|
|
|
136
136
|
const hookResult = await deps.registerClient({
|
|
137
137
|
tenant,
|
|
138
138
|
request,
|
|
139
|
+
client: clientConfig,
|
|
140
|
+
...(secret !== undefined ? { secret } : {}),
|
|
139
141
|
})
|
|
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
142
|
if (isErr(hookResult)) return err(hookResult.error)
|
|
143
|
+
|
|
144
|
+
// The host returns what it actually persisted — normally `clientConfig`
|
|
145
|
+
// unchanged, sometimes adjusted (narrowed scopes, host-owned id). That
|
|
146
|
+
// return is authoritative; the framework does not second-guess it.
|
|
148
147
|
const persisted = hookResult.value.client
|
|
149
|
-
|
|
148
|
+
// The plaintext may only be reused when the host persisted *our* hash;
|
|
149
|
+
// if it substituted its own, our secret would not verify and the RP
|
|
150
|
+
// would receive a credential that silently never works.
|
|
151
|
+
const keptOurSecret =
|
|
152
|
+
persisted.type === "confidential" &&
|
|
153
|
+
clientConfig.type === "confidential" &&
|
|
154
|
+
persisted.secretHash === clientConfig.secretHash
|
|
155
|
+
const persistedSecret =
|
|
156
|
+
hookResult.value.secret ?? (keptOurSecret ? secret : undefined)
|
|
157
|
+
|
|
158
|
+
// RFC 7591 §3.2.1 — a confidential client must leave registration with
|
|
159
|
+
// a usable secret. Better to fail loudly than to hand back a client
|
|
160
|
+
// that cannot authenticate.
|
|
161
|
+
if (persisted.type === "confidential" && persistedSecret === undefined) {
|
|
162
|
+
return err(
|
|
163
|
+
authError.serverError(
|
|
164
|
+
"registerClient persisted a confidential client with a substituted " +
|
|
165
|
+
"secretHash but returned no matching plaintext secret",
|
|
166
|
+
),
|
|
167
|
+
)
|
|
168
|
+
}
|
|
150
169
|
|
|
151
170
|
const issuedAt = Math.floor(deps.clock() / 1000)
|
|
152
171
|
return ok({
|
|
@@ -140,3 +140,43 @@ function isEnvelopeShape(value: unknown): value is EnvelopeShape {
|
|
|
140
140
|
typeof v.kid === "string"
|
|
141
141
|
)
|
|
142
142
|
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Pull the MAC state envelope off an inbound callback request.
|
|
146
|
+
*
|
|
147
|
+
* The envelope normally rides `?state=` on the upstream redirect.
|
|
148
|
+
* POST-binding callbacks carry it in the form body instead: OAuth
|
|
149
|
+
* `response_mode=form_post` uses `state`, SAML's HTTP-POST binding uses
|
|
150
|
+
* `RelayState`. Read the query first (cheap, the common case) and fall
|
|
151
|
+
* back to a **cloned** body read so the downstream method handler still
|
|
152
|
+
* gets an unconsumed request body (it needs it for `code` /
|
|
153
|
+
* `SAMLResponse`).
|
|
154
|
+
*
|
|
155
|
+
* Any body-parse failure degrades to "no state" — identical to the
|
|
156
|
+
* behaviour when the query param is simply absent.
|
|
157
|
+
*
|
|
158
|
+
* **This is the single extraction point**, shared by the tenant
|
|
159
|
+
* middleware and the callback domain. They ran near-identical copies
|
|
160
|
+
* until 0.14.0, and the middleware copy — which runs *first* — read only
|
|
161
|
+
* `state`. SP-initiated SAML callbacks therefore fell through to
|
|
162
|
+
* `resolveTenant`, breaking the documented guarantee that callbacks
|
|
163
|
+
* recover the tenant without it. Keep exactly one of these.
|
|
164
|
+
*/
|
|
165
|
+
export async function extractCallbackState(
|
|
166
|
+
req: Request,
|
|
167
|
+
): Promise<string | null> {
|
|
168
|
+
const fromQuery = new URL(req.url).searchParams.get("state")
|
|
169
|
+
if (fromQuery) return fromQuery
|
|
170
|
+
if (req.method !== "POST") return null
|
|
171
|
+
const ct = req.headers.get("content-type") ?? ""
|
|
172
|
+
if (!ct.toLowerCase().includes("application/x-www-form-urlencoded")) {
|
|
173
|
+
return null
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const form = new URLSearchParams(await req.clone().text())
|
|
177
|
+
const v = form.get("state") ?? form.get("RelayState")
|
|
178
|
+
return v && v.length > 0 ? v : null
|
|
179
|
+
} catch {
|
|
180
|
+
return null
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subject-claim validation at issuance.
|
|
3
|
+
*
|
|
4
|
+
* `IdPOptions.subjects` is a required option, and until 0.14.0 nothing on
|
|
5
|
+
* the server read it: `createIdP` accepted the schema and signed whatever
|
|
6
|
+
* `success()` returned. Two things went wrong as a result.
|
|
7
|
+
*
|
|
8
|
+
* First, the failure surfaced in the wrong place. `client.verify()` does
|
|
9
|
+
* validate against the same schema, so a malformed claim was caught by the
|
|
10
|
+
* relying party — after the token had been signed, written into the refresh
|
|
11
|
+
* payload, and handed to anything else holding it. Validating here turns a
|
|
12
|
+
* late failure in another service into an immediate local one.
|
|
13
|
+
*
|
|
14
|
+
* Second, the types were unsound. `SubjectPayload` declares
|
|
15
|
+
* `properties: v1.InferOutput<T[type]>` — the schema's *parsed output* —
|
|
16
|
+
* but with no parse anywhere the runtime value was the raw input. Any
|
|
17
|
+
* schema carrying a transform or a default had a declared type that lied.
|
|
18
|
+
* Returning the validated value is what makes that declaration true, and
|
|
19
|
+
* it makes the issued token agree with what `client.verify()` will hand
|
|
20
|
+
* back to the RP.
|
|
21
|
+
*
|
|
22
|
+
* Standard Schema keeps this validator spec-neutral (zod, valibot,
|
|
23
|
+
* arktype) and adds no dependency the public surface didn't already have.
|
|
24
|
+
*/
|
|
25
|
+
import type { AuthError } from "../types/error"
|
|
26
|
+
import { authError } from "../types/error"
|
|
27
|
+
import type { Result } from "../types/result"
|
|
28
|
+
import { err, ok } from "../types/result"
|
|
29
|
+
import type { SubjectClaim, SubjectSchema } from "../types/subject"
|
|
30
|
+
|
|
31
|
+
/** Why a claim was rejected — enough for an operator, never the values. */
|
|
32
|
+
export type SubjectClaimRejection = {
|
|
33
|
+
reason: "unknown-type" | "invalid-properties"
|
|
34
|
+
/** The offending `claim.type`. Host-declared, safe to log. */
|
|
35
|
+
subjectType: string
|
|
36
|
+
/** Standard Schema issue path, or the list of declared types. */
|
|
37
|
+
detail: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate the host's `SubjectClaim` against its own declared schema.
|
|
42
|
+
*
|
|
43
|
+
* On success the returned claim carries the **parsed** properties, which
|
|
44
|
+
* is what gets signed and persisted. On failure the caller emits an
|
|
45
|
+
* `invalid_subject_claim` audit event and returns a server error: the
|
|
46
|
+
* host's callback broke its own contract, which is a deployment fault,
|
|
47
|
+
* not something the relying party did wrong.
|
|
48
|
+
*/
|
|
49
|
+
export async function validateSubjectClaim(
|
|
50
|
+
subjects: SubjectSchema,
|
|
51
|
+
claim: SubjectClaim,
|
|
52
|
+
): Promise<
|
|
53
|
+
Result<SubjectClaim, AuthError & { rejection: SubjectClaimRejection }>
|
|
54
|
+
> {
|
|
55
|
+
const declared = Object.keys(subjects)
|
|
56
|
+
const subjectType = typeof claim?.type === "string" ? claim.type : ""
|
|
57
|
+
|
|
58
|
+
const schema = subjectType ? subjects[subjectType] : undefined
|
|
59
|
+
if (!schema) {
|
|
60
|
+
return err(
|
|
61
|
+
Object.assign(
|
|
62
|
+
authError.serverError(
|
|
63
|
+
`success() returned subject type "${subjectType}", which is not declared in \`subjects\``,
|
|
64
|
+
),
|
|
65
|
+
{
|
|
66
|
+
rejection: {
|
|
67
|
+
reason: "unknown-type" as const,
|
|
68
|
+
subjectType,
|
|
69
|
+
detail: `declared: ${declared.join(", ") || "(none)"}`,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const validated = await schema["~standard"].validate(claim.properties)
|
|
77
|
+
if (validated.issues) {
|
|
78
|
+
// Paths only — the values themselves may be personal data.
|
|
79
|
+
const detail =
|
|
80
|
+
validated.issues
|
|
81
|
+
.map((i) => (i.path ?? []).map(String).join(".") || "(root)")
|
|
82
|
+
.join(", ") || "(root)"
|
|
83
|
+
return err(
|
|
84
|
+
Object.assign(
|
|
85
|
+
authError.serverError(
|
|
86
|
+
`success() returned properties that violate the "${subjectType}" schema`,
|
|
87
|
+
),
|
|
88
|
+
{
|
|
89
|
+
rejection: {
|
|
90
|
+
reason: "invalid-properties" as const,
|
|
91
|
+
subjectType,
|
|
92
|
+
detail,
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return ok({
|
|
100
|
+
type: subjectType,
|
|
101
|
+
properties: validated.value,
|
|
102
|
+
} as SubjectClaim)
|
|
103
|
+
}
|
package/src/domain/token.ts
CHANGED
|
@@ -27,7 +27,7 @@ import { authError } from "../types/error"
|
|
|
27
27
|
import type { PersistUpstreamTokens, SuccessMapInput } from "../types/idp"
|
|
28
28
|
import type { Result } from "../types/result"
|
|
29
29
|
import { err, isErr, ok } from "../types/result"
|
|
30
|
-
import type { SubjectClaim } from "../types/subject"
|
|
30
|
+
import type { SubjectClaim, SubjectSchema } from "../types/subject"
|
|
31
31
|
import type { TenantContext } from "../types/tenant"
|
|
32
32
|
import type {
|
|
33
33
|
AccessTokenClaims,
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
|
|
51
51
|
import { signAccessToken, signIdToken } from "./jwt"
|
|
52
52
|
import { validatePkce } from "./pkce"
|
|
53
|
+
import { validateSubjectClaim } from "./subject"
|
|
53
54
|
|
|
54
55
|
/**
|
|
55
56
|
* Encrypt a `CodePayload` with the active `KeyStore` encryption key and
|
|
@@ -104,6 +105,12 @@ export type ExchangeCodeDeps = {
|
|
|
104
105
|
keyStore: KeyStore
|
|
105
106
|
auditLog?: AuditLog
|
|
106
107
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
108
|
+
/**
|
|
109
|
+
* The host's declared subject schemas. The claim `success()` returns is
|
|
110
|
+
* validated against these before anything is signed — see
|
|
111
|
+
* `domain/subject.ts` for why that is the library's job.
|
|
112
|
+
*/
|
|
113
|
+
subjects: SubjectSchema
|
|
107
114
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
108
115
|
issuerUrl: string
|
|
109
116
|
clock: () => number
|
|
@@ -204,6 +211,23 @@ export async function exchangeCode(
|
|
|
204
211
|
return err(authError.serverError("success callback threw", e))
|
|
205
212
|
}
|
|
206
213
|
|
|
214
|
+
const checked = await validateSubjectClaim(deps.subjects, claim)
|
|
215
|
+
if (isErr(checked)) {
|
|
216
|
+
await safeAudit(deps, {
|
|
217
|
+
kind: "invalid_subject_claim",
|
|
218
|
+
tenantId: payload.tenantId,
|
|
219
|
+
clientId: payload.clientId,
|
|
220
|
+
subjectType: checked.error.rejection.subjectType,
|
|
221
|
+
reason: checked.error.rejection.reason,
|
|
222
|
+
detail: checked.error.rejection.detail,
|
|
223
|
+
timestamp: deps.clock(),
|
|
224
|
+
})
|
|
225
|
+
return err(checked.error)
|
|
226
|
+
}
|
|
227
|
+
// Parsed value from here on, so the token matches what the schema
|
|
228
|
+
// declares and what `client.verify()` returns to the RP.
|
|
229
|
+
claim = checked.value
|
|
230
|
+
|
|
207
231
|
// 7. Optional upstream-tokens hook (runs after success, before
|
|
208
232
|
// mint — failed mints below should NOT roll back this hook because
|
|
209
233
|
// by contract the hook itself decides whether to persist).
|
package/src/http/context.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { SessionStore } from "../ports/session-store"
|
|
|
16
16
|
import type { TokenStore } from "../ports/token-store"
|
|
17
17
|
import type { MethodCache } from "../domain/method-cache"
|
|
18
18
|
import type { AuthError } from "../types/error"
|
|
19
|
+
import type { SubjectSchema } from "../types/subject"
|
|
19
20
|
import type {
|
|
20
21
|
ExchangeAudience,
|
|
21
22
|
LogoutEventInput,
|
|
@@ -52,6 +53,8 @@ export type HttpDeps = {
|
|
|
52
53
|
resolveIssuer: (req: Request) => string
|
|
53
54
|
/** Optional partitioned-host helper (recovery #2). */
|
|
54
55
|
callbackHostFor?: (tenantId: TenantId) => string
|
|
56
|
+
/** Host-declared subject schemas, validated at issuance. */
|
|
57
|
+
subjects: SubjectSchema
|
|
55
58
|
resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
|
|
56
59
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
57
60
|
/** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
handlePublicMethodRoute,
|
|
13
13
|
} from "../../domain/method-route"
|
|
14
14
|
import type { RouteKey } from "../../domain/method-dispatch"
|
|
15
|
+
import { callbackTarget } from "../../domain/mount"
|
|
15
16
|
import { authError } from "../../types/error"
|
|
16
17
|
import { isErr } from "../../types/result"
|
|
17
18
|
|
|
@@ -51,13 +52,15 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
|
|
|
51
52
|
resolved.value.publicRoutes?.includes(routeKey)
|
|
52
53
|
) {
|
|
53
54
|
const issuerUrl = c.get("issuerUrl")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// Mirrors the ACS URL derivation in domain/authorize.ts
|
|
57
|
-
// (callbackHost/path/url). Source of truth is there; the
|
|
55
|
+
// Same derivation as domain/authorize.ts — both call
|
|
56
|
+
// `callbackTarget`, which is the single source of truth. The
|
|
58
57
|
// metadata.test.ts anti-drift test asserts the emitted
|
|
59
58
|
// entityID/ACS equal what buildAuthnRequestRedirect derives.
|
|
60
|
-
const callbackUrl =
|
|
59
|
+
const { url: callbackUrl } = callbackTarget({
|
|
60
|
+
issuerUrl,
|
|
61
|
+
methodId,
|
|
62
|
+
callbackHost: deps.callbackHostFor?.(tenant.id),
|
|
63
|
+
})
|
|
61
64
|
const pub = await handlePublicMethodRoute(
|
|
62
65
|
{
|
|
63
66
|
rawRequest: c.req.raw,
|
|
@@ -122,6 +125,7 @@ export function makeMethodRouteHandler(deps: HttpDeps) {
|
|
|
122
125
|
httpMethod,
|
|
123
126
|
flowId,
|
|
124
127
|
cookies,
|
|
128
|
+
issuerUrl: c.get("issuerUrl"),
|
|
125
129
|
},
|
|
126
130
|
{
|
|
127
131
|
sessionStore: deps.sessionStore,
|
|
@@ -127,6 +127,7 @@ export function makeTokenHandler(deps: HttpDeps) {
|
|
|
127
127
|
keyStore: deps.keyStore,
|
|
128
128
|
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
129
129
|
success: deps.success,
|
|
130
|
+
subjects: deps.subjects,
|
|
130
131
|
...(deps.persistUpstreamTokens
|
|
131
132
|
? { persistUpstreamTokens: deps.persistUpstreamTokens }
|
|
132
133
|
: {}),
|
|
@@ -174,6 +175,7 @@ export function makeTokenHandler(deps: HttpDeps) {
|
|
|
174
175
|
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
175
176
|
methodCache: deps.methodCache,
|
|
176
177
|
success: deps.success,
|
|
178
|
+
subjects: deps.subjects,
|
|
177
179
|
...(deps.persistUpstreamTokens
|
|
178
180
|
? { persistUpstreamTokens: deps.persistUpstreamTokens }
|
|
179
181
|
: {}),
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import type { MiddlewareHandler } from "hono"
|
|
19
19
|
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
extractCallbackState,
|
|
22
|
+
verifyStateEnvelope,
|
|
23
|
+
} from "../../domain/state-envelope"
|
|
21
24
|
import { isErr } from "../../types/result"
|
|
22
25
|
import type {
|
|
23
26
|
TenantConfig,
|
|
@@ -174,7 +177,7 @@ async function runCallbackRecovery(
|
|
|
174
177
|
req: Request,
|
|
175
178
|
deps: HttpDeps,
|
|
176
179
|
): Promise<TenantRecovery> {
|
|
177
|
-
const state = await
|
|
180
|
+
const state = await extractCallbackState(req)
|
|
178
181
|
if (state) {
|
|
179
182
|
const env = await verifyStateEnvelope(state, deps.stateKeys)
|
|
180
183
|
if (env.ok) {
|
|
@@ -187,25 +190,3 @@ async function runCallbackRecovery(
|
|
|
187
190
|
}
|
|
188
191
|
return { kind: "fresh-request" }
|
|
189
192
|
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* Pull `state` from the query for GET callbacks, or from a form body for
|
|
193
|
-
* POST callbacks (Apple's `response_mode=form_post`). The body is cloned
|
|
194
|
-
* so the handler can still read it.
|
|
195
|
-
*/
|
|
196
|
-
async function extractStateParam(req: Request): Promise<string | null> {
|
|
197
|
-
const url = new URL(req.url)
|
|
198
|
-
const fromQuery = url.searchParams.get("state")
|
|
199
|
-
if (fromQuery) return fromQuery
|
|
200
|
-
if (req.method !== "POST") return null
|
|
201
|
-
const ct = req.headers.get("content-type") ?? ""
|
|
202
|
-
if (!ct.toLowerCase().startsWith("application/x-www-form-urlencoded")) {
|
|
203
|
-
return null
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
const text = await req.clone().text()
|
|
207
|
-
return new URLSearchParams(text).get("state")
|
|
208
|
-
} catch {
|
|
209
|
-
return null
|
|
210
|
-
}
|
|
211
|
-
}
|
package/src/index.ts
CHANGED
|
@@ -73,7 +73,6 @@ export type {
|
|
|
73
73
|
|
|
74
74
|
export type {
|
|
75
75
|
ExchangeAudience,
|
|
76
|
-
FailureEvent,
|
|
77
76
|
IdP,
|
|
78
77
|
IdPOptions,
|
|
79
78
|
LogoutEventInput,
|
|
@@ -83,7 +82,6 @@ export type {
|
|
|
83
82
|
RegisterClientRequest,
|
|
84
83
|
RegisterClientResponse,
|
|
85
84
|
RenderPicker,
|
|
86
|
-
SuccessEvent,
|
|
87
85
|
SuccessMapInput,
|
|
88
86
|
} from "./types/idp"
|
|
89
87
|
|
|
@@ -133,6 +131,23 @@ export type { TokenStore } from "./ports/token-store"
|
|
|
133
131
|
*/
|
|
134
132
|
export { revokeAllForSubject } from "./domain/revoke"
|
|
135
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Build a path-absolute URL for one of the library's own routes, carrying
|
|
136
|
+
* the mount prefix from `issuerUrl`.
|
|
137
|
+
*
|
|
138
|
+
* Custom methods that render their own URLs — a form `action`, say — must
|
|
139
|
+
* use this rather than a path-absolute literal, or the URL will 404 under
|
|
140
|
+
* a deployment mounted behind a proxy at a path prefix. Root-mounted
|
|
141
|
+
* issuers yield the literal unchanged.
|
|
142
|
+
*
|
|
143
|
+
* mountedPath(ctx.issuerUrl, `/m/${id}/send`)
|
|
144
|
+
* // "https://x" -> "/m/mycode/send"
|
|
145
|
+
* // "https://x/idp" -> "/idp/m/mycode/send"
|
|
146
|
+
*
|
|
147
|
+
* See INTEGRATION.md § 8.
|
|
148
|
+
*/
|
|
149
|
+
export { mountedPath, mountPath } from "./domain/mount"
|
|
150
|
+
|
|
136
151
|
// Phase 4 — credential + WebAuthn method factories.
|
|
137
152
|
export { passwordMethod } from "./methods/password"
|
|
138
153
|
export type {
|
|
@@ -268,6 +283,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
268
283
|
...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
|
|
269
284
|
resolveTenant: opts.resolveTenant,
|
|
270
285
|
success: opts.success,
|
|
286
|
+
subjects: opts.subjects,
|
|
271
287
|
...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
|
|
272
288
|
...(opts.persistUpstreamTokens
|
|
273
289
|
? { persistUpstreamTokens: opts.persistUpstreamTokens }
|