refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,20 @@
{
"name": "@fluxer/oauth2",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./*": "./*"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsgo --noEmit"
},
"devDependencies": {
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}

View 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');
}

View 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',
);
}
}

View 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;
}
}

View 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);
});
});

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

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

View 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>;
}

View 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);
}

View 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'],
]);
});
});

View 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;
}

View 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);
}
}

View 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>;
}

View 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;
}

View 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;
}

View 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>;
}

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfigs/package.json",
"compilerOptions": {},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,44 @@
/*
* 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 path from 'node:path';
import {fileURLToPath} from 'node:url';
import tsconfigPaths from 'vite-tsconfig-paths';
import {defineConfig} from 'vitest/config';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [
tsconfigPaths({
root: path.resolve(__dirname, '../..'),
}),
],
test: {
globals: true,
environment: 'node',
include: ['**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
},
},
});