You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

371 lines
11 KiB

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<string, OAuthProviderConfig> = {
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<OAuthCallbackResult> {
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<void> {
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<OAuthBinding[]> {
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<string, unknown>, mapping: FieldMapping) {
const getValue = (path: string) => {
const value = (raw as Record<string, unknown>)[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<string, unknown>;
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<boolean> {
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();