feat: Profilseite, Etikettendruck mit QR-Code, mehr Automatisierungen und Entra-ID-SSO
- 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:
1 parent
0a52b29c57
commit
8432313fa9
83 files changed
+2684
-289
No files matched your search
@@ -7,7 +7,6 @@ import {
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
@@ -19,49 +18,23 @@ import { LoginDto } from './dto/login.dto';
|
||||
import { AccessTokenDto } from './dto/access-token.dto';
|
||||
import { MfaVerifyDto } from './dto/mfa-verify.dto';
|
||||
import { AuthService, CurrentUserProfile } from './auth.service';
|
||||
import { parseDurationToMs } from './util/duration';
|
||||
import { SENSITIVE_ACTION_THROTTLE } from './util/throttle';
|
||||
import {
|
||||
clearRefreshTokenCookie,
|
||||
extractRefreshToken,
|
||||
setRefreshTokenCookie,
|
||||
} from './util/refresh-token-cookie';
|
||||
|
||||
interface MfaRequiredResponse {
|
||||
mfaRequired: true;
|
||||
mfaToken: string;
|
||||
}
|
||||
|
||||
const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
@ApiTags('auth')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
private setRefreshTokenCookie(res: Response, refreshToken: string): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
httpOnly: true,
|
||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||
// over plaintext HTTP.
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
sameSite: 'lax',
|
||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||
});
|
||||
}
|
||||
|
||||
private clearRefreshTokenCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||
}
|
||||
|
||||
private extractRefreshToken(req: Request): string {
|
||||
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
||||
REFRESH_TOKEN_COOKIE
|
||||
];
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Throttle(SENSITIVE_ACTION_THROTTLE)
|
||||
@Post('login')
|
||||
@@ -74,7 +47,7 @@ export class AuthController {
|
||||
if (result.mfaRequired) {
|
||||
return { mfaRequired: true, mfaToken: result.mfaToken };
|
||||
}
|
||||
this.setRefreshTokenCookie(res, result.refreshToken);
|
||||
setRefreshTokenCookie(res, result.refreshToken);
|
||||
return { accessToken: result.accessToken };
|
||||
}
|
||||
|
||||
@@ -88,7 +61,7 @@ export class AuthController {
|
||||
): Promise<AccessTokenDto> {
|
||||
const { accessToken, refreshToken } =
|
||||
await this.authService.verifyMfaAndLogin(dto.mfaToken, dto.code);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@@ -101,9 +74,9 @@ export class AuthController {
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AccessTokenDto> {
|
||||
const { accessToken, refreshToken } = await this.authService.refresh(
|
||||
this.extractRefreshToken(req),
|
||||
extractRefreshToken(req),
|
||||
);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
setRefreshTokenCookie(res, refreshToken);
|
||||
return { accessToken };
|
||||
}
|
||||
|
||||
@@ -114,8 +87,8 @@ export class AuthController {
|
||||
@Req() req: Request,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<void> {
|
||||
await this.authService.logout(this.extractRefreshToken(req));
|
||||
this.clearRefreshTokenCookie(res);
|
||||
await this.authService.logout(extractRefreshToken(req));
|
||||
clearRefreshTokenCookie(res);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
|
||||
@@ -6,10 +6,18 @@ import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||
import { MfaModule } from './mfa/mfa.module';
|
||||
import { SsoModule } from './sso/sso.module';
|
||||
import { SsoController } from './sso/sso.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PassportModule, JwtModule.register({}), UsersModule, MfaModule],
|
||||
controllers: [AuthController],
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.register({}),
|
||||
UsersModule,
|
||||
MfaModule,
|
||||
SsoModule,
|
||||
],
|
||||
controllers: [AuthController, SsoController],
|
||||
providers: [AuthService, JwtStrategy],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -229,6 +229,33 @@ describe('AuthService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('issueTokensForSsoUser', () => {
|
||||
it('issues tokens and records LOGIN for an active user', async () => {
|
||||
usersService.findById.mockResolvedValue(activeUser);
|
||||
prisma.refreshToken.create.mockResolvedValue({});
|
||||
|
||||
const result = await authService.issueTokensForSsoUser(activeUser.id);
|
||||
|
||||
expect(result.accessToken).toBe('signed.jwt.token');
|
||||
expect(prisma.refreshToken.create).toHaveBeenCalled();
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'LOGIN', userId: activeUser.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when the user is deactivated', async () => {
|
||||
usersService.findById.mockResolvedValue({
|
||||
...activeUser,
|
||||
isActive: false,
|
||||
});
|
||||
|
||||
await expect(
|
||||
authService.issueTokensForSsoUser(activeUser.id),
|
||||
).rejects.toBeInstanceOf(UnauthorizedException);
|
||||
expect(prisma.refreshToken.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh', () => {
|
||||
it('throws when the refresh token is unknown', async () => {
|
||||
prisma.refreshToken.findUnique.mockResolvedValue(null);
|
||||
|
||||
@@ -144,6 +144,26 @@ export class AuthService {
|
||||
return { mfaRequired: false, accessToken, refreshToken };
|
||||
}
|
||||
|
||||
/** Issues our own access/refresh token pair for a user already authenticated via SSO. */
|
||||
async issueTokensForSsoUser(userId: string): Promise<AuthTokens> {
|
||||
const user = await this.usersService.findById(userId);
|
||||
if (!user.isActive) {
|
||||
throw new UnauthorizedException('Invalid credentials');
|
||||
}
|
||||
const accessToken = this.signAccessToken({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
const refreshToken = await this.issueRefreshToken(user.id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'AuthSession',
|
||||
entityId: user.id,
|
||||
action: 'LOGIN',
|
||||
userId: user.id,
|
||||
});
|
||||
return { accessToken, refreshToken };
|
||||
}
|
||||
|
||||
async verifyMfaAndLogin(mfaToken: string, code: string): Promise<AuthTokens> {
|
||||
const { userId } = this.mfaService.verifyChallengeToken(mfaToken);
|
||||
const user = await this.usersService.findById(userId);
|
||||
|
||||
@@ -194,6 +194,18 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
1600,
|
||||
),
|
||||
...group(
|
||||
'Etiketten',
|
||||
'label-templates',
|
||||
[
|
||||
['read', 'Ansehen'],
|
||||
['create', 'Anlegen'],
|
||||
['update', 'Bearbeiten'],
|
||||
['deactivate', 'Deaktivieren'],
|
||||
['print', 'Drucken'],
|
||||
],
|
||||
1650,
|
||||
),
|
||||
...group(
|
||||
'Inventar',
|
||||
'equipment',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { parseDurationToMs } from './duration';
|
||||
|
||||
export const REFRESH_TOKEN_COOKIE = 'refreshToken';
|
||||
export const REFRESH_TOKEN_COOKIE_PATH = '/api/v1/auth';
|
||||
|
||||
export function setRefreshTokenCookie(
|
||||
res: Response,
|
||||
refreshToken: string,
|
||||
): void {
|
||||
res.cookie(REFRESH_TOKEN_COOKIE, refreshToken, {
|
||||
httpOnly: true,
|
||||
// Explicit opt-out for local HTTP dev only; every real deployment must
|
||||
// set COOKIE_SECURE=true so the refresh-token cookie is never sent
|
||||
// over plaintext HTTP.
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
sameSite: 'lax',
|
||||
path: REFRESH_TOKEN_COOKIE_PATH,
|
||||
maxAge: parseDurationToMs(process.env.JWT_REFRESH_EXPIRES_IN ?? '7d'),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearRefreshTokenCookie(res: Response): void {
|
||||
res.clearCookie(REFRESH_TOKEN_COOKIE, { path: REFRESH_TOKEN_COOKIE_PATH });
|
||||
}
|
||||
|
||||
export function extractRefreshToken(req: Request): string {
|
||||
const refreshToken = (req.cookies as Record<string, string> | undefined)?.[
|
||||
REFRESH_TOKEN_COOKIE
|
||||
];
|
||||
if (!refreshToken) {
|
||||
throw new UnauthorizedException('Missing refresh token');
|
||||
}
|
||||
return refreshToken;
|
||||
}
|
||||
Reference in new issue
Block a user