Skip to content

Epic 1 — Authentication & Onboarding


BE-AUTH-001 — Social login (Google, Facebook, LinkedIn)

  • [x] Implemented

Files:

  • Create: src/services/auth/socialLogin.ts
  • Edit: src/graphql/schema/index.ts — add socialLogin(provider: String!, idToken: String!): AuthPayload!
  • Edit: src/graphql/resolvers/auth.ts — add socialLogin resolver

Description: Accept a provider ID token from the frontend (issued by Firebase Auth or Auth0). Verify the token server-side, find or create the user in PostgreSQL, return a CasTyou JWT pair. Supported providers: GOOGLE, FACEBOOK, LINKEDIN.

Acceptance criteria:

  • New users are created with role determined by the onboarding step (not hardcoded)
  • Existing social users receive a fresh token pair
  • Invalid/expired provider tokens throw UNAUTHENTICATED

BE-AUTH-002 — Two-Factor Authentication (2FA)

  • [x] Implemented

Files:

  • Create: src/services/auth/twoFactor.ts — TOTP generation/verification (use otplib)
  • Edit: prisma/schema.prisma — add twoFactorSecret String?, twoFactorEnabled Boolean @default(false) to User
  • Edit: src/graphql/schema/index.ts — add enable2FA, verify2FA, disable2FA mutations; extend LoginResult
  • Edit: src/graphql/resolvers/auth.ts

Description: TOTP-based 2FA. enable2FA returns a QR code URL and backup codes. verify2FA(code) confirms setup. Login returns a requiresTwoFactor: Boolean flag when 2FA is enabled; client must call verify2FALogin(tempToken, code) to get the full JWT pair.


FE-AUTH-001 — Social login buttons (app)

  • [x] Implemented

Files:

  • Edit: apps/app/src/pages/auth/LoginPage.tsx
  • Edit: apps/app/src/pages/auth/RegisterPage.tsx
  • Create: apps/app/src/lib/socialAuth.ts — Firebase client init

Description: Add Google, Facebook, LinkedIn sign-in buttons below the email/password form. Use Firebase Auth JS SDK to get the provider ID token, then call the socialLogin GraphQL mutation. Store the resulting JWT in Zustand auth store.


FE-AUTH-002 — Onboarding flow (role selection + profile bootstrap)

  • [x] Implemented

Files:

  • Create: apps/app/src/pages/onboarding/OnboardingPage.tsx
  • Create: apps/app/src/pages/onboarding/RoleSelectStep.tsx
  • Create: apps/app/src/pages/onboarding/TalentBootstrapStep.tsx
  • Create: apps/app/src/pages/onboarding/ProducerBootstrapStep.tsx
  • Edit: apps/app/src/App.tsx — add /onboarding route, redirect new users there

Description: After first login (or social auth), if the user has no profile yet, redirect to /onboarding. Step 1: choose role (Talent / Producer / Agency). Step 2: collect minimum required fields for that role (Talent: displayName, primaryCategory, location; Producer: industryRole, companyName). On completion, create the profile via GraphQL and redirect to the main app.

All step components must use @castyou/design-system components only.


BE-VERIFY-001 — Identity liveness verification (Verified badge)

  • [x] Implemented

Files:

  • Create: src/services/auth/liveness.ts — challenge token generation + HMAC validation
  • Create: src/services/auth/faceComparison.ts — HTTP client to the face-comparison sidecar
  • Edit: prisma/schema.prisma — add fields to User, TalentProfile, ProducerProfile
  • Run: pnpm db:migrate
  • Edit: src/graphql/schema/index.ts — add createLivenessChallenge, confirmLiveness mutations
  • Create: src/graphql/resolvers/verification.ts
  • Edit: src/graphql/resolvers/index.ts
  • Create: face-comparison/ — standalone Python/FastAPI sidecar (separate repo or subdirectory with its own Dockerfile)

Description: Optional one-time identity check that awards a permanent Verified badge. Fully self-hosted on Hetzner — no external API. Built on two parts:

  1. Browser-side liveness (MediaPipe FaceMesh, runs as WebAssembly — no server GPU needed): issues a short randomised movement challenge (e.g. look left → look right → blink), detects completion via facial landmark tracking, and captures a still frame when the challenge passes.
  2. Server-side face comparison (Python/FastAPI sidecar using face_recognition, dlib-based, CPU-only, ~300 MB RAM): compares the captured liveness frame against the user's existing profile photo.

Challenge flow:

  1. Client calls createLivenessChallenge → backend generates a time-limited HMAC-signed token containing a random challenge sequence (e.g. ["LOOK_LEFT", "LOOK_RIGHT", "BLINK"]) and returns it
  2. Frontend runs MediaPipe FaceMesh, verifies each step of the challenge in order, captures a JPEG frame on success
  3. Client calls confirmLiveness(challengeToken, capturedFrameBase64)
  4. Backend validates the HMAC token (expiry + user binding), forwards the frame + profile photo URL to the face-comparison sidecar
  5. Sidecar returns { match: boolean, distance: float } — accept if distance ≤ 0.5
  6. On success: set User.verifiedAt, award "Verified" badge

Prisma additions:

prisma
// on User
verifiedAt        DateTime?

// on TalentProfile and ProducerProfile
verifiedAt        DateTime?   // denormalised for fast feed queries

GraphQL:

graphql
type LivenessChallenge {
  token:     String!   # HMAC-signed, expires in 5 min
  steps:     [String!]!  # e.g. ["LOOK_LEFT", "LOOK_RIGHT", "BLINK"]
}

type VerificationResult {
  success:    Boolean!
  verifiedAt: String
  error:      String   # FACE_MISMATCH | TOKEN_EXPIRED | NO_PROFILE_PHOTO | CHALLENGE_FAILED
}

createLivenessChallenge: LivenessChallenge!
confirmLiveness(token: String!, frame: String!): VerificationResult!

Face-comparison sidecar (face-comparison/):

face-comparison/
  main.py          # FastAPI app, POST /compare { frame_b64, photo_url } → { match, distance }
  requirements.txt # face_recognition, fastapi, uvicorn, Pillow, requests
  Dockerfile       # python:3.11-slim, installs dlib + face_recognition (CPU build)

Runs as a separate Coolify service on the same Docker network. Express BE calls it via internal hostname (e.g. http://face-comparison:8001/compare) — never exposed to the internet.

Security rules:

  • Challenge token is HMAC-SHA256 signed with LIVENESS_SECRET env var; includes userId + expiresAt + random nonce
  • Token expires after 5 minutes
  • One valid token per user at a time (store nonce in Redis, invalidate on use)
  • confirmLiveness rejects tokens not belonging to the requesting user
  • The captured frame is never persisted — used only for the comparison call, then discarded
  • Re-verification is allowed (e.g. after profile photo change) — overwrites verifiedAt
  • Require profile photo to exist before verification can be attempted; return NO_PROFILE_PHOTO error otherwise

RAM impact on Hetzner CX22: sidecar uses ~300 MB idle. Current headroom is ~2 GB, so fits comfortably. Upgrade to CX32 only if overall usage exceeds 3 GB.

Environment variables needed:

env
LIVENESS_SECRET=<random-secret>          # for HMAC signing
FACE_COMPARISON_URL=http://face-comparison:8001  # internal Docker network

Acceptance criteria:

  • Face mismatch → success: false, error: "FACE_MISMATCH"
  • Expired / tampered token → success: false, error: "TOKEN_EXPIRED"
  • No profile photo → success: false, error: "NO_PROFILE_PHOTO"
  • Verified users show a shield/checkmark badge on TalentCard, profile pages, and job applications
  • Non-verified users can still use the platform fully — verification is optional

FE-VERIFY-001 — Liveness verification UI

  • [x] Implemented

Files:

  • Create: apps/app/src/pages/verify/VerifyIdentityPage.tsx
  • Create: apps/app/src/components/LivenessChallenge.tsx — MediaPipe camera component
  • Create: apps/app/src/hooks/useLivenessVerification.ts
  • Edit: apps/app/src/pages/profile/ProfilePage.tsx — add "Get Verified" banner when verifiedAt is null
  • Edit: apps/app/src/App.tsx — add route /verify

Dependencies to add:

bash
pnpm --filter @castyou/app add @mediapipe/tasks-vision

Description: Verification page accessible from the profile page banner and settings. Shows a brief explanation ("Verify your identity to earn a Verified badge — producers trust verified talent"), then runs the liveness challenge in-browser using MediaPipe FaceMesh (no camera stream leaves the device until the final still frame is sent).

Flow:

  1. Page mounts → calls createLivenessChallenge mutation → gets { token, steps }
  2. LivenessChallenge component opens the camera, loads MediaPipe FaceMesh (WebAssembly), and guides the user through each step with an animated overlay (arrow for look directions, eye icon for blink)
  3. On each step completion (detected via landmark deltas), advances to the next; captures a JPEG frame when all steps are done
  4. Calls confirmLiveness(token, frameBase64) mutation
  5. Success → show confetti / success state, update verifiedAt in Zustand store, badge appears immediately
  6. Failure → show friendly retry prompt; allow max 3 attempts per page load (generate a fresh token each time)

LivenessChallenge component is the exception to the DS rule — it owns the raw camera/ML logic. All surrounding UI (banner, result states, CTA buttons) must use @castyou/design-system.

Acceptance criteria:

  • Works on mobile browsers (Chrome, Safari) via the MediaStream API
  • Gracefully handles camera permission denial with a clear explanation and a link to browser settings
  • "Get Verified" banner is dismissible but reappears after 7 days if still unverified
  • Verified badge visible immediately after success without page reload
  • MediaPipe WASM loads lazily (not on app startup) to avoid bundle bloat