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
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();
|