import log4js from "logger"; import { dbGlobal } from 'drizzle-pkg/lib/db'; import { oauthAccounts } from 'drizzle-pkg/lib/schema/auth'; import { eq, and } from 'drizzle-orm'; import { OAuthStateStore, oauthStateStore } from './oauth-state-store'; import { OAuthError, OAuthErrorCodes } from './oauth-error'; import { registerUser, createSession } from '#server/service/auth'; import type { MinimalUser } from '#server/service/auth'; import { FRONTEND_LOGIN_PATH } from "common/config" const logger = log4js.getLogger("OAUTH"); export type OAuthBinding = { id: number; provider: string; username: string | null; email: string | null; avatar: string | null; boundAt: Date; }; export type OAuthCallbackResult = { success: boolean; isNewUser: boolean; user?: MinimalUser; requiresBinding: boolean; bindingToken?: string; sessionId?: string; expiresAt?: Date; }; export type FieldMapping = { provider?: string; providerUserId: string; username?: string; email?: string; avatar?: string; }; export type OAuthProviderConfig = { name: string; icon: string; clientId: string; clientSecret: string; authorizeUrl: string; tokenUrl: string; userInfoUrl: string; scopes: string[]; redirectUri: string; userInfoMapping: FieldMapping; }; const providers: Record = { github: { name: 'github', icon: 'github', clientId: process.env.GITHUB_CLIENT_ID || '', clientSecret: process.env.GITHUB_CLIENT_SECRET || '', authorizeUrl: 'https://github.com/login/oauth/authorize', tokenUrl: 'https://github.com/login/oauth/access_token', userInfoUrl: 'https://api.github.com/user', scopes: ['read:user', 'user:email'], redirectUri: `${process.env.APP_URL}/api/auth/oauth/github/callback`, userInfoMapping: { providerUserId: 'id', username: 'login', email: 'email', avatar: 'avatar_url', }, }, }; export class OAuthManager { private stateStore: OAuthStateStore; constructor() { this.stateStore = oauthStateStore; } getAuthorizationUrl(providerName: string, userId?: number): string { const provider = providers[providerName]; if (!provider) { logger.warn(`[getAuthorizationUrl] Provider not found: ${providerName}`); throw new OAuthError( OAuthErrorCodes.PROVIDER_NOT_FOUND, `Provider ${providerName} not found` ); } const redirectUri = userId ? `/profile?bind_success=1` : `${FRONTEND_LOGIN_PATH}?oauth_success=1`; const { state } = this.stateStore.generate(providerName, redirectUri, userId); logger.info(`[getAuthorizationUrl] Generating auth URL for provider: ${providerName}, userId: ${userId ?? 'anonymous'}, redirectUri: ${redirectUri}`); const params = new URLSearchParams({ client_id: provider.clientId, redirect_uri: provider.redirectUri, scope: provider.scopes.join(' '), state, }); return `${provider.authorizeUrl}?${params.toString()}`; } async handleCallback( providerName: string, code: string, state: string ): Promise { logger.info(`[handleCallback] Provider: ${providerName}`); const provider = providers[providerName]; if (!provider) { logger.warn(`[handleCallback] Provider not found: ${providerName}`); throw new OAuthError( OAuthErrorCodes.PROVIDER_NOT_FOUND, `Provider ${providerName} not found` ); } const oauthState = this.stateStore.consume(state); if (!oauthState) { logger.warn(`[handleCallback] Invalid or expired state`); throw new OAuthError( OAuthErrorCodes.STATE_INVALID, 'Invalid or expired state' ); } if (oauthState.providerName !== providerName) { logger.warn(`[handleCallback] State provider mismatch: expected ${oauthState.providerName}, got ${providerName}`); throw new OAuthError( OAuthErrorCodes.STATE_INVALID, 'State provider mismatch' ); } const tokenResponse = await this.exchangeToken(provider, code); if (!tokenResponse.access_token) { logger.error(`[handleCallback] Token exchange failed for provider: ${providerName}`); throw new OAuthError( OAuthErrorCodes.TOKEN_EXCHANGE_FAILED, 'Failed to exchange token' ); } const userInfo = await this.getUserInfo(provider, tokenResponse.access_token); if (!userInfo) { logger.error(`[handleCallback] Failed to get user info from provider: ${providerName}`); throw new OAuthError( OAuthErrorCodes.USER_INFO_FAILED, 'Failed to get user info' ); } logger.info(`[handleCallback] User info retrieved: provider=${providerName}, userId=${userInfo.providerUserId}, username=${userInfo.username}`); const existingAccount = await this.findOAuthAccount(providerName, userInfo.providerUserId); if (oauthState.isBinding) { if (existingAccount && existingAccount.userId !== oauthState.userId) { logger.warn(`[handleCallback] OAuth account already bound to another user: provider=${providerName}, providerUserId=${userInfo.providerUserId}`); throw new OAuthError( OAuthErrorCodes.ALREADY_BIND, 'This OAuth account is already bound to another user' ); } await this.createOAuthAccount({ userId: oauthState.userId!, provider: providerName, providerUserId: userInfo.providerUserId, username: userInfo.username, email: userInfo.email, avatar: userInfo.avatar, }); logger.info(`[handleCallback] OAuth account bound successfully: userId=${oauthState.userId}, provider=${providerName}`); return { success: true, isNewUser: false, requiresBinding: false, }; } if (existingAccount) { const { sessionId, expiresAt } = await createSession(existingAccount.userId); logger.info(`[handleCallback] Existing user logged in: userId=${existingAccount.userId}, provider=${providerName}`); return { success: true, isNewUser: false, requiresBinding: false, sessionId, expiresAt, }; } const newUser = await registerUser({ username: userInfo.username || `oauth_${providerName}_${userInfo.providerUserId}`, password: crypto.randomUUID(), } as any); await this.createOAuthAccount({ userId: newUser.id, provider: providerName, providerUserId: userInfo.providerUserId, username: userInfo.username, email: userInfo.email, avatar: userInfo.avatar, }); const { sessionId, expiresAt } = await createSession(newUser.id); logger.info(`[handleCallback] New user registered and logged in: userId=${newUser.id}, provider=${providerName}`); return { success: true, isNewUser: true, user: newUser, requiresBinding: false, sessionId, expiresAt, }; } async unbindAccount(userId: number, providerName: string): Promise { const deleted = await this.deleteOAuthAccount(userId, providerName); if (!deleted) { logger.warn(`[unbindAccount] OAuth account not found: userId=${userId}, provider=${providerName}`); throw new OAuthError( OAuthErrorCodes.BINDING_USER_MISMATCH, 'OAuth account not found' ); } logger.info(`[unbindAccount] OAuth account unbound: userId=${userId}, provider=${providerName}`); } async getUserBindings(userId: number): Promise { const accounts = await this.findUserOAuthAccounts(userId); return accounts.map((account) => ({ id: account.id, provider: account.provider, username: account.username, email: account.email, avatar: account.avatar, boundAt: account.createdAt ?? new Date(), })); } private async exchangeToken( provider: OAuthProviderConfig, code: string ): Promise<{ access_token?: string; token_type?: string; scope?: string }> { const response = await fetch(provider.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ client_id: provider.clientId, client_secret: provider.clientSecret, code, redirect_uri: provider.redirectUri, }), }); if (!response.ok) { throw new OAuthError( OAuthErrorCodes.TOKEN_EXCHANGE_FAILED, 'Failed to exchange token' ); } return response.json() as Promise<{ access_token?: string; token_type?: string; scope?: string; }>; } private mapUserInfo(raw: Record, mapping: FieldMapping) { const getValue = (path: string) => { const value = (raw as Record)[path]; return value != null ? String(value) : undefined; }; return { provider: mapping.provider || '', providerUserId: getValue(mapping.providerUserId) || '', username: mapping.username ? getValue(mapping.username) : undefined, email: mapping.email ? getValue(mapping.email) : undefined, avatar: mapping.avatar ? getValue(mapping.avatar) : undefined, }; } private async getUserInfo( provider: OAuthProviderConfig, accessToken: string ): Promise<{ provider: string; providerUserId: string; username?: string; email?: string; avatar?: string } | null> { const response = await fetch(provider.userInfoUrl, { headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (!response.ok) { return null; } const raw = await response.json() as Record; return this.mapUserInfo(raw, provider.userInfoMapping); } private async createOAuthAccount(data: { userId: number; provider: string; providerUserId: string; username?: string; email?: string; avatar?: string; }) { const now = new Date(); await dbGlobal.insert(oauthAccounts).values({ userId: data.userId, provider: data.provider, providerUserId: data.providerUserId, username: data.username, email: data.email, avatar: data.avatar, createdAt: now, updatedAt: now, }); } private async findOAuthAccount(provider: string, providerUserId: string) { const [account] = await dbGlobal .select() .from(oauthAccounts) .where(and( eq(oauthAccounts.provider, provider), eq(oauthAccounts.providerUserId, providerUserId) )); return account ?? null; } private async findUserOAuthAccounts(userId: number) { return dbGlobal .select() .from(oauthAccounts) .where(eq(oauthAccounts.userId, userId)); } private async deleteOAuthAccount(userId: number, provider: string): Promise { const result = await dbGlobal .delete(oauthAccounts) .where(and( eq(oauthAccounts.userId, userId), eq(oauthAccounts.provider, provider) )); const info = result as unknown as { changes?: number; rowsAffected?: number }; return (info.changes ?? info.rowsAffected ?? 0) > 0; } } export const oauthManager = new OAuthManager();