@_mustachio/openauth 0.13.3 → 0.15.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 +10 -0
- package/dist/esm/domain/callback.js +21 -19
- package/dist/esm/domain/client-credentials.js +16 -0
- package/dist/esm/domain/method-route.js +10 -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 +28 -0
- package/dist/esm/http/handlers/token.js +6 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +2 -0
- 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.map +1 -1
- package/dist/types/domain/client-credentials.d.ts +6 -2
- package/dist/types/domain/client-credentials.d.ts.map +1 -1
- package/dist/types/domain/method-route.d.ts.map +1 -1
- package/dist/types/domain/refresh.d.ts +3 -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-exchange.d.ts +3 -1
- package/dist/types/domain/token-exchange.d.ts.map +1 -1
- package/dist/types/domain/token.d.ts +11 -2
- package/dist/types/domain/token.d.ts.map +1 -1
- package/dist/types/http/context.d.ts +6 -0
- package/dist/types/http/context.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 +1 -1
- package/dist/types/index.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 +105 -38
- package/dist/types/types/idp.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +145 -129
- package/src/domain/authorize.ts +10 -0
- package/src/domain/callback.ts +22 -29
- package/src/domain/client-credentials.ts +27 -2
- package/src/domain/method-route.ts +10 -0
- package/src/domain/refresh.ts +42 -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-exchange.ts +3 -1
- package/src/domain/token.ts +54 -2
- package/src/http/context.ts +6 -0
- package/src/http/handlers/token.ts +6 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +3 -2
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +107 -41
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
|
+
}
|
|
@@ -36,7 +36,7 @@ import type { ConfigStore } from "../ports/config-store"
|
|
|
36
36
|
import type { KeyStore } from "../ports/key-store"
|
|
37
37
|
import type { TokenStore } from "../ports/token-store"
|
|
38
38
|
import { authError, type AuthError } from "../types/error"
|
|
39
|
-
import type { ExchangeAudience } from "../types/idp"
|
|
39
|
+
import type { ExchangeAudience, OnTokenIssued } from "../types/idp"
|
|
40
40
|
import type { Result } from "../types/result"
|
|
41
41
|
import { err, isErr, ok } from "../types/result"
|
|
42
42
|
import type { SubjectClaim } from "../types/subject"
|
|
@@ -73,6 +73,8 @@ export type ExchangeTokenDeps = {
|
|
|
73
73
|
exchangeAudience?: ExchangeAudience
|
|
74
74
|
issuerUrl: string
|
|
75
75
|
clock: () => number
|
|
76
|
+
/** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
|
|
77
|
+
onTokenIssued?: OnTokenIssued
|
|
76
78
|
newRefreshFamily?: () => string
|
|
77
79
|
/** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
|
|
78
80
|
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
package/src/domain/token.ts
CHANGED
|
@@ -24,10 +24,14 @@ import type { TokenStore } from "../ports/token-store"
|
|
|
24
24
|
import type { ConfigStore } from "../ports/config-store"
|
|
25
25
|
import type { AuthError } from "../types/error"
|
|
26
26
|
import { authError } from "../types/error"
|
|
27
|
-
import type {
|
|
27
|
+
import type {
|
|
28
|
+
OnTokenIssued,
|
|
29
|
+
PersistUpstreamTokens,
|
|
30
|
+
SuccessMapInput,
|
|
31
|
+
} from "../types/idp"
|
|
28
32
|
import type { Result } from "../types/result"
|
|
29
33
|
import { err, isErr, ok } from "../types/result"
|
|
30
|
-
import type { SubjectClaim } from "../types/subject"
|
|
34
|
+
import type { SubjectClaim, SubjectSchema } from "../types/subject"
|
|
31
35
|
import type { TenantContext } from "../types/tenant"
|
|
32
36
|
import type {
|
|
33
37
|
AccessTokenClaims,
|
|
@@ -50,6 +54,7 @@ import {
|
|
|
50
54
|
import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token"
|
|
51
55
|
import { signAccessToken, signIdToken } from "./jwt"
|
|
52
56
|
import { validatePkce } from "./pkce"
|
|
57
|
+
import { validateSubjectClaim } from "./subject"
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
60
|
* Encrypt a `CodePayload` with the active `KeyStore` encryption key and
|
|
@@ -104,9 +109,17 @@ export type ExchangeCodeDeps = {
|
|
|
104
109
|
keyStore: KeyStore
|
|
105
110
|
auditLog?: AuditLog
|
|
106
111
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
112
|
+
/**
|
|
113
|
+
* The host's declared subject schemas. The claim `success()` returns is
|
|
114
|
+
* validated against these before anything is signed — see
|
|
115
|
+
* `domain/subject.ts` for why that is the library's job.
|
|
116
|
+
*/
|
|
117
|
+
subjects: SubjectSchema
|
|
107
118
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
108
119
|
issuerUrl: string
|
|
109
120
|
clock: () => number
|
|
121
|
+
/** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
|
|
122
|
+
onTokenIssued?: OnTokenIssued
|
|
110
123
|
newRefreshToken?: () => string
|
|
111
124
|
/** Test override. */
|
|
112
125
|
newRefreshFamily?: () => string
|
|
@@ -204,6 +217,23 @@ export async function exchangeCode(
|
|
|
204
217
|
return err(authError.serverError("success callback threw", e))
|
|
205
218
|
}
|
|
206
219
|
|
|
220
|
+
const checked = await validateSubjectClaim(deps.subjects, claim)
|
|
221
|
+
if (isErr(checked)) {
|
|
222
|
+
await safeAudit(deps, {
|
|
223
|
+
kind: "invalid_subject_claim",
|
|
224
|
+
tenantId: payload.tenantId,
|
|
225
|
+
clientId: payload.clientId,
|
|
226
|
+
subjectType: checked.error.rejection.subjectType,
|
|
227
|
+
reason: checked.error.rejection.reason,
|
|
228
|
+
detail: checked.error.rejection.detail,
|
|
229
|
+
timestamp: deps.clock(),
|
|
230
|
+
})
|
|
231
|
+
return err(checked.error)
|
|
232
|
+
}
|
|
233
|
+
// Parsed value from here on, so the token matches what the schema
|
|
234
|
+
// declares and what `client.verify()` returns to the RP.
|
|
235
|
+
claim = checked.value
|
|
236
|
+
|
|
207
237
|
// 7. Optional upstream-tokens hook (runs after success, before
|
|
208
238
|
// mint — failed mints below should NOT roll back this hook because
|
|
209
239
|
// by contract the hook itself decides whether to persist).
|
|
@@ -285,6 +315,7 @@ export async function mintTokens(args: {
|
|
|
285
315
|
auditLog?: AuditLog
|
|
286
316
|
issuerUrl: string
|
|
287
317
|
clock: () => number
|
|
318
|
+
onTokenIssued?: OnTokenIssued
|
|
288
319
|
newRefreshToken?: () => string
|
|
289
320
|
/**
|
|
290
321
|
* Host-supplied vendor scope → claim-names map merged into the
|
|
@@ -314,6 +345,27 @@ export async function mintTokens(args: {
|
|
|
314
345
|
receivingClient?.sectorIdentifier,
|
|
315
346
|
)
|
|
316
347
|
|
|
348
|
+
// Hand the host the derived subject id *before* anything durable is
|
|
349
|
+
// written. `subjectId` exists nowhere else — it is signed as `sub` and
|
|
350
|
+
// is the key `revokeBySubject` takes — so a host that misses it holds a
|
|
351
|
+
// token it can never revoke. Running after `saveRefresh` and aborting
|
|
352
|
+
// on failure would leave precisely that: a live chain with no record.
|
|
353
|
+
if (deps.onTokenIssued) {
|
|
354
|
+
try {
|
|
355
|
+
await deps.onTokenIssued({
|
|
356
|
+
tenant,
|
|
357
|
+
clientId: payload.clientId,
|
|
358
|
+
subjectId,
|
|
359
|
+
claim,
|
|
360
|
+
// No refresh row is written when `skipRefresh` is set, so there
|
|
361
|
+
// is no chain for the host to revoke by family.
|
|
362
|
+
...(skipRefresh ? {} : { family }),
|
|
363
|
+
})
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return err(authError.serverError("onTokenIssued hook threw", e))
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
317
369
|
const keyRes = await deps.keyStore.currentSigningKey()
|
|
318
370
|
if (isErr(keyRes)) return err(keyRes.error)
|
|
319
371
|
const signingKey = keyRes.value
|
package/src/http/context.ts
CHANGED
|
@@ -16,6 +16,8 @@ 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 { OnTokenIssued } from "../types/idp"
|
|
20
|
+
import type { SubjectSchema } from "../types/subject"
|
|
19
21
|
import type {
|
|
20
22
|
ExchangeAudience,
|
|
21
23
|
LogoutEventInput,
|
|
@@ -52,6 +54,10 @@ export type HttpDeps = {
|
|
|
52
54
|
resolveIssuer: (req: Request) => string
|
|
53
55
|
/** Optional partitioned-host helper (recovery #2). */
|
|
54
56
|
callbackHostFor?: (tenantId: TenantId) => string
|
|
57
|
+
/** Host-declared subject schemas, validated at issuance. */
|
|
58
|
+
subjects: SubjectSchema
|
|
59
|
+
/** See `IdPOptions.onTokenIssued`. */
|
|
60
|
+
onTokenIssued?: OnTokenIssued
|
|
55
61
|
resolveTenant: (req: Request) => Promise<Result<TenantId, AuthError>>
|
|
56
62
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
57
63
|
/** See `IdPOptions.onLogout` — upstream Single Logout host hook. */
|
|
@@ -127,11 +127,13 @@ 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
|
: {}),
|
|
133
134
|
issuerUrl: c.get("issuerUrl"),
|
|
134
135
|
clock: deps.clock,
|
|
136
|
+
...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
|
|
135
137
|
...(deps.customScopeClaims !== undefined
|
|
136
138
|
? { customScopeClaims: deps.customScopeClaims }
|
|
137
139
|
: {}),
|
|
@@ -174,11 +176,13 @@ export function makeTokenHandler(deps: HttpDeps) {
|
|
|
174
176
|
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
175
177
|
methodCache: deps.methodCache,
|
|
176
178
|
success: deps.success,
|
|
179
|
+
subjects: deps.subjects,
|
|
177
180
|
...(deps.persistUpstreamTokens
|
|
178
181
|
? { persistUpstreamTokens: deps.persistUpstreamTokens }
|
|
179
182
|
: {}),
|
|
180
183
|
issuerUrl: c.get("issuerUrl"),
|
|
181
184
|
clock: deps.clock,
|
|
185
|
+
...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
|
|
182
186
|
...(deps.customScopeClaims !== undefined
|
|
183
187
|
? { customScopeClaims: deps.customScopeClaims }
|
|
184
188
|
: {}),
|
|
@@ -208,6 +212,7 @@ export function makeTokenHandler(deps: HttpDeps) {
|
|
|
208
212
|
...(deps.auditLog ? { auditLog: deps.auditLog } : {}),
|
|
209
213
|
issuerUrl: c.get("issuerUrl"),
|
|
210
214
|
clock: deps.clock,
|
|
215
|
+
...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
|
|
211
216
|
...(deps.customScopeClaims !== undefined
|
|
212
217
|
? { customScopeClaims: deps.customScopeClaims }
|
|
213
218
|
: {}),
|
|
@@ -248,6 +253,7 @@ export function makeTokenHandler(deps: HttpDeps) {
|
|
|
248
253
|
: {}),
|
|
249
254
|
issuerUrl: c.get("issuerUrl"),
|
|
250
255
|
clock: deps.clock,
|
|
256
|
+
...(deps.onTokenIssued ? { onTokenIssued: deps.onTokenIssued } : {}),
|
|
251
257
|
...(deps.customScopeClaims !== undefined
|
|
252
258
|
? { customScopeClaims: deps.customScopeClaims }
|
|
253
259
|
: {}),
|
|
@@ -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,17 +73,16 @@ export type {
|
|
|
73
73
|
|
|
74
74
|
export type {
|
|
75
75
|
ExchangeAudience,
|
|
76
|
-
FailureEvent,
|
|
77
76
|
IdP,
|
|
78
77
|
IdPOptions,
|
|
79
78
|
LogoutEventInput,
|
|
80
79
|
LogoutHookResult,
|
|
80
|
+
OnTokenIssued,
|
|
81
81
|
PersistUpstreamTokens,
|
|
82
82
|
RegisterClient,
|
|
83
83
|
RegisterClientRequest,
|
|
84
84
|
RegisterClientResponse,
|
|
85
85
|
RenderPicker,
|
|
86
|
-
SuccessEvent,
|
|
87
86
|
SuccessMapInput,
|
|
88
87
|
} from "./types/idp"
|
|
89
88
|
|
|
@@ -285,6 +284,8 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
285
284
|
...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
|
|
286
285
|
resolveTenant: opts.resolveTenant,
|
|
287
286
|
success: opts.success,
|
|
287
|
+
subjects: opts.subjects,
|
|
288
|
+
...(opts.onTokenIssued ? { onTokenIssued: opts.onTokenIssued } : {}),
|
|
288
289
|
...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
|
|
289
290
|
...(opts.persistUpstreamTokens
|
|
290
291
|
? { persistUpstreamTokens: opts.persistUpstreamTokens }
|
package/src/ports/audit-log.ts
CHANGED
|
@@ -28,13 +28,24 @@ export type AuditEvent =
|
|
|
28
28
|
flowId: string
|
|
29
29
|
}
|
|
30
30
|
| {
|
|
31
|
+
/**
|
|
32
|
+
* A method authenticated the end user and an authorization code was
|
|
33
|
+
* minted. Completes the `authorize_started` / `authorize_failed`
|
|
34
|
+
* pair, which shipped without it.
|
|
35
|
+
*/
|
|
31
36
|
kind: "authorize_succeeded"
|
|
32
37
|
tenantId: TenantId
|
|
33
38
|
clientId: string
|
|
34
39
|
methodId: string
|
|
35
40
|
methodKind: string
|
|
36
41
|
flowId: string
|
|
37
|
-
|
|
42
|
+
/**
|
|
43
|
+
* The **upstream** identifier the method returned. Deliberately not
|
|
44
|
+
* `subjectId`: the OIDC subject is derived from the host's
|
|
45
|
+
* `success()` claim at `/token`, which has not run yet. Correlate
|
|
46
|
+
* with `token_issued.subjectId` via `clientId` + `flowId`.
|
|
47
|
+
*/
|
|
48
|
+
providerSubject: string
|
|
38
49
|
}
|
|
39
50
|
| {
|
|
40
51
|
kind: "authorize_failed"
|
|
@@ -162,6 +173,20 @@ export type AuditEvent =
|
|
|
162
173
|
/** Zod error path string. Never the raw config blob. */
|
|
163
174
|
errorPath: string
|
|
164
175
|
}
|
|
176
|
+
| {
|
|
177
|
+
/**
|
|
178
|
+
* `IdPOptions.success` returned a claim that violates the host's
|
|
179
|
+
* own `subjects` schema. A deployment fault, not RP behaviour —
|
|
180
|
+
* token issuance is refused. Carries paths, never values.
|
|
181
|
+
*/
|
|
182
|
+
kind: "invalid_subject_claim"
|
|
183
|
+
tenantId: TenantId
|
|
184
|
+
clientId: string
|
|
185
|
+
subjectType: string
|
|
186
|
+
reason: "unknown-type" | "invalid-properties"
|
|
187
|
+
/** Standard Schema issue path, or the declared type list. */
|
|
188
|
+
detail: string
|
|
189
|
+
}
|
|
165
190
|
| {
|
|
166
191
|
kind: "unknown_method_kind"
|
|
167
192
|
tenantId: TenantId
|