Skip to main content

JWT Refresh Token Rotation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Replace 24h/7d access tokens with 15-minute tokens backed by full refresh-token rotation, family-level reuse detection, and KV revocation — without changing any MFA, passkey, TOTP, or OTP login flow logic. Architecture: A new refresh-tokens.ts helper centralises all KV reads/writes and refresh token issuance. Every login endpoint swaps generateRefreshToken() for issueRefreshToken(). The /auth/refresh endpoint is replaced with the full 15-step rotation flow. dualAuthMiddleware returns a clean TOKEN_EXPIRED 401 (not an error log) for expired access tokens. Frontend serverComm.ts gets a 3-min proactive threshold and error-code-specific 401 handling. Tech Stack: Hono, jose, Cloudflare Workers KV (OTT_STORE namespace with rt: / rtf: / rtlock: prefixes), Drizzle ORM, Vitest (server unit tests), React (frontend) Spec: docs/superpowers/specs/2026-04-28-jwt-refresh-rotation.md

File Map

FileActionPurpose
server/src/lib/refresh-tokens.tsCreatesha256hex, issueRefreshToken, KV CRUD helpers, isJwtExpiredError
server/src/middleware/auth-enhanced.tsModifyReplace generic JWT error catch with isJwtExpiredError + info log + TOKEN_EXPIRED 401
server/src/routes/auth.tsModifyReplace /auth/refresh handler; swap all generateRefreshToken() calls; change '7d''15m' access token in refresh
server/src/lib/nextauth.tsModifyChange generateJWT default expiry from '24h''15m'
ui/src/lib/serverComm.tsModifyThreshold 3 min; 401 code branching
ui/src/hooks/useClinicEvents.tsModifyAdd refreshTokenIfNeeded before EventSource open and on reconnect
ui/src/components/providers/NotificationProvider.tsxModifyPass explicit 3-min threshold; add 12-min proactive reconnect timer

Task 1: Create server/src/lib/refresh-tokens.ts

Files:
  • Create: server/src/lib/refresh-tokens.ts
This is the only purely additive task. Nothing else in the codebase changes here.
  • Step 1: Write the file
// server/src/lib/refresh-tokens.ts
import { generateRefreshToken } from './nextauth';

export interface RefreshTokenMetadata {
  status: 'valid' | 'revoked';
  userId: string;
  sessionId: string;
  familyId: string;
  issuedAt: number;
  expiresAt: number;
  revokedAt?: number;
  replacedBy?: string;
}

export interface FamilyMetadata {
  status: 'active' | 'revoked';
  userId: string;
  revokedAt?: number;
}

const NINETY_DAYS_SECONDS = 90 * 24 * 60 * 60; // 7_776_000

export function isJwtExpiredError(err: unknown): boolean {
  return (
    err instanceof Error &&
    (
      err.name === 'JWTExpired' ||
      err.message.includes('"exp" claim timestamp check failed') ||
      (err as any).code === 'ERR_JWT_EXPIRED'
    )
  );
}

export async function sha256hex(input: string): Promise<string> {
  const buffer = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(input)
  );
  return Array.from(new Uint8Array(buffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

export async function issueRefreshToken(
  params: {
    userId: string;
    email: string;
    sessionId: string;
    familyId?: string;
    customSecret?: string;
  },
  kvNamespace: KVNamespace | null | undefined
): Promise<{ rawToken: string; familyId: string; hash: string }> {
  const { userId, email, sessionId, customSecret } = params;
  const familyId = params.familyId ?? `rtf_${crypto.randomUUID()}`;
  const now = Math.floor(Date.now() / 1000);
  const expiresAt = now + NINETY_DAYS_SECONDS;

  const rawToken = await generateRefreshToken({ sub: userId, email, sessionId }, customSecret);
  const hash = await sha256hex(rawToken);

  const tokenMeta: RefreshTokenMetadata = {
    status: 'valid',
    userId,
    sessionId,
    familyId,
    issuedAt: now,
    expiresAt,
  };

  if (kvNamespace) {
    await kvNamespace.put(`rt:${hash}`, JSON.stringify(tokenMeta), {
      expirationTtl: NINETY_DAYS_SECONDS,
    });

    // Only write family record on first issuance (no familyId passed)
    if (!params.familyId) {
      const familyMeta: FamilyMetadata = { status: 'active', userId };
      await kvNamespace.put(`rtf:${familyId}`, JSON.stringify(familyMeta), {
        expirationTtl: NINETY_DAYS_SECONDS,
      });
    }
  }

  return { rawToken, familyId, hash };
}

export async function getRefreshTokenRecord(
  hash: string,
  kvNamespace: KVNamespace | null | undefined
): Promise<RefreshTokenMetadata | null> {
  if (!kvNamespace) return null;
  return kvNamespace.get<RefreshTokenMetadata>(`rt:${hash}`, { type: 'json' });
}

export async function getFamilyRecord(
  familyId: string,
  kvNamespace: KVNamespace | null | undefined
): Promise<FamilyMetadata | null> {
  if (!kvNamespace) return null;
  return kvNamespace.get<FamilyMetadata>(`rtf:${familyId}`, { type: 'json' });
}

export async function acquireRefreshLock(
  hash: string,
  kvNamespace: KVNamespace | null | undefined
): Promise<boolean> {
  if (!kvNamespace) return true; // dev: no KV, no lock needed
  const existing = await kvNamespace.get(`rtlock:${hash}`);
  if (existing) return false; // lock held by concurrent request
  await kvNamespace.put(`rtlock:${hash}`, '1', { expirationTtl: 10 });
  return true;
}

export async function revokeRefreshToken(
  hash: string,
  existing: RefreshTokenMetadata,
  replacedByHash: string,
  kvNamespace: KVNamespace | null | undefined
): Promise<void> {
  if (!kvNamespace) return;
  const now = Math.floor(Date.now() / 1000);
  const ttlRemaining = Math.max(existing.expiresAt - now, 1);
  const revoked: RefreshTokenMetadata = {
    ...existing,
    status: 'revoked',
    revokedAt: now,
    replacedBy: replacedByHash,
  };
  await kvNamespace.put(`rt:${hash}`, JSON.stringify(revoked), {
    expirationTtl: ttlRemaining,
  });
}

export async function revokeFamilyRecord(
  familyId: string,
  userId: string,
  kvNamespace: KVNamespace | null | undefined
): Promise<void> {
  if (!kvNamespace) return;
  const now = Math.floor(Date.now() / 1000);
  const meta: FamilyMetadata = { status: 'revoked', userId, revokedAt: now };
  // Keep family record alive for remaining 90d to block any future tokens in the family
  await kvNamespace.put(`rtf:${familyId}`, JSON.stringify(meta), {
    expirationTtl: NINETY_DAYS_SECONDS,
  });
}
  • Step 2: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors from refresh-tokens.ts. If KVNamespace type is missing, add /// <reference types="@cloudflare/workers-types" /> at the top of the file.
  • Step 3: Commit
git add server/src/lib/refresh-tokens.ts
git commit -m "feat(auth): add refresh-tokens helper with KV rotation primitives"

Task 2: Update dualAuthMiddleware — clean 401 for expired tokens

Files:
  • Modify: server/src/middleware/auth-enhanced.ts:83-121
The current catch block logs every expired token as console.error and returns a generic 401. We split this into expired vs invalid.
  • Step 1: Import isJwtExpiredError
At the top of server/src/middleware/auth-enhanced.ts, add the import after the existing imports:
import { isJwtExpiredError } from '../lib/refresh-tokens';
  • Step 2: Replace the JWT catch block
Find and replace lines 87–121 (the catch (jwtError) block). The block currently starts at:
    } catch (jwtError) {
      // Fallback: allow upgrade invite tokens for upgrade requests
      const path = c.req.path || '';
Replace the entire catch block (lines 87–121) with:
    } catch (jwtError) {
      // Fallback: allow upgrade invite tokens for upgrade requests
      const path = c.req.path || '';
      if (path.includes('/protected/billing/upgrade-requests')) {
        const [invite] = await db.select()
          .from(upgradeInviteTokens)
          .where(eq(upgradeInviteTokens.id, token))
          .limit(1);

        if (invite) {
          const now = new Date();
          if (!invite.expiresAt || new Date(invite.expiresAt) < now) {
            return c.json({ error: 'Upgrade token expired', code: 'UPGRADE_TOKEN_EXPIRED' }, 401);
          }
          // @ts-ignore
          c.set('user', {
            id: `upgrade-token-${invite.id}`,
            email: invite.emailRecipient || 'unknown@token',
            role: 'admin',
            clinicId: invite.clinicId,
            status: 'active',
            isActive: true,
          });
          return next();
        }
      }

      if (isJwtExpiredError(jwtError)) {
        console.info('Access token expired', { path: c.req.path });
        return c.json({ error: 'TOKEN_EXPIRED', code: 'TOKEN_EXPIRED' }, 401);
      }

      console.warn('JWT Verification Error in dualAuthMiddleware:', jwtError);
      return c.json({
        error: 'Invalid token',
        code: 'INVALID_TOKEN',
        message: jwtError instanceof Error ? jwtError.message : 'The session token is invalid or has expired.'
      }, 401);
    }
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors.
  • Step 4: Commit
git add server/src/middleware/auth-enhanced.ts
git commit -m "fix(auth): demote expired JWT log to info, return TOKEN_EXPIRED 401"

Task 3: Replace /auth/refresh endpoint with full rotation flow

Files:
  • Modify: server/src/routes/auth.ts:2982-3089
This is the most significant change. The existing handler at lines 2982–3089 is replaced wholesale.
  • Step 1: Add imports at the top of auth.ts
Find the imports section at the top of server/src/routes/auth.ts. Add after the existing imports from ../lib/nextauth:
import {
  issueRefreshToken,
  getRefreshTokenRecord,
  getFamilyRecord,
  acquireRefreshLock,
  revokeRefreshToken,
  revokeFamilyRecord,
  sha256hex,
} from '../lib/refresh-tokens';
  • Step 2: Replace the handler body
Find the handler starting at line 2982:
auth.post('/refresh', async (c) => {
  try {
    const body = await c.req.json() as { refreshToken?: string };
    const { refreshToken } = body;
Replace the entire auth.post('/refresh', ...) block (lines 2982–3089) with:
auth.post('/refresh', async (c) => {
  try {
    const body = await c.req.json() as { refreshToken?: string };
    const { refreshToken } = body;

    if (!refreshToken) {
      throw new AppError('Refresh token is required', 400);
    }

    const secretFromEnv = getEnv('JWT_SECRET');
    const kv = (c.env as any)?.OTT_STORE as KVNamespace | undefined;

    // 1. Verify JWT signature and tokenType
    let refreshPayload;
    try {
      refreshPayload = await verifyJWT(refreshToken, secretFromEnv);
    } catch {
      throw new AppError('Invalid or expired refresh token', 401);
    }

    if (refreshPayload.tokenType !== 'refresh') {
      throw new AppError('Invalid token type', 401);
    }

    const userId = refreshPayload.sub;
    const email = refreshPayload.email;
    if (!userId || !email) {
      throw new AppError('Invalid refresh token payload', 401);
    }

    // 2. Hash the presented token
    const hash = await sha256hex(refreshToken);

    // 3. Read rt:{hash} from KV
    const tokenRecord = await getRefreshTokenRecord(hash, kv);
    if (!tokenRecord) {
      return c.json({ error: 'INVALID_TOKEN', code: 'INVALID_TOKEN' }, 401);
    }

    // 4. Check family state (revoked family = all tokens in chain are dead)
    const familyRecord = await getFamilyRecord(tokenRecord.familyId, kv);
    if (familyRecord?.status === 'revoked') {
      return c.json({ error: 'SESSION_REVOKED', code: 'SESSION_REVOKED' }, 401);
    }

    // 5. Detect reuse of already-rotated token
    if (tokenRecord.status === 'revoked') {
      // Revoke the family to block all tokens in this chain
      await revokeFamilyRecord(tokenRecord.familyId, tokenRecord.userId, kv);
      // Clear lastSessionId only if it still matches the compromised session
      if (!isSuperAdminEmail(email)) {
        const databaseUrl = getDatabaseUrl();
        const db = await getDatabase(databaseUrl);
        const [u] = await db.select().from(users).where(eq(users.id, tokenRecord.userId)).limit(1);
        if (u && u.lastSessionId === tokenRecord.sessionId) {
          await db.update(users).set({ lastSessionId: null }).where(eq(users.id, tokenRecord.userId));
        }
      }
      return c.json({ error: 'SESSION_REVOKED', code: 'SESSION_REVOKED' }, 401);
    }

    // 6. Acquire best-effort refresh lock to reduce concurrent-refresh races
    const locked = await acquireRefreshLock(hash, kv);
    if (!locked) {
      return c.json({ error: 'REFRESH_IN_PROGRESS', code: 'REFRESH_IN_PROGRESS' }, 409);
    }

    const databaseUrl = getDatabaseUrl();
    const db = await getDatabase(databaseUrl);

    // Look up the user
    let userRecord: any = null;
    let isSuperAdmin = false;

    const [superAdminRecord] = await db.select()
      .from(superAdmins)
      .where(eq(superAdmins.email, email))
      .limit(1);

    if (superAdminRecord) {
      userRecord = superAdminRecord;
      isSuperAdmin = true;
    } else {
      const [regularUser] = await db.select()
        .from(users)
        .where(eq(users.id, userId))
        .limit(1);
      if (!regularUser || !regularUser.isActive || regularUser.status !== 'active') {
        throw new AppError('User account is inactive or not found', 401);
      }
      userRecord = regularUser;
    }

    if (!userRecord) {
      throw new AppError('User not found', 401);
    }

    // 7. Compute new token hash BEFORE any KV writes
    const newSessionId = crypto.randomUUID();
    // Pre-generate the new refresh token so we can compute its hash before revoking old
    const newRawRefreshToken = await generateRefreshToken(
      { sub: userRecord.id, email: userRecord.email, sessionId: newSessionId },
      secretFromEnv
    );
    const newHash = await sha256hex(newRawRefreshToken);

    // 8. Revoke old token (with replacedBy pointing to new hash)
    await revokeRefreshToken(hash, tokenRecord, newHash, kv);

    // 9. Register new refresh token in KV (same familyId, new sessionId)
    const { familyId } = await issueRefreshToken(
      {
        userId: userRecord.id,
        email: userRecord.email,
        sessionId: newSessionId,
        familyId: tokenRecord.familyId,
        customSecret: secretFromEnv,
      },
      kv
    );
    // issueRefreshToken generates its own JWT — we use our pre-generated one instead.
    // Write the KV record directly so newRawRefreshToken hash matches what we computed.
    // (issueRefreshToken will have written with its own token; override with correct hash)
    // CORRECTION: call issueRefreshToken with the pre-generated token by extracting KV write:
    // Instead, register newRawRefreshToken directly in KV:
    const now = Math.floor(Date.now() / 1000);
    const NINETY_DAYS_SECONDS = 90 * 24 * 60 * 60;
    if (kv) {
      await kv.put(`rt:${newHash}`, JSON.stringify({
        status: 'valid',
        userId: userRecord.id,
        sessionId: newSessionId,
        familyId: tokenRecord.familyId,
        issuedAt: now,
        expiresAt: now + NINETY_DAYS_SECONDS,
      }), { expirationTtl: NINETY_DAYS_SECONDS });
    }

    // 10. Update lastSessionId in D1
    if (!isSuperAdmin) {
      await db.update(users)
        .set({ lastSessionId: newSessionId })
        .where(eq(users.id, userRecord.id));
    }

    // 11. Issue 15-minute access token
    const accessPayload: any = {
      sub: userRecord.id,
      email: userRecord.email,
      role: isSuperAdmin ? 'superadmin' : userRecord.role,
      name: `${userRecord.firstName} ${userRecord.lastName}`,
      sessionId: newSessionId,
    };
    if (isSuperAdmin) {
      accessPayload.type = 'super_admin';
    } else {
      accessPayload.clinicId = userRecord.clinicId;
      accessPayload.primaryClinicId = userRecord.primaryClinicId;
    }

    const newAccessToken = await generateJWT(accessPayload, '15m', secretFromEnv);
    const cookieHeader = serializeSessionCookie(newAccessToken);

    return c.json({
      token: newAccessToken,
      refreshToken: newRawRefreshToken,
    }, 200, {
      'Set-Cookie': cookieHeader,
    });
  } catch (error) {
    return handleError(error, c);
  }
});
Note on the pre-generate pattern: Because we need replacedBy: newHash in the revocation record, we must know the new token hash before writing the revocation. We generate the raw JWT and its hash first (steps 7), then revoke old (step 8), then write new KV record directly (step 9). This avoids the double-write problem that would occur if we called issueRefreshToken (which generates its own JWT and hash). The NINETY_DAYS_SECONDS constant is duplicated here intentionally — it keeps the refresh handler self-contained without importing an internal constant.
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors. If KVNamespace is unresolved, ensure @cloudflare/workers-types is in tsconfig.json types array.
  • Step 4: Smoke test the refresh endpoint locally (if Wrangler dev is running)
# get a valid refresh token from localStorage in browser devtools, then:
curl -X POST http://localhost:8787/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"<paste_token>"}'
Expected: { "token": "...", "refreshToken": "..." } with HTTP 200.
  • Step 5: Commit
git add server/src/routes/auth.ts server/src/lib/refresh-tokens.ts
git commit -m "feat(auth): implement refresh token rotation with KV reuse detection"

Task 4: Update all login endpoints to use issueRefreshToken

Files:
  • Modify: server/src/routes/auth.ts — 8 call sites
At every call site, replace generateRefreshToken({...}, secretFromEnv) with issueRefreshToken({...}, kv). The login flow logic itself does not change at all — only the token issuance call. For each call site below, the pattern is: Before:
const refreshToken = await generateRefreshToken({
  sub: userRecord.id,
  email: userRecord.email,
  sessionId: newSessionId,  // may or may not exist
}, secretFromEnv);
After:
const kv = (c.env as any)?.OTT_STORE as KVNamespace | undefined;
const { rawToken: refreshToken } = await issueRefreshToken({
  userId: userRecord.id,
  email: userRecord.email,
  sessionId: newSessionId,   // must always be present — see notes per call site
  customSecret: secretFromEnv,
}, kv);
Important: issueRefreshToken requires sessionId. If the existing call site does not have one, generate it with const sessionId = crypto.randomUUID() and also write it to D1: await db.update(users).set({ lastSessionId: sessionId }).where(eq(users.id, userId)).
  • Step 1: Update superadmin login (auth.ts ~line 390)
Find: generateRefreshToken({ sub near line 390. The superadmin session is not tracked in D1 (isSuperAdmin = true), so no lastSessionId update needed. Apply the pattern above with userId = superAdminRecord.id.
  • Step 2: Update regular signin (auth.ts ~line 663)
Find: generateRefreshToken({ sub near line 663. This call site already has sessionId. Apply the pattern. The kv binding line can be added just above the call.
  • Step 3: Update OTP/2FA verification (auth.ts ~line 881)
Find: generateRefreshToken({ sub near line 881. This path already has sessionId. Apply the pattern. This covers TOTP and OTP paths that converge at this handler.
  • Step 4: Update onboarding/registration (auth.ts ~line 1281)
Find: generateRefreshToken({ sub near line 1281. Apply the pattern; sessionId already present.
  • Step 5: Update staff invite accept (auth.ts ~line 2589)
Find: generateRefreshToken({ sub near line 2589. This call site uses { sub, email }no sessionId. Add before the call:
const sessionId = crypto.randomUUID();
await db.update(users).set({ lastSessionId: sessionId }).where(eq(users.id, userId));
Then apply the pattern passing sessionId.
  • Step 6: Update set-password (auth.ts ~line 2745)
Find: generateRefreshToken({ sub near line 2745. Same situation — no sessionId. Add before the call:
const sessionId = crypto.randomUUID();
await db.update(users).set({ lastSessionId: sessionId }).where(eq(users.id, userId));
Then apply the pattern.
  • Step 7: Update impersonation restore — user path (auth.ts ~line 2889)
Find: generateRefreshToken({ sub near line 2889. sessionId is present. Apply the pattern. The impersonation logic itself (the impersonatorId on the access token) is untouched.
  • Step 8: Update impersonation restore — superadmin path (auth.ts ~line 2933)
Find: generateRefreshToken({ sub near line 2933. sessionId is present. Apply the pattern.
  • Step 9: Verify TypeScript compiles with zero errors
cd server && npx tsc --noEmit 2>&1 | head -30
  • Step 10: Verify no remaining raw generateRefreshToken calls in login paths
grep -n "generateRefreshToken" server/src/routes/auth.ts
Expected: zero results. (The function itself is still exported from nextauth.ts — only the call sites in auth.ts should be gone.)
  • Step 11: Commit
git add server/src/routes/auth.ts
git commit -m "feat(auth): replace generateRefreshToken with issueRefreshToken at all login paths"

Task 5: Shorten access token lifetime to 15 minutes

Files:
  • Modify: server/src/lib/nextauth.ts:24
  • Step 1: Change the default expiry
In server/src/lib/nextauth.ts line 24, the function signature is:
export async function generateJWT(payload: SessionPayload, expirationTime: string = '24h', customSecret?: string): Promise<string> {
Change '24h' to '15m':
export async function generateJWT(payload: SessionPayload, expirationTime: string = '15m', customSecret?: string): Promise<string> {
  • Step 2: Scan for other hardcoded expiry strings in auth.ts
grep -n "'24h'\|'7d'\|\"24h\"\|\"7d\"" server/src/routes/auth.ts | head -20
For any generateJWT call that explicitly passes '24h' or '7d', change it to '15m'. The /auth/refresh handler was already fixed in Task 3 to pass '15m' explicitly.
  • Step 3: Verify TypeScript compiles
cd server && npx tsc --noEmit 2>&1 | head -20
  • Step 4: Commit
git add server/src/lib/nextauth.ts server/src/routes/auth.ts
git commit -m "feat(auth): shorten access token lifetime to 15 minutes"

Task 6: Frontend — 401 branching + refresh threshold in serverComm.ts

Files:
  • Modify: ui/src/lib/serverComm.ts
Two targeted changes in serverComm.ts. Do not touch anything else in this file.
  • Step 1: Change the proactive refresh threshold
Find the call to refreshTokenIfNeeded inside fetchWithAuth (around line 763). It is called with no argument or with a large threshold (24h = 86_400_000). Change it to pass 3 minutes:
await refreshTokenIfNeeded(3 * 60 * 1000);
Also update the refreshTokenIfNeeded function default parameter (around line 710):
// Before
async function refreshTokenIfNeeded(thresholdMs: number = 24 * 60 * 60 * 1000) {
// After
async function refreshTokenIfNeeded(thresholdMs: number = 3 * 60 * 1000) {
  • Step 2: Update the 401 handler in fetchWithAuth
Find the 401 handling block (around lines 889–916). It currently attempts a silent refresh on any 401. Replace it with error-code-specific branching:
if (response.status === 401 && !options?._retry) {
  let errorCode: string | undefined;
  try {
    const errBody = await response.clone().json() as { error?: string; code?: string };
    errorCode = errBody.error ?? errBody.code;
  } catch {
    // non-JSON 401 — treat as generic
  }

  if (errorCode === 'SESSION_REVOKED') {
    // Compromised session — force logout immediately, no retry
    removeAuthToken();
    removeRefreshToken();
    window.dispatchEvent(new CustomEvent('auth:force-logout'));
    throw new APIError('Session revoked', response.status, 'SESSION_REVOKED');
  }

  if (errorCode === 'TOKEN_EXPIRED') {
    // Attempt one refresh, then retry the original request once
    const refreshed = await attemptTokenRefresh();
    if (refreshed) {
      return fetchWithAuth(url, { ...options, _retry: true } as any);
    }
    // Refresh failed — propagate 401
    throw new APIError('Session expired', response.status, 'TOKEN_EXPIRED');
  }

  // Any other 401 — propagate without retry
  throw new APIError(
    `Unauthorized`,
    response.status,
    errorCode ?? 'UNAUTHORIZED'
  );
}
Key points: The _retry: true flag prevents infinite retry loops. SESSION_REVOKED never triggers a refresh attempt. Other 401s (e.g., INVALID_TOKEN, ConcurrentSession) are propagated as-is.
  • Step 3: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
Expected: zero errors.
  • Step 4: Commit
git add ui/src/lib/serverComm.ts
git commit -m "fix(frontend): 3-min proactive refresh threshold + TOKEN_EXPIRED/SESSION_REVOKED 401 branching"

Task 7: Frontend — SSE reconnect with token refresh

Files:
  • Modify: ui/src/hooks/useClinicEvents.ts
  • Modify: ui/src/components/providers/NotificationProvider.tsx

Part A — useClinicEvents.ts

The current hook opens an EventSource without refreshing the token first and does not refresh before reconnecting. With 15-min tokens, every reconnect must use a fresh token.
  • Step 1: Import refreshTokenIfNeeded and getAuthToken
At the top of ui/src/hooks/useClinicEvents.ts, ensure these are imported:
import { refreshTokenIfNeeded, getAuthToken } from '../lib/serverComm';
(If getAuthToken is already imported from auth-cookie.ts instead, keep that import.)
  • Step 2: Add refresh before EventSource construction
Find where the EventSource is constructed (it will be something like new EventSource(\…?token=$“). Wrap the construction logic so it always refreshes first:
// Refresh before opening — ensures token has ≥3 min left
await refreshTokenIfNeeded(3 * 60 * 1000);
const token = getAuthToken();
if (!token) return; // not logged in, abort
const es = new EventSource(`${API_URL}/api/v1/protected/sse/clinic-events?token=${token}`);
  • Step 3: Add refresh before each reconnect
In the error/close handler where reconnection is scheduled (the exponential backoff code), add the same refresh call at the point of reconnect — before constructing the new EventSource:
// inside the reconnect setTimeout callback:
await refreshTokenIfNeeded(3 * 60 * 1000);
// then open new EventSource as above
  • Step 4: Add 12-minute proactive reconnect timer
Inside the hook, after the EventSource is opened successfully, schedule a proactive reconnect at 12 minutes (80% of 15-min token lifetime):
const proactiveReconnectTimer = setTimeout(() => {
  es.close();
  // trigger reconnect via existing reconnect logic
}, 12 * 60 * 1000);

// Clear in cleanup:
return () => {
  clearTimeout(proactiveReconnectTimer);
  es.close();
};

Part B — NotificationProvider.tsx

The connectSSE function (lines ~194–278) already calls refreshTokenIfNeeded() before reconnection but uses the default threshold. Tighten it and add proactive reconnect.
  • Step 5: Pass explicit threshold to refreshTokenIfNeeded
Find every call to refreshTokenIfNeeded() without arguments in NotificationProvider.tsx and add the explicit threshold:
// Before
await refreshTokenIfNeeded();
// After
await refreshTokenIfNeeded(3 * 60 * 1000);
  • Step 6: Add 12-minute proactive reconnect timer
Inside connectSSE, after the EventSource opens successfully, add:
const proactiveTimer = setTimeout(() => {
  eventSource.close();
  connectSSE(); // triggers reconnect with fresh token
}, 12 * 60 * 1000);

// Add to existing cleanup: clearTimeout(proactiveTimer)
  • Step 7: Handle SESSION_REVOKED in SSE error path
In the existing storage event listener and auth:force-logout handler in NotificationProvider.tsx, ensure that a SESSION_REVOKED error from the /auth/refresh call also stops SSE reconnect attempts. The existing auth:force-logout event dispatch from serverComm.ts already handles this — verify the listener is wired up:
window.addEventListener('auth:force-logout', () => {
  if (eventSourceRef.current) {
    eventSourceRef.current.close();
  }
  // stop reconnect attempts
  setReconnectAttempts(MAX_RECONNECT_ATTEMPTS); // or equivalent guard
});
  • Step 8: Verify TypeScript compiles
cd ui && npx tsc --noEmit 2>&1 | head -30
  • Step 9: Commit
git add ui/src/hooks/useClinicEvents.ts ui/src/components/providers/NotificationProvider.tsx
git commit -m "fix(frontend): SSE refresh-before-reconnect + 12-min proactive reconnect for 15-min tokens"

Task 8: End-to-end smoke test

No code changes. Verify the full flow works across all auth paths.
  • Step 1: Deploy to staging
Use the odontox-commit-deploy skill or run the standard staging deploy.
  • Step 2: Verify email + password login
  1. Log in via email + password
  2. Open DevTools → Application → localStorage — confirm odontox_rt is present
  3. Open DevTools → Application → Cookies — confirm auth_token cookie is present and has ~15-min expiry
  4. Check Cloudflare Worker logs — confirm no JWTExpired at error level
  • Step 3: Verify TOTP / OTP login
  1. Log in with 2FA enabled (TOTP or OTP path)
  2. Confirm same token storage as above
  3. Confirm protected API calls succeed
  • Step 4: Verify passkey login
  1. Log in via passkey
  2. Confirm auth_token cookie and odontox_rt in localStorage
  3. Confirm protected API calls succeed
  • Step 5: Verify refresh cycle
  1. Open DevTools Network tab
  2. Wait 12–13 minutes (or manually expire the cookie by editing its value to an old JWT in Application tab)
  3. Make any API call — observe a POST to /api/v1/auth/refresh succeed with 200
  4. Observe the original API call retry and succeed
  5. Confirm odontox_rt in localStorage has been updated to a new value
  • Step 6: Verify SESSION_REVOKED triggers logout
  1. Log in and capture the refresh token from localStorage
  2. In a second session (incognito), log in and refresh — this rotates the refresh token
  3. Back in the first session, trigger a 401 TOKEN_EXPIRED (wait or manually expire cookie)
  4. The frontend will attempt a refresh using the now-revoked token
  5. Expect: redirect to login page (SESSION_REVOKED caught, auth:force-logout dispatched)
  • Step 7: Verify live-feed SSE reconnects cleanly
  1. Open the notifications panel
  2. Wait 12 minutes or manually close/reopen the SSE connection
  3. Confirm SSE reconnects without a 401 error in the Network tab
  4. Confirm Worker logs show no JWTExpired errors
  • Step 8: Verify Cloudflare Worker logs are clean
In Cloudflare Dashboard → Workers → odonto-prod → Logs:
  • JWTExpired entries should no longer appear at level: error
  • Any token expiry should appear at level: info if logged at all

Self-Review

Spec coverage check:
Spec requirementTask
15-min access tokensTask 5
issueRefreshToken helperTask 1
KV rt:, rtf:, rtlock: recordsTask 1
Revoke-before-issue orderingTask 3
Race condition lockTask 1 + Task 3
Family-wide revocation on reuseTask 3
lastSessionId cleared only if matchesTask 3
isJwtExpiredError helperTask 1
dualAuthMiddlewareinfo log + TOKEN_EXPIREDTask 2
All login endpoints → issueRefreshTokenTask 4
rtf: written on first login onlyTask 1
Frontend 3-min thresholdTask 6
Frontend TOKEN_EXPIRED retry-onceTask 6
Frontend SESSION_REVOKED → logoutTask 6
SSE refresh before open + reconnectTask 7
SSE 12-min proactive reconnectTask 7
Bridge / upgrade invite tokens untouchedTasks 2, 3 (explicitly preserved)
MFA / TOTP / OTP logic untouchedTask 4 (mechanical swap only)