Add local username/password login as fallback when Microsoft login isn't available
Build and Deploy / version (push) Successful in 1s
Build and Deploy / build (push) Successful in 21s
Build and Deploy / deploy (push) Successful in 1s

Neues "users"-Table (bcrypt-Hash, nie Klartext), POST /api/auth/login gibt ein
selbst-signiertes JWT (HS256) aus. requireAuth unterscheidet MSAL- (RS256) und
lokale Tokens (HS256) anhand des alg-Headers. Server legt beim Start optional
ein erstes lokales Konto an, wenn SEED_ADMIN_USERNAME/SEED_ADMIN_PASSWORD als
Stack-Env gesetzt sind (idempotent, Klartext-Passwort landet nie im Repo).

Frontend: Login-Screen hat jetzt einen Alternativ-Link zu Benutzername/Passwort,
App.tsx kombiniert MSAL- und lokalen Auth-Status.

Temporärer test-build-only.yml Workflow zur Docker-Build-Verifikation ohne
Registry-Push (wird nach dem Test wieder entfernt).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Fidelis Huber
2026-08-21 17:56:40 +02:00
co-authored by Claude Sonnet 5
parent 7bdf4f2897
commit 97c4aa1c68
14 changed files with 263 additions and 32 deletions
+23 -2
View File
@@ -4,6 +4,7 @@ import jwksClient from 'jwks-rsa';
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
export const JWT_SECRET = process.env.JWT_SECRET ?? '';
const jwks = jwksClient({
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
@@ -23,8 +24,10 @@ export interface AuthedRequest extends Request {
user?: { email: string; name: string; oid: string };
}
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
// Zwei Anmeldearten werden akzeptiert:
// - Microsoft Entra ID (RS256, per JWKS von login.microsoftonline.com verifiziert)
// - Lokales Konto (HS256, mit unserem eigenen JWT_SECRET signiert, siehe routes/auth.ts)
// Unterschieden wird anhand des "alg" im (unverifizierten) Token-Header.
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
@@ -32,6 +35,24 @@ export function requireAuth(req: AuthedRequest, res: Response, next: NextFunctio
return;
}
const token = header.slice('Bearer '.length);
const unverifiedHeader = jwt.decode(token, { complete: true })?.header;
if (unverifiedHeader?.alg === 'HS256') {
if (!JWT_SECRET) {
res.status(500).json({ error: 'JWT_SECRET ist serverseitig nicht konfiguriert.' });
return;
}
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }, (err, decoded) => {
if (err || !decoded || typeof decoded === 'string') {
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
return;
}
const claims = decoded as jwt.JwtPayload & { username?: string; name?: string; oid?: string };
req.user = { email: claims.username ?? '', name: claims.name ?? claims.username ?? '', oid: claims.oid ?? '' };
next();
});
return;
}
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
if (err || !decoded || typeof decoded === 'string') {