forked from wangziqi/gongxue-base
feat: add supabase jwt auth context
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@supabase/storage-js": "^2.108.2",
|
||||
"ali-oss": "^6.23.0",
|
||||
"jose": "^6.2.3",
|
||||
"pg": "^8.16.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user