#216: hard AUTH_LOCAL_ENABLED switch over every local credential flow #284

Merged
fable-5 merged 1 commits from issue-216-local-auth-switch into main 2026-07-31 13:35:55 +02:00
12 changed files with 332 additions and 64 deletions

View File

@ -21,6 +21,7 @@ import { InstanceSettingsService } from '../settings/instance-settings.service';
import { SetupExempt } from '../setup/setup.guard'; import { SetupExempt } from '../setup/setup.guard';
import { import {
AuthedRequest, AuthedRequest,
LocalCredentialFlow,
Public, Public,
SESSION_COOKIE, SESSION_COOKIE,
setSessionCookie, setSessionCookie,
@ -55,13 +56,14 @@ export class AuthController {
@Get('methods') @Get('methods')
methods(): AuthMethodsView { methods(): AuthMethodsView {
return { return {
local: true, local: this.config.env.AUTH_LOCAL_ENABLED,
oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null, oidc: this.oidc.enabled ? { label: this.oidc.providerLabel } : null,
}; };
} }
@Public() @Public()
@Post('signup') @Post('signup')
@LocalCredentialFlow()
@HttpCode(201) @HttpCode(201)
@RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'signup', limit: 5, windowSeconds: 60 * 60 })
async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> { async signup(@Body(new ZodValidationPipe(signupInputSchema)) input: SignupInput): Promise<void> {
@ -70,6 +72,7 @@ export class AuthController {
@Public() @Public()
@Post('verify-email') @Post('verify-email')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'verify-email', limit: 20, windowSeconds: 60 * 60 })
async verifyEmail( async verifyEmail(
@ -80,6 +83,7 @@ export class AuthController {
@Public() @Public()
@Post('resend-verification') @Post('resend-verification')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'resend-verification', limit: 5, windowSeconds: 60 * 60 })
async resendVerification( async resendVerification(
@ -93,6 +97,7 @@ export class AuthController {
@SetupExempt() @SetupExempt()
@Public() @Public()
@Post('login') @Post('login')
@LocalCredentialFlow()
@HttpCode(200) @HttpCode(200)
@RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 }) @RateLimit({ scope: 'login', limit: 10, windowSeconds: 60 })
async login( async login(
@ -136,6 +141,7 @@ export class AuthController {
@Public() @Public()
@Post('forgot-password') @Post('forgot-password')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'forgot-password', limit: 5, windowSeconds: 60 * 60 })
async forgotPassword( async forgotPassword(
@ -146,6 +152,7 @@ export class AuthController {
@Public() @Public()
@Post('reset-password') @Post('reset-password')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
@RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 }) @RateLimit({ scope: 'reset-password', limit: 10, windowSeconds: 60 * 60 })
async resetPassword( async resetPassword(

View File

@ -3,6 +3,7 @@ import {
ExecutionContext, ExecutionContext,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException,
SetMetadata, SetMetadata,
UnauthorizedException, UnauthorizedException,
createParamDecorator, createParamDecorator,
@ -22,6 +23,18 @@ const IS_PUBLIC_KEY = 'isPublic';
/** Marks a route as reachable without a session (login, signup, healthz…). */ /** Marks a route as reachable without a session (login, signup, healthz…). */
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true); export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(IS_PUBLIC_KEY, true);
export const LOCAL_CREDENTIAL_KEY = 'isLocalCredentialFlow';
/**
* Marks a route as part of the LOCAL credential machinery (issue #216,
* ADR 0021): password login, signup, e-mail verification, password
* forgot/reset/change. With `AUTH_LOCAL_ENABLED=false` every marked route
* answers 404 (existence hidden, the switch precedent) and the
* enumeration fence in `local-auth-switch.e2e.db.test.ts` fails when an
* auth route is neither marked nor on its reviewed allowlist, so a new
* credential flow cannot ship unswitched by accident.
*/
export const LocalCredentialFlow = (): MethodDecorator => SetMetadata(LOCAL_CREDENTIAL_KEY, true);
export interface AuthedRequest extends Request { export interface AuthedRequest extends Request {
user?: User; user?: User;
sessionId?: string; sessionId?: string;
@ -90,6 +103,17 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AuthedRequest>(); const request = context.switchToHttp().getRequest<AuthedRequest>();
// The hard local-auth switch (issue #216): marked credential routes
// disappear entirely — before any session or CSRF logic runs.
if (!this.config.env.AUTH_LOCAL_ENABLED) {
const isLocalFlow = this.reflector.getAllAndOverride<boolean>(LOCAL_CREDENTIAL_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isLocalFlow) throw new NotFoundException();
}
const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE]; const rawToken = (request.cookies as Record<string, string> | undefined)?.[SESSION_COOKIE];
if (rawToken && MUTATING_METHODS.has(request.method)) { if (rawToken && MUTATING_METHODS.has(request.method)) {

View File

@ -1,6 +1,8 @@
import { Module } from '@nestjs/common'; import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { AppConfig } from '../config/app-config.service';
import { MailModule } from '../mail/mail.module'; import { MailModule } from '../mail/mail.module';
import { PondsModule } from '../ponds/ponds.module'; import { PondsModule } from '../ponds/ponds.module';
import { UsersModule } from '../users/users.module'; import { UsersModule } from '../users/users.module';
@ -27,4 +29,21 @@ import { SessionsModule } from './sessions.module';
], ],
exports: [AuthTokensService, AuthService, OidcService], exports: [AuthTokensService, AuthService, OidcService],
}) })
export class AuthModule {} export class AuthModule implements OnModuleInit {
constructor(
private readonly config: AppConfig,
private readonly oidc: OidcService,
private readonly proxyIdentity: ProxyIdentityService,
) {}
onModuleInit(): void {
// #216: local auth off without ANY external path means nobody can ever
// sign in — loudly stated at boot, because the operator will otherwise
// discover it at the login screen.
if (!this.config.env.AUTH_LOCAL_ENABLED && !this.oidc.enabled && !this.proxyIdentity.enabled) {
new Logger(AuthModule.name).warn(
'AUTH_LOCAL_ENABLED=false with neither OIDC nor proxy authentication configured — no sign-in path exists',
);
}
}
}

View File

@ -0,0 +1,157 @@
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);
}
}
});
});

View File

@ -17,7 +17,7 @@ import {
updateProfileInputSchema, updateProfileInputSchema,
} from '@dorfteich/shared'; } from '@dorfteich/shared';
import { AuthedRequest, toCurrentUser } from '../auth/auth.guard'; import { AuthedRequest, toCurrentUser, LocalCredentialFlow } from '../auth/auth.guard';
import { SessionsService } from '../auth/sessions.service'; import { SessionsService } from '../auth/sessions.service';
import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { ZodValidationPipe } from '../common/zod-validation.pipe';
import { AuthenticatedOnly } from '../permissions/permission.decorators'; import { AuthenticatedOnly } from '../permissions/permission.decorators';
@ -58,6 +58,7 @@ export class UsersController {
} }
@Post('change-password') @Post('change-password')
@LocalCredentialFlow()
@HttpCode(204) @HttpCode(204)
async changePassword( async changePassword(
@Body(new ZodValidationPipe(changePasswordInputSchema)) @Body(new ZodValidationPipe(changePasswordInputSchema))

View File

@ -68,17 +68,27 @@ export function LoginPage(): React.JSX.Element {
<a className="button button--block" href="/api/v1/auth/oidc/login"> <a className="button button--block" href="/api/v1/auth/oidc/login">
{t('auth:oidc.signIn', { provider: methods.data.oidc.label })} {t('auth:oidc.signIn', { provider: methods.data.oidc.label })}
</a> </a>
{methods.data.local && (
<p className="auth-card__separator" aria-hidden="true"> <p className="auth-card__separator" aria-hidden="true">
{t('auth:oidc.or')} {t('auth:oidc.or')}
</p> </p>
)}
</> </>
)} )}
{/* AUTH_LOCAL_ENABLED=false (#216): the local form and its
credential links disappear the api answers 404 there anyway. */}
{(methods.data?.local ?? true) && (
<>
<form onSubmit={onSubmit} noValidate> <form onSubmit={onSubmit} noValidate>
<FormError error={error} /> <FormError error={error} />
{unverified && !resent && ( {unverified && !resent && (
<p className="form-banner"> <p className="form-banner">
{t('auth:login.resendHint')}{' '} {t('auth:login.resendHint')}{' '}
<button type="button" className="linklike" onClick={() => void resendVerification()}> <button
type="button"
className="linklike"
onClick={() => void resendVerification()}
>
{t('auth:login.resendLink')} {t('auth:login.resendLink')}
</button> </button>
</p> </p>
@ -91,7 +101,11 @@ export function LoginPage(): React.JSX.Element {
<input type="text" autoComplete="username" {...form.register('usernameOrEmail')} /> <input type="text" autoComplete="username" {...form.register('usernameOrEmail')} />
</Field> </Field>
<Field label={t('auth:login.password')} error={form.formState.errors.password?.message}> <Field label={t('auth:login.password')} error={form.formState.errors.password?.message}>
<input type="password" autoComplete="current-password" {...form.register('password')} /> <input
type="password"
autoComplete="current-password"
{...form.register('password')}
/>
</Field> </Field>
<button type="submit" className="button" disabled={form.formState.isSubmitting}> <button type="submit" className="button" disabled={form.formState.isSubmitting}>
{t('auth:login.submit')} {t('auth:login.submit')}
@ -103,6 +117,8 @@ export function LoginPage(): React.JSX.Element {
<p className="auth-card__links"> <p className="auth-card__links">
{t('auth:login.noAccount')} <Link to="/signup">{t('auth:login.signupLink')}</Link> {t('auth:login.noAccount')} <Link to="/signup">{t('auth:login.signupLink')}</Link>
</p> </p>
</>
)}
</div> </div>
); );
} }

View File

@ -81,6 +81,25 @@ expect the application to trust a header or a client certificate.
terminator forwards the certificate subject DN and the configured terminator forwards the certificate subject DN and the configured
attribute (default CN) is the identity, under the same peer rules. attribute (default CN) is the identity, under the same peer rules.
## Decisions taken in #216
- **Deploy-level, not runtime**: the switch is the environment variable
`AUTH_LOCAL_ENABLED` (default true). A compromised Site Admin cannot
reopen the local path — the runtime-flip residual risk from the
consequences below therefore does NOT materialize (R-02 closed).
- **404 semantics** on every marked flow (login, signup, verification,
resend, password forgot/reset/change), enforced centrally in the auth
guard via the `@LocalCredentialFlow()` marker; an enumeration fence
fails when an auth route is neither marked nor on the reviewed
allowlist, so a new credential flow cannot ship unswitched.
- **Bootstrap**: complete the first-run setup (or the `SETUP_ADMIN_*`
pre-seed, which does not run through HTTP routes) BEFORE flipping to
false — the wizard needs no permanent exemption. The api warns at boot
when local auth is off and neither OIDC nor proxy auth is configured.
- **PAT and feed-token issuance stay available** to (IdP-)authenticated
sessions: they authorize API access under their own switches
(`api.enabled`, `feeds.enabled`), they are not interactive sign-in.
## Consequences ## Consequences
- Bootstrapping needs a documented answer: the first-run wizard creates a - Bootstrapping needs a documented answer: the first-run wizard creates a

View File

@ -73,6 +73,14 @@ or sloppy plugin authors, compromised dependencies.
is deliberately NOT implemented: sessions are short-bounded, and the is deliberately NOT implemented: sessions are short-bounded, and the
claim-mapping revocation path (#217) plus the account-disable flag claim-mapping revocation path (#217) plus the account-disable flag
cover the leaver case — recorded in ADR 0021. cover the leaver case — recorded in ADR 0021.
- **The hard local-auth switch (issue #216)**: `AUTH_LOCAL_ENABLED=false`
closes every local credential flow with 404 — login, signup, e-mail
verification, resend, password forgot/reset/change — enforced centrally
in the auth guard via a route marker with an enumeration fence.
Deploy-level on purpose (a compromised Site Admin cannot flip it back).
Sessions, logout and PAT/feed-token issuance for externally
authenticated users keep working; stored password hashes remain
(documented, ADR 0021). Bootstrap: complete setup before flipping.
- **Trusted-proxy / mTLS path (issue #215)** — for perimeters that - **Trusted-proxy / mTLS path (issue #215)** — for perimeters that
authenticate before the application. **The trust boundary, precisely:** authenticate before the application. **The trust boundary, precisely:**
the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if the identity header (`AUTH_PROXY_HEADER`) is honoured if and only if

View File

@ -40,7 +40,7 @@ _Meilenstein: `M27 — VS-NfD: external authentication`_
ausbauen), Keycloak als Referenz-IdP · 56 AT · #214 ausbauen), Keycloak als Referenz-IdP · 56 AT · #214
- [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS- - [x] Alternativpfad vertrauenswürdiger Reverse-Proxy-Header bzw. mTLS-
Client-Zertifikat · 2 AT · #215 Client-Zertifikat · 2 AT · #215
- [ ] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und - [x] **Harter Schalter `auth.local.enabled = false`** inkl. Reset- und
Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216 Registrierungs-Flows, PATs und Feed-Tokens · 2 AT · #216
- [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 23 AT · #217 - [ ] Gruppen-/Rollen-Mapping aus IdP-Claims auf das Permission-Modell · 23 AT · #217

View File

@ -47,7 +47,7 @@ Settings-Cache ist in-process (operations.md).
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)
| Variable | Referenzwert | Warum | | Variable | Referenzwert | Warum |
| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). | | `BACKUP_ALLOWED_TARGETS` | leer lassen **oder** exakt der eine freigegebene Spiegel-Host | leere Allowlist schaltet ALLE Fernziele hart ab (ADR 0026, #192) — „Backup nur lokal" ist damit deploy-seitig erzwungen und vom Site-Admin nicht aufweichbar (Rollentrennung, Betriebshandbuch §6). |
| `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. | | `SESSION_ABSOLUTE_HOURS` | `12` (Default 168) | eine Sitzung überdauert keinen Arbeitstag; Neuanmeldung am nächsten Tag ist der Preis. |
| `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. | | `SESSION_IDLE_HOURS` | `2` (Default 72) | unbeaufsichtigte, noch angemeldete Arbeitsplätze fallen schnell zurück auf die Anmeldemaske. |
@ -56,12 +56,14 @@ Settings-Cache ist in-process (operations.md).
| `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. | | `LOG_LEVEL` | `info` | Audit-Zeilen (`audit: `-Präfix) müssen den Collector erreichen; `debug` nur zur Störungssuche. |
| `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. | | `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_SCOPES`, `OIDC_PROVIDER_LABEL` | IdP der Behörde konfigurieren | Fremdauthentisierung (#214, ADR 0021): Authorization Code + PKCE gegen den IdP der Umgebung; deploy-seitig, weil die Authentisierungshoheit Plattformsache ist. Erst-Login legt Konten just-in-time an; bestehende lokale Konten werden NIE stillschweigend per E-Mail übernommen (expliziter Link-Flow). Konfigurationsdetails: security.md §External authentication. |
| `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). | | `AUTH_PROXY_HEADER`, `AUTH_PROXY_TRUSTED_PEERS`, `AUTH_PROXY_MAP`, `AUTH_PROXY_MODE`, `AUTH_PROXY_DN_ATTRIBUTE` | nur bei Perimeter-Authentisierung setzen | Alternativpfad (#215): Identität aus dem Proxy-Header, gültig NUR vom TCP-Peer der Allowlist; fremder Peer mit Header wird abgewiesen und auditiert (`auth.proxy_rejected`). Der Proxy MUSS den Header aus eingehendem Verkehr strippen. mTLS-Variante über weitergereichten Zertifikats-DN (`mtls-dn`). Ohne Perimeter-Auth: unkonfiguriert lassen (Header wirkungslos). |
| `AUTH_LOCAL_ENABLED` | `false` (Default `true`) | **der** harte Schalter aus ADR 0021 (#216, deploy-seitige Realisierung von `auth.local.enabled`): `false` schaltet JEDEN lokalen Credential-Flow auf 404 (Login, Signup, Verifikation, Passwort vergessen/zuruecksetzen/aendern) — Anmeldung ausschliesslich ueber OIDC (#214) bzw. Perimeter-Auth (#215). Deploy-seitig, damit ein kompromittierter Site-Admin ihn nicht zurueckdrehen kann. Reihenfolge: erst Setup/Bootstrap (SETUP_ADMIN_*), dann auf `false`. PAT-/Feed-Token-Ausgabe bleibt fuer IdP-authentisierte Nutzer verfuegbar (eigene Schalter `api.enabled`/`feeds.enabled`; bewusste Entscheidung #216). |
### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR) ### 1.3 Noch nicht verfügbar (Regel: landet hier im selben PR)
Derzeit leer — `auth.local.enabled` ist mit #216 als `AUTH_LOCAL_ENABLED` (1.2) scharfgestellt.
| Schalter | Referenzwert (geplant) | Status | | Schalter | Referenzwert (geplant) | Status |
| -------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | ---------------------- | ------ |
| `auth.local.enabled` | `false` — lokale Passwort-Auth aus, Anmeldung nur über die Fremdauthentisierung der Behörde | ⏳ kommt mit #216 (M27); bis dahin bleibt lokale Auth der einzige Anmeldeweg und `auth.registrationMode=closed` + Session-Verkürzung sind die Kompensation. Zeile wird im #216-PR scharfgestellt. |
## 2 Verifikations-Checkliste ## 2 Verifikations-Checkliste
@ -73,6 +75,7 @@ curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/public/v1/ponds # 40
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false) curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/mcp # 404 (mcp.enabled=false)
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false) curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/public/IRGENDEIN-TEICH/feed.xml # 404 (feeds.enabled=false)
curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session curl -s -o /dev/null -w '%{http_code}\n' https://HOST/api/v1/admin/plugins # 401/404, nie 200 ohne Session
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://HOST/api/v1/auth/login # 404 (AUTH_LOCAL_ENABLED=false)
curl -s https://HOST/api/v1/readyz # status ok curl -s https://HOST/api/v1/readyz # status ok
``` ```

View File

@ -33,21 +33,21 @@ nachvollziehbar.
Empfänger. Empfänger.
- **Entscheidung:** Projektleitung, PR #271 / Issue #212, 31.07.2026. - **Entscheidung:** Projektleitung, PR #271 / Issue #212, 31.07.2026.
## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar ## R-02 Lokale Passwort-Authentifizierung noch nicht abschaltbar — ERLEDIGT (#216, 31.07.2026)
- **Risiko:** Bis #216 (M27) existiert kein Schalter - **Status: geschlossen.** Der harte Schalter existiert als Deploy-Variable
`auth.local.enabled=false`; die Anwendung führt eigene `AUTH_LOCAL_ENABLED=false` (#214#216, ADR 0021): jeder lokale
Passwort-Konten, obwohl die Behördenumgebung Fremdauthentisierung Credential-Flow (Login, Signup, Verifikation, Passwort
vorsieht. Zusätzlich ist noch offen, ob der Schalter zur Laufzeit vergessen/zurücksetzen/ändern) antwortet 404; Anmeldung läuft über
umschaltbar sein wird oder einen Neustart verlangt — das entscheidet OIDC (#214) bzw. Perimeter-Auth (#215). Die im Risiko offene Frage
#216 und trägt es hier nach. „Laufzeit oder Deploy-Ebene" ist zugunsten der Deploy-Ebene
- **Warum akzeptiert:** Reihenfolge der Umsetzung (M26 vor M27); entschieden — ein kompromittierter Site-Admin kann den lokalen Pfad
produktiver VS-NfD-Betrieb beginnt erst nach M27. nicht wieder öffnen; damit entsteht KEIN Laufzeit-Restrisiko.
- **Kompensation:** Referenzkonfiguration (`50-haertungsleitfaden.md`): Verbleibender Hinweis (dokumentiert, ADR 0021): bestehende
Registrierung geschlossen, kurze Sessions (12 h absolut / 2 h idle), Argon2id-Passwort-Hashes bleiben nach dem Umschalten in der Datenbank
Argon2id-Hashes, Rate-Limits, Audit der Anmeldungen. stehen; ihre Löschung ist bewusst nicht Teil von #216.
- **Entscheidung:** Projektleitung, Maßnahmenplan Rev. 2 (M27-Planung), - **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
30.07.2026. Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026).
## R-03 Plugin-Hash-Pinning verschoben ## R-03 Plugin-Hash-Pinning verschoben

View File

@ -177,6 +177,20 @@ export const apiEnvSchema = z.object({
* header); a request carrying the header from any other peer is * header); a request carrying the header from any other peer is
* rejected and audited. * rejected and audited.
*/ */
/**
* The hard local-authentication switch (ADR 0021, issue #216) the
* deploy-level realization of the planned `auth.local.enabled`. FALSE
* closes EVERY local credential flow with 404 (login, signup, e-mail
* verification, resend, password forgot/reset/change); authentication
* then comes exclusively from OIDC (#214) or the trusted proxy (#215).
* Deploy-level on purpose: a compromised Site Admin must not be able to
* reopen the local path at runtime. Bootstrap order: complete the
* first-run setup (or SETUP_ADMIN_* pre-seed) BEFORE flipping to false.
*/
AUTH_LOCAL_ENABLED: z
.enum(['true', 'false'])
.default('true')
.transform((value) => value === 'true'),
AUTH_PROXY_HEADER: z.string().min(1).optional(), AUTH_PROXY_HEADER: z.string().min(1).optional(),
AUTH_PROXY_TRUSTED_PEERS: z AUTH_PROXY_TRUSTED_PEERS: z
.string() .string()