@_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.
Files changed (57) 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 +15 -0
  11. package/dist/esm/http/handlers/token.js +2 -0
  12. package/dist/esm/http/middleware/tenant.js +5 -20
  13. package/dist/esm/index.js +1 -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 +3 -1
  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.map +1 -1
  22. package/dist/types/domain/register.d.ts.map +1 -1
  23. package/dist/types/domain/state-envelope.d.ts +22 -0
  24. package/dist/types/domain/state-envelope.d.ts.map +1 -1
  25. package/dist/types/domain/subject.d.ts +48 -0
  26. package/dist/types/domain/subject.d.ts.map +1 -0
  27. package/dist/types/domain/token.d.ts +7 -1
  28. package/dist/types/domain/token.d.ts.map +1 -1
  29. package/dist/types/http/context.d.ts +3 -0
  30. package/dist/types/http/context.d.ts.map +1 -1
  31. package/dist/types/http/handlers/token.d.ts.map +1 -1
  32. package/dist/types/http/middleware/tenant.d.ts.map +1 -1
  33. package/dist/types/http/schemas/revocation.d.ts +4 -4
  34. package/dist/types/http/schemas/token.d.ts +12 -12
  35. package/dist/types/index.d.ts +1 -1
  36. package/dist/types/index.d.ts.map +1 -1
  37. package/dist/types/ports/audit-log.d.ts +25 -1
  38. package/dist/types/ports/audit-log.d.ts.map +1 -1
  39. package/dist/types/types/idp.d.ts +33 -38
  40. package/dist/types/types/idp.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/client.ts +145 -129
  43. package/src/domain/authorize.ts +10 -0
  44. package/src/domain/callback.ts +22 -29
  45. package/src/domain/client-credentials.ts +20 -1
  46. package/src/domain/method-route.ts +10 -0
  47. package/src/domain/refresh.ts +39 -10
  48. package/src/domain/register.ts +27 -8
  49. package/src/domain/state-envelope.ts +40 -0
  50. package/src/domain/subject.ts +103 -0
  51. package/src/domain/token.ts +25 -1
  52. package/src/http/context.ts +3 -0
  53. package/src/http/handlers/token.ts +2 -0
  54. package/src/http/middleware/tenant.ts +5 -24
  55. package/src/index.ts +1 -2
  56. package/src/ports/audit-log.ts +26 -1
  57. package/src/types/idp.ts +33 -41
@@ -37,59 +37,59 @@ function createClient(input) {
37
37
  jwksCache.set(issuer, result);
38
38
  return result;
39
39
  }
40
+ function applyClientAuth(headers, body) {
41
+ const secret = input.clientSecret;
42
+ if (secret === undefined)
43
+ return;
44
+ if ((input.tokenEndpointAuthMethod ?? "client_secret_basic") === "client_secret_post") {
45
+ body.set("client_secret", secret);
46
+ return;
47
+ }
48
+ const cred = `${encodeURIComponent(input.clientID)}:${encodeURIComponent(secret)}`;
49
+ headers["authorization"] = `Basic ${btoa(cred)}`;
50
+ }
40
51
  const result = {
41
- async authorize(redirectURI, response, opts) {
42
- const result = new URL(issuer + "/authorize");
52
+ async authorize(redirectURI, opts) {
53
+ const wk = await getIssuer();
54
+ const url = new URL(wk.authorization_endpoint);
55
+ const pkce = await generatePKCE();
43
56
  const challenge = {
44
- state: crypto.randomUUID()
57
+ state: crypto.randomUUID(),
58
+ verifier: pkce.verifier
45
59
  };
46
- result.searchParams.set("client_id", input.clientID);
47
- result.searchParams.set("redirect_uri", redirectURI);
48
- result.searchParams.set("response_type", response);
49
- result.searchParams.set("state", challenge.state);
60
+ url.searchParams.set("client_id", input.clientID);
61
+ url.searchParams.set("redirect_uri", redirectURI);
62
+ url.searchParams.set("response_type", "code");
63
+ url.searchParams.set("state", challenge.state);
64
+ url.searchParams.set("code_challenge_method", "S256");
65
+ url.searchParams.set("code_challenge", pkce.challenge);
50
66
  if (opts?.provider)
51
- result.searchParams.set("provider", opts.provider);
67
+ url.searchParams.set("provider", opts.provider);
52
68
  if (opts?.scope !== undefined) {
53
69
  const scope = Array.isArray(opts.scope) ? opts.scope.join(" ") : opts.scope;
54
70
  if (scope)
55
- result.searchParams.set("scope", scope);
56
- }
57
- if (opts?.pkce && response === "code") {
58
- const pkce = await generatePKCE();
59
- result.searchParams.set("code_challenge_method", "S256");
60
- result.searchParams.set("code_challenge", pkce.challenge);
61
- challenge.verifier = pkce.verifier;
71
+ url.searchParams.set("scope", scope);
62
72
  }
63
- return {
64
- challenge,
65
- url: result.toString()
66
- };
67
- },
68
- async pkce(redirectURI, opts) {
69
- const result = new URL(issuer + "/authorize");
70
- if (opts?.provider)
71
- result.searchParams.set("provider", opts.provider);
72
- result.searchParams.set("client_id", input.clientID);
73
- result.searchParams.set("redirect_uri", redirectURI);
74
- result.searchParams.set("response_type", "code");
75
- const pkce = await generatePKCE();
76
- result.searchParams.set("code_challenge_method", "S256");
77
- result.searchParams.set("code_challenge", pkce.challenge);
78
- return [pkce.verifier, result.toString()];
73
+ return { challenge, url: url.toString() };
79
74
  },
80
75
  async exchange(code, redirectURI, verifier) {
81
- const tokens = await f(issuer + "/token", {
76
+ const wk = await getIssuer();
77
+ const body = new URLSearchParams({
78
+ code,
79
+ redirect_uri: redirectURI,
80
+ grant_type: "authorization_code",
81
+ client_id: input.clientID
82
+ });
83
+ if (verifier)
84
+ body.set("code_verifier", verifier);
85
+ const headers = {
86
+ "Content-Type": "application/x-www-form-urlencoded"
87
+ };
88
+ applyClientAuth(headers, body);
89
+ const tokens = await f(wk.token_endpoint, {
82
90
  method: "POST",
83
- headers: {
84
- "Content-Type": "application/x-www-form-urlencoded"
85
- },
86
- body: new URLSearchParams({
87
- code,
88
- redirect_uri: redirectURI,
89
- grant_type: "authorization_code",
90
- client_id: input.clientID,
91
- code_verifier: verifier || ""
92
- }).toString()
91
+ headers,
92
+ body: body.toString()
93
93
  });
94
94
  const json = await tokens.json();
95
95
  if (!tokens.ok) {
@@ -121,15 +121,20 @@ function createClient(input) {
121
121
  };
122
122
  }
123
123
  }
124
- const tokens = await f(issuer + "/token", {
124
+ const wk = await getIssuer();
125
+ const body = new URLSearchParams({
126
+ grant_type: "refresh_token",
127
+ refresh_token: refresh,
128
+ client_id: input.clientID
129
+ });
130
+ const headers = {
131
+ "Content-Type": "application/x-www-form-urlencoded"
132
+ };
133
+ applyClientAuth(headers, body);
134
+ const tokens = await f(wk.token_endpoint, {
125
135
  method: "POST",
126
- headers: {
127
- "Content-Type": "application/x-www-form-urlencoded"
128
- },
129
- body: new URLSearchParams({
130
- grant_type: "refresh_token",
131
- refresh_token: refresh
132
- }).toString()
136
+ headers,
137
+ body: body.toString()
133
138
  });
134
139
  const json = await tokens.json();
135
140
  if (!tokens.ok) {
@@ -151,30 +156,24 @@ function createClient(input) {
151
156
  const jwks = await getJWKS();
152
157
  try {
153
158
  const result = await jwtVerify(token, jwks, {
154
- issuer
159
+ issuer,
160
+ audience: options?.audience ?? input.clientID
155
161
  });
156
162
  const claim = result.payload.claim;
157
- let subjectType;
158
- let subjectProperties;
159
- if (claim && typeof claim.type === "string") {
160
- subjectType = claim.type;
161
- subjectProperties = claim.properties;
162
- } else if (result.payload.mode === "access" && typeof result.payload.type === "string") {
163
- subjectType = result.payload.type;
164
- subjectProperties = result.payload.properties;
165
- }
166
- if (subjectType === undefined) {
163
+ if (!claim || typeof claim.type !== "string") {
167
164
  return { err: new InvalidSubjectError };
168
165
  }
166
+ const subjectType = claim.type;
169
167
  const schema = subjects[subjectType];
170
168
  if (!schema) {
171
169
  return { err: new InvalidSubjectError };
172
170
  }
173
- const validated = await schema["~standard"].validate(subjectProperties);
171
+ const validated = await schema["~standard"].validate(claim.properties);
174
172
  if (validated.issues) {
175
173
  return { err: new InvalidSubjectError };
176
174
  }
177
175
  return {
176
+ err: false,
178
177
  aud: result.payload.aud,
179
178
  subject: {
180
179
  type: subjectType,
@@ -211,6 +211,16 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
211
211
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
212
212
  if (isErr(saved))
213
213
  return err(saved.error);
214
+ await safeAudit(deps, {
215
+ kind: "authorize_succeeded",
216
+ tenantId: flow.tenantId,
217
+ clientId: flow.clientId,
218
+ methodId: flow.methodId,
219
+ methodKind: flow.methodKind,
220
+ flowId: flow.flowId,
221
+ providerSubject: result.providerSubject,
222
+ timestamp: now
223
+ });
214
224
  return ok({
215
225
  kind: "issue-code",
216
226
  code,
@@ -6,26 +6,8 @@ import { randomToken } from "./crypto";
6
6
  import { dispatchMethod } from "./method-dispatch";
7
7
  import { callbackTarget } from "./mount";
8
8
  import { saveEncryptedCode } from "./token";
9
- import { verifyStateEnvelope } from "./state-envelope";
9
+ import { extractCallbackState, verifyStateEnvelope } from "./state-envelope";
10
10
  import { AUTH_CODE_TTL_MS } from "./authorize";
11
- async function extractCallbackState(req) {
12
- const fromQuery = new URL(req.url).searchParams.get("state");
13
- if (fromQuery)
14
- return fromQuery;
15
- if (req.method !== "POST")
16
- return null;
17
- const ct = req.headers.get("content-type") ?? "";
18
- if (!ct.includes("application/x-www-form-urlencoded"))
19
- return null;
20
- try {
21
- const body = await req.clone().text();
22
- const form = new URLSearchParams(body);
23
- const v = form.get("state") ?? form.get("RelayState");
24
- return v && v.length > 0 ? v : null;
25
- } catch {
26
- return null;
27
- }
28
- }
29
11
  async function handleCallback(input, deps) {
30
12
  const url = new URL(input.rawRequest.url);
31
13
  const state = await extractCallbackState(input.rawRequest);
@@ -147,6 +129,16 @@ async function translate(result, flow, deps) {
147
129
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
148
130
  if (isErr(saved))
149
131
  return err(saved.error);
132
+ await safeAudit(deps, {
133
+ kind: "authorize_succeeded",
134
+ tenantId: flow.tenantId,
135
+ clientId: flow.clientId,
136
+ methodId: flow.methodId,
137
+ methodKind: flow.methodKind,
138
+ flowId: flow.flowId,
139
+ providerSubject: result.providerSubject,
140
+ timestamp: now
141
+ });
150
142
  return ok({
151
143
  kind: "issue-code",
152
144
  code,
@@ -282,6 +274,16 @@ async function tryIdpInitiated(input, deps, url) {
282
274
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
283
275
  if (isErr(saved))
284
276
  return err(saved.error);
277
+ await safeAudit(deps, {
278
+ kind: "authorize_succeeded",
279
+ tenantId: input.tenant.id,
280
+ clientId: binding.clientId,
281
+ methodId,
282
+ methodKind: method.kind,
283
+ flowId: "",
284
+ providerSubject: result.providerSubject,
285
+ timestamp: now
286
+ });
285
287
  return ok({
286
288
  kind: "issue-code",
287
289
  code,
@@ -4,6 +4,8 @@ import { err, isErr, ok } from "../types/result";
4
4
  import { verifyClientCredentials } from "./client-auth";
5
5
  import { randomId } from "./crypto";
6
6
  import { mintTokens } from "./token";
7
+ import { safeAudit } from "./audit";
8
+ import { validateSubjectClaim } from "./subject";
7
9
  async function clientCredentialsGrant(req, deps, tenantId) {
8
10
  const tenantCfg = await deps.configStore.getTenantConfig(tenantId);
9
11
  if (isErr(tenantCfg))
@@ -67,6 +69,20 @@ async function clientCredentialsGrant(req, deps, tenantId) {
67
69
  } catch (e) {
68
70
  return err(authError.serverError("success callback threw", e));
69
71
  }
72
+ const checked = await validateSubjectClaim(deps.subjects, claim);
73
+ if (isErr(checked)) {
74
+ await safeAudit(deps, {
75
+ kind: "invalid_subject_claim",
76
+ tenantId: tenant.id,
77
+ clientId: client.id,
78
+ subjectType: checked.error.rejection.subjectType,
79
+ reason: checked.error.rejection.reason,
80
+ detail: checked.error.rejection.detail,
81
+ timestamp: deps.clock()
82
+ });
83
+ return err(checked.error);
84
+ }
85
+ claim = checked.value;
70
86
  const minted = await mintTokens({
71
87
  tenant,
72
88
  claim,
@@ -74,6 +74,16 @@ async function translate(result, flow, deps) {
74
74
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
75
75
  if (isErr(saved))
76
76
  return err(saved.error);
77
+ await safeAudit(deps, {
78
+ kind: "authorize_succeeded",
79
+ tenantId: final.tenantId,
80
+ clientId: final.clientId,
81
+ methodId: final.methodId,
82
+ methodKind: final.methodKind,
83
+ flowId: final.flowId,
84
+ providerSubject: result.providerSubject,
85
+ timestamp: now
86
+ });
77
87
  return ok({
78
88
  kind: "issue-code",
79
89
  code,
@@ -4,6 +4,16 @@ import { err, isErr } from "../types/result";
4
4
  import { safeAudit } from "./audit";
5
5
  import { verifyClientCredentials } from "./client-auth";
6
6
  import { mintTokens } from "./token";
7
+ function validateRequestedScopes(requested, granted) {
8
+ if (!requested)
9
+ return null;
10
+ for (const s of requested.split(" ").filter(Boolean)) {
11
+ if (!granted.includes(s)) {
12
+ return authError.invalidScope(`requested scope "${s}" not granted by original refresh token`);
13
+ }
14
+ }
15
+ return null;
16
+ }
7
17
  async function refreshTokens(req, deps) {
8
18
  const peek = await deps.tokenStore.peekRefresh(req.refreshToken);
9
19
  if (isErr(peek))
@@ -39,6 +49,9 @@ async function refreshTokens(req, deps) {
39
49
  return err(authError.invalidDpopProof("refresh token DPoP jkt does not match the original binding"));
40
50
  }
41
51
  }
52
+ const scopeErr = validateRequestedScopes(req.scope, peekedPayload.scopes);
53
+ if (scopeErr)
54
+ return err(scopeErr);
42
55
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
43
56
  reuseWindowMs: deps.reuseWindowMs
44
57
  });
@@ -56,12 +69,10 @@ async function refreshTokens(req, deps) {
56
69
  return err(consumed.error);
57
70
  }
58
71
  const payload = consumed.value;
72
+ const authoritativeScopeErr = validateRequestedScopes(req.scope, payload.scopes);
73
+ if (authoritativeScopeErr)
74
+ return err(authoritativeScopeErr);
59
75
  const requestedScopes = req.scope ? req.scope.split(" ").filter(Boolean) : payload.scopes;
60
- for (const s of requestedScopes) {
61
- if (!payload.scopes.includes(s)) {
62
- return err(authError.invalidScope(`requested scope "${s}" not granted by original refresh token`));
63
- }
64
- }
65
76
  const tenant = {
66
77
  id: payload.tenantId,
67
78
  config: tenantCfg.value,
@@ -50,12 +50,18 @@ async function registerNewClient(request, tenant, deps) {
50
50
  };
51
51
  const hookResult = await deps.registerClient({
52
52
  tenant,
53
- request
53
+ request,
54
+ client: clientConfig,
55
+ ...secret !== undefined ? { secret } : {}
54
56
  });
55
57
  if (isErr(hookResult))
56
58
  return err(hookResult.error);
57
59
  const persisted = hookResult.value.client;
58
- const persistedSecret = hookResult.value.secret;
60
+ const keptOurSecret = persisted.type === "confidential" && clientConfig.type === "confidential" && persisted.secretHash === clientConfig.secretHash;
61
+ const persistedSecret = hookResult.value.secret ?? (keptOurSecret ? secret : undefined);
62
+ if (persisted.type === "confidential" && persistedSecret === undefined) {
63
+ return err(authError.serverError("registerClient persisted a confidential client with a substituted " + "secretHash but returned no matching plaintext secret"));
64
+ }
59
65
  const issuedAt = Math.floor(deps.clock() / 1000);
60
66
  return ok({
61
67
  client_id: persisted.id,
@@ -74,7 +74,26 @@ function isEnvelopeShape(value) {
74
74
  const v = value;
75
75
  return typeof v.tenantId === "string" && typeof v.flowId === "string" && typeof v.nonce === "string" && typeof v.kid === "string";
76
76
  }
77
+ async function extractCallbackState(req) {
78
+ const fromQuery = new URL(req.url).searchParams.get("state");
79
+ if (fromQuery)
80
+ return fromQuery;
81
+ if (req.method !== "POST")
82
+ return null;
83
+ const ct = req.headers.get("content-type") ?? "";
84
+ if (!ct.toLowerCase().includes("application/x-www-form-urlencoded")) {
85
+ return null;
86
+ }
87
+ try {
88
+ const form = new URLSearchParams(await req.clone().text());
89
+ const v = form.get("state") ?? form.get("RelayState");
90
+ return v && v.length > 0 ? v : null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
77
95
  export {
96
+ extractCallbackState,
78
97
  mintStateEnvelope,
79
98
  verifyStateEnvelope
80
99
  };
@@ -0,0 +1,35 @@
1
+ // src/domain/subject.ts
2
+ import { authError } from "../types/error";
3
+ import { err, ok } from "../types/result";
4
+ async function validateSubjectClaim(subjects, claim) {
5
+ const declared = Object.keys(subjects);
6
+ const subjectType = typeof claim?.type === "string" ? claim.type : "";
7
+ const schema = subjectType ? subjects[subjectType] : undefined;
8
+ if (!schema) {
9
+ return err(Object.assign(authError.serverError(`success() returned subject type "${subjectType}", which is not declared in \`subjects\``), {
10
+ rejection: {
11
+ reason: "unknown-type",
12
+ subjectType,
13
+ detail: `declared: ${declared.join(", ") || "(none)"}`
14
+ }
15
+ }));
16
+ }
17
+ const validated = await schema["~standard"].validate(claim.properties);
18
+ if (validated.issues) {
19
+ const detail = validated.issues.map((i) => (i.path ?? []).map(String).join(".") || "(root)").join(", ") || "(root)";
20
+ return err(Object.assign(authError.serverError(`success() returned properties that violate the "${subjectType}" schema`), {
21
+ rejection: {
22
+ reason: "invalid-properties",
23
+ subjectType,
24
+ detail
25
+ }
26
+ }));
27
+ }
28
+ return ok({
29
+ type: subjectType,
30
+ properties: validated.value
31
+ });
32
+ }
33
+ export {
34
+ validateSubjectClaim
35
+ };
@@ -15,6 +15,7 @@ import {
15
15
  import { buildIdTokenClaims, shouldIssueIdToken } from "./id-token";
16
16
  import { signAccessToken, signIdToken } from "./jwt";
17
17
  import { validatePkce } from "./pkce";
18
+ import { validateSubjectClaim } from "./subject";
18
19
  async function saveEncryptedCode(code, payload, ttl, deps) {
19
20
  const keyResult = await deps.keyStore.currentEncryptionKey();
20
21
  if (isErr(keyResult))
@@ -91,6 +92,20 @@ async function exchangeCode(req, deps) {
91
92
  } catch (e) {
92
93
  return err(authError.serverError("success callback threw", e));
93
94
  }
95
+ const checked = await validateSubjectClaim(deps.subjects, claim);
96
+ if (isErr(checked)) {
97
+ await safeAudit(deps, {
98
+ kind: "invalid_subject_claim",
99
+ tenantId: payload.tenantId,
100
+ clientId: payload.clientId,
101
+ subjectType: checked.error.rejection.subjectType,
102
+ reason: checked.error.rejection.reason,
103
+ detail: checked.error.rejection.detail,
104
+ timestamp: deps.clock()
105
+ });
106
+ return err(checked.error);
107
+ }
108
+ claim = checked.value;
94
109
  if (deps.persistUpstreamTokens) {
95
110
  try {
96
111
  await deps.persistUpstreamTokens({
@@ -67,6 +67,7 @@ function makeTokenHandler(deps) {
67
67
  keyStore: deps.keyStore,
68
68
  ...deps.auditLog ? { auditLog: deps.auditLog } : {},
69
69
  success: deps.success,
70
+ subjects: deps.subjects,
70
71
  ...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
71
72
  issuerUrl: c.get("issuerUrl"),
72
73
  clock: deps.clock,
@@ -99,6 +100,7 @@ function makeTokenHandler(deps) {
99
100
  ...deps.auditLog ? { auditLog: deps.auditLog } : {},
100
101
  methodCache: deps.methodCache,
101
102
  success: deps.success,
103
+ subjects: deps.subjects,
102
104
  ...deps.persistUpstreamTokens ? { persistUpstreamTokens: deps.persistUpstreamTokens } : {},
103
105
  issuerUrl: c.get("issuerUrl"),
104
106
  clock: deps.clock,
@@ -1,5 +1,8 @@
1
1
  // src/http/middleware/tenant.ts
2
- import { verifyStateEnvelope } from "../../domain/state-envelope";
2
+ import {
3
+ extractCallbackState,
4
+ verifyStateEnvelope
5
+ } from "../../domain/state-envelope";
3
6
  import { isErr } from "../../types/result";
4
7
  import { parseCookieHeader } from "../cookies";
5
8
  import {
@@ -90,7 +93,7 @@ function buildTenantContext(req, id, config, custom) {
90
93
  };
91
94
  }
92
95
  async function runCallbackRecovery(req, deps) {
93
- const state = await extractStateParam(req);
96
+ const state = await extractCallbackState(req);
94
97
  if (state) {
95
98
  const env = await verifyStateEnvelope(state, deps.stateKeys);
96
99
  if (env.ok) {
@@ -103,24 +106,6 @@ async function runCallbackRecovery(req, deps) {
103
106
  }
104
107
  return { kind: "fresh-request" };
105
108
  }
106
- async function extractStateParam(req) {
107
- const url = new URL(req.url);
108
- const fromQuery = url.searchParams.get("state");
109
- if (fromQuery)
110
- return fromQuery;
111
- if (req.method !== "POST")
112
- return null;
113
- const ct = req.headers.get("content-type") ?? "";
114
- if (!ct.toLowerCase().startsWith("application/x-www-form-urlencoded")) {
115
- return null;
116
- }
117
- try {
118
- const text = await req.clone().text();
119
- return new URLSearchParams(text).get("state");
120
- } catch {
121
- return null;
122
- }
123
- }
124
109
  export {
125
110
  bootstrapMiddleware,
126
111
  tenantMiddleware
package/dist/esm/index.js CHANGED
@@ -67,6 +67,7 @@ function createIdP(opts) {
67
67
  ...opts.callbackHostFor ? { callbackHostFor: opts.callbackHostFor } : {},
68
68
  resolveTenant: opts.resolveTenant,
69
69
  success: opts.success,
70
+ subjects: opts.subjects,
70
71
  ...opts.onLogout ? { onLogout: opts.onLogout } : {},
71
72
  ...opts.persistUpstreamTokens ? { persistUpstreamTokens: opts.persistUpstreamTokens } : {},
72
73
  ...opts.exchangeAudience ? { exchangeAudience: opts.exchangeAudience } : {},
@@ -90,26 +90,45 @@ export interface ClientInput {
90
90
  */
91
91
  issuer?: string;
92
92
  /**
93
- * Optionally, override the internally used fetch function.
93
+ * The client secret, for **confidential** clients only.
94
94
  *
95
- * This is useful if you are using a polyfilled fetch function in your application and you
96
- * want the client to use it too.
97
- */
98
- fetch?: FetchLike;
99
- }
100
- export interface AuthorizeOptions {
101
- /**
102
- * Enable the PKCE flow. This is for SPA apps.
95
+ * Supply this for a server-side app registered as a confidential client;
96
+ * `exchange()` and `refresh()` then authenticate at `/token`. Omit it for
97
+ * public clients (SPA, mobile, CLI) — a secret cannot be kept in code
98
+ * that ships to users, and the IdP requires PKCE there instead.
99
+ *
100
+ * Without this, a confidential client's `exchange()` is rejected with
101
+ * `invalid_client`: the token endpoint has no way to authenticate it.
103
102
  *
103
+ * @example
104
104
  * ```ts
105
105
  * {
106
- * pkce: true
106
+ * clientID: "my-server-app",
107
+ * clientSecret: process.env.CLIENT_SECRET
107
108
  * }
108
109
  * ```
110
+ */
111
+ clientSecret?: string;
112
+ /**
113
+ * How to present `clientSecret` at the token endpoint.
109
114
  *
110
- * @default false
115
+ * `client_secret_basic` (the default) sends HTTP Basic credentials, which
116
+ * is what RFC 6749 §2.3.1 prefers and what the IdP parses first.
117
+ * `client_secret_post` puts them in the form body. Both are advertised in
118
+ * discovery as `token_endpoint_auth_methods_supported`.
119
+ *
120
+ * @default "client_secret_basic"
121
+ */
122
+ tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
123
+ /**
124
+ * Optionally, override the internally used fetch function.
125
+ *
126
+ * This is useful if you are using a polyfilled fetch function in your application and you
127
+ * want the client to use it too.
111
128
  */
112
- pkce?: boolean;
129
+ fetch?: FetchLike;
130
+ }
131
+ export interface AuthorizeOptions {
113
132
  /**
114
133
  * The provider you want to use for the OAuth flow.
115
134
  *
@@ -238,7 +257,13 @@ export interface VerifyOptions {
238
257
  */
239
258
  issuer?: string;
240
259
  /**
241
- * @internal
260
+ * The audience to require on the token.
261
+ *
262
+ * Defaults to this client's `clientID`, which is what the IdP puts in
263
+ * `aud` for an ordinary login. Set this when verifying a token minted
264
+ * for a **resource** — an `/authorize` call that passed `audience` puts
265
+ * that value in `aud` instead, so a resource server verifying it must
266
+ * name itself here.
242
267
  */
243
268
  audience?: string;
244
269
  /**
@@ -251,9 +276,14 @@ export interface VerifyOptions {
251
276
  }
252
277
  export interface VerifyResult<T extends SubjectSchema> {
253
278
  /**
254
- * This is always `undefined` when the verify is successful.
279
+ * This is always `false` when the verify is successful.
280
+ *
281
+ * A literal, not an optional — `err?: undefined` would leave the
282
+ * property present on this arm, so `"err" in result` narrowed nothing
283
+ * and callers had to test truthiness instead. Matches `ExchangeSuccess`
284
+ * and `RefreshSuccess`.
255
285
  */
256
- err?: undefined;
286
+ err: false;
257
287
  /**
258
288
  * Returns the refreshed tokens only if they’ve been refreshed.
259
289
  *
@@ -297,38 +327,29 @@ export interface VerifyError {
297
327
  */
298
328
  export interface Client {
299
329
  /**
300
- * Start the autorization flow. For example, in SSR sites.
330
+ * Start the authorization code flow.
301
331
  *
302
332
  * ```ts
303
- * const { url } = await client.authorize(<redirect_uri>, "code")
333
+ * const { challenge, url } = await client.authorize(<redirect_uri>)
334
+ * // store `challenge`, then redirect the user to `url`
304
335
  * ```
305
336
  *
306
- * This takes a redirect URI and the type of flow you want to use. The redirect URI is the
307
- * location where the user will be redirected to after the flow is complete.
308
- *
309
- * Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more
310
- * secure.
337
+ * Returns the URL to send the user to, and a `challenge` carrying the
338
+ * CSRF `state` and the PKCE `verifier`. Persist the challenge (a cookie
339
+ * server-side, `sessionStorage` in a SPA) and hand the verifier back to
340
+ * {@link Client.exchange} when the user returns.
311
341
  *
312
- * :::tip
313
- * This returns a URL to redirect the user to. This starts the OAuth flow.
314
- * :::
315
- *
316
- * This returns a URL to the auth server. You can redirect the user to the URL to start the
317
- * OAuth flow.
318
- *
319
- * For SPA apps, we recommend using the PKCE flow.
320
- *
321
- * ```ts {4}
322
- * const { challenge, url } = await client.authorize(
323
- * <redirect_uri>,
324
- * "code",
325
- * { pkce: true }
326
- * )
327
- * ```
342
+ * **PKCE is always used.** The IdP requires it for public clients, and
343
+ * OAuth 2.1 §7.5.1 recommends it for confidential ones too, so there is
344
+ * no reason to offer it as a toggle. Before 0.14.0 it was opt-in and
345
+ * off by default, which meant the documented server-side flow could not
346
+ * complete against a public client at all.
328
347
  *
329
- * This returns a redirect URL and a challenge that you need to use later to verify the code.
348
+ * Only the authorization code flow is supported. The implicit flow
349
+ * (`response_type=token`) is removed in OAuth 2.1 and the IdP rejects
350
+ * it with `unsupported_response_type`.
330
351
  */
331
- authorize(redirectURI: string, response: "code" | "token", opts?: AuthorizeOptions): Promise<AuthorizeResult>;
352
+ authorize(redirectURI: string, opts?: AuthorizeOptions): Promise<AuthorizeResult>;
332
353
  /**
333
354
  * Exchange the code for access and refresh tokens.
334
355
  *