432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
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<ReturnType<typeof buildTestApp>>
|
|
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<string, string> = {},
|
|
): Promise<string> {
|
|
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<string, string>) =>
|
|
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)
|
|
})
|
|
})
|