@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 +9 -9
- package/init/realm.json +6 -6
- package/package.json +1 -1
- package/template-js-app/app/home/page.jsx +75 -1
- package/template-js-app/app/tide_dpop/[...path]/route.js +35 -0
- package/template-js-app/init/realm.json +6 -6
- package/template-js-app/init/tcinit.sh +1 -1
- package/template-js-app/middleware.js +7 -3
- package/template-js-app/package.json +1 -1
- package/template-js-app/public/tide_dpop_auth.html +238 -0
- package/template-ts-app/app/api/protected/route.ts +1 -1
- package/template-ts-app/app/home/page.tsx +76 -2
- package/template-ts-app/app/page.tsx +1 -1
- package/template-ts-app/app/tide_dpop/[...path]/route.ts +35 -0
- package/template-ts-app/init/realm.json +6 -6
- package/template-ts-app/init/tcinit.sh +1 -1
- package/template-ts-app/middleware.ts +40 -44
- package/template-ts-app/next.config.js +5 -0
- package/template-ts-app/package.json +1 -1
- package/template-ts-app/public/tide_dpop_auth.html +238 -0
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
|
-
|
|
|
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
|
|
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.
|
|
57
|
-
├── middleware.js <- Run on each page navigation - this is where the
|
|
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": "
|
|
16
|
-
"description": "Tide E2EE self-encrypt
|
|
15
|
+
"name": "_tide_message.selfencrypt",
|
|
16
|
+
"description": "Tide E2EE self-encrypt message data"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"name": "
|
|
20
|
-
"description": "Tide E2EE self-decrypt
|
|
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
|
-
"
|
|
29
|
-
"
|
|
28
|
+
"_tide_message.selfencrypt",
|
|
29
|
+
"_tide_message.selfdecrypt",
|
|
30
30
|
"appUser"
|
|
31
31
|
]
|
|
32
32
|
}
|
package/package.json
CHANGED
|
@@ -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": "
|
|
16
|
-
"description": "Tide E2EE self-encrypt
|
|
15
|
+
"name": "_tide_message.selfencrypt",
|
|
16
|
+
"description": "Tide E2EE self-encrypt message data"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"name": "
|
|
20
|
-
"description": "Tide E2EE self-decrypt
|
|
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
|
-
"
|
|
29
|
-
"
|
|
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
|
|
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
|
-
|
|
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
|
};
|
|
@@ -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
|
-
|
|
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',
|
|
@@ -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": "
|
|
16
|
-
"description": "Tide E2EE self-encrypt
|
|
15
|
+
"name": "_tide_message.selfencrypt",
|
|
16
|
+
"description": "Tide E2EE self-encrypt message data"
|
|
17
17
|
},
|
|
18
18
|
{
|
|
19
|
-
"name": "
|
|
20
|
-
"description": "Tide E2EE self-decrypt
|
|
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
|
-
"
|
|
29
|
-
"
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
},
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
{
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
onError: (
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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,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>
|