feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
Backend Image / build-and-push (push) Successful in 2m28s
Frontend Image / build-and-push (push) Successful in 2m10s

- Profilseite mit MFA-Einrichtung, Passwort/E-Mail/Name aendern, Konto deaktivieren
- Bearbeiten-Buttons fuer Bienenstaende/Voelker/Koeniginnen/Aufgaben-Datensaetze
- Restliche englische Enum-Werte durch deutsche Labels ersetzt (SelectValue-Fix)
- Sidebar-Eintraege fuer Ernten/Schleuderungen ergaenzt
- Automatisierungen von 3 auf 6 Regeln erweitert (Schwarmstimmung, kritischer Gesundheitsstatus, Lagerbestand)
- Responsive Grid-Layout fuer Volk-/Koeniginnen-/Honigchargen-/Bienenstand-Detailseiten
- Etikettenvorlagen mit Platzhaltern inkl. QR-Code (verweist auf Herkunftsseite) und PDF-Druck
- Entra-ID-SSO (Authorization Code + PKCE, kein Auto-Provisioning, Secrets nur ueber Environment-Variablen)
This commit is contained in:
Nick Szaunig committed 2026-08-31 18:56:08 +02:00
1 parent 0a52b29c57
commit 8432313fa9
83 files changed
+2684 -289

No files matched your search

@@ -0,0 +1,158 @@
import {
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import type { Request, Response } from 'express';
import { Public } from '../decorators/public.decorator';
import { AuthService } from '../auth.service';
import { AccessTokenDto } from '../dto/access-token.dto';
import { SENSITIVE_ACTION_THROTTLE } from '../util/throttle';
import { setRefreshTokenCookie } from '../util/refresh-token-cookie';
import { SsoService } from './sso.service';
const PKCE_COOKIE = 'oidcPkceVerifier';
const PKCE_COOKIE_PATH = '/api/v1/auth/sso';
const PKCE_COOKIE_MAX_AGE_MS = 5 * 60 * 1000;
// Scoped to /complete only - the completion endpoint is the only place that
// ever needs to read it, and it's never sent on the wider /auth/sso/* path.
const SSO_CODE_COOKIE = 'oidcSsoCode';
const SSO_CODE_COOKIE_PATH = '/api/v1/auth/sso/complete';
const SSO_CODE_COOKIE_MAX_AGE_MS = 60 * 1000;
interface PkceCookiePayload {
codeVerifier: string;
state: string;
}
@ApiTags('auth')
@Controller('auth/sso')
export class SsoController {
constructor(
private readonly ssoService: SsoService,
private readonly authService: AuthService,
) {}
private frontendLoginUrl(): string {
return `${process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000'}/login`;
}
private cookieOptions(path: string, maxAge: number) {
return {
httpOnly: true,
secure: process.env.COOKIE_SECURE !== 'false',
sameSite: 'lax' as const,
path,
maxAge,
};
}
@Public()
@Get('status')
status(): { enabled: boolean } {
return { enabled: this.ssoService.isConfigured() };
}
@Public()
@Get('login')
async login(@Res() res: Response): Promise<void> {
const { redirectUrl, codeVerifier, state } =
await this.ssoService.buildAuthorizationRequest();
const payload: PkceCookiePayload = { codeVerifier, state };
res.cookie(
PKCE_COOKIE,
JSON.stringify(payload),
this.cookieOptions(PKCE_COOKIE_PATH, PKCE_COOKIE_MAX_AGE_MS),
);
res.redirect(redirectUrl);
}
@Public()
@Get('callback')
async callback(@Req() req: Request, @Res() res: Response): Promise<void> {
const rawCookie = (req.cookies as Record<string, string> | undefined)?.[
PKCE_COOKIE
];
res.clearCookie(PKCE_COOKIE, { path: PKCE_COOKIE_PATH });
const pkce = this.parsePkceCookie(rawCookie);
if (!pkce) {
res.redirect(`${this.frontendLoginUrl()}?ssoError=session_expired`);
return;
}
const callbackUrl = new URL(
req.originalUrl,
`${req.protocol}://${req.get('host')}`,
);
const result = await this.ssoService.handleCallback(
callbackUrl,
pkce.codeVerifier,
pkce.state,
);
if (result.status === 'error') {
res.redirect(`${this.frontendLoginUrl()}?ssoError=${result.reason}`);
return;
}
// The one-time completion code never touches the URL (history, server
// logs, Referer headers) - it travels only in an httpOnly cookie scoped
// to the /complete endpoint, which the frontend calls right after.
res.cookie(
SSO_CODE_COOKIE,
result.ssoCode,
this.cookieOptions(SSO_CODE_COOKIE_PATH, SSO_CODE_COOKIE_MAX_AGE_MS),
);
res.redirect(`${this.frontendLoginUrl()}?ssoPending=1`);
}
@Public()
@Throttle(SENSITIVE_ACTION_THROTTLE)
@Post('complete')
@HttpCode(HttpStatus.OK)
async complete(
@Req() req: Request,
@Res({ passthrough: true }) res: Response,
): Promise<AccessTokenDto> {
const ssoCode = (req.cookies as Record<string, string> | undefined)?.[
SSO_CODE_COOKIE
];
res.clearCookie(SSO_CODE_COOKIE, { path: SSO_CODE_COOKIE_PATH });
if (!ssoCode) {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
const { userId } = this.ssoService.verifySsoCode(ssoCode);
const { accessToken, refreshToken } =
await this.authService.issueTokensForSsoUser(userId);
setRefreshTokenCookie(res, refreshToken);
return { accessToken };
}
private parsePkceCookie(raw: string | undefined): PkceCookiePayload | null {
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as Partial<PkceCookiePayload>;
if (
typeof parsed.codeVerifier !== 'string' ||
typeof parsed.state !== 'string'
) {
return null;
}
return { codeVerifier: parsed.codeVerifier, state: parsed.state };
} catch {
return null;
}
}
}
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { UsersModule } from '../../users/users.module';
import { SsoService } from './sso.service';
// SsoController lives in AuthModule (not here) because it also depends on
// AuthService, which only AuthModule provides - keeping the controller here
// would require a circular import between AuthModule and SsoModule.
@Module({
imports: [JwtModule.register({}), UsersModule],
providers: [SsoService],
exports: [SsoService],
})
export class SsoModule {}
@@ -0,0 +1,209 @@
import { JwtService } from '@nestjs/jwt';
import {
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import * as client from 'openid-client';
import { SsoService } from './sso.service';
import { UsersService } from '../../users/users.service';
jest.mock('openid-client', () => ({
discovery: jest.fn(),
randomPKCECodeVerifier: jest.fn(),
calculatePKCECodeChallenge: jest.fn(),
randomState: jest.fn(),
buildAuthorizationUrl: jest.fn(),
authorizationCodeGrant: jest.fn(),
}));
const mockedClient = client as jest.Mocked<typeof client>;
describe('SsoService', () => {
let ssoService: SsoService;
let usersService: { findByEmail: jest.Mock };
let jwtService: { sign: jest.Mock; verify: jest.Mock };
const originalEnv = { ...process.env };
beforeEach(() => {
jest.clearAllMocks();
process.env.OIDC_ISSUER_URL =
'https://login.microsoftonline.com/tenant/v2.0';
process.env.OIDC_CLIENT_ID = 'client-id';
process.env.OIDC_CLIENT_SECRET = 'client-secret';
process.env.OIDC_REDIRECT_URI =
'https://api.example.com/api/v1/auth/sso/callback';
process.env.JWT_ACCESS_SECRET = 'unit-test-jwt-access-secret';
usersService = { findByEmail: jest.fn() };
jwtService = { sign: jest.fn(), verify: jest.fn() };
ssoService = new SsoService(
usersService as unknown as UsersService,
jwtService as unknown as JwtService,
);
});
afterEach(() => {
process.env = { ...originalEnv };
});
describe('isConfigured', () => {
it('returns true when all four OIDC variables are set', () => {
expect(ssoService.isConfigured()).toBe(true);
});
it('returns false when a variable is missing', () => {
delete process.env.OIDC_CLIENT_SECRET;
expect(ssoService.isConfigured()).toBe(false);
});
});
describe('buildAuthorizationRequest', () => {
it('throws ServiceUnavailableException when SSO is not configured', async () => {
delete process.env.OIDC_ISSUER_URL;
await expect(ssoService.buildAuthorizationRequest()).rejects.toThrow(
ServiceUnavailableException,
);
});
it('discovers the issuer once and returns the authorization URL with a PKCE verifier', async () => {
const config = {} as client.Configuration;
mockedClient.discovery.mockResolvedValue(config);
mockedClient.randomPKCECodeVerifier.mockReturnValue('verifier');
mockedClient.calculatePKCECodeChallenge.mockResolvedValue('challenge');
mockedClient.randomState.mockReturnValue('random-state');
mockedClient.buildAuthorizationUrl.mockReturnValue(
new URL(
'https://login.microsoftonline.com/tenant/oauth2/v2.0/authorize?x=1',
),
);
const result = await ssoService.buildAuthorizationRequest();
await ssoService.buildAuthorizationRequest();
expect(result.codeVerifier).toBe('verifier');
expect(result.state).toBe('random-state');
expect(result.redirectUrl).toContain('authorize');
expect(mockedClient.discovery).toHaveBeenCalledTimes(1);
expect(mockedClient.buildAuthorizationUrl).toHaveBeenCalledWith(
config,
expect.objectContaining({
redirect_uri: process.env.OIDC_REDIRECT_URI,
code_challenge: 'challenge',
code_challenge_method: 'S256',
state: 'random-state',
}),
);
});
});
describe('handleCallback', () => {
beforeEach(() => {
mockedClient.discovery.mockResolvedValue({} as client.Configuration);
});
it('returns exchange_failed when the token exchange throws', async () => {
mockedClient.authorizationCodeGrant.mockRejectedValue(
new Error('bad code'),
);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'exchange_failed' });
});
it('returns no_email when the ID token has neither email nor preferred_username', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ sub: 'abc' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_email' });
});
it('returns no_account when no active local user matches the email claim', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ email: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue(null);
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'error', reason: 'no_account' });
});
it('signs a short-lived sso-complete token for an active matching user', async () => {
mockedClient.authorizationCodeGrant.mockResolvedValue({
claims: () => ({ preferred_username: '[email protected]' }),
} as unknown as Awaited<
ReturnType<typeof client.authorizationCodeGrant>
>);
usersService.findByEmail.mockResolvedValue({
id: 'user-1',
isActive: true,
});
jwtService.sign.mockReturnValue('signed-sso-code');
const result = await ssoService.handleCallback(
new URL('https://api.example.com/callback?code=x'),
'verifier',
'random-state',
);
expect(result).toEqual({ status: 'success', ssoCode: 'signed-sso-code' });
expect(mockedClient.authorizationCodeGrant).toHaveBeenCalledWith(
{},
expect.any(URL),
{ pkceCodeVerifier: 'verifier', expectedState: 'random-state' },
);
expect(jwtService.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: 'user-1', purpose: 'sso-complete' }),
expect.objectContaining({ expiresIn: '60s' }),
);
});
});
describe('verifySsoCode', () => {
it('returns the userId for a valid sso-complete token', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'sso-complete',
});
expect(ssoService.verifySsoCode('token')).toEqual({ userId: 'user-1' });
});
it('throws UnauthorizedException when verification fails', () => {
jwtService.verify.mockImplementation(() => {
throw new Error('expired');
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
it('throws UnauthorizedException when the token purpose does not match', () => {
jwtService.verify.mockReturnValue({
sub: 'user-1',
purpose: 'mfa-challenge',
});
expect(() => ssoService.verifySsoCode('token')).toThrow(
UnauthorizedException,
);
});
});
});
+138
View File
@@ -0,0 +1,138 @@
import {
Injectable,
Logger,
ServiceUnavailableException,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as client from 'openid-client';
import { UsersService } from '../../users/users.service';
const SSO_COMPLETE_TOKEN_TTL = '60s';
const SSO_COMPLETE_TOKEN_PURPOSE = 'sso-complete';
interface SsoCompleteTokenPayload {
sub: string;
purpose: typeof SSO_COMPLETE_TOKEN_PURPOSE;
}
export type SsoCallbackResult =
| { status: 'success'; ssoCode: string }
| { status: 'error'; reason: 'exchange_failed' | 'no_email' | 'no_account' };
@Injectable()
export class SsoService {
private readonly logger = new Logger(SsoService.name);
private config: client.Configuration | undefined;
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService,
) {}
isConfigured(): boolean {
return Boolean(
process.env.OIDC_ISSUER_URL &&
process.env.OIDC_CLIENT_ID &&
process.env.OIDC_CLIENT_SECRET &&
process.env.OIDC_REDIRECT_URI,
);
}
private async getConfig(): Promise<client.Configuration> {
if (!this.isConfigured()) {
throw new ServiceUnavailableException(
'Microsoft-Anmeldung ist nicht konfiguriert',
);
}
if (!this.config) {
this.config = await client.discovery(
new URL(process.env.OIDC_ISSUER_URL as string),
process.env.OIDC_CLIENT_ID as string,
process.env.OIDC_CLIENT_SECRET,
);
}
return this.config;
}
async buildAuthorizationRequest(): Promise<{
redirectUrl: string;
codeVerifier: string;
state: string;
}> {
const config = await this.getConfig();
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const redirectUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.OIDC_REDIRECT_URI as string,
scope: 'openid email profile',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
});
return { redirectUrl: redirectUrl.href, codeVerifier, state };
}
async handleCallback(
callbackUrl: URL,
codeVerifier: string,
state: string,
): Promise<SsoCallbackResult> {
const config = await this.getConfig();
let tokens: Awaited<ReturnType<typeof client.authorizationCodeGrant>>;
try {
tokens = await client.authorizationCodeGrant(config, callbackUrl, {
pkceCodeVerifier: codeVerifier,
expectedState: state,
});
} catch (error) {
this.logger.warn(
`SSO callback token exchange failed: ${(error as Error).message}`,
);
return { status: 'error', reason: 'exchange_failed' };
}
const claims = tokens.claims();
const email =
(claims?.email as string | undefined) ??
(claims?.preferred_username as string | undefined);
if (!email) {
this.logger.warn(
'SSO callback succeeded but ID token had no email claim',
);
return { status: 'error', reason: 'no_email' };
}
const user = await this.usersService.findByEmail(email);
if (!user || !user.isActive) {
this.logger.warn(
`SSO login denied: no active local account for ${email}`,
);
return { status: 'error', reason: 'no_account' };
}
const ssoCode = this.jwtService.sign(
{ sub: user.id, purpose: SSO_COMPLETE_TOKEN_PURPOSE },
{
secret: process.env.JWT_ACCESS_SECRET,
expiresIn: SSO_COMPLETE_TOKEN_TTL,
},
);
return { status: 'success', ssoCode };
}
verifySsoCode(ssoCode: string): { userId: string } {
let payload: SsoCompleteTokenPayload;
try {
payload = this.jwtService.verify<SsoCompleteTokenPayload>(ssoCode, {
secret: process.env.JWT_ACCESS_SECRET,
});
} catch {
throw new UnauthorizedException(
'SSO-Anmeldung abgelaufen, bitte erneut versuchen',
);
}
if (payload.purpose !== SSO_COMPLETE_TOKEN_PURPOSE) {
throw new UnauthorizedException('Ungültiger Token');
}
return { userId: payload.sub };
}
}