← vibesdk
/**
* Main Authentication Service
* Orchestrates all auth operations including login, registration, and OAuth
*/
import * as schema from '../schema';
import { eq, and, sql, or, lt, isNull } from 'drizzle-orm';
import { JWTUtils } from '../../utils/jwtUtils';
import { generateSecureToken } from '../../utils/cryptoUtils';
import { SessionService } from './SessionService';
import { ApiKeyService } from './ApiKeyService';
import { PasswordService } from '../../utils/passwordService';
import { GoogleOAuthProvider } from '../../services/oauth/google';
import { GitHubOAuthProvider } from '../../services/oauth/github';
import { CloudflareConnectOAuthProvider } from '../../services/oauth/cloudflare-connect';
import { BaseOAuthProvider } from '../../services/oauth/base';
import { readOAuthNonceCookie } from '../../utils/oauthCookie';
import {
SecurityError,
SecurityErrorType
} from 'shared/types/errors';
import { AuthResult, AuthUserSession, OAuthUserInfo } from '../../types/auth-types';
import { generateId } from '../../utils/idGenerator';
import {
AuthUser,
OAuthProvider
} from '../../types/auth-types';
import { mapUserResponse, validateRedirectUrl, enforceAllowedEmail } from '../../utils/authUtils';
import { createLogger } from '../../logger';
import { validateEmail, validatePassword } from '../../utils/validationUtils';
import { extractRequestMetadata } from '../../utils/authUtils';
import { BaseService } from './BaseService';
const logger = createLogger('AuthService');
/**
* Login credentials
*/
export interface LoginCredentials {
email: string;
password: string;
}
/**
* Registration data
*/
export interface RegistrationData {
email: string;
password: string;
name?: string;
}
/**
* Human-friendly label for a stored auth provider, used in user-facing messages.
*/
export function providerLabel(provider: string | null | undefined): string {
switch (provider) {
case 'google':
return 'Google';
case 'github':
return 'GitHub';
case 'cloudflare':
return 'Cloudflare';
default:
return 'a different sign-in method';
}
}
/**
* Main Authentication Service
*/
export class AuthService extends BaseService {
private readonly sessionService: SessionService;
private readonly apiKeyService: ApiKeyService;
private readonly passwordService: PasswordService;
constructor(
env: Env,
) {
super(env);
this.sessionService = new SessionService(env);
this.apiKeyService = new ApiKeyService(env);
this.passwordService = new PasswordService();
}
/**
* Register a new user
*/
async register(data: RegistrationData, request: Request): Promise<AuthResult> {
try {
// Deployment-level admission gate (ALLOWED_EMAIL)
enforceAllowedEmail(this.env, data.email, 'register');
// Validate email format using centralized utility
const emailValidation = validateEmail(data.email);
if (!emailValidation.valid) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
emailValidation.error || 'Invalid email format',
400
);
}
// Validate password using centralized utility
const passwordValidation = validatePassword(data.password, undefined, {
email: data.email,
name: data.name
});
if (!passwordValidation.valid) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
passwordValidation.errors!.join(', '),
400
);
}
// Check if user already exists
const existingUser = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.email, data.email.toLowerCase()))
.get();
if (existingUser) {
// OAuth-only account: guide the user to their provider instead of
// implying they can create a password-based account for this email.
if (!existingUser.passwordHash) {
const label = providerLabel(existingUser.provider);
throw new SecurityError(
SecurityErrorType.CONFLICT,
`This email is registered with ${label}. Please sign in with ${label}.`,
409
);
}
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
'An account with this email already exists. Please sign in.',
400
);
}
// Hash password
const passwordHash = await this.passwordService.hash(data.password);
// Create user
const userId = generateId();
const now = new Date();
// Store user as verified immediately (no OTP verification required)
await this.database.insert(schema.users).values({
id: userId,
email: data.email.toLowerCase(),
passwordHash,
displayName: data.name || data.email.split('@')[0],
emailVerified: true, // Set as verified immediately
provider: 'email',
providerId: userId,
createdAt: now,
updatedAt: now
});
// Get the created user
const newUser = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.get();
if (!newUser) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
'Failed to retrieve created user',
500
);
}
// Log successful registration
await this.logAuthAttempt(data.email, 'register', true, request);
logger.info('User registered and logged in directly', { userId, email: data.email });
// Create session and tokens immediately (log user in after registration)
const { accessToken, session } = await this.sessionService.createSession(
userId,
request
);
return {
user: mapUserResponse(newUser),
sessionId: session.sessionId,
expiresAt: session.expiresAt,
accessToken,
};
} catch (error) {
await this.logAuthAttempt(data.email, 'register', false, request);
if (error instanceof SecurityError) {
throw error;
}
logger.error('Registration error', error);
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
'Registration failed',
500
);
}
}
/**
* Login with email and password
*/
async login(credentials: LoginCredentials, request: Request): Promise<AuthResult> {
try {
// Deployment-level admission gate (ALLOWED_EMAIL)
enforceAllowedEmail(this.env, credentials.email, 'login');
// Find user
const user = await this.database
.select()
.from(schema.users)
.where(
and(
eq(schema.users.email, credentials.email.toLowerCase()),
sql`${schema.users.deletedAt} IS NULL`
)
)
.get();
if (!user) {
await this.logAuthAttempt(credentials.email, 'login', false, request);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Invalid email or password',
401
);
}
// The email belongs to an OAuth-only account (no password set). Tell the
// user which provider to use instead of a misleading credentials error.
if (!user.passwordHash) {
await this.logAuthAttempt(credentials.email, 'login', false, request);
const label = providerLabel(user.provider);
throw new SecurityError(
SecurityErrorType.CONFLICT,
`This account uses ${label} to sign in. Please continue with ${label}.`,
409
);
}
// Verify password
const passwordValid = await this.passwordService.verify(
credentials.password,
user.passwordHash
);
if (!passwordValid) {
await this.logAuthAttempt(credentials.email, 'login', false, request);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Invalid email or password',
401
);
}
// Create session
const { accessToken, session } = await this.sessionService.createSession(
user.id,
request
);
// Log successful attempt
await this.logAuthAttempt(credentials.email, 'login', true, request);
logger.info('User logged in', { userId: user.id, email: user.email });
return {
user: mapUserResponse(user),
accessToken,
sessionId: session.sessionId,
expiresAt: session.expiresAt,
};
} catch (error) {
if (error instanceof SecurityError) {
throw error;
}
logger.error('Login error', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Login failed',
500
);
}
}
/**
* Logout
*/
async logout(sessionId: string): Promise<void> {
try {
await this.sessionService.revokeSessionId(sessionId);
logger.info('User logged out', { sessionId });
} catch (error) {
logger.error('Logout error', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'Logout failed',
500
);
}
}
async getOauthProvider(provider: OAuthProvider, request: Request): Promise<BaseOAuthProvider> {
const url = new URL(request.url).origin;
switch (provider) {
case 'google':
return GoogleOAuthProvider.create(this.env, url);
case 'github':
return GitHubOAuthProvider.create(this.env, url);
case 'cloudflare':
return CloudflareConnectOAuthProvider.createForLogin(this.env, url);
default:
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
`OAuth provider ${provider} not configured`,
400
);
}
}
/**
* Get OAuth authorization URL
*/
async getOAuthAuthorizationUrl(
provider: OAuthProvider,
request: Request,
intendedRedirectUrl?: string,
linkUserId?: string
): Promise<{ authUrl: string; nonce: string }> {
const oauthProvider = await this.getOauthProvider(provider, request);
if (!oauthProvider) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
`OAuth provider ${provider} not configured`,
400
);
}
// Clean up expired OAuth states first
await this.cleanupExpiredOAuthStates();
// Validate and sanitize intended redirect URL
let validatedRedirectUrl: string | null = null;
if (intendedRedirectUrl) {
validatedRedirectUrl = validateRedirectUrl(intendedRedirectUrl, request);
}
// Generate state for CSRF protection
const state = generateSecureToken();
// Generate PKCE code verifier
const codeVerifier = BaseOAuthProvider.generateCodeVerifier();
// Generate a nonce that binds this state to the initiating browser. It is
// stored here and also set as an HttpOnly cookie by the controller; the
// callback rejects any request whose cookie nonce does not match. This is
// the core defense against login CSRF / session fixation.
const nonce = generateSecureToken();
// Store OAuth state with intended redirect URL
await this.database.insert(schema.oauthStates).values({
id: generateId(),
state,
provider,
codeVerifier,
redirectUri: validatedRedirectUrl || oauthProvider['redirectUri'],
createdAt: new Date(),
expiresAt: new Date(Date.now() + 600000), // 10 minutes
isUsed: false,
scopes: [],
userId: linkUserId ?? null,
nonce
});
// Get authorization URL
const authUrl = await oauthProvider.getAuthorizationUrl(state, codeVerifier);
logger.info('OAuth authorization initiated', { provider });
return { authUrl, nonce };
}
/**
* Clean up expired OAuth states
*/
private async cleanupExpiredOAuthStates(): Promise<void> {
try {
const now = new Date();
await this.database
.delete(schema.oauthStates)
.where(
or(
lt(schema.oauthStates.expiresAt, now),
eq(schema.oauthStates.isUsed, true)
)
);
logger.debug('Cleaned up expired OAuth states');
} catch (error) {
logger.error('Error cleaning up OAuth states', error);
}
}
/**
* Validate an OAuth state row (existence, expiry, browser nonce) and mark it as
* used. Shared by the login callback and the account-link callback.
*/
private async validateAndConsumeOAuthState(
provider: OAuthProvider,
state: string,
request: Request
): Promise<schema.OAuthState> {
const now = new Date();
const oauthState = await this.database
.select()
.from(schema.oauthStates)
.where(
and(
eq(schema.oauthStates.state, state),
eq(schema.oauthStates.provider, provider),
eq(schema.oauthStates.isUsed, false)
)
)
.get();
if (!oauthState || new Date(oauthState.expiresAt) < now) {
throw new SecurityError(
SecurityErrorType.CSRF_VIOLATION,
'Invalid or expired OAuth state',
400
);
}
// Bind the state to the initiating browser via the nonce cookie. A callback
// replayed in a different browser (login CSRF / session fixation) will not
// carry the matching nonce and is rejected here.
const cookieNonce = readOAuthNonceCookie(request, this.env);
if (!oauthState.nonce || !cookieNonce || cookieNonce !== oauthState.nonce) {
logger.warn('OAuth callback nonce mismatch - possible login CSRF', {
provider,
hasStoredNonce: !!oauthState.nonce,
hasCookieNonce: !!cookieNonce,
});
throw new SecurityError(
SecurityErrorType.CSRF_VIOLATION,
'Invalid or expired OAuth state',
400
);
}
// Mark state as used
await this.database
.update(schema.oauthStates)
.set({ isUsed: true })
.where(eq(schema.oauthStates.id, oauthState.id));
return oauthState;
}
/**
* Look up whether a still-valid, unused OAuth state is bound to a user (i.e. an
* account-link flow). Returns the bound userId or null. Does not consume state.
*/
async getPendingLinkUserId(
provider: OAuthProvider,
state: string
): Promise<string | null> {
const row = await this.database
.select({ userId: schema.oauthStates.userId })
.from(schema.oauthStates)
.where(
and(
eq(schema.oauthStates.state, state),
eq(schema.oauthStates.provider, provider),
eq(schema.oauthStates.isUsed, false)
)
)
.get();
return row?.userId ?? null;
}
/**
* Handle OAuth callback
*/
async handleOAuthCallback(
provider: OAuthProvider,
code: string,
state: string,
request: Request
): Promise<AuthResult> {
try {
const oauthProvider = await this.getOauthProvider(provider, request);
if (!oauthProvider) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
`OAuth provider ${provider} not configured`,
400
);
}
// Validate the state (existence, expiry, browser nonce) and mark it used.
const oauthState = await this.validateAndConsumeOAuthState(provider, state, request);
// A state bound to a user is an account-link flow and must go through
// completeOAuthLink (which re-checks the session), never the login path.
if (oauthState.userId) {
throw new SecurityError(
SecurityErrorType.CSRF_VIOLATION,
'Invalid OAuth state for login',
400
);
}
// Exchange code for tokens
const tokens = await oauthProvider.exchangeCodeForTokens(
code,
oauthState.codeVerifier || undefined
);
// Get user info
const oauthUserInfo = await oauthProvider.getUserInfo(tokens.accessToken);
// Deployment-level admission gate (ALLOWED_EMAIL). Enforced BEFORE any
// user row or session is created so a rejected email never gets admitted.
enforceAllowedEmail(this.env, oauthUserInfo.email, 'oauth');
// Find or create user
const user = await this.findOrCreateOAuthUser(provider, oauthUserInfo);
// Create session
const { accessToken: sessionAccessToken, session } = await this.sessionService.createSession(
user.id,
request
);
// Log auth attempt
await this.logAuthAttempt(user.email, `oauth_${provider}`, true, request);
logger.info('OAuth login successful', { userId: user.id, provider });
return {
user: mapUserResponse(user),
accessToken: sessionAccessToken,
sessionId: session.sessionId,
expiresAt: session.expiresAt,
redirectUrl: oauthState.redirectUri || undefined,
// Surface raw provider tokens only for Cloudflare so the controller can
// best-effort auto-connect the AI Gateway in the same round-trip.
oauthTokens: provider === 'cloudflare' ? tokens : undefined,
};
} catch (error) {
await this.logAuthAttempt('', `oauth_${provider}`, false, request);
if (error instanceof SecurityError) {
throw error;
}
logger.error('OAuth callback error', error);
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'OAuth authentication failed',
500
);
}
}
/**
* Resolve the user for an OAuth login.
*
* An OAuth login is a request to authenticate as a (provider, providerId)
* identity, NOT as an email address. Lookup therefore happens against the
* user_oauth_identities table. Binding an OAuth identity to an existing
* account only happens through the authenticated link flow (linkOAuthIdentity),
* never implicitly by email match — that implicit bind was the account-takeover
* vector this method previously had.
*/
private async findOrCreateOAuthUser(
provider: OAuthProvider,
oauthUserInfo: OAuthUserInfo
): Promise<schema.User> {
// Fail closed unless the provider asserts the email is verified.
if (oauthUserInfo.emailVerified !== true) {
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'OAuth provider did not verify the email address',
401
);
}
const email = oauthUserInfo.email.toLowerCase();
// 1. Identity-first lookup: match by (provider, providerId).
const identity = await this.database
.select()
.from(schema.userOauthIdentities)
.where(
and(
eq(schema.userOauthIdentities.provider, provider),
eq(schema.userOauthIdentities.providerId, oauthUserInfo.id)
)
)
.get();
if (identity) {
const now = new Date();
// Refresh the identity's cached email/verification.
await this.database
.update(schema.userOauthIdentities)
.set({ email, emailVerified: true, updatedAt: now })
.where(eq(schema.userOauthIdentities.id, identity.id));
// Refresh profile fields on the user, but never the primary
// provider/providerId binding.
await this.database
.update(schema.users)
.set({
displayName: oauthUserInfo.name || undefined,
avatarUrl: oauthUserInfo.picture || undefined,
updatedAt: now
})
.where(eq(schema.users.id, identity.userId));
const user = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.id, identity.userId))
.get();
if (!user) {
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'OAuth authentication failed',
500
);
}
return user;
}
// 2. No identity match, but a user already exists with this email (local
// signup or a different provider). The provider has asserted the email
// is verified (checked above), so attach this provider to the existing
// account and sign the user into it instead of failing.
const emailRow = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.email, email))
.get();
if (emailRow) {
await this.linkOAuthIdentity(emailRow.id, provider, oauthUserInfo);
logger.info('Auto-linked OAuth provider to existing account', {
userId: emailRow.id,
provider
});
return emailRow;
}
// 3. Brand-new identity: create the user and its first identity row.
return this.createOAuthUser(provider, oauthUserInfo);
}
/**
* Create a new user from a verified OAuth identity, writing both the users
* row (primary identity, for display/back-compat) and its user_oauth_identities row.
*/
private async createOAuthUser(
provider: OAuthProvider,
oauthUserInfo: OAuthUserInfo
): Promise<schema.User> {
const userId = generateId();
const now = new Date();
const email = oauthUserInfo.email.toLowerCase();
await this.database.insert(schema.users).values({
id: userId,
email,
displayName: oauthUserInfo.name || email.split('@')[0],
avatarUrl: oauthUserInfo.picture,
emailVerified: true,
provider,
providerId: oauthUserInfo.id,
createdAt: now,
updatedAt: now
});
await this.database.insert(schema.userOauthIdentities).values({
id: generateId(),
userId,
provider,
providerId: oauthUserInfo.id,
email,
emailVerified: true,
createdAt: now,
updatedAt: now
});
const user = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.get();
return user!;
}
/**
* Complete an authenticated account-link callback: validate the state, verify
* it is bound to the acting user, and attach the (provider, providerId) identity.
*/
async completeOAuthLink(
provider: OAuthProvider,
code: string,
state: string,
request: Request,
sessionUserId: string
): Promise<{ userId: string; provider: OAuthProvider; redirectUrl?: string }> {
const oauthProvider = await this.getOauthProvider(provider, request);
if (!oauthProvider) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
`OAuth provider ${provider} not configured`,
400
);
}
const oauthState = await this.validateAndConsumeOAuthState(provider, state, request);
// The state must have been created by (and for) the acting session's user.
if (!oauthState.userId || oauthState.userId !== sessionUserId) {
throw new SecurityError(
SecurityErrorType.CSRF_VIOLATION,
'Invalid account-link state',
403
);
}
const tokens = await oauthProvider.exchangeCodeForTokens(
code,
oauthState.codeVerifier || undefined
);
const oauthUserInfo = await oauthProvider.getUserInfo(tokens.accessToken);
await this.linkOAuthIdentity(oauthState.userId, provider, oauthUserInfo);
return {
userId: oauthState.userId,
provider,
redirectUrl: oauthState.redirectUri || undefined
};
}
/**
* Attach a verified OAuth identity to an existing user. Rejects if the identity
* is already bound to a different user.
*/
async linkOAuthIdentity(
userId: string,
provider: OAuthProvider,
oauthUserInfo: OAuthUserInfo
): Promise<void> {
if (oauthUserInfo.emailVerified !== true) {
throw new SecurityError(
SecurityErrorType.UNAUTHORIZED,
'OAuth provider did not verify the email address',
401
);
}
const email = oauthUserInfo.email.toLowerCase();
const now = new Date();
const existing = await this.database
.select()
.from(schema.userOauthIdentities)
.where(
and(
eq(schema.userOauthIdentities.provider, provider),
eq(schema.userOauthIdentities.providerId, oauthUserInfo.id)
)
)
.get();
if (existing) {
if (existing.userId !== userId) {
throw new SecurityError(
SecurityErrorType.CONFLICT,
'This provider account is already linked to another user.',
409
);
}
// Already linked to this user; just refresh the cached email.
await this.database
.update(schema.userOauthIdentities)
.set({ email, emailVerified: true, updatedAt: now })
.where(eq(schema.userOauthIdentities.id, existing.id));
return;
}
await this.database.insert(schema.userOauthIdentities).values({
id: generateId(),
userId,
provider,
providerId: oauthUserInfo.id,
email,
emailVerified: true,
createdAt: now,
updatedAt: now
});
logger.info('OAuth identity linked', { userId, provider });
}
/**
* List the OAuth identities linked to a user.
*/
async getUserIdentities(userId: string): Promise<schema.UserOauthIdentity[]> {
return this.database
.select()
.from(schema.userOauthIdentities)
.where(eq(schema.userOauthIdentities.userId, userId))
.all();
}
/**
* Remove a linked OAuth identity. Refuses to remove the user's only remaining
* login method (must keep at least one identity or a password).
*/
async unlinkOAuthIdentity(userId: string, provider: OAuthProvider): Promise<void> {
const identities = await this.getUserIdentities(userId);
const target = identities.find((i) => i.provider === provider);
if (!target) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
'No linked identity found for this provider.',
404
);
}
const user = await this.database
.select()
.from(schema.users)
.where(eq(schema.users.id, userId))
.get();
const hasPassword = !!user?.passwordHash;
const remainingIdentities = identities.length - 1;
if (remainingIdentities < 1 && !hasPassword) {
throw new SecurityError(
SecurityErrorType.INVALID_INPUT,
'Cannot remove your only login method.',
400
);
}
await this.database
.delete(schema.userOauthIdentities)
.where(eq(schema.userOauthIdentities.id, target.id));
// If the removed identity was the primary one on the users row, repoint the
// primary to another remaining identity so display/back-compat stays coherent.
if (user && user.provider === provider) {
const next = identities.find((i) => i.provider !== provider);
if (next) {
await this.database
.update(schema.users)
.set({ provider: next.provider, providerId: next.providerId, updatedAt: new Date() })
.where(eq(schema.users.id, userId));
}
}
logger.info('OAuth identity unlinked', { userId, provider });
}
/**
* Log authentication attempt
*/
private async logAuthAttempt(
identifier: string,
attemptType: string,
success: boolean,
request: Request
): Promise<void> {
try {
const requestMetadata = extractRequestMetadata(request);
await this.database.insert(schema.authAttempts).values({
identifier: identifier.toLowerCase(),
attemptType: attemptType as 'login' | 'register' | 'oauth_google' | 'oauth_github' | 'oauth_cloudflare' | 'refresh' | 'reset_password',
success: success,
ipAddress: requestMetadata.ipAddress
});
} catch (error) {
logger.error('Failed to log auth attempt', error);
}
}
/**
* Get user for authentication (for middleware)
*/
async getUserForAuth(userId: string): Promise<AuthUser | null> {
try {
const user = await this.database
.select({
id: schema.users.id,
email: schema.users.email,
displayName: schema.users.displayName,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
bio: schema.users.bio,
timezone: schema.users.timezone,
provider: schema.users.provider,
emailVerified: schema.users.emailVerified,
createdAt: schema.users.createdAt,
})
.from(schema.users)
.where(
and(
eq(schema.users.id, userId),
isNull(schema.users.deletedAt),
eq(schema.users.isActive, true),
eq(schema.users.isSuspended, false),
or(
isNull(schema.users.lockedUntil),
lt(schema.users.lockedUntil, new Date())
)
)
)
.get()
.catch((error: unknown) => {
logger.error('getUserForAuth query failed', {
errorMessage: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : 'UnknownError',
errorCause: (error as any)?.cause,
errorStack: error instanceof Error ? error.stack?.split('\n').slice(0, 5).join('\n') : undefined,
userId
});
throw error;
});
if (!user) {
logger.debug('User not found or not eligible for auth', { userId });
return null;
}
return mapUserResponse(user);
} catch (error: unknown) {
logger.error('Error getting user for auth', {
errorMessage: error instanceof Error ? error.message : String(error),
errorName: error instanceof Error ? error.name : 'UnknownError',
errorCause: (error as any)?.cause,
userId
});
return null;
}
}
/**
* Whether an API key id still corresponds to an active, unexpired key.
*/
private async isApiKeyActive(apiKeyId: string): Promise<boolean> {
const apiKey = await this.apiKeyService.getApiKeyById(apiKeyId);
if (!apiKey || !apiKey.isActive) {
return false;
}
if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) {
return false;
}
return true;
}
/**
* Validate token and return user (for middleware).
*
* Treats the JWT as a pointer, not a self-contained credential: every token
* is cross-checked against the live session / API key behind it, so logout,
* session revoke, and API-key revoke all take effect immediately rather than
* at token exp.
*/
async validateTokenAndGetUser(token: string, env: Env): Promise<AuthUserSession | null> {
try {
const jwtUtils = JWTUtils.getInstance(env);
const payload = await jwtUtils.verifyToken(token);
if (!payload || payload.type !== 'access') {
return null;
}
// Check if token is expired
if (payload.exp * 1000 < Date.now()) {
logger.debug('Token expired', { exp: payload.exp });
return null;
}
if (!payload.sessionId) {
return null;
}
// Cross-check the session / API key behind this token is still live.
if (payload.sessionId.startsWith('api_key:')) {
// API-key-derived token: resolve the synthetic sessionId back to the
// source key and require it to still be active.
const apiKeyId = payload.sessionId.slice('api_key:'.length);
if (!(await this.isApiKeyActive(apiKeyId))) {
return null;
}
} else {
const session = await this.sessionService.getSessionById(payload.sessionId);
if (!session || session.isRevoked) {
return null;
}
if (session.expiresAt && session.expiresAt.getTime() < Date.now()) {
return null;
}
}
// Get user (also enforces account status)
const user = await this.getUserForAuth(payload.sub);
if (!user) {
return null;
}
return {
user,
sessionId: payload.sessionId,
};
} catch (error) {
logger.error('Token validation error', error);
return null;
}
}
}