feat: add supabase jwt auth context

This commit is contained in:
Codex
2026-06-29 00:17:29 +08:00
parent fbcfa5127e
commit 9553836ac7
20 changed files with 452 additions and 85 deletions

View File

@@ -1,4 +1,5 @@
import crypto from 'node:crypto';
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose';
import { config } from './config.js';
import { queryOne } from './db.js';
import { getHeader, type RequestContext } from './http.js';
@@ -11,9 +12,11 @@ export interface SessionIdentity {
avatarUrl: string | null;
primaryRole: string;
createdAt: string;
tenantId: string;
tenantId: string | null;
sessionId: string;
sessionExpiresAt: string;
authSource: 'app_session' | 'supabase_jwt';
authUserId: string | null;
}
interface RequestAuthState {
@@ -23,6 +26,7 @@ interface RequestAuthState {
}
const requestAuthState = new WeakMap<RequestContext, RequestAuthState>();
let remoteJwks: ReturnType<typeof createRemoteJWKSet> | null = null;
export function bearerTokenFrom(ctx: RequestContext) {
const authorization = getHeader(ctx.req, 'authorization');
@@ -30,6 +34,10 @@ export function bearerTokenFrom(ctx: RequestContext) {
return match?.[1]?.trim() || '';
}
function tenantContextFrom(ctx: RequestContext) {
return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId') || '';
}
export function hashSessionToken(token: string) {
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
}
@@ -40,7 +48,9 @@ export async function findUserBySessionToken(token: string) {
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt",
'app_session'::text as "authSource",
u.auth_user_id as "authUserId"
from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id
where s.token_hash = $1
@@ -52,12 +62,140 @@ export async function findUserBySessionToken(token: string) {
);
}
function expectedIssuer() {
return config.authJwtIssuer || undefined;
}
function expectedAudience() {
return config.authJwtAudience || undefined;
}
function symmetricJwtSecret() {
return new TextEncoder().encode(config.authJwtSecret);
}
function jwksKeySet() {
if (!config.authJwtJwksUrl) return null;
if (!remoteJwks) remoteJwks = createRemoteJWKSet(new URL(config.authJwtJwksUrl));
return remoteJwks;
}
async function verifySupabaseJwt(token: string) {
const verifyOptions = {
issuer: expectedIssuer(),
audience: expectedAudience(),
};
const jwks = jwksKeySet();
const verified = jwks
? await jwtVerify(token, jwks, verifyOptions)
: await jwtVerify(token, symmetricJwtSecret(), verifyOptions);
return verified.payload;
}
function objectClaim(payload: JWTPayload, key: string) {
const value = payload[key];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function tenantClaimFrom(payload: JWTPayload) {
const claim = payload.tenant_id || objectClaim(payload, 'app_metadata').tenant_id || objectClaim(payload, 'user_metadata').tenant_id;
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
}
function appRoleClaimFrom(payload: JWTPayload) {
const claim = payload.app_role || objectClaim(payload, 'app_metadata').app_role || payload.role;
return typeof claim === 'string' && claim.trim() ? claim.trim() : '';
}
function sessionExpiryFrom(payload: JWTPayload) {
return payload.exp ? new Date(payload.exp * 1000).toISOString() : new Date(Date.now() + 60_000).toISOString();
}
export async function findUserBySupabaseJwt(token: string, requestedTenantContext = '') {
let payload: JWTPayload;
try {
payload = await verifySupabaseJwt(token);
} catch {
return null;
}
const authUserId = typeof payload.sub === 'string' && payload.sub ? payload.sub : '';
if (!authUserId) return null;
const tenantClaim = tenantClaimFrom(payload);
if (tenantClaim && requestedTenantContext && tenantClaim !== requestedTenantContext) return null;
const requestedTenantId = tenantClaim || requestedTenantContext;
const appRole = appRoleClaimFrom(payload);
const platformUser = await queryOne<SessionIdentity>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
coalesce($2::uuid, tm.tenant_id) as "tenantId",
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
from public.platform_users u
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
where u.auth_user_id = $1::uuid
and u.primary_role = 'platform_admin'
and ($2::uuid is null or exists (
select 1
from public.tenant_memberships scoped_tm
where scoped_tm.user_id = u.id
and scoped_tm.tenant_id = $2::uuid
and scoped_tm.status = 'active'
))
order by tm.created_at asc nulls last
limit 1
`,
[authUserId, requestedTenantId || null, sessionExpiryFrom(payload)],
);
if (platformUser && (!appRole || appRole === 'platform_admin' || appRole === 'service_role')) {
return platformUser;
}
if (!requestedTenantId) return null;
return queryOne<SessionIdentity>(
`
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
u.primary_role as "primaryRole", u.created_at as "createdAt",
tm.tenant_id as "tenantId",
$1::text as "sessionId",
$3::timestamptz as "sessionExpiresAt",
'supabase_jwt'::text as "authSource",
u.auth_user_id as "authUserId"
from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id
where u.auth_user_id = $1::uuid
and tm.status = 'active'
and tm.tenant_id = $2::uuid
order by case
when u.primary_role = 'platform_admin' then 0
when tm.role = 'tenant_owner' then 1
when tm.role = 'tenant_admin' then 2
else 9
end, tm.created_at asc
limit 1
`,
[authUserId, requestedTenantId, sessionExpiryFrom(payload)],
);
}
export async function hydrateRequestAuth(ctx: RequestContext) {
const cached = requestAuthState.get(ctx);
if (cached?.sessionResolved) return cached;
const bearerToken = bearerTokenFrom(ctx);
const session = bearerToken ? await findUserBySessionToken(bearerToken) : null;
let session: SessionIdentity | null = null;
if (bearerToken) {
session = bearerToken.startsWith('tk_')
? await findUserBySessionToken(bearerToken)
: await findUserBySupabaseJwt(bearerToken, tenantContextFrom(ctx));
}
const state = { bearerToken, session, sessionResolved: true };
requestAuthState.set(ctx, state);
return state;

View File

@@ -11,6 +11,10 @@ export interface ApiConfig {
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
authJwtIssuer: string;
authJwtAudience: string;
authJwtSecret: string;
authJwtJwksUrl: string;
authCodeTtlSeconds: number;
authSmsCooldownSeconds: number;
authSessionTtlSeconds: number;
@@ -44,6 +48,7 @@ loadDotenv();
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
@@ -78,6 +83,9 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) {
failures.push('AUTH_SESSION_SECRET must be a strong production secret');
}
if (!nextConfig.authJwtJwksUrl && isUnsafeSecret(nextConfig.authJwtSecret, DEFAULT_AUTH_JWT_SECRET)) {
failures.push('AUTH_JWT_SECRET or AUTH_JWT_JWKS_URL must be configured for production JWT verification');
}
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
}
@@ -107,6 +115,10 @@ const loadedConfig: ApiConfig = {
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authJwtIssuer: envString('AUTH_JWT_ISSUER', ''),
authJwtAudience: envString('AUTH_JWT_AUDIENCE', 'authenticated'),
authJwtSecret: envString('AUTH_JWT_SECRET', DEFAULT_AUTH_JWT_SECRET),
authJwtJwksUrl: envString('AUTH_JWT_JWKS_URL', ''),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),

View File

@@ -23,6 +23,9 @@ export async function tenantIdFrom(ctx: RequestContext) {
if (legacyTenantId && legacyTenantId !== auth.session.tenantId) {
throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH');
}
if (!auth.session.tenantId) {
throw new HttpError(400, 'Tenant context is required for this authenticated request', 'TENANT_ID_REQUIRED');
}
return auth.session.tenantId;
}
@@ -148,7 +151,7 @@ export async function requirePlatformAdmin(ctx: RequestContext) {
const auth = await hydrateRequestAuth(ctx);
if (auth.session?.primaryRole === 'platform_admin') return;
if (auth.bearerToken) {
throw new HttpError(401, 'Invalid or expired platform admin session', 'AUTH_SESSION_INVALID');
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
}
const provided = getHeader(ctx.req, 'x-platform-admin-key');

View File

@@ -1,6 +1,6 @@
import crypto from 'node:crypto';
import { config } from '../../core/config.js';
import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js';
import { currentSessionFromContext, hydrateRequestAuth } from '../../core/auth-context.js';
import { query, transaction } from '../../core/db.js';
import { HttpError, type RequestContext } from '../../core/http.js';
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
@@ -352,12 +352,9 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
export async function meRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) {
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
}
await hydrateRequestAuth(ctx);
const session = await findUserBySessionToken(token);
const session = currentSessionFromContext(ctx);
if (!session) {
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
}
@@ -378,17 +375,16 @@ export async function meRoute(ctx: RequestContext) {
session: {
id: session.sessionId,
expiresAt: session.sessionExpiresAt,
source: session.authSource,
},
};
}
export async function logoutRoute(ctx: RequestContext) {
const tenantId = await tenantIdFrom(ctx);
const token = bearerTokenFrom(ctx);
if (!token) return { ok: true };
const session = await findUserBySessionToken(token);
if (session && session.tenantId === tenantId) {
await hydrateRequestAuth(ctx);
const session = currentSessionFromContext(ctx);
if (session?.authSource === 'app_session' && session.tenantId === tenantId) {
await query(
`
update app_private.auth_sessions