Refactor CCXP proxy login with server-side credential storage#793
Refactor CCXP proxy login with server-side credential storage#793ImJustChew wants to merge 5 commits intomainfrom
Conversation
Backend (services/api): - Replace fetch()-based CCXP login with Puppeteer (real browser) via Cloudflare Browser Rendering binding to bypass bot protection - Add browser-login.ts: isolated Puppeteer login module with retry logic - Add ocr.ts: extracted OCR client for CAPTCHA solving - Add credential-store.ts: opt-in server-side credential storage using AES-256-GCM encryption (Web Crypto API), stored in D1 via ProxyCredential model - Rewrite auth.ts: three routes (login, refresh, logout) with token rotation on refresh to limit credential compromise window - Add wrangler.toml: [browser] binding + LOGIN_RATE_LIMITER (10 req/min) - Add D1 migration: ProxyCredential table with expiresAt index (30-day TTL) - Fix Prisma v7 schema: remove unsupported `url` from datasource Frontend (apps/web): - Passwords never stored client-side; only opaque credentialToken + ACIXSTORE - Update HeadlessAISStorage type: remove password/encrypted, add credentialToken - Create useHeadlessAIS hook: manages CCXP connection state in localStorage, auto-refreshes ACIXSTORE when expired (if credentialToken present), rotates token - Create lib/headless-ais-api.ts: thin API client wrappers for all CCXP endpoints - Rewrite AISNotLoggedIn.tsx: inline login form with opt-in credential storage - Delete useClearAuth.tsx (was blocking proxy login); remove from AppProviders - Restore student/grades/page.tsx: uncomment and wire to useHeadlessAIS - Restore student/id/page.tsx: uncomment and wire to useHeadlessAIS - Restore student/parcel/page.tsx: uncomment and wire to useHeadlessAIS - Add grades, student ID, parcel entries back to apps.ts - Update proxy-login documentation page (ZH + EN) to reflect new architecture https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ
Critical fixes: - Move credentialToken from localStorage to httpOnly cookie (SameSite=Strict, Secure, path-scoped to /ccxp/auth) — XSS can no longer steal it - Rate-limit /refresh endpoint (shares LOGIN_RATE_LIMITER) — prevents unlimited upstream CCXP logins via token brute-force - /refresh and /logout now read credential_token from httpOnly cookie, not from request body — no token in JS-accessible storage High fixes: - Fix rate limit fallback: use x-forwarded-for before falling back to "unknown-ip" (previously all non-CF requests shared one bucket) - Add keyVersion column to ProxyCredential for encryption key rotation support — old-version records safely rejected on retrieval - CORS: replace wildcard "*" with explicit localhost:5173 in dev, enable credentials:true for cookie support - Fix getACIXSTORE race condition: use ref-based dedup lock so concurrent callers share a single in-flight /refresh instead of racing Medium fixes: - Add cleanupExpired() + POST /ccxp/auth/cleanup endpoint for purging expired credentials from D1 - Validate hex inputs in credential-store.ts (key length, format) instead of crashing on malformed data - Add autocomplete="off" on CCXP password field - Fix misleading privacy text — now accurately states server operator can technically decrypt stored credentials New: Privacy consent dialog: - Users must acknowledge 5 specific risks (bilingual ZH/EN) before seeing the login form - Risks cover: password transit, server-side decryptability, data logging, non-official status, localStorage/XSS exposure - Consent state tracked in localStorage via consentGiven flag Frontend type changes: - HeadlessAISStorage: replace credentialToken with hasStoredCredentials boolean + consentGiven flag - API client: credentials:'include' on all fetch calls, proxyRefresh() and proxyLogout() no longer take token parameter https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ
The encryption key management is now fully automatic — zero operator
intervention required:
- NTHU_HEADLESS_AIS_ENCRYPTION_KEY is now a master key (set once, never
changed)
- Actual encryption keys are derived using HKDF(masterKey, version):
crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256",
salt: "nthumods-proxy-v{version}", info: "aes-gcm-credential-key" })
- Key version = floor(daysSinceEpoch / 90) — auto-increments every
90 days deterministically from the system clock
- Credentials expire after 30 days, so the max version gap for any
valid record is 1 (stored at end of period N, accessed at start of N+1)
Lazy re-encryption on retrieval:
- When a credential is retrieved with keyVersion < currentVersion,
it is decrypted with the old derived key, re-encrypted with the
current derived key, and the record is updated in-place
- This means all actively-used credentials migrate forward automatically
- No batch migration scripts needed
The old static CURRENT_KEY_VERSION=1 constant is removed. The version
is now computed from the clock, making it impossible for old keys to
persist beyond the rotation boundary + credential expiry window.
https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR refactors the CCXP (headless AIS) proxy-login flow to move credential handling server-side, improve login reliability via Puppeteer + OCR, and add an optional httpOnly-cookie based auto-refresh mechanism. It also re-enables several student-facing pages in the web app and centralizes frontend auth logic.
Changes:
- Add Puppeteer-based CCXP login flow and extracted OCR client for CAPTCHA solving.
- Implement server-side encrypted credential storage (AES-256-GCM + HKDF) with token-based retrieval and rate-limited login/refresh endpoints.
- Introduce a new frontend hook + API client and re-enable grades/student ID/parcel pages and navigation entries.
Reviewed changes
Copilot reviewed 20 out of 21 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| services/api/wrangler.toml | Adds a dedicated login rate limiter and Cloudflare browser binding. |
| services/api/src/prisma/schema.prisma | Adds ProxyCredential model for encrypted credential storage (but datasource config changed). |
| services/api/src/index.ts | Updates CORS to support credentialed requests and adds new bindings. |
| services/api/src/headless-ais/ocr.ts | New OCR client used to solve CCXP CAPTCHA. |
| services/api/src/headless-ais/credential-store.ts | New encrypted credential store with rotation + expiry handling. |
| services/api/src/headless-ais/browser-login.ts | New Puppeteer-based CCXP login implementation. |
| services/api/src/headless-ais/auth.ts | Refactors auth routes into /login, /refresh, /logout, /cleanup with rate limiting + cookies. |
| services/api/package.json | Adds @cloudflare/puppeteer dependency. |
| services/api/migrations/20251127_add_proxy_credentials.sql | Creates ProxyCredential table and expiry index. |
| bun.lock | Locks new backend dependencies. |
| apps/web/src/types/headless_ais.ts | Updates local storage schema to remove plaintext password and add server-side credential flags. |
| apps/web/src/lib/headless-ais-api.ts | New API client for CCXP login/refresh/logout and data endpoints. |
| apps/web/src/layouts/AppProviders.tsx | Removes local-storage clearing component from providers. |
| apps/web/src/hooks/useHeadlessAIS.ts | New centralized hook with refresh deduplication and session management. |
| apps/web/src/hooks/useClearAuth.tsx | Removes the one-time local storage clearing logic. |
| apps/web/src/const/apps.ts | Adds Grades/Student ID/Parcel to navigation. |
| apps/web/src/components/Pages/AISNotLoggedIn.tsx | Adds consent dialog + integrated login UI with credential-storage opt-in. |
| apps/web/src/app/[lang]/(mods-pages)/student/parcel/page.tsx | Re-enables parcel tracking UI backed by new hook/API client. |
| apps/web/src/app/[lang]/(mods-pages)/student/id/page.tsx | Re-enables student ID + door access QR UI backed by new hook/API client. |
| apps/web/src/app/[lang]/(mods-pages)/student/grades/page.tsx | Re-enables grades page with auto-refresh behavior. |
| apps/web/src/app/[lang]/(mods-pages)/(side-pages)/proxy-login/page.tsx | Updates proxy-login documentation to match the new flow and security model. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function getPrisma(c: any): PrismaClient { | ||
| const adapter = new PrismaD1(c.env.DB); | ||
| return new PrismaClient({ adapter } as any); | ||
| } |
There was a problem hiding this comment.
This file introduces its own getPrisma() that instantiates new PrismaClient(...) directly, but the codebase already uses the shared helper prismaClients.fetch(c.env.DB) (e.g. services/api/src/graduation/cache.ts:11). To keep connection setup consistent (and make future pooling/caching changes centralized), prefer reusing the existing helper instead of creating a one-off initializer here.
| export function useHeadlessAIS() { | ||
| const [ais, setAis] = useLocalStorage<HeadlessAISStorage>(STORAGE_KEY, { | ||
| enabled: false, | ||
| }); | ||
| const [loading, setLoading] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| // Dedup lock: prevents multiple concurrent refresh calls from racing | ||
| const refreshPromiseRef = useRef<Promise<string> | null>(null); | ||
|
|
||
| const isConnected = ais.enabled; | ||
| const user: UserJWTDetails | null = ais.enabled ? ais.user : null; |
There was a problem hiding this comment.
useLocalStorage hydrates whatever is already in localStorage.headless_ais, but this PR changes the stored shape (removes password/encrypted, adds user, hasStoredCredentials, consentGiven). There’s no runtime schema validation/migration here, so users with old persisted data can end up with ais.enabled === true but missing required fields, leading to inconsistent behavior. Consider adding a storage version + migration, or validate the object on load and reset to { enabled: false } when it doesn’t match the expected shape.
| @@ -12,7 +12,6 @@ generator client { | |||
|
|
|||
| datasource db { | |||
| provider = "sqlite" | |||
There was a problem hiding this comment.
datasource db no longer specifies a url, but repo tooling still expects DATABASE_URL (see services/api/turbo.json). This will likely break prisma generate/migrations and any workflows that rely on the datasource URL. Restore url = env("DATABASE_URL") (or an equivalent D1-compatible URL) so Prisma tooling continues to work.
| provider = "sqlite" | |
| provider = "sqlite" | |
| url = env("DATABASE_URL") |
| const answer = await fetch(`${ocrBaseUrl}/?url=${captchaUrl}`).then( | ||
| (res) => res.text(), | ||
| ); | ||
|
|
||
| if (answer.length === 6) return answer; | ||
| console.error( | ||
| `OCR: Got invalid answer length ${answer.length}, retrying`, |
There was a problem hiding this comment.
solveCaptcha builds the OCR request as ${ocrBaseUrl}/?url=${captchaUrl} without URL-encoding captchaUrl, so the nested querystring can be parsed incorrectly by the OCR service. Also, OCR responses often include a trailing newline; checking answer.length === 6 without trim() can cause false negatives. Encode the URL parameter (e.g. encodeURIComponent) and validate answer.trim().
| const answer = await fetch(`${ocrBaseUrl}/?url=${captchaUrl}`).then( | |
| (res) => res.text(), | |
| ); | |
| if (answer.length === 6) return answer; | |
| console.error( | |
| `OCR: Got invalid answer length ${answer.length}, retrying`, | |
| const encodedCaptchaUrl = encodeURIComponent(captchaUrl); | |
| const answer = await fetch(`${ocrBaseUrl}/?url=${encodedCaptchaUrl}`).then( | |
| (res) => res.text(), | |
| ); | |
| const trimmedAnswer = answer.trim(); | |
| if (trimmedAnswer.length === 6) return trimmedAnswer; | |
| console.error( | |
| `OCR: Got invalid answer length ${trimmedAnswer.length}, retrying`, |
| function setCredentialCookie(c: any, token: string) { | ||
| setCookie(c, COOKIE_NAME, token, { | ||
| httpOnly: true, | ||
| secure: true, | ||
| sameSite: "Strict", | ||
| path: "/ccxp/auth", | ||
| maxAge: COOKIE_MAX_AGE, | ||
| }); |
There was a problem hiding this comment.
setCredentialCookie always sets secure: true. In local dev wrangler.toml uses local_protocol = "http", so browsers will silently ignore the cookie and auto-refresh/logout via httpOnly cookie won't work. Consider setting secure conditionally (e.g. only when NODE_ENV === "production" or when the request is HTTPS).
| // POST /ccxp/auth/cleanup — purge expired credentials (call from cron or admin) | ||
| app.post("/cleanup", async (c) => { | ||
| const prisma = getPrisma(c); | ||
| const count = await cleanupExpired(prisma); | ||
| return c.json({ purged: count }); | ||
| }); |
There was a problem hiding this comment.
POST /ccxp/auth/cleanup is exposed without any authentication/authorization, but it performs database deletes. Even though it only purges expired rows, it can still be abused for needless load/DoS. Restrict this endpoint (e.g. require an admin token, CF Access, or only allow cron-triggered invocation).
| const refreshPromise = (async () => { | ||
| setLoading(true); | ||
| try { | ||
| const result = await proxyRefresh(); | ||
| setAis({ | ||
| ...ais, | ||
| ACIXSTORE: result.ACIXSTORE, | ||
| lastUpdated: Date.now(), | ||
| expired: false, | ||
| }); | ||
| return result.ACIXSTORE; | ||
| } catch { | ||
| setAis({ ...ais, expired: true, hasStoredCredentials: false }); | ||
| throw new Error("Session expired. Please log in again."); |
There was a problem hiding this comment.
getACIXSTORE updates localStorage state using the captured ais object inside an async refresh. If the user logs out (or another state change happens) while a refresh is in-flight, the later setAis({ ...ais, ... }) can resurrect stale state. Use functional updates (setAis(prev => ...)) and/or verify the session is still enabled before writing refreshed data.
- Move PRIVACY_RISKS strings to en.json/zh.json dictionaries - Refactor useHeadlessAIS to useReducer state machine (idle/logging_in/refreshing/logging_out/error) - Chain auth.ts routes for Hono RPC type inference - Rewrite headless-ais-api.ts to use typed Hono client instead of manual fetch - Fix field name mismatches in inthu API calls (snake_case → camelCase) https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ
- Fix build: remove /// <reference lib="dom" /> that leaked DOM types into linkedom files, use (globalThis as any).document in page.evaluate() - Use existing prismaClients.fetch() helper instead of inline getPrisma() - Make cookie secure flag env-aware (secure only in production) - Rate-limit cleanup endpoint to prevent abuse - URL-encode captchaUrl in OCR requests, trim OCR responses - Add client-side refresh: in-memory credentials (useRef) enable auto-refresh without server-side password storage - Fix refresh/logout race: loggedOutRef prevents stale state resurrection https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ
|



Summary
This PR significantly refactors the CCXP proxy login system to improve security and user experience by:
Key Changes
Backend (services/api)
New
browser-login.ts: Puppeteer-based CCXP login module that handles the complete login flow including CAPTCHA solving via OCR. Passwords are never stored in this module—only used transiently during login.New
credential-store.ts: Server-side encrypted credential storage with:New
ocr.ts: Extracted CAPTCHA solving logic for cleaner separation of concernsRefactored
auth.ts:browser-login.ts)/loginendpoint with optionalstore_credentialsparameter/refreshendpoint for automatic session renewal via httpOnly cookie/logoutendpoint for credential cleanupDatabase: Added
ProxyCredentialtable for storing encrypted passwords with version tracking and expirationWrangler config: Added
LOGIN_RATE_LIMITERand Puppeteer browser bindingFrontend (apps/web)
New
useHeadlessAIS.tshook: Replaces scattered auth logic with centralized state management including:New
headless-ais-api.ts: API client for login, refresh, logout, and data fetching endpointsUpdated
AISNotLoggedIn.tsx:Re-enabled student pages:
grades/page.tsx: Grades query with auto-refreshstudent/id/page.tsx: Door access QR code and student ID displayparcel/page.tsx: Parcel tracking with status filteringUpdated documentation page (
proxy-login/page.tsx):Updated app registry (
const/apps.ts): Added grades, student ID, and parcel apps to navigationType Updates
headless_ais.ts: Restructured storage schema to remove plaintext password, add user details, credential token, and expiration trackingNotable Implementation Details
Password Security: Passwords are never stored on the client. They're transmitted once during login, used immediately by Puppeteer, then discarded. Only the opaque
credentialToken(UUID) is stored in httpOnly cookies for auto-refresh.Key Rotation: Encryption keys are derived deterministically from a master key using HKDF with a version component that increments every 90 days. Old credentials are lazily re-encrypted on retrieval.
Rate Limiting: Login endpoint is protected with per-IP rate limiting to prevent brute force attacks.
Deduplication: The
getACIXSTORE()hook deduplicates concurrent refresh calls to prevent race conditions.Transparency: Added detailed privacy disclosures explaining all data flows and risks
https://claude.ai/code/session_018Y1hrzwvFrXPtS3mxJyVJZ