F 01 Sign In
Status: ๐ข ready to test Owner: Adilet Last updated: 2026-06-06 Last verified on production: โ
Goal
A user authenticates with Clerk โ via Google OAuth or email + password โ
and reaches /app. The first time anyone signs up, Clerk fires a user.created
webhook that creates the matching users row (global role STUDENT). On every
later request, resolveCurrentDbUser/resolve-user.ts maps the live Clerk
session back to that users row.
Auth provider is Clerk, not NextAuth. There is no
next-authdependency, no/api/auth/[...nextauth]route, and noaccounts/sessions/verificationTokenstables. Clerk holds the credentials and the session; our DB holds a thinusersmirror keyed byclerk_id.
Actors
- Primary โ any visitor (becomes a
STUDENTon first sign-up; may later be promoted toADMIN/QA/SUPER_ADMINby changingusers.roleId). - Secondary โ the Clerk webhook (svix-signed
user.createdPOST to/api/webhooks) that creates theusersrow out-of-band; and any admin who may have pre-seeded ausersrow by email before the user ever signed in (the self-heal path inresolve-user.tsrelinks it).
Preconditions
- Clerk is configured (publishable + secret keys), and a webhook endpoint
pointing at
/api/webhookswithCLERK_WEBHOOK_SECRETset. Without the webhook, a brand-new Clerk user has a session but nousersrow, soresolveCurrentDbUserreturnsnulland/app/*API calls 401/redirect. - For Google OAuth, the Google account is reachable; Clerk brokers the
redirect to
/sso-callback. - For a returning sign-in, the
usersrow already exists (created on a prior sign-up's webhook, or pre-seeded by an admin withclerk_id = NULL).
Trigger
Either:
- The visitor clicks "Sign in" / "Sign up" and lands on
/sign-inor/sign-up, or - The visitor hits any protected route (e.g.
/app/issues) while signed out.clerkMiddlewarecallsauth.protect(), which โ because the route is not in the public matcher โ rewrites the request to a 404 rather than redirecting to a login page. (Public routes:/, locale roots/en|/kz|/ru,/sign-in*,/sign-up*,/sso-callback*,/api/webhooks*,/api/health*,/api/invitations*,/terms,/privacy.)
Happy path โ sign up (first ever) with email + password
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | User | /sign-up | Enter email + password, submit | Clerk client signUp.create({ emailAddress, password }) | โ | Clerk creates a pending user; status missing_requirements | โ |
| 2 | User | /sign-up (OTP step) | Enter 6-digit code | signUp.prepareEmailAddressVerification โ attemptEmailAddressVerification({ code }) | โ | email verified; Clerk session created via setActive | โ |
| 3 | Clerk | โ (async webhook) | Clerk POSTs user.created to /api/webhooks | POST /api/webhooks (svix-verified) | INSERT INTO users (clerk_id, email, first_name, last_name, role_id = ROLES.STUDENT=2) | row idempotently skipped if clerk_id already present | auth.signup |
| 4 | App | /app | router.push("/app") after setActive | first authed request โ resolveCurrentDbUser | โ (or self-heal UPDATE โ see N-path) | dashboard renders | auth.login |
Steps 3 and 4 race: the redirect to
/appcan land before the webhook finishes. If theusersrow isn't there yet,findOrRelinkUserByClerkSessionreturnsnullfor that first request. See Open questions.
Happy path โ sign up / sign in with Google (OAuth)
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | User | /sign-in or /sign-up | Click "Continue with Google" | authenticateWithRedirect({ strategy: "oauth_google", redirectUrl: "/sso-callback", redirectUrlComplete: "/app" }) | โ | redirect to Google consent | โ |
| 2 | Google โ Clerk | Google consent | Approve | back to /sso-callback; handleRedirectCallback({ afterSignInUrl, afterSignUpUrl }) | on new user only: user.created webhook โ INSERT INTO users (โฆ role_id=STUDENT) | Clerk session established | auth.signup (new) / auth.login |
| 3 | App | /app | /sso-callback resolves, pushes to /app | resolveCurrentDbUser on first authed request | self-heal UPDATE if a pre-seeded email row had NULL clerk_id | dashboard renders | auth.login |
Happy path โ returning sign in with email + password
| # | Actor | UI surface | Action | API call | DB writes | Side effects | Suggested event |
|---|---|---|---|---|---|---|---|
| 1 | User | /sign-in | Enter email + password, submit | signIn.create({ strategy: "password", identifier, password }) | โ | on status === "complete", setActive({ session }) | โ |
| 2 | App | /app | router.push("/app") | resolveCurrentDbUser resolves existing row by clerk_id | โ | dashboard renders | auth.login |
Session โ DB user resolution (every authed request)
resolveCurrentDbUser (via findOrRelinkUserByClerkSession in
src/lib/auth/resolve-user.ts) is the single source of truth for "who is the
current user":
auth()yields the ClerkuserId; if absent โnull(not signed in).- Look up
usersbyclerk_id(fast path) โ return it. - Self-heal: if no
clerk_idmatch, read the email off the Clerk session and look up ausersrow with that email ANDclerk_id IS NULL. If found,UPDATE users SET clerk_id = <session>and return it. A row already linked to a different Clerk id is never overwritten (prevents account hijack by email reuse). - If neither matches โ
null(Clerk session exists but no mirror row yet).
resolveCurrentEffectiveDbUserwraps this and additionally applies admin impersonation (see F-03); routes that need the real signed-in user useresolveRealCurrentDbUser.
Acceptance criteria
- AC-1 โ Given a signed-out visitor, when they request a protected
route like
/app/issues, then the middleware (auth.protect()on a non-public route) returns 404 โ not a redirect to a sign-in page. - AC-2 โ Given a brand-new Clerk user (first sign-up via email or
Google), when the
user.createdwebhook is delivered to/api/webhooks, then exactly oneusersrow is inserted withrole_id = ROLES.STUDENT (2),clerk_id= the Clerk user id, andemail= the primary email. - AC-3 โ Given a
user.createdwebhook for aclerk_idthat already has ausersrow, when it is processed, then no duplicate row is inserted (responds200 "User already exists"). - AC-4 โ Given a
usersrow that an admin pre-seeded by email withclerk_id = NULL, when that person signs in via Clerk with the matching email, thenresolve-user.tsrelinks the row (setsclerk_id) and returns it โ without creating a second row. - AC-5 โ Given a
usersrow whoseclerk_idis already set to id A, when a different Clerk session id B presents the same email, then the row is not relinked (noUPDATE), and resolution returnsnullfor B. - AC-6 โ Given a valid email + password, when the user submits the
sign-in form and Clerk returns
status === "complete", thensetActiveestablishes the session and the user lands on/app. - AC-7 โ Given an invalid password, when the user submits, then the
Clerk error's
longMessage/messageis shown inline and no session is set. - AC-8 โ Given a request to
/api/webhookswith missing or invalidsvix-id/svix-timestamp/svix-signatureheaders, then it is rejected with 400 and no DB write occurs. - AC-9 โ Given an API request carrying
Authorization: Bearer hk_live_โฆ, when it hits a non-public API route, then the middleware lets it through (skips the Clerk session check) so the route's own key validation runs.
Test data / fixtures
- A Clerk DEV instance (never prod) with two test users wired in
tests/e2e/fixtures/auth.ts: a Tester and an Admin. Each must also have a matchingusersrow (created by the webhook on first sign-in โ sign in once manually at/sign-into seed, pertests/e2e/README.md). - Env vars:
CLERK_PUBLISHABLE_KEY,CLERK_SECRET_KEY,E2E_TESTER_EMAIL/_PASSWORD,E2E_ADMIN_EMAIL/_PASSWORD,CLERK_WEBHOOK_SECRET(for webhook tests). - For the self-heal AC (AC-4/AC-5): an admin-seeded
usersrow withclerk_id = NULLand a known email.
Negative paths
| # | Scenario | Expected behavior |
|---|---|---|
| N-1 | Signed-out user navigates to /app/issues | Middleware auth.protect() rewrites to 404 (no redirect, no sign-in page) |
| N-2 | Wrong password at /sign-in | Clerk throws; form shows longMessage/message (e.g. "Password is incorrect"); no session, no DB write |
| N-3 | Sign-up email already registered in Clerk | signUp.create throws; first Clerk error message shown; user stays on /sign-up |
| N-4 | Webhook POST with missing svix headers | 400 "Error occurred -- no svix headers", no insert |
| N-5 | Webhook POST with bad signature | 400 "Error occured during webhook verification", no insert |
| N-6 | CLERK_WEBHOOK_SECRET unset | 500 "Webhook secret not configured", no insert |
| N-7 | New Clerk user reaches /app before webhook lands | resolveCurrentDbUser returns null; authed API/page can't resolve the user until the row exists (race โ see Open questions) |
| N-8 | Email row already linked to a different clerk_id, same email re-presented | Self-heal declines to relink (account-hijack guard); resolves to null |
| N-9 | OAuth consent canceled / /sso-callback errors | handleRedirectCallback catch โ router.push("/sign-in"); no session |
Manual QA checklist
- Signed out, navigate to
/app/issuesโ page returns 404 (confirm it is a 404, not a redirect to/sign-in) - Go to
/sign-up, register a brand-new email + password โ enter the 6-digit OTP โ land on/app - In the DB, confirm a new
usersrow exists withrole_id = 2(STUDENT) and the correctclerk_id+email - Sign out (Clerk), then sign back in at
/sign-inwith the same email + password โ land on/app, no duplicateusersrow created - Sign in / sign up with Continue with Google โ bounce through
/sso-callbackโ land on/app - (Self-heal) Pre-seed a
usersrow by email withclerk_id = NULL, then sign in via Clerk with that email โ confirm the row'sclerk_idgot populated (check the[auth-self-heal]server log) and no second row appeared - Enter a wrong password at
/sign-inโ inline error shown, still on/sign-in - POST to
/api/webhookswithout svix headers โ 400
Automated test outline
Playwright specs live in tests/e2e/flows/; auth is wired via the
@clerk/testing fixtures in tests/e2e/fixtures/auth.ts (testerPage /
adminPage, using clerkSetup + setupClerkTestingToken). Webhook + resolver
assertions are better expressed as unit tests on the pure logic.
// tests/e2e/flows/sign-in.spec.ts
test.describe("F-01 sign-in (Clerk)", () => {
test("unauthenticated /app/issues returns 404 (not redirect)", async ({ page }) => {
// 1. fresh context, no session
// 2. goto /app/issues
// 3. assert response status 404
});
test("email/password happy path lands on /app", async ({ testerPage }) => { โฆ });
test("google oauth happy path", async ({ page }) => { /* requires Clerk test OAuth */ });
test("wrong password shows inline error", async ({ page }) => { โฆ });
});
// webhook + resolver โ pure/unit (Vitest)
describe("user.created webhook", () => {
it("inserts users row with roleId = ROLES.STUDENT", โฆ);
it("is idempotent for an existing clerk_id", โฆ);
it("400s on missing svix headers", โฆ);
});
describe("resolveCurrentDbUser self-heal", () => {
it("relinks a NULL clerk_id row by email", โฆ);
it("refuses to relink a row already bound to another clerk_id", โฆ);
});Code references
- Pages:
src/app/(auth)/sign-in/[[...sign-in]]/page.tsx,src/app/(auth)/sign-up/[[...sign-up]]/page.tsx,src/app/(auth)/sso-callback/page.tsx,src/app/(auth)/layout.tsx - UI:
src/components/auth/sign-in-form.tsx,src/components/auth/sign-up-form.tsx,src/components/auth/oauth-buttons.tsx,src/components/auth/auth-layout.tsx - Middleware:
src/middleware.tsโclerkMiddleware, public-route matcher,auth.protect()(unauth/app/*โ 404 rewrite),Bearer hk_live_API-key passthrough - Webhook:
src/app/api/webhooks/route.tsโ svix-verified Clerkuser.createdhandler that inserts theusersrow (roleId: ROLES.STUDENT) - Session โ DB user:
src/lib/auth/resolve-user.tsโfindOrRelinkUserByClerkSession,resolveRealCurrentDbUser,resolveCurrentEffectiveDbUser(impersonation-aware) - Schema:
src/db/schema/users.ts(users.clerkId,users.email,users.roleId),src/db/schema/_enums.ts(ROLESโSTUDENT = 2,rolestable) - Tests:
tests/e2e/fixtures/auth.ts(@clerk/testingtoken flow),tests/e2e/flows/,tests/e2e/README.md
Events emitted (proposed)
Once telemetry is wired (see Feature Matrix ยง4):
auth.signupโ fired when theuser.createdwebhook inserts a newusersrow. Payload:{ user_id, clerk_id, method: 'google' | 'password' }.auth.loginโ fired on a successful session establishment (setActive/ resolved session). Payload:{ user_id, method: 'google' | 'password' }.
Open questions / known gaps
- Webhook race on first sign-up. The client redirects to
/appas soon assetActivesucceeds, but theusersrow is created asynchronously by theuser.createdwebhook. A first authed request can arrive before the row exists, soresolveCurrentDbUserbriefly returnsnull. No on-demand "ensure user row" fallback exists yet โ consider a lazy upsert inresolve-user.ts, or gating/appon row existence. - Sign-out is not specified here. Clerk's
signOut()clears the session; there is no custom sign-out route orauth.logoutserver event today. user.updated/user.deletedwebhooks are stubs insrc/app/api/webhooks/route.tsโ email/name changes in Clerk are not mirrored tousers, and deletions leave the row in place. Document or implement before relying onusersemail staying in sync with Clerk.- No 2FA / magic-link surfaced;
needs_second_factoris handled only as an error string in the sign-in form.