mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
6dda05f672
Eight files in services/ hold no state and touch no database: avatarUrl, conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two crypto helpers. They are imported from 65 places, 62 of them already inside src/nest, so having them live in the legacy layer was pure inertia. They stay plain functions rather than becoming providers. Three importers sit outside the container and would have no way to reach an injectable one: db/migrations.ts runs before the app is built, and middleware/auth.ts and middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony and nothing else. mfaCrypto and apiKeyCrypto move together into common/crypto — they read the same ENCRYPTION_KEY, and the directory says so. services/ drops from 44 files to 36, 9,870 lines to 8,493. One trap worth recording: src/app-config and src/nest/app-config both exist. cookie.ts imported '../app-config' from services/, which resolved to the former; after the move the same string silently pointed at the latter, which has no readEnv. It is now explicit about which one it wants. Nothing else changes — no signatures, no behaviour, no tests rewritten beyond their import paths.
163 lines
9.9 KiB
TypeScript
163 lines
9.9 KiB
TypeScript
/**
|
|
* Host-brokered outbound OAuth (#plugins). Proves the security-critical broker logic
|
|
* without a browser: PKCE challenge derivation, single-use + user-bound + TTL state,
|
|
* https/SSRF guard on the endpoints, the code + refresh token exchanges (mocked fetch),
|
|
* tokens encrypted at rest, and a stored refresh token that the plugin never sees.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
|
encrypt_api_key: (v: unknown) => (typeof v === 'string' ? `enc:${v}` : v),
|
|
decrypt_api_key: (v: unknown) => (typeof v === 'string' && v.startsWith('enc:') ? v.slice(4) : v),
|
|
}));
|
|
vi.mock('../../../src/app-config', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../../../src/app-config')>();
|
|
return { ...actual, getAppUrl: () => 'https://trek.example' };
|
|
});
|
|
|
|
const { getDb } = vi.hoisted(() => ({ getDb: { current: null as unknown } }));
|
|
vi.mock('../../../src/db/database', () => ({ get db() { return getDb.current; } }));
|
|
import { db as dbConn } from '../../../src/db/database';
|
|
import { DatabaseService } from '../../../src/nest/database/database.service';
|
|
|
|
// The token POST now runs through the SSRF guard (ssrfGuard.safeFetchLlm), which
|
|
// resolves the host before fetching. Stub DNS so the fake provider.example host
|
|
// resolves — to a public IP by default, or to a per-test address for the guard.
|
|
const { dnsState } = vi.hoisted(() => ({ dnsState: { address: '93.184.216.34', family: 4 } }));
|
|
vi.mock('node:dns/promises', () => {
|
|
const lookup = async () => ({ address: dnsState.address, family: dnsState.family });
|
|
return { default: { lookup }, lookup };
|
|
});
|
|
|
|
import Database from 'better-sqlite3';
|
|
import { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
|
|
|
|
const CFG = {
|
|
oauth_authorize_url: 'https://provider.example/authorize',
|
|
oauth_token_url: 'https://provider.example/token',
|
|
oauth_scopes: 'read write',
|
|
oauth_client_id: 'enc:client-123', // stored encrypted
|
|
oauth_client_secret: 'enc:secret-abc',
|
|
};
|
|
|
|
function freshDb(cfg: Record<string, unknown> = CFG) {
|
|
const d = new Database(':memory:');
|
|
d.exec(`
|
|
CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT, status TEXT);
|
|
CREATE TABLE plugin_oauth_tokens (plugin_id TEXT, user_id INTEGER, access_token TEXT, refresh_token TEXT, expires_at INTEGER, scope TEXT, updated_at TEXT, PRIMARY KEY (plugin_id, user_id));
|
|
CREATE TABLE plugin_oauth_state (state TEXT PRIMARY KEY, plugin_id TEXT, user_id INTEGER, verifier TEXT, created_at INTEGER);
|
|
`);
|
|
d.prepare("INSERT INTO plugins (id, config, status) VALUES ('p', ?, 'active')").run(JSON.stringify(cfg));
|
|
return d;
|
|
}
|
|
|
|
const NOW = 1_700_000_000_000;
|
|
|
|
describe('PluginOAuthService', () => {
|
|
let svc: PluginOAuthService;
|
|
beforeEach(() => { getDb.current = freshDb(); svc = new PluginOAuthService(new DatabaseService(dbConn)); vi.restoreAllMocks(); dnsState.address = '93.184.216.34'; dnsState.family = 4; });
|
|
|
|
it('providerConfig returns null unless every piece is present, decrypting the secrets', () => {
|
|
expect(svc.providerConfig('p')).toMatchObject({ clientId: 'client-123', clientSecret: 'secret-abc', scopes: 'read write' });
|
|
getDb.current = freshDb({ ...CFG, oauth_client_secret: '' });
|
|
expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull();
|
|
});
|
|
|
|
it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
|
|
const url = new URL(svc.startConnect('p', 42, NOW));
|
|
expect(url.origin + url.pathname).toBe('https://provider.example/authorize');
|
|
expect(url.searchParams.get('code_challenge_method')).toBe('S256');
|
|
expect(url.searchParams.get('client_id')).toBe('client-123');
|
|
expect(url.searchParams.get('redirect_uri')).toBe('https://trek.example/api/plugin-oauth/p/callback');
|
|
expect(url.searchParams.get('code_challenge')).toBeTruthy();
|
|
const state = url.searchParams.get('state')!;
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
const stored = rows.prepare('SELECT verifier, user_id FROM plugin_oauth_state WHERE state = ?').get(state) as { verifier: string; user_id: number };
|
|
expect(stored.user_id).toBe(42);
|
|
// a second connect replaces the first (one live state per user)
|
|
svc.startConnect('p', 42, NOW);
|
|
expect((rows.prepare('SELECT COUNT(*) c FROM plugin_oauth_state WHERE user_id = 42').get() as { c: number }).c).toBe(1);
|
|
});
|
|
|
|
it('rejects a non-https / loopback / metadata / internal authorize endpoint', () => {
|
|
getDb.current = freshDb({ ...CFG, oauth_authorize_url: 'http://provider.example/authorize' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/https/);
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://127.0.0.1/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|private/);
|
|
// IPv6-literal loopback must not slip past the fast-fail
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://[::1]/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback/);
|
|
// cloud-metadata by literal is refused too
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://169.254.169.254/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/loopback|metadata/);
|
|
// an internal name suffix is refused
|
|
getDb.current = freshDb({ ...CFG, oauth_token_url: 'https://idp.internal/token' });
|
|
expect(() => new PluginOAuthService(new DatabaseService(dbConn)).startConnect('p', 42, NOW)).toThrow(/local/);
|
|
});
|
|
|
|
it('completeCallback verifies state (single-use, user-bound, TTL), exchanges the code, encrypts tokens', async () => {
|
|
const url = new URL(svc.startConnect('p', 42, NOW));
|
|
const state = url.searchParams.get('state')!;
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
ok: true, json: async () => ({ access_token: 'AT', refresh_token: 'RT', expires_in: 3600, scope: 'read' }),
|
|
} as Response);
|
|
|
|
await svc.completeCallback('p', 42, 'the-code', state, NOW + 1000);
|
|
// the token exchange used PKCE (code_verifier) + client creds
|
|
const body = (fetchMock.mock.calls[0][1] as { body: string }).body;
|
|
expect(body).toContain('grant_type=authorization_code');
|
|
expect(body).toContain('code_verifier=');
|
|
expect(body).toContain('client_secret=secret-abc');
|
|
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
const tok = rows.prepare('SELECT access_token, refresh_token FROM plugin_oauth_tokens WHERE plugin_id = ? AND user_id = 42').get('p') as { access_token: string; refresh_token: string };
|
|
expect(tok.access_token).toBe('enc:AT'); // encrypted at rest
|
|
expect(tok.refresh_token).toBe('enc:RT');
|
|
expect(svc.status('p', 42)).toMatchObject({ configured: true, connected: true });
|
|
// state is single-use — replaying it fails
|
|
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 2000)).rejects.toThrow(/state/);
|
|
});
|
|
|
|
it('rejects a foreign or expired state', async () => {
|
|
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
await expect(svc.completeCallback('p', 99, 'code', state, NOW + 1000)).rejects.toThrow(/state/); // wrong user
|
|
const state2 = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
await expect(svc.completeCallback('p', 42, 'code', state2, NOW + 20 * 60 * 1000)).rejects.toThrow(/state/); // > 10 min
|
|
});
|
|
|
|
it('getAccessToken returns the token, refreshes an expiring one, and hands the plugin only the access token', async () => {
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
// a live token → returned decrypted, no network
|
|
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token, refresh_token, expires_at) VALUES (?,?,?,?,?)').run('p', 42, 'enc:LIVE', 'enc:RT', NOW + 3600_000);
|
|
expect(await svc.getAccessToken('p', 42, NOW)).toBe('LIVE');
|
|
|
|
// an expired token → refreshed via the refresh_token grant
|
|
rows.prepare('UPDATE plugin_oauth_tokens SET access_token = ?, expires_at = ? WHERE user_id = 42').run('enc:OLD', NOW - 1000);
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'NEW', expires_in: 3600 }) } as Response);
|
|
expect(await svc.getAccessToken('p', 42, NOW)).toBe('NEW');
|
|
expect((fetchMock.mock.calls[0][1] as { body: string }).body).toContain('grant_type=refresh_token');
|
|
// the provider omitted a new refresh_token → the old one is kept
|
|
const tok = rows.prepare('SELECT refresh_token FROM plugin_oauth_tokens WHERE user_id = 42').get() as { refresh_token: string };
|
|
expect(tok.refresh_token).toBe('enc:RT');
|
|
|
|
// a user who never connected → null
|
|
expect(await svc.getAccessToken('p', 7, NOW)).toBeNull();
|
|
});
|
|
|
|
it('routes the token exchange through the SSRF guard — a token_url resolving to cloud metadata is refused', async () => {
|
|
const state = new URL(svc.startConnect('p', 42, NOW)).searchParams.get('state')!;
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, json: async () => ({ access_token: 'AT' }) } as Response);
|
|
// The provider's token_url now resolves to the cloud-metadata address.
|
|
dnsState.address = '169.254.169.254';
|
|
await expect(svc.completeCallback('p', 42, 'the-code', state, NOW + 1000)).rejects.toThrow();
|
|
expect(fetchMock).not.toHaveBeenCalled(); // blocked before any request left the host
|
|
});
|
|
|
|
it('disconnect drops the user\'s tokens', async () => {
|
|
const rows = getDb.current as unknown as InstanceType<typeof Database>;
|
|
rows.prepare('INSERT INTO plugin_oauth_tokens (plugin_id, user_id, access_token) VALUES (?,?,?)').run('p', 42, 'enc:X');
|
|
svc.disconnect('p', 42);
|
|
expect(svc.status('p', 42).connected).toBe(false);
|
|
});
|
|
});
|