@_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/client.ts
CHANGED
|
@@ -149,6 +149,37 @@ export interface ClientInput {
|
|
|
149
149
|
* ```
|
|
150
150
|
*/
|
|
151
151
|
issuer?: string
|
|
152
|
+
/**
|
|
153
|
+
* The client secret, for **confidential** clients only.
|
|
154
|
+
*
|
|
155
|
+
* Supply this for a server-side app registered as a confidential client;
|
|
156
|
+
* `exchange()` and `refresh()` then authenticate at `/token`. Omit it for
|
|
157
|
+
* public clients (SPA, mobile, CLI) — a secret cannot be kept in code
|
|
158
|
+
* that ships to users, and the IdP requires PKCE there instead.
|
|
159
|
+
*
|
|
160
|
+
* Without this, a confidential client's `exchange()` is rejected with
|
|
161
|
+
* `invalid_client`: the token endpoint has no way to authenticate it.
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```ts
|
|
165
|
+
* {
|
|
166
|
+
* clientID: "my-server-app",
|
|
167
|
+
* clientSecret: process.env.CLIENT_SECRET
|
|
168
|
+
* }
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
clientSecret?: string
|
|
172
|
+
/**
|
|
173
|
+
* How to present `clientSecret` at the token endpoint.
|
|
174
|
+
*
|
|
175
|
+
* `client_secret_basic` (the default) sends HTTP Basic credentials, which
|
|
176
|
+
* is what RFC 6749 §2.3.1 prefers and what the IdP parses first.
|
|
177
|
+
* `client_secret_post` puts them in the form body. Both are advertised in
|
|
178
|
+
* discovery as `token_endpoint_auth_methods_supported`.
|
|
179
|
+
*
|
|
180
|
+
* @default "client_secret_basic"
|
|
181
|
+
*/
|
|
182
|
+
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post"
|
|
152
183
|
/**
|
|
153
184
|
* Optionally, override the internally used fetch function.
|
|
154
185
|
*
|
|
@@ -159,18 +190,6 @@ export interface ClientInput {
|
|
|
159
190
|
}
|
|
160
191
|
|
|
161
192
|
export interface AuthorizeOptions {
|
|
162
|
-
/**
|
|
163
|
-
* Enable the PKCE flow. This is for SPA apps.
|
|
164
|
-
*
|
|
165
|
-
* ```ts
|
|
166
|
-
* {
|
|
167
|
-
* pkce: true
|
|
168
|
-
* }
|
|
169
|
-
* ```
|
|
170
|
-
*
|
|
171
|
-
* @default false
|
|
172
|
-
*/
|
|
173
|
-
pkce?: boolean
|
|
174
193
|
/**
|
|
175
194
|
* The provider you want to use for the OAuth flow.
|
|
176
195
|
*
|
|
@@ -306,7 +325,13 @@ export interface VerifyOptions {
|
|
|
306
325
|
*/
|
|
307
326
|
issuer?: string
|
|
308
327
|
/**
|
|
309
|
-
*
|
|
328
|
+
* The audience to require on the token.
|
|
329
|
+
*
|
|
330
|
+
* Defaults to this client's `clientID`, which is what the IdP puts in
|
|
331
|
+
* `aud` for an ordinary login. Set this when verifying a token minted
|
|
332
|
+
* for a **resource** — an `/authorize` call that passed `audience` puts
|
|
333
|
+
* that value in `aud` instead, so a resource server verifying it must
|
|
334
|
+
* name itself here.
|
|
310
335
|
*/
|
|
311
336
|
audience?: string
|
|
312
337
|
/**
|
|
@@ -320,9 +345,14 @@ export interface VerifyOptions {
|
|
|
320
345
|
|
|
321
346
|
export interface VerifyResult<T extends SubjectSchema> {
|
|
322
347
|
/**
|
|
323
|
-
* This is always `
|
|
348
|
+
* This is always `false` when the verify is successful.
|
|
349
|
+
*
|
|
350
|
+
* A literal, not an optional — `err?: undefined` would leave the
|
|
351
|
+
* property present on this arm, so `"err" in result` narrowed nothing
|
|
352
|
+
* and callers had to test truthiness instead. Matches `ExchangeSuccess`
|
|
353
|
+
* and `RefreshSuccess`.
|
|
324
354
|
*/
|
|
325
|
-
err
|
|
355
|
+
err: false
|
|
326
356
|
/**
|
|
327
357
|
* Returns the refreshed tokens only if they’ve been refreshed.
|
|
328
358
|
*
|
|
@@ -365,40 +395,30 @@ export interface VerifyError {
|
|
|
365
395
|
*/
|
|
366
396
|
export interface Client {
|
|
367
397
|
/**
|
|
368
|
-
* Start the
|
|
398
|
+
* Start the authorization code flow.
|
|
369
399
|
*
|
|
370
400
|
* ```ts
|
|
371
|
-
* const { url } = await client.authorize(<redirect_uri
|
|
401
|
+
* const { challenge, url } = await client.authorize(<redirect_uri>)
|
|
402
|
+
* // store `challenge`, then redirect the user to `url`
|
|
372
403
|
* ```
|
|
373
404
|
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* secure.
|
|
379
|
-
*
|
|
380
|
-
* :::tip
|
|
381
|
-
* This returns a URL to redirect the user to. This starts the OAuth flow.
|
|
382
|
-
* :::
|
|
383
|
-
*
|
|
384
|
-
* This returns a URL to the auth server. You can redirect the user to the URL to start the
|
|
385
|
-
* OAuth flow.
|
|
405
|
+
* Returns the URL to send the user to, and a `challenge` carrying the
|
|
406
|
+
* CSRF `state` and the PKCE `verifier`. Persist the challenge (a cookie
|
|
407
|
+
* server-side, `sessionStorage` in a SPA) and hand the verifier back to
|
|
408
|
+
* {@link Client.exchange} when the user returns.
|
|
386
409
|
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
392
|
-
* "code",
|
|
393
|
-
* { pkce: true }
|
|
394
|
-
* )
|
|
395
|
-
* ```
|
|
410
|
+
* **PKCE is always used.** The IdP requires it for public clients, and
|
|
411
|
+
* OAuth 2.1 §7.5.1 recommends it for confidential ones too, so there is
|
|
412
|
+
* no reason to offer it as a toggle. Before 0.14.0 it was opt-in and
|
|
413
|
+
* off by default, which meant the documented server-side flow could not
|
|
414
|
+
* complete against a public client at all.
|
|
396
415
|
*
|
|
397
|
-
*
|
|
416
|
+
* Only the authorization code flow is supported. The implicit flow
|
|
417
|
+
* (`response_type=token`) is removed in OAuth 2.1 and the IdP rejects
|
|
418
|
+
* it with `unsupported_response_type`.
|
|
398
419
|
*/
|
|
399
420
|
authorize(
|
|
400
421
|
redirectURI: string,
|
|
401
|
-
response: "code" | "token",
|
|
402
422
|
opts?: AuthorizeOptions,
|
|
403
423
|
): Promise<AuthorizeResult>
|
|
404
424
|
/**
|
|
@@ -593,74 +613,76 @@ export function createClient(input: ClientInput): Client {
|
|
|
593
613
|
return result
|
|
594
614
|
}
|
|
595
615
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
616
|
+
/**
|
|
617
|
+
* Client authentication for the token endpoint (RFC 6749 §2.3.1).
|
|
618
|
+
* Applied only when a `clientSecret` is configured; public clients
|
|
619
|
+
* authenticate with PKCE instead and send neither.
|
|
620
|
+
*/
|
|
621
|
+
function applyClientAuth(
|
|
622
|
+
headers: Record<string, string>,
|
|
623
|
+
body: URLSearchParams,
|
|
624
|
+
) {
|
|
625
|
+
const secret = input.clientSecret
|
|
626
|
+
if (secret === undefined) return
|
|
627
|
+
if (
|
|
628
|
+
(input.tokenEndpointAuthMethod ?? "client_secret_basic") ===
|
|
629
|
+
"client_secret_post"
|
|
601
630
|
) {
|
|
602
|
-
|
|
631
|
+
body.set("client_secret", secret)
|
|
632
|
+
return
|
|
633
|
+
}
|
|
634
|
+
// §2.3.1 requires form-urlencoding each half before base64.
|
|
635
|
+
const cred = `${encodeURIComponent(input.clientID)}:${encodeURIComponent(secret)}`
|
|
636
|
+
headers["authorization"] = `Basic ${btoa(cred)}`
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const result = {
|
|
640
|
+
async authorize(redirectURI: string, opts?: AuthorizeOptions) {
|
|
641
|
+
const wk = await getIssuer()
|
|
642
|
+
const url = new URL(wk.authorization_endpoint)
|
|
643
|
+
// PKCE unconditionally: required by the IdP for public clients, and
|
|
644
|
+
// recommended for confidential ones by OAuth 2.1 §7.5.1.
|
|
645
|
+
const pkce = await generatePKCE()
|
|
603
646
|
const challenge: Challenge = {
|
|
604
647
|
state: crypto.randomUUID(),
|
|
648
|
+
verifier: pkce.verifier,
|
|
605
649
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
650
|
+
url.searchParams.set("client_id", input.clientID)
|
|
651
|
+
url.searchParams.set("redirect_uri", redirectURI)
|
|
652
|
+
url.searchParams.set("response_type", "code")
|
|
653
|
+
url.searchParams.set("state", challenge.state)
|
|
654
|
+
url.searchParams.set("code_challenge_method", "S256")
|
|
655
|
+
url.searchParams.set("code_challenge", pkce.challenge)
|
|
656
|
+
if (opts?.provider) url.searchParams.set("provider", opts.provider)
|
|
611
657
|
if (opts?.scope !== undefined) {
|
|
612
658
|
const scope = Array.isArray(opts.scope)
|
|
613
659
|
? opts.scope.join(" ")
|
|
614
660
|
: opts.scope
|
|
615
|
-
if (scope)
|
|
616
|
-
}
|
|
617
|
-
if (opts?.pkce && response === "code") {
|
|
618
|
-
const pkce = await generatePKCE()
|
|
619
|
-
result.searchParams.set("code_challenge_method", "S256")
|
|
620
|
-
result.searchParams.set("code_challenge", pkce.challenge)
|
|
621
|
-
challenge.verifier = pkce.verifier
|
|
622
|
-
}
|
|
623
|
-
return {
|
|
624
|
-
challenge,
|
|
625
|
-
url: result.toString(),
|
|
661
|
+
if (scope) url.searchParams.set("scope", scope)
|
|
626
662
|
}
|
|
627
|
-
|
|
628
|
-
/**
|
|
629
|
-
* @deprecated use `authorize` instead, it will do pkce by default unless disabled with `opts.pkce = false`
|
|
630
|
-
*/
|
|
631
|
-
async pkce(
|
|
632
|
-
redirectURI: string,
|
|
633
|
-
opts?: {
|
|
634
|
-
provider?: string
|
|
635
|
-
},
|
|
636
|
-
) {
|
|
637
|
-
const result = new URL(issuer + "/authorize")
|
|
638
|
-
if (opts?.provider) result.searchParams.set("provider", opts.provider)
|
|
639
|
-
result.searchParams.set("client_id", input.clientID)
|
|
640
|
-
result.searchParams.set("redirect_uri", redirectURI)
|
|
641
|
-
result.searchParams.set("response_type", "code")
|
|
642
|
-
const pkce = await generatePKCE()
|
|
643
|
-
result.searchParams.set("code_challenge_method", "S256")
|
|
644
|
-
result.searchParams.set("code_challenge", pkce.challenge)
|
|
645
|
-
return [pkce.verifier, result.toString()]
|
|
663
|
+
return { challenge, url: url.toString() }
|
|
646
664
|
},
|
|
647
665
|
async exchange(
|
|
648
666
|
code: string,
|
|
649
667
|
redirectURI: string,
|
|
650
668
|
verifier?: string,
|
|
651
669
|
): Promise<ExchangeSuccess | ExchangeError> {
|
|
652
|
-
const
|
|
670
|
+
const wk = await getIssuer()
|
|
671
|
+
const body = new URLSearchParams({
|
|
672
|
+
code,
|
|
673
|
+
redirect_uri: redirectURI,
|
|
674
|
+
grant_type: "authorization_code",
|
|
675
|
+
client_id: input.clientID,
|
|
676
|
+
})
|
|
677
|
+
if (verifier) body.set("code_verifier", verifier)
|
|
678
|
+
const headers: Record<string, string> = {
|
|
679
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
680
|
+
}
|
|
681
|
+
applyClientAuth(headers, body)
|
|
682
|
+
const tokens = await f(wk.token_endpoint, {
|
|
653
683
|
method: "POST",
|
|
654
|
-
headers
|
|
655
|
-
|
|
656
|
-
},
|
|
657
|
-
body: new URLSearchParams({
|
|
658
|
-
code,
|
|
659
|
-
redirect_uri: redirectURI,
|
|
660
|
-
grant_type: "authorization_code",
|
|
661
|
-
client_id: input.clientID,
|
|
662
|
-
code_verifier: verifier || "",
|
|
663
|
-
}).toString(),
|
|
684
|
+
headers,
|
|
685
|
+
body: body.toString(),
|
|
664
686
|
})
|
|
665
687
|
const json = (await tokens.json()) as any
|
|
666
688
|
if (!tokens.ok) {
|
|
@@ -698,15 +720,24 @@ export function createClient(input: ClientInput): Client {
|
|
|
698
720
|
}
|
|
699
721
|
}
|
|
700
722
|
}
|
|
701
|
-
const
|
|
723
|
+
const wk = await getIssuer()
|
|
724
|
+
// `client_id` was previously omitted here too. The IdP rejects a
|
|
725
|
+
// confidential client's refresh outright when the request carries no
|
|
726
|
+
// client identity (RFC 6749 §6), so rotation was unreachable for
|
|
727
|
+
// exactly the clients that most need it.
|
|
728
|
+
const body = new URLSearchParams({
|
|
729
|
+
grant_type: "refresh_token",
|
|
730
|
+
refresh_token: refresh,
|
|
731
|
+
client_id: input.clientID,
|
|
732
|
+
})
|
|
733
|
+
const headers: Record<string, string> = {
|
|
734
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
735
|
+
}
|
|
736
|
+
applyClientAuth(headers, body)
|
|
737
|
+
const tokens = await f(wk.token_endpoint, {
|
|
702
738
|
method: "POST",
|
|
703
|
-
headers
|
|
704
|
-
|
|
705
|
-
},
|
|
706
|
-
body: new URLSearchParams({
|
|
707
|
-
grant_type: "refresh_token",
|
|
708
|
-
refresh_token: refresh,
|
|
709
|
-
}).toString(),
|
|
739
|
+
headers,
|
|
740
|
+
body: body.toString(),
|
|
710
741
|
})
|
|
711
742
|
const json = (await tokens.json()) as any
|
|
712
743
|
if (!tokens.ok) {
|
|
@@ -733,55 +764,40 @@ export function createClient(input: ClientInput): Client {
|
|
|
733
764
|
): Promise<VerifyResult<T> | VerifyError> {
|
|
734
765
|
const jwks = await getJWKS()
|
|
735
766
|
try {
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
// 2. Legacy `issuer({...})` — `payload.type` / `payload.properties`
|
|
741
|
-
// at the top level, gated by `payload.mode === "access"` so the
|
|
742
|
-
// same key ring couldn't sign a refresh token that verified as
|
|
743
|
-
// an access token.
|
|
767
|
+
// RFC 9068 §4 / RFC 7519 §4.1.3 — `aud` MUST be checked. Until
|
|
768
|
+
// 0.14.0 only `iss` was, so any client sharing an issuer accepted
|
|
769
|
+
// any other client's token: a confused deputy across every RP on
|
|
770
|
+
// the deployment.
|
|
744
771
|
//
|
|
745
|
-
//
|
|
772
|
+
// The IdP sets `aud` to the requested `audience` when one was
|
|
773
|
+
// asked for at /authorize, and to the client id otherwise — so a
|
|
774
|
+
// resource server verifying a resource-scoped token passes its own
|
|
775
|
+
// identifier via `options.audience`, and everyone else gets the
|
|
776
|
+
// right default for free.
|
|
746
777
|
const result = await jwtVerify<{
|
|
747
778
|
claim?: {
|
|
748
779
|
type: keyof T
|
|
749
780
|
properties: v1.InferInput<T[keyof T]>
|
|
750
781
|
}
|
|
751
|
-
mode?: "access" | "refresh"
|
|
752
|
-
type?: keyof T
|
|
753
|
-
properties?: v1.InferInput<T[keyof T]>
|
|
754
782
|
}>(token, jwks, {
|
|
755
783
|
issuer,
|
|
784
|
+
audience: options?.audience ?? input.clientID,
|
|
756
785
|
})
|
|
757
786
|
const claim = result.payload.claim
|
|
758
|
-
|
|
759
|
-
let subjectProperties: v1.InferInput<T[keyof T]> | undefined
|
|
760
|
-
if (claim && typeof claim.type === "string") {
|
|
761
|
-
// New shape — `mode` is not emitted; nested claim is authoritative.
|
|
762
|
-
subjectType = claim.type
|
|
763
|
-
subjectProperties = claim.properties
|
|
764
|
-
} else if (
|
|
765
|
-
result.payload.mode === "access" &&
|
|
766
|
-
typeof result.payload.type === "string"
|
|
767
|
-
) {
|
|
768
|
-
// Legacy shape — keep the mode gate so a refresh-token payload
|
|
769
|
-
// signed under the same keys does not verify as an access token.
|
|
770
|
-
subjectType = result.payload.type
|
|
771
|
-
subjectProperties = result.payload.properties
|
|
772
|
-
}
|
|
773
|
-
if (subjectType === undefined) {
|
|
787
|
+
if (!claim || typeof claim.type !== "string") {
|
|
774
788
|
return { err: new InvalidSubjectError() }
|
|
775
789
|
}
|
|
790
|
+
const subjectType = claim.type
|
|
776
791
|
const schema = subjects[subjectType]
|
|
777
792
|
if (!schema) {
|
|
778
793
|
return { err: new InvalidSubjectError() }
|
|
779
794
|
}
|
|
780
|
-
const validated = await schema["~standard"].validate(
|
|
795
|
+
const validated = await schema["~standard"].validate(claim.properties)
|
|
781
796
|
if (validated.issues) {
|
|
782
797
|
return { err: new InvalidSubjectError() }
|
|
783
798
|
}
|
|
784
799
|
return {
|
|
800
|
+
err: false,
|
|
785
801
|
aud: result.payload.aud as string,
|
|
786
802
|
subject: {
|
|
787
803
|
type: subjectType,
|
package/src/domain/authorize.ts
CHANGED
|
@@ -413,6 +413,16 @@ async function issueCodeFromInlineSuccess(
|
|
|
413
413
|
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
414
414
|
)
|
|
415
415
|
if (isErr(saved)) return err(saved.error)
|
|
416
|
+
await safeAudit(deps, {
|
|
417
|
+
kind: "authorize_succeeded",
|
|
418
|
+
tenantId: flow.tenantId,
|
|
419
|
+
clientId: flow.clientId,
|
|
420
|
+
methodId: flow.methodId,
|
|
421
|
+
methodKind: flow.methodKind,
|
|
422
|
+
flowId: flow.flowId,
|
|
423
|
+
providerSubject: result.providerSubject,
|
|
424
|
+
timestamp: now,
|
|
425
|
+
})
|
|
416
426
|
return ok({
|
|
417
427
|
kind: "issue-code",
|
|
418
428
|
code,
|
package/src/domain/callback.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { MethodCache } from "./method-cache"
|
|
|
37
37
|
import { dispatchMethod } from "./method-dispatch"
|
|
38
38
|
import { callbackTarget } from "./mount"
|
|
39
39
|
import { saveEncryptedCode } from "./token"
|
|
40
|
-
import { verifyStateEnvelope } from "./state-envelope"
|
|
40
|
+
import { extractCallbackState, verifyStateEnvelope } from "./state-envelope"
|
|
41
41
|
import { AUTH_CODE_TTL_MS } from "./authorize"
|
|
42
42
|
|
|
43
43
|
export type CallbackOutput =
|
|
@@ -98,34 +98,6 @@ export type HandleCallbackDeps = {
|
|
|
98
98
|
) => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
/**
|
|
102
|
-
* The framework's MAC state envelope normally rides `?state=` on the
|
|
103
|
-
* upstream redirect. POST-binding callbacks carry it in the form body
|
|
104
|
-
* instead: OAuth `response_mode=form_post` uses `state`, SAML's
|
|
105
|
-
* HTTP-POST binding uses `RelayState`. Read the query first (cheap,
|
|
106
|
-
* the common case) and fall back to a **cloned** body read so the
|
|
107
|
-
* downstream method handler still gets an unconsumed request body
|
|
108
|
-
* (it needs it for `code` / `SAMLResponse`).
|
|
109
|
-
*
|
|
110
|
-
* Any body-parse failure degrades to "no state" — identical to the
|
|
111
|
-
* pre-existing behaviour when the query param is absent.
|
|
112
|
-
*/
|
|
113
|
-
async function extractCallbackState(req: Request): Promise<string | null> {
|
|
114
|
-
const fromQuery = new URL(req.url).searchParams.get("state")
|
|
115
|
-
if (fromQuery) return fromQuery
|
|
116
|
-
if (req.method !== "POST") return null
|
|
117
|
-
const ct = req.headers.get("content-type") ?? ""
|
|
118
|
-
if (!ct.includes("application/x-www-form-urlencoded")) return null
|
|
119
|
-
try {
|
|
120
|
-
const body = await req.clone().text()
|
|
121
|
-
const form = new URLSearchParams(body)
|
|
122
|
-
const v = form.get("state") ?? form.get("RelayState")
|
|
123
|
-
return v && v.length > 0 ? v : null
|
|
124
|
-
} catch {
|
|
125
|
-
return null
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
101
|
export async function handleCallback(
|
|
130
102
|
input: HandleCallbackInput,
|
|
131
103
|
deps: HandleCallbackDeps,
|
|
@@ -285,6 +257,16 @@ async function translate(
|
|
|
285
257
|
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
286
258
|
)
|
|
287
259
|
if (isErr(saved)) return err(saved.error)
|
|
260
|
+
await safeAudit(deps, {
|
|
261
|
+
kind: "authorize_succeeded",
|
|
262
|
+
tenantId: flow.tenantId,
|
|
263
|
+
clientId: flow.clientId,
|
|
264
|
+
methodId: flow.methodId,
|
|
265
|
+
methodKind: flow.methodKind,
|
|
266
|
+
flowId: flow.flowId,
|
|
267
|
+
providerSubject: result.providerSubject,
|
|
268
|
+
timestamp: now,
|
|
269
|
+
})
|
|
288
270
|
return ok({
|
|
289
271
|
kind: "issue-code",
|
|
290
272
|
code,
|
|
@@ -474,6 +456,17 @@ async function tryIdpInitiated(
|
|
|
474
456
|
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
475
457
|
)
|
|
476
458
|
if (isErr(saved)) return err(saved.error)
|
|
459
|
+
await safeAudit(deps, {
|
|
460
|
+
kind: "authorize_succeeded",
|
|
461
|
+
tenantId: input.tenant.id,
|
|
462
|
+
clientId: binding.clientId,
|
|
463
|
+
methodId,
|
|
464
|
+
methodKind: method.kind,
|
|
465
|
+
// Unsolicited: there is no FlowRecord, so no flowId to correlate on.
|
|
466
|
+
flowId: "",
|
|
467
|
+
providerSubject: result.providerSubject,
|
|
468
|
+
timestamp: now,
|
|
469
|
+
})
|
|
477
470
|
return ok({
|
|
478
471
|
kind: "issue-code",
|
|
479
472
|
code,
|
|
@@ -22,10 +22,14 @@ import type { ConfigStore } from "../ports/config-store"
|
|
|
22
22
|
import type { KeyStore } from "../ports/key-store"
|
|
23
23
|
import type { TokenStore } from "../ports/token-store"
|
|
24
24
|
import { authError, type AuthError } from "../types/error"
|
|
25
|
-
import type {
|
|
25
|
+
import type {
|
|
26
|
+
OnTokenIssued,
|
|
27
|
+
PersistUpstreamTokens,
|
|
28
|
+
SuccessMapInput,
|
|
29
|
+
} from "../types/idp"
|
|
26
30
|
import type { Result } from "../types/result"
|
|
27
31
|
import { err, isErr, ok } from "../types/result"
|
|
28
|
-
import type { SubjectClaim } from "../types/subject"
|
|
32
|
+
import type { SubjectClaim, SubjectSchema } from "../types/subject"
|
|
29
33
|
import type { TenantContext, TenantId } from "../types/tenant"
|
|
30
34
|
import type { TokenResponse } from "../types/token"
|
|
31
35
|
|
|
@@ -33,6 +37,8 @@ import { verifyClientCredentials } from "./client-auth"
|
|
|
33
37
|
import { randomId } from "./crypto"
|
|
34
38
|
import { mintTokens } from "./token"
|
|
35
39
|
import { MethodCache } from "./method-cache"
|
|
40
|
+
import { safeAudit } from "./audit"
|
|
41
|
+
import { validateSubjectClaim } from "./subject"
|
|
36
42
|
|
|
37
43
|
export type ClientCredentialsRequest = {
|
|
38
44
|
grantType: "client_credentials"
|
|
@@ -50,9 +56,13 @@ export type ClientCredentialsDeps = {
|
|
|
50
56
|
auditLog?: AuditLog
|
|
51
57
|
methodCache: MethodCache
|
|
52
58
|
success: (input: SuccessMapInput) => Promise<SubjectClaim>
|
|
59
|
+
/** Host-declared subject schemas; the claim is validated against them. */
|
|
60
|
+
subjects: SubjectSchema
|
|
53
61
|
persistUpstreamTokens?: PersistUpstreamTokens
|
|
54
62
|
issuerUrl: string
|
|
55
63
|
clock: () => number
|
|
64
|
+
/** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
|
|
65
|
+
onTokenIssued?: OnTokenIssued
|
|
56
66
|
newRefreshFamily?: () => string
|
|
57
67
|
/** See `IdPOptions.customScopeClaims`. Forwarded to `mintTokens`. */
|
|
58
68
|
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
@@ -156,6 +166,21 @@ export async function clientCredentialsGrant(
|
|
|
156
166
|
return err(authError.serverError("success callback threw", e))
|
|
157
167
|
}
|
|
158
168
|
|
|
169
|
+
const checked = await validateSubjectClaim(deps.subjects, claim)
|
|
170
|
+
if (isErr(checked)) {
|
|
171
|
+
await safeAudit(deps, {
|
|
172
|
+
kind: "invalid_subject_claim",
|
|
173
|
+
tenantId: tenant.id,
|
|
174
|
+
clientId: client.id,
|
|
175
|
+
subjectType: checked.error.rejection.subjectType,
|
|
176
|
+
reason: checked.error.rejection.reason,
|
|
177
|
+
detail: checked.error.rejection.detail,
|
|
178
|
+
timestamp: deps.clock(),
|
|
179
|
+
})
|
|
180
|
+
return err(checked.error)
|
|
181
|
+
}
|
|
182
|
+
claim = checked.value
|
|
183
|
+
|
|
159
184
|
// 6. Mint access only — RFC 6749 §4.4.3 says client_credentials SHOULD
|
|
160
185
|
// NOT issue a refresh token. `skipRefresh: true` keeps the token-
|
|
161
186
|
// store from accumulating orphaned rows that the response would
|
|
@@ -192,6 +192,16 @@ async function translate(
|
|
|
192
192
|
{ keyStore: deps.keyStore, tokenStore: deps.tokenStore },
|
|
193
193
|
)
|
|
194
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
|
+
})
|
|
195
205
|
return ok({
|
|
196
206
|
kind: "issue-code",
|
|
197
207
|
code,
|
package/src/domain/refresh.ts
CHANGED
|
@@ -17,6 +17,7 @@ import type { ConfigStore } from "../ports/config-store"
|
|
|
17
17
|
import type { KeyStore } from "../ports/key-store"
|
|
18
18
|
import type { TokenStore } from "../ports/token-store"
|
|
19
19
|
import { authError, type AuthError } from "../types/error"
|
|
20
|
+
import type { OnTokenIssued } from "../types/idp"
|
|
20
21
|
import type { Result } from "../types/result"
|
|
21
22
|
import { err, isErr } from "../types/result"
|
|
22
23
|
import type { TenantContext } from "../types/tenant"
|
|
@@ -50,6 +51,8 @@ export type RefreshTokensDeps = {
|
|
|
50
51
|
auditLog?: AuditLog
|
|
51
52
|
issuerUrl: string
|
|
52
53
|
clock: () => number
|
|
54
|
+
/** See `IdPOptions.onTokenIssued`. Threaded to `mintTokens`. */
|
|
55
|
+
onTokenIssued?: OnTokenIssued
|
|
53
56
|
/** Reuse-detection window (ms). Default 60 s. */
|
|
54
57
|
reuseWindowMs?: number
|
|
55
58
|
newRefreshToken?: () => string
|
|
@@ -57,6 +60,26 @@ export type RefreshTokensDeps = {
|
|
|
57
60
|
customScopeClaims?: Record<string, ReadonlyArray<string>>
|
|
58
61
|
}
|
|
59
62
|
|
|
63
|
+
/**
|
|
64
|
+
* Requested scopes must be a subset of what the grant carries (RFC 6749
|
|
65
|
+
* §6). Returns the error rather than throwing so both call sites -- the
|
|
66
|
+
* pre-consume gate and the post-consume re-check -- read identically.
|
|
67
|
+
*/
|
|
68
|
+
function validateRequestedScopes(
|
|
69
|
+
requested: string | undefined,
|
|
70
|
+
granted: string[],
|
|
71
|
+
): AuthError | null {
|
|
72
|
+
if (!requested) return null
|
|
73
|
+
for (const s of requested.split(" ").filter(Boolean)) {
|
|
74
|
+
if (!granted.includes(s)) {
|
|
75
|
+
return authError.invalidScope(
|
|
76
|
+
`requested scope "${s}" not granted by original refresh token`,
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
60
83
|
export async function refreshTokens(
|
|
61
84
|
req: RefreshGrantRequest,
|
|
62
85
|
deps: RefreshTokensDeps,
|
|
@@ -123,6 +146,16 @@ export async function refreshTokens(
|
|
|
123
146
|
}
|
|
124
147
|
}
|
|
125
148
|
|
|
149
|
+
// Scope narrowing is validated against the *peeked* grant, before the
|
|
150
|
+
// token is consumed. Same reasoning as the client-auth and DPoP gates
|
|
151
|
+
// above: a request the grant cannot satisfy must not burn the token,
|
|
152
|
+
// or a client typo makes the next legitimate refresh look like theft
|
|
153
|
+
// and can revoke the whole family. `consumeRefresh` stays the
|
|
154
|
+
// authoritative atomic gate immediately below (TokenStore port:
|
|
155
|
+
// "callers race peekRefresh then consumeRefresh").
|
|
156
|
+
const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes)
|
|
157
|
+
if (scopeErr) return err(scopeErr)
|
|
158
|
+
|
|
126
159
|
const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
|
|
127
160
|
reuseWindowMs: deps.reuseWindowMs,
|
|
128
161
|
})
|
|
@@ -147,19 +180,18 @@ export async function refreshTokens(
|
|
|
147
180
|
}
|
|
148
181
|
const payload = consumed.value
|
|
149
182
|
|
|
150
|
-
//
|
|
183
|
+
// Re-check against the consumed payload, which the port declares
|
|
184
|
+
// authoritative. In practice this cannot diverge from the peeked
|
|
185
|
+
// grant -- rotation preserves scopes -- so this is defence in depth,
|
|
186
|
+
// not the user-facing gate; that already ran above without burning.
|
|
187
|
+
const authoritativeScopeErr = validateRequestedScopes(
|
|
188
|
+
req.scope,
|
|
189
|
+
payload.scopes,
|
|
190
|
+
)
|
|
191
|
+
if (authoritativeScopeErr) return err(authoritativeScopeErr)
|
|
151
192
|
const requestedScopes = req.scope
|
|
152
193
|
? req.scope.split(" ").filter(Boolean)
|
|
153
194
|
: 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
195
|
|
|
164
196
|
const tenant: TenantContext = {
|
|
165
197
|
id: payload.tenantId,
|