Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
668f310
feat(auth): add vercel-style passkey login with discovery UX
gaboesquivel Mar 8, 2026
76ad050
fix(docu): escape MDX email placeholder to fix build
gaboesquivel Mar 8, 2026
c9ba5a9
fix(fastify): validate userHandle base64url before decode in passkey …
gaboesquivel Mar 9, 2026
0328b24
chore(next): update button text
gaboesquivel Mar 9, 2026
511193b
fix(fastify): remove unnecessary curly braces in passkey resolve-user
gaboesquivel Mar 9, 2026
cbe4fd3
feat(next): replace passkey and github buttons with icon buttons
gaboesquivel Mar 9, 2026
74b2b63
feat(auth): add google one tap, facebook, twitter oauth login
gaboesquivel Mar 9, 2026
cbb26aa
feat(auth): disable oauth buttons when provider not configured
gaboesquivel Mar 9, 2026
6703629
fix(auth): OAuth robustness, Twitter user model, one-tap, cookie secu…
gaboesquivel Mar 9, 2026
04cfa4a
feat(auth): add magic link email pre-fill from cookie
gaboesquivel Mar 9, 2026
c56ea51
fix(auth): implement OAuth, passkey, and login feedback
gaboesquivel Mar 9, 2026
f723dd5
fix(auth): apply code review fixes for passkey, oauth, and login
gaboesquivel Mar 9, 2026
7c35750
refactor(next): remove last-used-email localStorage prefill
gaboesquivel Mar 9, 2026
e98eb0c
fix(react): await onSuccess before invalidating auth queries in use-p…
gaboesquivel Mar 9, 2026
4e9438d
fix(auth): security, atomicity and consistency improvements
gaboesquivel Mar 9, 2026
ec0f240
fix(auth): passkey error handling, redirect validation, verify-id-tok…
gaboesquivel Mar 9, 2026
8e0c8da
chore(cursor): add git rule
gaboesquivel Mar 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions apps/docu/content/docs/architecture/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ Passkeys use WebAuthn for passwordless registration and sign-in. Registration cr
- **Flow**: Client calls start → stores sessionId → `startAuthentication` → verify with sessionId in body → `window.location.assign(redirectUrl)` → callback page exchanges code, sets cookies, redirects
- **Error codes**: `INVALID_ORIGIN`, `EXPIRED_CHALLENGE`, `UNKNOWN_CREDENTIAL`, `VERIFICATION_FAILED`, `INVALID_CALLBACK_URL`

**Vercel-style discovery UX** (optional):

- **Resolve user**: `POST /auth/passkey/resolve-user` with `{ userHandle }` (base64url from assertion) → returns `{ maskedIdentifier }` for "Login as …" display (PII-safe). Rate limited (10/min per IP).
- **Discovery flow**: On login page load, client calls start → `startAuthentication({ useBrowserAutofill: true })` (conditional UI) → if credential returned, call resolve-user with `response.userHandle` → show `PasskeyShortcut` card above the form.
- **PasskeyShortcut**: Compact card with "Login as [email]", [Use Passkey] button, "Use another method" link. When user clicks "Use Passkey", verify without `callbackUrl` → receive `{ token, refreshToken }` → `updateAuthTokens` + `router.push`.
- **Direct token flow**: When `callbackUrl` is absent, verify returns `{ token, refreshToken }` directly (no redirect). Caller uses `onSuccess` to update cookies and navigate.
- **Fallback**: When discovery finds nothing, passkey remains in "Or continue with" (redirect flow).

### Magic link (implemented)

Magic link sign-in issues a **single-use token** over email, then exchanges it for JWTs.
Expand All @@ -92,6 +100,16 @@ OAuth sign-in uses provider authorization + callback flows and issues access + r

**Setup:** Create a [GitHub OAuth App](https://github.com/settings/applications/new). Set callback URL to `http://localhost:3000/auth/callback/oauth/github` (dev) or your production URL. Add `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, and `OAUTH_GITHUB_CALLBACK_URL` to Fastify env.

### OAuth (Google, Facebook, Twitter)

**Google One Tap:** Uses [Google Identity Services](https://developers.google.com/identity/gsi/web) (GIS). Load GIS script, call `google.accounts.id.prompt()` — popup appears in top-right when user is logged into Google. Client POSTs `{ credential }` to `POST /auth/oauth/google/verify-id-token`. Backend verifies JWT with `google-auth-library`, creates session. **Setup:** `GOOGLE_CLIENT_ID` (Fastify), `NEXT_PUBLIC_GOOGLE_CLIENT_ID` (Next.js).

**Facebook:** Redirect flow like GitHub. `GET /auth/oauth/facebook/authorize-url` → `POST /auth/oauth/facebook/exchange`. Callback: `/auth/callback/oauth/facebook`. **Setup:** `FACEBOOK_CLIENT_ID`, `FACEBOOK_CLIENT_SECRET`, `OAUTH_FACEBOOK_CALLBACK_URL`.

**Twitter (X):** OAuth 2.0 with PKCE. `GET /auth/oauth/twitter/authorize-url` (includes `code_challenge`) → `POST /auth/oauth/twitter/exchange` (includes `code_verifier` from verification `meta`). Callback: `/auth/callback/oauth/twitter`. **Setup:** `TWITTER_CLIENT_ID`, `TWITTER_CLIENT_SECRET`, `OAUTH_TWITTER_CALLBACK_URL`.

When a provider is not configured (503 `OAUTH_NOT_CONFIGURED`), the client shows a Sonner toast instead of redirecting.

### Web3 (SIWE / SIWS)

Web3 sign-in follows a **nonce → sign → verify** flow and returns access + refresh JWTs. Nonce and verify requests go **directly to Fastify**; the **callback** (token → cookie exchange) goes through the Next.js backend when cookies are needed.
Expand Down Expand Up @@ -254,6 +272,14 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
- `GET /auth/oauth/github/authorize-url` → `{ redirectUrl }` (for client-side redirect; preferred)
- `GET /auth/oauth/github/authorize` → 302 redirect to GitHub (legacy)
- `POST /auth/oauth/github/exchange` → `{ token, refreshToken }` (body: `{ code, state }`)
- **OAuth (Google One Tap)**
- `POST /auth/oauth/google/verify-id-token` → `{ token, refreshToken }` (body: `{ credential }`)
- **OAuth (Facebook)**
- `GET /auth/oauth/facebook/authorize-url` → `{ redirectUrl }`
- `POST /auth/oauth/facebook/exchange` → `{ token, refreshToken }` (body: `{ code, state }`)
- **OAuth (Twitter)**
- `GET /auth/oauth/twitter/authorize-url` → `{ redirectUrl }` (PKCE)
- `POST /auth/oauth/twitter/exchange` → `{ token, refreshToken }` (body: `{ code, state }`)
- **Web3**
- `GET /auth/web3/nonce` → `{ nonce }` (query: `chain`, `address`)
- `POST /auth/web3/:chain/verify` → `{ token, refreshToken }`
Expand Down Expand Up @@ -285,6 +311,7 @@ All endpoints below are served by `apps/fastify`. The API does not set cookies;
- **Passkey sign-in** (no auth)
- `POST /auth/passkey/start` → `{ options, sessionId }`
- `POST /auth/passkey/verify` → `{ redirectUrl }` or `{ token, refreshToken }` (body: `{ assertion, sessionId, callbackUrl? }`)
- `POST /auth/passkey/resolve-user` → `{ maskedIdentifier }` (body: `{ userHandle }`, for discovery UX)
- `POST /auth/passkey/exchange` → `{ token, refreshToken }` (body: `{ code }`)

- **Session**
Expand Down
14 changes: 14 additions & 0 deletions apps/fastify/.env-sample
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,19 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres
# GITHUB_CLIENT_SECRET=
# OAUTH_GITHUB_CALLBACK_URL=http://localhost:3000/auth/callback/oauth/github

# Optional: Google OAuth One Tap (GIS popup, top-right)
# GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
# NEXT_PUBLIC_GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com (same value, for Next.js)

# Optional: Facebook OAuth
# FACEBOOK_CLIENT_ID=
# FACEBOOK_CLIENT_SECRET=
# OAUTH_FACEBOOK_CALLBACK_URL=http://localhost:3000/auth/callback/oauth/facebook

# Optional: Twitter (X) OAuth
# TWITTER_CLIENT_ID=
# TWITTER_CLIENT_SECRET=
# OAUTH_TWITTER_CALLBACK_URL=http://localhost:3000/auth/callback/oauth/twitter

# CORS + URL allowlist. Default: * (any). Comma-separated origins to restrict.
# ALLOWED_ORIGINS=https://app.example.com,https://localhost:3000
10 changes: 9 additions & 1 deletion apps/fastify/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { config } from '@repo/eslint-config/base'

export default config
export default [
...config,
{
files: ['src/routes/auth/oauth/twitter/exchange.ts'],
rules: {
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],
},
},
]
Loading
Loading