Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 6m55s
CI / Build container images (pull_request) Successful in 4m43s
CI / Auth e2e pack (pull_request) Successful in 9m13s
CI / Import/export fidelity gate (pull_request) Successful in 1m4s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CI / Lint, typecheck, test (push) Has been cancelled
CD / Build and push images (push) Has been cancelled
The deploy-level realization of auth.local.enabled (ADR 0021): FALSE answers 404 on every local credential flow — login, signup, e-mail verification, resend, password forgot/reset/change — enforced centrally in the auth guard via the @LocalCredentialFlow() marker before any session or CSRF logic runs. Deploy-level on purpose: a compromised Site Admin cannot reopen the local path, so the runtime-flip residual risk from ADR 0021 does not materialize (R-02 closed in the risk list). An enumeration fence fails when an auth route is neither marked nor on the reviewed allowlist, so a new credential flow cannot ship unswitched. Stated decisions, each tested: sessions/logout keep working for externally authenticated users; PAT and feed-token issuance stays available (API authorization under its own switches, not interactive sign-in). Bootstrap: complete setup (or SETUP_ADMIN_* pre-seed) before flipping; the api warns at boot when local auth is off with neither OIDC nor proxy auth configured. GET /auth/methods reports local:false and the login page hides the local form and credential links. Hardening guide: the planned auth.local.enabled row moves from 1.3 into the live deploy table with the bootstrap ordering, and the verification checklist gains the login-404 probe. Refs #216. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
158 lines
6.3 KiB
TypeScript
158 lines
6.3 KiB
TypeScript
import 'reflect-metadata';
|
|
|
|
import { INestApplication } from '@nestjs/common';
|
|
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
import { PrismaClient } from '@prisma/client';
|
|
import request from 'supertest';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
import { createTestApp } from '../testing/test-app';
|
|
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
|
|
import { UsersService } from '../users/users.service';
|
|
|
|
import { LOCAL_CREDENTIAL_KEY } from './auth.guard';
|
|
import { AuthController } from './auth.controller';
|
|
import { OidcController } from './oidc.controller';
|
|
import { SessionsService } from './sessions.service';
|
|
|
|
/**
|
|
* The hard local-auth switch (issue #216, ADR 0021): AUTH_LOCAL_ENABLED=false
|
|
* closes EVERY local credential flow with 404 — enumerated, not assumed —
|
|
* while sessions themselves, logout, and token issuance for
|
|
* externally-authenticated users keep working (the stated decision: PATs
|
|
* and feed tokens authorize API access under their own switches, they are
|
|
* not interactive sign-in). A fence asserts every auth route is either
|
|
* marked as a local flow or on the reviewed allowlist.
|
|
*/
|
|
describe.skipIf(!hasTestDb)('local-auth switch (e2e, issue #216)', () => {
|
|
let app: INestApplication;
|
|
let prisma: PrismaClient;
|
|
const suffix = uniqueSuffix();
|
|
|
|
/** Every local credential surface — the enumeration the issue demands. */
|
|
const LOCAL_ROUTES: { method: 'post'; path: string; body: Record<string, unknown> }[] = [
|
|
{ method: 'post', path: '/api/v1/auth/login', body: { usernameOrEmail: 'x', password: 'y' } },
|
|
{
|
|
method: 'post',
|
|
path: '/api/v1/auth/signup',
|
|
body: {
|
|
username: `switch-${suffix}`,
|
|
email: `switch-${suffix}@example.test`,
|
|
displayName: 'x',
|
|
password: 'ein langes passwort 123',
|
|
locale: 'en',
|
|
},
|
|
},
|
|
{ method: 'post', path: '/api/v1/auth/verify-email', body: { token: 'x' } },
|
|
{
|
|
method: 'post',
|
|
path: '/api/v1/auth/resend-verification',
|
|
body: { email: 'x@example.test' },
|
|
},
|
|
{ method: 'post', path: '/api/v1/auth/forgot-password', body: { email: 'x@example.test' } },
|
|
{
|
|
method: 'post',
|
|
path: '/api/v1/auth/reset-password',
|
|
body: { token: 'x', password: 'ein langes passwort 123' },
|
|
},
|
|
{
|
|
method: 'post',
|
|
path: '/api/v1/users/me/change-password',
|
|
body: { currentPassword: 'x', newPassword: 'ein langes passwort 123' },
|
|
},
|
|
];
|
|
|
|
const api = () => request(app.getHttpServer());
|
|
|
|
beforeAll(async () => {
|
|
prisma = createTestPrisma();
|
|
await prisma.rateLimit.deleteMany({});
|
|
process.env.AUTH_LOCAL_ENABLED = 'false';
|
|
app = await createTestApp();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
delete process.env.AUTH_LOCAL_ENABLED;
|
|
await prisma.apiToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
|
await prisma.feedToken.deleteMany({ where: { user: { username: { contains: suffix } } } });
|
|
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
|
|
await prisma.$disconnect();
|
|
await app.close();
|
|
});
|
|
|
|
it('answers 404 on every enumerated local credential route', async () => {
|
|
for (const route of LOCAL_ROUTES) {
|
|
const res = await api()[route.method](route.path).send(route.body);
|
|
expect(`${route.path}: ${res.status}`).toBe(`${route.path}: 404`);
|
|
}
|
|
});
|
|
|
|
it('reports local:false so the login screen hides the form', async () => {
|
|
const res = await api().get('/api/v1/auth/methods').expect(200);
|
|
expect(res.body.local).toBe(false);
|
|
});
|
|
|
|
it('keeps sessions, logout, and PAT/feed-token issuance working for externally-authenticated users', async () => {
|
|
// An externally-authenticated user is simulated by creating the session
|
|
// through the session service — exactly what the OIDC/proxy paths do.
|
|
const users = app.get(UsersService);
|
|
const user = await users.createUser({
|
|
username: `ext-${suffix}`,
|
|
email: `ext-${suffix}@example.test`,
|
|
displayName: 'External',
|
|
password: 'nie benutzt weil lokal aus',
|
|
locale: 'en',
|
|
});
|
|
await users.markEmailVerified(user.id);
|
|
const token = await app.get(SessionsService).create(user.id, undefined);
|
|
const cookie = `dt_session=${token}`;
|
|
|
|
const me = await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(200);
|
|
expect(me.body.id).toBe(user.id);
|
|
|
|
// Stated decision (#216): token issuance is API authorization, not
|
|
// interactive sign-in — it stays available under its own switches.
|
|
await api()
|
|
.post('/api/v1/users/me/api-tokens')
|
|
.set('Cookie', cookie)
|
|
.send({ name: `switch-${suffix}`, scope: 'read' })
|
|
.expect(201);
|
|
await api()
|
|
.post('/api/v1/users/me/feed-tokens')
|
|
.set('Cookie', cookie)
|
|
.send({ name: `switch-${suffix}` })
|
|
.expect(201);
|
|
|
|
await api().post('/api/v1/auth/logout').set('Cookie', cookie).expect(204);
|
|
await api().get('/api/v1/auth/me').set('Cookie', cookie).expect(401);
|
|
});
|
|
|
|
it('fence: every auth route is either a marked local flow or on the reviewed allowlist', () => {
|
|
// Routes that must stay reachable with local auth off — reviewed here.
|
|
const allowlist = new Set([
|
|
'registration', // signup-mode discovery; harmless metadata
|
|
'methods', // the login screen's discovery endpoint
|
|
'logout', // ending a session is not a credential flow
|
|
'me', // session introspection
|
|
'login', // OidcController: IdP redirect
|
|
'link', // OidcController: explicit identity linking
|
|
'callback', // OidcController: IdP return leg
|
|
]);
|
|
for (const controller of [AuthController, OidcController]) {
|
|
for (const name of Object.getOwnPropertyNames(controller.prototype)) {
|
|
if (name === 'constructor') continue;
|
|
const handler = controller.prototype[name as keyof typeof controller.prototype] as (
|
|
...args: unknown[]
|
|
) => unknown;
|
|
const path = Reflect.getMetadata(PATH_METADATA, handler) as string | undefined;
|
|
if (path === undefined) continue; // not a route
|
|
const marked = Reflect.getMetadata(LOCAL_CREDENTIAL_KEY, handler) === true;
|
|
expect(
|
|
marked || allowlist.has(path),
|
|
`${controller.name}.${name} (path "${path}") is neither @LocalCredentialFlow nor allowlisted`,
|
|
).toBe(true);
|
|
}
|
|
}
|
|
});
|
|
});
|