351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
/**
|
|
* GET /api/me — returns the authenticated user's identity, admin role, and provider setup status.
|
|
*
|
|
* Flow (normal — OIDC active):
|
|
* 1. getAuth(c) reads iss + sub from the OIDC session JWT cookie
|
|
* (validated and refreshed by oidcAuthMiddleware — never reaches here unauthenticated)
|
|
* 2. Derives displayName from OIDC claims (name → preferred_username → email → sub fallback)
|
|
* then calls upsertUser(iss, sub, displayName) — writes on first visit, corrects a
|
|
* previously blank displayName on subsequent visits (idempotent, keyed on iss+sub, D-10)
|
|
* 3. Queries users.isAdmin, member_credentials existence, and local_credentials existence
|
|
* 4. Returns { user: { id, displayName, color, isAdmin, needsProviderSetup, hasLocalCredential } }
|
|
*
|
|
* Flow (dev bypass — DEV_AUTH_BYPASS=true, non-production):
|
|
* devAuthBypass() injects DEV_USER via c.set('user', DEV_USER). oidcAuthMiddleware
|
|
* is NOT mounted in index.ts when the bypass is active, so getAuth(c) is never called.
|
|
* This handler reads c.get('user') first and short-circuits using the dev user's id,
|
|
* but STILL queries the DB for isAdmin (T-10-05: bypass skips OIDC, not the DB check)
|
|
* and member_credentials/local_credentials existence.
|
|
*
|
|
* Security (D-03, T-10-06):
|
|
* isAdmin is exposed for UX-only PWA nav gating — it is NOT the security boundary.
|
|
* The server enforces the role on every /api/admin/* request via requireAdmin (Plan 03).
|
|
*
|
|
* The OIDC session cookie is httpOnly + Secure + SameSite (T-02-03).
|
|
* No credential or refresh-token data is included in the response (T-02-04, T-10-07).
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
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';
|
|
import { users, memberCredentials, localCredentials } from '../db/schema.js';
|
|
import {
|
|
validateEncryptAndStoreCredential,
|
|
CredentialValidationError,
|
|
} from '../broker/credentialSync.js';
|
|
import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
|
|
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
|
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
|
import '../auth/devBypass.js';
|
|
|
|
export const meRouter = new Hono();
|
|
|
|
/**
|
|
* Looks up isAdmin, needsProviderSetup, and hasLocalCredential for a given userId.
|
|
* Always reads from the DB — bypass only skips OIDC, not this check (T-10-05).
|
|
* hasLocalCredential (AUTH-LOCAL-17): true when a local_credentials row exists for userId.
|
|
*/
|
|
async function resolveAdminAndSetupStatus(userId: number) {
|
|
const [userRow] = await db
|
|
.select({ isAdmin: users.isAdmin })
|
|
.from(users)
|
|
.where(eq(users.id, userId))
|
|
.limit(1);
|
|
|
|
const [cred] = await db
|
|
.select({ id: memberCredentials.id })
|
|
.from(memberCredentials)
|
|
.where(eq(memberCredentials.userId, userId))
|
|
.limit(1);
|
|
|
|
// AUTH-LOCAL-17: expose whether the user has a local username/password credential
|
|
const [localCred] = await db
|
|
.select({ id: localCredentials.id })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, userId))
|
|
.limit(1);
|
|
|
|
return {
|
|
isAdmin: userRow?.isAdmin ?? false,
|
|
needsProviderSetup: !cred,
|
|
hasLocalCredential: Boolean(localCred), // AUTH-LOCAL-17
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth helper — resolves the current user id from dev-bypass or OIDC session.
|
|
// Per project convention: duplicated per router (not extracted to shared module).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
async function resolveUserId(c: Context): Promise<number | null> {
|
|
const devUser = c.get('user') as { id: number } | undefined;
|
|
if (devUser) return devUser.id;
|
|
|
|
const auth = await getAuth(c);
|
|
if (!auth) return null;
|
|
|
|
const iss = (auth.iss as string | undefined) ?? '';
|
|
const sub = auth.sub ?? '';
|
|
const displayName = deriveDisplayName(auth);
|
|
const user = await upsertUser(iss, sub, displayName);
|
|
return user?.id ?? null;
|
|
}
|
|
|
|
meRouter.get('/', async (c) => {
|
|
// Dev-auth bypass path: devAuthBypass() sets c.get('user') to DEV_USER when active.
|
|
// Use the injected dev identity's id for DB lookups — no OIDC session needed,
|
|
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
|
|
const devUser = c.get('user');
|
|
if (devUser) {
|
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id);
|
|
return c.json({
|
|
user: {
|
|
id: devUser.id,
|
|
displayName: devUser.displayName,
|
|
color: devUser.color,
|
|
isAdmin,
|
|
needsProviderSetup,
|
|
hasLocalCredential, // AUTH-LOCAL-17
|
|
},
|
|
});
|
|
}
|
|
|
|
// Normal OIDC path: getAuth returns null only if the session is invalid.
|
|
// oidcAuthMiddleware on /api/* redirects unauthenticated requests before this handler
|
|
// is reached, so null here indicates a genuine session error.
|
|
const auth = await getAuth(c);
|
|
if (!auth) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
// iss and sub are the stable identity fields — identity is always keyed on iss+sub (D-10).
|
|
const iss = (auth.iss as string | undefined) ?? '';
|
|
const sub = auth.sub ?? '';
|
|
|
|
// Derive the best available display name from OIDC claims (name →
|
|
// preferred_username → email → sub fallback). Shared helper keeps every
|
|
// upsert call site in agreement (see deriveDisplayName).
|
|
const displayName = deriveDisplayName(auth);
|
|
|
|
const user = await upsertUser(iss, sub, displayName);
|
|
|
|
if (!user) {
|
|
return c.json({ error: 'Could not resolve user' }, 500);
|
|
}
|
|
|
|
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id);
|
|
|
|
return c.json({
|
|
user: {
|
|
id: user.id,
|
|
displayName: user.displayName,
|
|
color: user.color,
|
|
isAdmin,
|
|
needsProviderSetup,
|
|
hasLocalCredential, // AUTH-LOCAL-17
|
|
},
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/me/credential — member self-service credential endpoint (D-07)
|
|
//
|
|
// Security contract (T-10-12 Pitfall 6):
|
|
// - ALWAYS writes to currentUserId from the session — NEVER a body userId.
|
|
// - Any body.userId field is IGNORED — this endpoint cannot cross-write.
|
|
// - Uses the SAME shared validateEncryptAndStoreCredential helper as admin path.
|
|
// - Does NOT require requireAdmin — any authenticated member can set their own credential.
|
|
// - Failure (any Zod or validation failure) returns { error: 'Invalid request' } 400
|
|
// with NO echoed password (noEchoHook + CredentialValidationError → 400).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const meCredentialSchema = z.object({
|
|
// D-07: no userId field — body userId is not accepted (Pitfall 6)
|
|
providerType: z.literal('caldav'),
|
|
fastmailEmail: z.string().email().max(256),
|
|
appPassword: z.string().min(1).max(500),
|
|
});
|
|
|
|
/**
|
|
* noEchoHook for /api/me/credential: NEVER echo Zod error details (T-10-09 / Pitfall 7).
|
|
*/
|
|
const meNoEchoHook = (result: { success: boolean }, c: Context) => {
|
|
if (!result.success) {
|
|
return c.json({ error: 'Invalid request' }, 400);
|
|
}
|
|
};
|
|
|
|
meRouter.post('/credential', zValidator('json', meCredentialSchema, meNoEchoHook), async (c) => {
|
|
// Pitfall 6: ALWAYS resolve currentUserId from the session — never from the body.
|
|
const currentUserId = await resolveUserId(c);
|
|
if (!currentUserId) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const { fastmailEmail, appPassword, providerType } = c.req.valid('json');
|
|
// T-10-10: NEVER log appPassword or c.req.valid('json') here
|
|
|
|
try {
|
|
// D-07: identical validate→encrypt→store→sync path as admin, but always with
|
|
// currentUserId (not a body userId). Admin passes the target member's userId;
|
|
// self-service passes the authenticated session userId. Same helper, same argument order.
|
|
await validateEncryptAndStoreCredential(
|
|
currentUserId,
|
|
fastmailEmail,
|
|
appPassword,
|
|
providerType,
|
|
);
|
|
} catch (err) {
|
|
if (err instanceof CredentialValidationError) {
|
|
return c.json({ error: 'Invalid request' }, 400);
|
|
}
|
|
console.error(
|
|
'[me/POST /credential] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
|
|
return c.json({ ok: true }, 200);
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/me/password — self-change password (AUTH-LOCAL-09, T-19-07)
|
|
//
|
|
// Security contract:
|
|
// - T-19-07: verifyPassword(current) required before any update; resolveUserId from
|
|
// session (not body). User can only change their OWN password.
|
|
// - meNoEchoHook: NEVER return Zod error details (contains submitted passwords, T-19-06).
|
|
// - Never log currentPassword, newPassword, or c.req.valid('json') (T-19-06).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const mePasswordSchema = z.object({
|
|
currentPassword: z.string().min(1),
|
|
newPassword: z.string().min(8),
|
|
});
|
|
|
|
meRouter.post(
|
|
'/password',
|
|
zValidator('json', mePasswordSchema, meNoEchoHook),
|
|
async (c) => {
|
|
// T-19-07: ALWAYS resolve userId from session — never from body
|
|
const currentUserId = await resolveUserId(c);
|
|
if (!currentUserId) {
|
|
return c.json({ error: 'Unauthorized' }, 401);
|
|
}
|
|
|
|
const { currentPassword, newPassword } = c.req.valid('json');
|
|
// T-19-06: NEVER log currentPassword, newPassword, or the request body
|
|
|
|
// Look up the user's local_credentials row (404 if none — no local credential to change)
|
|
const [credRow] = await db
|
|
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
|
|
.from(localCredentials)
|
|
.where(eq(localCredentials.userId, currentUserId))
|
|
.limit(1);
|
|
|
|
if (!credRow) {
|
|
return c.json({ error: 'No local credential found' }, 404);
|
|
}
|
|
|
|
// T-19-07: verify current password before any update (WR-03: async scrypt)
|
|
const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
|
|
if (!isCorrect) {
|
|
// CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
|
|
// MutationCache treats any 401 as "session expired" and arms the re-auth
|
|
// interstitial / login redirect — so a 401 here would force-log-out a user who
|
|
// merely mistyped their current password. 403 is in-app authorization-failure and
|
|
// lets the client surface "current password incorrect" without dropping the session.
|
|
return c.json({ error: 'Current password incorrect' }, 403);
|
|
}
|
|
|
|
try {
|
|
await db
|
|
.update(localCredentials)
|
|
.set({ passwordHash: await hashPassword(newPassword) })
|
|
.where(eq(localCredentials.userId, currentUserId));
|
|
|
|
return c.json({ ok: true }, 200);
|
|
} catch (err) {
|
|
console.error(
|
|
'[me/POST /password] Unexpected error:',
|
|
err instanceof Error ? err.message : String(err),
|
|
);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
},
|
|
);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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).
|
|
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
|
|
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
|
|
// instance does not report oidcEnabled:true while returning authorizationUrl:null here.
|
|
// WR-02: discover the authorization_endpoint from the provider's discovery document instead
|
|
// of hardcoding Authelia's /api/oidc/authorization path.
|
|
let authorizationUrl: string | null = null;
|
|
const oidc = await resolveOidcConfig();
|
|
if (oidc) {
|
|
const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer);
|
|
if (authEndpoint) {
|
|
const url = new URL(authEndpoint);
|
|
url.searchParams.set('response_type', 'code');
|
|
url.searchParams.set('client_id', oidc.clientId);
|
|
url.searchParams.set('redirect_uri', oidc.redirectUri);
|
|
url.searchParams.set('scope', 'openid profile email');
|
|
url.searchParams.set('state', signedState);
|
|
authorizationUrl = url.toString();
|
|
}
|
|
}
|
|
|
|
return c.json({ signedState, authorizationUrl }, 200);
|
|
});
|