feat: switch oidc client to confidential (backend token exchange)

The provisioned IdP client (https://auth.forgecore.work) is confidential
rather than public/PKCE-only, so a client secret must never reach the
browser. The frontend now only performs the Authorization Code + PKCE
redirect itself (hand-rolled PKCE, oidc-client-ts dependency removed)
and hands the resulting code + verifier to a new, intentionally
unauthenticated POST /api/v1/auth/session endpoint, which performs the
code-for-tokens exchange server-side using OIDC_CLIENT_SECRET and
returns only {accessToken, expiresIn} — refresh_token/id_token are
never forwarded to the client.

New required backend env vars: OIDC_CLIENT_ID, OIDC_CLIENT_SECRET.
Added frontend/proxy.conf.json so the Angular dev server forwards
/api and /health to the local API without needing CORS.
This commit is contained in:
Bastian Wagner
2026-08-17 16:36:49 +02:00
parent 8eb5f0a3ed
commit 981cecbcbd
26 changed files with 554 additions and 58 deletions

View File

@@ -6,12 +6,14 @@ import { HealthModule } from './health/health.module';
import { VersionModule } from './version/version.module';
import { UsersApiModule } from './users/users.module';
import { TripsApiModule } from './trips/trips.module';
import { AuthApiModule } from './auth/auth.module';
@Module({
imports: [
ConfigurationModule,
HealthModule,
VersionModule,
AuthApiModule,
UsersApiModule,
TripsApiModule,
],

View File

@@ -0,0 +1,23 @@
import { AuthSessionController } from './auth-session.controller';
describe('AuthSessionController', () => {
it('POST /auth/session exchanges the authorization code via the token exchange service', async () => {
const tokenExchange = {
exchangeAuthorizationCode: jest
.fn()
.mockResolvedValue({ accessToken: 'at-1', expiresIn: 3600 }),
};
const controller = new AuthSessionController(tokenExchange as never);
const dto = {
code: 'code-1',
codeVerifier: 'verifier-1',
redirectUri: 'http://localhost:4200/auth/callback',
};
await expect(controller.createSession(dto)).resolves.toEqual({
accessToken: 'at-1',
expiresIn: 3600,
});
expect(tokenExchange.exchangeAuthorizationCode).toHaveBeenCalledWith(dto);
});
});

View File

@@ -0,0 +1,15 @@
import { Body, Controller, Post } from '@nestjs/common';
import { TokenExchangeService } from '../../../../libs/auth/src';
import type { AuthorizationCodeExchangeRequest, AuthorizationCodeExchangeResult } from '../../../../libs/auth/src';
@Controller('auth')
export class AuthSessionController {
constructor(private readonly tokenExchange: TokenExchangeService) {}
@Post('session')
createSession(
@Body() dto: AuthorizationCodeExchangeRequest,
): Promise<AuthorizationCodeExchangeResult> {
return this.tokenExchange.exchangeAuthorizationCode(dto);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AuthSessionController } from './auth-session.controller';
@Module({
controllers: [AuthSessionController],
})
export class AuthApiModule {}