@strivacity/sdk-core 3.0.3 → 4.0.0-beta.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 (327) hide show
  1. package/README.md +1873 -340
  2. package/dist/assets/oidc.cjs +2 -0
  3. package/dist/assets/oidc.cjs.map +1 -0
  4. package/dist/assets/oidc.mjs +2 -0
  5. package/dist/assets/oidc.mjs.map +1 -0
  6. package/dist/assets/server.cjs +2 -0
  7. package/dist/assets/server.cjs.map +1 -0
  8. package/dist/assets/server.mjs +2 -0
  9. package/dist/assets/server.mjs.map +1 -0
  10. package/dist/flows/base.cjs +2 -0
  11. package/dist/flows/base.cjs.map +1 -0
  12. package/dist/flows/base.d.ts +26 -0
  13. package/dist/flows/base.mjs +2 -0
  14. package/dist/flows/base.mjs.map +1 -0
  15. package/dist/flows/embedded.cjs +2 -0
  16. package/dist/flows/embedded.cjs.map +1 -0
  17. package/dist/flows/embedded.d.ts +9 -0
  18. package/dist/flows/embedded.mjs +2 -0
  19. package/dist/flows/embedded.mjs.map +1 -0
  20. package/dist/flows/index.cjs +1 -0
  21. package/dist/flows/index.d.ts +5 -0
  22. package/dist/flows/index.mjs +1 -0
  23. package/dist/flows/native.cjs +2 -0
  24. package/dist/flows/native.cjs.map +1 -0
  25. package/dist/flows/native.d.ts +9 -0
  26. package/dist/flows/native.mjs +2 -0
  27. package/dist/flows/native.mjs.map +1 -0
  28. package/dist/flows/popup.cjs +2 -0
  29. package/dist/flows/popup.cjs.map +1 -0
  30. package/dist/flows/popup.d.ts +9 -0
  31. package/dist/flows/popup.mjs +2 -0
  32. package/dist/flows/popup.mjs.map +1 -0
  33. package/dist/flows/redirect.cjs +2 -0
  34. package/dist/flows/redirect.cjs.map +1 -0
  35. package/dist/flows/redirect.d.ts +9 -0
  36. package/dist/flows/redirect.mjs +2 -0
  37. package/dist/flows/redirect.mjs.map +1 -0
  38. package/dist/handlers/embedded.cjs +2 -0
  39. package/dist/handlers/embedded.cjs.map +1 -0
  40. package/dist/handlers/embedded.d.ts +37 -0
  41. package/dist/handlers/embedded.mjs +2 -0
  42. package/dist/handlers/embedded.mjs.map +1 -0
  43. package/dist/handlers/index.cjs +1 -0
  44. package/dist/handlers/index.d.ts +2 -0
  45. package/dist/handlers/index.mjs +1 -0
  46. package/dist/handlers/native.cjs +2 -0
  47. package/dist/handlers/native.cjs.map +1 -0
  48. package/dist/handlers/native.d.ts +56 -0
  49. package/dist/handlers/native.mjs +2 -0
  50. package/dist/handlers/native.mjs.map +1 -0
  51. package/dist/handlers/popup.cjs +2 -0
  52. package/dist/handlers/popup.cjs.map +1 -0
  53. package/dist/handlers/popup.d.ts +21 -0
  54. package/dist/handlers/popup.mjs +2 -0
  55. package/dist/handlers/popup.mjs.map +1 -0
  56. package/dist/handlers/redirect.cjs +2 -0
  57. package/dist/handlers/redirect.cjs.map +1 -0
  58. package/dist/handlers/redirect.d.ts +18 -0
  59. package/dist/handlers/redirect.mjs +2 -0
  60. package/dist/handlers/redirect.mjs.map +1 -0
  61. package/dist/index.cjs +2 -2
  62. package/dist/index.cjs.map +1 -1
  63. package/dist/index.d.ts +16 -21
  64. package/dist/index.mjs +2 -2
  65. package/dist/index.mjs.map +1 -1
  66. package/dist/server/base.cjs +2 -0
  67. package/dist/server/base.cjs.map +1 -0
  68. package/dist/server/base.d.ts +15 -0
  69. package/dist/server/base.mjs +2 -0
  70. package/dist/server/base.mjs.map +1 -0
  71. package/dist/server/index.cjs +1 -0
  72. package/dist/server/index.d.ts +1 -0
  73. package/dist/server/index.mjs +1 -0
  74. package/dist/storages/cache.cjs +2 -0
  75. package/dist/storages/cache.cjs.map +1 -0
  76. package/dist/storages/cache.d.ts +11 -0
  77. package/dist/storages/cache.mjs +2 -0
  78. package/dist/storages/cache.mjs.map +1 -0
  79. package/dist/storages/index.cjs +1 -0
  80. package/dist/storages/index.d.ts +7 -0
  81. package/dist/storages/index.mjs +1 -0
  82. package/dist/storages/indexeddb.cjs +2 -0
  83. package/dist/storages/indexeddb.cjs.map +1 -0
  84. package/dist/storages/indexeddb.d.ts +10 -0
  85. package/dist/storages/indexeddb.mjs +2 -0
  86. package/dist/storages/indexeddb.mjs.map +1 -0
  87. package/dist/storages/local.cjs +2 -0
  88. package/dist/storages/local.cjs.map +1 -0
  89. package/dist/storages/local.d.ts +7 -0
  90. package/dist/storages/local.mjs +2 -0
  91. package/dist/storages/local.mjs.map +1 -0
  92. package/dist/storages/memory.cjs +2 -0
  93. package/dist/storages/memory.cjs.map +1 -0
  94. package/dist/storages/memory.d.ts +8 -0
  95. package/dist/storages/memory.mjs +2 -0
  96. package/dist/storages/memory.mjs.map +1 -0
  97. package/dist/storages/server.cjs +1 -0
  98. package/dist/storages/server.d.ts +39 -0
  99. package/dist/storages/server.mjs +1 -0
  100. package/dist/storages/session.cjs +2 -0
  101. package/dist/storages/session.cjs.map +1 -0
  102. package/dist/storages/session.d.ts +7 -0
  103. package/dist/storages/session.mjs +2 -0
  104. package/dist/storages/session.mjs.map +1 -0
  105. package/dist/storages/worker.cjs +2 -0
  106. package/dist/storages/worker.cjs.map +1 -0
  107. package/dist/storages/worker.d.ts +46 -0
  108. package/dist/storages/worker.mjs +2 -0
  109. package/dist/storages/worker.mjs.map +1 -0
  110. package/dist/types/common.cjs +0 -0
  111. package/dist/types/common.d.ts +145 -0
  112. package/dist/types/common.mjs +0 -0
  113. package/dist/types/embedded.cjs +0 -0
  114. package/dist/types/embedded.d.ts +103 -0
  115. package/dist/types/embedded.mjs +0 -0
  116. package/dist/types/index.cjs +1 -0
  117. package/dist/types/index.d.ts +7 -0
  118. package/dist/types/index.mjs +1 -0
  119. package/dist/types/native.cjs +0 -0
  120. package/dist/types/native.d.ts +385 -0
  121. package/dist/types/native.mjs +0 -0
  122. package/dist/types/oidc.cjs +2 -0
  123. package/dist/types/oidc.cjs.map +1 -0
  124. package/dist/types/oidc.d.ts +768 -0
  125. package/dist/types/oidc.mjs +2 -0
  126. package/dist/types/oidc.mjs.map +1 -0
  127. package/dist/types/popup.cjs +0 -0
  128. package/dist/types/popup.d.ts +69 -0
  129. package/dist/types/popup.mjs +0 -0
  130. package/dist/types/redirect.cjs +0 -0
  131. package/dist/types/redirect.d.ts +21 -0
  132. package/dist/types/redirect.mjs +0 -0
  133. package/dist/types/server.cjs +0 -0
  134. package/dist/types/server.d.ts +200 -0
  135. package/dist/types/server.mjs +0 -0
  136. package/dist/utils/base64url.cjs +2 -0
  137. package/dist/utils/base64url.cjs.map +1 -0
  138. package/dist/utils/base64url.d.ts +14 -0
  139. package/dist/utils/base64url.mjs +2 -0
  140. package/dist/utils/base64url.mjs.map +1 -0
  141. package/dist/utils/common.cjs +2 -0
  142. package/dist/utils/common.cjs.map +1 -0
  143. package/dist/utils/common.d.ts +23 -0
  144. package/dist/utils/common.mjs +2 -0
  145. package/dist/utils/common.mjs.map +1 -0
  146. package/dist/utils/credentials.cjs +2 -2
  147. package/dist/utils/credentials.cjs.map +1 -1
  148. package/dist/utils/credentials.d.ts +19 -3
  149. package/dist/utils/credentials.mjs +2 -2
  150. package/dist/utils/credentials.mjs.map +1 -1
  151. package/dist/utils/crypto.cjs +2 -2
  152. package/dist/utils/crypto.cjs.map +1 -1
  153. package/dist/utils/crypto.d.ts +16 -26
  154. package/dist/utils/crypto.mjs +2 -2
  155. package/dist/utils/crypto.mjs.map +1 -1
  156. package/dist/utils/dom.cjs +2 -0
  157. package/dist/utils/dom.cjs.map +1 -0
  158. package/dist/utils/dom.d.ts +7 -0
  159. package/dist/utils/dom.mjs +2 -0
  160. package/dist/utils/dom.mjs.map +1 -0
  161. package/dist/utils/errors.cjs +2 -2
  162. package/dist/utils/errors.cjs.map +1 -1
  163. package/dist/utils/errors.d.ts +62 -1
  164. package/dist/utils/errors.mjs +2 -2
  165. package/dist/utils/errors.mjs.map +1 -1
  166. package/dist/utils/httpClient.cjs +2 -0
  167. package/dist/utils/httpClient.cjs.map +1 -0
  168. package/dist/utils/httpClient.d.ts +2 -0
  169. package/dist/utils/httpClient.mjs +2 -0
  170. package/dist/utils/httpClient.mjs.map +1 -0
  171. package/dist/utils/index.cjs +1 -0
  172. package/dist/utils/index.d.ts +12 -0
  173. package/dist/utils/index.mjs +1 -0
  174. package/dist/utils/logging.cjs +2 -0
  175. package/dist/utils/logging.cjs.map +1 -0
  176. package/dist/utils/logging.d.ts +2 -0
  177. package/dist/utils/logging.mjs +2 -0
  178. package/dist/utils/logging.mjs.map +1 -0
  179. package/dist/utils/oidc.cjs +1 -0
  180. package/dist/utils/oidc.d.ts +264 -0
  181. package/dist/utils/oidc.mjs +1 -0
  182. package/dist/utils/server.cjs +1 -0
  183. package/dist/utils/server.d.ts +72 -0
  184. package/dist/utils/server.mjs +1 -0
  185. package/dist/utils/session.cjs +2 -0
  186. package/dist/utils/session.cjs.map +1 -0
  187. package/dist/utils/session.d.ts +29 -0
  188. package/dist/utils/session.mjs +2 -0
  189. package/dist/utils/session.mjs.map +1 -0
  190. package/dist/utils/state.cjs +1 -0
  191. package/dist/utils/state.d.ts +23 -0
  192. package/dist/utils/state.mjs +1 -0
  193. package/package.json +53 -1
  194. package/testing/tests/flows/base.spec.ts +1030 -0
  195. package/testing/tests/flows/embedded.spec.ts +443 -0
  196. package/testing/tests/flows/index.spec.ts +8 -0
  197. package/testing/tests/flows/native.spec.ts +487 -0
  198. package/testing/tests/flows/popup.spec.ts +400 -0
  199. package/testing/tests/flows/redirect.spec.ts +369 -0
  200. package/testing/tests/handlers/embedded.spec.ts +199 -0
  201. package/testing/tests/handlers/index.spec.ts +8 -0
  202. package/testing/tests/handlers/native.spec.ts +283 -0
  203. package/testing/tests/handlers/popup.spec.ts +206 -0
  204. package/testing/tests/handlers/redirect.spec.ts +70 -0
  205. package/testing/tests/index.spec.ts +78 -0
  206. package/testing/tests/server/base.spec.ts +846 -0
  207. package/testing/tests/server/index.spec.ts +15 -0
  208. package/testing/tests/storages/cache.spec.ts +107 -0
  209. package/testing/tests/storages/index.spec.ts +22 -0
  210. package/testing/tests/storages/indexeddb.spec.ts +113 -0
  211. package/testing/tests/storages/local.spec.ts +46 -0
  212. package/testing/tests/storages/memory.spec.ts +51 -0
  213. package/testing/tests/storages/server.spec.ts +435 -0
  214. package/testing/tests/storages/session.spec.ts +46 -0
  215. package/testing/tests/storages/worker.spec.ts +106 -0
  216. package/testing/tests/utils/common.spec.ts +77 -0
  217. package/testing/tests/utils/credentials.spec.ts +195 -0
  218. package/testing/tests/utils/dom.spec.ts +37 -0
  219. package/testing/tests/utils/index.spec.ts +71 -0
  220. package/testing/tests/utils/logging.spec.ts +82 -0
  221. package/testing/tests/utils/oidc.spec.ts +744 -0
  222. package/testing/tests/utils/server.spec.ts +235 -0
  223. package/dist/flows/BaseFlow.cjs +0 -2
  224. package/dist/flows/BaseFlow.cjs.map +0 -1
  225. package/dist/flows/BaseFlow.d.ts +0 -191
  226. package/dist/flows/BaseFlow.mjs +0 -2
  227. package/dist/flows/BaseFlow.mjs.map +0 -1
  228. package/dist/flows/EmbeddedFlow.cjs +0 -2
  229. package/dist/flows/EmbeddedFlow.cjs.map +0 -1
  230. package/dist/flows/EmbeddedFlow.d.ts +0 -26
  231. package/dist/flows/EmbeddedFlow.mjs +0 -2
  232. package/dist/flows/EmbeddedFlow.mjs.map +0 -1
  233. package/dist/flows/NativeFlow.cjs +0 -2
  234. package/dist/flows/NativeFlow.cjs.map +0 -1
  235. package/dist/flows/NativeFlow.d.ts +0 -34
  236. package/dist/flows/NativeFlow.mjs +0 -2
  237. package/dist/flows/NativeFlow.mjs.map +0 -1
  238. package/dist/flows/PopupFlow.cjs +0 -2
  239. package/dist/flows/PopupFlow.cjs.map +0 -1
  240. package/dist/flows/PopupFlow.d.ts +0 -36
  241. package/dist/flows/PopupFlow.mjs +0 -2
  242. package/dist/flows/PopupFlow.mjs.map +0 -1
  243. package/dist/flows/RedirectFlow.cjs +0 -2
  244. package/dist/flows/RedirectFlow.cjs.map +0 -1
  245. package/dist/flows/RedirectFlow.d.ts +0 -38
  246. package/dist/flows/RedirectFlow.mjs +0 -2
  247. package/dist/flows/RedirectFlow.mjs.map +0 -1
  248. package/dist/handlers/BaseFlowHandler.cjs +0 -2
  249. package/dist/handlers/BaseFlowHandler.cjs.map +0 -1
  250. package/dist/handlers/BaseFlowHandler.d.ts +0 -61
  251. package/dist/handlers/BaseFlowHandler.mjs +0 -2
  252. package/dist/handlers/BaseFlowHandler.mjs.map +0 -1
  253. package/dist/handlers/EmbeddedFlowHandler.cjs +0 -2
  254. package/dist/handlers/EmbeddedFlowHandler.cjs.map +0 -1
  255. package/dist/handlers/EmbeddedFlowHandler.d.ts +0 -28
  256. package/dist/handlers/EmbeddedFlowHandler.mjs +0 -2
  257. package/dist/handlers/EmbeddedFlowHandler.mjs.map +0 -1
  258. package/dist/handlers/NativeFlowHandler.cjs +0 -2
  259. package/dist/handlers/NativeFlowHandler.cjs.map +0 -1
  260. package/dist/handlers/NativeFlowHandler.d.ts +0 -31
  261. package/dist/handlers/NativeFlowHandler.mjs +0 -2
  262. package/dist/handlers/NativeFlowHandler.mjs.map +0 -1
  263. package/dist/storages/LocalStorage.cjs +0 -2
  264. package/dist/storages/LocalStorage.cjs.map +0 -1
  265. package/dist/storages/LocalStorage.d.ts +0 -23
  266. package/dist/storages/LocalStorage.mjs +0 -2
  267. package/dist/storages/LocalStorage.mjs.map +0 -1
  268. package/dist/storages/SessionStorage.cjs +0 -2
  269. package/dist/storages/SessionStorage.cjs.map +0 -1
  270. package/dist/storages/SessionStorage.d.ts +0 -23
  271. package/dist/storages/SessionStorage.mjs +0 -2
  272. package/dist/storages/SessionStorage.mjs.map +0 -1
  273. package/dist/types.cjs +0 -2
  274. package/dist/types.cjs.map +0 -1
  275. package/dist/types.d.ts +0 -1263
  276. package/dist/types.mjs +0 -2
  277. package/dist/types.mjs.map +0 -1
  278. package/dist/utils/HttpClient.cjs +0 -2
  279. package/dist/utils/HttpClient.cjs.map +0 -1
  280. package/dist/utils/HttpClient.d.ts +0 -4
  281. package/dist/utils/HttpClient.mjs +0 -2
  282. package/dist/utils/HttpClient.mjs.map +0 -1
  283. package/dist/utils/Logging.cjs +0 -2
  284. package/dist/utils/Logging.cjs.map +0 -1
  285. package/dist/utils/Logging.d.ts +0 -8
  286. package/dist/utils/Logging.mjs +0 -2
  287. package/dist/utils/Logging.mjs.map +0 -1
  288. package/dist/utils/Metadata.cjs +0 -2
  289. package/dist/utils/Metadata.cjs.map +0 -1
  290. package/dist/utils/Metadata.d.ts +0 -98
  291. package/dist/utils/Metadata.mjs +0 -2
  292. package/dist/utils/Metadata.mjs.map +0 -1
  293. package/dist/utils/Session.cjs +0 -2
  294. package/dist/utils/Session.cjs.map +0 -1
  295. package/dist/utils/Session.d.ts +0 -82
  296. package/dist/utils/Session.mjs +0 -2
  297. package/dist/utils/Session.mjs.map +0 -1
  298. package/dist/utils/State.cjs +0 -2
  299. package/dist/utils/State.cjs.map +0 -1
  300. package/dist/utils/State.d.ts +0 -43
  301. package/dist/utils/State.mjs +0 -2
  302. package/dist/utils/State.mjs.map +0 -1
  303. package/dist/utils/base64Url.cjs +0 -2
  304. package/dist/utils/base64Url.cjs.map +0 -1
  305. package/dist/utils/base64Url.d.ts +0 -27
  306. package/dist/utils/base64Url.mjs +0 -2
  307. package/dist/utils/base64Url.mjs.map +0 -1
  308. package/dist/utils/date.cjs +0 -2
  309. package/dist/utils/date.cjs.map +0 -1
  310. package/dist/utils/date.d.ts +0 -6
  311. package/dist/utils/date.mjs +0 -2
  312. package/dist/utils/date.mjs.map +0 -1
  313. package/dist/utils/handlers.cjs +0 -2
  314. package/dist/utils/handlers.cjs.map +0 -1
  315. package/dist/utils/handlers.d.ts +0 -35
  316. package/dist/utils/handlers.mjs +0 -2
  317. package/dist/utils/handlers.mjs.map +0 -1
  318. package/dist/utils/jwt.cjs +0 -2
  319. package/dist/utils/jwt.cjs.map +0 -1
  320. package/dist/utils/jwt.d.ts +0 -34
  321. package/dist/utils/jwt.mjs +0 -2
  322. package/dist/utils/jwt.mjs.map +0 -1
  323. package/dist/utils/object.cjs +0 -2
  324. package/dist/utils/object.cjs.map +0 -1
  325. package/dist/utils/object.d.ts +0 -1
  326. package/dist/utils/object.mjs +0 -2
  327. package/dist/utils/object.mjs.map +0 -1
@@ -0,0 +1,744 @@
1
+ import { beforeAll, beforeEach, describe, test, expect, vi } from 'vitest';
2
+ import { http, HttpResponse } from 'msw';
3
+ import { createOptions, getMockStorage } from '@strivacity/testing/mocks/sdk';
4
+ import { worker } from '@strivacity/testing/mocks/msw';
5
+ import openidConfiguration from '@strivacity/testing/fixtures/openid-configuration.json';
6
+ import {
7
+ decodeJwt,
8
+ verifyJwt,
9
+ generateJwt,
10
+ verifyIdToken,
11
+ refreshToken,
12
+ generateCodeVerifier,
13
+ generateCodeChallenge,
14
+ generateRandomHex,
15
+ buildAuthorizationUrl,
16
+ exchangeCode,
17
+ getDefaultFlowState,
18
+ defaultOptions,
19
+ } from '../../../src/utils/oidc';
20
+ import { NetworkError, ServerError, ProtocolError } from '../../../src/utils/errors';
21
+ import { encodeBase64URL, decodeBase64URL } from '../../../src/utils/base64url';
22
+ import { buildIdToken, buildIdTokenClaims, exportSigningKey, generateRSAKeyPair, signRS256 } from '@strivacity/testing/mocks/oidc';
23
+ import type { IdTokenClaims } from '../../../src/types/oidc';
24
+
25
+ describe('getDefaultFlowState', () => {
26
+ test('should return the default flow state', () => {
27
+ expect(getDefaultFlowState()).toEqual({
28
+ initialized: false,
29
+ session: null,
30
+ sessionId: null,
31
+ shortAppId: null,
32
+ language: globalThis.navigator.language,
33
+ });
34
+ });
35
+
36
+ test('should fall back to "en-US" when navigator.language is unavailable', () => {
37
+ vi.stubGlobal('navigator', {});
38
+
39
+ expect(getDefaultFlowState().language).toBe('en-US');
40
+
41
+ vi.unstubAllGlobals();
42
+ });
43
+ });
44
+
45
+ describe('defaultOptions', () => {
46
+ test('should provide the default SDK options', () => {
47
+ expect(defaultOptions).toEqual({
48
+ lazyLoad: false,
49
+ autoRefresh: true,
50
+ serverSessionUri: null,
51
+ scopes: ['openid'],
52
+ responseType: 'code',
53
+ responseMode: 'query',
54
+ storageTokenName: 'sty.session',
55
+ discoveryDocumentCacheTTL: 3600,
56
+ jwksCacheTTL: 600,
57
+ });
58
+ });
59
+ });
60
+
61
+ describe('generateCodeVerifier', () => {
62
+ test('generates a verifier between 43 and 128 characters, using only the unreserved charset', () => {
63
+ for (let i = 0; i < 50; i++) {
64
+ const verifier = generateCodeVerifier();
65
+
66
+ expect(verifier.length).toBeGreaterThanOrEqual(43);
67
+ expect(verifier.length).toBeLessThanOrEqual(128);
68
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/);
69
+ }
70
+ });
71
+
72
+ test('generates cryptographically random, non-colliding verifiers', () => {
73
+ const verifiers = new Set(Array.from({ length: 2000 }, () => generateCodeVerifier()));
74
+
75
+ expect(verifiers.size).toBe(2000);
76
+ });
77
+ });
78
+
79
+ describe('generateCodeChallenge', () => {
80
+ test('derives base64url(SHA-256(verifier)) using the RFC 7636 Appendix B fixed vector', async () => {
81
+ await expect(generateCodeChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')).resolves.toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
82
+ });
83
+
84
+ test('produces an unpadded base64url string (no "=", "+", or "/")', async () => {
85
+ const challenge = await generateCodeChallenge(generateCodeVerifier());
86
+
87
+ expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/);
88
+ });
89
+ });
90
+
91
+ describe('generateRandomHex', () => {
92
+ test('generates non-colliding random hex strings', () => {
93
+ const values = new Set(Array.from({ length: 2000 }, () => generateRandomHex(16)));
94
+
95
+ expect(values.size).toBe(2000);
96
+ });
97
+ });
98
+
99
+ describe('buildAuthorizationUrl', () => {
100
+ test('sets response_type=code exactly once', async () => {
101
+ const url = await buildAuthorizationUrl({ params: {}, options: createOptions() });
102
+
103
+ expect(url.searchParams.getAll('response_type')).toEqual(['code']);
104
+ });
105
+
106
+ test('sets client_id verbatim, including hostile/reserved characters', async () => {
107
+ const options = createOptions({ clientId: 'client+id with space&reserved=chars 例え' });
108
+ const url = await buildAuthorizationUrl({ params: {}, options });
109
+
110
+ expect(url.searchParams.get('client_id')).toBe('client+id with space&reserved=chars 例え');
111
+ });
112
+
113
+ test('sets redirect_uri byte-identical to the configured value, without normalization', async () => {
114
+ const options = createOptions({ redirectUri: 'https://Brandtegrity.io:443/Callback' });
115
+ const url = await buildAuthorizationUrl({ params: {}, options });
116
+
117
+ expect(url.searchParams.get('redirect_uri')).toBe('https://Brandtegrity.io:443/Callback');
118
+ });
119
+
120
+ test('never emits any parameter more than once', async () => {
121
+ const url = await buildAuthorizationUrl({
122
+ params: { prompt: 'login', display: 'page', loginHint: 'user@example.com', acrValues: ['urn:a'], uiLocales: ['en-US'], audiences: ['aud-1'] },
123
+ options: createOptions(),
124
+ });
125
+
126
+ for (const key of url.searchParams.keys()) {
127
+ expect(url.searchParams.getAll(key)).toHaveLength(1);
128
+ }
129
+ });
130
+
131
+ test('preserves an existing query string on a custom authorization endpoint', async () => {
132
+ const url = await buildAuthorizationUrl({ url: 'https://brandtegrity.io/authorize?tenant=acme', params: {}, options: createOptions() });
133
+
134
+ expect(url.searchParams.get('tenant')).toBe('acme');
135
+ expect(url.searchParams.get('response_type')).toBe('code');
136
+ });
137
+
138
+ test('rejects an authorization endpoint scheme other than https', async () => {
139
+ vi.resetModules();
140
+
141
+ const { getDiscoveryDocument } = await import('../../../src/utils/oidc');
142
+
143
+ worker.use(
144
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
145
+ HttpResponse.json({ ...openidConfiguration, authorization_endpoint: 'http://brandtegrity.io/oauth2/authorize' }),
146
+ ),
147
+ );
148
+
149
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('must be an https:// URL');
150
+ });
151
+
152
+ test('space-delimits and deduplicates scope entries', async () => {
153
+ const options = createOptions({ scopes: ['openid', 'profile', 'openid', 'email'] });
154
+ const url = await buildAuthorizationUrl({ params: {}, options });
155
+
156
+ expect(url.searchParams.get('scope')).toBe('openid profile email');
157
+ });
158
+
159
+ test('sets state unconditionally', async () => {
160
+ const url = await buildAuthorizationUrl({ params: {}, options: createOptions() });
161
+
162
+ expect(url.searchParams.get('state')).toBeTruthy();
163
+ });
164
+
165
+ test('omits optional parameters entirely when unset, rather than emitting them empty', async () => {
166
+ const url = await buildAuthorizationUrl({ params: {}, options: createOptions() });
167
+
168
+ for (const key of ['prompt', 'display', 'login_hint', 'acr_values', 'ui_locales', 'audience', 'max_age']) {
169
+ expect(url.searchParams.has(key)).toBe(false);
170
+ }
171
+ });
172
+
173
+ test('percent-encodes pass-through parameters exactly once', async () => {
174
+ const url = await buildAuthorizationUrl({ params: { loginHint: 'a b+c%20d@例え.jp' }, options: createOptions() });
175
+
176
+ expect(url.searchParams.get('login_hint')).toBe('a b+c%20d@例え.jp');
177
+ expect(url.toString()).toContain('login_hint=a+b%2Bc%2520d%40%E4%BE%8B%E3%81%88.jp');
178
+ });
179
+
180
+ test('always uses S256 for code_challenge_method, never "plain"', async () => {
181
+ const url = await buildAuthorizationUrl({ params: {}, options: createOptions() });
182
+
183
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256');
184
+ });
185
+
186
+ test('never places the code_verifier in the authorization URL', async () => {
187
+ const options = createOptions();
188
+ const url = await buildAuthorizationUrl({ params: {}, options });
189
+ const codeVerifier = getMockStorage(options.stateStorage).getLastState()?.codeVerifier;
190
+
191
+ expect(codeVerifier).toBeTruthy();
192
+ expect(url.searchParams.has('code_verifier')).toBe(false);
193
+ expect(url.toString()).not.toContain(codeVerifier);
194
+ });
195
+
196
+ test('never emits a client secret', () => {
197
+ expect(JSON.stringify(createOptions())).not.toMatch(/client_secret|clientSecret/i);
198
+ });
199
+
200
+ test('persists a unique PKCE verifier per call, even for concurrent journeys', async () => {
201
+ const options = createOptions();
202
+
203
+ await Promise.all(Array.from({ length: 20 }, () => buildAuthorizationUrl({ params: {}, options })));
204
+
205
+ const verifiers = new Set(
206
+ getMockStorage(options.stateStorage)
207
+ .values()
208
+ .map((value) => (JSON.parse(value) as { codeVerifier: string }).codeVerifier),
209
+ );
210
+
211
+ expect(verifiers.size).toBe(20);
212
+ });
213
+
214
+ test('accepts a valid prompt value and stores it on the state', async () => {
215
+ const url = await buildAuthorizationUrl({ params: { prompt: 'login' }, options: createOptions() });
216
+
217
+ expect(url.searchParams.get('prompt')).toBe('login');
218
+ });
219
+ });
220
+
221
+ describe('getDiscoveryDocument', () => {
222
+ let getDiscoveryDocument: (typeof import('../../../src/utils/oidc'))['getDiscoveryDocument'];
223
+
224
+ beforeEach(async () => {
225
+ vi.resetModules();
226
+
227
+ // NOTE: Disable caching by resetting the module before each test
228
+ ({ getDiscoveryDocument } = await import('../../../src/utils/oidc'));
229
+ });
230
+
231
+ test('fetches and returns the discovery document from the issuer', async () => {
232
+ await expect(getDiscoveryDocument(createOptions())).resolves.toMatchObject({
233
+ issuer: 'https://brandtegrity.io/',
234
+ token_endpoint: 'https://brandtegrity.io/oauth2/token',
235
+ });
236
+ });
237
+
238
+ test('caches the document and does not refetch while the TTL is valid', async () => {
239
+ const options = createOptions();
240
+ const requestSpy = vi.spyOn(options.httpClient, 'request');
241
+
242
+ await getDiscoveryDocument(options);
243
+ await getDiscoveryDocument(options);
244
+
245
+ expect(requestSpy).toHaveBeenCalledTimes(1);
246
+ });
247
+
248
+ test('dedupes concurrent fetches into a single request', async () => {
249
+ const options = createOptions();
250
+ const requestSpy = vi.spyOn(options.httpClient, 'request');
251
+
252
+ await Promise.all([getDiscoveryDocument(options), getDiscoveryDocument(options)]);
253
+
254
+ expect(requestSpy).toHaveBeenCalledTimes(1);
255
+ });
256
+
257
+ describe('error handling', () => {
258
+ test('throws a ServerError when the request fails', async () => {
259
+ worker.use(http.get('https://brandtegrity.io/.well-known/openid-configuration', () => new HttpResponse(null, { status: 500 })));
260
+
261
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Failed to fetch OIDC metadata');
262
+ });
263
+
264
+ test('throws a ProtocolError when the issuer does not match', async () => {
265
+ worker.use(
266
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
267
+ HttpResponse.json({ ...openidConfiguration, issuer: 'https://other.example.com' }),
268
+ ),
269
+ );
270
+
271
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Discovery document issuer mismatch');
272
+ });
273
+
274
+ test.each(['authorization_endpoint', 'token_endpoint', 'jwks_uri'])('throws a ProtocolError when %s is missing', async (field) => {
275
+ worker.use(http.get('https://brandtegrity.io/.well-known/openid-configuration', () => HttpResponse.json({ ...openidConfiguration, [field]: undefined })));
276
+
277
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow(`Discovery document is missing required field: ${field}`);
278
+ });
279
+
280
+ test('throws a ProtocolError when issuer is missing entirely', async () => {
281
+ worker.use(http.get('https://brandtegrity.io/.well-known/openid-configuration', () => HttpResponse.json({ ...openidConfiguration, issuer: undefined })));
282
+
283
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Discovery document issuer mismatch');
284
+ });
285
+
286
+ test('does not cache a failed fetch, and a subsequent call can still succeed', async () => {
287
+ vi.resetModules();
288
+
289
+ const { getDiscoveryDocument: freshGetDiscoveryDocument } = await import('../../../src/utils/oidc');
290
+
291
+ worker.use(http.get('https://brandtegrity.io/.well-known/openid-configuration', () => new HttpResponse(null, { status: 500 }), { once: true }));
292
+
293
+ await expect(freshGetDiscoveryDocument(createOptions())).rejects.toThrow('Failed to fetch OIDC metadata');
294
+ await expect(freshGetDiscoveryDocument(createOptions())).resolves.toMatchObject({ issuer: 'https://brandtegrity.io/' });
295
+ });
296
+
297
+ test('throws a ProtocolError when a required endpoint is not an https:// URL on the issuer host', async () => {
298
+ worker.use(
299
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
300
+ HttpResponse.json({ ...openidConfiguration, token_endpoint: 'https://evil.example.com/oauth2/token' }),
301
+ ),
302
+ );
303
+
304
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow(
305
+ 'Discovery document field "token_endpoint" must be an https:// URL on brandtegrity.io',
306
+ );
307
+ });
308
+
309
+ test('throws a ProtocolError when an optional endpoint is invalid', async () => {
310
+ worker.use(
311
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
312
+ HttpResponse.json({ ...openidConfiguration, revocation_endpoint: 'http://brandtegrity.io/oauth2/revoke' }),
313
+ ),
314
+ );
315
+
316
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Discovery document field "revocation_endpoint" must be an https:// URL');
317
+ });
318
+
319
+ test('throws a ProtocolError when S256 PKCE is not advertised', async () => {
320
+ worker.use(
321
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
322
+ HttpResponse.json({ ...openidConfiguration, code_challenge_methods_supported: ['plain'] }),
323
+ ),
324
+ );
325
+
326
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Discovery document does not advertise S256 PKCE support');
327
+ });
328
+
329
+ test('throws a ProtocolError when response body is not valid JSON', async () => {
330
+ worker.use(http.get('https://brandtegrity.io/.well-known/openid-configuration', () => HttpResponse.text('<html>not json</html>')));
331
+
332
+ await expect(getDiscoveryDocument(createOptions())).rejects.toThrow('Non-JSON response from discovery document endpoint');
333
+ });
334
+ });
335
+ });
336
+
337
+ describe('getJwks', () => {
338
+ let keyPair: CryptoKeyPair;
339
+
340
+ beforeAll(async () => {
341
+ keyPair = await generateRSAKeyPair();
342
+ });
343
+
344
+ test('fetches and caches the key set, avoiding a refetch on the next call', async () => {
345
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
346
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'kid-cache');
347
+ let requestCount = 0;
348
+
349
+ worker.use(
350
+ http.get(jwksUri, () => {
351
+ requestCount++;
352
+
353
+ return HttpResponse.json({ keys: [signingKey] });
354
+ }),
355
+ );
356
+
357
+ const token = await signRS256({ alg: 'RS256', kid: 'kid-cache' }, { sub: 'user-1' }, keyPair.privateKey);
358
+ const options = createOptions();
359
+
360
+ await verifyJwt({ token, jwksUri, options });
361
+ await verifyJwt({ token, jwksUri, options });
362
+
363
+ expect(requestCount).toBe(1);
364
+ });
365
+
366
+ test('force-refreshes the key set when the requested kid is missing from the cache', async () => {
367
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
368
+ const otherKeyPair = await generateRSAKeyPair();
369
+ const firstKey = await exportSigningKey(otherKeyPair.publicKey, 'kid-old');
370
+ const secondKey = await exportSigningKey(keyPair.publicKey, 'kid-new');
371
+ let requestCount = 0;
372
+
373
+ worker.use(
374
+ http.get(jwksUri, () => {
375
+ requestCount++;
376
+
377
+ return HttpResponse.json({ keys: requestCount === 1 ? [firstKey] : [firstKey, secondKey] });
378
+ }),
379
+ );
380
+
381
+ const options = createOptions();
382
+ const firstToken = await signRS256({ alg: 'RS256', kid: 'kid-old' }, { sub: 'a' }, otherKeyPair.privateKey);
383
+
384
+ await verifyJwt({ token: firstToken, jwksUri, options });
385
+
386
+ const secondToken = await signRS256({ alg: 'RS256', kid: 'kid-new' }, { sub: 'b' }, keyPair.privateKey);
387
+
388
+ await expect(verifyJwt({ token: secondToken, jwksUri, options })).resolves.toEqual({ sub: 'b' });
389
+ expect(requestCount).toBe(2);
390
+ });
391
+
392
+ test('force-refreshes exactly once per unknown kid, never looping', async () => {
393
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
394
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'the-real-kid');
395
+ let requestCount = 0;
396
+
397
+ worker.use(
398
+ http.get(jwksUri, () => {
399
+ requestCount++;
400
+
401
+ return HttpResponse.json({ keys: [signingKey] });
402
+ }),
403
+ );
404
+
405
+ const options = createOptions();
406
+ const hostileToken = await signRS256({ alg: 'RS256', kid: 'unknown-kid' }, {}, keyPair.privateKey);
407
+
408
+ await expect(verifyJwt({ token: hostileToken, jwksUri, options })).rejects.toThrow('No matching key found in JWKS');
409
+ expect(requestCount).toBe(2);
410
+ });
411
+
412
+ describe('error handling', () => {
413
+ test('throws a NetworkError when the JWKS request fails due to a network issue', async () => {
414
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
415
+
416
+ worker.use(http.get(jwksUri, () => HttpResponse.error()));
417
+
418
+ const token = await signRS256({ alg: 'RS256', kid: 'kid-network-error' }, { sub: 'user-1' }, keyPair.privateKey);
419
+
420
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).rejects.toThrow(new NetworkError(`Failed to fetch JWKS: ${jwksUri}`));
421
+ });
422
+
423
+ test('throws a ServerError when the JWKS request fails with a server error', async () => {
424
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
425
+
426
+ worker.use(http.get(jwksUri, () => new HttpResponse(null, { status: 500 })));
427
+
428
+ const token = await signRS256({ alg: 'RS256', kid: 'kid-server-error' }, { sub: 'user-1' }, keyPair.privateKey);
429
+
430
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).rejects.toThrow(new ServerError('Failed to fetch JWKS: HTTP 500', 500));
431
+ });
432
+ });
433
+ });
434
+
435
+ describe('decodeJwt', () => {
436
+ test('decodes the payload segment of a JWT', () => {
437
+ const payload = { sub: 'user-1', foo: 'bar' };
438
+ const encodedPayload = encodeBase64URL(new TextEncoder().encode(JSON.stringify(payload)).buffer);
439
+
440
+ expect(decodeJwt(`header.${encodedPayload}.signature`)).toEqual(payload);
441
+ });
442
+
443
+ test('throws a ProtocolError when the payload segment is missing', () => {
444
+ expect(() => decodeJwt('not-a-jwt')).toThrow('Invalid JWT: missing payload segment');
445
+ });
446
+
447
+ test('throws a ProtocolError when the payload is not valid base64url', () => {
448
+ expect(() => decodeJwt('header.not!!valid==base64url.signature')).toThrow(ProtocolError);
449
+ });
450
+
451
+ test('throws a ProtocolError when the decoded payload is not valid JSON', () => {
452
+ const notJson = encodeBase64URL(new TextEncoder().encode('not-json').buffer);
453
+
454
+ expect(() => decodeJwt(`header.${notJson}.signature`)).toThrow(ProtocolError);
455
+ });
456
+ });
457
+
458
+ describe('verifyJwt', () => {
459
+ let keyPair: CryptoKeyPair;
460
+
461
+ beforeAll(async () => {
462
+ keyPair = await generateRSAKeyPair();
463
+ });
464
+
465
+ test('verifies the signature and returns the payload for a valid RS256 JWT', async () => {
466
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
467
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'kid-1');
468
+
469
+ worker.use(http.get(jwksUri, () => HttpResponse.json({ keys: [signingKey] })));
470
+
471
+ const token = await signRS256({ alg: 'RS256', kid: 'kid-1' }, { sub: 'user-1' }, keyPair.privateKey);
472
+
473
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).resolves.toEqual({ sub: 'user-1' });
474
+ });
475
+
476
+ test('selects the sole RS256 key when the JWT header has no kid', async () => {
477
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
478
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'kid-2');
479
+
480
+ worker.use(http.get(jwksUri, () => HttpResponse.json({ keys: [signingKey] })));
481
+
482
+ const token = await signRS256({ alg: 'RS256' }, { sub: 'user-2' }, keyPair.privateKey);
483
+
484
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).resolves.toEqual({ sub: 'user-2' });
485
+ });
486
+
487
+ test('uses the first matching key when the JWKS has a duplicate kid, failing closed if that is the wrong one', async () => {
488
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
489
+ const otherKeyPair = await generateRSAKeyPair();
490
+ const wrongFirstKey = await exportSigningKey(otherKeyPair.publicKey, 'shared-kid');
491
+ const correctSecondKey = await exportSigningKey(keyPair.publicKey, 'shared-kid');
492
+
493
+ worker.use(http.get(jwksUri, () => HttpResponse.json({ keys: [wrongFirstKey, correctSecondKey] })));
494
+
495
+ const token = await signRS256({ alg: 'RS256', kid: 'shared-kid' }, { sub: 'user-1' }, keyPair.privateKey);
496
+
497
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).rejects.toThrow('JWT signature verification failed');
498
+ });
499
+
500
+ test('rejects a token that does not have exactly 3 segments', async () => {
501
+ await expect(verifyJwt({ token: 'a.b', jwksUri: 'https://brandtegrity.io/jwks.json', options: createOptions() })).rejects.toThrow(
502
+ 'Invalid JWT: must have 3 parts',
503
+ );
504
+ });
505
+
506
+ test('rejects a token signed with an unsupported algorithm', async () => {
507
+ const header = encodeBase64URL(new TextEncoder().encode(JSON.stringify({ alg: 'HS256' })).buffer);
508
+ const payload = encodeBase64URL(new TextEncoder().encode(JSON.stringify({})).buffer);
509
+
510
+ await expect(verifyJwt({ token: `${header}.${payload}.sig`, jwksUri: 'https://brandtegrity.io/jwks.json', options: createOptions() })).rejects.toThrow(
511
+ 'Unsupported JWT algorithm: HS256',
512
+ );
513
+ });
514
+
515
+ test('rejects when no matching key is found in the JWKS', async () => {
516
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
517
+
518
+ worker.use(http.get(jwksUri, () => HttpResponse.json({ keys: [] })));
519
+
520
+ const token = await signRS256({ alg: 'RS256', kid: 'missing-kid' }, {}, keyPair.privateKey);
521
+
522
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).rejects.toThrow('No matching key found in JWKS for kid: missing-kid');
523
+ });
524
+
525
+ test('rejects when the signature was produced by a different key', async () => {
526
+ const jwksUri = `https://brandtegrity.io/jwks/${crypto.randomUUID()}.json`;
527
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'kid-3');
528
+
529
+ worker.use(http.get(jwksUri, () => HttpResponse.json({ keys: [signingKey] })));
530
+
531
+ const otherKeyPair = await generateRSAKeyPair();
532
+ const token = await signRS256({ alg: 'RS256', kid: 'kid-3' }, {}, otherKeyPair.privateKey);
533
+
534
+ await expect(verifyJwt({ token, jwksUri, options: createOptions() })).rejects.toThrow('JWT signature verification failed');
535
+ });
536
+ });
537
+
538
+ describe('generateJwt', () => {
539
+ test('returns an unsigned token with a placeholder signature when no private key is provided', async () => {
540
+ const token = await generateJwt({ alg: 'none' }, { sub: 'user-1' });
541
+ const [header, , signature] = token.split('.');
542
+
543
+ expect(signature).toBe('signature');
544
+ expect(decodeJwt(token)).toEqual({ sub: 'user-1' });
545
+ expect(JSON.parse(new TextDecoder().decode(decodeBase64URL(header)))).toEqual({ alg: 'none' });
546
+ });
547
+
548
+ test('signs the token with ECDSA when a private key is provided', async () => {
549
+ const keyPair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
550
+ const token = await generateJwt({ alg: 'ES256' }, { sub: 'user-1' }, keyPair.privateKey);
551
+ const [encodedHeader, encodedPayload, encodedSignature] = token.split('.');
552
+
553
+ const valid = await crypto.subtle.verify(
554
+ { name: 'ECDSA', hash: { name: 'SHA-256' } },
555
+ keyPair.publicKey,
556
+ decodeBase64URL(encodedSignature),
557
+ new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`),
558
+ );
559
+
560
+ expect(valid).toBe(true);
561
+ });
562
+ });
563
+
564
+ describe('verifyIdToken', () => {
565
+ test('returns the decoded claims for a valid ID token', async () => {
566
+ const claims = buildIdTokenClaims();
567
+ const idToken = buildIdToken(claims);
568
+
569
+ await expect(verifyIdToken({ idToken, options: createOptions() })).resolves.toEqual(claims);
570
+ });
571
+
572
+ test('verifies the signature when verifySignature is true', async () => {
573
+ const keyPair = await generateRSAKeyPair();
574
+ const signingKey = await exportSigningKey(keyPair.publicKey, 'id-token-kid');
575
+
576
+ worker.use(http.get('https://brandtegrity.io/.well-known/jwks.json', () => HttpResponse.json({ keys: [signingKey] })));
577
+
578
+ const claims = buildIdTokenClaims();
579
+ const idToken = await signRS256({ alg: 'RS256', kid: 'id-token-kid' }, claims, keyPair.privateKey);
580
+
581
+ await expect(verifyIdToken({ idToken, options: createOptions(), verifySignature: true })).resolves.toEqual(claims);
582
+ });
583
+
584
+ describe('error handling', () => {
585
+ test('throws a ProtocolError when iss does not match the issuer', async () => {
586
+ const idToken = buildIdToken(buildIdTokenClaims({ iss: 'https://impostor.example.com/' }));
587
+
588
+ await expect(verifyIdToken({ idToken, options: createOptions() })).rejects.toThrow('Invalid iss');
589
+ });
590
+
591
+ test('throws a ProtocolError when aud does not include the client id', async () => {
592
+ const idToken = buildIdToken(buildIdTokenClaims({ aud: 'other-client' }));
593
+
594
+ await expect(verifyIdToken({ idToken, options: createOptions() })).rejects.toThrow('Invalid aud');
595
+ });
596
+
597
+ test('throws a ProtocolError when sub is missing', async () => {
598
+ const claims = { ...buildIdTokenClaims(), sub: undefined } as unknown as IdTokenClaims;
599
+ const idToken = buildIdToken(claims);
600
+
601
+ await expect(verifyIdToken({ idToken, options: createOptions() })).rejects.toThrow('Invalid sub');
602
+ });
603
+
604
+ test('throws a ProtocolError when sub does not match the expected subject', async () => {
605
+ const idToken = buildIdToken(buildIdTokenClaims({ sub: 'user-1' }));
606
+
607
+ await expect(verifyIdToken({ idToken, options: createOptions(), expectedSub: 'user-2' })).rejects.toThrow('Invalid sub');
608
+ });
609
+
610
+ test('throws a ProtocolError when the nonce does not match the expected nonce', async () => {
611
+ const idToken = buildIdToken(buildIdTokenClaims({ nonce: 'actual-nonce' }));
612
+
613
+ await expect(verifyIdToken({ idToken, options: createOptions(), expectedNonce: 'expected-nonce' })).rejects.toThrow('Invalid nonce');
614
+ });
615
+
616
+ test('throws a ProtocolError when a nonce is expected but the token has none', async () => {
617
+ const idToken = buildIdToken(buildIdTokenClaims());
618
+
619
+ await expect(verifyIdToken({ idToken, options: createOptions(), expectedNonce: 'expected-nonce' })).rejects.toThrow('Invalid nonce');
620
+ });
621
+
622
+ test("throws a ProtocolError when the token carries another journey's nonce", async () => {
623
+ const idToken = buildIdToken(buildIdTokenClaims({ nonce: 'journey-a-nonce' }));
624
+
625
+ await expect(verifyIdToken({ idToken, options: createOptions(), expectedNonce: 'journey-b-nonce' })).rejects.toThrow('Invalid nonce');
626
+ });
627
+ });
628
+ });
629
+
630
+ describe('exchangeCode', () => {
631
+ test('sends exactly the 5 required fields in the token request body', async () => {
632
+ const options = createOptions();
633
+ const stateData = await getMockStorage(options.stateStorage).generateState();
634
+ let capturedBody: URLSearchParams | undefined;
635
+ worker.use(
636
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
637
+ capturedBody = new URLSearchParams(await request.text());
638
+
639
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
640
+ }),
641
+ );
642
+
643
+ await exchangeCode({ params: { code: 'abc123', state: stateData.id }, options });
644
+
645
+ expect(Array.from(capturedBody!.keys()).sort()).toEqual(['client_id', 'code', 'code_verifier', 'grant_type', 'redirect_uri']);
646
+ expect(capturedBody?.get('grant_type')).toBe('authorization_code');
647
+ expect(capturedBody?.get('code_verifier')).toBe(stateData.codeVerifier);
648
+ });
649
+
650
+ test.each(['bearer', 'BEARER', 'Bearer'])('accepts a case-insensitive token_type "%s"', async (tokenType) => {
651
+ const options = createOptions();
652
+ const stateData = await getMockStorage(options.stateStorage).generateState();
653
+ worker.use(
654
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ access_token: 'access-token', token_type: tokenType, expires_in: 3600 })),
655
+ );
656
+
657
+ await expect(exchangeCode({ params: { code: 'abc123', state: stateData.id }, options })).resolves.toMatchObject({ access_token: 'access-token' });
658
+ });
659
+
660
+ test.each(['MAC', 'DPoP'])('rejects an unsupported token_type "%s"', async (tokenType) => {
661
+ const options = createOptions();
662
+ const stateData = await getMockStorage(options.stateStorage).generateState();
663
+ worker.use(
664
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ access_token: 'access-token', token_type: tokenType, expires_in: 3600 })),
665
+ );
666
+
667
+ await expect(exchangeCode({ params: { code: 'abc123', state: stateData.id }, options })).rejects.toThrow(`Unsupported token_type: ${tokenType}`);
668
+ });
669
+
670
+ test.each([
671
+ ['omitted', undefined],
672
+ ['a string', '3600'],
673
+ ['zero', 0],
674
+ ['negative', -1],
675
+ ])('does not crash when expires_in is %s', async (_label, expiresIn) => {
676
+ const options = createOptions();
677
+ const stateData = await getMockStorage(options.stateStorage).generateState();
678
+ worker.use(
679
+ http.post('https://brandtegrity.io/oauth2/token', () =>
680
+ HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', ...(expiresIn === undefined ? {} : { expires_in: expiresIn }) }),
681
+ ),
682
+ );
683
+
684
+ await expect(exchangeCode({ params: { code: 'abc123', state: stateData.id }, options })).resolves.toMatchObject({ access_token: 'access-token' });
685
+ });
686
+
687
+ test('records a narrower granted scope rather than assuming it equals the requested scope', async () => {
688
+ const options = createOptions({ scopes: ['openid', 'profile', 'email'] });
689
+ const stateData = await getMockStorage(options.stateStorage).generateState();
690
+ worker.use(
691
+ http.post('https://brandtegrity.io/oauth2/token', () =>
692
+ HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600, scope: 'openid' }),
693
+ ),
694
+ );
695
+
696
+ await expect(exchangeCode({ params: { code: 'abc123', state: stateData.id }, options })).resolves.toMatchObject({ scope: 'openid' });
697
+ });
698
+ });
699
+
700
+ describe('refreshToken', () => {
701
+ test('includes audience and scope in the token request when provided', async () => {
702
+ let capturedBody: URLSearchParams | undefined;
703
+
704
+ worker.use(
705
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
706
+ capturedBody = new URLSearchParams(await request.text());
707
+
708
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
709
+ }),
710
+ );
711
+
712
+ await refreshToken({ refreshToken: 'refresh-token', options: createOptions(), audience: 'https://api.example.com', scope: 'openid profile' });
713
+
714
+ expect(capturedBody?.get('audience')).toBe('https://api.example.com');
715
+ expect(capturedBody?.get('scope')).toBe('openid profile');
716
+ });
717
+
718
+ test('omits audience and scope from the token request when not provided', async () => {
719
+ let capturedBody: URLSearchParams | undefined;
720
+
721
+ worker.use(
722
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
723
+ capturedBody = new URLSearchParams(await request.text());
724
+
725
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
726
+ }),
727
+ );
728
+
729
+ await refreshToken({ refreshToken: 'refresh-token', options: createOptions() });
730
+
731
+ expect(capturedBody?.has('audience')).toBe(false);
732
+ expect(capturedBody?.has('scope')).toBe(false);
733
+ });
734
+
735
+ test('replaces the refresh token when the response includes a new one', async () => {
736
+ worker.use(
737
+ http.post('https://brandtegrity.io/oauth2/token', () =>
738
+ HttpResponse.json({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', token_type: 'Bearer', expires_in: 3600 }),
739
+ ),
740
+ );
741
+
742
+ await expect(refreshToken({ refreshToken: 'old-refresh-token', options: createOptions() })).resolves.toMatchObject({ refresh_token: 'new-refresh-token' });
743
+ });
744
+ });