Skip to content

Refactor CCXP proxy login with server-side credential storage#793

Open
ImJustChew wants to merge 5 commits intomainfrom
claude/enhance-proxy-login-security-VQnHw
Open

Refactor CCXP proxy login with server-side credential storage#793
ImJustChew wants to merge 5 commits intomainfrom
claude/enhance-proxy-login-security-VQnHw

Conversation

@ImJustChew
Copy link
Copy Markdown
Member

Summary

This PR significantly refactors the CCXP proxy login system to improve security and user experience by:

  • Moving password handling to server-side with AES-256-GCM encryption
  • Implementing Puppeteer-based browser automation for reliable CCXP login
  • Adding optional automatic credential refresh via httpOnly cookies
  • Restructuring the authentication flow with proper error handling and rate limiting

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:

    • AES-256-GCM encryption using HKDF key derivation
    • Automatic key rotation every 90 days (deterministic versioning)
    • 30-day credential expiration with lazy cleanup
    • Token-based retrieval (opaque UUID, not password-based)
  • New ocr.ts: Extracted CAPTCHA solving logic for cleaner separation of concerns

  • Refactored auth.ts:

    • Removed inline login logic (moved to browser-login.ts)
    • Added /login endpoint with optional store_credentials parameter
    • Added /refresh endpoint for automatic session renewal via httpOnly cookie
    • Added /logout endpoint for credential cleanup
    • Implemented IP-based rate limiting (10 attempts/60s)
    • Added helper functions for cookie and encryption key management
  • Database: Added ProxyCredential table for storing encrypted passwords with version tracking and expiration

  • Wrangler config: Added LOGIN_RATE_LIMITER and Puppeteer browser binding

Frontend (apps/web)

  • New useHeadlessAIS.ts hook: Replaces scattered auth logic with centralized state management including:

    • Deduplication of concurrent refresh calls
    • Automatic session refresh when ACIXSTORE expires
    • Proper error handling and loading states
  • New headless-ais-api.ts: API client for login, refresh, logout, and data fetching endpoints

  • Updated AISNotLoggedIn.tsx:

    • Added comprehensive privacy/risk disclosure dialog
    • Integrated login form with optional credential storage checkbox
    • Clear explanation of what data is stored and how
  • Re-enabled student pages:

    • grades/page.tsx: Grades query with auto-refresh
    • student/id/page.tsx: Door access QR code and student ID display
    • parcel/page.tsx: Parcel tracking with status filtering
  • Updated documentation page (proxy-login/page.tsx):

    • Clarified that proxy login is optional and independent from main NTHUMods auth
    • Explained the Puppeteer + OCR login flow with diagram
    • Detailed auto-refresh mechanism and encryption approach
    • Added explicit security disclaimers about password transmission and server-side storage
  • Updated app registry (const/apps.ts): Added grades, student ID, and parcel apps to navigation

Type Updates

  • headless_ais.ts: Restructured storage schema to remove plaintext password, add user details, credential token, and expiration tracking

Notable 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

claude added 3 commits March 19, 2026 08:18
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
Copilot AI review requested due to automatic review settings March 29, 2026 13:41
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 29, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
courseweb Error Error Apr 1, 2026 11:37am

Request Review

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread services/api/src/headless-ais/auth.ts Outdated
Comment on lines +49 to +52
function getPrisma(c: any): PrismaClient {
const adapter = new PrismaD1(c.env.DB);
return new PrismaClient({ adapter } as any);
}
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +19
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;
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@@ -12,7 +12,6 @@ generator client {

datasource db {
provider = "sqlite"
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
provider = "sqlite"
provider = "sqlite"
url = env("DATABASE_URL")

Copilot uses AI. Check for mistakes.
Comment thread services/api/src/headless-ais/ocr.ts Outdated
Comment on lines +20 to +26
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`,
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().

Suggested change
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`,

Copilot uses AI. Check for mistakes.
Comment on lines +31 to +38
function setCredentialCookie(c: any, token: string) {
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: true,
sameSite: "Strict",
path: "/ccxp/auth",
maxAge: COOKIE_MAX_AGE,
});
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment thread services/api/src/headless-ais/auth.ts Outdated
Comment on lines +232 to +237
// 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 });
});
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +73 to +86
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.");
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
- 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
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Apr 1, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants