This commit is contained in:
Bastian Wagner
2026-07-20 09:01:36 +02:00
parent 8cf57d7878
commit e62673ac11
98 changed files with 17372 additions and 80 deletions

View File

@@ -17,8 +17,8 @@ export class AuthController {
@Public()
@SensitiveRateLimit()
@Redirect()
async login() {
return { url: await this.auth.createLoginUrl() };
async login(@Query('returnTo') returnTo?: string) {
return { url: await this.auth.createLoginUrl(returnTo) };
}
@Get('callback')
@@ -30,7 +30,7 @@ export class AuthController {
@Req() req: AuthenticatedRequest,
@Res() res: Response,
) {
const { session, csrfToken } = await this.auth.completeLogin(
const { session, csrfToken, returnPath } = await this.auth.completeLogin(
code,
state,
req.get('user-agent'),
@@ -51,7 +51,9 @@ export class AuthController {
path: '/',
expires: session.absoluteExpiresAt,
});
res.redirect(this.config.frontendBaseUrl);
res.redirect(
new URL(returnPath ?? '/', this.config.frontendBaseUrl).toString(),
);
}
@Get('logout')

View File

@@ -9,6 +9,50 @@ import type { OidcLoginStateEntity } from './entities/oidc-login-state.entity';
import { AuthService } from './auth.service';
describe('AuthService', () => {
it('stores only an internal return path in the short-lived OIDC login state', async () => {
const saved: OidcLoginStateEntity[] = [];
const service = new AuthService(
{
frontendBaseUrl: 'https://app.example.test',
appBaseUrl: 'https://app.example.test',
oidc: {
issuer: 'https://idp.example.test',
clientId: 'business-app',
clientSecret: 'secret',
scopes: 'openid profile email',
allowedAlgorithms: ['RS256'],
httpTimeoutMs: 5000,
},
} as AppConfigService,
{
requestJson: () =>
Promise.resolve({
issuer: 'https://idp.example.test',
authorization_endpoint: 'https://idp.example.test/authorize',
token_endpoint: 'https://idp.example.test/token',
jwks_uri: 'https://idp.example.test/jwks',
}),
} as unknown as ExternalHttpClient,
{} as RolesService,
{} as UsersRepository,
{} as SessionsService,
{} as DataSource,
{
delete: () => Promise.resolve({}),
save: (state: OidcLoginStateEntity) => {
saved.push(state);
return Promise.resolve(state);
},
} as unknown as Repository<OidcLoginStateEntity>,
);
await service.createLoginUrl('/einladungen/sicher');
await service.createLoginUrl('//evil.example/path');
expect(saved[0]?.returnPath).toBe('/einladungen/sicher');
expect(saved[1]?.returnPath).toBeNull();
});
it('revokes the local session and redirects to the OIDC logout endpoint', async () => {
const revoke = vi.fn<() => Promise<number>>(() => Promise.resolve(1));
const getIdTokenForLogout = vi.fn<() => Promise<string | undefined>>(() =>

View File

@@ -31,7 +31,7 @@ export class AuthService {
private readonly loginStates: Repository<OidcLoginStateEntity>,
) {}
async createLoginUrl(): Promise<string> {
async createLoginUrl(returnTo?: string): Promise<string> {
const discovery = await this.discovery();
const state = randomBytes(32).toString('hex');
const nonce = randomBytes(32).toString('base64url');
@@ -45,6 +45,7 @@ export class AuthService {
loginState.state = state;
loginState.codeVerifier = codeVerifier;
loginState.nonce = nonce;
loginState.returnPath = this.safeReturnPath(returnTo);
loginState.expiresAt = new Date(Date.now() + 10 * 60 * 1000);
await this.loginStates.save(loginState);
@@ -96,7 +97,7 @@ export class AuthService {
);
}
return this.sessions.createSession(
const result = await this.sessions.createSession(
user,
{
accessToken: tokens.access_token,
@@ -109,6 +110,7 @@ export class AuthService {
userAgent,
ip,
);
return { ...result, returnPath: loginState.returnPath };
}
async logout(sessionId: string | undefined): Promise<string> {
@@ -146,6 +148,10 @@ export class AuthService {
}
user.name = profile.name ?? profile.email ?? profile.sub;
user.email = profile.email ?? null;
user.emailVerified =
typeof profile.email_verified === 'boolean'
? profile.email_verified
: null;
user.lastLoginAt = new Date();
const savedUser = await manager.getRepository(UserEntity).save(user);
savedUser.settings = await this.ensureUserSettings(manager, savedUser);
@@ -296,6 +302,9 @@ export class AuthService {
if (typeof payload['email'] === 'string') {
fallback.email = payload['email'];
}
if (typeof payload['email_verified'] === 'boolean') {
fallback.email_verified = payload['email_verified'];
}
return fallback;
}
const userInfo = await this.http.requestJson<OidcUserInfo>(
@@ -311,6 +320,21 @@ export class AuthService {
401,
);
}
if (!userInfo.name && typeof payload['name'] === 'string') {
userInfo.name = payload['name'];
}
if (!userInfo.email && typeof payload['email'] === 'string') {
userInfo.email = payload['email'];
}
if (
typeof userInfo.email_verified !== 'boolean' &&
typeof payload['email_verified'] === 'boolean' &&
typeof payload['email'] === 'string' &&
payload['email'].trim().toLowerCase() ===
userInfo.email?.trim().toLowerCase()
) {
userInfo.email_verified = payload['email_verified'];
}
return userInfo;
}
@@ -325,4 +349,18 @@ export class AuthService {
private get callbackUrl(): string {
return new URL('/api/auth/callback', this.config.appBaseUrl).toString();
}
private safeReturnPath(value: string | undefined): string | null {
if (
!value ||
value.length > 500 ||
!value.startsWith('/') ||
value.startsWith('//') ||
value.includes('\\') ||
/^[a-z][a-z0-9+.-]*:/i.test(value)
) {
return null;
}
return value;
}
}

View File

@@ -11,6 +11,9 @@ export class OidcLoginStateEntity {
@Column({ type: 'varchar', length: 160 })
nonce!: string;
@Column({ name: 'return_path', type: 'varchar', length: 500, nullable: true })
returnPath!: string | null;
@Column({ name: 'expires_at', type: 'datetime', precision: 3 })
expiresAt!: Date;

View File

@@ -20,4 +20,5 @@ export interface OidcUserInfo {
sub: string;
name?: string;
email?: string;
email_verified?: boolean;
}