164 lines
12 KiB
TypeScript
164 lines
12 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { createHash } from 'node:crypto';
|
|
import { createPool } from '../../src/db';
|
|
import { buildApp } from '../../src/app';
|
|
import { readSettings, saveSettings } from '../../src/oidc';
|
|
import { seal, unseal } from '../../src/token-vault';
|
|
import { startMockOidc } from '../helpers/mock-oidc.mjs';
|
|
import { testAccount } from '../helpers/account';
|
|
|
|
const pool = createPool();
|
|
process.env.OIDC_ALLOW_HTTP = 'true';
|
|
process.env.APP_ORIGINS = 'http://localhost:4174';
|
|
const app = buildApp(pool, false);
|
|
let provider: Awaited<ReturnType<typeof startMockOidc>>;
|
|
let original: Awaited<ReturnType<typeof readSettings>>;
|
|
let admin: Record<string, string>;
|
|
const cookie = (response: { cookies: Array<{ name: string; value: string }> }) => response.cookies.map(c => `${c.name}=${c.value}`).join('; ');
|
|
async function begin() {
|
|
const started = await app.inject({ method: 'POST', url: '/auth/login', headers: { 'x-model-request': '1' } });
|
|
expect(started.statusCode).toBe(200);
|
|
const authorizationUrl = new URL(started.json().url);
|
|
expect(authorizationUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
|
expect(authorizationUrl.searchParams.get('scope')).toContain('offline_access');
|
|
expect(authorizationUrl.searchParams.get('scope')).toContain('groups');
|
|
expect(started.headers['set-cookie']).toContain('SameSite=Lax');
|
|
const redirect = await fetch(authorizationUrl, { redirect: 'manual' });
|
|
const callback = new URL(redirect.headers.get('location')!);
|
|
return { url: `/auth/callback${callback.search}`, headers: { cookie: cookie(started) } };
|
|
}
|
|
async function login() {
|
|
const callback = await app.inject(await begin());
|
|
expect(callback.headers.location).toBe('/');
|
|
return { cookie: cookie(callback), 'x-model-request': '1' };
|
|
}
|
|
beforeAll(async () => {
|
|
expect(process.env.DB_NAME).toBe('model_delivery_test');
|
|
provider = await startMockOidc();
|
|
original = await readSettings(pool);
|
|
const status = (await app.inject('/local-admin/status')).json();
|
|
const result = await app.inject({ method: 'POST', url: `/local-admin/${status.setupRequired ? 'setup' : 'login'}`, headers: { 'x-model-request': '1' }, payload: status.setupRequired ? { ...testAccount, setupToken: process.env.LOCAL_ADMIN_SETUP_TOKEN } : { username: testAccount.username, password: testAccount.password } });
|
|
expect(result.statusCode).toBe(200);
|
|
admin = { cookie: cookie(result), 'x-model-request': '1' };
|
|
const saved = await app.inject({ method: 'POST', url: '/local-admin/settings', headers: admin, payload: { enabled: true, issuer: provider.issuer, discoveryUrl: `${provider.issuer}/.well-known/openid-configuration`, clientId: 'model-test', clientSecret: 'mock-test-secret', redirectUri: 'http://localhost:4174/api/auth/callback', scopes: 'openid profile email groups', authMethod: 'client_secret_post', offlineAccess: true, promptConsent: true, displayNameClaim: 'name', groupAccessEnabled: true, groupClaim: 'groups', allowedGroups: ['model-delivery-users'] } });
|
|
expect(saved.statusCode).toBe(200);
|
|
expect(saved.json()).toMatchObject({ clientSecret: '', secretConfigured: true });
|
|
});
|
|
afterAll(async () => {
|
|
if (original) await saveSettings(pool, original, (await app.inject({ url: '/auth/session', headers: admin })).json().user.id);
|
|
await app.close(); await pool.end(); await provider?.close();
|
|
});
|
|
describe('OIDC and local recovery', () => {
|
|
it('keeps a personal display name across SSO logins when the provider returns only a username', async () => {
|
|
provider.controls.omitName = true;
|
|
const headers = await login();
|
|
const before = (await app.inject({ url: '/auth/session', headers })).json();
|
|
expect(before.user.displayName).toBe('test.sso');
|
|
const payload = { displayName: 'תום רוף' };
|
|
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { 'x-model-request': '1' }, payload })).statusCode).toBe(401);
|
|
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: { ...headers, 'x-model-user': 'another-account' }, payload })).statusCode).toBe(401);
|
|
const ownHeaders = { ...headers, 'x-model-user': before.user.id };
|
|
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload: { ...payload, role: 'admin' } })).statusCode).toBe(400);
|
|
expect((await app.inject({ method: 'POST', url: '/auth/profile', headers: ownHeaders, payload })).json().user).toMatchObject({ displayName: 'תום רוף', username: 'test.sso', role: 'staff' });
|
|
const again = (await app.inject({ url: '/auth/session', headers: await login() })).json();
|
|
expect(again.user).toMatchObject({ id: before.user.id, displayName: 'תום רוף', username: 'test.sso' });
|
|
const stored = await pool.query('SELECT display_name, display_name_override FROM app_users WHERE id = ?', [before.user.id]);
|
|
expect(stored[0]).toMatchObject({ display_name: 'test.sso', display_name_override: 'תום רוף' });
|
|
await pool.query('UPDATE app_users SET display_name_override = NULL WHERE id = ?', [before.user.id]);
|
|
provider.controls.omitName = false;
|
|
});
|
|
it('guards recovery setup/settings and masks/encrypts the client secret', async () => {
|
|
expect((await app.inject('/local-admin/settings')).statusCode).toBe(401);
|
|
expect((await app.inject({ url: '/local-admin/status', remoteAddress: '192.0.2.1' })).statusCode).toBe(403);
|
|
expect((await app.inject({ method: 'POST', url: '/local-admin/setup', headers: { 'x-model-request': '1' }, payload: { ...testAccount, setupToken: 'wrong' } })).statusCode).toBe(403);
|
|
const settings = await app.inject({ url: '/local-admin/settings', headers: admin });
|
|
expect(settings.body).not.toContain('mock-test-secret');
|
|
const stored = await pool.query('SELECT encrypted_config FROM oidc_settings');
|
|
expect(stored[0].encrypted_config).not.toContain('mock-test-secret');
|
|
expect((await app.inject({ method: 'POST', url: '/local-admin/discover', headers: admin })).json().pkceAdvertised).toBe(true);
|
|
});
|
|
it('binds sign-in to its browser, checks state/nonce and rejects callback replay', async () => {
|
|
const pending = await begin();
|
|
expect((await app.inject(pending.url)).headers.location).toBe('/?authError=signin_failed');
|
|
const tampered = new URL(pending.url, 'http://test'); tampered.searchParams.set('state', 'wrong-state');
|
|
expect((await app.inject({ ...pending, url: `${tampered.pathname}${tampered.search}` })).headers.location).toBe('/?authError=signin_failed');
|
|
provider.controls.badNonce = true;
|
|
expect((await app.inject(await begin())).headers.location).toBe('/?authError=signin_failed');
|
|
provider.controls.badNonce = false;
|
|
const valid = await begin();
|
|
expect((await app.inject(valid)).headers.location).toBe('/');
|
|
expect((await app.inject(valid)).headers.location).toBe('/?authError=signin_failed');
|
|
});
|
|
it('denies SSO users outside the configured Synology group allowlist', async () => {
|
|
provider.controls.groups = ['unrelated-team'];
|
|
const denied = await app.inject(await begin());
|
|
expect(denied.headers.location).toBe('/?authError=group_denied');
|
|
provider.controls.groups = ['MODEL-DELIVERY-USERS'];
|
|
const allowedHeaders = await login();
|
|
expect((await app.inject({ url: '/auth/session', headers: allowedHeaders })).json().user.groups).toEqual(['MODEL-DELIVERY-USERS']);
|
|
provider.controls.groups = ['model-delivery-users'];
|
|
});
|
|
it('keeps a bounded app session after provider expiry when no refresh token was issued', async () => {
|
|
provider.controls.omitRefreshToken = true;
|
|
try {
|
|
const headers = await login();
|
|
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
|
const hash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!).digest('hex');
|
|
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
|
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
|
expect(tokens.refreshToken).toBeUndefined();
|
|
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 60 * 60 * 1000 }), hash]);
|
|
const refreshCount = provider.controls.refreshCount;
|
|
const resumed = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
|
expect(resumed.every(r => r.json().user?.id === profile.user.id && r.json().expiresAt === profile.expiresAt)).toBe(true);
|
|
expect(provider.controls.refreshCount).toBe(refreshCount);
|
|
const syncHeaders = { ...headers, 'x-model-user': profile.user.id };
|
|
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(200);
|
|
|
|
// Losing app access still blocks synchronization even without token refresh.
|
|
await pool.query('UPDATE app_users SET active = FALSE WHERE id = ?', [profile.user.id]);
|
|
try { expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401); }
|
|
finally { await pool.query('UPDATE app_users SET active = TRUE WHERE id = ?', [profile.user.id]); }
|
|
const settings = await readSettings(pool);
|
|
const adminId = (await app.inject({ url: '/auth/session', headers: admin })).json().user.id;
|
|
try {
|
|
await saveSettings(pool, { ...settings, allowedGroups: ['another-team'] }, adminId);
|
|
expect((await app.inject({ url: '/sync/pull', headers: syncHeaders })).statusCode).toBe(401);
|
|
} finally { await saveSettings(pool, settings, adminId); }
|
|
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
|
|
|
const expiredHeaders = await login();
|
|
const expiredHash = createHash('sha256').update(/(?:^|; )model_session=([^;]+)/.exec(expiredHeaders.cookie)![1]!).digest('hex');
|
|
await pool.query('UPDATE auth_sessions SET expires_at = DATE_SUB(UTC_TIMESTAMP(3), INTERVAL 1 SECOND) WHERE token_hash = ?', [expiredHash]);
|
|
expect((await app.inject({ url: '/sync/pull', headers: { ...expiredHeaders, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
|
expect((await app.inject({ url: '/auth/session', headers: expiredHeaders })).json().user).toBeNull();
|
|
|
|
const logoutHeaders = await login();
|
|
expect((await app.inject({ method: 'POST', url: '/auth/logout', headers: logoutHeaders })).statusCode).toBe(200);
|
|
expect((await app.inject({ url: '/auth/session', headers: logoutHeaders })).json().user).toBeNull();
|
|
} finally { provider.controls.omitRefreshToken = false; }
|
|
});
|
|
it('returns the greeting profile without tokens, refreshes once concurrently, and reauthenticates after revoked refresh', async () => {
|
|
const headers = await login();
|
|
const profile = (await app.inject({ url: '/auth/session', headers })).json();
|
|
expect(profile.user).toMatchObject({ displayName: 'טום בדיקה', username: 'test.sso', authProvider: 'oidc', role: 'staff', groups: ['model-delivery-users'] });
|
|
expect(Object.keys(profile).sort()).toEqual(['expiresAt', 'user']);
|
|
expect((await app.inject({ url: '/local-admin/settings', headers })).statusCode).toBe(403);
|
|
const token = /(?:^|; )model_session=([^;]+)/.exec(headers.cookie)![1]!;
|
|
const hash = createHash('sha256').update(token).digest('hex');
|
|
async function expire() {
|
|
const rows = await pool.query('SELECT encrypted_tokens FROM auth_sessions WHERE token_hash = ?', [hash]);
|
|
const tokens = unseal<Record<string, unknown>>(rows[0].encrypted_tokens);
|
|
expect(rows[0].encrypted_tokens).not.toContain(tokens.refreshToken);
|
|
await pool.query('UPDATE auth_sessions SET encrypted_tokens = ? WHERE token_hash = ?', [seal({ ...tokens, expiresAt: Date.now() - 1000 }), hash]);
|
|
}
|
|
await expire();
|
|
const replies = await Promise.all(Array.from({ length: 3 }, () => app.inject({ url: '/auth/session', headers })));
|
|
expect(replies.every(r => r.json().user?.id === profile.user.id)).toBe(true);
|
|
expect(provider.controls.refreshCount).toBe(1);
|
|
await expire(); provider.controls.denyRefresh = true;
|
|
expect((await app.inject({ url: '/sync/pull', headers: { ...headers, 'x-model-user': profile.user.id } })).statusCode).toBe(401);
|
|
expect((await app.inject({ url: '/auth/session', headers })).json().user).toBeNull();
|
|
});
|
|
});
|