161 lines
5.1 KiB
TypeScript
161 lines
5.1 KiB
TypeScript
import {
|
|
identityApp,
|
|
type IdentityApp,
|
|
ownerPassword,
|
|
} from './helpers/identity-app';
|
|
import { hashToken } from '../src/identity/tokens';
|
|
|
|
describe('authentication with migrated PostgreSQL engine', () => {
|
|
let ctx: IdentityApp;
|
|
beforeAll(async () => {
|
|
ctx = await identityApp();
|
|
}, 60000);
|
|
afterAll(async () => {
|
|
await ctx?.close();
|
|
});
|
|
beforeEach(async () => {
|
|
await ctx.clearLimits();
|
|
});
|
|
|
|
it('stores only hashed session tokens and never exposes password hashes', async () => {
|
|
const session = await ctx.db.session.findUniqueOrThrow({
|
|
where: { tokenHash: hashToken(ctx.token) },
|
|
});
|
|
expect(session.tokenHash).not.toBe(ctx.token);
|
|
const me = await ctx
|
|
.api()
|
|
.get('/api/v1/auth/me')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(200);
|
|
expect(me.body.organizationId).toBe(ctx.owner.organizationId);
|
|
expect(me.text).not.toContain('password');
|
|
expect(me.headers['cache-control']).toBe('no-store');
|
|
});
|
|
it('rejects missing, malformed, unknown and expired sessions', async () => {
|
|
await ctx.api().get('/api/v1/users').expect(401);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/users')
|
|
.set('Authorization', 'Bearer bad')
|
|
.expect(401);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/users')
|
|
.auth('a'.repeat(43), { type: 'bearer' })
|
|
.expect(401);
|
|
const login = await ctx.login();
|
|
await ctx.db.session.update({
|
|
where: { tokenHash: hashToken(login.body.accessToken) },
|
|
data: { expiresAt: new Date(0) },
|
|
});
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/auth/me')
|
|
.auth(login.body.accessToken, { type: 'bearer' })
|
|
.expect(401);
|
|
});
|
|
it('returns the same credential error for unknown email and wrong password', async () => {
|
|
const missing = await ctx.login('missing@example.com');
|
|
const wrong = await ctx.login('owner@example.com', 'wrong password');
|
|
expect(missing.status).toBe(401);
|
|
expect(wrong.body).toEqual(missing.body);
|
|
});
|
|
it('validates payloads without echoing secrets and rejects mass assignment', async () => {
|
|
const response = await ctx
|
|
.api()
|
|
.post('/api/v1/auth/login')
|
|
.send({
|
|
organizationId: ctx.owner.organizationId,
|
|
email: 'owner@example.com',
|
|
password: 'SECRET',
|
|
isOwner: true,
|
|
})
|
|
.expect(400);
|
|
expect(response.text).not.toContain('SECRET');
|
|
});
|
|
it('revokes a session on logout', async () => {
|
|
const login = await ctx.login();
|
|
const bearer = login.body.accessToken;
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/auth/logout')
|
|
.auth(bearer, { type: 'bearer' })
|
|
.expect(204);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/auth/me')
|
|
.auth(bearer, { type: 'bearer' })
|
|
.expect(401);
|
|
});
|
|
it('limits repeated attempts per account in durable storage', async () => {
|
|
for (let i = 0; i < 10; i++)
|
|
expect((await ctx.login('unknown@example.com')).status).toBe(401);
|
|
expect((await ctx.login('unknown@example.com')).status).toBe(429);
|
|
}, 15000);
|
|
it('enforces IP throttling even for malformed input', async () => {
|
|
for (let i = 0; i < 30; i++)
|
|
await ctx.api().post('/api/v1/auth/login').send({}).expect(400);
|
|
await ctx.api().post('/api/v1/auth/login').send({}).expect(429);
|
|
});
|
|
it('recovery consumes a token once and revokes existing sessions', async () => {
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/auth/recovery/request')
|
|
.send({
|
|
organizationId: ctx.owner.organizationId,
|
|
email: 'owner@example.com',
|
|
})
|
|
.expect(202);
|
|
const recoveryToken = ctx.mailer.send.mock.calls.at(-1)![1] as string;
|
|
const newPassword = 'a replacement secure passphrase';
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/auth/recovery/reset')
|
|
.send({ token: recoveryToken, password: newPassword })
|
|
.expect(204);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/auth/recovery/reset')
|
|
.send({ token: recoveryToken, password: newPassword })
|
|
.expect(400);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/auth/me')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(401);
|
|
expect((await ctx.login('owner@example.com', ownerPassword)).status).toBe(
|
|
401,
|
|
);
|
|
const login = await ctx.login('owner@example.com', newPassword);
|
|
expect(login.status).toBe(200);
|
|
ctx.token = login.body.accessToken;
|
|
});
|
|
it('revokes all sessions and records security events without secrets', async () => {
|
|
const login = await ctx.login(
|
|
'owner@example.com',
|
|
'a replacement secure passphrase',
|
|
);
|
|
await ctx
|
|
.api()
|
|
.post('/api/v1/auth/logout-all')
|
|
.auth(ctx.token, { type: 'bearer' })
|
|
.expect(204);
|
|
await ctx
|
|
.api()
|
|
.get('/api/v1/auth/me')
|
|
.auth(login.body.accessToken, { type: 'bearer' })
|
|
.expect(401);
|
|
const events = await ctx.db.auditEvent.findMany();
|
|
expect(events.map((event) => event.action)).toEqual(
|
|
expect.arrayContaining([
|
|
'auth.login',
|
|
'auth.login_failed',
|
|
'auth.password_reset',
|
|
'auth.logout_all',
|
|
]),
|
|
);
|
|
expect(JSON.stringify(events)).not.toContain(ctx.token);
|
|
expect(JSON.stringify(events)).not.toContain(ownerPassword);
|
|
});
|
|
});
|