mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-12 23:31:02 +00:00
034cfb539a
A browser that ran TREK before 5.1.1 keeps that version's app in its service worker. When the app opened /native-handoff, the worker answered it from its cache with an app that has no such page, which sent the user to /, and the sign-in ended on the dashboard in the browser instead of in the app. The app now opens /api/auth/native/start, which every TREK service worker passes to the server. That page takes the workers off the browser and forwards to the handoff page. Until the sign-in is handed back, the web app registers no service worker and does not reload for a new version: a tab still open on the older TREK keeps its worker alive, and a registration or a reload in the middle of the sign-in brought it back to answer the next page.
146 lines
6.1 KiB
TypeScript
146 lines
6.1 KiB
TypeScript
/**
|
|
* Native sign-in hand-off e2e: the real CookieAuthGuard, the real cookie
|
|
* helpers, the real AuthService and session table over a migrated temp SQLite
|
|
* db. Walks the whole round trip the apps make: the browser side mints a code
|
|
* for a challenge, the WebView side redeems it with the verifier and walks
|
|
* away with a session cookie that /api/auth/me accepts.
|
|
*/
|
|
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
|
import { AuthModule } from '../../src/nest/auth/auth.module';
|
|
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
|
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
|
import { NativeAuthModule } from '../../src/nest/native-auth/native-auth.module';
|
|
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
|
import { createSnapshotTestDb } from '../helpers/db-mock';
|
|
import { findRows } from '../helpers/factories/rows';
|
|
import { makeUser } from '../helpers/factories/users';
|
|
import { resetRateLimits } from '../helpers/test-db';
|
|
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
|
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
|
import { sessionCookie, signSession } from './harness';
|
|
import { Test } from '@nestjs/testing';
|
|
|
|
import cookieParser from 'cookie-parser';
|
|
import { createHash, randomBytes } from 'crypto';
|
|
import type { Server } from 'http';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
|
LOG_LEVEL: 'error',
|
|
logInfo: vi.fn(),
|
|
logDebug: vi.fn(),
|
|
logError: vi.fn(),
|
|
logWarn: vi.fn(),
|
|
}));
|
|
|
|
const verifier = randomBytes(32).toString('base64url');
|
|
const challenge = createHash('sha256').update(verifier).digest('hex');
|
|
|
|
const db = createSnapshotTestDb();
|
|
let orm: TestOrm;
|
|
let server: Server;
|
|
let app: Awaited<ReturnType<typeof build>>;
|
|
let userId: number;
|
|
|
|
async function build() {
|
|
const moduleRef = await Test.createTestingModule({
|
|
imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), AuthModule, NativeAuthModule],
|
|
})
|
|
.overrideProvider(MailerService)
|
|
.useValue({ sendPasswordResetEmail: vi.fn() })
|
|
.compile();
|
|
const nest = moduleRef.createNestApplication();
|
|
nest.use(cookieParser());
|
|
nest.useGlobalFilters(new TrekExceptionFilter());
|
|
nest.useGlobalPipes(new ZodValidationPipe());
|
|
await nest.init();
|
|
return nest;
|
|
}
|
|
|
|
const handoff = (cookie: string, body: object = { challenge }) =>
|
|
request(server).post('/api/auth/native/handoff').set('Cookie', cookie).send(body);
|
|
const exchange = (body: object) => request(server).post('/api/auth/native/exchange').send(body);
|
|
|
|
beforeAll(async () => {
|
|
orm = await createTestOrm(db);
|
|
userId = (await makeUser(orm, { username: 'native-e2e', email: 'native-e2e@example.test' })).user.id;
|
|
app = await build();
|
|
server = app.getHttpServer();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetRateLimits(app);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await orm.close();
|
|
});
|
|
|
|
describe('Native sign-in hand-off e2e', () => {
|
|
it('NATIVE-E2E-001: carries a browser session into a fresh session cookie for the app', async () => {
|
|
const minted = await handoff(sessionCookie(userId));
|
|
expect(minted.status).toBe(200);
|
|
expect(typeof minted.body.code).toBe('string');
|
|
|
|
const redeemed = await exchange({ code: minted.body.code, verifier });
|
|
expect(redeemed.status).toBe(200);
|
|
expect(redeemed.body).toEqual({ success: true });
|
|
const cookie = (redeemed.headers['set-cookie'] as unknown as string[]).find((c) => c.startsWith('trek_session='));
|
|
expect(cookie).toMatch(/HttpOnly/);
|
|
expect(cookie).toMatch(/Max-Age=\d+/);
|
|
|
|
const me = await request(server).get('/api/auth/me').set('Cookie', String(cookie).split(';')[0]);
|
|
expect(me.status).toBe(200);
|
|
expect(me.body.user.id).toBe(userId);
|
|
|
|
const logins = await findRows(orm, AuditLog, { user: userId, action: 'user.login' });
|
|
expect(logins.map((row) => JSON.parse(row.details ?? '{}'))).toContainEqual({ method: 'native_app' });
|
|
});
|
|
|
|
it('NATIVE-E2E-002: refuses to mint a code without a cookie session, even for a valid bearer token', async () => {
|
|
const res = await request(server)
|
|
.post('/api/auth/native/handoff')
|
|
.set('Authorization', `Bearer ${signSession(userId)}`)
|
|
.send({ challenge });
|
|
expect(res.status).toBe(401);
|
|
expect(res.body.code).toBe('COOKIE_AUTH_REQUIRED');
|
|
});
|
|
|
|
it('NATIVE-E2E-003: validates the challenge shape', async () => {
|
|
expect((await handoff(sessionCookie(userId), { challenge: 'not-a-hash' })).status).toBe(400);
|
|
});
|
|
|
|
it('NATIVE-E2E-004: refuses a code presented with the wrong verifier, and the code is gone after', async () => {
|
|
const { code } = (await handoff(sessionCookie(userId))).body as { code: string };
|
|
const wrong = await exchange({ code, verifier: 'x'.repeat(43) });
|
|
expect(wrong.status).toBe(400);
|
|
expect(wrong.body).toEqual({ error: 'Invalid or expired code' });
|
|
expect(wrong.headers['set-cookie']).toBeUndefined();
|
|
|
|
const retry = await exchange({ code, verifier });
|
|
expect(retry.status).toBe(400);
|
|
});
|
|
|
|
it('NATIVE-E2E-005: rate-limits the exchange per ip', async () => {
|
|
// One after another: twenty requests at once reset the ephemeral test server's connections.
|
|
await Array.from({ length: 20 }).reduce<Promise<unknown>>(
|
|
(previous, _, i) => previous.then(() => exchange({ code: `nope-${i}`, verifier })),
|
|
Promise.resolve(),
|
|
);
|
|
const blocked = await exchange({ code: 'nope', verifier });
|
|
expect(blocked.status).toBe(429);
|
|
});
|
|
|
|
it('NATIVE-E2E-006: the browser starts at a page under /api that forwards to the handoff page', async () => {
|
|
const res = await request(server).get(`/api/auth/native/start?challenge=${challenge}&start=oidc`);
|
|
expect(res.status).toBe(200);
|
|
expect(res.headers['content-type']).toMatch(/text\/html/);
|
|
expect(res.headers['cache-control']).toBe('no-store');
|
|
expect(res.text).toContain(`data-target="/native-handoff?challenge=${challenge}&start=oidc"`);
|
|
|
|
expect((await request(server).get('/api/auth/native/start?challenge=nope')).status).toBe(400);
|
|
});
|
|
});
|