quality / commitlint (push) Skipped
quality / changes (push) Successful in 8s
quality / docker-check (push) Skipped
quality / web (push) Successful in 1m11s
quality / api (push) Successful in 47s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 1m50s
Альтернатива паролю на портале; SSO приложений без изменений. Co-authored-by: Cursor <cursoragent@cursor.com>
185 lines
5.8 KiB
TypeScript
185 lines
5.8 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { buildApp } from '../src/app.js'
|
|
import { loadConfig } from '../src/config.js'
|
|
import { createWebauthnCredential } from '@authportal/db'
|
|
|
|
async function buildTestApp() {
|
|
const config = loadConfig({
|
|
...process.env,
|
|
JWT_SECRET: 'test-secret-at-least-8',
|
|
ADMIN_EMAIL: 'admin@test.local',
|
|
ADMIN_PASSWORD: 'adminpass',
|
|
DATABASE_URL: 'sqlite::memory:',
|
|
ISSUER: 'https://auth.test.local',
|
|
NODE_ENV: 'test',
|
|
})
|
|
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
|
}
|
|
|
|
async function adminToken(app: Awaited<ReturnType<typeof buildTestApp>>) {
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/auth/login',
|
|
payload: { email: 'admin@test.local', password: 'adminpass' },
|
|
})
|
|
expect(login.statusCode).toBe(200)
|
|
return (login.json() as { access_token: string }).access_token
|
|
}
|
|
|
|
describe('webauthn / passkeys', () => {
|
|
it('exposes webauthn flag on auth config', async () => {
|
|
const app = await buildTestApp()
|
|
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json()).toMatchObject({ webauthn: true })
|
|
expect(app.config.webauthnRpID).toBe('auth.test.local')
|
|
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
|
|
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
|
|
await app.close()
|
|
})
|
|
|
|
it('requires JWT for register options', async () => {
|
|
const app = await buildTestApp()
|
|
const denied = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/webauthn/register/options',
|
|
})
|
|
expect(denied.statusCode).toBe(401)
|
|
|
|
const token = await adminToken(app)
|
|
const ok = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/webauthn/register/options',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(ok.statusCode).toBe(200)
|
|
const body = ok.json() as {
|
|
challenge_id: string
|
|
options: { challenge: string; rp: { id: string } }
|
|
}
|
|
expect(body.challenge_id).toBeTruthy()
|
|
expect(body.options.challenge).toBeTruthy()
|
|
expect(body.options.rp.id).toBe('auth.test.local')
|
|
await app.close()
|
|
})
|
|
|
|
it('allows public login options and rejects a bogus assertion', async () => {
|
|
const app = await buildTestApp()
|
|
const options = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/webauthn/login/options',
|
|
})
|
|
expect(options.statusCode).toBe(200)
|
|
const body = options.json() as { challenge_id: string; options: unknown }
|
|
expect(body.challenge_id).toBeTruthy()
|
|
|
|
const login = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/v1/webauthn/login',
|
|
payload: {
|
|
challenge_id: body.challenge_id,
|
|
response: { id: 'not-a-credential', type: 'public-key' },
|
|
},
|
|
})
|
|
expect(login.statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
|
|
it('lists, deletes own credentials and reports passkey_count', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
const me = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const userId = (me.json() as { id: string }).id
|
|
|
|
createWebauthnCredential(app.db, {
|
|
userId,
|
|
credentialId: 'dGVzdC1jcmVkLWlk',
|
|
publicKey: 'dGVzdC1wdWJrZXk',
|
|
counter: 0,
|
|
name: 'Test key',
|
|
})
|
|
|
|
const listed = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/webauthn/credentials',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(listed.statusCode).toBe(200)
|
|
const creds = listed.json() as { id: string; name: string }[]
|
|
expect(creds).toHaveLength(1)
|
|
expect(creds[0]?.name).toBe('Test key')
|
|
|
|
const users = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/admin/users',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const admin = (
|
|
users.json() as { email: string; passkey_count: number }[]
|
|
).find((u) => u.email === 'admin@test.local')
|
|
expect(admin?.passkey_count).toBe(1)
|
|
|
|
const renamed = await app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
payload: { name: 'Laptop' },
|
|
})
|
|
expect(renamed.statusCode).toBe(200)
|
|
expect((renamed.json() as { name: string }).name).toBe('Laptop')
|
|
|
|
const deleted = await app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(deleted.statusCode).toBe(200)
|
|
|
|
const empty = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/webauthn/credentials',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(empty.json()).toEqual([])
|
|
await app.close()
|
|
})
|
|
|
|
it('lets admin reset a user passkeys', async () => {
|
|
const app = await buildTestApp()
|
|
const token = await adminToken(app)
|
|
const me = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/auth/me',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
const userId = (me.json() as { id: string }).id
|
|
createWebauthnCredential(app.db, {
|
|
userId,
|
|
credentialId: 'cmVzZXQta2V5',
|
|
publicKey: 'cHVia2V5',
|
|
counter: 1,
|
|
name: 'To reset',
|
|
})
|
|
|
|
const reset = await app.inject({
|
|
method: 'DELETE',
|
|
url: `/api/v1/admin/users/${userId}/passkeys`,
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(reset.statusCode).toBe(200)
|
|
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
|
|
|
|
const listed = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/v1/webauthn/credentials',
|
|
headers: { authorization: `Bearer ${token}` },
|
|
})
|
|
expect(listed.json()).toEqual([])
|
|
await app.close()
|
|
})
|
|
})
|