import { createHash } from 'node:crypto' import { afterEach, describe, expect, it } from 'vitest' import { hash } from '@node-rs/argon2' import { createOidcClient, generateOidcClientSecret } from '@authportal/db' import { adminToken, buildTestApp } from './helpers.js' type App = Awaited> let app: App | undefined afterEach(async () => { await app?.close() app = undefined }) const auth = (token: string) => ({ authorization: `Bearer ${token}` }) async function login(a: App, email = 'admin@test.local', password = 'adminpass') { const res = await a.inject({ method: 'POST', url: '/api/v1/auth/login', payload: { email, password }, }) return res } describe('auth', () => { it('login: неверный пароль 401, отключённый пользователь 401', async () => { app = await buildTestApp() const token = await adminToken(app) expect((await login(app, 'admin@test.local', 'wrong')).statusCode).toBe(401) expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401) const created = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload: { email: 'off@test.local', name: 'Off', password: 'secret12', is_admin: false, apps: ['cfdm'], permissions: [], }, }) const id = (created.json() as { id: string }).id await app.inject({ method: 'PATCH', url: `/api/v1/admin/users/${id}`, headers: auth(token), payload: { disabled: true }, }) expect((await login(app, 'off@test.local', 'secret12')).statusCode).toBe(401) }) it('me / reissue / catalog / logout по cookie', async () => { app = await buildTestApp() const res = await login(app) const token = (res.json() as { access_token: string }).access_token const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) }) expect(me.statusCode).toBe(200) expect((me.json() as { email: string }).email).toBe('admin@test.local') const reissue = await app.inject({ method: 'POST', url: '/api/v1/auth/reissue', headers: auth(token), }) expect(reissue.statusCode).toBe(200) expect((reissue.json() as { access_token: string }).access_token).toBeTruthy() const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) }) expect(catalog.statusCode).toBe(200) expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0) const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' }) expect(noAuth.statusCode).toBe(401) const logout = await app.inject({ method: 'POST', url: '/api/v1/auth/logout', headers: { ...auth(token), cookie: `refresh_token=${refresh}` }, }) expect(logout.statusCode).toBe(200) const revoked = app.sqlite .prepare('SELECT revoked_at FROM refresh_sessions') .get() as { revoked_at: string | null } expect(revoked.revoked_at).toBeTruthy() }) }) describe('admin users', () => { it('создание: конфликт email 409, валидация 400; patch и удаление', async () => { app = await buildTestApp() const token = await adminToken(app) const payload = { email: 'dup@test.local', name: 'Dup', password: 'secret12', is_admin: false, apps: ['cfdm'], permissions: [], } const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload }) expect(first.statusCode).toBe(201) const id = (first.json() as { id: string }).id const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload }) expect(dup.statusCode).toBe(409) const bad = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload: { email: 'nope' }, }) expect(bad.statusCode).toBe(400) const patched = await app.inject({ method: 'PATCH', url: `/api/v1/admin/users/${id}`, headers: auth(token), payload: { name: 'Renamed', password: 'newsecret12' }, }) expect(patched.statusCode).toBe(200) expect((patched.json() as { name: string }).name).toBe('Renamed') expect((await login(app, 'dup@test.local', 'newsecret12')).statusCode).toBe(200) expect((await login(app, 'dup@test.local', 'secret12')).statusCode).toBe(401) const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) }) expect(del.statusCode).toBe(204) const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) }) expect(gone.statusCode).toBe(404) }) it('нельзя удалить себя; обычный пользователь не админ (403)', async () => { app = await buildTestApp() const token = await adminToken(app) const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) }) const myId = (me.json() as { id: string }).id const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) }) expect(self.statusCode).toBe(400) await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload: { email: 'plain@test.local', name: 'Plain', password: 'secret12', is_admin: false, apps: ['cfdm'], permissions: [], }, }) const userTok = ((await login(app, 'plain@test.local', 'secret12')).json() as { access_token: string }).access_token const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) }) expect(forbidden.statusCode).toBe(403) }) }) describe('OIDC клиенты и обмен кода', () => { async function setup(a: App) { const token = await adminToken(a) const created = await a.inject({ method: 'POST', url: '/api/v1/admin/oidc/clients', headers: auth(token), payload: { name: 'RP', redirect_uris: ['https://rp.test.local/cb'], scopes: ['openid', 'profile', 'email', 'groups'], enabled: true, }, }) expect(created.statusCode).toBe(200) const client = created.json() as { id: string; client_id: string; client_secret: string } const loginRes = await login(a) const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value return { token, client, refresh } } async function authorizeCode( a: App, refresh: string, clientId: string, extra: Record = {}, ): Promise { const res = await a.inject({ method: 'GET', url: '/oauth/authorize', cookies: { refresh_token: refresh }, query: { client_id: clientId, redirect_uri: 'https://rp.test.local/cb', response_type: 'code', scope: 'openid profile', ...extra, }, }) expect(res.statusCode).toBe(302) return new URL(res.headers.location as string).searchParams.get('code')! } const exchange = (a: App, body: Record) => a.inject({ method: 'POST', url: '/oauth/token', headers: { 'content-type': 'application/x-www-form-urlencoded' }, payload: new URLSearchParams({ grant_type: 'authorization_code', redirect_uri: 'https://rp.test.local/cb', ...body, }).toString(), }) it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => { app = await buildTestApp() const { client, refresh } = await setup(app) const stored = app.sqlite .prepare('SELECT client_secret_hash h FROM oidc_clients') .get() as { h: string } expect(stored.h.startsWith('sha256:')).toBe(true) const code = await authorizeCode(app, refresh, client.client_id) const ok = await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code, }) expect(ok.statusCode).toBe(200) expect((ok.json() as { id_token: string }).id_token).toBeTruthy() const replay = await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code, }) expect(replay.statusCode).toBe(400) expect((replay.json() as { error: string }).error).toBe('invalid_grant') }) it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => { app = await buildTestApp() const { client, refresh } = await setup(app) const verifier = 'v'.repeat(50) const challenge = createHash('sha256').update(verifier).digest('base64url') const pkce = { code_challenge: challenge, code_challenge_method: 'S256' } const creds = { client_id: client.client_id, client_secret: client.client_secret } const good = await exchange(app, { ...creds, code: await authorizeCode(app, refresh, client.client_id, pkce), code_verifier: verifier, }) expect(good.statusCode).toBe(200) const wrong = await exchange(app, { ...creds, code: await authorizeCode(app, refresh, client.client_id, pkce), code_verifier: 'x'.repeat(50), }) expect(wrong.statusCode).toBe(400) const missing = await exchange(app, { ...creds, code: await authorizeCode(app, refresh, client.client_id, pkce), }) expect(missing.statusCode).toBe(400) const plain = await exchange(app, { ...creds, code: await authorizeCode(app, refresh, client.client_id, { code_challenge: 'plain-challenge', code_challenge_method: 'plain', }), code_verifier: 'plain-challenge', }) expect(plain.statusCode).toBe(200) }) it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => { app = await buildTestApp() const { client, refresh } = await setup(app) const code = await authorizeCode(app, refresh, client.client_id) const badSecret = await exchange(app, { client_id: client.client_id, client_secret: 'nope', code, }) expect(badSecret.statusCode).toBe(401) const badRedirect = await app.inject({ method: 'GET', url: '/oauth/authorize', cookies: { refresh_token: refresh }, query: { client_id: client.client_id, redirect_uri: 'https://evil.test/cb', response_type: 'code', scope: 'openid', }, }) expect(badRedirect.statusCode).toBe(400) const badScope = await app.inject({ method: 'GET', url: '/oauth/authorize', cookies: { refresh_token: refresh }, query: { client_id: client.client_id, redirect_uri: 'https://rp.test.local/cb', response_type: 'code', scope: 'openid admin', }, }) expect(badScope.statusCode).toBe(302) expect(badScope.headers.location).toContain('error=invalid_scope') const ok = await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code, }) const accessToken = (ok.json() as { access_token: string }).access_token const info = await app.inject({ method: 'GET', url: '/oauth/userinfo', headers: auth(accessToken), }) expect(info.statusCode).toBe(200) expect((info.json() as { email: string }).email).toBe('admin@test.local') const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' }) expect(noToken.statusCode).toBe(401) }) it('без сессии authorize редиректит на логин', async () => { app = await buildTestApp() const { client } = await setup(app) const res = await app.inject({ method: 'GET', url: '/oauth/authorize', query: { client_id: client.client_id, redirect_uri: 'https://rp.test.local/cb', response_type: 'code', scope: 'openid', }, }) expect(res.statusCode).toBe(302) expect(res.headers.location).toContain('return_to=') }) it('старый argon2-секрет работает и мигрирует в sha256', async () => { app = await buildTestApp() const { refresh } = await setup(app) const secret = generateOidcClientSecret() const legacy = createOidcClient(app.db, { name: 'Legacy', redirectUris: ['https://rp.test.local/cb'], scopes: ['openid', 'profile'], enabled: true, clientSecretHash: await hash(secret), }) const code = await authorizeCode(app, refresh, legacy.clientId) const res = await exchange(app, { client_id: legacy.clientId, client_secret: secret, code, }) expect(res.statusCode).toBe(200) const row = app.sqlite .prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?') .get(legacy.clientId) as { h: string } expect(row.h.startsWith('sha256:')).toBe(true) // после миграции секрет всё ещё работает const code2 = await authorizeCode(app, refresh, legacy.clientId) const again = await exchange(app, { client_id: legacy.clientId, client_secret: secret, code: code2, }) expect(again.statusCode).toBe(200) }) it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => { app = await buildTestApp() const { token, client, refresh } = await setup(app) const patch = await app.inject({ method: 'PATCH', url: `/api/v1/admin/oidc/clients/${client.id}`, headers: auth(token), payload: { name: 'Renamed RP' }, }) expect(patch.statusCode).toBe(200) expect((patch.json() as { name: string }).name).toBe('Renamed RP') const rotate = await app.inject({ method: 'POST', url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`, headers: auth(token), }) expect(rotate.statusCode).toBe(200) const fresh = (rotate.json() as { client_secret: string }).client_secret expect(fresh).not.toBe(client.client_secret) const code = await authorizeCode(app, refresh, client.client_id) expect( (await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode, ).toBe(401) expect( (await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode, ).toBe(200) const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/oidc/clients/${client.id}`, headers: auth(token), }) expect(del.statusCode).toBe(200) const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/oidc/clients/${client.id}`, headers: auth(token), }) expect(gone.statusCode).toBe(404) }) })