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:
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* linkOidc.ts — OIDC-link binding helper (AUTH-LOCAL-10, D-12).
|
||||||
|
*
|
||||||
|
* Exports:
|
||||||
|
* - OidcLinkConflictError: thrown when iss+sub already belongs to a DIFFERENT user.
|
||||||
|
* - linkOidcToUser(userId, iss, sub): binds oidc_iss+oidc_sub to the user row and
|
||||||
|
* deletes their local_credentials row in an atomic transaction.
|
||||||
|
*
|
||||||
|
* Security contract (T-19-08):
|
||||||
|
* - Preflight SELECT checks iss+sub uniqueness BEFORE any write.
|
||||||
|
* - If a different user already owns iss+sub: throw OidcLinkConflictError; NO write occurs.
|
||||||
|
* - db.transaction wraps the UPDATE users + DELETE local_credentials so both succeed or
|
||||||
|
* neither does — no partial state where oidc is bound but local cred survives or vice versa.
|
||||||
|
* - Identity binding uses iss+sub ONLY — never the user's address field (D-10).
|
||||||
|
* - The uniq_oidc_identity DB constraint on users is the backstop behind the preflight
|
||||||
|
* (RESEARCH Pitfall 6 — preflight prevents the race-case before hitting the constraint).
|
||||||
|
*
|
||||||
|
* Called by:
|
||||||
|
* - apps/api/src/routes/me.ts POST /link-oidc (initiates OIDC flow, state carries userId)
|
||||||
|
* - apps/api/src/routes/localAuth.ts /callback (19-03) — reads linkUserId from state,
|
||||||
|
* calls linkOidcToUser after verifying the OIDC token.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { and, eq, ne } from 'drizzle-orm';
|
||||||
|
import { db } from '../db/client.js';
|
||||||
|
import { users, localCredentials } from '../db/schema.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by linkOidcToUser when iss+sub already belongs to a DIFFERENT user.
|
||||||
|
*
|
||||||
|
* Callers should translate this to a 409 response or error-redirect to the PWA.
|
||||||
|
* The thrown error deliberately carries no raw iss/sub values to avoid leaking
|
||||||
|
* identity correlation info in error logs (T-19-08).
|
||||||
|
*/
|
||||||
|
export class OidcLinkConflictError extends Error {
|
||||||
|
readonly name = 'OidcLinkConflictError';
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('OIDC identity already linked to a different account');
|
||||||
|
Object.setPrototypeOf(this, OidcLinkConflictError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind an OIDC identity (iss+sub) to the given userId and delete that user's
|
||||||
|
* local_credentials row (D-12: OIDC-link replaces local credential).
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. Preflight SELECT: check if iss+sub belongs to a user with id ≠ userId.
|
||||||
|
* If so: throw OidcLinkConflictError (NO writes).
|
||||||
|
* 2. db.transaction:
|
||||||
|
* a. UPDATE users SET oidc_iss=iss, oidc_sub=sub, claimed=true WHERE id=userId
|
||||||
|
* b. DELETE FROM local_credentials WHERE user_id=userId
|
||||||
|
* (user becomes OIDC-only; no local credential remains)
|
||||||
|
*
|
||||||
|
* D-10 constraint: binding is strictly iss+sub — no address claim or contact field used.
|
||||||
|
* T-19-08: abort before any write on conflict; uniq_oidc_identity constraint is backstop.
|
||||||
|
*
|
||||||
|
* @throws OidcLinkConflictError if iss+sub is already owned by a DIFFERENT userId.
|
||||||
|
*/
|
||||||
|
export async function linkOidcToUser(userId: number, iss: string, sub: string): Promise<void> {
|
||||||
|
// Preflight: check if another user already holds this iss+sub (T-19-08 / RESEARCH Pitfall 6)
|
||||||
|
// We SELECT WHERE oidc_iss=iss AND oidc_sub=sub AND id ≠ userId — only a DIFFERENT user is a conflict.
|
||||||
|
// If the same userId already has iss+sub (idempotent re-link): allow the UPDATE to proceed.
|
||||||
|
const [conflicting] = await db
|
||||||
|
.select({ id: users.id })
|
||||||
|
.from(users)
|
||||||
|
.where(and(eq(users.oidcIss, iss), eq(users.oidcSub, sub), ne(users.id, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (conflicting) {
|
||||||
|
// iss+sub belongs to a DIFFERENT user — abort before any write (T-19-08)
|
||||||
|
throw new OidcLinkConflictError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic: UPDATE users + DELETE local_credentials — both or neither (D-12)
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
// a. Bind the OIDC identity and mark the user as claimed
|
||||||
|
await tx
|
||||||
|
.update(users)
|
||||||
|
.set({ oidcIss: iss, oidcSub: sub, claimed: true })
|
||||||
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
|
// b. Delete the local_credentials row — user is now OIDC-only (D-12)
|
||||||
|
// Silently succeeds even if no local_credentials row exists (DELETE 0 rows is fine)
|
||||||
|
await tx.delete(localCredentials).where(eq(localCredentials.userId, userId));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ import type { Context } from 'hono';
|
|||||||
import { zValidator } from '@hono/zod-validator';
|
import { zValidator } from '@hono/zod-validator';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { Jwt } from 'hono/utils/jwt';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
import { getAuth } from '../auth/middleware.js';
|
import { getAuth } from '../auth/middleware.js';
|
||||||
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
import { upsertUser, deriveDisplayName } from '../auth/user.js';
|
||||||
import { db } from '../db/client.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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user