@_mustachio/openauth 0.13.3 → 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 +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 +15 -0
- package/dist/esm/http/handlers/token.js +2 -0
- package/dist/esm/http/middleware/tenant.js +5 -20
- package/dist/esm/index.js +1 -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 +3 -1
- 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.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/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 +33 -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 +20 -1
- package/src/domain/method-route.ts +10 -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/token.ts +2 -0
- package/src/http/middleware/tenant.ts +5 -24
- package/src/index.ts +1 -2
- package/src/ports/audit-log.ts +26 -1
- package/src/types/idp.ts +33 -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
|
+
}
|
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. */
|
|
@@ -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
|
|
|
@@ -285,6 +283,7 @@ export function createIdP(opts: IdPOptions): IdP {
|
|
|
285
283
|
...(opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {}),
|
|
286
284
|
resolveTenant: opts.resolveTenant,
|
|
287
285
|
success: opts.success,
|
|
286
|
+
subjects: opts.subjects,
|
|
288
287
|
...(opts.onLogout ? { onLogout: opts.onLogout } : {}),
|
|
289
288
|
...(opts.persistUpstreamTokens
|
|
290
289
|
? { 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
|
package/src/types/idp.ts
CHANGED
|
@@ -46,30 +46,6 @@ export type SuccessMapInput = {
|
|
|
46
46
|
context: Record<string, unknown> | null
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Optional observation hook payload — fires after the subject claim has
|
|
51
|
-
* already been minted. **Does not** influence the issued subject; use it
|
|
52
|
-
* for audit, analytics, side effects only.
|
|
53
|
-
*/
|
|
54
|
-
export type SuccessEvent = SuccessMapInput & {
|
|
55
|
-
/** The final subject claim that became the JWT `sub`. */
|
|
56
|
-
claim: SubjectClaim
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Optional observation hook payload — fires on a failed auth attempt.
|
|
61
|
-
* Carries enough id information for operators to find the offending flow
|
|
62
|
-
* / config row without leaking secrets.
|
|
63
|
-
*/
|
|
64
|
-
export type FailureEvent = {
|
|
65
|
-
tenantId: TenantId | null
|
|
66
|
-
clientId: string | null
|
|
67
|
-
methodId?: string
|
|
68
|
-
methodKind?: string
|
|
69
|
-
flowId?: string
|
|
70
|
-
error: AuthError
|
|
71
|
-
}
|
|
72
|
-
|
|
73
49
|
/**
|
|
74
50
|
* Input to the optional `IdPOptions.onLogout` hook.
|
|
75
51
|
*
|
|
@@ -217,19 +193,41 @@ export type RegisterClientResponse = {
|
|
|
217
193
|
/**
|
|
218
194
|
* Optional Dynamic Client Registration hook. Hosts that want to expose
|
|
219
195
|
* RFC 7591 client provisioning supply this; the framework validates the
|
|
220
|
-
* wire format, then defers persistence to the
|
|
221
|
-
* `/register` endpoint returns `invalid_request` so
|
|
222
|
-
* "not enabled" signal rather than a 404.
|
|
196
|
+
* wire format and mints credentials, then defers **persistence** to the
|
|
197
|
+
* host. If absent, the `/register` endpoint returns `invalid_request` so
|
|
198
|
+
* RPs receive a clear "not enabled" signal rather than a 404.
|
|
223
199
|
*
|
|
224
|
-
* The
|
|
225
|
-
*
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
*
|
|
200
|
+
* The library owns credential generation — entropy, hashing, and the
|
|
201
|
+
* `ClientConfig` discriminated union, which requires `pkceRequired: true`
|
|
202
|
+
* as a literal on public clients and a `secretHash` on confidential ones.
|
|
203
|
+
* Those are protocol and security concerns, and making every host
|
|
204
|
+
* reimplement them is how they get done wrong. The host owns the table:
|
|
205
|
+
* write `client` through your own `ConfigStore` and return it.
|
|
206
|
+
*
|
|
207
|
+
* Before 0.14.0 this hook received only `{ tenant, request }` and the
|
|
208
|
+
* framework discarded what it had generated, so hosts had to mint their
|
|
209
|
+
* own — contradicting both this doc comment and `ARCHITECTURE.md`.
|
|
210
|
+
*
|
|
211
|
+
* Return the config you actually persisted. Adjusting it first is fine
|
|
212
|
+
* (narrowing `scopes`, substituting your own `id`); if you replace `id`
|
|
213
|
+
* or `secretHash`, return the matching plaintext as `secret` so the RP
|
|
214
|
+
* receives something that works.
|
|
229
215
|
*/
|
|
230
216
|
export type RegisterClient = (input: {
|
|
231
217
|
tenant: TenantContext
|
|
232
218
|
request: RegisterClientRequest
|
|
219
|
+
/**
|
|
220
|
+
* Framework-minted `ClientConfig`, ready to persist as-is. Public
|
|
221
|
+
* clients carry `pkceRequired: true`; confidential clients carry
|
|
222
|
+
* `secretHash` for `secret` below.
|
|
223
|
+
*/
|
|
224
|
+
client: ClientConfig
|
|
225
|
+
/**
|
|
226
|
+
* Plaintext secret matching `client.secretHash`. Present only for
|
|
227
|
+
* confidential clients. Return it in the result so the RP can record
|
|
228
|
+
* it — this is the only time it exists.
|
|
229
|
+
*/
|
|
230
|
+
secret?: string
|
|
233
231
|
}) => Promise<Result<{ client: ClientConfig; secret?: string }, AuthError>>
|
|
234
232
|
|
|
235
233
|
/**
|
|
@@ -328,12 +326,6 @@ export type IdPOptions = {
|
|
|
328
326
|
|
|
329
327
|
theme?: ThemeConfig
|
|
330
328
|
|
|
331
|
-
hooks?: {
|
|
332
|
-
/** Observation only — does NOT influence the subject. */
|
|
333
|
-
onSuccess?: (event: SuccessEvent) => Promise<void>
|
|
334
|
-
onFailure?: (event: FailureEvent) => Promise<void>
|
|
335
|
-
}
|
|
336
|
-
|
|
337
329
|
/**
|
|
338
330
|
* Optional hook fired when an upstream provider signals that a
|
|
339
331
|
* federated session ended — SAML front-channel Single Logout today.
|
|
@@ -342,9 +334,9 @@ export type IdPOptions = {
|
|
|
342
334
|
* OIDC subject (if any) whose library-issued tokens to revoke. See
|
|
343
335
|
* the `LogoutEventInput` / `LogoutHookResult` type docs.
|
|
344
336
|
*
|
|
345
|
-
* Unlike `
|
|
346
|
-
*
|
|
347
|
-
*
|
|
337
|
+
* Unlike `AuditLog` (observation only) this hook **influences**
|
|
338
|
+
* library behaviour — its return drives token revocation — so it sits
|
|
339
|
+
* at the top level alongside `success`.
|
|
348
340
|
*
|
|
349
341
|
* Absent ⇒ the library still verifies the logout, emits a
|
|
350
342
|
* `session_logout` audit event, and returns the protocol
|