@tidecloak/create-nextjs 0.13.30 → 0.13.32

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.
package/README.md CHANGED
@@ -38,23 +38,22 @@ npm init @tidecloak/nextjs@latest my-app
38
38
 
39
39
  ```
40
40
  my-app/
41
- ├── app
42
- | └── api/
41
+ ├── app/
42
+ | ├── api/
43
43
  | │ └── protected/
44
44
  | │ └── route.js <- A protected API on your NextJS server that verifies the user's access token
45
45
  | ├── auth/
46
46
  | │ └── redirect/
47
47
  | │ └── page.jsx <- A dedicated page to redirect the user back to once authentication is complete
48
48
  | ├── home/
49
- | | └── page.jsx <- Your home page the user goes to once autenticated
50
- | ├── public/
51
- │ | └── silent-check-sso.html
49
+ | | └── page.jsx <- Your home page the user goes to once authenticated
52
50
  | ├── layout.jsx <- Entry point of your app before the user sees any actual pages
53
51
  | └── page.jsx <- Your login page the user is brought to when they need to authenticate
54
- |
52
+ ├── public/
53
+ │ └── silent-check-sso.html <- Silent SSO check page served at the site root
55
54
  ├── tidecloak.json <- Where your Tidecloak configuration sits
56
- ├── next.config.json
57
- ├── middleware.js <- Run on each page navigation - this is where the Tideccloak token is verified
55
+ ├── next.config.js
56
+ ├── middleware.js <- Run on each page navigation - this is where the Tidecloak token is verified
58
57
  └── package.json
59
58
  ```
60
59
 
@@ -204,7 +203,8 @@ TideCloak provides server-side route protection for both the **Pages Router** an
204
203
  #### Options
205
204
 
206
205
  * **`config`** (`TidecloakConfig`): Your Tidecloak adapter JSON (downloaded from your TideCloak client settings).
207
- * **`protectedRoutes`** (`ProtectedRoutesMap`): Map of path patterns to arrays of required roles.
206
+ * **`protectedRoutes`** (`ProtectedRoutesMap`): Map of path patterns to arrays of required roles. A trailing `/*` glob (e.g. `"/admin/*"`) also matches the bare base path (`/admin`).
207
+ * **`cookieName`** (`string`, default `"kcToken"`): Name of the cookie that holds the access token.
208
208
  * **`onRequest`**<br>`(ctx: { token: string | null }, req: NextRequest) => NextResponse | void`<br>Hook before auth logic; can short-circuit by returning a `NextResponse`.
209
209
  * **`onSuccess`**<br>`(ctx: { payload: Record<string, any> }, req: NextRequest) => NextResponse | void`<br>Hook after successful auth & role checks; override the response by returning one.
210
210
  * **`onFailure`**<br>`(ctx: { token: string | null }, req: NextRequest) => NextResponse | void`<br>Hook when auth or role check fails; return a `NextResponse` to override.
package/init/realm.json CHANGED
@@ -12,12 +12,12 @@
12
12
  "description": "Standard application user"
13
13
  },
14
14
  {
15
- "name": "_tide_dob.selfencrypt",
16
- "description": "Tide E2EE self-encrypt DoB data"
15
+ "name": "_tide_message.selfencrypt",
16
+ "description": "Tide E2EE self-encrypt message data"
17
17
  },
18
18
  {
19
- "name": "_tide_dob.selfdecrypt",
20
- "description": "Tide E2EE self-decrypt DoB data"
19
+ "name": "_tide_message.selfdecrypt",
20
+ "description": "Tide E2EE self-decrypt message data"
21
21
  },
22
22
  {
23
23
  "name": "default-roles-nextjs-test",
@@ -25,8 +25,8 @@
25
25
  "composite": true,
26
26
  "composites": {
27
27
  "realm": [
28
- "_tide_dob.selfencrypt",
29
- "_tide_dob.selfdecrypt",
28
+ "_tide_message.selfencrypt",
29
+ "_tide_message.selfdecrypt",
30
30
  "appUser"
31
31
  ]
32
32
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tidecloak/create-nextjs",
3
- "version": "0.13.30",
3
+ "version": "0.13.32",
4
4
  "type": "module",
5
5
  "description": "Scaffold a TideCloak-ready Next.js app with optional IAM setup and working auth - start building instantly with a live example",
6
6
  "bin": {
@@ -5,19 +5,40 @@ import { useTideCloak } from '@tidecloak/nextjs'
5
5
  import tcConfig from "../../tidecloak.json"
6
6
 
7
7
  export default function HomePage() {
8
- const { logout, getValueFromIdToken, hasRealmRole, token } = useTideCloak()
8
+ const { logout, getValueFromIdToken, hasRealmRole, token, doEncrypt, doDecrypt } = useTideCloak()
9
9
 
10
10
  const [username, setUsername] = useState("")
11
11
  const [hasDefaultRole, setHasDefaultRole] = useState(false)
12
12
  const [verifyResult, setVerifyResult] = useState(null)
13
13
  const [verifying, setVerifying] = useState(false)
14
14
 
15
+ // Self encrypt/decrypt: data is bound to THIS user's identity — only they can
16
+ // decrypt it. The "message" tag matches the _tide_message.selfencrypt/.selfdecrypt
17
+ // roles granted to every user in init/realm.json.
18
+ const TAG = "message"
19
+ const [text, setText] = useState("") // always the decrypted value, editable
20
+ const [busy, setBusy] = useState(false)
21
+ const [status, setStatus] = useState("")
22
+ const [cryptoErr, setCryptoErr] = useState("")
23
+
24
+ // localStorage key for the saved note, namespaced per user (vuid).
25
+ const storageKey = () => `tide-note:${getValueFromIdToken("vuid")}`
26
+
15
27
  useEffect(() => {
16
28
  if (token) {
17
29
  const name = getValueFromIdToken("preferred_username")
18
30
  const defaultRole = hasRealmRole(`default-roles-${tcConfig["realm"]}`)
19
31
  setUsername(name);
20
32
  setHasDefaultRole(defaultRole)
33
+
34
+ // Restore the saved note. Only the CIPHERTEXT is persisted; we decrypt it
35
+ // client-side here so the field shows plaintext when you log back in.
36
+ const stored = typeof window !== "undefined" ? localStorage.getItem(storageKey()) : null
37
+ if (stored) {
38
+ doDecrypt([{ encrypted: stored, tags: [TAG] }])
39
+ .then((res) => setText(String(res[0])))
40
+ .catch(() => {})
41
+ }
21
42
  }
22
43
 
23
44
  }, [token])
@@ -48,6 +69,25 @@ export default function HomePage() {
48
69
  }
49
70
  }, [token])
50
71
 
72
+ // Submit = encrypt the current value, persist the ciphertext, then decrypt it
73
+ // straight back so the field keeps showing plaintext. We store only the
74
+ // ciphertext (here in localStorage; in a real app, on your server) — it's
75
+ // decrypted again when you log back in.
76
+ const onSubmit = useCallback(async () => {
77
+ setBusy(true); setCryptoErr(""); setStatus("")
78
+ try {
79
+ const [ct] = await doEncrypt([{ data: text, tags: [TAG] }])
80
+ if (typeof window !== "undefined") localStorage.setItem(storageKey(), ct)
81
+ const [pt] = await doDecrypt([{ encrypted: ct, tags: [TAG] }])
82
+ setText(String(pt))
83
+ setStatus("Message successfully stored")
84
+ } catch (err) {
85
+ setCryptoErr(err.message || "Failed")
86
+ } finally {
87
+ setBusy(false)
88
+ }
89
+ }, [text, doEncrypt, doDecrypt])
90
+
51
91
  return (
52
92
  <div style={containerStyle}>
53
93
  <div style={cardStyle}>
@@ -75,11 +115,45 @@ export default function HomePage() {
75
115
  {verifyResult}
76
116
  </p>
77
117
  )}
118
+
119
+ {/* ── Encrypted note: always shown decrypted; Submit re-encrypts then decrypts ── */}
120
+ <div style={{ marginTop: '1.5rem', borderTop: '1px solid #eee', paddingTop: '1rem', textAlign: 'left' }}>
121
+ <h2 style={{ fontSize: '1.1rem', margin: '0 0 0.25rem' }}>Your encrypted note</h2>
122
+ <p style={{ margin: '0 0 0.5rem', color: '#777', fontSize: '0.85rem' }}>
123
+ This is an encrypted textbox under your own identity — only you can decrypt it.
124
+ </p>
125
+
126
+ <textarea
127
+ value={text}
128
+ onChange={(e) => setText(e.target.value)}
129
+ placeholder="Type your note…"
130
+ style={textareaStyle}
131
+ />
132
+ <button onClick={onSubmit} style={buttonStyle} disabled={busy}>
133
+ {busy ? 'Submitting…' : 'Submit'}
134
+ </button>
135
+
136
+ {status && <p style={{ color: 'green', marginTop: '0.5rem', fontSize: '0.85rem' }}>{status}</p>}
137
+
138
+ {cryptoErr && <p style={{ color: 'red', marginTop: '0.5rem' }}>{cryptoErr}</p>}
139
+ </div>
78
140
  </div>
79
141
  </div>
80
142
  )
81
143
  }
82
144
 
145
+ const textareaStyle = {
146
+ width: '100%',
147
+ minHeight: '64px',
148
+ padding: '0.5rem',
149
+ borderRadius: '4px',
150
+ border: '1px solid #ccc',
151
+ boxSizing: 'border-box',
152
+ fontFamily: 'inherit',
153
+ fontSize: '0.9rem',
154
+ }
155
+
156
+
83
157
  const containerStyle = {
84
158
  minHeight: '100vh',
85
159
  display: 'flex',
@@ -0,0 +1,35 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Tide DPoP resource-server endpoint.
6
+ //
7
+ // DPoP is enabled by default, and the SDK's DPoP flow loads this page from your
8
+ // own origin at:
9
+ // /tide_dpop/iss/<issuer-hex>/aud/<client-hex>/tide_dpop_auth.html
10
+ //
11
+ // This catch-all route serves the single bundled `public/tide_dpop_auth.html`
12
+ // for any such path, with the two response headers Tide requires:
13
+ // • Content-Security-Policy — the sha256 hashes pin the file's inline
14
+ // script/style (so only that exact code runs).
15
+ // • Allow-CSP-From: * — lets the ORK embed this page cross-origin.
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ const htmlPath = path.join(process.cwd(), "public", "tide_dpop_auth.html")
19
+
20
+ const CSP =
21
+ "default-src 'self'; " +
22
+ "script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; " +
23
+ "style-src 'self' 'sha256-1tYy8m3c1KLuGI2eID9TfLkc50Y+iSPJMpI7n/apN/w=' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='"
24
+
25
+ export async function GET() {
26
+ const html = fs.readFileSync(htmlPath, "utf-8")
27
+ return new Response(html, {
28
+ status: 200,
29
+ headers: {
30
+ "Content-Type": "text/html",
31
+ "Content-Security-Policy": CSP,
32
+ "Allow-CSP-From": "*",
33
+ },
34
+ })
35
+ }
@@ -12,12 +12,12 @@
12
12
  "description": "Standard application user"
13
13
  },
14
14
  {
15
- "name": "_tide_dob.selfencrypt",
16
- "description": "Tide E2EE self-encrypt DoB data"
15
+ "name": "_tide_message.selfencrypt",
16
+ "description": "Tide E2EE self-encrypt message data"
17
17
  },
18
18
  {
19
- "name": "_tide_dob.selfdecrypt",
20
- "description": "Tide E2EE self-decrypt DoB data"
19
+ "name": "_tide_message.selfdecrypt",
20
+ "description": "Tide E2EE self-decrypt message data"
21
21
  },
22
22
  {
23
23
  "name": "default-roles-nextjs-test",
@@ -25,8 +25,8 @@
25
25
  "composite": true,
26
26
  "composites": {
27
27
  "realm": [
28
- "_tide_dob.selfencrypt",
29
- "_tide_dob.selfdecrypt",
28
+ "_tide_message.selfencrypt",
29
+ "_tide_message.selfdecrypt",
30
30
  "appUser"
31
31
  ]
32
32
  }
@@ -135,7 +135,7 @@ echo "🔐 Initializing Tide realm + IGA..."
135
135
  curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/setUpTideRealm" \
136
136
  -H "Authorization: Bearer ${TOKEN}" \
137
137
  -H "Content-Type: application/x-www-form-urlencoded" \
138
- --data-urlencode "email=email@tide.org" >/dev/null
138
+ --data-urlencode "email=${SUBSCRIPTION_EMAIL:-test@demo.org}" >/dev/null
139
139
 
140
140
  curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/toggle-iga" \
141
141
  -H "Authorization: Bearer ${TOKEN}" \
@@ -7,6 +7,9 @@ import tcConfig from "./tidecloak.json";
7
7
  export default createTideCloakMiddleware({
8
8
  config: tcConfig,
9
9
  protectedRoutes:{
10
+ // "offline_access" is granted to every authenticated user, so this protects
11
+ // the route for "any logged-in user". Swap it for a real realm/client role
12
+ // (e.g. "appUser") to demonstrate role-based access control.
10
13
  "/protected": ["offline_access"]
11
14
  },
12
15
  onFailure: (ctx, req) => {
@@ -19,13 +22,14 @@ export default createTideCloakMiddleware({
19
22
  onSuccess: (ctx, req) => {
20
23
  return NextResponse.next();
21
24
  },
22
- onError: (ctx, req) => {
25
+ // Note: onError receives (err, req) - the error is the first argument.
26
+ onError: (err, req) => {
23
27
  console.error("[Middleware] ", err);
24
28
  return NextResponse.redirect(new URL("/auth/redirect", req.url));
25
29
  }
26
30
  })
27
31
 
28
- //Which routes the middleware should run on:
32
+ //Which routes the middleware should run on (include the bare path and subpaths):
29
33
  export const config = {
30
- matcher: ["/protected/:path*"],
34
+ matcher: ["/protected", "/protected/:path*"],
31
35
  };
@@ -12,6 +12,6 @@
12
12
  "next": "16.x",
13
13
  "react": "19.x",
14
14
  "react-dom": "19.x",
15
- "@tidecloak/nextjs": "^0.13.30"
15
+ "@tidecloak/nextjs": "^0.13.32"
16
16
  }
17
17
  }
@@ -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>
@@ -16,7 +16,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
16
16
  const token = authHeader.split(' ')[1]
17
17
 
18
18
  try {
19
- const user = await verifyTideCloakToken(tcConfig, token, [ALLOWED_ROLE])
19
+ const user = await verifyTideCloakToken(tcConfig, token, [ALLOWED_ROLE]) as Record<string, any>
20
20
 
21
21
  if (!user) {
22
22
  return NextResponse.json(
@@ -6,19 +6,40 @@ import tcConfig from "../../tidecloak.json"
6
6
 
7
7
 
8
8
  export default function HomePage() {
9
- const { logout, getValueFromIdToken, hasRealmRole, token } = useTideCloak()
9
+ const { logout, getValueFromIdToken, hasRealmRole, token, doEncrypt, doDecrypt } = useTideCloak()
10
10
 
11
11
  const [username, setUsername] = useState("")
12
12
  const [hasDefaultRole, setHasDefaultRole] = useState(false)
13
13
  const [verifyResult, setVerifyResult] = useState<string | null>(null)
14
14
  const [verifying, setVerifying] = useState(false)
15
15
 
16
- useEffect(() => {
16
+ // Self encrypt/decrypt: data is bound to THIS user's identity — only they can
17
+ // decrypt it. The "message" tag matches the _tide_message.selfencrypt/.selfdecrypt
18
+ // roles granted to every user in init/realm.json.
19
+ const TAG = "message"
20
+ const [text, setText] = useState("") // always the decrypted value, editable
21
+ const [busy, setBusy] = useState(false)
22
+ const [status, setStatus] = useState("")
23
+ const [cryptoErr, setCryptoErr] = useState("")
24
+
25
+ // localStorage key for the saved note, namespaced per user (vuid).
26
+ const storageKey = () => `tide-note:${getValueFromIdToken("vuid")}`
27
+
28
+ useEffect(() => {
17
29
  if (token) {
18
30
  const name = getValueFromIdToken("preferred_username")
19
31
  const defaultRole = hasRealmRole(`default-roles-${tcConfig["realm"]}`)
20
32
  setUsername(name);
21
33
  setHasDefaultRole(defaultRole)
34
+
35
+ // Restore the saved note. Only the CIPHERTEXT is persisted; we decrypt it
36
+ // client-side here so the field shows plaintext when you log back in.
37
+ const stored = typeof window !== "undefined" ? localStorage.getItem(storageKey()) : null
38
+ if (stored) {
39
+ doDecrypt([{ encrypted: stored, tags: [TAG] }])
40
+ .then((res) => setText(String(res[0])))
41
+ .catch(() => {})
42
+ }
22
43
  }
23
44
 
24
45
  }, [token])
@@ -50,6 +71,25 @@ export default function HomePage() {
50
71
  }
51
72
  }, [token])
52
73
 
74
+ // Submit = encrypt the current value, persist the ciphertext, then decrypt it
75
+ // straight back so the field keeps showing plaintext. We store only the
76
+ // ciphertext (here in localStorage; in a real app, on your server) — it's
77
+ // decrypted again when you log back in.
78
+ const onSubmit = useCallback(async () => {
79
+ setBusy(true); setCryptoErr(""); setStatus("")
80
+ try {
81
+ const [ct] = await doEncrypt([{ data: text, tags: [TAG] }])
82
+ if (typeof window !== "undefined") localStorage.setItem(storageKey(), ct)
83
+ const [pt] = await doDecrypt([{ encrypted: ct, tags: [TAG] }])
84
+ setText(String(pt))
85
+ setStatus("Message successfully stored")
86
+ } catch (err: any) {
87
+ setCryptoErr(err.message || "Failed")
88
+ } finally {
89
+ setBusy(false)
90
+ }
91
+ }, [text, doEncrypt, doDecrypt])
92
+
53
93
  return (
54
94
  <div style={containerStyle}>
55
95
  <div style={cardStyle}>
@@ -75,11 +115,45 @@ export default function HomePage() {
75
115
  {verifyResult}
76
116
  </p>
77
117
  )}
118
+
119
+ {/* ── Encrypted note: always shown decrypted; Submit re-encrypts then decrypts ── */}
120
+ <div style={{ marginTop: '1.5rem', borderTop: '1px solid #eee', paddingTop: '1rem', textAlign: 'left' }}>
121
+ <h2 style={{ fontSize: '1.1rem', margin: '0 0 0.25rem' }}>Your encrypted note</h2>
122
+ <p style={{ margin: '0 0 0.5rem', color: '#777', fontSize: '0.85rem' }}>
123
+ This is an encrypted textbox under your own identity — only you can decrypt it.
124
+ </p>
125
+
126
+ <textarea
127
+ value={text}
128
+ onChange={(e) => setText(e.target.value)}
129
+ placeholder="Type your note…"
130
+ style={textareaStyle}
131
+ />
132
+ <button onClick={onSubmit} style={buttonStyle} disabled={busy}>
133
+ {busy ? 'Submitting…' : 'Submit'}
134
+ </button>
135
+
136
+ {status && <p style={{ color: 'green', marginTop: '0.5rem', fontSize: '0.85rem' }}>{status}</p>}
137
+
138
+ {cryptoErr && <p style={{ color: 'red', marginTop: '0.5rem' }}>{cryptoErr}</p>}
139
+ </div>
78
140
  </div>
79
141
  </div>
80
142
  )
81
143
  }
82
144
 
145
+ const textareaStyle: React.CSSProperties = {
146
+ width: '100%',
147
+ minHeight: '64px',
148
+ padding: '0.5rem',
149
+ borderRadius: '4px',
150
+ border: '1px solid #ccc',
151
+ boxSizing: 'border-box',
152
+ fontFamily: 'inherit',
153
+ fontSize: '0.9rem',
154
+ }
155
+
156
+
83
157
  const containerStyle: React.CSSProperties = {
84
158
  minHeight: '100vh',
85
159
  display: 'flex',
@@ -35,7 +35,7 @@ const buttonStyle: CSSProperties = {
35
35
  cursor: 'pointer',
36
36
  }
37
37
 
38
- export default function LoginPage(): JSX.Element {
38
+ export default function LoginPage() {
39
39
  const { login, authenticated } = useTideCloak()
40
40
  const router = useRouter()
41
41
 
@@ -0,0 +1,35 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+
4
+ // ─────────────────────────────────────────────────────────────────────────────
5
+ // Tide DPoP resource-server endpoint.
6
+ //
7
+ // DPoP is enabled by default, and the SDK's DPoP flow loads this page from your
8
+ // own origin at:
9
+ // /tide_dpop/iss/<issuer-hex>/aud/<client-hex>/tide_dpop_auth.html
10
+ //
11
+ // This catch-all route serves the single bundled `public/tide_dpop_auth.html`
12
+ // for any such path, with the two response headers Tide requires:
13
+ // • Content-Security-Policy — the sha256 hashes pin the file's inline
14
+ // script/style (so only that exact code runs).
15
+ // • Allow-CSP-From: * — lets the ORK embed this page cross-origin.
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+
18
+ const htmlPath = path.join(process.cwd(), "public", "tide_dpop_auth.html")
19
+
20
+ const CSP =
21
+ "default-src 'self'; " +
22
+ "script-src 'self' 'sha256-utc6UrebuHOyLd/2aiMXS/p1EDy9UZBDe/XEMKDw9Mc='; " +
23
+ "style-src 'self' 'sha256-1tYy8m3c1KLuGI2eID9TfLkc50Y+iSPJMpI7n/apN/w=' 'sha256-F7OJTdJYct4J+cQfuJUoDauitndqt8pAc8EbA8gwDPU='"
24
+
25
+ export async function GET(): Promise<Response> {
26
+ const html = fs.readFileSync(htmlPath, "utf-8")
27
+ return new Response(html, {
28
+ status: 200,
29
+ headers: {
30
+ "Content-Type": "text/html",
31
+ "Content-Security-Policy": CSP,
32
+ "Allow-CSP-From": "*",
33
+ },
34
+ })
35
+ }
@@ -12,12 +12,12 @@
12
12
  "description": "Standard application user"
13
13
  },
14
14
  {
15
- "name": "_tide_dob.selfencrypt",
16
- "description": "Tide E2EE self-encrypt DoB data"
15
+ "name": "_tide_message.selfencrypt",
16
+ "description": "Tide E2EE self-encrypt message data"
17
17
  },
18
18
  {
19
- "name": "_tide_dob.selfdecrypt",
20
- "description": "Tide E2EE self-decrypt DoB data"
19
+ "name": "_tide_message.selfdecrypt",
20
+ "description": "Tide E2EE self-decrypt message data"
21
21
  },
22
22
  {
23
23
  "name": "default-roles-nextjs-test",
@@ -25,8 +25,8 @@
25
25
  "composite": true,
26
26
  "composites": {
27
27
  "realm": [
28
- "_tide_dob.selfencrypt",
29
- "_tide_dob.selfdecrypt",
28
+ "_tide_message.selfencrypt",
29
+ "_tide_message.selfdecrypt",
30
30
  "appUser"
31
31
  ]
32
32
  }
@@ -135,7 +135,7 @@ echo "🔐 Initializing Tide realm + IGA..."
135
135
  curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/setUpTideRealm" \
136
136
  -H "Authorization: Bearer ${TOKEN}" \
137
137
  -H "Content-Type: application/x-www-form-urlencoded" \
138
- --data-urlencode "email=email@tide.org" >/dev/null
138
+ --data-urlencode "email=${SUBSCRIPTION_EMAIL:-test@demo.org}" >/dev/null
139
139
 
140
140
  curl -s -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/toggle-iga" \
141
141
  -H "Authorization: Bearer ${TOKEN}" \
@@ -1,44 +1,40 @@
1
- // an example nextJS middleware router that does server-side validation on all traffic to secure pages
2
- import type { NextRequest } from "next/server";
3
- import { NextResponse } from "next/server";
4
- import {
5
- createTideCloakMiddleware,
6
- type TideCloakContext,
7
- } from "@tidecloak/nextjs/server";
8
- import tcConfig from "./tidecloak.json";
9
-
10
- export default createTideCloakMiddleware({
11
- config: tcConfig,
12
- protectedRoutes: {
13
- // list each protected route and the roles allowed to access it
14
- "/protected": ["offline_access"],
15
- },
16
- onFailure: (ctx: TideCloakContext, req: NextRequest) => {
17
- console.debug("Token verification failed", {
18
- path: req.nextUrl.pathname,
19
- ctx,
20
- });
21
- return NextResponse.json(
22
- { error: "Access forbidden: invalid token" },
23
- { status: 403 }
24
- );
25
- },
26
- onSuccess: (ctx: TideCloakContext, req: NextRequest) => {
27
- return NextResponse.next();
28
- },
29
- onError: (
30
- ctx: TideCloakContext,
31
- req: NextRequest,
32
- err: unknown
33
- ) => {
34
- console.error("[Middleware] error verifying token for", req.nextUrl.pathname, err);
35
- // if something unexpected happens, redirect to your auth flow
36
- const redirectUrl = new URL("/auth/redirect", req.url);
37
- return NextResponse.redirect(redirectUrl);
38
- },
39
- });
40
-
41
- // Tell Next.js which paths to apply this middleware to
42
- export const config = {
43
- matcher: ["/protected/:path*"],
44
- };
1
+ // an example nextJS middleware router that does server-side validation on all traffic to secure pages
2
+ import type { NextRequest } from "next/server";
3
+ import { NextResponse } from "next/server";
4
+ import { createTideCloakMiddleware } from "@tidecloak/nextjs/server";
5
+ import tcConfig from "./tidecloak.json";
6
+
7
+ export default createTideCloakMiddleware({
8
+ config: tcConfig,
9
+ protectedRoutes: {
10
+ // "offline_access" is granted to every authenticated user, so this protects
11
+ // the route for "any logged-in user". Swap it for a real realm/client role
12
+ // (e.g. "appUser") to demonstrate role-based access control.
13
+ "/protected": ["offline_access"],
14
+ },
15
+ onFailure: (ctx: { token: string | null }, req: NextRequest) => {
16
+ console.debug("Token verification failed", {
17
+ path: req.nextUrl.pathname,
18
+ ctx,
19
+ });
20
+ return NextResponse.json(
21
+ { error: "Access forbidden: invalid token" },
22
+ { status: 403 }
23
+ );
24
+ },
25
+ onSuccess: (ctx: { payload: Record<string, any> }, req: NextRequest) => {
26
+ return NextResponse.next();
27
+ },
28
+ // Note: onError receives (err, req) - the error is the first argument.
29
+ onError: (err: unknown, req: NextRequest) => {
30
+ console.error("[Middleware] error verifying token for", req.nextUrl.pathname, err);
31
+ // if something unexpected happens, redirect to your auth flow
32
+ const redirectUrl = new URL("/auth/redirect", req.url);
33
+ return NextResponse.redirect(redirectUrl);
34
+ },
35
+ });
36
+
37
+ // Tell Next.js which paths to apply this middleware to (bare path and subpaths)
38
+ export const config = {
39
+ matcher: ["/protected", "/protected/:path*"],
40
+ };
@@ -0,0 +1,5 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+
4
+ }
5
+ module.exports = nextConfig
@@ -12,7 +12,7 @@
12
12
  "next": "16.x",
13
13
  "react": "19.x",
14
14
  "react-dom": "19.x",
15
- "@tidecloak/nextjs": "^0.13.30"
15
+ "@tidecloak/nextjs": "^0.13.32"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "24.0.13",
@@ -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>