@tidecloak/js 0.14.33 → 0.14.34

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 (44) hide show
  1. package/dist/cjs/src/IAMService.js +13 -3
  2. package/dist/cjs/src/IAMService.js.map +1 -1
  3. package/dist/cjs/src/dpopRoute.js +30 -0
  4. package/dist/cjs/src/dpopRoute.js.map +1 -0
  5. package/dist/cjs/src/dpopServer.js +77 -0
  6. package/dist/cjs/src/dpopServer.js.map +1 -0
  7. package/dist/cjs/src/generated/dpopAuthAsset.js +7 -0
  8. package/dist/cjs/src/generated/dpopAuthAsset.js.map +1 -0
  9. package/dist/cjs/src/utils/dpopAuthPage.js +69 -0
  10. package/dist/cjs/src/utils/dpopAuthPage.js.map +1 -0
  11. package/dist/cjs/src/utils/index.js +3 -1
  12. package/dist/cjs/src/utils/index.js.map +1 -1
  13. package/dist/cjs/src/vite.js +43 -0
  14. package/dist/cjs/src/vite.js.map +1 -0
  15. package/dist/esm/src/IAMService.js +14 -4
  16. package/dist/esm/src/IAMService.js.map +1 -1
  17. package/dist/esm/src/dpopRoute.js +27 -0
  18. package/dist/esm/src/dpopRoute.js.map +1 -0
  19. package/dist/esm/src/dpopServer.js +68 -0
  20. package/dist/esm/src/dpopServer.js.map +1 -0
  21. package/dist/esm/src/generated/dpopAuthAsset.js +4 -0
  22. package/dist/esm/src/generated/dpopAuthAsset.js.map +1 -0
  23. package/dist/esm/src/utils/dpopAuthPage.js +63 -0
  24. package/dist/esm/src/utils/dpopAuthPage.js.map +1 -0
  25. package/dist/esm/src/utils/index.js +1 -0
  26. package/dist/esm/src/utils/index.js.map +1 -1
  27. package/dist/esm/src/vite.js +40 -0
  28. package/dist/esm/src/vite.js.map +1 -0
  29. package/dist/tide_dpop_auth.html +238 -0
  30. package/dist/types/dpopRoute.d.ts +11 -0
  31. package/dist/types/dpopServer.d.ts +35 -0
  32. package/dist/types/generated/dpopAuthAsset.d.ts +2 -0
  33. package/dist/types/src/dpopRoute.d.ts +11 -0
  34. package/dist/types/src/dpopServer.d.ts +35 -0
  35. package/dist/types/src/generated/dpopAuthAsset.d.ts +2 -0
  36. package/dist/types/src/utils/dpopAuthPage.d.ts +22 -0
  37. package/dist/types/src/utils/index.d.ts +1 -0
  38. package/dist/types/src/vite.d.ts +13 -0
  39. package/dist/types/utils/dpopAuthPage.d.ts +22 -0
  40. package/dist/types/utils/index.d.ts +1 -0
  41. package/dist/types/vite.d.ts +13 -0
  42. package/lib/README.md +19 -17
  43. package/package.json +21 -4
  44. package/tide_dpop_auth.html +238 -0
@@ -0,0 +1,238 @@
1
+ <html>
2
+
3
+ <head>
4
+ <style>
5
+ body {
6
+ margin: 0;
7
+ display: flex;
8
+ align-items: center;
9
+ justify-content: center;
10
+ height: 100vh;
11
+ background: #f6f6f7;
12
+ font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
13
+ }
14
+
15
+ .status {
16
+ text-align: center;
17
+ opacity: 0;
18
+ }
19
+
20
+ .status.visible {
21
+ opacity: 1;
22
+ }
23
+
24
+ .checkmark-circle {
25
+ width: 72px;
26
+ height: 72px;
27
+ margin: 0 auto 16px;
28
+ }
29
+
30
+ .checkmark-circle circle {
31
+ fill: none;
32
+ stroke: #039855;
33
+ stroke-width: 3;
34
+ stroke-dasharray: 201;
35
+ stroke-dashoffset: 201;
36
+ }
37
+
38
+ .checkmark-circle path {
39
+ fill: none;
40
+ stroke: #039855;
41
+ stroke-width: 3;
42
+ stroke-linecap: round;
43
+ stroke-linejoin: round;
44
+ stroke-dasharray: 50;
45
+ stroke-dashoffset: 50;
46
+ }
47
+
48
+ .status.visible .checkmark-circle circle {
49
+ animation: circle-draw 0.5s ease forwards;
50
+ }
51
+
52
+ .status.visible .checkmark-circle path {
53
+ animation: check-draw 0.3s 0.4s ease forwards;
54
+ }
55
+
56
+ .status.visible .status-text {
57
+ animation: fade-in 0.3s 0.6s ease forwards;
58
+ }
59
+
60
+ .status-text {
61
+ font-size: 15px;
62
+ color: #464647;
63
+ margin: 0;
64
+ opacity: 0;
65
+ }
66
+
67
+ @keyframes circle-draw {
68
+ to {
69
+ stroke-dashoffset: 0;
70
+ }
71
+ }
72
+
73
+ @keyframes check-draw {
74
+ to {
75
+ stroke-dashoffset: 0;
76
+ }
77
+ }
78
+
79
+ @keyframes fade-in {
80
+ to {
81
+ opacity: 1;
82
+ }
83
+ }
84
+ </style>
85
+ </head>
86
+
87
+ <body>
88
+ <div class="status" id="status">
89
+ <svg class="checkmark-circle" viewBox="0 0 72 72">
90
+ <circle cx="36" cy="36" r="32" />
91
+ <path d="M22 36 l10 10 l18 -20" />
92
+ </svg>
93
+ <p class="status-text">Session verified</p>
94
+ </div>
95
+ <script>
96
+ const thisVersion = 1;
97
+
98
+ const hexToStr = (hex) => {
99
+ if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) throw Error("Invalid hex string");
100
+ const bytes = new Uint8Array(hex.length / 2);
101
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
102
+ return new TextDecoder().decode(bytes);
103
+ };
104
+
105
+ const createDbName = async (issuer, clientId) => {
106
+ const encoder = new TextEncoder();
107
+ const issuerHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(issuer))).slice(0, 8))
108
+ .map(b => b.toString(16).padStart(2, '0')).join('');
109
+ const clientHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(clientId))).slice(0, 8))
110
+ .map(b => b.toString(16).padStart(2, '0')).join('');
111
+ return `dpop:${issuerHash}:${clientHash}`;
112
+ };
113
+
114
+ // Determine target window: popup has window.opener, iframe has window.parent
115
+ const isPopup = !!window.opener;
116
+ const targetWindow = window.opener || window.parent;
117
+
118
+ if (!targetWindow || targetWindow === window) {
119
+ document.body.textContent = "Error: No parent or opener window found.";
120
+ throw Error("No target window available for postMessage");
121
+ }
122
+
123
+ const urlParams = new URL(window.location.href).searchParams;
124
+ const enclaveVersion = urlParams.get("version");
125
+ if (!enclaveVersion) throw Error("No enclave version provided in url");
126
+ let openerOrigin = urlParams.get("openerOrigin");
127
+ if (!openerOrigin) throw Error("No opener origin provided in url");
128
+ openerOrigin = new URL(decodeURIComponent(openerOrigin)).origin;
129
+
130
+ const showSuccess = () => {
131
+ document.getElementById('status').classList.add('visible');
132
+ };
133
+
134
+ const version1Flow = async () => {
135
+ const paths = new URL(window.location.href).pathname.split("/");
136
+ const iss = hexToStr(paths[paths.indexOf("iss") + 1]);
137
+ const aud = hexToStr(paths[paths.indexOf("aud") + 1]);
138
+ const dbname = await createDbName(iss, aud);
139
+ let dpopKey;
140
+ try {
141
+ // Chrome 125+: requestStorageAccess({ indexedDB: true }) returns a handle
142
+ // whose .indexedDB factory points directly at the unpartitioned bucket.
143
+ // If permission was already granted (within 30 days) this resolves
144
+ // automatically with no prompt.
145
+ //
146
+ // In popup mode we have first-party context so we can access IndexedDB directly.
147
+ let idbFactory;
148
+ if (isPopup) {
149
+ idbFactory = window.indexedDB;
150
+ } else {
151
+ const handle = await document.requestStorageAccess({ indexedDB: true });
152
+ idbFactory = handle.indexedDB;
153
+ }
154
+
155
+ const db = await new Promise((resolve, reject) => {
156
+ const req = idbFactory.open(dbname);
157
+ req.onsuccess = () => resolve(req.result);
158
+ req.onerror = () => reject(req.error);
159
+ });
160
+ dpopKey = await new Promise((resolve, reject) => {
161
+ const tx = db.transaction("main", "readonly");
162
+ const store = tx.objectStore("main");
163
+ const req = store.get("dpopState");
164
+ req.onsuccess = () => resolve(req.result);
165
+ req.onerror = () => reject(req.error);
166
+ });
167
+ db.close();
168
+ if (!dpopKey || !dpopKey.keys) throw Error("No DPoP key found in IndexedDB");
169
+ } catch (ex) {
170
+ const isStorageBlocked =
171
+ ex?.name === "NotAllowedError" ||
172
+ (ex instanceof DOMException && (
173
+ ex.name === "SecurityError" ||
174
+ ex.name === "UnknownError" ||
175
+ ex.message?.includes("not a known object store name")
176
+ ));
177
+ const detail = isStorageBlocked
178
+ ? "Browser is blocking third-party storage. Please allow storage access or disable strict tracking protection."
179
+ : "Failed to access DPoP key from IndexedDB.";
180
+ targetWindow.postMessage({ type: "error", message: "db failed", detail }, openerOrigin);
181
+ document.body.textContent = detail;
182
+ throw ex;
183
+ }
184
+
185
+ const challenge = () => new Promise((resolve) => {
186
+ const handler = (event) => {
187
+ if (event.origin !== openerOrigin) return;
188
+ if (event.data.type === "challenge") {
189
+ resolve(event.data.challenge);
190
+ window.removeEventListener("message", handler);
191
+ }
192
+ };
193
+ window.addEventListener("message", handler, false);
194
+ });
195
+
196
+ const pre_challenge = challenge();
197
+
198
+ targetWindow.postMessage({ type: "pageLoaded", message: "yay" }, openerOrigin);
199
+
200
+ const challengeResp = await pre_challenge;
201
+ if (typeof challengeResp !== "string" || challengeResp?.length != 64) throw Error("Unexpect challenge format");
202
+
203
+ const te = new TextEncoder();
204
+ const signatureInput = te.encode("dpop-auth-challenge:" + challengeResp);
205
+ const algName = dpopKey.keys.privateKey.algorithm.name;
206
+ const signParams = algName === "ECDSA"
207
+ ? { name: "ECDSA", hash: { "P-256": "SHA-256", "P-384": "SHA-384", "P-521": "SHA-512" }[dpopKey.keys.privateKey.algorithm.namedCurve] }
208
+ : { name: algName };
209
+ const signature = await crypto.subtle.sign(signParams, dpopKey.keys.privateKey, signatureInput);
210
+
211
+ const publicJwk = await crypto.subtle.exportKey("jwk", dpopKey.keys.publicKey);
212
+
213
+ targetWindow.postMessage({
214
+ type: "challenge response",
215
+ message: {
216
+ dpop_public: JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y }),
217
+ signature: btoa(String.fromCharCode(...new Uint8Array(signature)))
218
+ }
219
+ }, openerOrigin);
220
+
221
+ showSuccess();
222
+ };
223
+
224
+ const versionFlows = new Map([[1, version1Flow]]);
225
+ const latestFlow = version1Flow;
226
+
227
+ (async () => {
228
+ if (Number(enclaveVersion) >= thisVersion) await latestFlow();
229
+ else {
230
+ const flow = versionFlows.get(Number(enclaveVersion));
231
+ if (!flow) throw Error("Cannot find supported enclave version: " + enclaveVersion);
232
+ await flow();
233
+ }
234
+ })();
235
+ </script>
236
+ </body>
237
+
238
+ </html>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @param {Object} [options]
3
+ * @param {Object} [options.config] TideCloak adapter config (tidecloak.json). Used to
4
+ * reject requests for another realm or client. Omitted or empty, it serves any.
5
+ */
6
+ export declare function createDpopRoute({ config }?: {
7
+ config?: Object;
8
+ }): {
9
+ GET: (request: any) => Response;
10
+ HEAD: (request: any) => Response;
11
+ };
@@ -0,0 +1,35 @@
1
+ import { DPOP_AUTH_HTML, DPOP_AUTH_CSP } from './generated/dpopAuthAsset.js';
2
+ export { DPOP_AUTH_HTML, DPOP_AUTH_CSP };
3
+ export declare function dpopAuthHeaders(): {
4
+ 'Content-Type': string;
5
+ 'Content-Security-Policy': string;
6
+ 'Allow-CSP-From': string;
7
+ 'Cache-Control': string;
8
+ };
9
+ export declare function hexToUtf8(hex: any): string | null;
10
+ export declare function dpopAuthPathFor(issuer: any, clientId: any): string;
11
+ /**
12
+ * Issuer and client id to accept, from a TideCloak adapter config.
13
+ * Either is undefined when the config does not carry it (e.g. the `{}` placeholder).
14
+ */
15
+ export declare function expectationsFromConfig(config?: {}): {
16
+ issuer: string | undefined;
17
+ client: any;
18
+ };
19
+ /**
20
+ * The response for a request path, as `{ status, headers, body }`, or null when
21
+ * the path is not the DPoP page. Omitted expectations are not checked.
22
+ */
23
+ export declare function resolveDpopAuthRequest({ pathname, expectedIssuer, expectedClient, method }: {
24
+ expectedClient: any;
25
+ expectedIssuer: any;
26
+ method?: string | undefined;
27
+ pathname: any;
28
+ }): {
29
+ status: any;
30
+ headers: {
31
+ 'Content-Type': string;
32
+ 'Cache-Control': string;
33
+ };
34
+ body: any;
35
+ } | null;
@@ -0,0 +1,2 @@
1
+ export declare const DPOP_AUTH_HTML = "<html>\n\n<head>\n <style>\n body {\n margin: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100vh;\n background: #f6f6f7;\n font-family: -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n\n .status {\n text-align: center;\n opacity: 0;\n }\n\n .status.visible {\n opacity: 1;\n }\n\n .checkmark-circle {\n width: 72px;\n height: 72px;\n margin: 0 auto 16px;\n }\n\n .checkmark-circle circle {\n fill: none;\n stroke: #039855;\n stroke-width: 3;\n stroke-dasharray: 201;\n stroke-dashoffset: 201;\n }\n\n .checkmark-circle path {\n fill: none;\n stroke: #039855;\n stroke-width: 3;\n stroke-linecap: round;\n stroke-linejoin: round;\n stroke-dasharray: 50;\n stroke-dashoffset: 50;\n }\n\n .status.visible .checkmark-circle circle {\n animation: circle-draw 0.5s ease forwards;\n }\n\n .status.visible .checkmark-circle path {\n animation: check-draw 0.3s 0.4s ease forwards;\n }\n\n .status.visible .status-text {\n animation: fade-in 0.3s 0.6s ease forwards;\n }\n\n .status-text {\n font-size: 15px;\n color: #464647;\n margin: 0;\n opacity: 0;\n }\n\n @keyframes circle-draw {\n to {\n stroke-dashoffset: 0;\n }\n }\n\n @keyframes check-draw {\n to {\n stroke-dashoffset: 0;\n }\n }\n\n @keyframes fade-in {\n to {\n opacity: 1;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"status\" id=\"status\">\n <svg class=\"checkmark-circle\" viewBox=\"0 0 72 72\">\n <circle cx=\"36\" cy=\"36\" r=\"32\" />\n <path d=\"M22 36 l10 10 l18 -20\" />\n </svg>\n <p class=\"status-text\">Session verified</p>\n </div>\n <script>\n const thisVersion = 1;\n\n const hexToStr = (hex) => {\n if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) throw Error(\"Invalid hex string\");\n const bytes = new Uint8Array(hex.length / 2);\n for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);\n return new TextDecoder().decode(bytes);\n };\n\n const createDbName = async (issuer, clientId) => {\n const encoder = new TextEncoder();\n const issuerHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(issuer))).slice(0, 8))\n .map(b => b.toString(16).padStart(2, '0')).join('');\n const clientHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(clientId))).slice(0, 8))\n .map(b => b.toString(16).padStart(2, '0')).join('');\n return `dpop:${issuerHash}:${clientHash}`;\n };\n\n // Determine target window: popup has window.opener, iframe has window.parent\n const isPopup = !!window.opener;\n const targetWindow = window.opener || window.parent;\n\n if (!targetWindow || targetWindow === window) {\n document.body.textContent = \"Error: No parent or opener window found.\";\n throw Error(\"No target window available for postMessage\");\n }\n\n const urlParams = new URL(window.location.href).searchParams;\n const enclaveVersion = urlParams.get(\"version\");\n if (!enclaveVersion) throw Error(\"No enclave version provided in url\");\n let openerOrigin = urlParams.get(\"openerOrigin\");\n if (!openerOrigin) throw Error(\"No opener origin provided in url\");\n openerOrigin = new URL(decodeURIComponent(openerOrigin)).origin;\n\n const showSuccess = () => {\n document.getElementById('status').classList.add('visible');\n };\n\n const version1Flow = async () => {\n const paths = new URL(window.location.href).pathname.split(\"/\");\n const iss = hexToStr(paths[paths.indexOf(\"iss\") + 1]);\n const aud = hexToStr(paths[paths.indexOf(\"aud\") + 1]);\n const dbname = await createDbName(iss, aud);\n let dpopKey;\n try {\n // Chrome 125+: requestStorageAccess({ indexedDB: true }) returns a handle\n // whose .indexedDB factory points directly at the unpartitioned bucket.\n // If permission was already granted (within 30 days) this resolves\n // automatically with no prompt.\n //\n // In popup mode we have first-party context so we can access IndexedDB directly.\n let idbFactory;\n if (isPopup) {\n idbFactory = window.indexedDB;\n } else {\n const handle = await document.requestStorageAccess({ indexedDB: true });\n idbFactory = handle.indexedDB;\n }\n\n const db = await new Promise((resolve, reject) => {\n const req = idbFactory.open(dbname);\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n dpopKey = await new Promise((resolve, reject) => {\n const tx = db.transaction(\"main\", \"readonly\");\n const store = tx.objectStore(\"main\");\n const req = store.get(\"dpopState\");\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n db.close();\n if (!dpopKey || !dpopKey.keys) throw Error(\"No DPoP key found in IndexedDB\");\n } catch (ex) {\n const isStorageBlocked =\n ex?.name === \"NotAllowedError\" ||\n (ex instanceof DOMException && (\n ex.name === \"SecurityError\" ||\n ex.name === \"UnknownError\" ||\n ex.message?.includes(\"not a known object store name\")\n ));\n const detail = isStorageBlocked\n ? \"Browser is blocking third-party storage. Please allow storage access or disable strict tracking protection.\"\n : \"Failed to access DPoP key from IndexedDB.\";\n targetWindow.postMessage({ type: \"error\", message: \"db failed\", detail }, openerOrigin);\n document.body.textContent = detail;\n throw ex;\n }\n\n const challenge = () => new Promise((resolve) => {\n const handler = (event) => {\n if (event.origin !== openerOrigin) return;\n if (event.data.type === \"challenge\") {\n resolve(event.data.challenge);\n window.removeEventListener(\"message\", handler);\n }\n };\n window.addEventListener(\"message\", handler, false);\n });\n\n const pre_challenge = challenge();\n\n targetWindow.postMessage({ type: \"pageLoaded\", message: \"yay\" }, openerOrigin);\n\n const challengeResp = await pre_challenge;\n if (typeof challengeResp !== \"string\" || challengeResp?.length != 64) throw Error(\"Unexpect challenge format\");\n\n const te = new TextEncoder();\n const signatureInput = te.encode(\"dpop-auth-challenge:\" + challengeResp);\n const algName = dpopKey.keys.privateKey.algorithm.name;\n const signParams = algName === \"ECDSA\"\n ? { name: \"ECDSA\", hash: { \"P-256\": \"SHA-256\", \"P-384\": \"SHA-384\", \"P-521\": \"SHA-512\" }[dpopKey.keys.privateKey.algorithm.namedCurve] }\n : { name: algName };\n const signature = await crypto.subtle.sign(signParams, dpopKey.keys.privateKey, signatureInput);\n\n const publicJwk = await crypto.subtle.exportKey(\"jwk\", dpopKey.keys.publicKey);\n\n targetWindow.postMessage({\n type: \"challenge response\",\n message: {\n dpop_public: JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y }),\n signature: btoa(String.fromCharCode(...new Uint8Array(signature)))\n }\n }, openerOrigin);\n\n showSuccess();\n };\n\n const versionFlows = new Map([[1, version1Flow]]);\n const latestFlow = version1Flow;\n\n (async () => {\n if (Number(enclaveVersion) >= thisVersion) await latestFlow();\n else {\n const flow = versionFlows.get(Number(enclaveVersion));\n if (!flow) throw Error(\"Cannot find supported enclave version: \" + enclaveVersion);\n await flow();\n }\n })();\n </script>\n</body>\n\n</html>";
2
+ export declare const DPOP_AUTH_CSP = "default-src 'self'; script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; style-src 'self' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @param {Object} [options]
3
+ * @param {Object} [options.config] TideCloak adapter config (tidecloak.json). Used to
4
+ * reject requests for another realm or client. Omitted or empty, it serves any.
5
+ */
6
+ export declare function createDpopRoute({ config }?: {
7
+ config?: Object;
8
+ }): {
9
+ GET: (request: any) => Response;
10
+ HEAD: (request: any) => Response;
11
+ };
@@ -0,0 +1,35 @@
1
+ import { DPOP_AUTH_HTML, DPOP_AUTH_CSP } from './generated/dpopAuthAsset.js';
2
+ export { DPOP_AUTH_HTML, DPOP_AUTH_CSP };
3
+ export declare function dpopAuthHeaders(): {
4
+ 'Content-Type': string;
5
+ 'Content-Security-Policy': string;
6
+ 'Allow-CSP-From': string;
7
+ 'Cache-Control': string;
8
+ };
9
+ export declare function hexToUtf8(hex: any): string | null;
10
+ export declare function dpopAuthPathFor(issuer: any, clientId: any): string;
11
+ /**
12
+ * Issuer and client id to accept, from a TideCloak adapter config.
13
+ * Either is undefined when the config does not carry it (e.g. the `{}` placeholder).
14
+ */
15
+ export declare function expectationsFromConfig(config?: {}): {
16
+ issuer: string | undefined;
17
+ client: any;
18
+ };
19
+ /**
20
+ * The response for a request path, as `{ status, headers, body }`, or null when
21
+ * the path is not the DPoP page. Omitted expectations are not checked.
22
+ */
23
+ export declare function resolveDpopAuthRequest({ pathname, expectedIssuer, expectedClient, method }: {
24
+ expectedClient: any;
25
+ expectedIssuer: any;
26
+ method?: string | undefined;
27
+ pathname: any;
28
+ }): {
29
+ status: any;
30
+ headers: {
31
+ 'Content-Type': string;
32
+ 'Cache-Control': string;
33
+ };
34
+ body: any;
35
+ } | null;
@@ -0,0 +1,2 @@
1
+ export declare const DPOP_AUTH_HTML = "<html>\n\n<head>\n <style>\n body {\n margin: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n height: 100vh;\n background: #f6f6f7;\n font-family: -apple-system, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif;\n }\n\n .status {\n text-align: center;\n opacity: 0;\n }\n\n .status.visible {\n opacity: 1;\n }\n\n .checkmark-circle {\n width: 72px;\n height: 72px;\n margin: 0 auto 16px;\n }\n\n .checkmark-circle circle {\n fill: none;\n stroke: #039855;\n stroke-width: 3;\n stroke-dasharray: 201;\n stroke-dashoffset: 201;\n }\n\n .checkmark-circle path {\n fill: none;\n stroke: #039855;\n stroke-width: 3;\n stroke-linecap: round;\n stroke-linejoin: round;\n stroke-dasharray: 50;\n stroke-dashoffset: 50;\n }\n\n .status.visible .checkmark-circle circle {\n animation: circle-draw 0.5s ease forwards;\n }\n\n .status.visible .checkmark-circle path {\n animation: check-draw 0.3s 0.4s ease forwards;\n }\n\n .status.visible .status-text {\n animation: fade-in 0.3s 0.6s ease forwards;\n }\n\n .status-text {\n font-size: 15px;\n color: #464647;\n margin: 0;\n opacity: 0;\n }\n\n @keyframes circle-draw {\n to {\n stroke-dashoffset: 0;\n }\n }\n\n @keyframes check-draw {\n to {\n stroke-dashoffset: 0;\n }\n }\n\n @keyframes fade-in {\n to {\n opacity: 1;\n }\n }\n </style>\n</head>\n\n<body>\n <div class=\"status\" id=\"status\">\n <svg class=\"checkmark-circle\" viewBox=\"0 0 72 72\">\n <circle cx=\"36\" cy=\"36\" r=\"32\" />\n <path d=\"M22 36 l10 10 l18 -20\" />\n </svg>\n <p class=\"status-text\">Session verified</p>\n </div>\n <script>\n const thisVersion = 1;\n\n const hexToStr = (hex) => {\n if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(hex)) throw Error(\"Invalid hex string\");\n const bytes = new Uint8Array(hex.length / 2);\n for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(hex.substr(i * 2, 2), 16);\n return new TextDecoder().decode(bytes);\n };\n\n const createDbName = async (issuer, clientId) => {\n const encoder = new TextEncoder();\n const issuerHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(issuer))).slice(0, 8))\n .map(b => b.toString(16).padStart(2, '0')).join('');\n const clientHash = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', encoder.encode(clientId))).slice(0, 8))\n .map(b => b.toString(16).padStart(2, '0')).join('');\n return `dpop:${issuerHash}:${clientHash}`;\n };\n\n // Determine target window: popup has window.opener, iframe has window.parent\n const isPopup = !!window.opener;\n const targetWindow = window.opener || window.parent;\n\n if (!targetWindow || targetWindow === window) {\n document.body.textContent = \"Error: No parent or opener window found.\";\n throw Error(\"No target window available for postMessage\");\n }\n\n const urlParams = new URL(window.location.href).searchParams;\n const enclaveVersion = urlParams.get(\"version\");\n if (!enclaveVersion) throw Error(\"No enclave version provided in url\");\n let openerOrigin = urlParams.get(\"openerOrigin\");\n if (!openerOrigin) throw Error(\"No opener origin provided in url\");\n openerOrigin = new URL(decodeURIComponent(openerOrigin)).origin;\n\n const showSuccess = () => {\n document.getElementById('status').classList.add('visible');\n };\n\n const version1Flow = async () => {\n const paths = new URL(window.location.href).pathname.split(\"/\");\n const iss = hexToStr(paths[paths.indexOf(\"iss\") + 1]);\n const aud = hexToStr(paths[paths.indexOf(\"aud\") + 1]);\n const dbname = await createDbName(iss, aud);\n let dpopKey;\n try {\n // Chrome 125+: requestStorageAccess({ indexedDB: true }) returns a handle\n // whose .indexedDB factory points directly at the unpartitioned bucket.\n // If permission was already granted (within 30 days) this resolves\n // automatically with no prompt.\n //\n // In popup mode we have first-party context so we can access IndexedDB directly.\n let idbFactory;\n if (isPopup) {\n idbFactory = window.indexedDB;\n } else {\n const handle = await document.requestStorageAccess({ indexedDB: true });\n idbFactory = handle.indexedDB;\n }\n\n const db = await new Promise((resolve, reject) => {\n const req = idbFactory.open(dbname);\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n dpopKey = await new Promise((resolve, reject) => {\n const tx = db.transaction(\"main\", \"readonly\");\n const store = tx.objectStore(\"main\");\n const req = store.get(\"dpopState\");\n req.onsuccess = () => resolve(req.result);\n req.onerror = () => reject(req.error);\n });\n db.close();\n if (!dpopKey || !dpopKey.keys) throw Error(\"No DPoP key found in IndexedDB\");\n } catch (ex) {\n const isStorageBlocked =\n ex?.name === \"NotAllowedError\" ||\n (ex instanceof DOMException && (\n ex.name === \"SecurityError\" ||\n ex.name === \"UnknownError\" ||\n ex.message?.includes(\"not a known object store name\")\n ));\n const detail = isStorageBlocked\n ? \"Browser is blocking third-party storage. Please allow storage access or disable strict tracking protection.\"\n : \"Failed to access DPoP key from IndexedDB.\";\n targetWindow.postMessage({ type: \"error\", message: \"db failed\", detail }, openerOrigin);\n document.body.textContent = detail;\n throw ex;\n }\n\n const challenge = () => new Promise((resolve) => {\n const handler = (event) => {\n if (event.origin !== openerOrigin) return;\n if (event.data.type === \"challenge\") {\n resolve(event.data.challenge);\n window.removeEventListener(\"message\", handler);\n }\n };\n window.addEventListener(\"message\", handler, false);\n });\n\n const pre_challenge = challenge();\n\n targetWindow.postMessage({ type: \"pageLoaded\", message: \"yay\" }, openerOrigin);\n\n const challengeResp = await pre_challenge;\n if (typeof challengeResp !== \"string\" || challengeResp?.length != 64) throw Error(\"Unexpect challenge format\");\n\n const te = new TextEncoder();\n const signatureInput = te.encode(\"dpop-auth-challenge:\" + challengeResp);\n const algName = dpopKey.keys.privateKey.algorithm.name;\n const signParams = algName === \"ECDSA\"\n ? { name: \"ECDSA\", hash: { \"P-256\": \"SHA-256\", \"P-384\": \"SHA-384\", \"P-521\": \"SHA-512\" }[dpopKey.keys.privateKey.algorithm.namedCurve] }\n : { name: algName };\n const signature = await crypto.subtle.sign(signParams, dpopKey.keys.privateKey, signatureInput);\n\n const publicJwk = await crypto.subtle.exportKey(\"jwk\", dpopKey.keys.publicKey);\n\n targetWindow.postMessage({\n type: \"challenge response\",\n message: {\n dpop_public: JSON.stringify({ crv: publicJwk.crv, kty: publicJwk.kty, x: publicJwk.x, y: publicJwk.y }),\n signature: btoa(String.fromCharCode(...new Uint8Array(signature)))\n }\n }, openerOrigin);\n\n showSuccess();\n };\n\n const versionFlows = new Map([[1, version1Flow]]);\n const latestFlow = version1Flow;\n\n (async () => {\n if (Number(enclaveVersion) >= thisVersion) await latestFlow();\n else {\n const flow = versionFlows.get(Number(enclaveVersion));\n if (!flow) throw Error(\"Cannot find supported enclave version: \" + enclaveVersion);\n await flow();\n }\n })();\n </script>\n</body>\n\n</html>";
2
+ export declare const DPOP_AUTH_CSP = "default-src 'self'; script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; style-src 'self' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='";
@@ -0,0 +1,22 @@
1
+ export declare function utf8ToHex(value: any): string;
2
+ export declare function buildDpopAuthUrl({ origin, authServerUrl, realm, clientId }: {
3
+ authServerUrl: any;
4
+ clientId: any;
5
+ origin: any;
6
+ realm: any;
7
+ }): string | null;
8
+ export declare function probeDpopAuthPage({ url, fetchImpl, timeoutMs }: {
9
+ fetchImpl?: typeof fetch | undefined;
10
+ timeoutMs?: number | undefined;
11
+ url: any;
12
+ }): Promise<{
13
+ outcome: string;
14
+ missing?: undefined;
15
+ } | {
16
+ outcome: string;
17
+ missing: string[];
18
+ }>;
19
+ export declare function preflightDpopAuthPage({ logger, ...params }: {
20
+ [x: string]: any;
21
+ logger?: Console | undefined;
22
+ }): Promise<string>;
@@ -2,3 +2,4 @@ export { makePkce, randomPkceVerifier, base64UrlEncode } from './pkce.js';
2
2
  export { fetchJson } from './fetch.js';
3
3
  export { resolveSilentCheckSsoRedirectUri, SILENT_CHECK_SSO_FILENAME } from './silentCheckSso.js';
4
4
  export { buildInitOptions } from './initOptions.js';
5
+ export { preflightDpopAuthPage } from './dpopAuthPage.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @param {Object} [options]
3
+ * @param {Object} [options.config] TideCloak adapter config (tidecloak.json). Used to
4
+ * reject requests for another realm or client.
5
+ */
6
+ export declare function tideDpopPlugin({ config }?: {
7
+ config?: Object;
8
+ }): {
9
+ name: string;
10
+ configureServer: (server: any) => void;
11
+ configurePreviewServer: (server: any) => void;
12
+ };
13
+ export default tideDpopPlugin;
@@ -0,0 +1,22 @@
1
+ export declare function utf8ToHex(value: any): string;
2
+ export declare function buildDpopAuthUrl({ origin, authServerUrl, realm, clientId }: {
3
+ authServerUrl: any;
4
+ clientId: any;
5
+ origin: any;
6
+ realm: any;
7
+ }): string | null;
8
+ export declare function probeDpopAuthPage({ url, fetchImpl, timeoutMs }: {
9
+ fetchImpl?: typeof fetch | undefined;
10
+ timeoutMs?: number | undefined;
11
+ url: any;
12
+ }): Promise<{
13
+ outcome: string;
14
+ missing?: undefined;
15
+ } | {
16
+ outcome: string;
17
+ missing: string[];
18
+ }>;
19
+ export declare function preflightDpopAuthPage({ logger, ...params }: {
20
+ [x: string]: any;
21
+ logger?: Console | undefined;
22
+ }): Promise<string>;
@@ -2,3 +2,4 @@ export { makePkce, randomPkceVerifier, base64UrlEncode } from './pkce.js';
2
2
  export { fetchJson } from './fetch.js';
3
3
  export { resolveSilentCheckSsoRedirectUri, SILENT_CHECK_SSO_FILENAME } from './silentCheckSso.js';
4
4
  export { buildInitOptions } from './initOptions.js';
5
+ export { preflightDpopAuthPage } from './dpopAuthPage.js';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @param {Object} [options]
3
+ * @param {Object} [options.config] TideCloak adapter config (tidecloak.json). Used to
4
+ * reject requests for another realm or client.
5
+ */
6
+ export declare function tideDpopPlugin({ config }?: {
7
+ config?: Object;
8
+ }): {
9
+ name: string;
10
+ configureServer: (server: any) => void;
11
+ configurePreviewServer: (server: any) => void;
12
+ };
13
+ export default tideDpopPlugin;
package/lib/README.md CHANGED
@@ -61,30 +61,32 @@ const authenticated = await tidecloak.init({
61
61
  **Also ensure your resource server has DPoP JWT validation - this could be through using a package like Asgard or any other open sourced SDK**
62
62
 
63
63
  ## DPoP Resource Server Setup
64
- Quick explainer on how to configure the hosting of tide_dpop_auth.html to ensure your resource server has full Tide DPoP protection
64
+ With DPoP on, TideCloak loads `tide_dpop_auth.html` from your app's origin during login to confirm the browser holds the DPoP key. The file ships in this package and is the same for every app. Your issuer and client id go in the URL, not the file.
65
65
 
66
- #### 1. Know what your issuer and client (azp) values are
67
- Issuer will usually be a Tidecloak Realm URL such as "http://localhost:8080/realms/e756fd21-a055-4109-ba5d-afa536961ac7"
66
+ #### 1. Serve the page
67
+ In Next.js, or anything using web `Request`/`Response`:
68
68
 
69
- Client will usually be a simple text like "myclient"
69
+ ```javascript
70
+ // app/tide_dpop/[...path]/route.js
71
+ import { createDpopRoute } from "@tidecloak/js/dpop-route";
72
+ import config from "../../../tidecloak.json";
73
+
74
+ export const { GET, HEAD } = createDpopRoute({ config });
75
+ ```
70
76
 
71
- #### 2. Convert your issue and client values into HEX format
72
- You can use a site such as https://codebeautify.org/string-hex-converter
77
+ Use a route handler, not a proxy or middleware: common matchers skip paths containing a dot.
73
78
 
74
- Exmaple from above would be:
75
- Issuer: 687474703a2f2f6c6f63616c686f73743a383038302f7265616c6d732f65373536666432312d613035352d343130392d626135642d616661353336393631616337
76
- Client: 6d79636c69656e74
79
+ Elsewhere, serve `node_modules/@tidecloak/js/tide_dpop_auth.html` for any request to
80
+ `/tide_dpop/iss/{issuer as hex}/aud/{client id as hex}/tide_dpop_auth.html`
81
+ with these response headers:
82
+ 1. `Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; style-src 'self' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='`
83
+ 2. `Allow-CSP-From: *`
77
84
 
78
- #### 3. Host tide_dpop_auth.html on your client server
79
- You MUST host tide_dpop_auth.html at this URL path on your resource server:
80
- `/tide_dpop/iss/{your issuer value in hex}/aud/{your client value in hex}/tide_dpop_auth.html`
85
+ The hashes pin the page's inline script and style, so they change when the page does.
81
86
 
82
- #### 4. Configure Response Headers for tide_dpop_auth.html
83
- There are 2 Response Headers you MUST add when executing a request for tide_dpop_auth.html
84
- 1. Content-Security-Policy = default-src 'self'; script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; style-src 'self' 'sha256-1tYy8m3c1KLuGI2eID9TfLkc50Y+iSPJMpI7n/apN/w=' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='
85
- 2. Allow-CSP-From = *
87
+ If the page is missing, login still works but stalls for about four seconds and falls back to a popup. The SDK logs the URL it expected at startup.
86
88
 
87
- #### 5. Set up correct authenticator flow on Tidecloak
89
+ #### 2. Set up correct authenticator flow on Tidecloak
88
90
  1. Login to Tidecloak
89
91
  2. Go to Authentication -> tidebrowser
90
92
  3. Delete the Cookie Authenticator
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tidecloak/js",
3
- "version": "0.14.33",
3
+ "version": "0.14.34",
4
4
  "description": "Vanilla JavaScript/browser SDK for TideCloak with an IAMService singleton and low-level client.",
5
5
  "type": "module",
6
6
  "main": "dist/cjs/src/index.js",
@@ -16,6 +16,21 @@
16
16
  "require": "./dist/cjs/src/index.js",
17
17
  "default": "./dist/esm/src/index.js"
18
18
  },
19
+ "./dpop-server": {
20
+ "types": "./dist/types/src/dpopServer.d.ts",
21
+ "import": "./dist/esm/src/dpopServer.js",
22
+ "require": "./dist/cjs/src/dpopServer.js"
23
+ },
24
+ "./vite": {
25
+ "types": "./dist/types/src/vite.d.ts",
26
+ "import": "./dist/esm/src/vite.js",
27
+ "require": "./dist/cjs/src/vite.js"
28
+ },
29
+ "./dpop-route": {
30
+ "types": "./dist/types/src/dpopRoute.d.ts",
31
+ "import": "./dist/esm/src/dpopRoute.js",
32
+ "require": "./dist/cjs/src/dpopRoute.js"
33
+ },
19
34
  "./policy-react": {
20
35
  "types": "./dist/types/src/policy-react.d.ts",
21
36
  "import": "./dist/esm/src/policy-react.js",
@@ -30,7 +45,8 @@
30
45
  "build:esm": "tsc -p tsconfig.esm.json",
31
46
  "build:types": "tsc -p tsconfig.types.json",
32
47
  "build:copy-css": "node scripts/copy-css.cjs",
33
- "build": "npm run build:cjs && npm run build:esm && npm run build:types && npm run build:copy-css && cp ./silent-check-sso.html dist/ && node scripts/postbuild.cjs",
48
+ "build:dpop-asset": "node scripts/gen-dpop-asset.cjs",
49
+ "build": "npm run build:dpop-asset && npm run build:cjs && npm run build:esm && npm run build:types && npm run build:copy-css && cp ./silent-check-sso.html dist/ && cp ./tide_dpop_auth.html dist/ && node scripts/postbuild.cjs",
34
50
  "prepare": "npm run build"
35
51
  },
36
52
  "files": [
@@ -38,7 +54,8 @@
38
54
  "docs",
39
55
  "lib/README.md",
40
56
  "scripts/postinstall.cjs",
41
- "silent-check-sso.html"
57
+ "silent-check-sso.html",
58
+ "tide_dpop_auth.html"
42
59
  ],
43
60
  "publishConfig": {
44
61
  "access": "public"
@@ -59,7 +76,7 @@
59
76
  "authorization"
60
77
  ],
61
78
  "dependencies": {
62
- "heimdall-tide": "^0.14.20",
79
+ "heimdall-tide": "^0.14.34",
63
80
  "@tideorg/js": "^0.14.29"
64
81
  }
65
82
  }