@tideorg/mcp 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/GAP_REGISTER.md +117 -0
- package/README.md +38 -0
- package/adapters/AGENTS.md +241 -0
- package/adapters/CLAUDE.md +146 -0
- package/adapters/replit.md +224 -0
- package/canon/anti-patterns.md +2133 -0
- package/canon/concepts.md +816 -0
- package/canon/custom-contracts.md +533 -0
- package/canon/delegation.md +195 -0
- package/canon/feature-mapping.md +637 -0
- package/canon/framework-matrix.md +1125 -0
- package/canon/invariants.md +851 -0
- package/canon/redirect-handler.md +254 -0
- package/canon/tidecloak-bootstrap.md +287 -0
- package/canon/tidecloak-endpoints.md +294 -0
- package/canon/troubleshooting.md +1494 -0
- package/canon/version-policy.md +89 -0
- package/mcp-server/dist/index.d.ts +2 -0
- package/mcp-server/dist/index.js +605 -0
- package/package.json +45 -0
- package/playbooks/add-auth-nextjs-existing.md +799 -0
- package/playbooks/add-auth-nextjs-fresh.md +654 -0
- package/playbooks/add-rbac-nextjs.md +190 -0
- package/playbooks/bootstrap-realm-from-template.md +170 -0
- package/playbooks/configure-e2ee-roles-and-policies.md +196 -0
- package/playbooks/deploy-tidecloak-docker.md +792 -0
- package/playbooks/diagnose-broken-login.md +234 -0
- package/playbooks/diagnose-missing-roles-or-claims.md +253 -0
- package/playbooks/initialize-admin-and-link-account.md +235 -0
- package/playbooks/migrate-from-existing-auth.md +198 -0
- package/playbooks/protect-api-nextjs.md +451 -0
- package/playbooks/protect-routes-nextjs.md +544 -0
- package/playbooks/setup-forseti-e2ee.md +756 -0
- package/playbooks/setup-iga-admin-panel.md +344 -0
- package/playbooks/setup-server-delegation.md +142 -0
- package/playbooks/start-tidecloak-dev.md +130 -0
- package/playbooks/verify-jwt-server-side.md +439 -0
- package/prompts/add-admin-approval-flow.md +50 -0
- package/prompts/build-private-customer-portal.md +85 -0
- package/prompts/migrate-generic-auth-to-tide.md +67 -0
- package/prompts/secure-existing-app.md +80 -0
- package/reference-apps/INDEX.md +87 -0
- package/reference-apps/encrypted-communication/anti-patterns.md +123 -0
- package/reference-apps/encrypted-communication/bootstrap-sequence.md +152 -0
- package/reference-apps/encrypted-communication/manifest.yaml +100 -0
- package/reference-apps/encrypted-communication/role-policy-matrix.md +80 -0
- package/reference-apps/encrypted-communication/scenario.md +134 -0
- package/reference-apps/git-pr-signing-service/anti-patterns.md +61 -0
- package/reference-apps/git-pr-signing-service/bootstrap-sequence.md +157 -0
- package/reference-apps/git-pr-signing-service/manifest.yaml +80 -0
- package/reference-apps/git-pr-signing-service/role-policy-matrix.md +99 -0
- package/reference-apps/git-pr-signing-service/scenario.md +169 -0
- package/reference-apps/iga-admin-governance/anti-patterns.md +133 -0
- package/reference-apps/iga-admin-governance/bootstrap-sequence.md +153 -0
- package/reference-apps/iga-admin-governance/manifest.yaml +87 -0
- package/reference-apps/iga-admin-governance/role-policy-matrix.md +67 -0
- package/reference-apps/iga-admin-governance/scenario.md +126 -0
- package/reference-apps/organisation-password-manager/anti-patterns.md +71 -0
- package/reference-apps/organisation-password-manager/bootstrap-sequence.md +127 -0
- package/reference-apps/organisation-password-manager/manifest.yaml +86 -0
- package/reference-apps/organisation-password-manager/role-policy-matrix.md +59 -0
- package/reference-apps/organisation-password-manager/scenario.md +107 -0
- package/reference-apps/policy-governed-signing/anti-patterns.md +67 -0
- package/reference-apps/policy-governed-signing/bootstrap-sequence.md +128 -0
- package/reference-apps/policy-governed-signing/manifest.yaml +78 -0
- package/reference-apps/policy-governed-signing/role-policy-matrix.md +62 -0
- package/reference-apps/policy-governed-signing/scenario.md +113 -0
- package/skills/tide-diagnostics/SKILL.md +99 -0
- package/skills/tide-integration/SKILL.md +136 -0
- package/skills/tide-learning-capture/SKILL.md +174 -0
- package/skills/tide-rbac-and-e2ee/SKILL.md +214 -0
- package/skills/tide-reviewer/SKILL.md +133 -0
- package/skills/tide-route-and-api-protection/SKILL.md +201 -0
- package/skills/tide-scenario-resolver/SKILL.md +81 -0
- package/skills/tide-setup/SKILL.md +218 -0
- package/skills/tide-solutions-architect/SKILL.md +130 -0
|
@@ -0,0 +1,1125 @@
|
|
|
1
|
+
# Tide Framework Implementation Matrix
|
|
2
|
+
|
|
3
|
+
Framework-specific implementation guidance. Organized by priority: Next.js → React → Vanilla JS.
|
|
4
|
+
|
|
5
|
+
**Rule**: Focus on reusable patterns from keylessh exemplar (LIKELY_REUSABLE_PATTERN), not app-specific SSH logic.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Next.js Implementation
|
|
10
|
+
|
|
11
|
+
### Prerequisites
|
|
12
|
+
|
|
13
|
+
**Verified requirements** (tidecloak-nextjs-tutorial-quickstart.md):
|
|
14
|
+
- Next.js 13+ (App Router or Pages Router)
|
|
15
|
+
- Node.js 18+
|
|
16
|
+
- TideCloak instance running
|
|
17
|
+
- IGA enabled on realm **VERIFIED** (vendor confirmation, batch-02 Q-04). Canonical ordering: license → IGA → E2EE.
|
|
18
|
+
|
|
19
|
+
### Package Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @tidecloak/nextjs
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For Forseti policy-governed encryption or threshold signing, also install:
|
|
26
|
+
```bash
|
|
27
|
+
npm install @tideorg/js heimdall-tide asgard-tide
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
### SDK Package Layer Map
|
|
31
|
+
|
|
32
|
+
The Tide SDK is split across multiple packages. Each exports different classes. Importing from the wrong package causes undefined exports or runtime errors.
|
|
33
|
+
|
|
34
|
+
| Package | Key exports | Used for |
|
|
35
|
+
|---------|------------|----------|
|
|
36
|
+
| `@tidecloak/js` | `IAMService`, `TideCloak`, `Tools` (re-exports `TideMemory`), `Models` (re-exports `BaseTideRequest`, `Policy`, `ApprovalType`, `ExecutionType`, `Doken`) | Auth, admin API, context, core primitives |
|
|
37
|
+
| `@tidecloak/nextjs` | `TideCloakContextProvider`, `useTideCloak` | Next.js provider and hooks. Does NOT export `Models`, `PolicySignRequest`, or signing classes |
|
|
38
|
+
| `@tidecloak/react` | `TideCloakProvider`, `useTideCloak` | React provider (prefer `@tidecloak/nextjs` for Next.js) |
|
|
39
|
+
| `@tideorg/js` | `BaseTideRequest`, `Policy`, `TideMemory`, `Doken` | Core primitives (prefer accessing via `@tidecloak/js` re-exports to avoid ESM issues) |
|
|
40
|
+
| `asgard-tide` | `BasicCustomRequest`, `DynamicPayloadCustomRequest` | Building ORK signing requests. NOT in `@tidecloak/js` or `@tideorg/js` |
|
|
41
|
+
| `heimdall-tide` | `PolicySignRequest`, `RequestEnclave`, `ApprovalEnclave` | Policy deployment, enclave UI |
|
|
42
|
+
|
|
43
|
+
**Critical import rules**:
|
|
44
|
+
- `BasicCustomRequest` comes from `asgard-tide` only. Not from `@tideorg/js` or `@tidecloak/js`. VERIFIED (LEARNINGS-ratidefy-batch-001 L-11).
|
|
45
|
+
- `PolicySignRequest` comes from `heimdall-tide`. VERIFIED (LEARNINGS-ratidefy-batch-001 L-12).
|
|
46
|
+
- `Models` from `@tidecloak/nextjs` returns `undefined` at runtime. Import from `@tidecloak/js` or `@tideorg/js`.
|
|
47
|
+
- `secureFetch` and `getToken` must come from the `useTideCloak()` hook, NOT from static `IAMService` import. The static class is not connected to the React provider's auth state. Exception: `(IAMService as any)._tc` works for ORK signing operations. VERIFIED (LEARNINGS-ratidefy-batch-001 L-20).
|
|
48
|
+
|
|
49
|
+
See [SDK Internals](concepts.md#sdk-internals) for architecture details.
|
|
50
|
+
|
|
51
|
+
### Webpack Workarounds (Required)
|
|
52
|
+
|
|
53
|
+
Two webpack fixes are required for `@tidecloak/*` packages in Next.js:
|
|
54
|
+
|
|
55
|
+
1. **`strictExportPresence: false`** — `@tidecloak/js` has incomplete re-exports from `heimdall-tide`. Without this, webpack errors on missing re-exports. Note: the property is `config.module.strictExportPresence` (boolean), NOT `reexportExportsPresence` (which is not a valid webpack 5 property). VERIFIED (learning-batch-004, L-04).
|
|
56
|
+
|
|
57
|
+
2. **`@tidecloak/react` ESM alias** — `@tidecloak/react`'s CJS dist (`dist/cjs/index.js`) contains ESM `import` syntax. When `@tidecloak/nextjs` does `require("@tidecloak/react")`, webpack follows the CJS path and fails. Force resolution to the ESM dist. Use `path.resolve()` (not `require.resolve()` which throws `ERR_PACKAGE_PATH_NOT_EXPORTED`). Without this, the login page shows a redirect loop — the provider fails silently and auth state never initializes. VERIFIED (learning-batch-003, L-04).
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
import path from "path";
|
|
61
|
+
import type { NextConfig } from "next";
|
|
62
|
+
|
|
63
|
+
const nextConfig: NextConfig = {
|
|
64
|
+
webpack: (config) => {
|
|
65
|
+
// Fix 1: @tidecloak/js incomplete re-exports — suppress strict export checking
|
|
66
|
+
config.module.strictExportPresence = false;
|
|
67
|
+
|
|
68
|
+
// Fix 2: @tidecloak/react CJS dist contains ESM syntax
|
|
69
|
+
config.resolve.alias = {
|
|
70
|
+
...config.resolve.alias,
|
|
71
|
+
"@tidecloak/react": path.resolve(
|
|
72
|
+
__dirname,
|
|
73
|
+
"node_modules/@tidecloak/react/dist/esm/index.js"
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return config;
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Next.js 16+**: Turbopack is the default bundler. Since this webpack config is required for `@tidecloak/*`, use `next dev --webpack` in `package.json` scripts:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
"scripts": {
|
|
86
|
+
"dev": "next dev --webpack"
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Without `--webpack`, Next.js 16 errors with "This build is using Turbopack, with a `webpack` config and no `turbopack` config."
|
|
91
|
+
|
|
92
|
+
**VERIFIED** (forseti-crypto-quickstart `next.config.ts`, Next.js 16.2.1 runtime error)
|
|
93
|
+
|
|
94
|
+
### Scaffold Command
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm init @tidecloak/nextjs@latest
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**RESOLVED_BY_VENDOR** (GAP-005): Scaffolds Next.js app with interactive prompts for realm/client creation, IGA setup, admin user linking. Use manual setup if scaffold behavior is unclear.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### Client-Side Setup (App Router)
|
|
105
|
+
|
|
106
|
+
**Provider setup**:
|
|
107
|
+
|
|
108
|
+
There are two provider patterns depending on how the adapter JSON is loaded:
|
|
109
|
+
|
|
110
|
+
**Pattern A: Import config directly (recommended for Next.js)**:
|
|
111
|
+
```typescript
|
|
112
|
+
// app/providers.tsx
|
|
113
|
+
'use client';
|
|
114
|
+
|
|
115
|
+
import { TideCloakProvider } from '@tidecloak/nextjs';
|
|
116
|
+
import tcConfig from '../../data/tidecloak.json';
|
|
117
|
+
|
|
118
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
119
|
+
return (
|
|
120
|
+
<TideCloakProvider config={tcConfig}>
|
|
121
|
+
{children}
|
|
122
|
+
</TideCloakProvider>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
**Pattern B: Fetch config from URL** (when `tidecloak.json` is in `public/`):
|
|
128
|
+
```typescript
|
|
129
|
+
// app/providers.tsx
|
|
130
|
+
'use client';
|
|
131
|
+
|
|
132
|
+
import { TideCloakContextProvider } from '@tidecloak/nextjs';
|
|
133
|
+
|
|
134
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
135
|
+
return (
|
|
136
|
+
<TideCloakContextProvider configUrl="/tidecloak.json">
|
|
137
|
+
{children}
|
|
138
|
+
</TideCloakContextProvider>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**Critical**: `TideCloakContextProvider` uses the `configUrl` prop, NOT `configFilePath`. The `useTideCloak()` hook returns flat properties (`authenticated`, `login`, `logout`, `secureFetch`, `getToken`, `hasRealmRole`, etc.) — NOT a `tc` wrapper object. VERIFIED (LEARNINGS-ratidefy-batch-001 L-19).
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
// app/layout.tsx
|
|
148
|
+
import { Providers } from './providers';
|
|
149
|
+
|
|
150
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
151
|
+
return (
|
|
152
|
+
<html lang="en">
|
|
153
|
+
<body>
|
|
154
|
+
<Providers>{children}</Providers>
|
|
155
|
+
</body>
|
|
156
|
+
</html>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The provider reads all config (auth-server-url, realm, resource/clientId, redirect URIs) from `data/tidecloak.json`. Do not duplicate these values into `NEXT_PUBLIC_TIDECLOAK_*` env vars.
|
|
162
|
+
|
|
163
|
+
**DPoP configuration** **VERIFIED** (vendor confirmation, GAP-032 resolved):
|
|
164
|
+
```typescript
|
|
165
|
+
<TideCloakContextProvider
|
|
166
|
+
useDPoP={{ mode: 'strict', alg: 'ES256' }}
|
|
167
|
+
// ... other props
|
|
168
|
+
>
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
ES256 is the default and recommended algorithm. EdDSA also supported. DPoP is required for Tide's full security guarantees.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
### Client-Side Auth State Access
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// app/components/UserInfo.tsx
|
|
179
|
+
'use client';
|
|
180
|
+
import { useTideCloak } from '@tidecloak/nextjs';
|
|
181
|
+
|
|
182
|
+
export function UserInfo() {
|
|
183
|
+
const { authenticated, getValueFromIdToken, login, logout, hasRealmRole } = useTideCloak();
|
|
184
|
+
|
|
185
|
+
if (!authenticated) {
|
|
186
|
+
return <button onClick={login}>Login</button>;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div>
|
|
191
|
+
<p>Welcome {getValueFromIdToken('preferred_username')}</p>
|
|
192
|
+
{hasRealmRole('admin') && <AdminButton />} {/* UI gating only */}
|
|
193
|
+
<button onClick={logout}>Logout</button>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
**Critical**: `hasRealmRole()` and `hasClientRole()` are UI gating, NOT real authorization. The SDK hook exports `hasRealmRole(role)` for realm roles and `hasClientRole(role, client?)` for client roles. There is no generic `hasRole()` on the hook. See [Protected Routes vs Protected APIs](feature-mapping.md#protected-routes-vs-protected-apis).
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
### Server-Side JWT Verification (App Router)
|
|
204
|
+
|
|
205
|
+
**Pattern** (tidecloak-nextjs-reference.md, keylessh `server/lib/auth/tideJWT.ts`):
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// app/api/admin/users/route.ts
|
|
209
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
210
|
+
import { verifyTideJWT } from '@/lib/auth/tideJWT';
|
|
211
|
+
|
|
212
|
+
export async function GET(req: NextRequest) {
|
|
213
|
+
const authHeader = req.headers.get('authorization');
|
|
214
|
+
if (!authHeader) {
|
|
215
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
const jwt = await verifyTideJWT(authHeader.replace('Bearer ', ''));
|
|
220
|
+
|
|
221
|
+
// Check role
|
|
222
|
+
if (!jwt.realm_access?.roles?.includes('admin')) {
|
|
223
|
+
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Proceed with admin operation
|
|
227
|
+
return NextResponse.json({ users: [...] });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**`verifyTideJWT()` implementation** **VERIFIED** (keylessh `server/lib/auth/tideJWT.ts`):
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
// lib/auth/tideJWT.ts
|
|
238
|
+
import { jwtVerify, createLocalJWKSet } from 'jose';
|
|
239
|
+
import type { JWTPayload } from 'jose';
|
|
240
|
+
import { loadTideConfig } from './tidecloakConfig';
|
|
241
|
+
|
|
242
|
+
const config = loadTideConfig();
|
|
243
|
+
if (!config.jwk) throw new Error('Missing jwk in tidecloak.json. Re-export adapter with IGA enabled.');
|
|
244
|
+
const JWKS = createLocalJWKSet(config.jwk); // Local only. Do not use createRemoteJWKSet.
|
|
245
|
+
|
|
246
|
+
export async function verifyTideJWT(token: string): Promise<JWTPayload> {
|
|
247
|
+
const { payload } = await jwtVerify(token, JWKS, {
|
|
248
|
+
issuer: `${config['auth-server-url'].replace(/\/+$/, '')}/realms/${config.realm}`,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// TideCloak access tokens use azp (authorized party) for the client ID.
|
|
252
|
+
if (payload.azp !== config.resource) {
|
|
253
|
+
throw new Error('Token azp does not match client');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const now = Math.floor(Date.now() / 1000);
|
|
257
|
+
if (payload.exp && payload.exp < now) {
|
|
258
|
+
throw new Error('Token expired');
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return payload;
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
**`loadTideConfig()` implementation** **VERIFIED** (keylessh `server/lib/auth/tidecloakConfig.ts`):
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
// lib/auth/tidecloakConfig.ts
|
|
269
|
+
import { readFileSync } from 'fs';
|
|
270
|
+
|
|
271
|
+
interface TideConfig {
|
|
272
|
+
realm: string;
|
|
273
|
+
'auth-server-url': string;
|
|
274
|
+
resource: string;
|
|
275
|
+
'ssl-required': string;
|
|
276
|
+
'public-client': boolean;
|
|
277
|
+
'confidential-port': number;
|
|
278
|
+
jwk: { keys: any[] }; // Tide extension
|
|
279
|
+
vendorId: string; // Tide extension
|
|
280
|
+
homeOrkUrl: string; // Tide extension
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function loadTideConfig(): TideConfig {
|
|
284
|
+
// Priority: env var > file
|
|
285
|
+
if (process.env.CLIENT_ADAPTER) {
|
|
286
|
+
return JSON.parse(process.env.CLIENT_ADAPTER);
|
|
287
|
+
}
|
|
288
|
+
if (process.env.TIDECLOAK_CONFIG_B64) {
|
|
289
|
+
return JSON.parse(Buffer.from(process.env.TIDECLOAK_CONFIG_B64, 'base64').toString());
|
|
290
|
+
}
|
|
291
|
+
return JSON.parse(readFileSync('data/tidecloak.json', 'utf-8'));
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
**Agent implication**:
|
|
296
|
+
- Use embedded JWKS from adapter JSON only. Do not use `createRemoteJWKSet`. Do not fetch from `{realm}/protocol/openid-connect/certs`. If `jwk` is missing, re-export adapter with IGA enabled. (I-04)
|
|
297
|
+
- `jwk` field is only present when IGA is enabled on the realm. Validate at startup.
|
|
298
|
+
|
|
299
|
+
### Server-Side JWT Verification via `@tidecloak/verify`
|
|
300
|
+
|
|
301
|
+
**Alternative pattern** **VERIFIED** (keylessh exemplar):
|
|
302
|
+
|
|
303
|
+
The `@tidecloak/verify` package provides a higher-level verification function. It is CJS — never use named ESM imports.
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// @tidecloak/verify is CJS — use defensive interop pattern
|
|
307
|
+
import TideJWT from '@tidecloak/verify';
|
|
308
|
+
const _mod = (TideJWT as any)?.default ?? TideJWT;
|
|
309
|
+
const { verifyTideCloakToken } = _mod;
|
|
310
|
+
|
|
311
|
+
// config = parsed tidecloak.json adapter config
|
|
312
|
+
// token = raw JWT string (without "Bearer " prefix)
|
|
313
|
+
// allowedRoles = string[] of required realm roles (empty array to skip role check)
|
|
314
|
+
const payload = await verifyTideCloakToken(config, token, allowedRoles);
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
**Anti-pattern**: `import { verifyTideCloakToken } from "@tidecloak/verify"` fails with `does not provide an export named 'verifyTideCloakToken'`. The package is CJS. Always use the default import with defensive interop.
|
|
318
|
+
|
|
319
|
+
**Express middleware using `@tidecloak/verify`:**
|
|
320
|
+
|
|
321
|
+
```typescript
|
|
322
|
+
import TideJWT from '@tidecloak/verify';
|
|
323
|
+
const _mod = (TideJWT as any)?.default ?? TideJWT;
|
|
324
|
+
const { verifyTideCloakToken } = _mod;
|
|
325
|
+
|
|
326
|
+
async function authenticate(req, res, next) {
|
|
327
|
+
const token = req.headers.authorization?.substring(7);
|
|
328
|
+
if (!token) return res.status(401).json({ message: "No token" });
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const payload = await verifyTideCloakToken(config, token, []);
|
|
332
|
+
req.user = {
|
|
333
|
+
id: payload.sub,
|
|
334
|
+
username: payload.preferred_username,
|
|
335
|
+
email: payload.email,
|
|
336
|
+
roles: payload.realm_access?.roles || [],
|
|
337
|
+
};
|
|
338
|
+
next();
|
|
339
|
+
} catch (error) {
|
|
340
|
+
res.status(401).json({ message: "Token verification failed" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
app.use("/api/*", authenticate);
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
### DPoP Verification (Server-Side)
|
|
350
|
+
|
|
351
|
+
**Pattern** **VERIFIED** (keylessh `server/auth.ts`):
|
|
352
|
+
|
|
353
|
+
```typescript
|
|
354
|
+
// lib/auth/dpop.ts
|
|
355
|
+
import { jwtVerify, decodeJwt } from 'jose';
|
|
356
|
+
import { createHash } from 'crypto';
|
|
357
|
+
|
|
358
|
+
const jtiCache = new Map<string, number>(); // jti -> expiry timestamp
|
|
359
|
+
const JTI_TTL_MS = 2 * 60 * 1000; // 2 minutes
|
|
360
|
+
|
|
361
|
+
export async function verifyDPoP(
|
|
362
|
+
req: Request,
|
|
363
|
+
accessToken: string
|
|
364
|
+
): Promise<void> {
|
|
365
|
+
const dpopHeader = req.headers.get('dpop');
|
|
366
|
+
if (!dpopHeader) {
|
|
367
|
+
throw new Error('DPoP header missing');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Decode without verification first to get jwk
|
|
371
|
+
const unverified = decodeJwt(dpopHeader);
|
|
372
|
+
if (unverified.typ !== 'dpop+jwt') {
|
|
373
|
+
throw new Error('Invalid DPoP type');
|
|
374
|
+
}
|
|
375
|
+
if (unverified.alg !== 'ES256' && unverified.alg !== 'EdDSA') {
|
|
376
|
+
throw new Error('Invalid DPoP algorithm');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Verify signature
|
|
380
|
+
const jwk = unverified.jwk as any;
|
|
381
|
+
const { payload } = await jwtVerify(dpopHeader, await importJWK(jwk));
|
|
382
|
+
|
|
383
|
+
// Verify htm and htu
|
|
384
|
+
const url = new URL(req.url);
|
|
385
|
+
if (payload.htm !== req.method) {
|
|
386
|
+
throw new Error('DPoP htm mismatch');
|
|
387
|
+
}
|
|
388
|
+
if (payload.htu !== `${url.protocol}//${url.host}${url.pathname}`) {
|
|
389
|
+
throw new Error('DPoP htu mismatch');
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Verify iat freshness (120s window)
|
|
393
|
+
const now = Math.floor(Date.now() / 1000);
|
|
394
|
+
if (!payload.iat || payload.iat < now - 120 || payload.iat > now + 120) {
|
|
395
|
+
throw new Error('DPoP iat out of range');
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Verify jti not replayed
|
|
399
|
+
const jti = payload.jti as string;
|
|
400
|
+
if (jtiCache.has(jti)) {
|
|
401
|
+
throw new Error('DPoP jti replayed');
|
|
402
|
+
}
|
|
403
|
+
jtiCache.set(jti, Date.now() + JTI_TTL_MS);
|
|
404
|
+
|
|
405
|
+
// Clean expired jtis
|
|
406
|
+
const expiredKeys = Array.from(jtiCache.entries())
|
|
407
|
+
.filter(([_, expiry]) => expiry < Date.now())
|
|
408
|
+
.map(([key, _]) => key);
|
|
409
|
+
expiredKeys.forEach(key => jtiCache.delete(key));
|
|
410
|
+
|
|
411
|
+
// Verify cnf.jkt in access token matches DPoP proof
|
|
412
|
+
const tokenPayload = decodeJwt(accessToken);
|
|
413
|
+
const expectedJkt = createHash('sha256').update(JSON.stringify(jwk)).digest('base64url');
|
|
414
|
+
if (tokenPayload.cnf?.jkt !== expectedJkt) {
|
|
415
|
+
throw new Error('DPoP jkt mismatch');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
**Agent implication**:
|
|
421
|
+
- DPoP verification is per-request; maintain in-memory `jti` cache with TTL
|
|
422
|
+
- DPoP is required for Tide's full security guarantees **VERIFIED** (vendor confirmation, GAP-032 resolved). ES256 default.
|
|
423
|
+
|
|
424
|
+
---
|
|
425
|
+
|
|
426
|
+
### E2EE Usage (Client-Side)
|
|
427
|
+
|
|
428
|
+
```typescript
|
|
429
|
+
'use client';
|
|
430
|
+
import { useTideCloak } from '@tidecloak/nextjs';
|
|
431
|
+
|
|
432
|
+
export function EncryptedForm() {
|
|
433
|
+
const { doEncrypt, doDecrypt } = useTideCloak();
|
|
434
|
+
|
|
435
|
+
async function handleSubmit(plaintext: string) {
|
|
436
|
+
// Encrypt before sending to server
|
|
437
|
+
const ciphertext = await doEncrypt('ssn', plaintext);
|
|
438
|
+
await fetch('/api/users', {
|
|
439
|
+
method: 'POST',
|
|
440
|
+
body: JSON.stringify({ ssn: ciphertext }),
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function handleLoad(ciphertext: string) {
|
|
445
|
+
// Decrypt after receiving from server
|
|
446
|
+
const plaintext = await doDecrypt('ssn', ciphertext);
|
|
447
|
+
console.log(plaintext);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return <form onSubmit={...}>...</form>;
|
|
451
|
+
}
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
**Agent implication**:
|
|
455
|
+
- Requires roles: `_tide_ssn.selfencrypt`, `_tide_ssn.selfdecrypt`
|
|
456
|
+
- Both `Uint8Array` (binary) and string inputs supported **VERIFIED** (vendor confirmation, GAP-013 resolved)
|
|
457
|
+
- Requires online Fabric access **VERIFIED**
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
### Silent SSO Setup
|
|
462
|
+
|
|
463
|
+
**Required file** **VERIFIED** (tidecloak-nextjs-how-to-guide.md):
|
|
464
|
+
|
|
465
|
+
```html
|
|
466
|
+
<!-- public/silent-check-sso.html -->
|
|
467
|
+
<html>
|
|
468
|
+
<body>
|
|
469
|
+
<script>
|
|
470
|
+
parent.postMessage(location.href, location.origin);
|
|
471
|
+
</script>
|
|
472
|
+
</body>
|
|
473
|
+
</html>
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
**Corrupted state recovery pattern** **VERIFIED** (keylessh `client/src/contexts/AuthContext.tsx`, `main.tsx`):
|
|
477
|
+
|
|
478
|
+
```typescript
|
|
479
|
+
// app/layout.tsx or pages/_app.tsx
|
|
480
|
+
import { useEffect } from 'react';
|
|
481
|
+
|
|
482
|
+
export default function RootLayout({ children }) {
|
|
483
|
+
useEffect(() => {
|
|
484
|
+
// Check for corrupted auth state on startup
|
|
485
|
+
const params = new URLSearchParams(window.location.search);
|
|
486
|
+
if (params.get('reset') === 'true') {
|
|
487
|
+
// Clear all auth state
|
|
488
|
+
const keys = Object.keys(localStorage);
|
|
489
|
+
keys.forEach(key => {
|
|
490
|
+
if (key.includes('iam') || key.includes('auth')) {
|
|
491
|
+
localStorage.removeItem(key);
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
window.location.href = '/';
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Detect stale state: has IAM data but init failed
|
|
499
|
+
const hasIamData = Object.keys(localStorage).some(k =>
|
|
500
|
+
k.includes('iam') || k.includes('auth')
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// If init timeout (10s) and stale data exists, offer reset
|
|
504
|
+
setTimeout(() => {
|
|
505
|
+
if (hasIamData && !tideCloakContext.authenticated) {
|
|
506
|
+
console.warn('Stale auth state detected. Append ?reset=true to clear.');
|
|
507
|
+
}
|
|
508
|
+
}, 10000);
|
|
509
|
+
}, []);
|
|
510
|
+
|
|
511
|
+
return <TideCloakContextProvider>{children}</TideCloakContextProvider>;
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
**Agent implication**: Defensive pattern from keylessh; not SDK-provided.
|
|
516
|
+
|
|
517
|
+
---
|
|
518
|
+
|
|
519
|
+
### Request Interception / Route Protection (UI Only)
|
|
520
|
+
|
|
521
|
+
Next.js 16+ uses `proxy.ts` (or `src/proxy.ts`) for request interception. Next.js 15 and earlier use `middleware.ts`. Check the project's Next.js version before choosing the filename.
|
|
522
|
+
|
|
523
|
+
**Version detection**:
|
|
524
|
+
```bash
|
|
525
|
+
# Detect Next.js major version from the installed package
|
|
526
|
+
NEXT_MAJOR=$(node -e "try{console.log(require('next/package.json').version.split('.')[0])}catch{console.log('unknown')}" 2>/dev/null)
|
|
527
|
+
# 16+ → proxy.ts | 15 or earlier → middleware.ts
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
// proxy.ts (Next.js 16+) — called middleware.ts in Next.js 15 and earlier
|
|
532
|
+
import { NextResponse } from 'next/server';
|
|
533
|
+
import type { NextRequest } from 'next/server';
|
|
534
|
+
|
|
535
|
+
export function middleware(req: NextRequest) {
|
|
536
|
+
// WARNING: This is UI gating only, NOT real authorization
|
|
537
|
+
const session = req.cookies.get('SESSION_COOKIE_NAME');
|
|
538
|
+
|
|
539
|
+
if (!session && req.nextUrl.pathname.startsWith('/admin')) {
|
|
540
|
+
return NextResponse.redirect(new URL('/login', req.url));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return NextResponse.next();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export const config = {
|
|
547
|
+
matcher: '/admin/:path*',
|
|
548
|
+
};
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
**Critical**: Proxy/middleware route protection is UI gating. Always verify JWT server-side in API routes.
|
|
552
|
+
|
|
553
|
+
**Version rule**: Next.js 16+ uses `proxy.ts`. Next.js 15 and earlier use `middleware.ts`. Always check the project's installed Next.js version before creating or renaming this file. If migrating from `middleware.ts` to `proxy.ts`, rename the file. Next.js provides a codemod for automated migration.
|
|
554
|
+
|
|
555
|
+
**Do not confuse** `proxy.ts` (Next.js request interception at the edge) with `lib/auth/protect.ts` (Tide auth helper with `withAuth`/`withRole`). They are unrelated files with different purposes.
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## React (Vite/CRA) Implementation
|
|
560
|
+
|
|
561
|
+
### Prerequisites
|
|
562
|
+
|
|
563
|
+
**Verified requirements** (tidecloak-react-tutorial-quickstart.md):
|
|
564
|
+
- React 18+
|
|
565
|
+
- Vite or Create React App
|
|
566
|
+
- TideCloak instance running
|
|
567
|
+
- IGA enabled on realm **VERIFIED** (vendor confirmation, batch-02 Q-04). Canonical ordering: license → IGA → E2EE.
|
|
568
|
+
|
|
569
|
+
### Package Installation
|
|
570
|
+
|
|
571
|
+
```bash
|
|
572
|
+
npm install @tidecloak/react
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
**INFERRED** (A-11): Package assumed on public npm registry.
|
|
576
|
+
|
|
577
|
+
---
|
|
578
|
+
|
|
579
|
+
### SDK API Reference
|
|
580
|
+
|
|
581
|
+
**`useTideCloak()` hook** **VERIFIED** (SDK documentation, operational exemplars):
|
|
582
|
+
|
|
583
|
+
```tsx
|
|
584
|
+
import { useTideCloak, Authenticated, Unauthenticated } from '@tidecloak/react';
|
|
585
|
+
|
|
586
|
+
const {
|
|
587
|
+
authenticated, // boolean - is user logged in
|
|
588
|
+
login, // () => void - redirect to TideCloak login
|
|
589
|
+
logout, // () => void - log out
|
|
590
|
+
token, // string - raw JWT access token
|
|
591
|
+
tokenExp, // number - token expiry timestamp
|
|
592
|
+
refreshToken, // () => Promise - refresh the token
|
|
593
|
+
getValueFromToken, // (key: string) => any - read JWT claim
|
|
594
|
+
getValueFromIdToken, // (key: string) => any - read ID token claim
|
|
595
|
+
hasRealmRole, // (role: string) => boolean
|
|
596
|
+
hasClientRole, // (role: string, client?: string) => boolean
|
|
597
|
+
isInitializing, // boolean - SDK still loading
|
|
598
|
+
doEncrypt, // E2EE encrypt
|
|
599
|
+
doDecrypt, // E2EE decrypt
|
|
600
|
+
IAMService, // direct access to IAMService
|
|
601
|
+
} = useTideCloak();
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
**Guard components:**
|
|
605
|
+
|
|
606
|
+
```tsx
|
|
607
|
+
<Authenticated> {/* renders only when logged in */}
|
|
608
|
+
<Unauthenticated> {/* renders only when logged out */}
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
**Critical**: `hasRealmRole()` and `hasClientRole()` are UI gating, NOT real authorization. Server must verify JWT.
|
|
612
|
+
|
|
613
|
+
**Anti-pattern**: `tide-realm-admin` is a **client role** on the `realm-management` client, not a realm role. Use `hasClientRole("tide-realm-admin", "realm-management")`, not `hasRealmRole("tide-realm-admin")`.
|
|
614
|
+
|
|
615
|
+
**Anti-pattern**: Do not call `login()` before the SDK has initialized. Check `isInitializing` at the top of the component tree, BEFORE `<Authenticated>` / `<Unauthenticated>` guards render. See Provider Setup below.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
### Provider Setup
|
|
620
|
+
|
|
621
|
+
**Anti-pattern**: The prop is `configUrl`, NOT `configFilePath`. Using the wrong prop falls through to the default `/adapter.json`, which does not exist, and Vite returns the HTML index page instead (`SyntaxError: Unexpected token '<'`).
|
|
622
|
+
|
|
623
|
+
```typescript
|
|
624
|
+
// src/main.tsx (Vite) or src/index.tsx (CRA)
|
|
625
|
+
import React from 'react';
|
|
626
|
+
import ReactDOM from 'react-dom/client';
|
|
627
|
+
import { TideCloakContextProvider } from '@tidecloak/react';
|
|
628
|
+
import App from './App';
|
|
629
|
+
|
|
630
|
+
// Config file must be in public/tidecloak.json — do NOT import as a JS module
|
|
631
|
+
const initOptions = { enableDpop: true }; // DPoP required for Tide security; ES256 is the default algorithm
|
|
632
|
+
|
|
633
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
634
|
+
<React.StrictMode>
|
|
635
|
+
<TideCloakContextProvider
|
|
636
|
+
configUrl="/tidecloak.json"
|
|
637
|
+
initOptions={initOptions}
|
|
638
|
+
>
|
|
639
|
+
<App />
|
|
640
|
+
</TideCloakContextProvider>
|
|
641
|
+
</React.StrictMode>
|
|
642
|
+
);
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
**`isInitializing` guard pattern** **VERIFIED** (SDK documentation, operational exemplars):
|
|
646
|
+
|
|
647
|
+
`<Unauthenticated>` renders during initialization because the user is not yet authenticated. This exposes child components before the SDK is ready. Block the entire tree first:
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
import { useTideCloak, Authenticated, Unauthenticated } from '@tidecloak/react';
|
|
651
|
+
|
|
652
|
+
function App() {
|
|
653
|
+
return (
|
|
654
|
+
<TideCloakContextProvider configUrl="/tidecloak.json" initOptions={{ enableDpop: true }}>
|
|
655
|
+
<AppContent />
|
|
656
|
+
</TideCloakContextProvider>
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
function AppContent() {
|
|
661
|
+
const { isInitializing } = useTideCloak();
|
|
662
|
+
if (isInitializing) return <p>Initializing TideCloak...</p>;
|
|
663
|
+
return (
|
|
664
|
+
<>
|
|
665
|
+
<Authenticated><UserContent /></Authenticated>
|
|
666
|
+
<Unauthenticated><LoginPage /></Unauthenticated>
|
|
667
|
+
</>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
**Common failure**: Calling `login()` before initialization completes produces `TideCloak client not initialized - call initIAM() first`.
|
|
673
|
+
|
|
674
|
+
---
|
|
675
|
+
|
|
676
|
+
### Auth State Access
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
// src/components/UserInfo.tsx
|
|
680
|
+
import { useTideCloak } from '@tidecloak/react';
|
|
681
|
+
|
|
682
|
+
export function UserInfo() {
|
|
683
|
+
const { authenticated, getValueFromIdToken, login, logout, hasRealmRole, doEncrypt, doDecrypt } = useTideCloak();
|
|
684
|
+
|
|
685
|
+
if (!authenticated) {
|
|
686
|
+
return <button onClick={login}>Login</button>;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<div>
|
|
691
|
+
<p>Welcome {getValueFromIdToken('preferred_username')}</p>
|
|
692
|
+
{hasRealmRole('admin') && <AdminPanel />} {/* UI gating only */}
|
|
693
|
+
<button onClick={logout}>Logout</button>
|
|
694
|
+
</div>
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
---
|
|
700
|
+
|
|
701
|
+
### Auth Bridge Pattern (keylessh)
|
|
702
|
+
|
|
703
|
+
**Pattern** **VERIFIED** (keylessh `client/src/contexts/AuthContext.tsx`):
|
|
704
|
+
|
|
705
|
+
Syncs IAMService (Vanilla JS) state into React context for apps using both.
|
|
706
|
+
|
|
707
|
+
```typescript
|
|
708
|
+
// src/contexts/AuthContext.tsx
|
|
709
|
+
import { createContext, useContext, useEffect, useState } from 'react';
|
|
710
|
+
import { TideCloakContextProvider } from '@tidecloak/react';
|
|
711
|
+
import { IAMService } from '@tidecloak/js';
|
|
712
|
+
|
|
713
|
+
interface AuthContextType {
|
|
714
|
+
authenticated: boolean;
|
|
715
|
+
user: any | null;
|
|
716
|
+
login: () => void;
|
|
717
|
+
logout: () => void;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const AuthContext = createContext<AuthContextType | null>(null);
|
|
721
|
+
|
|
722
|
+
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
723
|
+
const [authenticated, setAuthenticated] = useState(false);
|
|
724
|
+
const [user, setUser] = useState(null);
|
|
725
|
+
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
const iam = IAMService; // singleton, direct import — do NOT call getInstance()
|
|
728
|
+
|
|
729
|
+
// Listen to IAM events
|
|
730
|
+
iam.on('tokenExpired', () => {
|
|
731
|
+
setAuthenticated(false);
|
|
732
|
+
setUser(null);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
iam.on('authRefreshSuccess', async () => {
|
|
736
|
+
const token = await iam.getToken();
|
|
737
|
+
if (token) {
|
|
738
|
+
setAuthenticated(true);
|
|
739
|
+
setUser(await iam.getUserInfo());
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
iam.on('authRefreshError', () => {
|
|
744
|
+
setAuthenticated(false);
|
|
745
|
+
setUser(null);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// Initial state
|
|
749
|
+
iam.getToken().then(token => {
|
|
750
|
+
if (token) {
|
|
751
|
+
setAuthenticated(true);
|
|
752
|
+
iam.getUserInfo().then(setUser);
|
|
753
|
+
}
|
|
754
|
+
});
|
|
755
|
+
}, []);
|
|
756
|
+
|
|
757
|
+
return (
|
|
758
|
+
<AuthContext.Provider value={{ authenticated, user, login: iam.doLogin, logout: iam.doLogout }}>
|
|
759
|
+
<TideCloakContextProvider>{children}</TideCloakContextProvider>
|
|
760
|
+
</AuthContext.Provider>
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
export const useAuth = () => useContext(AuthContext)!;
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
**Agent implication**: Bridge pattern is keylessh-specific; not SDK-prescribed. Use when integrating with existing IAMService code.
|
|
768
|
+
|
|
769
|
+
---
|
|
770
|
+
|
|
771
|
+
### Silent SSO Setup (React)
|
|
772
|
+
|
|
773
|
+
Same as Next.js:
|
|
774
|
+
|
|
775
|
+
```html
|
|
776
|
+
<!-- public/silent-check-sso.html -->
|
|
777
|
+
<html>
|
|
778
|
+
<body>
|
|
779
|
+
<script>
|
|
780
|
+
parent.postMessage(location.href, location.origin);
|
|
781
|
+
</script>
|
|
782
|
+
</body>
|
|
783
|
+
</html>
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
---
|
|
787
|
+
|
|
788
|
+
### Backend Integration (Express)
|
|
789
|
+
|
|
790
|
+
**Pattern** **VERIFIED** (keylessh `server/auth.ts`, `server/index.ts`):
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
// server/auth.ts
|
|
794
|
+
import express from 'express';
|
|
795
|
+
import { verifyTideJWT } from './lib/auth/tideJWT';
|
|
796
|
+
import { verifyDPoP } from './lib/auth/dpop';
|
|
797
|
+
|
|
798
|
+
export function requireAuth(requiredRole?: string) {
|
|
799
|
+
return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
800
|
+
const authHeader = req.headers.authorization;
|
|
801
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
802
|
+
return res.status(401).json({ error: 'Unauthorized' });
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const token = authHeader.substring(7);
|
|
806
|
+
|
|
807
|
+
try {
|
|
808
|
+
// Verify DPoP if present
|
|
809
|
+
if (req.headers.dpop) {
|
|
810
|
+
await verifyDPoP(req, token);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Verify JWT
|
|
814
|
+
const jwt = await verifyTideJWT(token);
|
|
815
|
+
|
|
816
|
+
// Check role if required
|
|
817
|
+
if (requiredRole && !jwt.realm_access?.roles?.includes(requiredRole)) {
|
|
818
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
req.user = jwt;
|
|
822
|
+
next();
|
|
823
|
+
} catch (err) {
|
|
824
|
+
return res.status(401).json({ error: 'Invalid token' });
|
|
825
|
+
}
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Usage
|
|
830
|
+
app.get('/api/admin/users', requireAuth('admin'), (req, res) => {
|
|
831
|
+
res.json({ users: [...] });
|
|
832
|
+
});
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
---
|
|
836
|
+
|
|
837
|
+
### CSP Configuration (React/Express)
|
|
838
|
+
|
|
839
|
+
**Required** **VERIFIED** (vendor confirmation, GAP-028 resolved):
|
|
840
|
+
|
|
841
|
+
```typescript
|
|
842
|
+
// server/index.ts
|
|
843
|
+
import helmet from 'helmet';
|
|
844
|
+
|
|
845
|
+
app.use(helmet({
|
|
846
|
+
contentSecurityPolicy: {
|
|
847
|
+
directives: {
|
|
848
|
+
frameSrc: ["'self'", '*'],
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
}));
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
`frame-src '*'` required for ORK re-homing. No fixed domain list.
|
|
855
|
+
|
|
856
|
+
---
|
|
857
|
+
|
|
858
|
+
## Vanilla JS Implementation
|
|
859
|
+
|
|
860
|
+
### Prerequisites
|
|
861
|
+
|
|
862
|
+
**Verified requirements** (tidecloak-js-tutorial.md):
|
|
863
|
+
- Modern browser (ES6+)
|
|
864
|
+
- TideCloak instance running
|
|
865
|
+
- IGA enabled on realm **VERIFIED** (vendor confirmation, batch-02 Q-04). Canonical ordering: license → IGA → E2EE.
|
|
866
|
+
|
|
867
|
+
### Package Installation
|
|
868
|
+
|
|
869
|
+
```bash
|
|
870
|
+
npm install @tidecloak/js
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
**INFERRED** (A-11): Package assumed on public npm registry.
|
|
874
|
+
|
|
875
|
+
---
|
|
876
|
+
|
|
877
|
+
### IAMService Initialization
|
|
878
|
+
|
|
879
|
+
```typescript
|
|
880
|
+
// src/auth.ts
|
|
881
|
+
import { IAMService } from '@tidecloak/js';
|
|
882
|
+
|
|
883
|
+
// IAMService is a pre-instantiated singleton — use it directly.
|
|
884
|
+
// Do NOT call IAMService.getInstance() — it does not exist.
|
|
885
|
+
const iam = IAMService;
|
|
886
|
+
|
|
887
|
+
// Pass the FULL adapter JSON from tidecloak.json, not a subset.
|
|
888
|
+
// Do NOT destructure into {url, realm, clientId} — initIAM reads
|
|
889
|
+
// "auth-server-url", "realm", "resource", "vendorId", "homeOrkUrl", etc.
|
|
890
|
+
const config = await fetch('/api/config').then(r => r.json());
|
|
891
|
+
await iam.initIAM({ ...config, useDPoP: { mode: 'strict', alg: 'ES256' } });
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
---
|
|
895
|
+
|
|
896
|
+
### Event-Driven Auth State
|
|
897
|
+
|
|
898
|
+
```typescript
|
|
899
|
+
// Listen to auth events
|
|
900
|
+
iam.on('tokenExpired', () => {
|
|
901
|
+
console.log('Token expired, redirecting to login');
|
|
902
|
+
iam.doLogin({ redirectUri: window.location.origin });
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
iam.on('authRefreshSuccess', async () => {
|
|
906
|
+
console.log('Token refreshed');
|
|
907
|
+
const user = await iam.getUserInfo();
|
|
908
|
+
updateUI(user);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
iam.on('authRefreshError', (error) => {
|
|
912
|
+
console.error('Refresh failed', error);
|
|
913
|
+
iam.doLogin({ redirectUri: window.location.origin });
|
|
914
|
+
});
|
|
915
|
+
```
|
|
916
|
+
|
|
917
|
+
**Agent implication**: Error handling from keylessh pattern **VERIFIED** (keylessh `AuthContext.tsx`).
|
|
918
|
+
|
|
919
|
+
---
|
|
920
|
+
|
|
921
|
+
### Login / Logout
|
|
922
|
+
|
|
923
|
+
```typescript
|
|
924
|
+
// Login
|
|
925
|
+
document.getElementById('login-btn').addEventListener('click', () => {
|
|
926
|
+
iam.doLogin({ redirectUri: window.location.origin });
|
|
927
|
+
});
|
|
928
|
+
|
|
929
|
+
// Logout
|
|
930
|
+
document.getElementById('logout-btn').addEventListener('click', () => {
|
|
931
|
+
iam.doLogout({ redirectUri: window.location.origin });
|
|
932
|
+
});
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
---
|
|
936
|
+
|
|
937
|
+
### Role Checks (UI Gating Only)
|
|
938
|
+
|
|
939
|
+
```typescript
|
|
940
|
+
const hasAdminRole = iam.hasRealmRole('admin');
|
|
941
|
+
|
|
942
|
+
if (hasAdminRole) {
|
|
943
|
+
document.getElementById('admin-panel').style.display = 'block';
|
|
944
|
+
} else {
|
|
945
|
+
document.getElementById('admin-panel').style.display = 'none';
|
|
946
|
+
}
|
|
947
|
+
```
|
|
948
|
+
|
|
949
|
+
**Critical**: This is UI gating, NOT authorization. Server must verify JWT.
|
|
950
|
+
|
|
951
|
+
---
|
|
952
|
+
|
|
953
|
+
### Secure API Calls
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
// Using secureFetch (SDK-provided DPoP-aware fetch)
|
|
957
|
+
const response = await iam.secureFetch('/api/admin/users', {
|
|
958
|
+
method: 'GET',
|
|
959
|
+
headers: {
|
|
960
|
+
'Content-Type': 'application/json',
|
|
961
|
+
},
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
const users = await response.json();
|
|
965
|
+
```
|
|
966
|
+
|
|
967
|
+
**Agent implication**: `secureFetch()` automatically adds `Authorization` and `DPoP` headers.
|
|
968
|
+
|
|
969
|
+
---
|
|
970
|
+
|
|
971
|
+
### E2EE (Vanilla JS)
|
|
972
|
+
|
|
973
|
+
```typescript
|
|
974
|
+
// Encrypt
|
|
975
|
+
const plaintext = 'sensitive data';
|
|
976
|
+
const ciphertext = await iam.doEncrypt('ssn', plaintext);
|
|
977
|
+
|
|
978
|
+
// Send to server
|
|
979
|
+
await iam.secureFetch('/api/users', {
|
|
980
|
+
method: 'POST',
|
|
981
|
+
body: JSON.stringify({ ssn: ciphertext }),
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
// Decrypt
|
|
985
|
+
const response = await iam.secureFetch('/api/users/123');
|
|
986
|
+
const data = await response.json();
|
|
987
|
+
const plaintext = await iam.doDecrypt('ssn', data.ssn);
|
|
988
|
+
```
|
|
989
|
+
|
|
990
|
+
---
|
|
991
|
+
|
|
992
|
+
### Silent SSO Setup (Vanilla JS)
|
|
993
|
+
|
|
994
|
+
Same file as React/Next.js:
|
|
995
|
+
|
|
996
|
+
```html
|
|
997
|
+
<!-- public/silent-check-sso.html -->
|
|
998
|
+
<html>
|
|
999
|
+
<body>
|
|
1000
|
+
<script>
|
|
1001
|
+
parent.postMessage(location.href, location.origin);
|
|
1002
|
+
</script>
|
|
1003
|
+
</body>
|
|
1004
|
+
</html>
|
|
1005
|
+
```
|
|
1006
|
+
|
|
1007
|
+
---
|
|
1008
|
+
|
|
1009
|
+
## Framework Comparison Matrix
|
|
1010
|
+
|
|
1011
|
+
| Feature | Next.js | React | Vanilla JS | Notes |
|
|
1012
|
+
|---------|---------|-------|------------|-------|
|
|
1013
|
+
| **Package** | `@tidecloak/nextjs` | `@tidecloak/react` | `@tidecloak/js` | INFERRED (A-11) |
|
|
1014
|
+
| **Provider** | `<TideCloakContextProvider>` | `<TideCloakContextProvider>` | `IAMService` (singleton, direct import) | VERIFIED |
|
|
1015
|
+
| **Auth state** | `useTideCloak()` hook | `useTideCloak()` hook | Event listeners | VERIFIED |
|
|
1016
|
+
| **Login/Logout** | Hook methods | Hook methods | `doLogin()` / `doLogout()` | VERIFIED |
|
|
1017
|
+
| **E2EE** | Hook methods | Hook methods | `doEncrypt()` / `doDecrypt()` | VERIFIED |
|
|
1018
|
+
| **Server-side JWT** | API routes | Express middleware | N/A (client-only) | VERIFIED (keylessh) |
|
|
1019
|
+
| **DPoP verification** | API routes | Express middleware | N/A (client-only) | VERIFIED (keylessh) |
|
|
1020
|
+
| **CSP setup** | `next.config.js` headers | Express `helmet()` | HTML meta tag | VERIFIED (keylessh) |
|
|
1021
|
+
| **Silent SSO** | `public/silent-check-sso.html` | `public/silent-check-sso.html` | `public/silent-check-sso.html` | VERIFIED |
|
|
1022
|
+
| **Route protection** | Middleware (UI only) | React Router guards (UI only) | Manual checks (UI only) | VERIFIED |
|
|
1023
|
+
| **API protection** | `verifyTideJWT()` in routes | `requireAuth()` middleware | Server-side separate | VERIFIED (keylessh) |
|
|
1024
|
+
|
|
1025
|
+
---
|
|
1026
|
+
|
|
1027
|
+
## Common Patterns Across Frameworks
|
|
1028
|
+
|
|
1029
|
+
### Adapter JSON Loading
|
|
1030
|
+
|
|
1031
|
+
All frameworks need server-side config:
|
|
1032
|
+
|
|
1033
|
+
```typescript
|
|
1034
|
+
// Priority: env var > file
|
|
1035
|
+
function loadTideConfig() {
|
|
1036
|
+
if (process.env.CLIENT_ADAPTER) {
|
|
1037
|
+
return JSON.parse(process.env.CLIENT_ADAPTER);
|
|
1038
|
+
}
|
|
1039
|
+
if (process.env.TIDECLOAK_CONFIG_B64) {
|
|
1040
|
+
return JSON.parse(Buffer.from(process.env.TIDECLOAK_CONFIG_B64, 'base64').toString());
|
|
1041
|
+
}
|
|
1042
|
+
return JSON.parse(fs.readFileSync('data/tidecloak.json', 'utf-8'));
|
|
1043
|
+
}
|
|
1044
|
+
```
|
|
1045
|
+
|
|
1046
|
+
**VERIFIED** (keylessh `tidecloakConfig.ts`, bridge configs).
|
|
1047
|
+
|
|
1048
|
+
---
|
|
1049
|
+
|
|
1050
|
+
### Token Extraction (Server-Side)
|
|
1051
|
+
|
|
1052
|
+
```typescript
|
|
1053
|
+
function extractToken(req: Request): string {
|
|
1054
|
+
const authHeader = req.headers.get('authorization') || req.headers.authorization;
|
|
1055
|
+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
1056
|
+
throw new Error('Missing or invalid Authorization header');
|
|
1057
|
+
}
|
|
1058
|
+
return authHeader.substring(7);
|
|
1059
|
+
}
|
|
1060
|
+
```
|
|
1061
|
+
|
|
1062
|
+
---
|
|
1063
|
+
|
|
1064
|
+
### Required Realm Roles
|
|
1065
|
+
|
|
1066
|
+
All frameworks require user has `_tide_enabled` role **VERIFIED** (vendor confirmation, GAP-031 resolved). Declare `_tide_enabled` in the realm.json template — `setUpTideRealm` does not create it automatically.
|
|
1067
|
+
|
|
1068
|
+
---
|
|
1069
|
+
|
|
1070
|
+
## Framework-Specific Gaps
|
|
1071
|
+
|
|
1072
|
+
### Next.js
|
|
1073
|
+
|
|
1074
|
+
- Scaffold command behavior **RESOLVED_BY_VENDOR** (GAP-005)
|
|
1075
|
+
- App Router vs Pages Router differences undocumented **ASSUMED**
|
|
1076
|
+
- Middleware CSP enforcement vs `next.config.js` headers unclear **ASSUMED**
|
|
1077
|
+
|
|
1078
|
+
### React
|
|
1079
|
+
|
|
1080
|
+
- CRA vs Vite configuration differences undocumented **ASSUMED**
|
|
1081
|
+
- Auth bridge pattern (IAMService + React Context) not SDK-prescribed **VERIFIED** (keylessh pattern)
|
|
1082
|
+
|
|
1083
|
+
### Vanilla JS
|
|
1084
|
+
|
|
1085
|
+
- No server-side patterns (pure client library)
|
|
1086
|
+
- Module bundler compatibility (Webpack, Rollup, Vite) undocumented **ASSUMED**
|
|
1087
|
+
|
|
1088
|
+
---
|
|
1089
|
+
|
|
1090
|
+
## Post-Auth Redirect Handler
|
|
1091
|
+
|
|
1092
|
+
Every Tide-enabled app must have a working page at its configured `redirectUri`. See [canon/redirect-handler.md](redirect-handler.md) for full doctrine.
|
|
1093
|
+
|
|
1094
|
+
| Framework | Default path | Handler location |
|
|
1095
|
+
|-----------|-------------|-----------------|
|
|
1096
|
+
| Vanilla JS / Vite | `/auth/redirect` | `public/auth/redirect.html` (must include SDK script) |
|
|
1097
|
+
| React SPA | `/auth/redirect` | Route component or SPA fallback |
|
|
1098
|
+
| Next.js App Router | `/auth/redirect` | `app/auth/redirect/page.tsx` |
|
|
1099
|
+
| Next.js Pages Router | `/auth/redirect` | `pages/auth/redirect.tsx` |
|
|
1100
|
+
|
|
1101
|
+
---
|
|
1102
|
+
|
|
1103
|
+
## Key Dependencies
|
|
1104
|
+
|
|
1105
|
+
| Package | Stable version | Purpose |
|
|
1106
|
+
|---------|---------------|---------|
|
|
1107
|
+
| `@tidecloak/js` | 0.13.26 | Core SDK (vanilla JS) |
|
|
1108
|
+
| `@tidecloak/react` | 0.13.26 | React hooks and guards |
|
|
1109
|
+
| `@tidecloak/nextjs` | 0.13.26 | Next.js provider and hooks |
|
|
1110
|
+
| `@tidecloak/verify` | 0.13.26 | Server-side JWT verification (CJS) |
|
|
1111
|
+
| `heimdall-tide` | 0.13.26 | Policy signing, BasicCustomRequest |
|
|
1112
|
+
| `@tideorg/js` | 0.13.26 | Models, Contracts (Forseti) |
|
|
1113
|
+
| `asgard-tide` | 0.13.26 | Vendor validation |
|
|
1114
|
+
|
|
1115
|
+
All Tide packages are pre-1.0. Pin to exact versions in templates. See [canon/version-policy.md](version-policy.md) for the full version policy.
|
|
1116
|
+
|
|
1117
|
+
---
|
|
1118
|
+
|
|
1119
|
+
## Status Legend
|
|
1120
|
+
|
|
1121
|
+
- **VERIFIED** - Directly sourced from documentation or keylessh exemplar
|
|
1122
|
+
- **INFERRED** - Strongly implied by source material
|
|
1123
|
+
- **ASSUMED** - Operator guidance where sources are silent
|
|
1124
|
+
- **REQUIRES_RUNTIME_VALIDATION** - Single-app evidence; needs confirmation
|
|
1125
|
+
- **STILL_UNRESOLVED** - Open gap
|