275 lines
7.8 KiB
TypeScript
275 lines
7.8 KiB
TypeScript
import { VkAuthError, VkValidationError, VkNetworkError } from './vk-errors';
|
|
|
|
export interface VkOAuthTokenResponse {
|
|
access_token: string;
|
|
token_type?: string;
|
|
expires_in: number;
|
|
user_id: number;
|
|
state?: string;
|
|
scope?: string;
|
|
refresh_token?: string;
|
|
id_token?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
}
|
|
|
|
export interface IVkOAuthClient {
|
|
buildAuthorizationUrl(params: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state: string;
|
|
codeChallenge: string;
|
|
scope?: string;
|
|
}): string;
|
|
|
|
exchangeCode(params: {
|
|
code: string;
|
|
codeVerifier: string;
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUri: string;
|
|
state?: string;
|
|
}): Promise<VkOAuthTokenResponse>;
|
|
|
|
refreshToken(params: {
|
|
refreshToken: string;
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
}): Promise<VkOAuthTokenResponse>;
|
|
|
|
getUserProfile(
|
|
accessToken: string,
|
|
userId: number | string
|
|
): Promise<{
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
username?: string;
|
|
avatarUrl?: string;
|
|
}>;
|
|
}
|
|
|
|
export class VkOAuthClient implements IVkOAuthClient {
|
|
public static readonly DEFAULT_AUTH_URL = 'https://id.vk.com/auth';
|
|
public static readonly DEFAULT_TOKEN_URL = 'https://id.vk.com/oauth2/auth';
|
|
public static readonly DEFAULT_API_BASE = 'https://api.vk.com/method/';
|
|
|
|
private readonly authUrl: string;
|
|
private readonly tokenUrl: string;
|
|
private readonly apiBase: string;
|
|
|
|
constructor(options?: { authUrl?: string; tokenUrl?: string; apiBase?: string }) {
|
|
this.authUrl = options?.authUrl || process.env.VK_ID_AUTH_URL || VkOAuthClient.DEFAULT_AUTH_URL;
|
|
this.tokenUrl = options?.tokenUrl || process.env.VK_ID_TOKEN_URL || VkOAuthClient.DEFAULT_TOKEN_URL;
|
|
this.apiBase = options?.apiBase || process.env.VK_API_BASE_URL || VkOAuthClient.DEFAULT_API_BASE;
|
|
}
|
|
|
|
public buildAuthorizationUrl(params: {
|
|
clientId: string;
|
|
redirectUri: string;
|
|
state: string;
|
|
codeChallenge: string;
|
|
scope?: string;
|
|
}): string {
|
|
const query = new URLSearchParams({
|
|
response_type: 'code',
|
|
client_id: params.clientId,
|
|
redirect_uri: params.redirectUri,
|
|
state: params.state,
|
|
code_challenge: params.codeChallenge,
|
|
code_challenge_method: 'S256',
|
|
});
|
|
|
|
if (params.scope) {
|
|
query.append('scope', params.scope);
|
|
}
|
|
|
|
return `${this.authUrl}?${query.toString()}`;
|
|
}
|
|
|
|
public async exchangeCode(params: {
|
|
code: string;
|
|
codeVerifier: string;
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
redirectUri: string;
|
|
state?: string;
|
|
}): Promise<VkOAuthTokenResponse> {
|
|
if (!params.code) {
|
|
throw new VkValidationError('Authorization code is missing');
|
|
}
|
|
if (!params.codeVerifier) {
|
|
throw new VkValidationError('PKCE code_verifier is missing');
|
|
}
|
|
|
|
const formBody = new URLSearchParams({
|
|
grant_type: 'authorization_code',
|
|
code: params.code,
|
|
code_verifier: params.codeVerifier,
|
|
client_id: params.clientId,
|
|
redirect_uri: params.redirectUri,
|
|
});
|
|
|
|
if (params.clientSecret) {
|
|
formBody.append('client_secret', params.clientSecret);
|
|
}
|
|
if (params.state) {
|
|
formBody.append('state', params.state);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.tokenUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Randomayzer-OAuth/1.0 (+https://github.com/ochenstarik-ui/randomayzer)',
|
|
},
|
|
body: formBody.toString(),
|
|
});
|
|
|
|
const data = await response.json().catch(() => null);
|
|
|
|
if (!response.ok || !data || data.error) {
|
|
const errorDesc = data?.error_description || data?.error_msg || data?.error || 'Token exchange failed';
|
|
throw new VkAuthError(`VK OAuth token exchange error (${response.status}): ${errorDesc}`, {
|
|
errorCode: response.status,
|
|
});
|
|
}
|
|
|
|
if (!data.access_token) {
|
|
throw new VkAuthError('VK OAuth response missing access_token');
|
|
}
|
|
|
|
return {
|
|
access_token: data.access_token,
|
|
token_type: data.token_type || 'Bearer',
|
|
expires_in: Number(data.expires_in || 0),
|
|
user_id: Number(data.user_id || 0),
|
|
refresh_token: data.refresh_token,
|
|
id_token: data.id_token,
|
|
email: data.email,
|
|
phone: data.phone,
|
|
scope: data.scope,
|
|
};
|
|
} catch (err: unknown) {
|
|
if (err instanceof VkAuthError || err instanceof VkValidationError) {
|
|
throw err;
|
|
}
|
|
const message = err instanceof Error ? err.message : 'Network error';
|
|
throw new VkNetworkError(`Failed to connect to VK OAuth token endpoint: ${message}`);
|
|
}
|
|
}
|
|
|
|
public async refreshToken(params: {
|
|
refreshToken: string;
|
|
clientId: string;
|
|
clientSecret?: string;
|
|
}): Promise<VkOAuthTokenResponse> {
|
|
if (!params.refreshToken) {
|
|
throw new VkValidationError('Refresh token is required');
|
|
}
|
|
|
|
const formBody = new URLSearchParams({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: params.refreshToken,
|
|
client_id: params.clientId,
|
|
});
|
|
|
|
if (params.clientSecret) {
|
|
formBody.append('client_secret', params.clientSecret);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(this.tokenUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
'User-Agent': 'Randomayzer-OAuth/1.0',
|
|
},
|
|
body: formBody.toString(),
|
|
});
|
|
|
|
const data = await response.json().catch(() => null);
|
|
|
|
if (!response.ok || !data || data.error) {
|
|
const errorDesc = data?.error_description || data?.error_msg || data?.error || 'Token refresh failed';
|
|
throw new VkAuthError(`VK OAuth token refresh error (${response.status}): ${errorDesc}`);
|
|
}
|
|
|
|
return {
|
|
access_token: data.access_token,
|
|
token_type: data.token_type || 'Bearer',
|
|
expires_in: Number(data.expires_in || 0),
|
|
user_id: Number(data.user_id || 0),
|
|
refresh_token: data.refresh_token || params.refreshToken,
|
|
id_token: data.id_token,
|
|
email: data.email,
|
|
phone: data.phone,
|
|
scope: data.scope,
|
|
};
|
|
} catch (err: unknown) {
|
|
if (err instanceof VkAuthError) throw err;
|
|
const message = err instanceof Error ? err.message : 'Network error';
|
|
throw new VkNetworkError(`Failed to refresh VK token: ${message}`);
|
|
}
|
|
}
|
|
|
|
public async getUserProfile(
|
|
accessToken: string,
|
|
userId: number | string
|
|
): Promise<{
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
username?: string;
|
|
avatarUrl?: string;
|
|
}> {
|
|
const url = `${this.apiBase}users.get`;
|
|
const formBody = new URLSearchParams({
|
|
user_ids: String(userId),
|
|
fields: 'photo_100,photo_200,screen_name',
|
|
access_token: accessToken,
|
|
v: '5.199',
|
|
});
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: formBody.toString(),
|
|
});
|
|
|
|
const data = await response.json().catch(() => null);
|
|
const user = data?.response?.[0];
|
|
|
|
if (!user) {
|
|
return {
|
|
id: String(userId),
|
|
firstName: 'VK Organizer',
|
|
lastName: String(userId),
|
|
};
|
|
}
|
|
|
|
return {
|
|
id: String(user.id),
|
|
firstName: user.first_name || 'Organizer',
|
|
lastName: user.last_name || '',
|
|
username: user.screen_name || undefined,
|
|
avatarUrl: user.photo_100 || user.photo_200 || undefined,
|
|
};
|
|
}
|
|
}
|
|
|
|
export let defaultVkOAuthClient: IVkOAuthClient = new VkOAuthClient();
|
|
|
|
export function setOAuthClient(client: IVkOAuthClient): void {
|
|
defaultVkOAuthClient = client;
|
|
}
|
|
|
|
export function getOAuthClient(): IVkOAuthClient {
|
|
return defaultVkOAuthClient;
|
|
}
|