0
0
# SSR Auth Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Server-side session cookies so SSR knows the viewer — no auth flash, authenticated `callXrpc` during SSR.
**Architecture:** OAuth callback sets a signed `HttpOnly` cookie (`did.timestamp.hmac`). SSR middleware forwards the cookie, resolves the viewer, and sets `globalThis.__hatk_viewer`. `callXrpc` and a new `getViewer()` export pick it up automatically.
**Tech Stack:** Web Crypto HMAC-SHA256, existing OAuth keypair, globalThis bridge pattern.
---
### Task 1: Session Cookie Helpers
**Files:**
- Modify: `packages/hatk/src/oauth/server.ts:70-92` (after key initialization)
**Step 1: Add cookie creation and parsing functions**
Add after the `initOAuth` function (after line ~93). These use the existing `serverPrivateKey` from the module scope:
```typescript
// --- SSR Session Cookie ---
let _sessionCookieName = '__hatk_session'
const SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds
export function setSessionCookieName(name: string): void {
_sessionCookieName = name
}
export function getSessionCookieName(): string {
return _sessionCookieName
}
export async function createSessionCookie(did: string): Promise<string> {
const timestamp = Math.floor(Date.now() / 1000)
const payload = `${did}.${timestamp}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(JSON.stringify(serverPrivateJwk)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
const signature = base64UrlEncode(new Uint8Array(sig))
return `${payload}.${signature}`
}
export function sessionCookieHeader(value: string, secure: boolean): string {
const parts = [
`${_sessionCookieName}=${value}`,
'HttpOnly',
'SameSite=Lax',
'Path=/',
`Max-Age=${SESSION_COOKIE_MAX_AGE}`,
]
if (secure) parts.push('Secure')
return parts.join('; ')
}
export function clearSessionCookieHeader(): string {
return `${_sessionCookieName}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`
}
export async function parseSessionCookie(request: Request): Promise<{ did: string } | null> {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null
const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${_sessionCookieName}=`))
if (!match) return null
const value = match.slice(_sessionCookieName.length + 1)
const parts = value.split('.')
// Format: did:plc:xxx.timestamp.signature — DID contains dots so we take last 2 parts
if (parts.length < 3) return null
const signature = parts.pop()!
const timestamp = parts.pop()!
const did = parts.join('.')
const ts = Number(timestamp)
if (isNaN(ts) || (Date.now() / 1000 - ts) > SESSION_COOKIE_MAX_AGE) return null
// Verify HMAC
const payload = `${did}.${timestamp}`
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(JSON.stringify(serverPrivateJwk)),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
)
const sigBytes = base64UrlDecode(signature)
const valid = await crypto.subtle.verify('HMAC', key, sigBytes, new TextEncoder().encode(payload))
if (!valid) return null
return { did }
}
```
Note: `base64UrlEncode` and `base64UrlDecode` are already imported from `./crypto.ts` (line 14).
**Step 2: Build and verify**
Run: `cd /Users/chadmiller/code/hatk/.worktrees/server-directory && npm run build`
Expected: Clean build
---
### Task 2: Return DID from handleCallback
**Files:**
- Modify: `packages/hatk/src/oauth/server.ts:324-449`
**Step 1: Add `did` to the return type and value**
The `handleCallback` function (line 324) currently returns `{ requestUri, clientRedirectUri, clientState }`. Add `did`:
Change line 329:
```typescript
): Promise<{ requestUri: string; clientRedirectUri: string; clientState: string | null; did: string }> {
```
Change line 449:
```typescript
return { requestUri: request.request_uri, clientRedirectUri, clientState: request.state, did }
```
**Step 2: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 3: Set Cookie on OAuth Callback
**Files:**
- Modify: `packages/hatk/src/server.ts:660-670`
**Step 1: Import new cookie functions**
Add to the existing import from `./oauth/server.ts` (line 34-45):
```typescript
import {
// ... existing imports ...
createSessionCookie,
sessionCookieHeader,
clearSessionCookieHeader,
parseSessionCookie,
setSessionCookieName,
} from './oauth/server.ts'
```
**Step 2: Set cookie on callback redirect**
Replace lines 666-667:
```typescript
const result = await handleCallback(oauth, code, state, iss)
return new Response(null, { status: 302, headers: { Location: result.clientRedirectUri } })
```
With:
```typescript
const result = await handleCallback(oauth, code, state, iss)
const isSecure = requestOrigin.startsWith('https')
const cookie = await createSessionCookie(result.did)
return new Response(null, {
status: 302,
headers: [
['Location', result.clientRedirectUri],
['Set-Cookie', sessionCookieHeader(cookie, isSecure)],
],
})
```
**Step 3: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 4: Add POST /auth/logout Route
**Files:**
- Modify: `packages/hatk/src/server.ts` (add after the `/oauth/callback` block, around line 670)
**Step 1: Add the logout route**
After the OAuth callback block (after line ~670, before the `/oauth/token` block):
```typescript
// Session cookie logout
if (url.pathname === '/auth/logout' && request.method === 'POST') {
return new Response(null, {
status: 200,
headers: { 'Set-Cookie': clearSessionCookieHeader() },
})
}
```
**Step 2: Configure cookie name from oauth config**
In the `createHandler` function, after `oauth` is set up (look for where `initOAuth` or oauth config is used), add cookie name configuration. In `createHandler` (around line 160-170), add:
```typescript
if (oauth?.cookieName) {
setSessionCookieName(oauth.cookieName)
}
```
This requires adding `cookieName?: string` to the `OAuthConfig` type in `config.ts`.
**Step 3: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 5: Add cookieName to OAuthConfig
**Files:**
- Modify: `packages/hatk/src/config.ts` (find OAuthConfig type)
**Step 1: Add the optional field**
Find the `OAuthConfig` interface/type and add:
```typescript
cookieName?: string
```
**Step 2: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 6: Forward Cookie Header in Vite SSR Middleware
**Files:**
- Modify: `packages/hatk/src/vite-plugin.ts:186-191`
**Step 1: Pass cookies to Request**
Change line 188 from:
```typescript
const request = new Request(fullUrl.href)
```
To:
```typescript
const headers: Record<string, string> = {}
if (req.headers.cookie) headers.cookie = req.headers.cookie
const request = new Request(fullUrl.href, { headers })
```
**Step 2: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 7: Resolve Viewer and Set globalThis in Vite SSR
**Files:**
- Modify: `packages/hatk/src/vite-plugin.ts:118-125` (module loading section)
- Modify: `packages/hatk/src/vite-plugin.ts:188-195` (SSR render section)
- Modify: `packages/hatk/src/dev-entry.ts:99-101` (exports)
**Step 1: Export parseSessionCookie from dev-entry.ts**
Add to the exports at the bottom of `dev-entry.ts`:
```typescript
export { parseSessionCookie } from './oauth/server.ts'
```
**Step 2: Capture parseSessionCookie from module runner**
In `vite-plugin.ts`, after line 125 (`(globalThis as any).__hatk_callXrpc = mod.callXrpc`), add:
```typescript
// Capture cookie parser for SSR viewer resolution
ssrParseSessionCookie = mod.parseSessionCookie
```
Add the variable declaration near the top of `configureServer` (near the other `let` declarations like `handler`, `ssrRenderPage`):
```typescript
let ssrParseSessionCookie: ((request: Request) => Promise<{ did: string } | null>) | null = null
```
**Step 3: Set globalThis.__hatk_viewer before render, clear after**
In the SSR middleware, after creating the `request` object and before calling `ssrRenderPage`, add viewer resolution:
```typescript
// Resolve viewer from session cookie for SSR
let viewer: { did: string } | null = null
if (ssrParseSessionCookie) {
try {
viewer = await ssrParseSessionCookie(request)
} catch {}
}
;(globalThis as any).__hatk_viewer = viewer
try {
const renderedHtml = await ssrRenderPage!(template, request)
// ... existing code ...
} finally {
;(globalThis as any).__hatk_viewer = null
}
```
This replaces the current `const renderedHtml = await ssrRenderPage!(template, request)` call (line 191) with a try/finally block.
**Step 4: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 8: Thread Viewer Through callXrpc
**Files:**
- Modify: `packages/hatk/src/xrpc.ts:310-330`
**Step 1: Pass viewer from globalThis in both paths**
Replace lines 317-327:
```typescript
// In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis.
// The runner's module instance has all registered handlers; this (Node's) instance may not.
if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) {
return (globalThis as any).__hatk_callXrpc(nsid, params, input)
}
const stringParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params)) {
if (v != null) stringParams[k] = String(v)
}
const limit = params.limit ? Number(params.limit) : 20
const cursor = params.cursor ?? undefined
const result = await executeXrpc(nsid, stringParams, cursor, limit, null, input)
```
With:
```typescript
const viewer = (globalThis as any).__hatk_viewer ?? null
// In externalized module context (e.g. SSR), delegate to the runner's callXrpc via globalThis.
// The runner's module instance has all registered handlers; this (Node's) instance may not.
if (handlers.size === 0 && (globalThis as any).__hatk_callXrpc) {
return (globalThis as any).__hatk_callXrpc(nsid, params, input)
}
const stringParams: Record<string, string> = {}
for (const [k, v] of Object.entries(params)) {
if (v != null) stringParams[k] = String(v)
}
const limit = params.limit ? Number(params.limit) : 20
const cursor = params.cursor ?? undefined
const result = await executeXrpc(nsid, stringParams, cursor, limit, viewer, input)
```
Note: The bridge path (`__hatk_callXrpc`) calls the runner's `callXrpc`, which will also read `globalThis.__hatk_viewer` — so both paths get the viewer.
**Step 2: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 9: Add getViewer() to Codegen
**Files:**
- Modify: `packages/hatk/src/cli.ts:1748-1768`
**Step 1: Add getViewer() export to generated client file**
After the `callXrpc` function generation (after line 1768, before `writeFileSync`), add:
```typescript
clientOut += `\nexport function getViewer(): { did: string } | null {\n`
clientOut += ` if (typeof window === 'undefined') {\n`
clientOut += ` return (globalThis as any).__hatk_viewer ?? null\n`
clientOut += ` }\n`
clientOut += ` // Client-side: delegate to OAuth client\n`
clientOut += ` try {\n`
clientOut += ` const mod = (globalThis as any).__hatk_auth\n`
clientOut += ` if (mod?.viewerDid) {\n`
clientOut += ` const did = mod.viewerDid()\n`
clientOut += ` return did ? { did } : null\n`
clientOut += ` }\n`
clientOut += ` } catch {}\n`
clientOut += ` return null\n`
clientOut += `}\n`
```
Note: The client-side path reads from `globalThis.__hatk_auth` — templates wire this up by setting `(globalThis as any).__hatk_auth = { viewerDid }` in their auth module. This avoids the generated file importing template-specific auth code.
**Step 2: Build and verify**
Run: `npm run build`
Expected: Clean build
---
### Task 10: Test End-to-End with Statusphere Template
**Step 1: Regenerate client types**
```bash
cd /Users/chadmiller/code/hatk-template-statusphere
node /Users/chadmiller/code/hatk/.worktrees/server-directory/packages/hatk/dist/cli.js generate types
```
Verify `hatk.generated.client.ts` now exports `getViewer`.
**Step 2: Wire up globalThis.__hatk_auth in template's auth module**
In `hatk-template-statusphere/app/lib/auth.ts`, add near the bottom:
```typescript
// Expose viewer for SSR getViewer() bridge
;(globalThis as any).__hatk_auth = { viewerDid }
```
**Step 3: Update Home.svelte to use getViewer()**
Import `getViewer` from `../hatk.generated.client.ts` and use it to conditionally render auth UI without flash.
**Step 4: Run dev server and test**
```bash
cd /Users/chadmiller/code/hatk-template-statusphere
npm run dev
```
1. Visit page — should SSR the feed, auth section hidden during SSR
2. Log in — should set `__hatk_session` cookie (check DevTools → Application → Cookies)
3. Refresh page — server should resolve viewer from cookie, SSR authenticated UI
4. Log out — cookie should be cleared