Skip to content

feat(api): OAuth 2.1 + sessions + magic link auth (full v2 auth stack)#22

Open
alextnetto wants to merge 15 commits into
mainfrom
relaxed-leavitt-61728d
Open

feat(api): OAuth 2.1 + sessions + magic link auth (full v2 auth stack)#22
alextnetto wants to merge 15 commits into
mainfrom
relaxed-leavitt-61728d

Conversation

@alextnetto
Copy link
Copy Markdown
Member

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:

  • 01 — env vars + 6 new auth tables (users, sessions, oauth_clients, auth_codes, refresh_tokens, magic_links) + pages.owner_id FK
  • 02 — Ed25519 JWT signing/verification with JWKS (uses jose)
  • 03.well-known/oauth-authorization-server (RFC 8414), .well-known/oauth-protected-resource (RFC 9728), .well-known/jwks.json (RFC 7517)
  • 04POST /oauth/register (RFC 7591 dynamic client registration), rate-limited 10/IP/hour
  • 05 — Google OAuth flow with state JWT, server-rendered login page, signature verification against Google's JWKS
  • 06 — Magic Link passwordless email login via nodemailer, 5/email/15min rate limit, anti-enumeration
  • 07POST /oauth/token (authorization_code + refresh_token grants), POST /oauth/revoke, S256 PKCE with timingSafeEqual, refresh token rotation + family revocation
  • 08resolveAuth() + requireAuth() Hono middleware, lookupSession/createSession/deleteSession, MCP Bearer middleware with WWW-Authenticate discovery header
  • 09GET /auth/me, POST /auth/logout, browser_session=1 flow that sets pagent_session cookie (HttpOnly, Secure in prod, SameSite=Lax, 30-day sliding expiry)
  • 10pages.owner_id populated from c.var.user?.id for REST and from extra.authInfo.extra.sub for MCP tools; stdio MCP forwards PAGENT_TOKEN as Bearer

Security & correctness highlights

  • All secrets stored as SHA-256 hashes; raw tokens only in transit (cookie, URL, email)
  • PKCE S256 mandatory (plain not advertised, not accepted) — timingSafeEqual comparison
  • Token family revocation on auth-code or refresh-token replay (OAuth 2.1 §6.1)
  • Auth codes single-use via atomic UPDATE ... WHERE consumed_at IS NULL RETURNING ...
  • Sliding session expiry extended on every authenticated request
  • Backwards-compatible: when REQUIRE_AUTH=false (default), anonymous page creation still works; owner_id = NULL
  • Google ID token verified against https://www.googleapis.com/oauth2/v3/certs JWKS, not just decoded
  • getIssuer() is the single source of truth for the public URL — no hardcoded api.pagent.link anywhere

Test coverage

480 tests passing across 24 test files (up from 250 on main):

  • Unit tests for JWT signing/verify, PKCE, state JWT, magic-link tokens, handle generation, session helpers, middleware
  • Integration tests via Hono 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)
  • Rate-limit tests (registration, token, magic/send)
  • MCP Bearer auth tests (401 + WWW-Authenticate, valid Bearer pass-through, REQUIRE_AUTH=false grace period)
  • Anti-enumeration test for magic link send
  • owner_id wiring tests for both REST and MCP paths

Known limitations (V2 follow-ups)

  • Rate limiters are in-memory — fine for single-instance deploy, will need Redis/Upstash on horizontal scale.
  • No JWT denylist — 1-hour TTL is the only access-token revocation mechanism (spec acknowledges this for V1).
  • Browser session cookie scoped to API host. Cross-origin renderer (pagent.linkapi.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 pass
  • npm run typecheck — clean for apps/api, apps/web, apps/mcp
  • npm run lint and npm run format:check — clean
  • Manual smoke: with REQUIRE_AUTH=false (default), verify existing POST /new, GET /:id, MCP tools still work unchanged
  • Manual smoke: with REQUIRE_AUTH=true and keys/SMTP configured, verify:
    • GET /.well-known/jwks.json returns the Ed25519 key
    • POST /oauth/register issues a client_id
    • GET /oauth/authorize → Google callback → /oauth/token mints a JWT
    • Bearer token on POST /mcp works; missing Bearer returns 401 with WWW-Authenticate
    • Magic link email is delivered and verifies

alextnetto added 15 commits May 17, 2026 15:22
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.
@vercel
Copy link
Copy Markdown

vercel Bot commented May 18, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pagent Ready Ready Preview, Comment May 18, 2026 4:03am

Request Review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/api/db.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/auth/provider.ts
Comment on lines +440 to +441
const response = await mintTokens(user, clientId, row.scope);
await db.revokeRefreshToken(row.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/auth/provider.ts
*/
export async function generateUniqueHandle(local: string): Promise<string> {
const base = sanitizeHandle(local);
if (!(await db.getUserByHandle(base))) return base;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Comment thread apps/api/app.ts
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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/auth/routes.ts
// 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) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/mcp/http.ts
Comment on lines +197 to +199
scopes: claims.scope.split(/\s+/).filter(Boolean),
expiresAt: claims.exp,
extra: { sub: claims.sub, email: claims.email, handle: claims.handle },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread apps/api/auth/routes.ts
clientId: client_id,
redirectUri: redirect_uri,
codeChallenge: code_challenge,
scope: typeof scope === 'string' && scope.length > 0 ? scope : undefined,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant