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/*(namedproxy, notmiddleware, since Next 16).src/app/providers.tsx— clientSessionProvider.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:
- Validate, and screen the number with
canDeliverTo— a destination we can never text would become an account that can never sign in. - Rate-limit
register:ip(10/h) andregister:email(3/h). - Spend the SMS send tokens (
reserveSendSlot) — before anything is written, because the account cannot be un-created if the send is refused. - 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. - 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 defaultprojectsrow + a verified sandboxdomainsrow. Neon HTTP has nodb.transaction, so this is the raw-sqlform. - Text the code, set the
sendoka_pvpending cookie, and returnphoneVerificationRequired: 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:
- email →
POST /api/auth/login-codemails a 6-digit code (or answers that this browser is already trusted). - code → validated before the password field is shown.
- password →
signIn("credentials", ...).
CredentialsProvider.authorize() runs, in this order:
- SAML one-shot token exchange, if present — claimed atomically, no password or 2FA challenge.
- Rate limits:
loginon(email, ip)5/15min,login_email50/15min. ssoEnforcedForEmail→SSO_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 gate —
PHONE_UNVERIFIEDwhen verification is on and the account has an unverified number. Deliberately after the compare. - Email gate —
EMAIL_UNVERIFIEDwhenusers.signup_sourceis set andemail_verifiedis NULL. Also after the compare, for the same oracle reason. Scoped tosignup_sourceon 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.userIdsession.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_SECRETGITHUB_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:truevouches,falserefuses, and silence (a failed/user/emailscall, 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, bumpssession_version, adopts the provider's name; - nulls
password_hash,phone,phone_verifiedand 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_keysthose 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()andsignup_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:
users.signup_sourceis set. NULL = invited / SAML / SCIM / pre-gate account, which is a new seat, not a new developer.email_verifiedis set.phone_verifiedis set, if a phone is on file andSYSTEM_SMS_FROMis configured — the same conditionauthorize()gates sign-in with, so the mail means "this account can be used now", and clearingSYSTEM_SMS_FROMduring 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.orgIddirectly see that default, not the cookie-overridden active org. Migrate togetActiveOrgId()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.