feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation

apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
  (UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)

apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
  ({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
This commit is contained in:
Lucas Berger
2026-06-17 16:38:14 -04:00
parent 8ced2d0a20
commit efb80c8c1a
2 changed files with 153 additions and 0 deletions
+65
View File
@@ -30,6 +30,8 @@ import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq } from 'drizzle-orm';
import { Jwt } from 'hono/utils/jwt';
import { randomBytes } from 'node:crypto';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
import { db } from '../db/client.js';
@@ -274,3 +276,66 @@ meRouter.post(
}
},
);
// ---------------------------------------------------------------------------
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
//
// Purpose: initiates the OIDC authorization-code flow for the currently-authenticated
// local user. The current userId is encoded in a signed OIDC `state` parameter so that
// the /callback handler (plan 19-03) can bind the returned iss+sub to this user.
//
// Security contract (T-19-09 — OIDC-link CSRF):
// - state payload: { linkUserId, nonce } signed with LOCAL_SESSION_SECRET (HS256)
// - nonce: 16-byte random hex string per request — prevents state replay
// - Only the user encoded in `state.linkUserId` is bound on callback (T-19-09)
//
// Returns:
// { signedState: string, authorizationUrl: string | null }
// signedState: JWT for the OIDC state parameter (plan 19-03 /callback reads this)
// authorizationUrl: OIDC authorization endpoint URL with state, or null if OIDC not configured
//
// The PWA (Surface 13) redirects the user to authorizationUrl. The actual binding
// (UPDATE users + DELETE local_credentials) happens in the /callback handler (19-03).
// ---------------------------------------------------------------------------
meRouter.post('/link-oidc', async (c) => {
const currentUserId = await resolveUserId(c);
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const secret = process.env.LOCAL_SESSION_SECRET;
if (!secret) {
return c.json({ error: 'Service unavailable' }, 503);
}
// Produce a signed state token encoding the current userId + a per-request nonce
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
const nonce = randomBytes(16).toString('hex');
const now = Math.floor(Date.now() / 1000);
const signedState = await Jwt.sign(
{ linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window
secret,
'HS256',
);
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button)
const issuer = process.env.OIDC_ISSUER ?? null;
const clientId = process.env.OIDC_CLIENT_ID ?? null;
const redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
let authorizationUrl: string | null = null;
if (issuer && clientId && redirectUri) {
// Construct the authorization URL. plan 19-03 will handle the full PKCE flow;
// for now encode the signed state so the callback can read linkUserId.
const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', clientId);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('scope', 'openid profile email');
url.searchParams.set('state', signedState);
authorizationUrl = url.toString();
}
return c.json({ signedState, authorizationUrl }, 200);
});