Basilic
Architecture

Authentication

JWT access + refresh. Magic link, OAuth, Web3, passkeys, TOTP, and API keys.

Auth lives in apps/api as JWT Bearer over Drizzle sessions. Fastify is the only issuer and revocation authority. Web cookie work uses Next client → Next route → @repo/core SDK → Fastify; the Next api.session cookie is an SSR/security copy, not a second session store. Domain data (profile, API keys, AI) stays browser SDK → Fastify Bearer. Next.js proxy.ts is a UI gate that verifies JWTs with the shared JWT_SECRET. It does not revoke sessions.

Methods: magic link, OAuth (GitHub, Google, Facebook, Twitter), Web3 (EIP-155 / Solana), passkeys, TOTP (2FA only — not a standalone sign-in method), API keys (bask_<prefix>_<secret>, hashed at rest). Linking (email, wallet, OAuth) is Account linking. Exact paths: /reference or OpenAPI.

Tokens

  • Access JWT (typ=access): sub, sid, optional wal for wallet sessions. Lifetime: ACCESS_JWT_EXPIRES_IN_SECONDS. Fastify verifies JWT then loads the session.
  • Refresh JWT (typ=refresh): sub, sid, jti. Rotation at POST /auth/session/refresh uses a compare-and-swap UPDATE on id, token (hashed jti), and userId (sub). Overlapping presents of the previous jti within REFRESH_REUSE_GRACE_SECONDS (default 10s) receive the same new tokens. Replay after the window returns 401 TOKEN_REUSE_DETECTED and revokes the session; further refresh attempts return SESSION_NOT_FOUND.
  • Web refresh hop: the browser never calls Fastify refresh. On 401, createClient calls same-origin POST /api/auth/refresh, which uses createBffClient → Fastify refresh and Set-Cookie. proxy.ts shares refreshTokensWithRefreshToken. Non-Next JWT consumers still call Fastify refresh directly. httpOnly stays false so the browser can send Bearer to Fastify for domain data.
  • Session: created on login, rotated on refresh, deleted on logout (POST /auth/session/logout returns 204 for JWT sessions) or via DELETE /auth/sessions/:id / public POST /auth/sessions/revoke.

New-device email

createSessionAndIssueTokens writes IP, user-agent, deviceLabel, location (Vercel/Cloudflare geo headers only), and deviceFingerprint (browserFamily|osFamily). Fastify emails only when no other session row for that user shares the fingerprint (the new row is excluded). Users without email skip mail. WEB_APP_URL (default http://localhost:3000) must pass isAllowedUrl or the email is skipped. The CTA is ${WEB_APP_URL}/auth/session/revoke?verificationId=&token= (session_revoke verification, hashToken, CAS consume). Send is fire-and-forget via emailProvider (render + send errors are caught and logged). There is no queue; completion is not guaranteed under serverless freeze.

GET /auth/sessions and DELETE /auth/sessions/:id are JWT-only (400 USE_KEY_REVOKE for API keys). List never returns userAgent or deviceFingerprint.

createClient modes: no-auth, JWT (getAuthToken / getRefreshToken / onTokensRefreshed, optional refreshTokens), API key. See Packages.

POST /auth/magiclink/verify accepts exactly one identifier:

  • { token, verificationId } — link-click flow (verificationId from callback URL)
  • { token, email } — code entry on the login page

Both/neither returns 400 INVALID_INPUT.

6-digit magic-link and change-email codes are stored as HMAC-SHA256 keyed by ENCRYPTION_KEY (hashLoginCode), not unsalted SHA-256. High-entropy secrets (API keys, refresh jti, OAuth state) still use hashToken (SHA-256). Codes issued before a deploy that changes the algorithm fail verify until the user re-requests (15-minute TTL).

Validate tokens

POST /auth/session/validate-tokens checks an access + refresh pair before Next.js writes cookies from in-page issuance (magic-link code, passkey, Google one-tap). Refresh itself does not use that adapter. Same-origin Next routes only; Fastify remains issuer and revocation authority.

Logout

POST /auth/session/logout (Bearer required) deletes the JWT session row and returns 204. API keys (bask_ / X-API-Key) cannot be logged out via this route — revoke the key instead (400 USE_KEY_REVOKE). Subsequent Bearer requests return 401.

API keys

POST /account/apikeys returns the raw bask_ key once. The database stores hashToken(secret), not the raw key.

Web3 callback storage

EIP-155 and Solana verify routes store short-lived callback JWTs in web3_callback encrypted at rest (AES-GCM). POST /auth/web3/exchange decrypts before returning tokens to the client. Decrypt failure returns 401 INVALID_OR_EXPIRED_CODE.

Passkey authentication

Passkey verify consumes the auth challenge before WebAuthn verification (one-shot; concurrent reuse returns EXPIRED_CHALLENGE). Credential counters use compare-and-swap on update. Redirect flows store encrypted tokens in passkey_callback (same AES-GCM helpers as Web3). POST /auth/passkey/exchange decrypts before returning tokens; decrypt failure returns 401 INVALID_OR_EXPIRED_CODE. Exchange requires a non-empty stored callbackOrigin and a matching request Origin / X-Callback-Origin. Stored callback origins must use HTTPS in production; outside production, HTTP is allowed only for loopback hosts (localhost, 127.0.0.1, ::1). See Login route rate limit for which auth routes share the tight Fastify cap.

Web (Next.js)

Callback routes under /auth/callback/* exchange with Fastify, set cookies, redirect to /. /auth/logout revokes the session (refresh first if the access JWT is expired) and clears cookies. /auth/session/revoke consumes the email token (works logged out) and clears api.session when the current JWT session was the one revoked. Browser 401 refresh uses same-origin POST /api/auth/refresh (cookie → SDK → Fastify); it does not call Fastify refresh from the browser. In-page issuance still uses POST /api/auth/update-tokens (same-origin, Fastify POST /auth/session/validate-tokens before Set-Cookie). After login, home is the news dashboard. Settings covers profile, sessions, and API keys. Change-email lives in Settings. Link-email and Web3 link exist on the API and in @repo/reactweb has no link-wallet or link-email UI. See Frontend.

Web auth gate

apps/web/proxy.ts is the only route gate. Matcher: /((?!api|_next/static|_next/image|favicon.ico|.*\\.svg$).*). Public paths: /auth/callback/*, /auth/logout, /auth/session/revoke, /terms, /privacy, /auth/login, /images/auth-login-hero.webp. Unauthenticated users → /auth/login; authenticated users on /auth/login/. Refreshes tokens on navigation.

OAuth environment variables

VariablePurpose
GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRETGitHub OAuth
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETGoogle OAuth
FACEBOOK_CLIENT_ID / FACEBOOK_CLIENT_SECRETFacebook OAuth
TWITTER_CLIENT_ID / TWITTER_CLIENT_SECRETTwitter / X OAuth
OAUTH_*_CALLBACK_URLSingle callback URL per provider
OAUTH_*_CALLBACK_URLSComma-separated callback URLs per provider
ENCRYPTION_KEYToken encryption and HMAC for magic-link codes
WEB_APP_URLOrigin for new-device revoke and sessions links (must be in ALLOWED_ORIGINS)

Magic link: request email → user clicks → verify → cookies. E2E uses test@test.ai when ALLOW_TEST=true. See E2E Testing.

On this page