@_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.
Files changed (61) hide show
  1. package/dist/esm/client.js +61 -62
  2. package/dist/esm/domain/authorize.js +10 -0
  3. package/dist/esm/domain/callback.js +21 -19
  4. package/dist/esm/domain/client-credentials.js +16 -0
  5. package/dist/esm/domain/method-route.js +10 -0
  6. package/dist/esm/domain/refresh.js +16 -5
  7. package/dist/esm/domain/register.js +8 -2
  8. package/dist/esm/domain/state-envelope.js +19 -0
  9. package/dist/esm/domain/subject.js +35 -0
  10. package/dist/esm/domain/token.js +28 -0
  11. package/dist/esm/http/handlers/token.js +6 -0
  12. package/dist/esm/http/middleware/tenant.js +5 -20
  13. package/dist/esm/index.js +2 -0
  14. package/dist/types/client.d.ts +61 -40
  15. package/dist/types/client.d.ts.map +1 -1
  16. package/dist/types/domain/authorize.d.ts.map +1 -1
  17. package/dist/types/domain/callback.d.ts.map +1 -1
  18. package/dist/types/domain/client-credentials.d.ts +6 -2
  19. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  20. package/dist/types/domain/method-route.d.ts.map +1 -1
  21. package/dist/types/domain/refresh.d.ts +3 -0
  22. package/dist/types/domain/refresh.d.ts.map +1 -1
  23. package/dist/types/domain/register.d.ts.map +1 -1
  24. package/dist/types/domain/state-envelope.d.ts +22 -0
  25. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  26. package/dist/types/domain/subject.d.ts +48 -0
  27. package/dist/types/domain/subject.d.ts.map +1 -0
  28. package/dist/types/domain/token-exchange.d.ts +3 -1
  29. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  30. package/dist/types/domain/token.d.ts +11 -2
  31. package/dist/types/domain/token.d.ts.map +1 -1
  32. package/dist/types/http/context.d.ts +6 -0
  33. package/dist/types/http/context.d.ts.map +1 -1
  34. package/dist/types/http/handlers/token.d.ts.map +1 -1
  35. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  36. package/dist/types/http/schemas/revocation.d.ts +4 -4
  37. package/dist/types/http/schemas/token.d.ts +12 -12
  38. package/dist/types/index.d.ts +1 -1
  39. package/dist/types/index.d.ts.map +1 -1
  40. package/dist/types/ports/audit-log.d.ts +25 -1
  41. package/dist/types/ports/audit-log.d.ts.map +1 -1
  42. package/dist/types/types/idp.d.ts +105 -38
  43. package/dist/types/types/idp.d.ts.map +1 -1
  44. package/package.json +1 -1
  45. package/src/client.ts +145 -129
  46. package/src/domain/authorize.ts +10 -0
  47. package/src/domain/callback.ts +22 -29
  48. package/src/domain/client-credentials.ts +27 -2
  49. package/src/domain/method-route.ts +10 -0
  50. package/src/domain/refresh.ts +42 -10
  51. package/src/domain/register.ts +27 -8
  52. package/src/domain/state-envelope.ts +40 -0
  53. package/src/domain/subject.ts +103 -0
  54. package/src/domain/token-exchange.ts +3 -1
  55. package/src/domain/token.ts +54 -2
  56. package/src/http/context.ts +6 -0
  57. package/src/http/handlers/token.ts +6 -0
  58. package/src/http/middleware/tenant.ts +5 -24
  59. package/src/index.ts +3 -2
  60. package/src/ports/audit-log.ts +26 -1
  61. 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
- * @internal
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 `undefined` when the verify is successful.
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?: undefined
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 autorization flow. For example, in SSR sites.
398
+ * Start the authorization code flow.
369
399
  *
370
400
  * ```ts
371
- * const { url } = await client.authorize(<redirect_uri>, "code")
401
+ * const { challenge, url } = await client.authorize(<redirect_uri>)
402
+ * // store `challenge`, then redirect the user to `url`
372
403
  * ```
373
404
  *
374
- * This takes a redirect URI and the type of flow you want to use. The redirect URI is the
375
- * location where the user will be redirected to after the flow is complete.
376
- *
377
- * Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more
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
- * For SPA apps, we recommend using the PKCE flow.
388
- *
389
- * ```ts {4}
390
- * const { challenge, url } = await client.authorize(
391
- * <redirect_uri>,
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
- * This returns a redirect URL and a challenge that you need to use later to verify the code.
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
- const result = {
597
- async authorize(
598
- redirectURI: string,
599
- response: "code" | "token",
600
- opts?: AuthorizeOptions,
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
- const result = new URL(issuer + "/authorize")
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
- result.searchParams.set("client_id", input.clientID)
607
- result.searchParams.set("redirect_uri", redirectURI)
608
- result.searchParams.set("response_type", response)
609
- result.searchParams.set("state", challenge.state)
610
- if (opts?.provider) result.searchParams.set("provider", opts.provider)
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) result.searchParams.set("scope", 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 tokens = await f(issuer + "/token", {
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
- "Content-Type": "application/x-www-form-urlencoded",
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 tokens = await f(issuer + "/token", {
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
- "Content-Type": "application/x-www-form-urlencoded",
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
- // Two issuer shapes are supported:
737
- //
738
- // 1. New `createIdP` — `AccessTokenClaims.claim = { type,
739
- // properties }`. No `mode` claim.
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
- // Prefer the nested shape, fall back to the top-level shape.
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
- let subjectType: keyof T | undefined
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(subjectProperties)
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,
@@ -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,
@@ -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 { PersistUpstreamTokens, SuccessMapInput } from "../types/idp"
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,
@@ -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
- // Narrow scope if requested (must be subset of original).
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,