@_mustachio/openauth 0.10.0 → 0.11.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 (151) hide show
  1. package/dist/esm/adapters/memory/session-store.js +22 -0
  2. package/dist/esm/adapters/memory/token-store.js +13 -0
  3. package/dist/esm/client.js +9 -2
  4. package/dist/esm/domain/authorize.js +5 -0
  5. package/dist/esm/domain/callback.js +3 -0
  6. package/dist/esm/domain/discovery.js +44 -2
  7. package/dist/esm/domain/dpop.js +122 -0
  8. package/dist/esm/domain/id-token.js +94 -0
  9. package/dist/esm/domain/introspect.js +5 -1
  10. package/dist/esm/domain/jwt.js +28 -0
  11. package/dist/esm/domain/logout.js +75 -0
  12. package/dist/esm/domain/method-route.js +3 -0
  13. package/dist/esm/domain/par.js +51 -0
  14. package/dist/esm/domain/refresh.js +12 -1
  15. package/dist/esm/domain/register.js +74 -0
  16. package/dist/esm/domain/token-exchange.js +1 -0
  17. package/dist/esm/domain/token.js +48 -7
  18. package/dist/esm/domain/userinfo.js +38 -3
  19. package/dist/esm/http/handlers/authorize.js +90 -3
  20. package/dist/esm/http/handlers/end-session.js +64 -0
  21. package/dist/esm/http/handlers/metadata.js +5 -2
  22. package/dist/esm/http/handlers/par.js +53 -0
  23. package/dist/esm/http/handlers/register.js +46 -0
  24. package/dist/esm/http/handlers/token.js +36 -6
  25. package/dist/esm/http/handlers/userinfo.js +35 -29
  26. package/dist/esm/http/router.js +10 -0
  27. package/dist/esm/http/schemas/authorize.js +7 -1
  28. package/dist/esm/http/schemas/end-session.js +13 -0
  29. package/dist/esm/http/schemas/par.js +9 -0
  30. package/dist/esm/http/schemas/register.js +16 -0
  31. package/dist/esm/index.js +7 -2
  32. package/dist/esm/types/error.js +5 -0
  33. package/dist/types/adapters/memory/session-store.d.ts +3 -1
  34. package/dist/types/adapters/memory/session-store.d.ts.map +1 -1
  35. package/dist/types/adapters/memory/token-store.d.ts +1 -0
  36. package/dist/types/adapters/memory/token-store.d.ts.map +1 -1
  37. package/dist/types/client.d.ts +19 -0
  38. package/dist/types/client.d.ts.map +1 -1
  39. package/dist/types/domain/authorize.d.ts.map +1 -1
  40. package/dist/types/domain/client-credentials.d.ts +2 -0
  41. package/dist/types/domain/client-credentials.d.ts.map +1 -1
  42. package/dist/types/domain/discovery.d.ts +37 -0
  43. package/dist/types/domain/discovery.d.ts.map +1 -1
  44. package/dist/types/domain/dpop.d.ts +61 -0
  45. package/dist/types/domain/dpop.d.ts.map +1 -0
  46. package/dist/types/domain/id-token.d.ts +83 -0
  47. package/dist/types/domain/id-token.d.ts.map +1 -0
  48. package/dist/types/domain/introspect.d.ts +8 -0
  49. package/dist/types/domain/introspect.d.ts.map +1 -1
  50. package/dist/types/domain/jwt.d.ts +27 -1
  51. package/dist/types/domain/jwt.d.ts.map +1 -1
  52. package/dist/types/domain/logout.d.ts +69 -0
  53. package/dist/types/domain/logout.d.ts.map +1 -0
  54. package/dist/types/domain/par.d.ts +62 -0
  55. package/dist/types/domain/par.d.ts.map +1 -0
  56. package/dist/types/domain/refresh.d.ts +9 -0
  57. package/dist/types/domain/refresh.d.ts.map +1 -1
  58. package/dist/types/domain/register.d.ts +33 -0
  59. package/dist/types/domain/register.d.ts.map +1 -0
  60. package/dist/types/domain/token-exchange.d.ts +2 -0
  61. package/dist/types/domain/token-exchange.d.ts.map +1 -1
  62. package/dist/types/domain/token.d.ts +41 -1
  63. package/dist/types/domain/token.d.ts.map +1 -1
  64. package/dist/types/domain/userinfo.d.ts +44 -5
  65. package/dist/types/domain/userinfo.d.ts.map +1 -1
  66. package/dist/types/http/context.d.ts +4 -1
  67. package/dist/types/http/context.d.ts.map +1 -1
  68. package/dist/types/http/handlers/authorize.d.ts.map +1 -1
  69. package/dist/types/http/handlers/end-session.d.ts +3 -0
  70. package/dist/types/http/handlers/end-session.d.ts.map +1 -0
  71. package/dist/types/http/handlers/metadata.d.ts +1 -1
  72. package/dist/types/http/handlers/metadata.d.ts.map +1 -1
  73. package/dist/types/http/handlers/par.d.ts +3 -0
  74. package/dist/types/http/handlers/par.d.ts.map +1 -0
  75. package/dist/types/http/handlers/register.d.ts +3 -0
  76. package/dist/types/http/handlers/register.d.ts.map +1 -0
  77. package/dist/types/http/handlers/token.d.ts.map +1 -1
  78. package/dist/types/http/handlers/userinfo.d.ts.map +1 -1
  79. package/dist/types/http/router.d.ts.map +1 -1
  80. package/dist/types/http/schemas/authorize.d.ts +40 -0
  81. package/dist/types/http/schemas/authorize.d.ts.map +1 -1
  82. package/dist/types/http/schemas/end-session.d.ts +31 -0
  83. package/dist/types/http/schemas/end-session.d.ts.map +1 -0
  84. package/dist/types/http/schemas/par.d.ts +20 -0
  85. package/dist/types/http/schemas/par.d.ts.map +1 -0
  86. package/dist/types/http/schemas/register.d.ts +42 -0
  87. package/dist/types/http/schemas/register.d.ts.map +1 -0
  88. package/dist/types/index.d.ts +3 -3
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/types/ports/audit-log.d.ts +27 -0
  91. package/dist/types/ports/audit-log.d.ts.map +1 -1
  92. package/dist/types/ports/session-store.d.ts +27 -0
  93. package/dist/types/ports/session-store.d.ts.map +1 -1
  94. package/dist/types/ports/token-store.d.ts +12 -0
  95. package/dist/types/ports/token-store.d.ts.map +1 -1
  96. package/dist/types/types/authorization.d.ts +23 -0
  97. package/dist/types/types/authorization.d.ts.map +1 -1
  98. package/dist/types/types/error.d.ts +9 -0
  99. package/dist/types/types/error.d.ts.map +1 -1
  100. package/dist/types/types/flow.d.ts +10 -0
  101. package/dist/types/types/flow.d.ts.map +1 -1
  102. package/dist/types/types/idp.d.ts +91 -1
  103. package/dist/types/types/idp.d.ts.map +1 -1
  104. package/dist/types/types/tenant.d.ts +37 -0
  105. package/dist/types/types/tenant.d.ts.map +1 -1
  106. package/dist/types/types/token.d.ts +128 -0
  107. package/dist/types/types/token.d.ts.map +1 -1
  108. package/package.json +1 -1
  109. package/src/adapters/memory/session-store.ts +44 -1
  110. package/src/adapters/memory/token-store.ts +16 -0
  111. package/src/client.ts +32 -0
  112. package/src/domain/authorize.ts +9 -0
  113. package/src/domain/callback.ts +5 -0
  114. package/src/domain/client-credentials.ts +2 -0
  115. package/src/domain/discovery.ts +80 -2
  116. package/src/domain/dpop.ts +293 -0
  117. package/src/domain/id-token.ts +213 -0
  118. package/src/domain/introspect.ts +10 -0
  119. package/src/domain/jwt.ts +80 -1
  120. package/src/domain/logout.ts +180 -0
  121. package/src/domain/method-route.ts +5 -0
  122. package/src/domain/par.ts +141 -0
  123. package/src/domain/refresh.ts +41 -0
  124. package/src/domain/register.ts +168 -0
  125. package/src/domain/token-exchange.ts +11 -0
  126. package/src/domain/token.ts +134 -8
  127. package/src/domain/userinfo.ts +121 -6
  128. package/src/http/context.ts +4 -0
  129. package/src/http/handlers/authorize.ts +144 -3
  130. package/src/http/handlers/end-session.ts +106 -0
  131. package/src/http/handlers/metadata.ts +7 -2
  132. package/src/http/handlers/par.ts +84 -0
  133. package/src/http/handlers/register.ts +70 -0
  134. package/src/http/handlers/token.ts +56 -0
  135. package/src/http/handlers/userinfo.ts +62 -38
  136. package/src/http/router.ts +19 -0
  137. package/src/http/schemas/authorize.ts +27 -0
  138. package/src/http/schemas/end-session.ts +23 -0
  139. package/src/http/schemas/par.ts +23 -0
  140. package/src/http/schemas/register.ts +29 -0
  141. package/src/index.ts +18 -1
  142. package/src/ports/CONSISTENCY.md +52 -21
  143. package/src/ports/audit-log.ts +29 -0
  144. package/src/ports/session-store.ts +33 -0
  145. package/src/ports/token-store.ts +13 -0
  146. package/src/types/authorization.ts +25 -0
  147. package/src/types/error.ts +23 -0
  148. package/src/types/flow.ts +10 -0
  149. package/src/types/idp.ts +96 -0
  150. package/src/types/tenant.ts +37 -0
  151. package/src/types/token.ts +135 -0
@@ -7,6 +7,7 @@ class MemorySessionStore {
7
7
  #clock;
8
8
  #flows = new Map;
9
9
  #sessions = new Map;
10
+ #par = new Map;
10
11
  constructor(opts = {}) {
11
12
  this.#clock = opts.clock ?? realClock;
12
13
  }
@@ -76,6 +77,27 @@ class MemorySessionStore {
76
77
  this.#sessions.delete(sessionId);
77
78
  return ok(undefined);
78
79
  }
80
+ async savePar(requestUri, payload, ttl) {
81
+ if (ttl <= 0) {
82
+ return err(authError.internalError(`savePar: ttl must be positive, got ${ttl}`));
83
+ }
84
+ this.#par.set(requestUri, {
85
+ record: payload,
86
+ expiresAt: this.#clock() + ttl
87
+ });
88
+ return ok(undefined);
89
+ }
90
+ async consumePar(requestUri) {
91
+ const stored = this.#par.get(requestUri);
92
+ if (!stored) {
93
+ return err(authError.unknownState(`par "${requestUri}" unknown or already consumed`));
94
+ }
95
+ this.#par.delete(requestUri);
96
+ if (this.#clock() >= stored.expiresAt) {
97
+ return err(authError.unknownState(`par "${requestUri}" expired`));
98
+ }
99
+ return ok(stored.record);
100
+ }
79
101
  }
80
102
  export {
81
103
  MemorySessionStore
@@ -8,6 +8,7 @@ class MemoryTokenStore {
8
8
  #clock;
9
9
  #codes = new Map;
10
10
  #refresh = new Map;
11
+ #dpopJtis = new Map;
11
12
  constructor(opts = {}) {
12
13
  this.#clock = opts.clock ?? realClock;
13
14
  }
@@ -85,6 +86,18 @@ class MemoryTokenStore {
85
86
  }
86
87
  return ok(undefined);
87
88
  }
89
+ async recordDpopJti(jti, ttlMs) {
90
+ const now = this.#clock();
91
+ for (const [k, exp] of this.#dpopJtis) {
92
+ if (exp <= now)
93
+ this.#dpopJtis.delete(k);
94
+ }
95
+ if (this.#dpopJtis.has(jti)) {
96
+ return err(authError.invalidGrant(`dpop jti "${jti}" replayed`));
97
+ }
98
+ this.#dpopJtis.set(jti, now + ttlMs);
99
+ return ok(undefined);
100
+ }
88
101
  }
89
102
  export {
90
103
  MemoryTokenStore
@@ -49,6 +49,11 @@ function createClient(input) {
49
49
  result2.searchParams.set("state", challenge.state);
50
50
  if (opts?.provider)
51
51
  result2.searchParams.set("provider", opts.provider);
52
+ if (opts?.scope !== undefined) {
53
+ const scope = Array.isArray(opts.scope) ? opts.scope.join(" ") : opts.scope;
54
+ if (scope)
55
+ result2.searchParams.set("scope", scope);
56
+ }
52
57
  if (opts?.pkce && response === "code") {
53
58
  const pkce = await generatePKCE();
54
59
  result2.searchParams.set("code_challenge_method", "S256");
@@ -97,7 +102,8 @@ function createClient(input) {
97
102
  tokens: {
98
103
  access: json.access_token,
99
104
  refresh: json.refresh_token,
100
- expiresIn: json.expires_in
105
+ expiresIn: json.expires_in,
106
+ ...typeof json.id_token === "string" ? { idToken: json.id_token } : {}
101
107
  }
102
108
  };
103
109
  },
@@ -136,7 +142,8 @@ function createClient(input) {
136
142
  tokens: {
137
143
  access: json.access_token,
138
144
  refresh: json.refresh_token,
139
- expiresIn: json.expires_in
145
+ expiresIn: json.expires_in,
146
+ ...typeof json.id_token === "string" ? { idToken: json.id_token } : {}
140
147
  }
141
148
  };
142
149
  },
@@ -73,6 +73,8 @@ async function startAuthorize(input, deps) {
73
73
  prompt: req.prompt,
74
74
  uiLocales: req.uiLocales,
75
75
  nonce,
76
+ ...req.nonce !== undefined ? { appNonce: req.nonce } : {},
77
+ ...req.claimsRequest !== undefined ? { claimsRequest: req.claimsRequest } : {},
76
78
  clientPkce: req.codeChallenge ? { challenge: req.codeChallenge, method: "S256" } : undefined,
77
79
  methodState: null,
78
80
  context: tenant.request.custom,
@@ -194,6 +196,9 @@ async function issueCodeFromInlineSuccess(result, record, deps) {
194
196
  context: flow.context ?? null,
195
197
  providerSubject: result.providerSubject,
196
198
  properties: result.properties,
199
+ ...flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {},
200
+ ...flow.claimsRequest !== undefined ? { claimsRequest: flow.claimsRequest } : {},
201
+ authTime: Math.floor(now / 1000),
197
202
  expiresAt: now + AUTH_CODE_TTL_MS
198
203
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
199
204
  if (isErr(saved))
@@ -117,6 +117,9 @@ async function translate(result, flow, deps) {
117
117
  context: flow.context ?? null,
118
118
  providerSubject: result.providerSubject,
119
119
  properties: result.properties,
120
+ ...flow.appNonce !== undefined ? { appNonce: flow.appNonce } : {},
121
+ ...flow.claimsRequest !== undefined ? { claimsRequest: flow.claimsRequest } : {},
122
+ authTime: Math.floor(now / 1000),
120
123
  expiresAt: now + AUTH_CODE_TTL_MS
121
124
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
122
125
  if (isErr(saved))
@@ -4,6 +4,12 @@ import { buildJwksDocument } from "./jwt";
4
4
  function buildDiscoveryDocument(deps) {
5
5
  const base = deps.issuerUrl.replace(/\/+$/, "");
6
6
  const p = deps.paths ?? {};
7
+ const customScopeKeys = Object.keys(deps.customScopeClaims ?? {});
8
+ const customClaimNames = new Set;
9
+ for (const list of Object.values(deps.customScopeClaims ?? {})) {
10
+ for (const name of list)
11
+ customClaimNames.add(name);
12
+ }
7
13
  return {
8
14
  issuer: deps.issuerUrl,
9
15
  authorization_endpoint: `${base}${p.authorize ?? "/authorize"}`,
@@ -12,15 +18,51 @@ function buildDiscoveryDocument(deps) {
12
18
  jwks_uri: `${base}${p.jwks ?? "/.well-known/jwks.json"}`,
13
19
  revocation_endpoint: `${base}${p.revoke ?? "/revoke"}`,
14
20
  introspection_endpoint: `${base}${p.introspect ?? "/introspect"}`,
21
+ end_session_endpoint: `${base}${p.endSession ?? "/end_session"}`,
22
+ registration_endpoint: `${base}${p.register ?? "/register"}`,
23
+ pushed_authorization_request_endpoint: `${base}${p.par ?? "/par"}`,
24
+ require_pushed_authorization_requests: false,
25
+ dpop_signing_alg_values_supported: ["ES256", "EdDSA"],
15
26
  response_types_supported: ["code"],
16
27
  grant_types_supported: [
17
28
  "authorization_code",
18
29
  "refresh_token",
19
30
  "client_credentials"
20
31
  ],
21
- subject_types_supported: ["public"],
32
+ subject_types_supported: ["public", "pairwise"],
22
33
  id_token_signing_alg_values_supported: ["ES256", "EdDSA"],
23
- scopes_supported: deps.scopes ?? ["openid", "email", "profile"],
34
+ scopes_supported: [
35
+ ...deps.scopes ?? ["openid", "email", "profile"],
36
+ ...customScopeKeys
37
+ ],
38
+ claims_supported: [
39
+ "sub",
40
+ "iss",
41
+ "aud",
42
+ "exp",
43
+ "iat",
44
+ "auth_time",
45
+ "nonce",
46
+ "amr",
47
+ "at_hash",
48
+ "email",
49
+ "email_verified",
50
+ "name",
51
+ "given_name",
52
+ "family_name",
53
+ "preferred_username",
54
+ "picture",
55
+ "locale",
56
+ "phone_number",
57
+ "phone_number_verified",
58
+ "address",
59
+ ...customClaimNames
60
+ ],
61
+ claims_parameter_supported: true,
62
+ request_parameter_supported: false,
63
+ request_uri_parameter_supported: false,
64
+ require_request_uri_registration: false,
65
+ ui_locales_supported: deps.uiLocales ?? ["en"],
24
66
  token_endpoint_auth_methods_supported: [
25
67
  "client_secret_basic",
26
68
  "client_secret_post",
@@ -0,0 +1,122 @@
1
+ // src/domain/dpop.ts
2
+ import {
3
+ calculateJwkThumbprint,
4
+ decodeProtectedHeader,
5
+ importJWK,
6
+ jwtVerify
7
+ } from "jose";
8
+ import { authError } from "../types/error";
9
+ import { err, isErr, ok } from "../types/result";
10
+ import { base64url, sha256, utf8 } from "./crypto";
11
+ var DPOP_ASYMMETRIC_ALGS = new Set(["ES256", "EdDSA"]);
12
+ var DEFAULT_DPOP_IAT_TOLERANCE_SEC = 60;
13
+ var DEFAULT_DPOP_JTI_TTL_MS = 2 * DEFAULT_DPOP_IAT_TOLERANCE_SEC * 1000;
14
+ async function verifyDpopProof(input, deps) {
15
+ if (!deps.tokenStore.recordDpopJti) {
16
+ return err(authError.invalidDpopProof("token-store adapter does not support DPoP replay protection"));
17
+ }
18
+ let header;
19
+ try {
20
+ header = decodeProtectedHeader(input.proofJwt);
21
+ } catch (e) {
22
+ return err(authError.invalidDpopProof(`dpop proof header is not a valid compact JWS: ${stringifyError(e)}`));
23
+ }
24
+ if (header.typ !== "dpop+jwt") {
25
+ return err(authError.invalidDpopProof(`dpop proof header.typ must be "dpop+jwt", got "${String(header.typ)}"`));
26
+ }
27
+ if (!header.alg || !DPOP_ASYMMETRIC_ALGS.has(header.alg)) {
28
+ return err(authError.invalidDpopProof(`dpop proof alg "${String(header.alg)}" not in {ES256, EdDSA}`));
29
+ }
30
+ const jwk = header.jwk;
31
+ if (!jwk || typeof jwk !== "object") {
32
+ return err(authError.invalidDpopProof("dpop proof header.jwk missing or not an object"));
33
+ }
34
+ if ("d" in jwk) {
35
+ return err(authError.invalidDpopProof("dpop proof header.jwk must be a public key (private material present)"));
36
+ }
37
+ let key;
38
+ try {
39
+ key = await importJWK(jwk, header.alg);
40
+ } catch (e) {
41
+ return err(authError.invalidDpopProof(`dpop proof JWK import failed: ${stringifyError(e)}`));
42
+ }
43
+ let verified;
44
+ try {
45
+ verified = await jwtVerify(input.proofJwt, key, {
46
+ algorithms: [header.alg]
47
+ });
48
+ } catch (e) {
49
+ return err(authError.invalidDpopProof(`dpop proof signature verification failed: ${stringifyError(e)}`));
50
+ }
51
+ const payload = verified.payload;
52
+ if (typeof payload.htm !== "string") {
53
+ return err(authError.invalidDpopProof("dpop proof missing string htm"));
54
+ }
55
+ if (typeof payload.htu !== "string") {
56
+ return err(authError.invalidDpopProof("dpop proof missing string htu"));
57
+ }
58
+ if (typeof payload.iat !== "number") {
59
+ return err(authError.invalidDpopProof("dpop proof missing numeric iat"));
60
+ }
61
+ if (typeof payload.jti !== "string" || payload.jti.length === 0) {
62
+ return err(authError.invalidDpopProof("dpop proof missing non-empty string jti"));
63
+ }
64
+ if (payload.htm !== input.htm) {
65
+ return err(authError.invalidDpopProof(`dpop htm "${payload.htm}" does not match request method "${input.htm}"`));
66
+ }
67
+ if (payload.htu !== input.htu) {
68
+ return err(authError.invalidDpopProof(`dpop htu "${payload.htu}" does not match request uri "${input.htu}"`));
69
+ }
70
+ const tolerance = input.iatToleranceSec ?? DEFAULT_DPOP_IAT_TOLERANCE_SEC;
71
+ const skew = Math.abs(input.nowSec - payload.iat);
72
+ if (skew > tolerance) {
73
+ return err(authError.invalidDpopProof(`dpop iat skew ${skew}s exceeds tolerance ${tolerance}s`));
74
+ }
75
+ if (input.expectedAth !== undefined) {
76
+ if (typeof payload.ath !== "string" || payload.ath !== input.expectedAth) {
77
+ return err(authError.invalidDpopProof("dpop ath does not match SHA-256 of the access token"));
78
+ }
79
+ }
80
+ const recorded = await deps.tokenStore.recordDpopJti(payload.jti, deps.jtiTtlMs ?? DEFAULT_DPOP_JTI_TTL_MS);
81
+ if (isErr(recorded)) {
82
+ return err(authError.invalidDpopProof(`dpop jti "${payload.jti}" replayed`, {
83
+ jti: payload.jti
84
+ }));
85
+ }
86
+ let jkt;
87
+ try {
88
+ jkt = await calculateJwkThumbprint(jwk, "sha256");
89
+ } catch (e) {
90
+ return err(authError.invalidDpopProof(`dpop jkt thumbprint computation failed: ${stringifyError(e)}`));
91
+ }
92
+ return ok({
93
+ jkt,
94
+ payload: {
95
+ htm: payload.htm,
96
+ htu: payload.htu,
97
+ iat: payload.iat,
98
+ jti: payload.jti,
99
+ ...payload.ath !== undefined ? { ath: payload.ath } : {},
100
+ ...payload.nonce !== undefined ? { nonce: payload.nonce } : {}
101
+ }
102
+ });
103
+ }
104
+ function canonicalHtu(rawUrl) {
105
+ const u = new URL(rawUrl);
106
+ u.search = "";
107
+ u.hash = "";
108
+ return u.toString();
109
+ }
110
+ async function computeAth(accessToken) {
111
+ return base64url.encode(await sha256(utf8.encode(accessToken)));
112
+ }
113
+ function stringifyError(e) {
114
+ return e instanceof Error ? e.message : String(e);
115
+ }
116
+ export {
117
+ verifyDpopProof,
118
+ computeAth,
119
+ canonicalHtu,
120
+ DEFAULT_DPOP_JTI_TTL_MS,
121
+ DEFAULT_DPOP_IAT_TOLERANCE_SEC
122
+ };
@@ -0,0 +1,94 @@
1
+ // src/domain/id-token.ts
2
+ import { base64url, sha256, utf8 } from "./crypto";
3
+ var SCOPE_TO_CLAIMS = {
4
+ profile: [
5
+ "name",
6
+ "given_name",
7
+ "family_name",
8
+ "middle_name",
9
+ "nickname",
10
+ "preferred_username",
11
+ "profile",
12
+ "picture",
13
+ "website",
14
+ "gender",
15
+ "birthdate",
16
+ "zoneinfo",
17
+ "locale",
18
+ "updated_at"
19
+ ],
20
+ email: ["email", "email_verified"],
21
+ phone: ["phone_number", "phone_number_verified"],
22
+ address: ["address"]
23
+ };
24
+ function deriveAmr(methodKind) {
25
+ switch (methodKind) {
26
+ case "password":
27
+ return ["pwd"];
28
+ case "code":
29
+ return ["otp"];
30
+ case "passkey":
31
+ return ["hwk"];
32
+ default:
33
+ return;
34
+ }
35
+ }
36
+ async function computeAtHash(accessToken) {
37
+ const digest = await sha256(utf8.encode(accessToken));
38
+ const half = digest.slice(0, digest.byteLength / 2);
39
+ return base64url.encode(half);
40
+ }
41
+ function pickScopedClaims(claim, scopes, extra = [], customMappings = {}) {
42
+ const props = claim.properties;
43
+ if (!props || typeof props !== "object")
44
+ return {};
45
+ const mappings = {
46
+ ...customMappings,
47
+ ...SCOPE_TO_CLAIMS
48
+ };
49
+ const granted = new Set;
50
+ for (const scope of scopes) {
51
+ const list = mappings[scope];
52
+ if (!list)
53
+ continue;
54
+ for (const c of list)
55
+ granted.add(c);
56
+ }
57
+ for (const name of extra)
58
+ granted.add(name);
59
+ const out = {};
60
+ for (const key of granted) {
61
+ if (key in props && props[key] !== undefined)
62
+ out[key] = props[key];
63
+ }
64
+ return out;
65
+ }
66
+ var DEFAULT_ID_TOKEN_TTL_MS = 5 * 60 * 1000;
67
+ async function buildIdTokenClaims(input) {
68
+ const ttl = input.ttlMs ?? DEFAULT_ID_TOKEN_TTL_MS;
69
+ const amr = deriveAmr(input.methodKind);
70
+ const extraClaims = Object.keys(input.claimsRequest?.id_token ?? {});
71
+ return {
72
+ iss: input.issuerUrl,
73
+ sub: input.subjectId,
74
+ aud: input.audience,
75
+ exp: Math.floor((input.now + ttl) / 1000),
76
+ iat: Math.floor(input.now / 1000),
77
+ auth_time: input.authTime,
78
+ at_hash: await computeAtHash(input.accessToken),
79
+ ...input.appNonce !== undefined ? { nonce: input.appNonce } : {},
80
+ ...amr ? { amr } : {},
81
+ ...pickScopedClaims(input.claim, input.scopes, extraClaims, input.customScopeClaims ?? {})
82
+ };
83
+ }
84
+ function shouldIssueIdToken(scopes) {
85
+ return scopes.includes("openid");
86
+ }
87
+ export {
88
+ shouldIssueIdToken,
89
+ pickScopedClaims,
90
+ deriveAmr,
91
+ computeAtHash,
92
+ buildIdTokenClaims,
93
+ DEFAULT_ID_TOKEN_TTL_MS
94
+ };
@@ -32,6 +32,7 @@ async function introspect(req, deps) {
32
32
  if (claims.aud !== req.clientId) {
33
33
  return ok({ active: false });
34
34
  }
35
+ const subjectType = claims.claim?.type;
35
36
  return ok({
36
37
  active: true,
37
38
  sub: claims.sub,
@@ -41,9 +42,12 @@ async function introspect(req, deps) {
41
42
  iat: claims.iat,
42
43
  scope: claims.scope,
43
44
  client_id: claims.aud,
45
+ token_type: claims.cnf?.jkt !== undefined ? "DPoP" : "Bearer",
44
46
  tid: claims.tid,
45
47
  mid: claims.mid,
46
- mkind: claims.mkind
48
+ mkind: claims.mkind,
49
+ ...subjectType !== undefined ? { subject_type: subjectType } : {},
50
+ ...claims.cnf !== undefined ? { cnf: claims.cnf } : {}
47
51
  });
48
52
  }
49
53
  export {
@@ -3,6 +3,32 @@ import { importJWK, jwtVerify, SignJWT } from "jose";
3
3
  async function signAccessToken(claims, privateKey, alg, kid) {
4
4
  return new SignJWT(claims).setProtectedHeader({ alg, typ: "JWT", kid }).sign(privateKey);
5
5
  }
6
+ async function signIdToken(claims, privateKey, alg, kid) {
7
+ return new SignJWT(claims).setProtectedHeader({ alg, typ: "JWT", kid }).sign(privateKey);
8
+ }
9
+ async function verifyIdToken(token, keys, options = {}) {
10
+ const algorithms = Array.from(new Set(keys.map((k) => k.alg).filter((a) => ASYMMETRIC_ALGS.has(a))));
11
+ const { payload } = await jwtVerify(token, async (header) => {
12
+ if (!header.alg || header.alg === "none") {
13
+ throw new Error(`verifyIdToken: refusing alg "${header.alg ?? ""}"`);
14
+ }
15
+ const match = keys.find((k) => k.kid === header.kid);
16
+ if (!match) {
17
+ throw new Error(`verifyIdToken: unknown kid "${header.kid}"`);
18
+ }
19
+ if (match.alg !== header.alg) {
20
+ throw new Error(`verifyIdToken: header.alg "${header.alg}" does not match key alg "${match.alg}"`);
21
+ }
22
+ const imported = await importJWK(match.publicJwk, match.alg);
23
+ return imported;
24
+ }, {
25
+ algorithms,
26
+ ...options.issuer ? { issuer: options.issuer } : {},
27
+ ...options.audience ? { audience: options.audience } : {},
28
+ ...options.acceptExpired ? { clockTolerance: Number.MAX_SAFE_INTEGER } : {}
29
+ });
30
+ return payload;
31
+ }
6
32
  var ASYMMETRIC_ALGS = new Set(["ES256", "EdDSA"]);
7
33
  async function verifyAccessToken(token, keys, options = {}) {
8
34
  const algorithms = Array.from(new Set(keys.map((k) => k.alg).filter((a) => ASYMMETRIC_ALGS.has(a))));
@@ -37,7 +63,9 @@ function buildJwksDocument(keys) {
37
63
  };
38
64
  }
39
65
  export {
66
+ verifyIdToken,
40
67
  verifyAccessToken,
68
+ signIdToken,
41
69
  signAccessToken,
42
70
  buildJwksDocument
43
71
  };
@@ -0,0 +1,75 @@
1
+ // src/domain/logout.ts
2
+ import { authError } from "../types/error";
3
+ import { err, isErr, ok } from "../types/result";
4
+ import { safeAudit } from "./audit";
5
+ import { verifyIdToken } from "./jwt";
6
+ import { revokeAllForSubject } from "./revoke";
7
+ async function endSession(req, tenant, deps) {
8
+ let hintSubject;
9
+ let hintAud;
10
+ if (req.idTokenHint) {
11
+ const keysRes = await deps.keyStore.signingKeys();
12
+ if (isErr(keysRes))
13
+ return err(keysRes.error);
14
+ try {
15
+ const claims = await verifyIdToken(req.idTokenHint, keysRes.value, {
16
+ issuer: deps.issuerUrl,
17
+ acceptExpired: true
18
+ });
19
+ hintSubject = claims.sub;
20
+ hintAud = claims.aud;
21
+ } catch (e) {
22
+ const reason = e instanceof Error ? e.message : String(e);
23
+ return err(authError.invalidRequest(`id_token_hint failed signature/issuer verification: ${reason}`, "id_token_hint"));
24
+ }
25
+ }
26
+ if (req.clientId && hintAud && req.clientId !== hintAud) {
27
+ return err(authError.invalidRequest("client_id does not match id_token_hint.aud", "client_id"));
28
+ }
29
+ const resolvedClientId = req.clientId ?? hintAud;
30
+ let client;
31
+ if (resolvedClientId) {
32
+ client = tenant.config.clients.find((c) => c.id === resolvedClientId);
33
+ if (!client) {
34
+ return err(authError.invalidRequest(`unknown client "${resolvedClientId}"`, "client_id"));
35
+ }
36
+ }
37
+ if (req.postLogoutRedirectUri) {
38
+ if (!client) {
39
+ return err(authError.invalidRequest("post_logout_redirect_uri requires identifying the client via client_id or id_token_hint", "post_logout_redirect_uri"));
40
+ }
41
+ const registered = client.postLogoutRedirectUris ?? [];
42
+ if (!registered.includes(req.postLogoutRedirectUri)) {
43
+ return err(authError.invalidRequest("post_logout_redirect_uri is not registered for this client", "post_logout_redirect_uri"));
44
+ }
45
+ }
46
+ if (hintSubject) {
47
+ const revoked = await revokeAllForSubject(tenant.id, hintSubject, {
48
+ tokenStore: deps.tokenStore,
49
+ ...deps.auditLog ? { auditLog: deps.auditLog } : {},
50
+ clock: deps.clock
51
+ });
52
+ if (isErr(revoked))
53
+ return err(revoked.error);
54
+ }
55
+ await safeAudit(deps, {
56
+ kind: "session_logout",
57
+ tenantId: tenant.id,
58
+ ...resolvedClientId ? { clientId: resolvedClientId } : {},
59
+ ...hintSubject ? { subjectId: hintSubject } : {},
60
+ timestamp: deps.clock()
61
+ });
62
+ if (req.postLogoutRedirectUri) {
63
+ const url = new URL(req.postLogoutRedirectUri);
64
+ if (req.state !== undefined)
65
+ url.searchParams.set("state", req.state);
66
+ return ok({ kind: "redirect", url: url.toString() });
67
+ }
68
+ return ok({
69
+ kind: "ok",
70
+ ...hintSubject ? { subjectId: hintSubject } : {}
71
+ });
72
+ }
73
+ export {
74
+ endSession
75
+ };
@@ -65,6 +65,9 @@ async function translate(result, flow, deps) {
65
65
  context: final.context ?? null,
66
66
  providerSubject: result.providerSubject,
67
67
  properties: result.properties,
68
+ ...final.appNonce !== undefined ? { appNonce: final.appNonce } : {},
69
+ ...final.claimsRequest !== undefined ? { claimsRequest: final.claimsRequest } : {},
70
+ authTime: Math.floor(now / 1000),
68
71
  expiresAt: now + AUTH_CODE_TTL_MS
69
72
  }, AUTH_CODE_TTL_MS, { keyStore: deps.keyStore, tokenStore: deps.tokenStore });
70
73
  if (isErr(saved))
@@ -0,0 +1,51 @@
1
+ // src/domain/par.ts
2
+ import { authError } from "../types/error";
3
+ import { err, isErr, ok } from "../types/result";
4
+ import { verifyClientCredentials } from "./client-auth";
5
+ import { randomToken } from "./crypto";
6
+ var PAR_URI_PREFIX = "urn:ietf:params:oauth:request_uri:";
7
+ var DEFAULT_PAR_TTL_MS = 60 * 1000;
8
+ async function pushAuthorizationRequest(req, tenant, deps) {
9
+ if (!deps.sessionStore.savePar || !deps.sessionStore.consumePar) {
10
+ return err(authError.invalidRequest("session adapter does not implement PAR storage"));
11
+ }
12
+ const client = tenant.config.clients.find((c) => c.id === req.clientId);
13
+ if (!client) {
14
+ return err(authError.invalidClient(`unknown client "${req.clientId}"`));
15
+ }
16
+ if (client.type === "confidential") {
17
+ const authErr = await verifyClientCredentials(client, req.clientSecret);
18
+ if (authErr)
19
+ return err(authErr);
20
+ } else if (req.clientSecret !== undefined) {
21
+ return err(authError.invalidClient("public client must not present client_secret"));
22
+ }
23
+ if (req.params.client_id && req.params.client_id !== req.clientId) {
24
+ return err(authError.invalidRequest("client_id in body conflicts with authenticated client", "client_id"));
25
+ }
26
+ if ("request_uri" in req.params) {
27
+ return err(authError.invalidRequest("request_uri MUST NOT be present in a PAR request", "request_uri"));
28
+ }
29
+ const suffix = (deps.newRequestUriSuffix ?? randomToken)();
30
+ const requestUri = PAR_URI_PREFIX + suffix;
31
+ const ttl = deps.ttlMs ?? DEFAULT_PAR_TTL_MS;
32
+ const now = deps.clock();
33
+ const saved = await deps.sessionStore.savePar(requestUri, {
34
+ requestUri,
35
+ params: { ...req.params, client_id: req.clientId },
36
+ clientId: req.clientId,
37
+ issuedAt: now,
38
+ expiresAt: now + ttl
39
+ }, ttl);
40
+ if (isErr(saved))
41
+ return err(saved.error);
42
+ return ok({
43
+ request_uri: requestUri,
44
+ expires_in: Math.floor(ttl / 1000)
45
+ });
46
+ }
47
+ export {
48
+ pushAuthorizationRequest,
49
+ PAR_URI_PREFIX,
50
+ DEFAULT_PAR_TTL_MS
51
+ };
@@ -31,6 +31,14 @@ async function refreshTokens(req, deps) {
31
31
  } else if (client.type === "confidential") {
32
32
  return err(authError.invalidGrant(INVALID_REFRESH_DESC));
33
33
  }
34
+ if (peekedPayload.dpopJkt !== undefined) {
35
+ if (req.dpopJkt === undefined) {
36
+ return err(authError.invalidDpopProof("refresh token is DPoP-bound; request is missing a DPoP proof"));
37
+ }
38
+ if (req.dpopJkt !== peekedPayload.dpopJkt) {
39
+ return err(authError.invalidDpopProof("refresh token DPoP jkt does not match the original binding"));
40
+ }
41
+ }
34
42
  const consumed = await deps.tokenStore.consumeRefresh(req.refreshToken, {
35
43
  reuseWindowMs: deps.reuseWindowMs
36
44
  });
@@ -68,7 +76,10 @@ async function refreshTokens(req, deps) {
68
76
  methodId: payload.methodId,
69
77
  methodKind: payload.methodKind,
70
78
  scopes: requestedScopes,
71
- audience: payload.audience
79
+ audience: payload.audience,
80
+ authTime: payload.authTime,
81
+ ...payload.dpopJkt !== undefined ? { dpopJkt: payload.dpopJkt } : {},
82
+ ...payload.claimsRequest !== undefined ? { claimsRequest: payload.claimsRequest } : {}
72
83
  },
73
84
  family: payload.family,
74
85
  deps