@strivacity/sdk-core 3.0.2 → 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 (328) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +1873 -340
  3. package/dist/assets/oidc.cjs +2 -0
  4. package/dist/assets/oidc.cjs.map +1 -0
  5. package/dist/assets/oidc.mjs +2 -0
  6. package/dist/assets/oidc.mjs.map +1 -0
  7. package/dist/assets/server.cjs +2 -0
  8. package/dist/assets/server.cjs.map +1 -0
  9. package/dist/assets/server.mjs +2 -0
  10. package/dist/assets/server.mjs.map +1 -0
  11. package/dist/flows/base.cjs +2 -0
  12. package/dist/flows/base.cjs.map +1 -0
  13. package/dist/flows/base.d.ts +26 -0
  14. package/dist/flows/base.mjs +2 -0
  15. package/dist/flows/base.mjs.map +1 -0
  16. package/dist/flows/embedded.cjs +2 -0
  17. package/dist/flows/embedded.cjs.map +1 -0
  18. package/dist/flows/embedded.d.ts +9 -0
  19. package/dist/flows/embedded.mjs +2 -0
  20. package/dist/flows/embedded.mjs.map +1 -0
  21. package/dist/flows/index.cjs +1 -0
  22. package/dist/flows/index.d.ts +5 -0
  23. package/dist/flows/index.mjs +1 -0
  24. package/dist/flows/native.cjs +2 -0
  25. package/dist/flows/native.cjs.map +1 -0
  26. package/dist/flows/native.d.ts +9 -0
  27. package/dist/flows/native.mjs +2 -0
  28. package/dist/flows/native.mjs.map +1 -0
  29. package/dist/flows/popup.cjs +2 -0
  30. package/dist/flows/popup.cjs.map +1 -0
  31. package/dist/flows/popup.d.ts +9 -0
  32. package/dist/flows/popup.mjs +2 -0
  33. package/dist/flows/popup.mjs.map +1 -0
  34. package/dist/flows/redirect.cjs +2 -0
  35. package/dist/flows/redirect.cjs.map +1 -0
  36. package/dist/flows/redirect.d.ts +9 -0
  37. package/dist/flows/redirect.mjs +2 -0
  38. package/dist/flows/redirect.mjs.map +1 -0
  39. package/dist/handlers/embedded.cjs +2 -0
  40. package/dist/handlers/embedded.cjs.map +1 -0
  41. package/dist/handlers/embedded.d.ts +37 -0
  42. package/dist/handlers/embedded.mjs +2 -0
  43. package/dist/handlers/embedded.mjs.map +1 -0
  44. package/dist/handlers/index.cjs +1 -0
  45. package/dist/handlers/index.d.ts +2 -0
  46. package/dist/handlers/index.mjs +1 -0
  47. package/dist/handlers/native.cjs +2 -0
  48. package/dist/handlers/native.cjs.map +1 -0
  49. package/dist/handlers/native.d.ts +56 -0
  50. package/dist/handlers/native.mjs +2 -0
  51. package/dist/handlers/native.mjs.map +1 -0
  52. package/dist/handlers/popup.cjs +2 -0
  53. package/dist/handlers/popup.cjs.map +1 -0
  54. package/dist/handlers/popup.d.ts +21 -0
  55. package/dist/handlers/popup.mjs +2 -0
  56. package/dist/handlers/popup.mjs.map +1 -0
  57. package/dist/handlers/redirect.cjs +2 -0
  58. package/dist/handlers/redirect.cjs.map +1 -0
  59. package/dist/handlers/redirect.d.ts +18 -0
  60. package/dist/handlers/redirect.mjs +2 -0
  61. package/dist/handlers/redirect.mjs.map +1 -0
  62. package/dist/index.cjs +2 -2
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.ts +16 -21
  65. package/dist/index.mjs +2 -2
  66. package/dist/index.mjs.map +1 -1
  67. package/dist/server/base.cjs +2 -0
  68. package/dist/server/base.cjs.map +1 -0
  69. package/dist/server/base.d.ts +15 -0
  70. package/dist/server/base.mjs +2 -0
  71. package/dist/server/base.mjs.map +1 -0
  72. package/dist/server/index.cjs +1 -0
  73. package/dist/server/index.d.ts +1 -0
  74. package/dist/server/index.mjs +1 -0
  75. package/dist/storages/cache.cjs +2 -0
  76. package/dist/storages/cache.cjs.map +1 -0
  77. package/dist/storages/cache.d.ts +11 -0
  78. package/dist/storages/cache.mjs +2 -0
  79. package/dist/storages/cache.mjs.map +1 -0
  80. package/dist/storages/index.cjs +1 -0
  81. package/dist/storages/index.d.ts +7 -0
  82. package/dist/storages/index.mjs +1 -0
  83. package/dist/storages/indexeddb.cjs +2 -0
  84. package/dist/storages/indexeddb.cjs.map +1 -0
  85. package/dist/storages/indexeddb.d.ts +10 -0
  86. package/dist/storages/indexeddb.mjs +2 -0
  87. package/dist/storages/indexeddb.mjs.map +1 -0
  88. package/dist/storages/local.cjs +2 -0
  89. package/dist/storages/local.cjs.map +1 -0
  90. package/dist/storages/local.d.ts +7 -0
  91. package/dist/storages/local.mjs +2 -0
  92. package/dist/storages/local.mjs.map +1 -0
  93. package/dist/storages/memory.cjs +2 -0
  94. package/dist/storages/memory.cjs.map +1 -0
  95. package/dist/storages/memory.d.ts +8 -0
  96. package/dist/storages/memory.mjs +2 -0
  97. package/dist/storages/memory.mjs.map +1 -0
  98. package/dist/storages/server.cjs +1 -0
  99. package/dist/storages/server.d.ts +39 -0
  100. package/dist/storages/server.mjs +1 -0
  101. package/dist/storages/session.cjs +2 -0
  102. package/dist/storages/session.cjs.map +1 -0
  103. package/dist/storages/session.d.ts +7 -0
  104. package/dist/storages/session.mjs +2 -0
  105. package/dist/storages/session.mjs.map +1 -0
  106. package/dist/storages/worker.cjs +2 -0
  107. package/dist/storages/worker.cjs.map +1 -0
  108. package/dist/storages/worker.d.ts +46 -0
  109. package/dist/storages/worker.mjs +2 -0
  110. package/dist/storages/worker.mjs.map +1 -0
  111. package/dist/types/common.cjs +0 -0
  112. package/dist/types/common.d.ts +145 -0
  113. package/dist/types/common.mjs +0 -0
  114. package/dist/types/embedded.cjs +0 -0
  115. package/dist/types/embedded.d.ts +103 -0
  116. package/dist/types/embedded.mjs +0 -0
  117. package/dist/types/index.cjs +1 -0
  118. package/dist/types/index.d.ts +7 -0
  119. package/dist/types/index.mjs +1 -0
  120. package/dist/types/native.cjs +0 -0
  121. package/dist/types/native.d.ts +385 -0
  122. package/dist/types/native.mjs +0 -0
  123. package/dist/types/oidc.cjs +2 -0
  124. package/dist/types/oidc.cjs.map +1 -0
  125. package/dist/types/oidc.d.ts +768 -0
  126. package/dist/types/oidc.mjs +2 -0
  127. package/dist/types/oidc.mjs.map +1 -0
  128. package/dist/types/popup.cjs +0 -0
  129. package/dist/types/popup.d.ts +69 -0
  130. package/dist/types/popup.mjs +0 -0
  131. package/dist/types/redirect.cjs +0 -0
  132. package/dist/types/redirect.d.ts +21 -0
  133. package/dist/types/redirect.mjs +0 -0
  134. package/dist/types/server.cjs +0 -0
  135. package/dist/types/server.d.ts +200 -0
  136. package/dist/types/server.mjs +0 -0
  137. package/dist/utils/base64url.cjs +2 -0
  138. package/dist/utils/base64url.cjs.map +1 -0
  139. package/dist/utils/base64url.d.ts +14 -0
  140. package/dist/utils/base64url.mjs +2 -0
  141. package/dist/utils/base64url.mjs.map +1 -0
  142. package/dist/utils/common.cjs +2 -0
  143. package/dist/utils/common.cjs.map +1 -0
  144. package/dist/utils/common.d.ts +23 -0
  145. package/dist/utils/common.mjs +2 -0
  146. package/dist/utils/common.mjs.map +1 -0
  147. package/dist/utils/credentials.cjs +2 -2
  148. package/dist/utils/credentials.cjs.map +1 -1
  149. package/dist/utils/credentials.d.ts +19 -3
  150. package/dist/utils/credentials.mjs +2 -2
  151. package/dist/utils/credentials.mjs.map +1 -1
  152. package/dist/utils/crypto.cjs +2 -2
  153. package/dist/utils/crypto.cjs.map +1 -1
  154. package/dist/utils/crypto.d.ts +16 -26
  155. package/dist/utils/crypto.mjs +2 -2
  156. package/dist/utils/crypto.mjs.map +1 -1
  157. package/dist/utils/dom.cjs +2 -0
  158. package/dist/utils/dom.cjs.map +1 -0
  159. package/dist/utils/dom.d.ts +7 -0
  160. package/dist/utils/dom.mjs +2 -0
  161. package/dist/utils/dom.mjs.map +1 -0
  162. package/dist/utils/errors.cjs +2 -2
  163. package/dist/utils/errors.cjs.map +1 -1
  164. package/dist/utils/errors.d.ts +62 -1
  165. package/dist/utils/errors.mjs +2 -2
  166. package/dist/utils/errors.mjs.map +1 -1
  167. package/dist/utils/httpClient.cjs +2 -0
  168. package/dist/utils/httpClient.cjs.map +1 -0
  169. package/dist/utils/httpClient.d.ts +2 -0
  170. package/dist/utils/httpClient.mjs +2 -0
  171. package/dist/utils/httpClient.mjs.map +1 -0
  172. package/dist/utils/index.cjs +1 -0
  173. package/dist/utils/index.d.ts +12 -0
  174. package/dist/utils/index.mjs +1 -0
  175. package/dist/utils/logging.cjs +2 -0
  176. package/dist/utils/logging.cjs.map +1 -0
  177. package/dist/utils/logging.d.ts +2 -0
  178. package/dist/utils/logging.mjs +2 -0
  179. package/dist/utils/logging.mjs.map +1 -0
  180. package/dist/utils/oidc.cjs +1 -0
  181. package/dist/utils/oidc.d.ts +264 -0
  182. package/dist/utils/oidc.mjs +1 -0
  183. package/dist/utils/server.cjs +1 -0
  184. package/dist/utils/server.d.ts +72 -0
  185. package/dist/utils/server.mjs +1 -0
  186. package/dist/utils/session.cjs +2 -0
  187. package/dist/utils/session.cjs.map +1 -0
  188. package/dist/utils/session.d.ts +29 -0
  189. package/dist/utils/session.mjs +2 -0
  190. package/dist/utils/session.mjs.map +1 -0
  191. package/dist/utils/state.cjs +1 -0
  192. package/dist/utils/state.d.ts +23 -0
  193. package/dist/utils/state.mjs +1 -0
  194. package/package.json +53 -1
  195. package/testing/tests/flows/base.spec.ts +1030 -0
  196. package/testing/tests/flows/embedded.spec.ts +443 -0
  197. package/testing/tests/flows/index.spec.ts +8 -0
  198. package/testing/tests/flows/native.spec.ts +487 -0
  199. package/testing/tests/flows/popup.spec.ts +400 -0
  200. package/testing/tests/flows/redirect.spec.ts +369 -0
  201. package/testing/tests/handlers/embedded.spec.ts +199 -0
  202. package/testing/tests/handlers/index.spec.ts +8 -0
  203. package/testing/tests/handlers/native.spec.ts +283 -0
  204. package/testing/tests/handlers/popup.spec.ts +206 -0
  205. package/testing/tests/handlers/redirect.spec.ts +70 -0
  206. package/testing/tests/index.spec.ts +78 -0
  207. package/testing/tests/server/base.spec.ts +846 -0
  208. package/testing/tests/server/index.spec.ts +15 -0
  209. package/testing/tests/storages/cache.spec.ts +107 -0
  210. package/testing/tests/storages/index.spec.ts +22 -0
  211. package/testing/tests/storages/indexeddb.spec.ts +113 -0
  212. package/testing/tests/storages/local.spec.ts +46 -0
  213. package/testing/tests/storages/memory.spec.ts +51 -0
  214. package/testing/tests/storages/server.spec.ts +435 -0
  215. package/testing/tests/storages/session.spec.ts +46 -0
  216. package/testing/tests/storages/worker.spec.ts +106 -0
  217. package/testing/tests/utils/common.spec.ts +77 -0
  218. package/testing/tests/utils/credentials.spec.ts +195 -0
  219. package/testing/tests/utils/dom.spec.ts +37 -0
  220. package/testing/tests/utils/index.spec.ts +71 -0
  221. package/testing/tests/utils/logging.spec.ts +82 -0
  222. package/testing/tests/utils/oidc.spec.ts +744 -0
  223. package/testing/tests/utils/server.spec.ts +235 -0
  224. package/dist/flows/BaseFlow.cjs +0 -2
  225. package/dist/flows/BaseFlow.cjs.map +0 -1
  226. package/dist/flows/BaseFlow.d.ts +0 -191
  227. package/dist/flows/BaseFlow.mjs +0 -2
  228. package/dist/flows/BaseFlow.mjs.map +0 -1
  229. package/dist/flows/EmbeddedFlow.cjs +0 -2
  230. package/dist/flows/EmbeddedFlow.cjs.map +0 -1
  231. package/dist/flows/EmbeddedFlow.d.ts +0 -26
  232. package/dist/flows/EmbeddedFlow.mjs +0 -2
  233. package/dist/flows/EmbeddedFlow.mjs.map +0 -1
  234. package/dist/flows/NativeFlow.cjs +0 -2
  235. package/dist/flows/NativeFlow.cjs.map +0 -1
  236. package/dist/flows/NativeFlow.d.ts +0 -34
  237. package/dist/flows/NativeFlow.mjs +0 -2
  238. package/dist/flows/NativeFlow.mjs.map +0 -1
  239. package/dist/flows/PopupFlow.cjs +0 -2
  240. package/dist/flows/PopupFlow.cjs.map +0 -1
  241. package/dist/flows/PopupFlow.d.ts +0 -36
  242. package/dist/flows/PopupFlow.mjs +0 -2
  243. package/dist/flows/PopupFlow.mjs.map +0 -1
  244. package/dist/flows/RedirectFlow.cjs +0 -2
  245. package/dist/flows/RedirectFlow.cjs.map +0 -1
  246. package/dist/flows/RedirectFlow.d.ts +0 -38
  247. package/dist/flows/RedirectFlow.mjs +0 -2
  248. package/dist/flows/RedirectFlow.mjs.map +0 -1
  249. package/dist/handlers/BaseFlowHandler.cjs +0 -2
  250. package/dist/handlers/BaseFlowHandler.cjs.map +0 -1
  251. package/dist/handlers/BaseFlowHandler.d.ts +0 -61
  252. package/dist/handlers/BaseFlowHandler.mjs +0 -2
  253. package/dist/handlers/BaseFlowHandler.mjs.map +0 -1
  254. package/dist/handlers/EmbeddedFlowHandler.cjs +0 -2
  255. package/dist/handlers/EmbeddedFlowHandler.cjs.map +0 -1
  256. package/dist/handlers/EmbeddedFlowHandler.d.ts +0 -28
  257. package/dist/handlers/EmbeddedFlowHandler.mjs +0 -2
  258. package/dist/handlers/EmbeddedFlowHandler.mjs.map +0 -1
  259. package/dist/handlers/NativeFlowHandler.cjs +0 -2
  260. package/dist/handlers/NativeFlowHandler.cjs.map +0 -1
  261. package/dist/handlers/NativeFlowHandler.d.ts +0 -31
  262. package/dist/handlers/NativeFlowHandler.mjs +0 -2
  263. package/dist/handlers/NativeFlowHandler.mjs.map +0 -1
  264. package/dist/storages/LocalStorage.cjs +0 -2
  265. package/dist/storages/LocalStorage.cjs.map +0 -1
  266. package/dist/storages/LocalStorage.d.ts +0 -23
  267. package/dist/storages/LocalStorage.mjs +0 -2
  268. package/dist/storages/LocalStorage.mjs.map +0 -1
  269. package/dist/storages/SessionStorage.cjs +0 -2
  270. package/dist/storages/SessionStorage.cjs.map +0 -1
  271. package/dist/storages/SessionStorage.d.ts +0 -23
  272. package/dist/storages/SessionStorage.mjs +0 -2
  273. package/dist/storages/SessionStorage.mjs.map +0 -1
  274. package/dist/types.cjs +0 -2
  275. package/dist/types.cjs.map +0 -1
  276. package/dist/types.d.ts +0 -1263
  277. package/dist/types.mjs +0 -2
  278. package/dist/types.mjs.map +0 -1
  279. package/dist/utils/HttpClient.cjs +0 -2
  280. package/dist/utils/HttpClient.cjs.map +0 -1
  281. package/dist/utils/HttpClient.d.ts +0 -4
  282. package/dist/utils/HttpClient.mjs +0 -2
  283. package/dist/utils/HttpClient.mjs.map +0 -1
  284. package/dist/utils/Logging.cjs +0 -2
  285. package/dist/utils/Logging.cjs.map +0 -1
  286. package/dist/utils/Logging.d.ts +0 -8
  287. package/dist/utils/Logging.mjs +0 -2
  288. package/dist/utils/Logging.mjs.map +0 -1
  289. package/dist/utils/Metadata.cjs +0 -2
  290. package/dist/utils/Metadata.cjs.map +0 -1
  291. package/dist/utils/Metadata.d.ts +0 -98
  292. package/dist/utils/Metadata.mjs +0 -2
  293. package/dist/utils/Metadata.mjs.map +0 -1
  294. package/dist/utils/Session.cjs +0 -2
  295. package/dist/utils/Session.cjs.map +0 -1
  296. package/dist/utils/Session.d.ts +0 -82
  297. package/dist/utils/Session.mjs +0 -2
  298. package/dist/utils/Session.mjs.map +0 -1
  299. package/dist/utils/State.cjs +0 -2
  300. package/dist/utils/State.cjs.map +0 -1
  301. package/dist/utils/State.d.ts +0 -43
  302. package/dist/utils/State.mjs +0 -2
  303. package/dist/utils/State.mjs.map +0 -1
  304. package/dist/utils/base64Url.cjs +0 -2
  305. package/dist/utils/base64Url.cjs.map +0 -1
  306. package/dist/utils/base64Url.d.ts +0 -27
  307. package/dist/utils/base64Url.mjs +0 -2
  308. package/dist/utils/base64Url.mjs.map +0 -1
  309. package/dist/utils/date.cjs +0 -2
  310. package/dist/utils/date.cjs.map +0 -1
  311. package/dist/utils/date.d.ts +0 -6
  312. package/dist/utils/date.mjs +0 -2
  313. package/dist/utils/date.mjs.map +0 -1
  314. package/dist/utils/handlers.cjs +0 -2
  315. package/dist/utils/handlers.cjs.map +0 -1
  316. package/dist/utils/handlers.d.ts +0 -35
  317. package/dist/utils/handlers.mjs +0 -2
  318. package/dist/utils/handlers.mjs.map +0 -1
  319. package/dist/utils/jwt.cjs +0 -2
  320. package/dist/utils/jwt.cjs.map +0 -1
  321. package/dist/utils/jwt.d.ts +0 -34
  322. package/dist/utils/jwt.mjs +0 -2
  323. package/dist/utils/jwt.mjs.map +0 -1
  324. package/dist/utils/object.cjs +0 -2
  325. package/dist/utils/object.cjs.map +0 -1
  326. package/dist/utils/object.d.ts +0 -1
  327. package/dist/utils/object.mjs +0 -2
  328. package/dist/utils/object.mjs.map +0 -1
@@ -0,0 +1,1030 @@
1
+ import { 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 { createBaseFlow } from '../../../src/flows/base';
7
+ import { getDefaultFlowState, ConfigurationError, OidcError, ProtocolError, NetworkError, ServerError } from '../../../src/utils';
8
+ import { timestamp } from '../../../src/utils/common';
9
+ import type { FlowState } from '../../../src/types/oidc';
10
+ import { buildIdToken } from '@strivacity/testing/mocks/oidc';
11
+
12
+ describe('createBaseFlow', () => {
13
+ let options: ReturnType<typeof createOptions>;
14
+ let flowState: FlowState;
15
+ let base: ReturnType<typeof createBaseFlow>;
16
+
17
+ beforeEach(() => {
18
+ globalThis.location.href = 'https://brandtegrity.io';
19
+
20
+ options = createOptions({ urlHandler: vi.fn() });
21
+ flowState = getDefaultFlowState();
22
+ base = createBaseFlow(flowState, options);
23
+ });
24
+
25
+ describe('events', () => {
26
+ test('should invoke subscribers with the dispatched arguments', () => {
27
+ const callback = vi.fn();
28
+ base.subscribeToEvent('sessionCleared', callback);
29
+
30
+ base.dispatchEvent('sessionCleared', []);
31
+
32
+ expect(callback).toHaveBeenCalledTimes(1);
33
+ });
34
+
35
+ test('should stop invoking a subscriber after it is disposed', () => {
36
+ const callback = vi.fn();
37
+ const subscription = base.subscribeToEvent('sessionCleared', callback);
38
+
39
+ subscription.dispose();
40
+ base.dispatchEvent('sessionCleared', []);
41
+
42
+ expect(callback).not.toHaveBeenCalled();
43
+ });
44
+
45
+ test('should invoke a subscribeToAllEvents callback for any event', () => {
46
+ const callback = vi.fn();
47
+ base.subscribeToAllEvents(callback);
48
+
49
+ base.dispatchEvent('sessionCleared', []);
50
+ base.dispatchEvent('logoutInitiated', [{ idToken: 'id-token' }]);
51
+
52
+ expect(callback).toHaveBeenCalledTimes(2);
53
+ expect(callback).toHaveBeenCalledWith({ idToken: 'id-token' });
54
+ });
55
+
56
+ test('should stop invoking a subscribeToAllEvents callback after it is disposed', () => {
57
+ const callback = vi.fn();
58
+ const subscription = base.subscribeToAllEvents(callback);
59
+
60
+ subscription.dispose();
61
+ base.dispatchEvent('sessionCleared', []);
62
+
63
+ expect(callback).not.toHaveBeenCalled();
64
+ });
65
+ });
66
+
67
+ describe('init', () => {
68
+ test('should initialize once and dispatch the init event', async () => {
69
+ const callback = vi.fn();
70
+ base.subscribeToEvent('init', callback);
71
+
72
+ await base.init();
73
+
74
+ expect(flowState.initialized).toBe(true);
75
+ expect(callback).toHaveBeenCalledTimes(1);
76
+ });
77
+
78
+ test('should be a no-op once already initialized', async () => {
79
+ await base.init();
80
+ vi.mocked(options.storage.get).mockClear();
81
+
82
+ await base.init();
83
+
84
+ expect(options.storage.get).not.toHaveBeenCalled();
85
+ });
86
+
87
+ test('should dedupe concurrent init calls', async () => {
88
+ await Promise.all([base.init(), base.init()]);
89
+
90
+ expect(options.storage.get).toHaveBeenCalledTimes(1);
91
+ });
92
+
93
+ test.each([
94
+ ['issuer', { issuer: '' }, 'Missing option: issuer'],
95
+ ['clientId', { clientId: '' }, 'Missing option: clientId'],
96
+ ['redirectUri', { redirectUri: '' }, 'Missing option: redirectUri'],
97
+ ])('should reject with a ConfigurationError when %s is missing', async (_name, overrides, message) => {
98
+ options = createOptions(overrides);
99
+ flowState = getDefaultFlowState();
100
+ base = createBaseFlow(flowState, options);
101
+
102
+ await expect(base.init()).rejects.toThrow(new ConfigurationError(message));
103
+ expect(options.logging?.error).toHaveBeenCalledWith('Required option missing', expect.any(ConfigurationError));
104
+ });
105
+
106
+ test('should reject with a ConfigurationError when scopes is not an array', async () => {
107
+ options = createOptions({ scopes: 'openid' as unknown as Array<string> });
108
+ flowState = getDefaultFlowState();
109
+ base = createBaseFlow(flowState, options);
110
+
111
+ await expect(base.init()).rejects.toThrow(new ConfigurationError('Invalid option: scopes'));
112
+ expect(options.logging?.error).toHaveBeenCalledWith('Invalid option provided', expect.any(ConfigurationError));
113
+ });
114
+
115
+ test('should load a persisted session from storage and dispatch sessionLoaded', async () => {
116
+ const session = getMockStorage(options.storage).generateSession();
117
+ const callback = vi.fn();
118
+ base.subscribeToEvent('sessionLoaded', callback);
119
+
120
+ await base.init();
121
+
122
+ expect(flowState.session).toEqual(session);
123
+ expect(callback).toHaveBeenCalledWith({
124
+ idToken: session.id_token,
125
+ accessToken: session.access_token,
126
+ refreshToken: session.refresh_token,
127
+ claims: session.claims,
128
+ });
129
+ });
130
+
131
+ test('should dispatch accessTokenExpired when the loaded session has already expired', async () => {
132
+ const session = getMockStorage(options.storage).generateSession({ expires_at: 0 });
133
+ const callback = vi.fn();
134
+ base.subscribeToEvent('accessTokenExpired', callback);
135
+
136
+ await base.init();
137
+
138
+ expect(callback).toHaveBeenCalledWith({ accessToken: session.access_token, refreshToken: session.refresh_token });
139
+ });
140
+
141
+ test('should skip storage access when serverSessionUri is set', async () => {
142
+ options = createOptions({ serverSessionUri: 'https://app.example.com/login' });
143
+ flowState = getDefaultFlowState();
144
+ base = createBaseFlow(flowState, options);
145
+
146
+ await base.init();
147
+
148
+ expect(options.storage.get).not.toHaveBeenCalled();
149
+ expect(flowState.session).toBeNull();
150
+ });
151
+ });
152
+
153
+ describe('getSession', () => {
154
+ test('should initialize the flow and return the loaded session', async () => {
155
+ const session = getMockStorage(options.storage).generateSession();
156
+
157
+ await expect(base.getSession()).resolves.toEqual(session);
158
+ });
159
+
160
+ test('should return null when there is no active session', async () => {
161
+ await expect(base.getSession()).resolves.toBeNull();
162
+ });
163
+ });
164
+
165
+ describe('updateSession', () => {
166
+ test('should persist the session to storage and dispatch sessionUpdated', async () => {
167
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
168
+ const callback = vi.fn();
169
+ base.subscribeToEvent('sessionUpdated', callback);
170
+
171
+ await base.updateSession(session);
172
+
173
+ expect(flowState.session).toBe(session);
174
+ expect(getMockStorage(options.storage).getSession()).toEqual(session);
175
+ expect(callback).toHaveBeenCalledWith({
176
+ idToken: session.id_token,
177
+ accessToken: session.access_token,
178
+ refreshToken: session.refresh_token,
179
+ claims: session.claims,
180
+ });
181
+ });
182
+
183
+ test('should skip persisting to storage when serverSessionUri is set', async () => {
184
+ options = createOptions({ serverSessionUri: 'https://app.example.com/login' });
185
+ flowState = getDefaultFlowState();
186
+ base = createBaseFlow(flowState, options);
187
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
188
+
189
+ await base.updateSession(session);
190
+
191
+ expect(options.storage.set).not.toHaveBeenCalled();
192
+ expect(flowState.session).toBe(session);
193
+ });
194
+ });
195
+
196
+ describe('cleanupSession', () => {
197
+ test('should remove the session from storage and dispatch sessionCleared', async () => {
198
+ getMockStorage(options.storage).generateSession();
199
+ await base.init();
200
+ const callback = vi.fn();
201
+ base.subscribeToEvent('sessionCleared', callback);
202
+
203
+ await base.cleanupSession();
204
+
205
+ expect(flowState.session).toBeNull();
206
+ expect(getMockStorage(options.storage).getSession()).toBeUndefined();
207
+ expect(callback).toHaveBeenCalledTimes(1);
208
+ });
209
+
210
+ test('should skip storage access when serverSessionUri is set', async () => {
211
+ options = createOptions({ serverSessionUri: 'https://app.example.com/login' });
212
+ flowState = getDefaultFlowState();
213
+ flowState.initialized = true;
214
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
215
+ base = createBaseFlow(flowState, options);
216
+
217
+ await base.cleanupSession();
218
+
219
+ expect(options.storage.delete).not.toHaveBeenCalled();
220
+ expect(flowState.session).toBeNull();
221
+ });
222
+ });
223
+
224
+ describe('checkAuthentication', () => {
225
+ test('should return true for a valid, non-expired session', async () => {
226
+ flowState.initialized = true;
227
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
228
+
229
+ await expect(base.checkAuthentication()).resolves.toBe(true);
230
+ });
231
+
232
+ test('should return false when there is no session', async () => {
233
+ flowState.initialized = true;
234
+
235
+ await expect(base.checkAuthentication()).resolves.toBe(false);
236
+ });
237
+
238
+ test('should attempt a refresh when the access token expired and autoRefresh is enabled', async () => {
239
+ flowState.initialized = true;
240
+ flowState.session = getMockStorage(options.storage).generateSession({ expires_at: 0 }, null);
241
+
242
+ worker.use(
243
+ http.post('https://brandtegrity.io/oauth2/token', () =>
244
+ HttpResponse.json({ access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600 }),
245
+ ),
246
+ );
247
+
248
+ await expect(base.checkAuthentication()).resolves.toBe(true);
249
+ expect(flowState.session?.access_token).toBe('new-access-token');
250
+ });
251
+
252
+ test('should return false when the refresh fails', async () => {
253
+ flowState.initialized = true;
254
+ flowState.session = getMockStorage(options.storage).generateSession({ expires_at: 0 }, null);
255
+
256
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => new HttpResponse(null, { status: 400 })));
257
+
258
+ await expect(base.checkAuthentication()).resolves.toBe(false);
259
+ });
260
+
261
+ test('should not attempt a refresh when autoRefresh is disabled', async () => {
262
+ flowState.initialized = true;
263
+ flowState.session = getMockStorage(options.storage).generateSession({ expires_at: 0 }, null);
264
+
265
+ await expect(base.checkAuthentication({ autoRefresh: false })).resolves.toBe(false);
266
+ });
267
+ });
268
+
269
+ describe('tokenExchange', () => {
270
+ test('should exchange the code for tokens and dispatch loggedIn', async () => {
271
+ const stateData = await getMockStorage(options.stateStorage).generateState();
272
+ worker.use(
273
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 })),
274
+ );
275
+ const callback = vi.fn();
276
+ base.subscribeToEvent('loggedIn', callback);
277
+
278
+ await base.tokenExchange({ code: 'abc123', state: stateData.id });
279
+
280
+ expect(flowState.session?.access_token).toBe('access-token');
281
+ expect(callback).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'access-token' }));
282
+ });
283
+
284
+ test('should reject when the response is missing an access_token', async () => {
285
+ const stateData = await getMockStorage(options.stateStorage).generateState();
286
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ token_type: 'Bearer', expires_in: 3600 })));
287
+
288
+ await expect(base.tokenExchange({ code: 'abc123', state: stateData.id })).rejects.toThrow(new ProtocolError('Missing access_token'));
289
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(ProtocolError));
290
+ });
291
+
292
+ test('should dedupe concurrent calls sharing the same state', async () => {
293
+ const stateData = await getMockStorage(options.stateStorage).generateState();
294
+ let requestCount = 0;
295
+ worker.use(
296
+ http.post('https://brandtegrity.io/oauth2/token', () => {
297
+ requestCount += 1;
298
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
299
+ }),
300
+ );
301
+
302
+ await Promise.all([base.tokenExchange({ code: 'abc123', state: stateData.id }), base.tokenExchange({ code: 'abc123', state: stateData.id })]);
303
+
304
+ expect(requestCount).toBe(1);
305
+ });
306
+
307
+ test('should throw an OidcError when the callback params contain an OIDC error', async () => {
308
+ await expect(base.tokenExchange({ error: 'access_denied', error_description: 'User denied access' })).rejects.toThrow(
309
+ new OidcError('access_denied', 'User denied access'),
310
+ );
311
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(OidcError));
312
+ });
313
+
314
+ test('should throw a ProtocolError when the authorization code is missing', async () => {
315
+ await expect(base.tokenExchange({ state: 'whatever' })).rejects.toThrow(new ProtocolError('Invalid or missing authorization code'));
316
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(ProtocolError));
317
+ });
318
+
319
+ test('should throw a ProtocolError when the stored state cannot be parsed', async () => {
320
+ await options.stateStorage.set('sty.corrupted-state', 'not-json');
321
+
322
+ await expect(base.tokenExchange({ code: 'abc123', state: 'corrupted-state' })).rejects.toThrow(new ProtocolError('Invalid or missing state'));
323
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(ProtocolError));
324
+ });
325
+
326
+ test('should throw an OidcError when the token endpoint responds with an OAuth error', async () => {
327
+ const stateData = await getMockStorage(options.stateStorage).generateState();
328
+ worker.use(
329
+ http.post('https://brandtegrity.io/oauth2/token', () =>
330
+ HttpResponse.json({ error: 'invalid_grant', error_description: 'Code expired' }, { status: 400 }),
331
+ ),
332
+ );
333
+
334
+ await expect(base.tokenExchange({ code: 'abc123', state: stateData.id })).rejects.toThrow(new OidcError('invalid_grant', 'Code expired'));
335
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(OidcError));
336
+ });
337
+
338
+ test('should throw an OidcError when the token endpoint response body contains an error despite a 200 status', async () => {
339
+ const stateData = await getMockStorage(options.stateStorage).generateState();
340
+ worker.use(
341
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ error: 'invalid_grant', error_description: 'Code already used' })),
342
+ );
343
+
344
+ await expect(base.tokenExchange({ code: 'abc123', state: stateData.id })).rejects.toThrow(new OidcError('invalid_grant', 'Code already used'));
345
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(OidcError));
346
+ });
347
+
348
+ test('should verify the ID token and populate claims when the response includes an id_token', async () => {
349
+ const stateData = await getMockStorage(options.stateStorage).generateState();
350
+ const idToken = buildIdToken({
351
+ iss: 'https://brandtegrity.io/',
352
+ sub: 'user-1',
353
+ aud: 'client-id',
354
+ exp: timestamp() + 3600,
355
+ iat: timestamp(),
356
+ nonce: stateData.nonce,
357
+ });
358
+ worker.use(
359
+ http.post('https://brandtegrity.io/oauth2/token', () =>
360
+ HttpResponse.json({ access_token: 'access-token', id_token: idToken, token_type: 'Bearer', expires_in: 3600 }),
361
+ ),
362
+ );
363
+
364
+ await base.tokenExchange({ code: 'abc123', state: stateData.id });
365
+
366
+ expect(flowState.session?.claims).toMatchObject({ sub: 'user-1' });
367
+ });
368
+
369
+ test('should expose a lower acr than requested via claims, letting the caller decide whether it was satisfied', async () => {
370
+ const stateData = await getMockStorage(options.stateStorage).generateState({
371
+ metadata: { acrValues: ['urn:mace:incommon:iap:silver'] },
372
+ });
373
+ const idToken = buildIdToken({
374
+ iss: 'https://brandtegrity.io/',
375
+ sub: 'user-1',
376
+ aud: 'client-id',
377
+ exp: timestamp() + 3600,
378
+ iat: timestamp(),
379
+ nonce: stateData.nonce,
380
+ acr: 'urn:mace:incommon:iap:bronze',
381
+ });
382
+ worker.use(
383
+ http.post('https://brandtegrity.io/oauth2/token', () =>
384
+ HttpResponse.json({ access_token: 'access-token', id_token: idToken, token_type: 'Bearer', expires_in: 3600 }),
385
+ ),
386
+ );
387
+
388
+ await base.tokenExchange({ code: 'abc123', state: stateData.id });
389
+
390
+ expect(flowState.session?.claims?.acr).toBe('urn:mace:incommon:iap:bronze');
391
+ });
392
+
393
+ test('should throw a ProtocolError when response body is not valid JSON', async () => {
394
+ const stateData = await getMockStorage(options.stateStorage).generateState();
395
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.text('<html>not json</html>')));
396
+
397
+ await expect(base.tokenExchange({ code: 'abc123', state: stateData.id })).rejects.toThrow(new ProtocolError('Non-JSON response from token endpoint'));
398
+ expect(options.logging?.error).toHaveBeenCalledWith('Token exchange failed', expect.any(ProtocolError));
399
+ });
400
+
401
+ describe('hostile authorization response battery', () => {
402
+ test('should reject an unknown state without ever calling the token endpoint', async () => {
403
+ const sendTokenRequestSpy = vi.spyOn(options.httpClient, 'sendTokenRequest');
404
+
405
+ await expect(base.tokenExchange({ code: 'abc123', state: 'never-issued' })).rejects.toThrow(new ProtocolError('Invalid or missing state'));
406
+
407
+ expect(sendTokenRequestSpy).not.toHaveBeenCalled();
408
+ });
409
+
410
+ test('should reject when state is absent though code is present', async () => {
411
+ await expect(base.tokenExchange({ code: 'abc123' })).rejects.toThrow(new ProtocolError('Invalid or missing state'));
412
+ });
413
+
414
+ test("should use only the code_verifier belonging to the given state, never a different journey's", async () => {
415
+ const stateA = await getMockStorage(options.stateStorage).generateState();
416
+ const stateB = await getMockStorage(options.stateStorage).generateState();
417
+ let capturedVerifier: string | null = null;
418
+ worker.use(
419
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
420
+ capturedVerifier = new URLSearchParams(await request.text()).get('code_verifier');
421
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
422
+ }),
423
+ );
424
+
425
+ await base.tokenExchange({ code: 'code-for-a', state: stateA.id });
426
+
427
+ expect(capturedVerifier).toBe(stateA.codeVerifier);
428
+ expect(capturedVerifier).not.toBe(stateB.codeVerifier);
429
+ });
430
+
431
+ test('should not re-exchange a replayed redirect using the same state twice', async () => {
432
+ const stateData = await getMockStorage(options.stateStorage).generateState();
433
+ let requestCount = 0;
434
+ worker.use(
435
+ http.post('https://brandtegrity.io/oauth2/token', () => {
436
+ requestCount += 1;
437
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
438
+ }),
439
+ );
440
+
441
+ await base.tokenExchange({ code: 'abc123', state: stateData.id });
442
+
443
+ await expect(base.tokenExchange({ code: 'abc123', state: stateData.id })).rejects.toThrow(new ProtocolError('Invalid or missing state'));
444
+ expect(requestCount).toBe(1);
445
+ });
446
+
447
+ test('should fail closed on the error when both code and error are present, regardless of state', async () => {
448
+ const sendTokenRequestSpy = vi.spyOn(options.httpClient, 'sendTokenRequest');
449
+
450
+ await expect(base.tokenExchange({ code: 'abc123', error: 'access_denied', state: 'whatever' })).rejects.toThrow(new OidcError('access_denied', null));
451
+
452
+ expect(sendTokenRequestSpy).not.toHaveBeenCalled();
453
+ });
454
+
455
+ test.each([
456
+ 'invalid_request',
457
+ 'unauthorized_client',
458
+ 'access_denied',
459
+ 'unsupported_response_type',
460
+ 'invalid_scope',
461
+ 'server_error',
462
+ 'temporarily_unavailable',
463
+ 'login_required',
464
+ 'consent_required',
465
+ 'interaction_required',
466
+ ])('should surface the "%s" authorization error verbatim', async (code) => {
467
+ await expect(base.tokenExchange({ error: code, error_description: 'a description', state: 'whatever' })).rejects.toThrow(
468
+ new OidcError(code, 'a description'),
469
+ );
470
+ });
471
+
472
+ test('should send the authorization code verbatim, without re-encoding, even 2KB long with reserved characters', async () => {
473
+ const stateData = await getMockStorage(options.stateStorage).generateState();
474
+ const hostileCode = `${'a'.repeat(2000)}+/=`;
475
+ let capturedCode: string | null = null;
476
+ worker.use(
477
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
478
+ capturedCode = new URLSearchParams(await request.text()).get('code');
479
+ return HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 });
480
+ }),
481
+ );
482
+
483
+ await base.tokenExchange({ code: hostileCode, state: stateData.id });
484
+
485
+ expect(capturedCode).toBe(hostileCode);
486
+ });
487
+
488
+ test('should accept a matching iss when the authorization server advertises RFC 9207', async () => {
489
+ vi.resetModules();
490
+
491
+ const { createBaseFlow: freshCreateBaseFlow } = await import('../../../src/flows/base');
492
+ const { getDefaultFlowState: freshGetDefaultFlowState } = await import('../../../src/utils/oidc');
493
+
494
+ worker.use(
495
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
496
+ HttpResponse.json({ ...openidConfiguration, authorization_response_iss_parameter_supported: true }),
497
+ ),
498
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 })),
499
+ );
500
+
501
+ const freshOptions = createOptions({ urlHandler: vi.fn() });
502
+ const freshFlowState = freshGetDefaultFlowState();
503
+ const freshBase = freshCreateBaseFlow(freshFlowState, freshOptions);
504
+ const stateData = await getMockStorage(freshOptions.stateStorage).generateState();
505
+
506
+ await freshBase.tokenExchange({ code: 'abc123', state: stateData.id, iss: 'https://brandtegrity.io/' });
507
+
508
+ expect(freshFlowState.session?.access_token).toBe('access-token');
509
+ });
510
+ });
511
+ });
512
+
513
+ describe('handleCallback', () => {
514
+ test('should parse the callback url and exchange the code for tokens', async () => {
515
+ const stateData = await getMockStorage(options.stateStorage).generateState();
516
+ worker.use(
517
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ access_token: 'access-token', token_type: 'Bearer', expires_in: 3600 })),
518
+ );
519
+
520
+ await base.handleCallback(`https://brandtegrity.io/callback?code=abc123&state=${stateData.id}`);
521
+
522
+ expect(flowState.session?.access_token).toBe('access-token');
523
+ });
524
+ });
525
+
526
+ describe('refresh', () => {
527
+ test('should refresh the session and dispatch tokenRefreshed', async () => {
528
+ flowState.initialized = true;
529
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
530
+ worker.use(
531
+ http.post('https://brandtegrity.io/oauth2/token', () =>
532
+ HttpResponse.json({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', token_type: 'Bearer', expires_in: 3600 }),
533
+ ),
534
+ );
535
+ const callback = vi.fn();
536
+ base.subscribeToEvent('tokenRefreshed', callback);
537
+
538
+ await base.refresh();
539
+
540
+ expect(flowState.session?.access_token).toBe('new-access-token');
541
+ expect(callback).toHaveBeenCalledWith(expect.objectContaining({ accessToken: 'new-access-token' }));
542
+ });
543
+
544
+ test('should be a no-op when there is no refresh token', async () => {
545
+ flowState.initialized = true;
546
+
547
+ await expect(base.refresh()).resolves.toBeUndefined();
548
+ });
549
+
550
+ test('should clean up the session and dispatch tokenRefreshFailed on failure', async () => {
551
+ flowState.initialized = true;
552
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
553
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => new HttpResponse(null, { status: 400 })));
554
+ const callback = vi.fn();
555
+ base.subscribeToEvent('tokenRefreshFailed', callback);
556
+
557
+ await expect(base.refresh()).rejects.toThrow();
558
+
559
+ expect(flowState.session).toBeNull();
560
+ expect(callback).toHaveBeenCalledTimes(1);
561
+ });
562
+
563
+ test('should preserve the session and not clean it up when the refresh fails with a recoverable ServerError', async () => {
564
+ flowState.initialized = true;
565
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
566
+ flowState.session = session;
567
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => new HttpResponse(null, { status: 500 })));
568
+ const callback = vi.fn();
569
+ base.subscribeToEvent('tokenRefreshFailed', callback);
570
+
571
+ await expect(base.refresh()).rejects.toThrow(ServerError);
572
+
573
+ expect(flowState.session).toBe(session);
574
+ expect(callback).toHaveBeenCalledTimes(1);
575
+ });
576
+
577
+ test('should preserve the session and not clean it up when the refresh fails with a recoverable NetworkError', async () => {
578
+ flowState.initialized = true;
579
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
580
+ flowState.session = session;
581
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.error()));
582
+
583
+ await expect(base.refresh()).rejects.toThrow(NetworkError);
584
+
585
+ expect(flowState.session).toBe(session);
586
+ });
587
+
588
+ test('should dedupe concurrent refresh calls', async () => {
589
+ flowState.initialized = true;
590
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
591
+ let requestCount = 0;
592
+ worker.use(
593
+ http.post('https://brandtegrity.io/oauth2/token', () => {
594
+ requestCount += 1;
595
+ return HttpResponse.json({ access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600 });
596
+ }),
597
+ );
598
+
599
+ await Promise.all([base.refresh(), base.refresh()]);
600
+
601
+ expect(requestCount).toBe(1);
602
+ });
603
+
604
+ test('should clean up the session and throw an OidcError when the refresh response body contains an error', async () => {
605
+ flowState.initialized = true;
606
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
607
+ worker.use(
608
+ http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.json({ error: 'invalid_grant', error_description: 'Refresh token revoked' })),
609
+ );
610
+
611
+ await expect(base.refresh()).rejects.toThrow(new OidcError('invalid_grant', 'Refresh token revoked'));
612
+
613
+ expect(flowState.session).toBeNull();
614
+ });
615
+
616
+ test('should throw a ProtocolError when response body is not valid JSON', async () => {
617
+ flowState.initialized = true;
618
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
619
+ worker.use(http.post('https://brandtegrity.io/oauth2/token', () => HttpResponse.text('<html>not json</html>')));
620
+
621
+ await expect(base.refresh()).rejects.toThrow(new ProtocolError('Non-JSON response from token endpoint'));
622
+ });
623
+
624
+ test('should verify the ID token and populate claims when the refreshed session includes an id_token', async () => {
625
+ flowState.initialized = true;
626
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
627
+ flowState.session = session;
628
+ const idToken = buildIdToken({
629
+ iss: 'https://brandtegrity.io/',
630
+ sub: session.claims!.sub,
631
+ aud: 'client-id',
632
+ exp: timestamp() + 3600,
633
+ iat: timestamp(),
634
+ });
635
+ worker.use(
636
+ http.post('https://brandtegrity.io/oauth2/token', () =>
637
+ HttpResponse.json({ access_token: 'new-access-token', id_token: idToken, token_type: 'Bearer', expires_in: 3600 }),
638
+ ),
639
+ );
640
+
641
+ await base.refresh();
642
+
643
+ expect(flowState.session?.claims).toMatchObject({ sub: session.claims!.sub });
644
+ });
645
+
646
+ test("rejects (and ends the session, per the SDK's fail-closed refresh policy) when the refreshed id_token has a different sub", async () => {
647
+ flowState.initialized = true;
648
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
649
+ const idToken = buildIdToken({
650
+ iss: 'https://brandtegrity.io/',
651
+ sub: 'a-completely-different-user',
652
+ aud: 'client-id',
653
+ exp: timestamp() + 3600,
654
+ iat: timestamp(),
655
+ });
656
+ worker.use(
657
+ http.post('https://brandtegrity.io/oauth2/token', () =>
658
+ HttpResponse.json({ access_token: 'new-access-token', id_token: idToken, token_type: 'Bearer', expires_in: 3600 }),
659
+ ),
660
+ );
661
+
662
+ await expect(base.refresh()).rejects.toThrow(new ProtocolError('Invalid sub'));
663
+
664
+ expect(flowState.session).toBeNull();
665
+ });
666
+
667
+ test('the old refresh token is gone from raw storage once rotation completes, never reusable', async () => {
668
+ flowState.initialized = true;
669
+ flowState.session = getMockStorage(options.storage).generateSession({ refresh_token: 'old-refresh-token' }, null);
670
+ worker.use(
671
+ http.post('https://brandtegrity.io/oauth2/token', () =>
672
+ HttpResponse.json({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', token_type: 'Bearer', expires_in: 3600 }),
673
+ ),
674
+ );
675
+
676
+ await base.refresh();
677
+
678
+ expect(flowState.session?.refresh_token).toBe('new-refresh-token');
679
+ expect(getMockStorage(options.storage).values().join('\n')).not.toContain('old-refresh-token');
680
+ });
681
+
682
+ test('never requests a scope beyond the original grant', async () => {
683
+ flowState.initialized = true;
684
+ flowState.session = getMockStorage(options.storage).generateSession({ scope: 'openid profile' }, null);
685
+ let capturedBody: URLSearchParams | undefined;
686
+ worker.use(
687
+ http.post('https://brandtegrity.io/oauth2/token', async ({ request }) => {
688
+ capturedBody = new URLSearchParams(await request.text());
689
+
690
+ return HttpResponse.json({ access_token: 'new-access-token', token_type: 'Bearer', expires_in: 3600 });
691
+ }),
692
+ );
693
+
694
+ await base.refresh();
695
+
696
+ expect(capturedBody?.has('scope')).toBe(false);
697
+ });
698
+
699
+ test('an already-expired loaded session is not deleted merely for being expired — only a failed refresh deletes it', async () => {
700
+ getMockStorage(options.storage).generateSession({ expires_at: 0 });
701
+
702
+ await base.init();
703
+
704
+ expect(flowState.session).not.toBeNull();
705
+ expect(getMockStorage(options.storage).getSession()).not.toBeUndefined();
706
+ });
707
+ });
708
+
709
+ describe('revoke', () => {
710
+ test('should clean up the session before revoking and dispatch tokenRevoked', async () => {
711
+ flowState.initialized = true;
712
+ const session = getMockStorage(options.storage).generateSession(undefined, null);
713
+ flowState.session = session;
714
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => HttpResponse.json({})));
715
+ const revokedCallback = vi.fn();
716
+ const clearedCallback = vi.fn();
717
+ base.subscribeToEvent('tokenRevoked', revokedCallback);
718
+ base.subscribeToEvent('sessionCleared', clearedCallback);
719
+
720
+ await base.revoke();
721
+
722
+ expect(flowState.session).toBeNull();
723
+ expect(revokedCallback).toHaveBeenCalledWith({ token: session.refresh_token, tokenTypeHint: 'refresh_token' });
724
+ expect(clearedCallback).toHaveBeenCalledTimes(1);
725
+ });
726
+
727
+ test('should revoke the access token when there is no refresh token', async () => {
728
+ flowState.initialized = true;
729
+ const session = getMockStorage(options.storage).generateSession({ refresh_token: null }, null);
730
+ flowState.session = session;
731
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => HttpResponse.json({})));
732
+ const revokedCallback = vi.fn();
733
+ base.subscribeToEvent('tokenRevoked', revokedCallback);
734
+
735
+ await base.revoke();
736
+
737
+ expect(flowState.session).toBeNull();
738
+ expect(revokedCallback).toHaveBeenCalledWith({ token: session.access_token, tokenTypeHint: 'access_token' });
739
+ });
740
+
741
+ test('should be a no-op when there is no session', async () => {
742
+ flowState.initialized = true;
743
+
744
+ await expect(base.revoke()).resolves.toBeUndefined();
745
+ });
746
+
747
+ test('should dispatch tokenRevokeFailed and rethrow on failure', async () => {
748
+ flowState.initialized = true;
749
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
750
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => new HttpResponse(null, { status: 400 })));
751
+ const callback = vi.fn();
752
+ base.subscribeToEvent('tokenRevokeFailed', callback);
753
+
754
+ await expect(base.revoke()).rejects.toThrow();
755
+
756
+ expect(callback).toHaveBeenCalledTimes(1);
757
+ });
758
+
759
+ test('should treat a 200 response as success even for an already-invalid token', async () => {
760
+ flowState.initialized = true;
761
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
762
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => HttpResponse.json({})));
763
+ const revokedCallback = vi.fn();
764
+ const failedCallback = vi.fn();
765
+ base.subscribeToEvent('tokenRevoked', revokedCallback);
766
+ base.subscribeToEvent('tokenRevokeFailed', failedCallback);
767
+
768
+ await expect(base.revoke()).resolves.toBeUndefined();
769
+
770
+ expect(revokedCallback).toHaveBeenCalledTimes(1);
771
+ expect(failedCallback).not.toHaveBeenCalled();
772
+ });
773
+
774
+ test('should reject with a recoverable ServerError on a 503, but the local session is already torn down', async () => {
775
+ flowState.initialized = true;
776
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
777
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => new HttpResponse(null, { status: 503 })));
778
+
779
+ await expect(base.revoke()).rejects.toThrow(ServerError);
780
+
781
+ expect(flowState.session).toBeNull();
782
+ expect(getMockStorage(options.storage).getSession()).toBeUndefined();
783
+ });
784
+
785
+ test('should dispatch tokenRevokeFailed with the access token when there is no refresh token', async () => {
786
+ flowState.initialized = true;
787
+ const session = getMockStorage(options.storage).generateSession({ refresh_token: null }, null);
788
+ flowState.session = session;
789
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => new HttpResponse(null, { status: 400 })));
790
+ const callback = vi.fn();
791
+ base.subscribeToEvent('tokenRevokeFailed', callback);
792
+
793
+ await expect(base.revoke()).rejects.toThrow();
794
+
795
+ expect(callback).toHaveBeenCalledWith({ token: session.access_token, tokenTypeHint: 'access_token' });
796
+ });
797
+
798
+ test('should dedupe concurrent revoke calls', async () => {
799
+ flowState.initialized = true;
800
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
801
+ let requestCount = 0;
802
+ worker.use(
803
+ http.post('https://brandtegrity.io/oauth2/revoke', () => {
804
+ requestCount += 1;
805
+ return HttpResponse.json({});
806
+ }),
807
+ );
808
+
809
+ await Promise.all([base.revoke(), base.revoke()]);
810
+
811
+ expect(requestCount).toBe(1);
812
+ });
813
+
814
+ test('should skip the revocation request and still dispatch tokenRevoked when there is no revocation_endpoint', async () => {
815
+ vi.resetModules();
816
+
817
+ const { createBaseFlow: freshCreateBaseFlow } = await import('../../../src/flows/base');
818
+ const { getDefaultFlowState: freshGetDefaultFlowState } = await import('../../../src/utils/oidc');
819
+
820
+ worker.use(
821
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
822
+ HttpResponse.json({ ...openidConfiguration, revocation_endpoint: undefined }),
823
+ ),
824
+ );
825
+
826
+ const freshOptions = createOptions({ urlHandler: vi.fn() });
827
+ const freshFlowState = freshGetDefaultFlowState();
828
+ const freshBase = freshCreateBaseFlow(freshFlowState, freshOptions);
829
+ freshFlowState.initialized = true;
830
+ const session = getMockStorage(freshOptions.storage).generateSession(undefined, null);
831
+ freshFlowState.session = session;
832
+ const callback = vi.fn();
833
+ freshBase.subscribeToEvent('tokenRevoked', callback);
834
+
835
+ await freshBase.revoke();
836
+
837
+ expect(freshFlowState.session).toBeNull();
838
+ expect(callback).toHaveBeenCalledWith({ token: session.refresh_token, tokenTypeHint: 'refresh_token' });
839
+ expect(freshOptions.logging?.debug).toHaveBeenCalledWith('Revocation skipped: no revocation_endpoint available');
840
+ });
841
+ });
842
+
843
+ describe('logout', () => {
844
+ test('should clean up the session and redirect to the end-session endpoint', async () => {
845
+ flowState.initialized = true;
846
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'id-token' }, null);
847
+ const callback = vi.fn();
848
+ base.subscribeToEvent('logoutInitiated', callback);
849
+
850
+ await base.logout({ postLogoutRedirectUri: 'https://brandtegrity.io/bye' });
851
+
852
+ expect(flowState.session).toBeNull();
853
+ expect(callback).toHaveBeenCalledWith(expect.objectContaining({ idToken: 'id-token' }));
854
+
855
+ const [url, params] = vi.mocked(options.urlHandler).mock.calls[0] as [URL, Record<string, unknown>];
856
+ expect(`${url.origin}${url.pathname}`).toBe('https://brandtegrity.io/oauth2/sessions/logout');
857
+ expect(url.searchParams.get('id_token_hint')).toBe('id-token');
858
+ expect(url.searchParams.get('post_logout_redirect_uri')).toBe('https://brandtegrity.io/bye');
859
+ expect(params).toEqual({ postLogoutRedirectUri: 'https://brandtegrity.io/bye' });
860
+ });
861
+
862
+ test('should be a no-op redirect when there is no id token', async () => {
863
+ flowState.initialized = true;
864
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
865
+
866
+ await base.logout();
867
+
868
+ expect(flowState.session).toBeNull();
869
+ expect(options.urlHandler).not.toHaveBeenCalled();
870
+ });
871
+
872
+ test('should log and swallow errors when redirecting to the end-session endpoint fails', async () => {
873
+ flowState.initialized = true;
874
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'id-token' }, null);
875
+ const error = new Error('redirect failed');
876
+ vi.mocked(options.urlHandler).mockRejectedValueOnce(error);
877
+
878
+ await expect(base.logout()).resolves.toBeUndefined();
879
+
880
+ expect(flowState.session).toBeNull();
881
+ expect(options.logging?.error).toHaveBeenCalledWith('Unable to redirect to the end-session endpoint', error);
882
+ });
883
+
884
+ test('should dedupe concurrent logout calls', async () => {
885
+ flowState.initialized = true;
886
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'id-token' }, null);
887
+
888
+ await Promise.all([base.logout(), base.logout()]);
889
+
890
+ expect(options.urlHandler).toHaveBeenCalledTimes(1);
891
+ });
892
+
893
+ test('should be a clean no-op when there was never a session at all', async () => {
894
+ flowState.initialized = true;
895
+
896
+ await expect(base.logout()).resolves.toBeUndefined();
897
+
898
+ expect(flowState.session).toBeNull();
899
+ expect(options.urlHandler).not.toHaveBeenCalled();
900
+ });
901
+
902
+ test('should complete local teardown and report the failure when end_session_endpoint is absent', async () => {
903
+ vi.resetModules();
904
+
905
+ const { createBaseFlow: freshCreateBaseFlow } = await import('../../../src/flows/base');
906
+ const { getDefaultFlowState: freshGetDefaultFlowState } = await import('../../../src/utils/oidc');
907
+
908
+ worker.use(
909
+ http.get('https://brandtegrity.io/.well-known/openid-configuration', () =>
910
+ HttpResponse.json({ ...openidConfiguration, end_session_endpoint: undefined }),
911
+ ),
912
+ );
913
+
914
+ const freshOptions = createOptions({ urlHandler: vi.fn() });
915
+ const freshFlowState = freshGetDefaultFlowState();
916
+ const freshBase = freshCreateBaseFlow(freshFlowState, freshOptions);
917
+ freshFlowState.initialized = true;
918
+ freshFlowState.session = getMockStorage(freshOptions.storage).generateSession({ id_token: 'id-token' }, null);
919
+
920
+ await expect(freshBase.logout()).resolves.toBeUndefined();
921
+
922
+ expect(freshFlowState.session).toBeNull();
923
+ expect(freshOptions.urlHandler).not.toHaveBeenCalled();
924
+ expect(freshOptions.logging?.error).toHaveBeenCalledWith('Unable to redirect to the end-session endpoint', expect.any(Error));
925
+ });
926
+
927
+ test('should leave no tokens or session data in raw storage after logout', async () => {
928
+ flowState.initialized = true;
929
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'id-token' }, null);
930
+
931
+ await base.logout();
932
+
933
+ expect(getMockStorage(options.storage).getSession()).toBeUndefined();
934
+ expect(getMockStorage(options.storage).values()).not.toEqual(expect.arrayContaining([expect.stringContaining('id-token')]));
935
+ });
936
+ });
937
+
938
+ describe('mutual exclusion between refresh and revoke/logout', () => {
939
+ test('refresh should wait for an in-flight revoke and become a no-op once the session is cleared', async () => {
940
+ flowState.initialized = true;
941
+ flowState.session = getMockStorage(options.storage).generateSession(undefined, null);
942
+ worker.use(http.post('https://brandtegrity.io/oauth2/revoke', () => HttpResponse.json({})));
943
+
944
+ const revokePromise = base.revoke();
945
+ const refreshPromise = base.refresh();
946
+
947
+ await revokePromise;
948
+ await expect(refreshPromise).resolves.toBeUndefined();
949
+
950
+ expect(flowState.session).toBeNull();
951
+ });
952
+
953
+ test('refresh should wait for an in-flight logout and become a no-op once the session is cleared', async () => {
954
+ flowState.initialized = true;
955
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'id-token' }, null);
956
+
957
+ const logoutPromise = base.logout();
958
+ const refreshPromise = base.refresh();
959
+
960
+ await logoutPromise;
961
+ await expect(refreshPromise).resolves.toBeUndefined();
962
+
963
+ expect(flowState.session).toBeNull();
964
+ });
965
+
966
+ test('revoke should wait for an in-flight refresh and revoke the freshly refreshed token instead of the stale one', async () => {
967
+ flowState.initialized = true;
968
+ flowState.session = getMockStorage(options.storage).generateSession({ refresh_token: 'old-refresh-token' }, null);
969
+
970
+ let resolveTokenRequest: ((response: Response) => void) | undefined;
971
+ worker.use(
972
+ http.post(
973
+ 'https://brandtegrity.io/oauth2/token',
974
+ () =>
975
+ new Promise<Response>((resolve) => {
976
+ resolveTokenRequest = resolve;
977
+ }),
978
+ ),
979
+ );
980
+
981
+ let revokedToken: string | null = null;
982
+ worker.use(
983
+ http.post('https://brandtegrity.io/oauth2/revoke', async ({ request }) => {
984
+ revokedToken = new URLSearchParams(await request.text()).get('token');
985
+ return HttpResponse.json({});
986
+ }),
987
+ );
988
+
989
+ const refreshPromise = base.refresh();
990
+ const revokePromise = base.revoke();
991
+
992
+ await vi.waitFor(() => expect(resolveTokenRequest).toBeDefined());
993
+ resolveTokenRequest!(HttpResponse.json({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', token_type: 'Bearer', expires_in: 3600 }));
994
+
995
+ await refreshPromise;
996
+ await revokePromise;
997
+
998
+ expect(flowState.session).toBeNull();
999
+ expect(revokedToken).toBe('new-refresh-token');
1000
+ });
1001
+
1002
+ test('logout should wait for an in-flight refresh and reflect the post-refresh session state', async () => {
1003
+ flowState.initialized = true;
1004
+ flowState.session = getMockStorage(options.storage).generateSession({ id_token: 'old-id-token' }, null);
1005
+
1006
+ let resolveTokenRequest: ((response: Response) => void) | undefined;
1007
+ worker.use(
1008
+ http.post(
1009
+ 'https://brandtegrity.io/oauth2/token',
1010
+ () =>
1011
+ new Promise<Response>((resolve) => {
1012
+ resolveTokenRequest = resolve;
1013
+ }),
1014
+ ),
1015
+ );
1016
+
1017
+ const refreshPromise = base.refresh();
1018
+ const logoutPromise = base.logout();
1019
+
1020
+ await vi.waitFor(() => expect(resolveTokenRequest).toBeDefined());
1021
+ resolveTokenRequest!(HttpResponse.json({ access_token: 'new-access-token', refresh_token: 'new-refresh-token', token_type: 'Bearer', expires_in: 3600 }));
1022
+
1023
+ await refreshPromise;
1024
+ await logoutPromise;
1025
+
1026
+ expect(flowState.session).toBeNull();
1027
+ expect(options.urlHandler).not.toHaveBeenCalled();
1028
+ });
1029
+ });
1030
+ });