diff --git a/apps/api/src/common/oidc-forwarded-proto.spec.ts b/apps/api/src/common/oidc-forwarded-proto.spec.ts new file mode 100644 index 0000000..5af9cc7 --- /dev/null +++ b/apps/api/src/common/oidc-forwarded-proto.spec.ts @@ -0,0 +1,52 @@ +import { normalizeOidcForwardedProto } from './oidc-forwarded-proto'; + +describe('normalizeOidcForwardedProto', () => { + it('corrects an OIDC request when the trusted public issuer uses HTTPS', () => { + const request = { + method: 'GET', + originalUrl: '/oidc/auth?client_id=test', + headers: { 'x-forwarded-proto': 'http' }, + }; + + const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true); + + expect(result).toEqual({ corrected: true, path: '/oidc/auth', previousProto: 'http' }); + expect(request.headers['x-forwarded-proto']).toBe('https'); + }); + + it('also corrects interaction routes used to finish login and consent', () => { + const request = { originalUrl: '/interaction/uid/login', headers: {} as Record }; + + const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true); + + expect(result.corrected).toBe(true); + expect(request.headers['x-forwarded-proto']).toBe('https'); + }); + + it('does not alter unrelated API requests', () => { + const request = { originalUrl: '/api/account', headers: { 'x-forwarded-proto': 'http' } }; + + const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', true); + + expect(result.corrected).toBe(false); + expect(request.headers['x-forwarded-proto']).toBe('http'); + }); + + it('does not override the protocol when proxy trust is disabled', () => { + const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } }; + + const result = normalizeOidcForwardedProto(request, 'https://auth.example.com', false); + + expect(result.corrected).toBe(false); + expect(request.headers['x-forwarded-proto']).toBe('http'); + }); + + it('does not force HTTPS for a configured HTTP issuer', () => { + const request = { originalUrl: '/oidc/auth', headers: { 'x-forwarded-proto': 'http' } }; + + const result = normalizeOidcForwardedProto(request, 'http://localhost:8080', true); + + expect(result.corrected).toBe(false); + expect(request.headers['x-forwarded-proto']).toBe('http'); + }); +}); diff --git a/apps/api/src/common/oidc-forwarded-proto.ts b/apps/api/src/common/oidc-forwarded-proto.ts new file mode 100644 index 0000000..ceb75f9 --- /dev/null +++ b/apps/api/src/common/oidc-forwarded-proto.ts @@ -0,0 +1,58 @@ +import type { IncomingHttpHeaders } from 'node:http'; + +interface OidcProxyRequest { + method?: string; + originalUrl?: string; + url?: string; + headers: IncomingHttpHeaders; +} + +export interface OidcForwardedProtoResult { + corrected: boolean; + path: string; + previousProto?: string; +} + +export function normalizeOidcForwardedProto( + request: OidcProxyRequest, + issuer: string | undefined, + trustProxy: boolean, +): OidcForwardedProtoResult { + const path = (request.originalUrl ?? request.url ?? '/').split('?', 1)[0]; + const previousProto = headerValue(request.headers['x-forwarded-proto']); + + if (!trustProxy || !isOidcPath(path) || !isHttpsUrl(issuer) || previousProto === 'https') { + return { corrected: false, path, previousProto }; + } + + request.headers['x-forwarded-proto'] = 'https'; + return { corrected: true, path, previousProto }; +} + +function isOidcPath(path: string): boolean { + return ( + path === '/oidc' || + path.startsWith('/oidc/') || + path === '/interaction' || + path.startsWith('/interaction/') || + path === '/.well-known' || + path.startsWith('/.well-known/') + ); +} + +function isHttpsUrl(value?: string): boolean { + if (!value) { + return false; + } + + try { + return new URL(value).protocol === 'https:'; + } catch { + return false; + } +} + +function headerValue(value: string | string[] | undefined): string | undefined { + const firstValue = Array.isArray(value) ? value[0] : value; + return firstValue?.split(',', 1)[0].trim().toLowerCase() || undefined; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1b22490..a78a1aa 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,8 +1,9 @@ import 'reflect-metadata'; -import { ValidationPipe } from '@nestjs/common'; +import { Logger, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import express, { NextFunction, Request, Response } from 'express'; import { AppModule } from './app.module'; +import { normalizeOidcForwardedProto } from './common/oidc-forwarded-proto'; async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false }); @@ -12,6 +13,26 @@ async function bootstrap() { credentials: true, }); + const oidcProxyLogger = new Logger('OidcProxyProtocol'); + app.use((request: Request, _response: Response, next: NextFunction) => { + const result = normalizeOidcForwardedProto( + request, + process.env.OIDC_ISSUER, + process.env.OIDC_TRUST_PROXY === 'true', + ); + if (result.corrected) { + oidcProxyLogger.warn( + `[OIDC_FORWARDED_PROTO_CORRECTED] ${JSON.stringify({ + method: request.method, + path: result.path, + receivedProto: result.previousProto, + effectiveProto: 'https', + })}`, + ); + } + next(); + }); + const jsonParser = express.json(); const formParser = express.urlencoded({ extended: false }); app.use((request: Request, response: Response, next: NextFunction) => {