File size: 12,145 Bytes
20f83d9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | /**
* Server-side session validation for the Vercel edge gateway.
*
* Validates Clerk-issued bearer tokens using local JWT verification
* with jose + cached JWKS. No Convex round-trip needed.
* Requires CLERK_PUBLISHABLE_KEY (server-side) and CLERK_JWT_ISSUER_DOMAIN.
*
* This module must NOT import anything from `src/` -- it runs in the
* Vercel edge runtime, not the browser.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
// Clerk Backend API secret -- used to look up user metadata when the JWT
// does not include a `plan` claim (i.e. standard session token, no template).
const CLERK_SECRET_KEY = process.env.CLERK_SECRET_KEY ?? '';
// Absorb minor issuer/edge clock skew without turning expiration into a broad
// grace period. jose's operators are asymmetric at the bound: `exp` is accepted
// strictly less than five seconds late (rejected at exactly five β jose tests
// `exp <= now - tolerance`), while `nbf` is accepted up to and including five
// seconds early. Either way the replay window widens by at most this bound.
const CLERK_JWT_CLOCK_TOLERANCE_SECONDS = 5;
// Exported so tests can assert the fallback (no-audience) path's options
// directly, mirroring the existing assertion on getClerkJwtVerifyOptions().
export function getClerkJwtVerifyBaseOptions() {
return {
// Read lazily (not from the module-scope const) for the same reason as
// getJWKS(): both halves of issuer handling must read the env at the same
// time. A module evaluated before CLERK_JWT_ISSUER_DOMAIN is set would
// otherwise pin issuer '' here β and jose skips the issuer VALUE check
// entirely on a falsy issuer β while the lazily-built JWKS still resolves.
issuer: process.env.CLERK_JWT_ISSUER_DOMAIN ?? '',
algorithms: ['RS256'],
clockTolerance: CLERK_JWT_CLOCK_TOLERANCE_SECONDS,
// The bounded tolerance above is only a bound if expiry is evaluated at
// all: jose skips the whole `exp` check (tolerance included) when the
// claim is absent. Clerk always mints `exp`, so requiring it rejects
// nothing real β it makes the stated bound enforced rather than assumed.
requiredClaims: ['exp'],
};
}
// Module-scope JWKS resolver -- cached across warm invocations.
// jose handles key rotation and caching internally.
// Exported so server/_shared/auth-session.ts can reuse the same singleton
// (avoids duplicate JWKS HTTP fetches on cold start).
// Reads CLERK_JWT_ISSUER_DOMAIN lazily (not from module-scope const) so that
// tests that set the env var after import still get a valid JWKS.
let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
export function getJWKS() {
if (!_jwks) {
const issuerDomain = process.env.CLERK_JWT_ISSUER_DOMAIN;
if (issuerDomain) {
const jwksUrl = new URL('/.well-known/jwks.json', issuerDomain);
_jwks = createRemoteJWKSet(jwksUrl);
}
}
return _jwks;
}
/**
* Drop the memoized resolver so a test can change CLERK_JWT_ISSUER_DOMAIN and
* have the next call rebuild against it. Without this the first test to touch a
* bearer pins the resolver for the whole module lifetime, and a later test that
* unsets the env still gets the old one β silently asserting the wrong branch.
*/
export function __resetJwksForTests(): void {
_jwks = null;
}
export interface SessionResult {
valid: boolean;
userId?: string;
orgId?: string | null;
role?: 'free' | 'pro';
email?: string;
name?: string;
/**
* Why a `valid: false` result is invalid β present only on the deny arm.
*
* `invalid` is a confirmed answer ABOUT THE TOKEN: bad signature, expired,
* wrong issuer, no subject. Re-authenticating is the fix.
*
* `unverifiable` means verification never happened β the issuer domain is
* unset, or the JWKS fetch failed. That says nothing about the token, so a
* caller must not render it as "your credential is bad, signing in again is
* the fix" (#5619 follow-up: the same "our defect is not a verdict" rule the
* entitlement path already follows).
*
* Optional and additive: `valid` keeps its exact meaning, so every existing
* consumer that only reads `valid` is unaffected. A caller opts in by
* branching on this to answer the retryable contract instead.
*/
reason?: 'invalid' | 'unverifiable';
/**
* Present only when verification succeeded BECAUSE of the bounded
* `clockTolerance` β the token's `exp` was already in the past on this
* machine's clock. Optional and additive, like `reason`: `valid` keeps its
* exact meaning for every existing consumer. A caller that re-presents the
* same bearer to a second verifier with its own clock (Convex via
* `client.setAuth`) opts in by branching on this to classify that verifier's
* rejection as expected near-expiry traffic rather than auth-config drift.
*/
acceptedWithinClockTolerance?: true;
}
/**
* True when a jwtVerify rejection means we could not REACH the JWKS, rather
* than that the token failed verification against it.
*
* Deliberately narrow. `JWKSNoMatchingKey` is excluded: it fires both for a
* forged token and for a mid-rotation key, and misclassifying a forged token as
* "retry later" is the worse error. Only unambiguous transport failures β jose's
* own JWKS timeout, and the bare `TypeError` a failed `fetch` surfaces β count.
*/
function isJwksFetchFailure(err: unknown): boolean {
if (err instanceof TypeError) return true;
const code = (err as { code?: unknown } | null)?.code;
return code === 'ERR_JWKS_TIMEOUT';
}
function getAllowedAudiences(): string[] {
const configured = [
process.env.CLERK_JWT_AUDIENCE,
process.env.CLERK_PUBLISHABLE_KEY,
]
.flatMap((value) => (value ?? '').split(','))
.map((value) => value.trim())
.filter(Boolean);
return Array.from(new Set(['convex', ...configured]));
}
export function getClerkJwtVerifyOptions() {
return {
...getClerkJwtVerifyBaseOptions(),
audience: getAllowedAudiences(),
};
}
function extractOrgId(payload: Record<string, unknown>): string | null {
const orgClaim = payload.org as Record<string, unknown> | undefined;
return (
(typeof orgClaim?.id === 'string' ? orgClaim.id : null) ??
(typeof payload.org_id === 'string' ? payload.org_id : null)
);
}
// Short-lived in-memory cache for plan lookups (userId β { role, expiresAt }).
// Avoids hammering the Clerk API on every premium request. TTL = 5 min.
const _planCache = new Map<string, { role: 'free' | 'pro'; expiresAt: number }>();
const PLAN_CACHE_TTL_MS = 5 * 60 * 1_000;
// Matches the 3s budget used for the other external auth lookup
// β an inline AbortSignal.timeout(3_000) in server/_shared/user-api-key.ts, and
// the VALIDATION_TIMEOUT_MS constant in api/_user-api-key.js.
const DEFAULT_PLAN_LOOKUP_TIMEOUT_MS = 3_000;
const MAX_ABORT_SIGNAL_TIMEOUT_MS = 2_147_483_647;
export function parsePlanLookupTimeoutMs(value: string | undefined): number {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_ABORT_SIGNAL_TIMEOUT_MS
? parsed
: DEFAULT_PLAN_LOOKUP_TIMEOUT_MS;
}
const PLAN_LOOKUP_TIMEOUT_MS = parsePlanLookupTimeoutMs(process.env.CLERK_PLAN_LOOKUP_TIMEOUT_MS);
async function lookupPlanFromClerk(userId: string): Promise<'free' | 'pro'> {
const cached = _planCache.get(userId);
if (cached && Date.now() < cached.expiresAt) return cached.role;
if (!CLERK_SECRET_KEY) return 'free';
try {
// Adversarial DoS guard: validateBearerToken awaits this on every standard
// (non-template) session token, so a Clerk API stall would otherwise let an
// authenticated caller pin gateway invocations open indefinitely.
const resp = await fetch(`https://api.clerk.com/v1/users/${userId}`, {
headers: {
Authorization: `Bearer ${CLERK_SECRET_KEY}`,
// AGENTS.md: always set User-Agent on server-side fetches. Matches the
// sibling auth lookups (entitlement-check.ts, _shared/user-api-key.ts).
'User-Agent': 'worldmonitor-gateway/1.0',
},
signal: AbortSignal.timeout(PLAN_LOOKUP_TIMEOUT_MS),
});
if (!resp.ok) return 'free';
const user = (await resp.json()) as { public_metadata?: Record<string, unknown> };
const role: 'free' | 'pro' = user.public_metadata?.plan === 'pro' ? 'pro' : 'free';
_planCache.set(userId, { role, expiresAt: Date.now() + PLAN_CACHE_TTL_MS });
return role;
} catch (err) {
// Log, don't swallow. This path downgrades a PRO user to 'free' for the
// request, and the AbortSignal.timeout added above made it newly reachable
// from a plain Clerk stall rather than only from a hard network error. With
// no log, a sustained Clerk outage is indistinguishable from a fleet of
// genuinely free users β the failure mode is silent revenue-affecting
// degradation. Not cached (see above), so the next request retries.
console.warn(
'[auth-session] lookupPlanFromClerk failed, degrading to free:',
err instanceof Error ? err.message : String(err),
);
return 'free';
}
}
/**
* Validate a Clerk-issued bearer token using local JWKS verification.
* Accepts both custom-template tokens (with `plan` claim) and standard
* session tokens (plan looked up via Clerk Backend API).
* Fails closed: invalid/expired/unverifiable tokens return { valid: false }.
*/
export async function validateBearerToken(token: string): Promise<SessionResult> {
const jwks = getJWKS();
// No issuer domain configured: a deploy defect, not a bad token.
if (!jwks) return { valid: false, reason: 'unverifiable' };
try {
// Try with audience first (Clerk 'convex' template tokens include aud).
// Fall back without audience for standard Clerk session tokens (no aud claim).
let payload: Record<string, unknown>;
try {
({ payload } = await jwtVerify(token, jwks, getClerkJwtVerifyOptions()));
} catch (audErr) {
if ((audErr as Error).message?.includes('missing required "aud"')) {
({ payload } = await jwtVerify(token, jwks, getClerkJwtVerifyBaseOptions()));
} else {
throw audErr;
}
}
const userId = payload.sub as string | undefined;
// Verified, but carries no subject β a confirmed answer about the token.
if (!userId) return { valid: false, reason: 'invalid' };
// `plan` claim is present only in 'convex' template tokens. For standard
// session tokens we fall back to a cached Clerk API lookup.
const rawPlan = (payload as Record<string, unknown>).plan;
const role: 'free' | 'pro' =
rawPlan !== undefined
? rawPlan === 'pro'
? 'pro'
: 'free'
: await lookupPlanFromClerk(userId);
const email = typeof payload.email === 'string' ? payload.email : undefined;
const givenName = typeof payload.given_name === 'string' ? payload.given_name : undefined;
const familyName = typeof payload.family_name === 'string' ? payload.family_name : undefined;
const name = [givenName, familyName].filter(Boolean).join(' ') || undefined;
const orgId = extractOrgId(payload);
// `exp` in the past on our clock means only the clockTolerance admitted
// this token (requiredClaims guarantees the claim is present on success).
const expMs = typeof payload.exp === 'number' ? payload.exp * 1000 : null;
const withinTolerance = expMs !== null && expMs <= Date.now();
return {
valid: true,
userId,
orgId,
role,
email,
name,
...(withinTolerance ? { acceptedWithinClockTolerance: true as const } : {}),
};
} catch (err) {
// Usually signature verification failed / expired / wrong issuer β a
// confirmed answer about the token. But this same catch also covers a JWKS
// FETCH failure, since createRemoteJWKSet resolves lazily inside jwtVerify,
// and that says nothing about the token at all. Split them so a Clerk
// outage stops rendering as "sign in again" (#5619 follow-up).
return { valid: false, reason: isJwksFetchFailure(err) ? 'unverifiable' : 'invalid' };
}
}
|