feat(api): OAuth 2.1 + sessions + magic link auth (full v2 auth stack)#22
feat(api): OAuth 2.1 + sessions + magic link auth (full v2 auth stack)#22alextnetto wants to merge 15 commits into
Conversation
Seven feature specs (auth, file uploads, webhooks, public forms, audit log, custom URLs, agent submit) with cross-spec consistency review, dependency mapping, and 50 task files for implementation.
Extends envSchema with REQUIRE_AUTH plus the crypto/SMTP/token-lifetime vars from the auth spec (§9), with a superRefine that fails boot when REQUIRE_AUTH=true but any of JWT_SIGNING_KEY, JWT_PUBLIC_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, MAGIC_LINK_SECRET, AUTH_STATE_SECRET, SMTP_HOST, SMTP_USER, SMTP_PASS are missing. Adds users, sessions, oauth_clients, auth_codes, refresh_tokens, and magic_links to db.init() following the existing idempotent CREATE TABLE IF NOT EXISTS pattern, plus the nullable owner_id FK on pages with ON DELETE SET NULL. Schema bootstraps on every boot — no migrations. Foundation task for the OAuth 2.1 + sessions rollout. Auth code is not yet wired; this only lays down the schema and env contract.
…_hash index z.coerce.boolean() coerced every non-empty string to true, including the literal 'false' — so REQUIRE_AUTH=false from process.env booted the service as REQUIRE_AUTH=true and tripped the auth-vars superRefine. Replace with an explicit union/transform that matches the rest of the env layer (only 'true' and '1' are truthy; anything else is false). Add a UNIQUE index on sessions.token_hash. Every authenticated request looks up by token_hash; without the index each request does a sequential scan, and UNIQUE also defends against hash-collision inserts.
Implements apps/api/auth/jwt.ts — the cryptographic primitive the OAuth token endpoint and bearer-auth middleware will rely on. Pure crypto with no DB I/O: tokens carry every claim the request handler needs (sub, email, handle, client_id, scope), so verification is a single signature check against the cached public key. Key details, per spec §5: - alg=EdDSA, typ=at+jwt, kid=pagent-2026-05 - iat + ACCESS_TOKEN_TTL_SECONDS (default 3600s) drives exp - iss === aud (co-hosted AS+RS), both derived from PUBLIC_URL with the same localhost fallback app.ts uses - getJwks() emits the spec §5.3 shape with use=sig and kid Tests cover the documented attack surface: round-trip, expired, tampered payload, wrong signing key, wrong issuer, malformed input.
Three .well-known discovery routes MCP clients use to find the authorization server: AS metadata (RFC 8414), protected resource metadata (RFC 9728), and JWKS (RFC 7517). All public, all derive URLs from PUBLIC_URL via getIssuer() so the routes work in dev and prod without hardcoding api.pagent.link. Mounted as a Hono sub-app at root — the literal RFC-defined paths need to land exactly where MCP clients expect them.
POST /oauth/register lets MCP clients self-register before starting the authorization code flow. Implements the MCP SDK's OAuthRegisteredClientsStore interface against the oauth_clients table. - clients-store.ts: registerClient + getClient with validation/mapping - db.ts: insertOAuthClient + getOAuthClientById helpers - routes.ts: POST /oauth/register, rate-limited 10/IP/hour, returns OAuthClientInformationFull
Implements GET /oauth/authorize and GET /oauth/callback/google. The authorize endpoint validates client_id, redirect_uri (exact match), and PKCE parameters, then renders a server-side HTML login page with "Continue with Google" and a magic-link form. The callback exchanges Google's code for an ID token, upserts the user by email (auto- generating a handle from the local part with collision suffixing), issues a Pagent authorization code with the original PKCE challenge, and 302s back to the MCP client. State across the Google round-trip is carried in a short-lived HMAC-SHA256 JWT keyed on AUTH_STATE_SECRET.
Implements the Magic Link flow per Task 06 of the auth design spec. - `apps/api/auth/magic-link.ts` (new): `sendMagicLink` mints a 32-byte base64url token, stores SHA-256(token) + authorize context in `magic_links` with a 15-minute TTL, then sends the link via nodemailer; `verifyMagicLink` re-hashes and atomically consumes the row via `UPDATE ... RETURNING`. - `apps/api/auth/routes.ts`: adds `POST /oauth/magic/send` (5 / email / 15 min rate limit, 503 when SMTP_HOST is unset, identical response for existing and new emails per spec section 7.6) and `GET /oauth/magic` (verifies token, upserts user, mints auth code, 302s to the MCP client's redirect_uri). - `apps/api/db.ts`: adds `authorize_context jsonb` column to `magic_links` (idempotent ALTER) plus `insertMagicLink` / `verifyAndConsumeMagicLink` helpers. - `apps/api/auth/magic-link.test.ts` (new): 24 cases covering the round-trip, expired / consumed rejection, rate limit enforcement, anti-enumeration response shape, and the redirect happy path.
Implements POST /oauth/token (authorization_code + refresh_token grants) and POST /oauth/revoke per OAuth 2.1 / RFC 7009. PKCE S256 verification on auth-code exchange, opaque rt_-prefixed refresh tokens with SHA-256 storage, refresh-token rotation, and token family revocation on replay.
Wires resolveAuth on every route to populate c.var.user from the pagent_session cookie (SHA-256 hashed, sliding 30-day expiry) or Authorization: Bearer JWT. POST /new gates on requireAuth when REQUIRE_AUTH=true, otherwise pass-through; read endpoints stay public. MCP /mcp handler returns 401 with WWW-Authenticate pointing to the oauth-protected-resource metadata when REQUIRE_AUTH=true and no/invalid Bearer is presented.
Adds GET /auth/me (cookie-only profile lookup) and POST /auth/logout (deletes DB session row + clears cookie). Completes the browser_session=1 authorize path by setting the pagent_session cookie and redirecting to / in both the Google callback and magic link verify handlers — previously they returned an error stub. Session cookies use HttpOnly, SameSite=Lax, Path=/, Max-Age=2592000, and Secure in production only (Secure breaks http://localhost development). Sessions capture the request IP (last x-forwarded-for hop) and User-Agent at creation time for audit visibility.
Page rows now carry owner_id when created by an authenticated user: - POST /new reads c.var.user from resolveAuth() and passes ownerId through store.createPage / store.createHtmlPage to db.insertPage. - MCP tool handlers (show_ui, show_html) extract extra.authInfo.extra.sub set by the HTTP MCP Bearer middleware and pass it as ownerId. - Stdio MCP server forwards PAGENT_TOKEN as Authorization: Bearer on every REST call so the API can derive owner_id from the JWT sub claim. Anonymous requests during the grace period still succeed with owner_id NULL.
Four review-driven fixes to the auth implementation: - server.ts now calls initKeys(JWT_SIGNING_KEY, JWT_PUBLIC_KEY) at boot when the keys are configured. Without this the first call to /oauth/token or /.well-known/jwks.json threw and surfaced as a 500. - Drop the unused MAGIC_LINK_SECRET from env schema, AUTH_REQUIRED_VARS, .env.example, and tests. Magic-link tokens already carry 256 bits of entropy; adding an HMAC pepper is defense-in-depth only, deferred to V2. - exchangeGoogleCode now verifies Google ID-token signatures against https://www.googleapis.com/oauth2/v3/certs via createRemoteJWKSet + jwtVerify (with issuer/audience pinned), instead of decodeJwt-only. - pkceVerify and any other hash comparisons on the auth surface use crypto.timingSafeEqual with a length pre-check. Google + routes tests updated to sign mock ID tokens with a per-run RSA key pair and serve the matching JWKS from the fetch spy.
Reformats auth files and (pre-existing) webhook task docs to satisfy the project's prettier config; unblocks the pre-push hook.
Picks up the changes from c5c3808 (Task 10) so stdio MCP includes Authorization: Bearer header when PAGENT_TOKEN is set.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 456d063d56
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const rows = await c<UserRow[]>` | ||
| insert into users (email, name, avatar_url, handle) | ||
| values (${input.email}, ${input.name}, ${input.avatarUrl}, ${input.handle}) | ||
| on conflict (email) do update set |
There was a problem hiding this comment.
Handle case-insensitive email conflicts in upsert
The users table is made case-insensitive via users_email_idx on lower(email), but this upsert only handles conflicts on the plain email column. If a returning user comes back with different casing (e.g. User@x.com vs user@x.com), Postgres raises a unique-violation on the expression index instead of taking the update path, causing login/upsert to fail. This breaks auth for legitimate users whenever provider/email casing changes.
Useful? React with 👍 / 👎.
| const response = await mintTokens(user, clientId, row.scope); | ||
| await db.revokeRefreshToken(row.id); |
There was a problem hiding this comment.
Make refresh-token rotation single-use under concurrency
Refresh-token rotation is not atomic: two concurrent refresh requests can both read the same token as active, both mint new refresh tokens, and only then race to revoke the old token. Because revocation happens after minting and revokeRefreshToken is idempotent, both callers can succeed, which violates single-use rotation and weakens replay detection. This shows up under normal retries or parallel client calls against /oauth/token with grant_type=refresh_token.
Useful? React with 👍 / 👎.
| */ | ||
| export async function generateUniqueHandle(local: string): Promise<string> { | ||
| const base = sanitizeHandle(local); | ||
| if (!(await db.getUserByHandle(base))) return base; |
There was a problem hiding this comment.
Retry handle allocation on concurrent collisions
Handle selection is a read-then-write check (getUserByHandle before insert), so two first-time users with the same local-part (for example alice@a.com and alice@b.com) can both pick alice concurrently and then race into users_handle_idx. The loser gets a unique-constraint failure instead of falling back to alice2, causing intermittent login/signup failures under concurrency.
Useful? React with 👍 / 👎.
| app.post('/new', newPageLimiter, newPageHandler); | ||
| // POST /new — gated by requireAuth when REQUIRE_AUTH=true; otherwise the | ||
| // requireAuthIfEnabled middleware is a no-op pass-through. | ||
| app.post('/new', requireAuthIfEnabled, newPageLimiter, newPageHandler); |
There was a problem hiding this comment.
Enforce page:create scope on POST /new
When REQUIRE_AUTH=true, this route only checks that a user is authenticated, not that the bearer token has page:create. A token minted with narrower scope (for example page:read) can still create pages, which violates the documented scope grants and turns scope reduction into a no-op for REST mutations.
Useful? React with 👍 / 👎.
| // signature (already validated). The MCP-client path additionally validates | ||
| // every PKCE field — those checks would reject a browser-session callback, | ||
| // so split the flow here before re-validating. | ||
| if (claims.browserSession) { |
There was a problem hiding this comment.
Bind browser-session OAuth state to requester session
The browser-session callback path accepts any validly signed state JWT and immediately creates a session cookie, but the state is not tied to the initiating browser/session. An attacker can complete Google auth for their own account, forward the resulting callback URL (code + state) to a victim before code consumption, and log the victim into the attacker's account (login CSRF/session swapping).
Useful? React with 👍 / 👎.
| scopes: claims.scope.split(/\s+/).filter(Boolean), | ||
| expiresAt: claims.exp, | ||
| extra: { sub: claims.sub, email: claims.email, handle: claims.handle }, |
There was a problem hiding this comment.
Enforce MCP scopes before tool execution
The MCP auth middleware verifies JWT validity and parses claims.scope, but it never rejects insufficient scopes before passing the request to the transport. As a result, a token lacking page:create can still invoke show_ui/show_html and create pages, so OAuth scopes are effectively informational on the MCP path.
Useful? React with 👍 / 👎.
| clientId: client_id, | ||
| redirectUri: redirect_uri, | ||
| codeChallenge: code_challenge, | ||
| scope: typeof scope === 'string' && scope.length > 0 ? scope : undefined, |
There was a problem hiding this comment.
Apply documented default scopes when scope is omitted
When the authorize request omits scope, the code stores it as undefined/null and later mints access tokens with an empty scope claim (""). The design docs and metadata define a default of page:create page:read, so clients that omit scope receive tokens inconsistent with the advertised contract and may unexpectedly lose baseline permissions.
Useful? React with 👍 / 👎.
Summary
Implements the full V2 authentication stack per docs/superpowers/specs/2026-05-17-auth-design.md — OAuth 2.1 Authorization Server + Resource Server co-hosted on the API, with session cookies for browser clients and Bearer JWTs for MCP/API clients.
10 tasks landed across 13 commits:
users,sessions,oauth_clients,auth_codes,refresh_tokens,magic_links) +pages.owner_idFKjose).well-known/oauth-authorization-server(RFC 8414),.well-known/oauth-protected-resource(RFC 9728),.well-known/jwks.json(RFC 7517)POST /oauth/register(RFC 7591 dynamic client registration), rate-limited 10/IP/hourPOST /oauth/token(authorization_code + refresh_token grants),POST /oauth/revoke, S256 PKCE withtimingSafeEqual, refresh token rotation + family revocationresolveAuth()+requireAuth()Hono middleware,lookupSession/createSession/deleteSession, MCP Bearer middleware withWWW-Authenticatediscovery headerGET /auth/me,POST /auth/logout, browser_session=1 flow that setspagent_sessioncookie (HttpOnly, Secure in prod, SameSite=Lax, 30-day sliding expiry)pages.owner_idpopulated fromc.var.user?.idfor REST and fromextra.authInfo.extra.subfor MCP tools; stdio MCP forwardsPAGENT_TOKENas BearerSecurity & correctness highlights
plainnot advertised, not accepted) —timingSafeEqualcomparisonUPDATE ... WHERE consumed_at IS NULL RETURNING ...REQUIRE_AUTH=false(default), anonymous page creation still works;owner_id = NULLhttps://www.googleapis.com/oauth2/v3/certsJWKS, not just decodedgetIssuer()is the single source of truth for the public URL — no hardcodedapi.pagent.linkanywhereTest coverage
480 tests passing across 24 test files (up from 250 on
main):app.fetch()for every new route (well-known, /oauth/register, /oauth/authorize, /oauth/callback/google, /oauth/magic, /oauth/token, /oauth/revoke, /auth/me, /auth/logout)Known limitations (V2 follow-ups)
pagent.link↔api.pagent.link) needs an apex cookie domain or a same-origin proxy before the cookie is useful for the renderer.Test plan
npm test— verify 480/480 passnpm run typecheck— clean forapps/api,apps/web,apps/mcpnpm run lintandnpm run format:check— cleanREQUIRE_AUTH=false(default), verify existingPOST /new,GET /:id, MCP tools still work unchangedREQUIRE_AUTH=trueand keys/SMTP configured, verify:GET /.well-known/jwks.jsonreturns the Ed25519 keyPOST /oauth/registerissues a client_idGET /oauth/authorize→ Google callback →/oauth/tokenmints a JWTPOST /mcpworks; missing Bearer returns 401 withWWW-Authenticate