Authentication (Dashboard)

Dashboard login uses NextAuth v4 with the credentials provider and JWT sessions.

Files

  • src/lib/auth/options.ts — NextAuth config.
  • src/app/api/auth/[...nextauth]/route.ts — NextAuth catch-all.
  • src/app/api/auth/register/route.ts — signup.
  • src/lib/auth/login-code.ts — emailed 6-digit first factor.
  • src/lib/auth/phone-verification.ts — signup SMS codes + the pending session.
  • src/lib/auth/email-verification.ts — signup email codes + the legacy link.
  • src/lib/auth/signup-notification.ts — the internal "new signup" mail, gated on a fully verified account.
  • src/lib/auth/trusted-device.ts — per-browser tokens that skip the emailed code.
  • proxy.ts — gate for /overview/* (named proxy, not middleware, since Next 16).
  • src/app/providers.tsx — client SessionProvider.
  • src/app/(auth)/login/page.tsx, register/page.tsx, verify-phone/page.tsx, verify-email/page.tsx — UI.

Registration flow

POST /api/auth/register:

{ "name": "Jane", "email": "jane@acme.com", "password": "at-least-8", "phone": "+14155550142" }

phone is required exactly when SYSTEM_SMS_FROM is set — see Phone verification.

Order matters:

  1. Validate, and screen the number with canDeliverTo — a destination we can never text would become an account that can never sign in.
  2. Rate-limit register:ip (10/h) and register:email (3/h).
  3. Spend the SMS send tokens (reserveSendSlot) — before anything is written, because the account cannot be un-created if the send is refused.
  4. With no phone collected, spend the email send token (reserveEmailSendSlot) too — same reasoning, since the mailed code is then the only thing standing between the account and a usable session.
  5. One sql.transaction([...]): users (bcrypt cost 12, signup_source = 'email/password') + organizations ("{name}'s Org", slug from the email prefix + last 6 of the org id) + org_members (owner) + the default projects row + a verified sandbox domains row. Neon HTTP has no db.transaction, so this is the raw-sql form.
  6. Text the code, set the sendoka_pv pending cookie, and return phoneVerificationRequired: true + emailVerificationRequired: true.

Registration never releases a session. The wizard runs phone → email, and the mailed code is minted by POST /api/auth/verify-phone rather than by registration, so its 10-minute window starts when the user reaches that screen instead of counting down while they type the texted one. POST /api/auth/verify-email-code is the last step in every variant of the flow, and for a new signup the only place the short-lived trusted-device cookie (VERIFICATION_TRUST_TTL_MS, 1 hour) is minted — verify-phone mints one only when no address is owed, which for a fresh signup is never. An hour rather than minutes because the window has to survive a pause the user paces: /api/auth/login-code answers { trusted: true } and mails nothing while the row is live, so a window that closes before the password step leaves authorize() checking a code that was never sent. The login form now also re-requests one when that happens.

With SYSTEM_SMS_FROM unset, steps 1/3/6 do not happen: registration mails the code itself and returns emailVerificationRequired: true with the sendoka_ev cookie.

See email-verification.md for the code/link split and the recovery paths.

Login flow

Three steps for a new browser, two for a trusted one:

  1. emailPOST /api/auth/login-code mails a 6-digit code (or answers that this browser is already trusted).
  2. code → validated before the password field is shown.
  3. passwordsignIn("credentials", ...).

CredentialsProvider.authorize() runs, in this order:

  • SAML one-shot token exchange, if present — claimed atomically, no password or 2FA challenge.
  • Rate limits: login on (email, ip) 5/15min, login_email 50/15min.
  • ssoEnforcedForEmailSSO_REQUIRED.
  • User lookup. A miss substitutes a sentinel id and runs the same gates, so a missing account is indistinguishable from a real one.
  • Emailed login code (verifyLoginCode), unless the browser is trusted. Checked before the password so a wrong code is not a password oracle. Not consumed here — a later failed TOTP would otherwise burn a valid code.
  • bcrypt.compare, always paid, against a fixed hash when the account is absent.
  • Phone gatePHONE_UNVERIFIED when verification is on and the account has an unverified number. Deliberately after the compare.
  • Email gateEMAIL_UNVERIFIED when users.signup_source is set and email_verified is NULL. Also after the compare, for the same oracle reason. Scoped to signup_source on purpose: only self-serve registration and OAuth set it, so invited/SAML/SCIM members and every account created before the gate are untouched. That matters because the old verification link 404'd, leaving most existing password accounts unverified — see email-verification.md.
  • TOTP / backup code when 2FA is enabled.
  • Consume the login code.

JWT callback

On first sign-in:

  • Set token.userId = user.id.
  • Look up user's first org_members.orgId (multi-org unsupported currently).
  • Set token.orgId.

Session callback

  • session.user.id = token.userId
  • session.user.orgId = token.orgId

Type augmentation in src/lib/auth/options.ts extends the NextAuth Session and JWT interfaces.

Post-login destination (?callbackUrl=)

Flows that need a session before they can finish — the OAuth consent screen, the invite page — send the browser to /login?callbackUrl=<path> and expect to be brought back. The login page and form honour it, for the credentials path and both OAuth buttons, and so does the already-signed-in redirect. sso-complete uses the same helper for the path carried through SAML RelayState.

The value arrives in a link, so it is attacker-controlled and points a browser that is about to hold a fresh session. safeCallbackPath (src/lib/auth/callback-url.ts) admits only a same-origin absolute path and falls back to /overview.

It parses rather than pattern-matches, because "starts with /, not //" does not model what a browser does with the string. For a special scheme a backslash is a slash, so /\evil.example/steal passes a prefix test and then resolves to another host — and %5C in a query string decodes to exactly that. Tab, LF and CR are stripped before parsing, so /<TAB>/evil.example becomes //evil.example after the test has run. Resolving against a sentinel origin and requiring the result to stay on it settles all of them, and the value returned is the rebuilt path, so anything the parser normalised away never reaches a Location: header. A repeated ?callbackUrl= arrives as string[], which is handled rather than thrown on.

Before this the parameter was produced by callers and silently discarded by the login form, so every "sign in first, we'll bring you back" flow dead-ended on the dashboard.

A session holder with no org is the exception. /login sends it to /no-org rather than to its callback, because the callback is an org-scoped page that would send it straight back to /login: a redirect loop. The one callback it keeps is /invite, since accepting an invite is how that user gets into an org. A session whose account no longer exists gets the login form. See Team → No organization.

Dashboard gate

proxy.ts runs on /overview/:path* (Next 16 renamed middleware.ts; the exported function must be called proxy):

const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET });
if (!token) return redirect("/login?callbackUrl=...");

Dashboard layout.tsx double-checks with getServerSession and redirects if absent — defense in depth.

OAuth providers

Google and GitHub providers are conditionally registered when their env pairs are set:

  • GOOGLE_CLIENT_ID + GOOGLE_CLIENT_SECRET
  • GITHUB_CLIENT_ID + GITHUB_CLIENT_SECRET

The signIn callback refuses before provisioning when:

  • The provider says it has not verified the address → /login?error=PROVIDER_EMAIL_UNVERIFIED. Checked first, before the SSO lookup, so an unproven address cannot probe whether an org enforces SSO. This is a three-valued signal, and the third value matters: true vouches, false refuses, and silence (a failed /user/emails call, an address the provider does not list) behaves exactly as it did before the signal existed. Collapsing unknown into false would turn a five-second API timeout into a lockout.
  • ssoEnforcedForEmail/login?error=SSO_REQUIRED.
  • The account has an unverified phone → /login?error=PHONE_UNVERIFIED.
  • The account is a self-provisioned signup with an unverified address → /login?error=EMAIL_UNVERIFIED.

Both gates are sign-in gates, not credentials-provider quirks: enforced only in authorize(), an abandoned signup could take the OAuth door on the same page that had just refused it and come out with a full session.

Claiming an unproven signup

users.email is UNIQUE, so the first /api/auth/register owns an address whether or not the person behind it can read that inbox. For the registrant the gates above are a bounce; for someone whose address was registered by a squatter they were a permanent lockout — phone-challenge texts the squatter's handset, email-challenge refuses while a phone is owed, and register answers 409 forever (issue #91).

So the callback runs one branch before either gate. When the row is an unverified self-serve signup (signup_source set, email_verified NULL) and the provider itself asserts it verified the address, claimUnverifiedSignup hands the row over in a single sql.transaction:

  • stamps email_verified, bumps session_version, adopts the provider's name;
  • nulls password_hash, phone, phone_verified and the SAML one-shot token;
  • deletes every pending email/phone verification, login code, password reset and trusted device, and revokes existing sessions;
  • revokes the row's OAuth refresh tokens and the api_keys those grants minted — best-effort and logged, since the transaction above has already committed by then, but a squatter who consented a browser-login grant would otherwise keep minting live keys against the claimed account;
  • renames the org, which registration derived from the squatter's display name.

signup_source is deliberately left as it was, so the row still records how it began. The UPDATE carries AND email_verified IS NULL AND signup_source IS NOT NULL, which makes the claim idempotent and means a verified account can never be scrubbed through this path even if the callback's branch were wrong.

The assertion is the load-bearing part. Google is OIDC and carries email_verified in the ID token claims. GitHub has no such field, so the provider block resolves it from /user/emails — for the address next-auth had already selected, never re-picking one, since changing the selection would re-point findOrCreateUser and provision duplicate accounts for people whose GitHub is already linked. A provider that asserts nothing does not claim: the row keeps its gate and the sign-in bounces, exactly as before.

When no provider vouches (or none is configured), the remaining way out is the 30-day stranded-signup sweep in /api/cron/retention, which deletes the account outright. It is opt-in — see crons.

Otherwise it calls findOrCreateUser(email, name, provider):

  • If the email exists → return the existing user id (OAuth links to the existing account).
  • Else create user + organization + org_member (owner) + default project + sandbox domain in a single transaction, with email_verified = NOW() and signup_source = <provider>.

Note findOrCreateUser never sets a password_hash or a phone, so an account it provisions is never subject to the phone gate, and it is verified the moment it exists — which is why the signup notification fires there immediately rather than waiting for a verification step.

2FA

Credentials provider accepts an optional totp field. If the user has two_factor_secrets.enabled = true:

  • Missing totp → throw new Error("2FA_REQUIRED") — client should re-prompt.
  • TOTP code (validates via verifyTotp()) OR one of the backup codes unlocks sign-in.
  • Used backup codes are filtered out of the stored array on successful login.

See two-factor-auth.md for setup flow.

Email verification & password reset

See email-verification.md and password-reset.md.

Internal signup notification

src/lib/auth/signup-notification.ts mails the team ("New Sendoka signup: …") when a new developer provisions their own org. Recipient: SIGNUP_NOTIFY_EMAIL, defaulting to the founder inbox.

It fires on a fully verified signup, not at account creation. Every endpoint that can stamp a verification column calls maybeNotifyNewSignupAsync(userId)verify-email-code, verify-email (link), verify-phone, and findOrCreateUser for OAuth — and the gate decides:

  1. users.signup_source is set. NULL = invited / SAML / SCIM / pre-gate account, which is a new seat, not a new developer.
  2. email_verified is set.
  3. phone_verified is set, if a phone is on file and SYSTEM_SMS_FROM is configured — the same condition authorize() gates sign-in with, so the mail means "this account can be used now", and clearing SYSTEM_SMS_FROM during an SMS incident does not silence it forever.

Once-only is a conditional UPDATE users SET signup_notified_at = NOW() WHERE signup_notified_at IS NULL, claimed before the send: the phone and email steps can finish concurrently and the link can be clicked twice. A failed send releases the claim so a later trigger retries.

Firing at creation instead announced accounts that could not sign in and often never would — the address might not exist, and the phone step might never be finished — so the inbox could not be read as "new developers".

Multi-org

See team.md. Active org is tracked by cookie (sendoka_active_org) — no re-login needed to switch.

Remaining gaps

  • JWT still captures first orgId at sign-in — server components using session.user.orgId directly see that default, not the cookie-overridden active org. Migrate to getActiveOrgId() helper as needed.
  • No WebAuthn / passkeys.
  • An already-enrolled member signing in through OAuth is not re-challenged for TOTP — NextAuth v4 has no hook to interleave a second factor into the OAuth callback. Tracked in the team's internal gap register.