refactor progress
This commit is contained in:
48
packages/oauth2/src/OAuth2.tsx
Normal file
48
packages/oauth2/src/OAuth2.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
|
||||
export interface OAuth2Config {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
authorizeEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export function generateState(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function authorizeUrl(config: OAuth2Config, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: config.clientId,
|
||||
redirect_uri: config.redirectUri,
|
||||
scope: config.scope,
|
||||
state,
|
||||
});
|
||||
return `${config.authorizeEndpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function base64EncodeString(str: string): string {
|
||||
return Buffer.from(str).toString('base64');
|
||||
}
|
||||
109
packages/oauth2/src/Token.tsx
Normal file
109
packages/oauth2/src/Token.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OAuth2Config} from '@fluxer/oauth2/src/OAuth2';
|
||||
import {base64EncodeString} from '@fluxer/oauth2/src/OAuth2';
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface LoggerInterface {
|
||||
debug(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
info(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
warn(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
error(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
}
|
||||
|
||||
interface ExchangeCodeOptions {
|
||||
logger?: LoggerInterface;
|
||||
}
|
||||
|
||||
export async function exchangeCode(
|
||||
config: OAuth2Config,
|
||||
code: string,
|
||||
options?: ExchangeCodeOptions,
|
||||
): Promise<TokenResponse | null> {
|
||||
try {
|
||||
const response = await fetch(config.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
options?.logger?.warn(
|
||||
{status: response.status, tokenEndpoint: config.tokenEndpoint},
|
||||
'OAuth2 code exchange failed',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as TokenResponse;
|
||||
} catch (err) {
|
||||
options?.logger?.error(
|
||||
{error: err instanceof Error ? err.message : String(err), tokenEndpoint: config.tokenEndpoint},
|
||||
'OAuth2 code exchange error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface RevokeTokenOptions {
|
||||
logger?: LoggerInterface;
|
||||
}
|
||||
|
||||
export async function revokeToken(
|
||||
config: OAuth2Config,
|
||||
token: string,
|
||||
revokeEndpoint: string,
|
||||
options?: RevokeTokenOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const basic = `Basic ${base64EncodeString(`${config.clientId}:${config.clientSecret}`)}`;
|
||||
await fetch(revokeEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: basic,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token,
|
||||
token_type_hint: 'access_token',
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
options?.logger?.warn(
|
||||
{error: err instanceof Error ? err.message : String(err), revokeEndpoint},
|
||||
'OAuth2 token revocation failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
65
packages/oauth2/src/User.tsx
Normal file
65
packages/oauth2/src/User.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: number;
|
||||
avatar: string | null;
|
||||
email?: string;
|
||||
acls?: Array<string>;
|
||||
}
|
||||
|
||||
export interface LoggerInterface {
|
||||
debug(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
info(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
warn(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
error(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
}
|
||||
|
||||
interface FetchUserOptions {
|
||||
logger?: LoggerInterface;
|
||||
}
|
||||
|
||||
export async function fetchUser(
|
||||
apiEndpoint: string,
|
||||
accessToken: string,
|
||||
options?: FetchUserOptions,
|
||||
): Promise<UserInfo | null> {
|
||||
try {
|
||||
const response = await fetch(`${apiEndpoint}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
options?.logger?.warn({status: response.status, apiEndpoint}, 'OAuth2 user fetch failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as UserInfo;
|
||||
} catch (err) {
|
||||
options?.logger?.error(
|
||||
{error: err instanceof Error ? err.message : String(err), apiEndpoint},
|
||||
'OAuth2 user fetch error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
163
packages/oauth2/src/__tests__/OAuth2.test.tsx
Normal file
163
packages/oauth2/src/__tests__/OAuth2.test.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {authorizeUrl, base64EncodeString, generateState, type OAuth2Config} from '@fluxer/oauth2/src/OAuth2';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function createTestConfig(overrides?: Partial<OAuth2Config>): OAuth2Config {
|
||||
return {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
authorizeEndpoint: 'https://auth.example.com/authorize',
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
scope: 'openid profile email',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('generateState', () => {
|
||||
it('should generate a base64url encoded string', () => {
|
||||
const state = generateState();
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
});
|
||||
|
||||
it('should generate a string of appropriate length for 32 bytes', () => {
|
||||
const state = generateState();
|
||||
expect(state.length).toBe(43);
|
||||
});
|
||||
|
||||
it('should generate unique states on each call', () => {
|
||||
const state1 = generateState();
|
||||
const state2 = generateState();
|
||||
const state3 = generateState();
|
||||
expect(state1).not.toBe(state2);
|
||||
expect(state2).not.toBe(state3);
|
||||
expect(state1).not.toBe(state3);
|
||||
});
|
||||
|
||||
it('should not contain characters that need URL encoding', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const state = generateState();
|
||||
expect(state).not.toContain('+');
|
||||
expect(state).not.toContain('/');
|
||||
expect(state).not.toContain('=');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorizeUrl', () => {
|
||||
it('should construct a valid authorization URL with all parameters', () => {
|
||||
const config = createTestConfig();
|
||||
const state = 'test-state-123';
|
||||
const url = authorizeUrl(config, state);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.origin).toBe('https://auth.example.com');
|
||||
expect(parsed.pathname).toBe('/authorize');
|
||||
expect(parsed.searchParams.get('response_type')).toBe('code');
|
||||
expect(parsed.searchParams.get('client_id')).toBe('test-client-id');
|
||||
expect(parsed.searchParams.get('redirect_uri')).toBe('https://example.com/callback');
|
||||
expect(parsed.searchParams.get('scope')).toBe('openid profile email');
|
||||
expect(parsed.searchParams.get('state')).toBe('test-state-123');
|
||||
});
|
||||
|
||||
it('should handle special characters in scope', () => {
|
||||
const config = createTestConfig({scope: 'read:user write:repo'});
|
||||
const state = 'test-state';
|
||||
const url = authorizeUrl(config, state);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get('scope')).toBe('read:user write:repo');
|
||||
});
|
||||
|
||||
it('should handle redirect URIs with query parameters', () => {
|
||||
const config = createTestConfig({
|
||||
redirectUri: 'https://example.com/callback?app=test',
|
||||
});
|
||||
const state = 'test-state';
|
||||
const url = authorizeUrl(config, state);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get('redirect_uri')).toBe('https://example.com/callback?app=test');
|
||||
});
|
||||
|
||||
it('should handle empty scope', () => {
|
||||
const config = createTestConfig({scope: ''});
|
||||
const state = 'test-state';
|
||||
const url = authorizeUrl(config, state);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get('scope')).toBe('');
|
||||
});
|
||||
|
||||
it('should handle authorize endpoint with existing query parameters', () => {
|
||||
const config = createTestConfig({
|
||||
authorizeEndpoint: 'https://auth.example.com/authorize?extra=param',
|
||||
});
|
||||
const state = 'test-state';
|
||||
const url = authorizeUrl(config, state);
|
||||
|
||||
expect(url).toContain('authorize?extra=param?');
|
||||
expect(url).toContain('response_type=code');
|
||||
});
|
||||
|
||||
it('should properly encode special characters in state', () => {
|
||||
const config = createTestConfig();
|
||||
const state = 'state+with/special=chars';
|
||||
const url = authorizeUrl(config, state);
|
||||
const parsed = new URL(url);
|
||||
|
||||
expect(parsed.searchParams.get('state')).toBe('state+with/special=chars');
|
||||
});
|
||||
});
|
||||
|
||||
describe('base64EncodeString', () => {
|
||||
it('should encode a simple string to base64', () => {
|
||||
const encoded = base64EncodeString('hello');
|
||||
expect(encoded).toBe('aGVsbG8=');
|
||||
});
|
||||
|
||||
it('should encode credentials for Basic auth', () => {
|
||||
const encoded = base64EncodeString('client_id:client_secret');
|
||||
expect(encoded).toBe('Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=');
|
||||
});
|
||||
|
||||
it('should encode empty string', () => {
|
||||
const encoded = base64EncodeString('');
|
||||
expect(encoded).toBe('');
|
||||
});
|
||||
|
||||
it('should encode unicode characters', () => {
|
||||
const encoded = base64EncodeString('hello world');
|
||||
expect(encoded).toBe('aGVsbG8gd29ybGQ=');
|
||||
});
|
||||
|
||||
it('should encode special characters', () => {
|
||||
const encoded = base64EncodeString('user:p@ss!word#123');
|
||||
expect(encoded).toBe('dXNlcjpwQHNzIXdvcmQjMTIz');
|
||||
});
|
||||
|
||||
it('should produce decodable output', () => {
|
||||
const original = 'test-client:test-secret';
|
||||
const encoded = base64EncodeString(original);
|
||||
const decoded = Buffer.from(encoded, 'base64').toString();
|
||||
expect(decoded).toBe(original);
|
||||
});
|
||||
});
|
||||
335
packages/oauth2/src/__tests__/Token.test.tsx
Normal file
335
packages/oauth2/src/__tests__/Token.test.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OAuth2Config} from '@fluxer/oauth2/src/OAuth2';
|
||||
import {exchangeCode, type LoggerInterface, revokeToken, type TokenResponse} from '@fluxer/oauth2/src/Token';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function createTestConfig(overrides?: Partial<OAuth2Config>): OAuth2Config {
|
||||
return {
|
||||
clientId: 'test-client-id',
|
||||
clientSecret: 'test-client-secret',
|
||||
redirectUri: 'https://example.com/callback',
|
||||
authorizeEndpoint: 'https://auth.example.com/authorize',
|
||||
tokenEndpoint: 'https://auth.example.com/token',
|
||||
scope: 'openid profile email',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestTokenResponse(overrides?: Partial<TokenResponse>): TokenResponse {
|
||||
return {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'test-refresh-token',
|
||||
scope: 'openid profile email',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestLogger(): LoggerInterface & {calls: Record<string, Array<unknown>>} {
|
||||
const calls: Record<string, Array<unknown>> = {
|
||||
debug: [],
|
||||
info: [],
|
||||
warn: [],
|
||||
error: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
debug: (...args: Array<unknown>) => calls.debug.push(args),
|
||||
info: (...args: Array<unknown>) => calls.info.push(args),
|
||||
warn: (...args: Array<unknown>) => calls.warn.push(args),
|
||||
error: (...args: Array<unknown>) => calls.error.push(args),
|
||||
};
|
||||
}
|
||||
|
||||
describe('exchangeCode', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should exchange code for tokens successfully', async () => {
|
||||
const config = createTestConfig();
|
||||
const tokenResponse = createTestTokenResponse();
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(tokenResponse),
|
||||
});
|
||||
|
||||
const result = await exchangeCode(config, 'authorization-code');
|
||||
|
||||
expect(result).toEqual(tokenResponse);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(config.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: expect.any(URLSearchParams),
|
||||
});
|
||||
});
|
||||
|
||||
it('should send correct form data in request body', async () => {
|
||||
const config = createTestConfig();
|
||||
let capturedBody: URLSearchParams | undefined;
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((_url, options) => {
|
||||
capturedBody = options?.body as URLSearchParams;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(createTestTokenResponse()),
|
||||
});
|
||||
});
|
||||
|
||||
await exchangeCode(config, 'test-auth-code');
|
||||
|
||||
expect(capturedBody).toBeDefined();
|
||||
expect(capturedBody?.get('grant_type')).toBe('authorization_code');
|
||||
expect(capturedBody?.get('code')).toBe('test-auth-code');
|
||||
expect(capturedBody?.get('redirect_uri')).toBe(config.redirectUri);
|
||||
expect(capturedBody?.get('client_id')).toBe(config.clientId);
|
||||
expect(capturedBody?.get('client_secret')).toBe(config.clientSecret);
|
||||
});
|
||||
|
||||
it('should return null on non-ok response', async () => {
|
||||
const config = createTestConfig();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
|
||||
const result = await exchangeCode(config, 'invalid-code');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should log warning on non-ok response when logger provided', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
|
||||
await exchangeCode(config, 'invalid-code', {logger});
|
||||
|
||||
expect(logger.calls.warn.length).toBe(1);
|
||||
expect(logger.calls.warn[0]).toEqual([
|
||||
{status: 401, tokenEndpoint: config.tokenEndpoint},
|
||||
'OAuth2 code exchange failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
const config = createTestConfig();
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const result = await exchangeCode(config, 'auth-code');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should log error on fetch exception when logger provided', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Connection timeout'));
|
||||
|
||||
await exchangeCode(config, 'auth-code', {logger});
|
||||
|
||||
expect(logger.calls.error.length).toBe(1);
|
||||
expect(logger.calls.error[0]).toEqual([
|
||||
{error: 'Connection timeout', tokenEndpoint: config.tokenEndpoint},
|
||||
'OAuth2 code exchange error',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue({code: 'ECONNREFUSED'});
|
||||
|
||||
await exchangeCode(config, 'auth-code', {logger});
|
||||
|
||||
expect(logger.calls.error.length).toBe(1);
|
||||
expect(logger.calls.error[0]).toEqual([
|
||||
{error: '[object Object]', tokenEndpoint: config.tokenEndpoint},
|
||||
'OAuth2 code exchange error',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle token response without refresh_token', async () => {
|
||||
const config = createTestConfig();
|
||||
const tokenResponse: TokenResponse = {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
scope: 'openid',
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(tokenResponse),
|
||||
});
|
||||
|
||||
const result = await exchangeCode(config, 'auth-code');
|
||||
|
||||
expect(result).toEqual(tokenResponse);
|
||||
expect(result?.refresh_token).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokeToken', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should revoke token with correct authorization header', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
let capturedHeaders: Headers | Record<string, string> | undefined;
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((_url, options) => {
|
||||
capturedHeaders = options?.headers;
|
||||
return Promise.resolve({ok: true});
|
||||
});
|
||||
|
||||
await revokeToken(config, 'token-to-revoke', revokeEndpoint);
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(revokeEndpoint, expect.any(Object));
|
||||
const authHeader = (capturedHeaders as Record<string, string>)?.Authorization;
|
||||
expect(authHeader).toMatch(/^Basic /);
|
||||
|
||||
const base64Creds = authHeader?.replace('Basic ', '');
|
||||
const decoded = Buffer.from(base64Creds ?? '', 'base64').toString();
|
||||
expect(decoded).toBe(`${config.clientId}:${config.clientSecret}`);
|
||||
});
|
||||
|
||||
it('should send correct form data in request body', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
let capturedBody: URLSearchParams | undefined;
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((_url, options) => {
|
||||
capturedBody = options?.body as URLSearchParams;
|
||||
return Promise.resolve({ok: true});
|
||||
});
|
||||
|
||||
await revokeToken(config, 'access-token-123', revokeEndpoint);
|
||||
|
||||
expect(capturedBody).toBeDefined();
|
||||
expect(capturedBody?.get('token')).toBe('access-token-123');
|
||||
expect(capturedBody?.get('token_type_hint')).toBe('access_token');
|
||||
});
|
||||
|
||||
it('should use POST method with correct content type', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
let capturedOptions: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = vi.fn().mockImplementation((_url, options) => {
|
||||
capturedOptions = options;
|
||||
return Promise.resolve({ok: true});
|
||||
});
|
||||
|
||||
await revokeToken(config, 'token', revokeEndpoint);
|
||||
|
||||
expect(capturedOptions?.method).toBe('POST');
|
||||
expect((capturedOptions?.headers as Record<string, string>)?.['Content-Type']).toBe(
|
||||
'application/x-www-form-urlencoded',
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw on network error', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
await expect(revokeToken(config, 'token', revokeEndpoint)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('should log warning on network error when logger provided', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await revokeToken(config, 'token', revokeEndpoint, {logger});
|
||||
|
||||
expect(logger.calls.warn.length).toBe(1);
|
||||
expect(logger.calls.warn[0]).toEqual([
|
||||
{error: 'Connection refused', revokeEndpoint},
|
||||
'OAuth2 token revocation failed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue('string error');
|
||||
|
||||
await revokeToken(config, 'token', revokeEndpoint, {logger});
|
||||
|
||||
expect(logger.calls.warn.length).toBe(1);
|
||||
expect(logger.calls.warn[0]).toEqual([{error: 'string error', revokeEndpoint}, 'OAuth2 token revocation failed']);
|
||||
});
|
||||
|
||||
it('should not log when revocation succeeds', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
const logger = createTestLogger();
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ok: true});
|
||||
|
||||
await revokeToken(config, 'token', revokeEndpoint, {logger});
|
||||
|
||||
expect(logger.calls.warn.length).toBe(0);
|
||||
expect(logger.calls.error.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should work without logger option', async () => {
|
||||
const config = createTestConfig();
|
||||
const revokeEndpoint = 'https://auth.example.com/revoke';
|
||||
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Error'));
|
||||
|
||||
await expect(revokeToken(config, 'token', revokeEndpoint)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
171
packages/oauth2/src/__tests__/User.test.tsx
Normal file
171
packages/oauth2/src/__tests__/User.test.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {fetchUser, type LoggerInterface, type UserInfo} from '@fluxer/oauth2/src/User';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const TEST_API_ENDPOINT = 'https://api.example.com';
|
||||
const TEST_ACCESS_TOKEN = 'test-access-token-123';
|
||||
|
||||
function createTestUser(overrides?: Partial<UserInfo>): UserInfo {
|
||||
return {
|
||||
id: '123456789',
|
||||
username: 'testuser',
|
||||
discriminator: 1234,
|
||||
avatar: 'abc123def456',
|
||||
email: 'test@example.com',
|
||||
acls: ['admin', 'moderator'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestLogger(): LoggerInterface & {calls: Record<string, Array<unknown>>} {
|
||||
const calls: Record<string, Array<unknown>> = {
|
||||
debug: [],
|
||||
info: [],
|
||||
warn: [],
|
||||
error: [],
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
debug: (...args: Array<unknown>) => calls.debug.push(args),
|
||||
info: (...args: Array<unknown>) => calls.info.push(args),
|
||||
warn: (...args: Array<unknown>) => calls.warn.push(args),
|
||||
error: (...args: Array<unknown>) => calls.error.push(args),
|
||||
};
|
||||
}
|
||||
|
||||
describe('fetchUser', () => {
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch user info successfully', async () => {
|
||||
const testUser = createTestUser();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(testUser),
|
||||
});
|
||||
|
||||
const user = await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN);
|
||||
|
||||
expect(user).toEqual(testUser);
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith(`${TEST_API_ENDPOINT}/users/@me`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_ACCESS_TOKEN}`,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null on non-ok response', async () => {
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
|
||||
const user = await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('should log warning on non-ok response when logger provided', async () => {
|
||||
const logger = createTestLogger();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
});
|
||||
|
||||
await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN, {logger});
|
||||
|
||||
expect(logger.calls.warn.length).toBe(1);
|
||||
expect(logger.calls.warn[0]).toEqual([{status: 403, apiEndpoint: TEST_API_ENDPOINT}, 'OAuth2 user fetch failed']);
|
||||
});
|
||||
|
||||
it('should return null on network error', async () => {
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const user = await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN);
|
||||
|
||||
expect(user).toBeNull();
|
||||
});
|
||||
|
||||
it('should log error on fetch exception when logger provided', async () => {
|
||||
const logger = createTestLogger();
|
||||
globalThis.fetch = vi.fn().mockRejectedValue(new Error('Connection refused'));
|
||||
|
||||
await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN, {logger});
|
||||
|
||||
expect(logger.calls.error.length).toBe(1);
|
||||
expect(logger.calls.error[0]).toEqual([
|
||||
{error: 'Connection refused', apiEndpoint: TEST_API_ENDPOINT},
|
||||
'OAuth2 user fetch error',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle non-Error exceptions', async () => {
|
||||
const logger = createTestLogger();
|
||||
globalThis.fetch = vi.fn().mockRejectedValue('string error');
|
||||
|
||||
await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN, {logger});
|
||||
|
||||
expect(logger.calls.error.length).toBe(1);
|
||||
expect(logger.calls.error[0]).toEqual([
|
||||
{error: 'string error', apiEndpoint: TEST_API_ENDPOINT},
|
||||
'OAuth2 user fetch error',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fetch user without logger option', async () => {
|
||||
const testUser = createTestUser();
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(testUser),
|
||||
});
|
||||
|
||||
const user = await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN);
|
||||
|
||||
expect(user).toEqual(testUser);
|
||||
});
|
||||
|
||||
it('should handle user with minimal fields', async () => {
|
||||
const minimalUser: UserInfo = {
|
||||
id: '111',
|
||||
username: 'minimal',
|
||||
discriminator: 0,
|
||||
avatar: null,
|
||||
};
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(minimalUser),
|
||||
});
|
||||
|
||||
const user = await fetchUser(TEST_API_ENDPOINT, TEST_ACCESS_TOKEN);
|
||||
|
||||
expect(user).toEqual(minimalUser);
|
||||
expect(user?.email).toBeUndefined();
|
||||
expect(user?.acls).toBeUndefined();
|
||||
});
|
||||
});
|
||||
29
packages/oauth2/src/client/IOAuth2Client.tsx
Normal file
29
packages/oauth2/src/client/IOAuth2Client.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {OAuth2TokenResponse} from '@fluxer/oauth2/src/models/OAuth2TokenResponse';
|
||||
import type {OAuth2UserInfo} from '@fluxer/oauth2/src/models/OAuth2UserInfo';
|
||||
|
||||
export interface IOAuth2Client {
|
||||
generateState(): string;
|
||||
createAuthorizationUrl(state: string): string;
|
||||
exchangeCodeForToken(code: string): Promise<OAuth2TokenResponse | null>;
|
||||
fetchCurrentUser(accessToken: string): Promise<OAuth2UserInfo | null>;
|
||||
revokeAccessToken(accessToken: string): Promise<void>;
|
||||
}
|
||||
163
packages/oauth2/src/client/OAuth2Client.tsx
Normal file
163
packages/oauth2/src/client/OAuth2Client.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {randomBytes} from 'node:crypto';
|
||||
import type {IOAuth2Client} from '@fluxer/oauth2/src/client/IOAuth2Client';
|
||||
import type {OAuth2ClientConfig} from '@fluxer/oauth2/src/config/OAuth2ClientConfig';
|
||||
import {FetchHttpClient} from '@fluxer/oauth2/src/http/FetchHttpClient';
|
||||
import type {IOAuth2HttpClient} from '@fluxer/oauth2/src/http/IOAuth2HttpClient';
|
||||
import type {IOAuth2Logger} from '@fluxer/oauth2/src/logging/IOAuth2Logger';
|
||||
import type {OAuth2TokenResponse} from '@fluxer/oauth2/src/models/OAuth2TokenResponse';
|
||||
import type {OAuth2UserInfo} from '@fluxer/oauth2/src/models/OAuth2UserInfo';
|
||||
|
||||
export interface OAuth2ClientDependencies {
|
||||
httpClient?: IOAuth2HttpClient;
|
||||
logger?: IOAuth2Logger;
|
||||
}
|
||||
|
||||
export class OAuth2Client implements IOAuth2Client {
|
||||
private readonly httpClient: IOAuth2HttpClient;
|
||||
private readonly logger?: IOAuth2Logger;
|
||||
|
||||
constructor(
|
||||
private readonly config: OAuth2ClientConfig,
|
||||
dependencies?: OAuth2ClientDependencies,
|
||||
) {
|
||||
this.httpClient = dependencies?.httpClient ?? new FetchHttpClient();
|
||||
this.logger = dependencies?.logger;
|
||||
}
|
||||
|
||||
generateState(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
createAuthorizationUrl(state: string): string {
|
||||
const url = new URL(this.config.endpoints.authorizeEndpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', this.config.clientId);
|
||||
url.searchParams.set('redirect_uri', this.config.redirectUri);
|
||||
url.searchParams.set('scope', this.config.scope);
|
||||
url.searchParams.set('state', state);
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async exchangeCodeForToken(code: string): Promise<OAuth2TokenResponse | null> {
|
||||
try {
|
||||
const response = await this.httpClient.request(this.config.endpoints.tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: this.config.redirectUri,
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger?.warn(
|
||||
{status: response.status, tokenEndpoint: this.config.endpoints.tokenEndpoint},
|
||||
'OAuth2 code exchange failed',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as OAuth2TokenResponse;
|
||||
} catch (error) {
|
||||
this.logger?.error(
|
||||
{error: this.formatError(error), tokenEndpoint: this.config.endpoints.tokenEndpoint},
|
||||
'OAuth2 code exchange error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchCurrentUser(accessToken: string): Promise<OAuth2UserInfo | null> {
|
||||
try {
|
||||
const response = await this.httpClient.request(this.config.endpoints.userInfoEndpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger?.warn(
|
||||
{status: response.status, userInfoEndpoint: this.config.endpoints.userInfoEndpoint},
|
||||
'OAuth2 user fetch failed',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as OAuth2UserInfo;
|
||||
} catch (error) {
|
||||
this.logger?.error(
|
||||
{error: this.formatError(error), userInfoEndpoint: this.config.endpoints.userInfoEndpoint},
|
||||
'OAuth2 user fetch error',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async revokeAccessToken(accessToken: string): Promise<void> {
|
||||
try {
|
||||
const response = await this.httpClient.request(this.config.endpoints.revokeEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: this.createBasicAuthorizationHeader(),
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
token: accessToken,
|
||||
token_type_hint: 'access_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
this.logger?.warn(
|
||||
{status: response.status, revokeEndpoint: this.config.endpoints.revokeEndpoint},
|
||||
'OAuth2 token revocation failed',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger?.warn(
|
||||
{error: this.formatError(error), revokeEndpoint: this.config.endpoints.revokeEndpoint},
|
||||
'OAuth2 token revocation failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private createBasicAuthorizationHeader(): string {
|
||||
const credentials = `${this.config.clientId}:${this.config.clientSecret}`;
|
||||
return `Basic ${Buffer.from(credentials).toString('base64')}`;
|
||||
}
|
||||
|
||||
private formatError(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createOAuth2Client(config: OAuth2ClientConfig, dependencies?: OAuth2ClientDependencies): IOAuth2Client {
|
||||
return new OAuth2Client(config, dependencies);
|
||||
}
|
||||
325
packages/oauth2/src/client/tests/OAuth2Client.test.tsx
Normal file
325
packages/oauth2/src/client/tests/OAuth2Client.test.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createOAuth2Client, OAuth2Client} from '@fluxer/oauth2/src/client/OAuth2Client';
|
||||
import type {OAuth2ClientConfig, OAuth2ClientEndpoints} from '@fluxer/oauth2/src/config/OAuth2ClientConfig';
|
||||
import type {IOAuth2HttpClient} from '@fluxer/oauth2/src/http/IOAuth2HttpClient';
|
||||
import type {IOAuth2Logger} from '@fluxer/oauth2/src/logging/IOAuth2Logger';
|
||||
import type {OAuth2TokenResponse} from '@fluxer/oauth2/src/models/OAuth2TokenResponse';
|
||||
import type {OAuth2UserInfo} from '@fluxer/oauth2/src/models/OAuth2UserInfo';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface LoggerMethodCalls {
|
||||
debug: Array<Array<unknown>>;
|
||||
info: Array<Array<unknown>>;
|
||||
warn: Array<Array<unknown>>;
|
||||
error: Array<Array<unknown>>;
|
||||
}
|
||||
|
||||
interface TestLogger extends IOAuth2Logger {
|
||||
calls: LoggerMethodCalls;
|
||||
}
|
||||
|
||||
interface OAuth2ClientConfigOverrides extends Partial<Omit<OAuth2ClientConfig, 'endpoints'>> {
|
||||
endpoints?: Partial<OAuth2ClientEndpoints>;
|
||||
}
|
||||
|
||||
interface RequestCall {
|
||||
url: string;
|
||||
init: RequestInit | undefined;
|
||||
}
|
||||
|
||||
class TestHttpClient implements IOAuth2HttpClient {
|
||||
public readonly calls: Array<RequestCall> = [];
|
||||
private readonly queue: Array<unknown> = [];
|
||||
|
||||
enqueueResponse(response: Response): void {
|
||||
this.queue.push(response);
|
||||
}
|
||||
|
||||
enqueueError(error: unknown): void {
|
||||
this.queue.push(error);
|
||||
}
|
||||
|
||||
async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
this.calls.push({url, init});
|
||||
const next = this.queue.shift();
|
||||
|
||||
if (next === undefined) {
|
||||
throw new Error('TestHttpClient request queue is empty');
|
||||
}
|
||||
|
||||
if (next instanceof Response) {
|
||||
return next;
|
||||
}
|
||||
|
||||
throw next;
|
||||
}
|
||||
}
|
||||
|
||||
function createTestConfig(overrides?: OAuth2ClientConfigOverrides): OAuth2ClientConfig {
|
||||
const endpoints: OAuth2ClientEndpoints = {
|
||||
authorizeEndpoint: overrides?.endpoints?.authorizeEndpoint ?? 'https://auth.example.com/authorize',
|
||||
tokenEndpoint: overrides?.endpoints?.tokenEndpoint ?? 'https://auth.example.com/token',
|
||||
userInfoEndpoint: overrides?.endpoints?.userInfoEndpoint ?? 'https://api.example.com/users/@me',
|
||||
revokeEndpoint: overrides?.endpoints?.revokeEndpoint ?? 'https://auth.example.com/revoke',
|
||||
};
|
||||
|
||||
return {
|
||||
clientId: overrides?.clientId ?? 'test-client-id',
|
||||
clientSecret: overrides?.clientSecret ?? 'test-client-secret',
|
||||
redirectUri: overrides?.redirectUri ?? 'https://example.com/callback',
|
||||
scope: overrides?.scope ?? 'identify email admin',
|
||||
endpoints,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestTokenResponse(overrides?: Partial<OAuth2TokenResponse>): OAuth2TokenResponse {
|
||||
return {
|
||||
access_token: 'test-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
refresh_token: 'test-refresh-token',
|
||||
scope: 'identify email admin',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestUserInfo(overrides?: Partial<OAuth2UserInfo>): OAuth2UserInfo {
|
||||
return {
|
||||
id: '123456789',
|
||||
username: 'test-user',
|
||||
discriminator: 4242,
|
||||
avatar: 'avatar-hash',
|
||||
email: 'test@example.com',
|
||||
acls: ['admin:authenticate'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestLogger(): TestLogger {
|
||||
const calls: LoggerMethodCalls = {
|
||||
debug: [],
|
||||
info: [],
|
||||
warn: [],
|
||||
error: [],
|
||||
};
|
||||
|
||||
return {
|
||||
calls,
|
||||
debug: (...args: Array<unknown>) => calls.debug.push(args),
|
||||
info: (...args: Array<unknown>) => calls.info.push(args),
|
||||
warn: (...args: Array<unknown>) => calls.warn.push(args),
|
||||
error: (...args: Array<unknown>) => calls.error.push(args),
|
||||
};
|
||||
}
|
||||
|
||||
function getBodyAsSearchParams(init?: RequestInit): URLSearchParams {
|
||||
if (init?.body instanceof URLSearchParams) {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
throw new Error('Expected request body to be URLSearchParams');
|
||||
}
|
||||
|
||||
function getAuthorizationHeader(init?: RequestInit): string | null {
|
||||
const headers = new Headers(init?.headers);
|
||||
return headers.get('Authorization');
|
||||
}
|
||||
|
||||
describe('OAuth2Client', () => {
|
||||
it('should create an OAuth2Client instance via the factory', () => {
|
||||
const client = createOAuth2Client(createTestConfig());
|
||||
expect(client).toBeInstanceOf(OAuth2Client);
|
||||
});
|
||||
|
||||
it('should generate URL-safe state values with stable length', () => {
|
||||
const client = createOAuth2Client(createTestConfig());
|
||||
const firstState = client.generateState();
|
||||
const secondState = client.generateState();
|
||||
|
||||
expect(firstState).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||
expect(firstState.length).toBe(43);
|
||||
expect(firstState).not.toBe(secondState);
|
||||
});
|
||||
|
||||
it('should build an authorization URL and preserve existing query params', () => {
|
||||
const client = createOAuth2Client(
|
||||
createTestConfig({
|
||||
endpoints: {
|
||||
authorizeEndpoint: 'https://auth.example.com/authorize?existing=value',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const url = new URL(client.createAuthorizationUrl('state+with/special=chars'));
|
||||
|
||||
expect(url.origin).toBe('https://auth.example.com');
|
||||
expect(url.pathname).toBe('/authorize');
|
||||
expect(url.searchParams.get('existing')).toBe('value');
|
||||
expect(url.searchParams.get('response_type')).toBe('code');
|
||||
expect(url.searchParams.get('client_id')).toBe('test-client-id');
|
||||
expect(url.searchParams.get('redirect_uri')).toBe('https://example.com/callback');
|
||||
expect(url.searchParams.get('scope')).toBe('identify email admin');
|
||||
expect(url.searchParams.get('state')).toBe('state+with/special=chars');
|
||||
});
|
||||
|
||||
it('should exchange an authorization code for tokens', async () => {
|
||||
const config = createTestConfig();
|
||||
const tokenResponse = createTestTokenResponse();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueResponse(
|
||||
new Response(JSON.stringify(tokenResponse), {
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient});
|
||||
const result = await client.exchangeCodeForToken('auth-code-123');
|
||||
|
||||
expect(result).toEqual(tokenResponse);
|
||||
expect(httpClient.calls.length).toBe(1);
|
||||
expect(httpClient.calls[0]?.url).toBe(config.endpoints.tokenEndpoint);
|
||||
expect(httpClient.calls[0]?.init?.method).toBe('POST');
|
||||
|
||||
const body = getBodyAsSearchParams(httpClient.calls[0]?.init);
|
||||
expect(body.get('grant_type')).toBe('authorization_code');
|
||||
expect(body.get('code')).toBe('auth-code-123');
|
||||
expect(body.get('redirect_uri')).toBe(config.redirectUri);
|
||||
expect(body.get('client_id')).toBe(config.clientId);
|
||||
expect(body.get('client_secret')).toBe(config.clientSecret);
|
||||
});
|
||||
|
||||
it('should return null and log a warning when token exchange fails', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueResponse(new Response(null, {status: 401}));
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient, logger});
|
||||
const result = await client.exchangeCodeForToken('bad-code');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.calls.warn).toEqual([
|
||||
[{status: 401, tokenEndpoint: config.endpoints.tokenEndpoint}, 'OAuth2 code exchange failed'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return null and log errors when token exchange throws', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueError(new Error('Network timeout'));
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient, logger});
|
||||
const result = await client.exchangeCodeForToken('auth-code');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.calls.error).toEqual([
|
||||
[{error: 'Network timeout', tokenEndpoint: config.endpoints.tokenEndpoint}, 'OAuth2 code exchange error'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should fetch the current user', async () => {
|
||||
const config = createTestConfig();
|
||||
const userInfo = createTestUserInfo();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueResponse(
|
||||
new Response(JSON.stringify(userInfo), {
|
||||
status: 200,
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
}),
|
||||
);
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient});
|
||||
const result = await client.fetchCurrentUser('test-access-token');
|
||||
|
||||
expect(result).toEqual(userInfo);
|
||||
expect(httpClient.calls.length).toBe(1);
|
||||
expect(httpClient.calls[0]?.url).toBe(config.endpoints.userInfoEndpoint);
|
||||
expect(getAuthorizationHeader(httpClient.calls[0]?.init)).toBe('Bearer test-access-token');
|
||||
});
|
||||
|
||||
it('should return null and log errors when user fetch throws', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueError('string error');
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient, logger});
|
||||
const result = await client.fetchCurrentUser('test-access-token');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(logger.calls.error).toEqual([
|
||||
[{error: 'string error', userInfoEndpoint: config.endpoints.userInfoEndpoint}, 'OAuth2 user fetch error'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should revoke a token with basic auth credentials', async () => {
|
||||
const config = createTestConfig();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueResponse(new Response(null, {status: 200}));
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient});
|
||||
await client.revokeAccessToken('token-to-revoke');
|
||||
|
||||
expect(httpClient.calls.length).toBe(1);
|
||||
expect(httpClient.calls[0]?.url).toBe(config.endpoints.revokeEndpoint);
|
||||
expect(httpClient.calls[0]?.init?.method).toBe('POST');
|
||||
|
||||
const authorizationHeader = getAuthorizationHeader(httpClient.calls[0]?.init);
|
||||
expect(authorizationHeader).toMatch(/^Basic /);
|
||||
const encodedCredentials = authorizationHeader?.replace('Basic ', '') ?? '';
|
||||
const decodedCredentials = Buffer.from(encodedCredentials, 'base64').toString();
|
||||
expect(decodedCredentials).toBe(`${config.clientId}:${config.clientSecret}`);
|
||||
|
||||
const body = getBodyAsSearchParams(httpClient.calls[0]?.init);
|
||||
expect(body.get('token')).toBe('token-to-revoke');
|
||||
expect(body.get('token_type_hint')).toBe('access_token');
|
||||
});
|
||||
|
||||
it('should log warnings when revocation fails with non-ok status', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueResponse(new Response(null, {status: 500}));
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient, logger});
|
||||
await client.revokeAccessToken('token-to-revoke');
|
||||
|
||||
expect(logger.calls.warn).toEqual([
|
||||
[{status: 500, revokeEndpoint: config.endpoints.revokeEndpoint}, 'OAuth2 token revocation failed'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should log warnings when revocation throws', async () => {
|
||||
const config = createTestConfig();
|
||||
const logger = createTestLogger();
|
||||
const httpClient = new TestHttpClient();
|
||||
httpClient.enqueueError(new Error('Connection reset'));
|
||||
|
||||
const client = createOAuth2Client(config, {httpClient, logger});
|
||||
await client.revokeAccessToken('token-to-revoke');
|
||||
|
||||
expect(logger.calls.warn).toEqual([
|
||||
[{error: 'Connection reset', revokeEndpoint: config.endpoints.revokeEndpoint}, 'OAuth2 token revocation failed'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
33
packages/oauth2/src/config/OAuth2ClientConfig.tsx
Normal file
33
packages/oauth2/src/config/OAuth2ClientConfig.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface OAuth2ClientEndpoints {
|
||||
authorizeEndpoint: string;
|
||||
tokenEndpoint: string;
|
||||
userInfoEndpoint: string;
|
||||
revokeEndpoint: string;
|
||||
}
|
||||
|
||||
export interface OAuth2ClientConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
scope: string;
|
||||
endpoints: OAuth2ClientEndpoints;
|
||||
}
|
||||
26
packages/oauth2/src/http/FetchHttpClient.tsx
Normal file
26
packages/oauth2/src/http/FetchHttpClient.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IOAuth2HttpClient} from '@fluxer/oauth2/src/http/IOAuth2HttpClient';
|
||||
|
||||
export class FetchHttpClient implements IOAuth2HttpClient {
|
||||
async request(url: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(url, init);
|
||||
}
|
||||
}
|
||||
22
packages/oauth2/src/http/IOAuth2HttpClient.tsx
Normal file
22
packages/oauth2/src/http/IOAuth2HttpClient.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface IOAuth2HttpClient {
|
||||
request(url: string, init?: RequestInit): Promise<Response>;
|
||||
}
|
||||
25
packages/oauth2/src/logging/IOAuth2Logger.tsx
Normal file
25
packages/oauth2/src/logging/IOAuth2Logger.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface IOAuth2Logger {
|
||||
debug(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
info(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
warn(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
error(obj: Record<string, unknown> | string, msg?: string): void;
|
||||
}
|
||||
26
packages/oauth2/src/models/OAuth2TokenResponse.tsx
Normal file
26
packages/oauth2/src/models/OAuth2TokenResponse.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface OAuth2TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
refresh_token?: string;
|
||||
scope: string;
|
||||
}
|
||||
27
packages/oauth2/src/models/OAuth2UserInfo.tsx
Normal file
27
packages/oauth2/src/models/OAuth2UserInfo.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface OAuth2UserInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
discriminator: number;
|
||||
avatar: string | null;
|
||||
email?: string;
|
||||
acls?: Array<string>;
|
||||
}
|
||||
Reference in New Issue
Block a user