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:
@@ -16,3 +16,7 @@ TLS_KEY_FILE=/etc/travel-planner/tls/tls.key
|
|||||||
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
||||||
OIDC_AUDIENCE=travel-planner-api
|
OIDC_AUDIENCE=travel-planner-api
|
||||||
OIDC_CLIENT_ID=travel-planner-web
|
OIDC_CLIENT_ID=travel-planner-web
|
||||||
|
# OIDC_CLIENT_SECRET: this client is confidential (holds a secret). Never commit
|
||||||
|
# a real value here; supply it only via the deployment host's secret store /
|
||||||
|
# the developer's own shell environment.
|
||||||
|
OIDC_CLIENT_SECRET=change-me-outside-source-control
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -19,25 +19,32 @@ docs/ Specs, plans, and architecture documentation
|
|||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev:infra
|
pnpm dev:infra
|
||||||
DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner \
|
|
||||||
REDIS_URL=redis://localhost:6379 \
|
|
||||||
OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner \
|
|
||||||
OIDC_AUDIENCE=travel-planner-api \
|
|
||||||
pnpm --filter backend start:api
|
|
||||||
pnpm --filter frontend start
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Run pending migrations against the dev database before starting the API for the first time:
|
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
|
||||||
|
|
||||||
|
Set the required OIDC env vars once per shell before running the backend. **`OIDC_CLIENT_SECRET` is a real secret — set it directly in your own shell, never commit it, never paste it into a shared/logged terminal.** `OIDC_AUDIENCE` should be set to the same value as `OIDC_CLIENT_ID` unless your IdP issues a distinct API audience.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
|
export DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
|
||||||
export REDIS_URL=redis://localhost:6379
|
export REDIS_URL=redis://localhost:6379
|
||||||
export OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
export OIDC_ISSUER=https://auth.forgecore.work
|
||||||
export OIDC_AUDIENCE=travel-planner-api
|
export OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
|
||||||
pnpm --filter backend build:api && node backend/dist/apps/api/src/migration.js
|
export OIDC_AUDIENCE=$OIDC_CLIENT_ID
|
||||||
|
export OIDC_CLIENT_SECRET=<your-client-secret> # never commit this value
|
||||||
```
|
```
|
||||||
|
|
||||||
`pnpm dev:infra` starts local PostgreSQL/Redis (see `compose.dev.yml`); `pnpm dev:infra:down` stops them.
|
Run pending migrations against the dev database (first time only), then start the API and frontend:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter backend build:api && node backend/dist/apps/api/src/migration.js
|
||||||
|
pnpm --filter backend start:api
|
||||||
|
pnpm --filter frontend start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:4200`. The Angular dev server proxies `/api/*` and `/health/*` to the API on `localhost:3000` (see `frontend/proxy.conf.json`), so no CORS configuration is needed locally. Make sure the IdP client `client_a297fd8d9c1f47a79d3600ea0c96984` allows the redirect URI `http://localhost:4200/auth/callback`.
|
||||||
|
|
||||||
|
This IdP client is **confidential** (it has a client secret), so the Authorization Code + PKCE token exchange happens server-side via `POST /api/v1/auth/session` (see "OIDC client type" below) — the secret never reaches the browser.
|
||||||
|
|
||||||
## Quality gates
|
## Quality gates
|
||||||
|
|
||||||
@@ -76,8 +83,9 @@ Production Docker Compose (`compose.yml`) publishes **exactly one** host port, o
|
|||||||
|
|
||||||
## Phase 02 status: OIDC auth, users, and trip core complete
|
## Phase 02 status: OIDC auth, users, and trip core complete
|
||||||
|
|
||||||
- Authentication: OIDC Authorization Code + PKCE against an external IdP (`oidc-client-ts` on the frontend, `jose`-based bearer-token verification on the backend). No local password storage; users are keyed by the OIDC `sub` claim and just-in-time provisioned on first login.
|
- Authentication: OIDC Authorization Code + PKCE against an external IdP. No local password storage; users are keyed by the OIDC `sub` claim and just-in-time provisioned on first login.
|
||||||
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`). New frontend build-time values: `OIDC_ISSUER`, `OIDC_CLIENT_ID` (baked into the production bundle by `docker/edge.Dockerfile`, never read from the container at runtime).
|
- **OIDC client type:** this deployment's IdP client is confidential (has a client secret), not a plain public/PKCE-only SPA client. A client secret must never be embedded in a browser bundle, so the frontend performs only the browser-side Authorization Code + PKCE redirect (hand-rolled PKCE in `frontend/src/app/auth/pkce.ts`, no `oidc-client-ts` dependency); the resulting `code` + PKCE `code_verifier` are then POSTed to the backend's `POST /api/v1/auth/session` (unauthenticated by design — there is no token yet), which performs the actual code-for-tokens exchange using `OIDC_CLIENT_SECRET` server-side (`TokenExchangeService`) and returns only `{ accessToken, expiresIn }` to the frontend — `refresh_token`/`id_token` are never forwarded. If a future deployment instead uses a public PKCE-only client, this proxy step could be skipped in favor of a direct frontend-to-IdP exchange, but the current IdP requires it.
|
||||||
|
- New required backend env vars: `OIDC_ISSUER`, `OIDC_AUDIENCE`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (validated fail-fast like `DATABASE_URL`/`REDIS_URL`; `OIDC_CLIENT_SECRET` is a real secret, never committed). New frontend build-time values: `OIDC_ISSUER`, `OIDC_CLIENT_ID` (non-secret; baked into the production bundle by `docker/edge.Dockerfile`, never read from the container at runtime).
|
||||||
- Real, versioned database migrations (`node-pg-migrate`, files under `backend/migrations/`) replace the Phase 01 no-op `migration.ts` body; the container command contract (`node backend/dist/apps/api/src/migration.js`) is unchanged. Database access goes through `kysely` (a type-safe query builder, not an ORM) over the existing `pg.Pool`; there is no schema auto-sync anywhere.
|
- Real, versioned database migrations (`node-pg-migrate`, files under `backend/migrations/`) replace the Phase 01 no-op `migration.ts` body; the container command contract (`node backend/dist/apps/api/src/migration.js`) is unchanged. Database access goes through `kysely` (a type-safe query builder, not an ORM) over the existing `pg.Pool`; there is no schema auto-sync anywhere.
|
||||||
- New routes: `GET/PUT /api/v1/users/me`(`/preferences`), `GET/POST /api/v1/trips`, `GET/PATCH/DELETE /api/v1/trips/:tripId`, `GET/PUT /api/v1/trips/:tripId/settings`, `GET/PATCH/DELETE /api/v1/trips/:tripId/members(/:memberId)`, `POST/GET/DELETE /api/v1/trips/:tripId/invitations(/:invitationId)`, `POST /api/v1/invitations/:token/accept`, `GET/POST/PATCH/DELETE /api/v1/trips/:tripId/travelers(/:travelerId)`, `GET/PUT/DELETE /api/v1/trips/:tripId/preference-overrides(/:overrideId)`.
|
- New routes: `GET/PUT /api/v1/users/me`(`/preferences`), `GET/POST /api/v1/trips`, `GET/PATCH/DELETE /api/v1/trips/:tripId`, `GET/PUT /api/v1/trips/:tripId/settings`, `GET/PATCH/DELETE /api/v1/trips/:tripId/members(/:memberId)`, `POST/GET/DELETE /api/v1/trips/:tripId/invitations(/:invitationId)`, `POST /api/v1/invitations/:token/accept`, `GET/POST/PATCH/DELETE /api/v1/trips/:tripId/travelers(/:travelerId)`, `GET/PUT/DELETE /api/v1/trips/:tripId/preference-overrides(/:overrideId)`.
|
||||||
- Authorization is enforced backend-side by `OidcAuthGuard` (who) and `TripMembershipGuard` (trip access + `@TripRoles('OWNER')`), never only in the frontend. `TripMember` and `Traveler` are independent tables — a `Traveler` never implies or requires trip membership.
|
- Authorization is enforced backend-side by `OidcAuthGuard` (who) and `TripMembershipGuard` (trip access + `@TripRoles('OWNER')`), never only in the frontend. `TripMember` and `Traveler` are independent tables — a `Traveler` never implies or requires trip membership.
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import { HealthModule } from './health/health.module';
|
|||||||
import { VersionModule } from './version/version.module';
|
import { VersionModule } from './version/version.module';
|
||||||
import { UsersApiModule } from './users/users.module';
|
import { UsersApiModule } from './users/users.module';
|
||||||
import { TripsApiModule } from './trips/trips.module';
|
import { TripsApiModule } from './trips/trips.module';
|
||||||
|
import { AuthApiModule } from './auth/auth.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
VersionModule,
|
VersionModule,
|
||||||
|
AuthApiModule,
|
||||||
UsersApiModule,
|
UsersApiModule,
|
||||||
TripsApiModule,
|
TripsApiModule,
|
||||||
],
|
],
|
||||||
|
|||||||
23
backend/apps/api/src/auth/auth-session.controller.spec.ts
Normal file
23
backend/apps/api/src/auth/auth-session.controller.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
15
backend/apps/api/src/auth/auth-session.controller.ts
Normal file
15
backend/apps/api/src/auth/auth-session.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/apps/api/src/auth/auth.module.ts
Normal file
7
backend/apps/api/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthSessionController } from './auth-session.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [AuthSessionController],
|
||||||
|
})
|
||||||
|
export class AuthApiModule {}
|
||||||
@@ -2,11 +2,17 @@ import { Global, Module } from '@nestjs/common';
|
|||||||
import { UsersLibModule } from '../../users/src';
|
import { UsersLibModule } from '../../users/src';
|
||||||
import { OidcDiscoveryService } from './oidc-discovery.service';
|
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||||
import { OidcAuthGuard } from './oidc-auth.guard';
|
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||||
|
import { TokenExchangeService } from './token-exchange.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersLibModule],
|
imports: [UsersLibModule],
|
||||||
providers: [OidcDiscoveryService, OidcAuthGuard],
|
providers: [OidcDiscoveryService, OidcAuthGuard, TokenExchangeService],
|
||||||
exports: [OidcDiscoveryService, OidcAuthGuard, UsersLibModule],
|
exports: [
|
||||||
|
OidcDiscoveryService,
|
||||||
|
OidcAuthGuard,
|
||||||
|
TokenExchangeService,
|
||||||
|
UsersLibModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from './oidc-discovery.service';
|
export * from './oidc-discovery.service';
|
||||||
export * from './oidc-auth.guard';
|
export * from './oidc-auth.guard';
|
||||||
|
export * from './token-exchange.service';
|
||||||
export * from './auth.module';
|
export * from './auth.module';
|
||||||
|
|||||||
47
backend/libs/auth/src/oidc-discovery.service.spec.ts
Normal file
47
backend/libs/auth/src/oidc-discovery.service.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||||
|
import type { AppEnvironment } from '../../configuration/src';
|
||||||
|
|
||||||
|
describe('OidcDiscoveryService', () => {
|
||||||
|
const environment: AppEnvironment = {
|
||||||
|
databaseUrl: 'postgresql://u:p@postgres:5432/db',
|
||||||
|
redisUrl: 'redis://redis:6379',
|
||||||
|
oidcIssuer: 'https://idp.example.test',
|
||||||
|
oidcAudience: 'client-1',
|
||||||
|
oidcClientId: 'client-1',
|
||||||
|
oidcClientSecret: 'secret-1',
|
||||||
|
appVersion: 'dev',
|
||||||
|
teamCityBuildNumber: 'local',
|
||||||
|
sourceRevision: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('fetches the discovery document once and exposes the token endpoint', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
jwks_uri: 'https://idp.example.test/oidc/jwks',
|
||||||
|
token_endpoint: 'https://idp.example.test/oidc/token',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
||||||
|
|
||||||
|
const service = new OidcDiscoveryService(environment);
|
||||||
|
await service.onModuleInit();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://idp.example.test/.well-known/openid-configuration',
|
||||||
|
);
|
||||||
|
expect(service.getTokenEndpoint()).toBe(
|
||||||
|
'https://idp.example.test/oidc/token',
|
||||||
|
);
|
||||||
|
expect(service.getIssuer()).toBe('https://idp.example.test');
|
||||||
|
expect(service.getAudience()).toBe('client-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when asked for the token endpoint before discovery has completed', () => {
|
||||||
|
const service = new OidcDiscoveryService(environment);
|
||||||
|
expect(() => service.getTokenEndpoint()).toThrow(
|
||||||
|
'OIDC discovery has not completed yet',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,11 +6,13 @@ import type { AppEnvironment } from '../../configuration/src';
|
|||||||
|
|
||||||
interface OidcDiscoveryDocument {
|
interface OidcDiscoveryDocument {
|
||||||
jwks_uri: string;
|
jwks_uri: string;
|
||||||
|
token_endpoint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OidcDiscoveryService implements OnModuleInit {
|
export class OidcDiscoveryService implements OnModuleInit {
|
||||||
private verificationKeySet: JWTVerifyGetKey | undefined;
|
private verificationKeySet: JWTVerifyGetKey | undefined;
|
||||||
|
private tokenEndpoint: string | undefined;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||||
@@ -26,6 +28,7 @@ export class OidcDiscoveryService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
const document = (await response.json()) as OidcDiscoveryDocument;
|
const document = (await response.json()) as OidcDiscoveryDocument;
|
||||||
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
|
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
|
||||||
|
this.tokenEndpoint = document.token_endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
getIssuer(): string {
|
getIssuer(): string {
|
||||||
@@ -42,4 +45,11 @@ export class OidcDiscoveryService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
return this.verificationKeySet;
|
return this.verificationKeySet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getTokenEndpoint(): string {
|
||||||
|
if (!this.tokenEndpoint) {
|
||||||
|
throw new Error('OIDC discovery has not completed yet');
|
||||||
|
}
|
||||||
|
return this.tokenEndpoint;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
110
backend/libs/auth/src/token-exchange.service.spec.ts
Normal file
110
backend/libs/auth/src/token-exchange.service.spec.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { TokenExchangeService } from './token-exchange.service';
|
||||||
|
import type { AppEnvironment } from '../../configuration/src';
|
||||||
|
|
||||||
|
describe('TokenExchangeService.exchangeAuthorizationCode', () => {
|
||||||
|
const environment: AppEnvironment = {
|
||||||
|
databaseUrl: 'postgresql://u:p@postgres:5432/db',
|
||||||
|
redisUrl: 'redis://redis:6379',
|
||||||
|
oidcIssuer: 'https://idp.example.test',
|
||||||
|
oidcAudience: 'client-1',
|
||||||
|
oidcClientId: 'client-1',
|
||||||
|
oidcClientSecret: 'super-secret',
|
||||||
|
appVersion: 'dev',
|
||||||
|
teamCityBuildNumber: 'local',
|
||||||
|
sourceRevision: 'local',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fakeDiscovery(
|
||||||
|
tokenEndpoint = 'https://idp.example.test/oidc/token',
|
||||||
|
) {
|
||||||
|
return { getTokenEndpoint: jest.fn().mockReturnValue(tokenEndpoint) };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('posts a client-secret-authenticated request and returns only the safe fields', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: 'at-1',
|
||||||
|
expires_in: 3600,
|
||||||
|
refresh_token: 'rt-1',
|
||||||
|
id_token: 'idt-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
||||||
|
|
||||||
|
const service = new TokenExchangeService(
|
||||||
|
environment,
|
||||||
|
fakeDiscovery() as never,
|
||||||
|
);
|
||||||
|
const result = await service.exchangeAuthorizationCode({
|
||||||
|
code: 'auth-code-1',
|
||||||
|
codeVerifier: 'verifier-1',
|
||||||
|
redirectUri: 'http://localhost:4200/auth/callback',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ accessToken: 'at-1', expiresIn: 3600 });
|
||||||
|
|
||||||
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||||
|
expect(url).toBe('https://idp.example.test/oidc/token');
|
||||||
|
const body = new URLSearchParams(init.body as string);
|
||||||
|
expect(body.get('grant_type')).toBe('authorization_code');
|
||||||
|
expect(body.get('client_id')).toBe('client-1');
|
||||||
|
expect(body.get('client_secret')).toBe('super-secret');
|
||||||
|
expect(body.get('code')).toBe('auth-code-1');
|
||||||
|
expect(body.get('code_verifier')).toBe('verifier-1');
|
||||||
|
expect(body.get('redirect_uri')).toBe(
|
||||||
|
'http://localhost:4200/auth/callback',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never leaks the refresh_token or id_token to the caller', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
access_token: 'at-1',
|
||||||
|
expires_in: 3600,
|
||||||
|
refresh_token: 'rt-1',
|
||||||
|
id_token: 'idt-1',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
||||||
|
|
||||||
|
const service = new TokenExchangeService(
|
||||||
|
environment,
|
||||||
|
fakeDiscovery() as never,
|
||||||
|
);
|
||||||
|
const result = await service.exchangeAuthorizationCode({
|
||||||
|
code: 'auth-code-1',
|
||||||
|
codeVerifier: 'verifier-1',
|
||||||
|
redirectUri: 'http://localhost:4200/auth/callback',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).not.toHaveProperty('refreshToken');
|
||||||
|
expect(result).not.toHaveProperty('idToken');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects with BadRequestException when the identity provider rejects the code', async () => {
|
||||||
|
const fetchMock = jest.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
json: () => Promise.resolve({ error: 'invalid_grant' }),
|
||||||
|
});
|
||||||
|
(globalThis as { fetch: typeof fetch }).fetch = fetchMock as never;
|
||||||
|
|
||||||
|
const service = new TokenExchangeService(
|
||||||
|
environment,
|
||||||
|
fakeDiscovery() as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.exchangeAuthorizationCode({
|
||||||
|
code: 'bad-code',
|
||||||
|
codeVerifier: 'verifier-1',
|
||||||
|
redirectUri: 'http://localhost:4200/auth/callback',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
backend/libs/auth/src/token-exchange.service.ts
Normal file
68
backend/libs/auth/src/token-exchange.service.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { APP_ENVIRONMENT } from '../../configuration/src';
|
||||||
|
import type { AppEnvironment } from '../../configuration/src';
|
||||||
|
import { OidcDiscoveryService } from './oidc-discovery.service';
|
||||||
|
|
||||||
|
export interface AuthorizationCodeExchangeRequest {
|
||||||
|
code: string;
|
||||||
|
codeVerifier: string;
|
||||||
|
redirectUri: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthorizationCodeExchangeResult {
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TokenEndpointResponse {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
refresh_token?: string;
|
||||||
|
id_token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exchanges an OIDC authorization code for tokens on behalf of the (confidential,
|
||||||
|
* client-secret-bearing) SPA client. The secret never reaches the browser: the
|
||||||
|
* frontend performs the Authorization Code + PKCE redirect itself, then hands the
|
||||||
|
* resulting code + PKCE verifier to this service, which is the only place the
|
||||||
|
* client secret is used.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TokenExchangeService {
|
||||||
|
constructor(
|
||||||
|
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||||
|
private readonly discovery: OidcDiscoveryService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async exchangeAuthorizationCode(
|
||||||
|
request: AuthorizationCodeExchangeRequest,
|
||||||
|
): Promise<AuthorizationCodeExchangeResult> {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: this.environment.oidcClientId,
|
||||||
|
client_secret: this.environment.oidcClientSecret,
|
||||||
|
code: request.code,
|
||||||
|
code_verifier: request.codeVerifier,
|
||||||
|
redirect_uri: request.redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(this.discovery.getTokenEndpoint(), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'The identity provider rejected the authorization code',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenResponse = (await response.json()) as TokenEndpointResponse;
|
||||||
|
return {
|
||||||
|
accessToken: tokenResponse.access_token,
|
||||||
|
expiresIn: tokenResponse.expires_in,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ describe('loadEnvironment', () => {
|
|||||||
REDIS_URL: 'redis://redis:6379',
|
REDIS_URL: 'redis://redis:6379',
|
||||||
OIDC_ISSUER: 'https://idp.example.test/',
|
OIDC_ISSUER: 'https://idp.example.test/',
|
||||||
OIDC_AUDIENCE: 'travel-planner-api',
|
OIDC_AUDIENCE: 'travel-planner-api',
|
||||||
|
OIDC_CLIENT_ID: 'test-client',
|
||||||
|
OIDC_CLIENT_SECRET: 'test-secret',
|
||||||
APP_VERSION: '1.2.3',
|
APP_VERSION: '1.2.3',
|
||||||
TEAMCITY_BUILD_NUMBER: '42',
|
TEAMCITY_BUILD_NUMBER: '42',
|
||||||
SOURCE_REVISION: 'abc123',
|
SOURCE_REVISION: 'abc123',
|
||||||
@@ -21,6 +23,8 @@ describe('loadEnvironment', () => {
|
|||||||
redisUrl: 'redis://redis:6379',
|
redisUrl: 'redis://redis:6379',
|
||||||
oidcIssuer: 'https://idp.example.test/',
|
oidcIssuer: 'https://idp.example.test/',
|
||||||
oidcAudience: 'travel-planner-api',
|
oidcAudience: 'travel-planner-api',
|
||||||
|
oidcClientId: 'test-client',
|
||||||
|
oidcClientSecret: 'test-secret',
|
||||||
appVersion: '1.2.3',
|
appVersion: '1.2.3',
|
||||||
teamCityBuildNumber: '42',
|
teamCityBuildNumber: '42',
|
||||||
sourceRevision: 'abc123',
|
sourceRevision: 'abc123',
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface AppEnvironment {
|
|||||||
redisUrl: string;
|
redisUrl: string;
|
||||||
oidcIssuer: string;
|
oidcIssuer: string;
|
||||||
oidcAudience: string;
|
oidcAudience: string;
|
||||||
|
oidcClientId: string;
|
||||||
|
oidcClientSecret: string;
|
||||||
appVersion: string;
|
appVersion: string;
|
||||||
teamCityBuildNumber: string;
|
teamCityBuildNumber: string;
|
||||||
sourceRevision: string;
|
sourceRevision: string;
|
||||||
@@ -20,6 +22,8 @@ export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
|
|||||||
redisUrl: required(env, 'REDIS_URL'),
|
redisUrl: required(env, 'REDIS_URL'),
|
||||||
oidcIssuer: required(env, 'OIDC_ISSUER'),
|
oidcIssuer: required(env, 'OIDC_ISSUER'),
|
||||||
oidcAudience: required(env, 'OIDC_AUDIENCE'),
|
oidcAudience: required(env, 'OIDC_AUDIENCE'),
|
||||||
|
oidcClientId: required(env, 'OIDC_CLIENT_ID'),
|
||||||
|
oidcClientSecret: required(env, 'OIDC_CLIENT_SECRET'),
|
||||||
appVersion: env.APP_VERSION?.trim() || 'dev',
|
appVersion: env.APP_VERSION?.trim() || 'dev',
|
||||||
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
|
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.trim() || 'local',
|
||||||
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',
|
sourceRevision: env.SOURCE_REVISION?.trim() || 'local',
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test';
|
|||||||
process.env.REDIS_URL ??= 'redis://localhost:6379';
|
process.env.REDIS_URL ??= 'redis://localhost:6379';
|
||||||
process.env.OIDC_ISSUER ??= 'https://idp.example.test/';
|
process.env.OIDC_ISSUER ??= 'https://idp.example.test/';
|
||||||
process.env.OIDC_AUDIENCE ??= 'travel-planner-api';
|
process.env.OIDC_AUDIENCE ??= 'travel-planner-api';
|
||||||
|
process.env.OIDC_CLIENT_ID ??= 'test-client';
|
||||||
|
process.env.OIDC_CLIENT_SECRET ??= 'test-secret';
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ services:
|
|||||||
REDIS_URL: "redis://redis:6379"
|
REDIS_URL: "redis://redis:6379"
|
||||||
OIDC_ISSUER: "${OIDC_ISSUER}"
|
OIDC_ISSUER: "${OIDC_ISSUER}"
|
||||||
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
||||||
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||||
|
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||||
APP_VERSION: "${APP_VERSION:-dev}"
|
APP_VERSION: "${APP_VERSION:-dev}"
|
||||||
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
||||||
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
||||||
@@ -57,6 +59,8 @@ services:
|
|||||||
REDIS_URL: "redis://redis:6379"
|
REDIS_URL: "redis://redis:6379"
|
||||||
OIDC_ISSUER: "${OIDC_ISSUER}"
|
OIDC_ISSUER: "${OIDC_ISSUER}"
|
||||||
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
||||||
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||||
|
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||||
APP_VERSION: "${APP_VERSION:-dev}"
|
APP_VERSION: "${APP_VERSION:-dev}"
|
||||||
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
||||||
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
||||||
|
|||||||
@@ -1325,3 +1325,18 @@ Do not start any of the following during this phase; each belongs to a later, ex
|
|||||||
- Payment/booking automation (permanent non-goal).
|
- Payment/booking automation (permanent non-goal).
|
||||||
|
|
||||||
Phase 03 begins only after the Phase 02 acceptance checklist is reviewed and green.
|
Phase 03 begins only after the Phase 02 acceptance checklist is reviewed and green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Addendum: Confidential IdP Client Requires Backend Token Exchange (2026-08-17)
|
||||||
|
|
||||||
|
The plan's Task 10 assumed a public, PKCE-only SPA client and used `oidc-client-ts` to perform the full Authorization Code + PKCE token exchange directly in the browser. During manual end-to-end testing against the actual target IdP (`https://auth.forgecore.work`), it turned out the provisioned client is **confidential** (it has a client secret) rather than public. A client secret must never be shipped in a browser bundle, so this required a real, user-confirmed architecture change rather than a workaround:
|
||||||
|
|
||||||
|
- The frontend still performs the Authorization Code + PKCE redirect itself (hand-rolled PKCE generation in `frontend/src/app/auth/pkce.ts`; `oidc-client-ts` was removed as a dependency since its callback handling assumes a direct-to-IdP token exchange that doesn't fit this model).
|
||||||
|
- The resulting authorization `code` and PKCE `code_verifier` are POSTed to a new, intentionally unauthenticated backend endpoint, `POST /api/v1/auth/session` (`AuthSessionController` → `TokenExchangeService`), which performs the code-for-tokens exchange using `OIDC_CLIENT_SECRET` server-side and returns only `{ accessToken, expiresIn }` — `refresh_token`/`id_token` are deliberately never forwarded to the frontend.
|
||||||
|
- New required backend env vars: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (in addition to the already-planned `OIDC_ISSUER`/`OIDC_AUDIENCE`).
|
||||||
|
- `OIDC_AUDIENCE` is operationally set equal to `OIDC_CLIENT_ID` for this IdP, since it does not issue a separate API-resource audience; no code change was needed for this, only a configuration convention.
|
||||||
|
- `OidcDiscoveryService` now also captures `token_endpoint` (in addition to `jwks_uri`), needed for the backend-side token exchange.
|
||||||
|
- A local dev proxy (`frontend/proxy.conf.json`, wired into `angular.json`'s `serve` target) forwards `/api/*` and `/health/*` from the Angular dev server to the API, avoiding a need for CORS configuration in local development (production already avoids CORS entirely since `edge` serves both origins).
|
||||||
|
|
||||||
|
This is a deployment-specific IdP constraint, not a general Travel Planner requirement — a future public-client IdP could skip the backend proxy — but the BFF pattern is what ships today since it is what the actual configured IdP requires.
|
||||||
|
|||||||
@@ -64,6 +64,9 @@
|
|||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular/build:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "frontend:build:production"
|
"buildTarget": "frontend:build:production"
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
"@angular/platform-browser": "^21.2.0",
|
"@angular/platform-browser": "^21.2.0",
|
||||||
"@angular/router": "^21.2.0",
|
"@angular/router": "^21.2.0",
|
||||||
"@angular/service-worker": "^21.2.0",
|
"@angular/service-worker": "^21.2.0",
|
||||||
"oidc-client-ts": "^3.5.0",
|
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0"
|
"tslib": "^2.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
4
frontend/proxy.conf.json
Normal file
4
frontend/proxy.conf.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"/api": { "target": "http://localhost:3000", "secure": false },
|
||||||
|
"/health": { "target": "http://localhost:3000", "secure": false }
|
||||||
|
}
|
||||||
@@ -1,11 +1,87 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
describe('AuthService', () => {
|
describe('AuthService', () => {
|
||||||
it('starts unauthenticated when no OIDC user is stored', async () => {
|
beforeEach(() => {
|
||||||
|
sessionStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts unauthenticated when no session is stored', () => {
|
||||||
const service = TestBed.inject(AuthService);
|
const service = TestBed.inject(AuthService);
|
||||||
await Promise.resolve();
|
|
||||||
expect(service.isAuthenticated()).toBe(false);
|
expect(service.isAuthenticated()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts authenticated when a non-expired access token is already stored', () => {
|
||||||
|
sessionStorage.setItem('auth.accessToken', 'stored-token');
|
||||||
|
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('login() persists a PKCE verifier and state before redirecting', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ authorization_endpoint: 'https://idp.example.test/oidc/auth' }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('location', { ...window.location, href: '' });
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
await service.login();
|
||||||
|
|
||||||
|
expect(sessionStorage.getItem('auth.codeVerifier')).toBeTruthy();
|
||||||
|
expect(sessionStorage.getItem('auth.state')).toBeTruthy();
|
||||||
|
expect(window.location.href).toContain('https://idp.example.test/oidc/auth?');
|
||||||
|
expect(window.location.href).toContain('code_challenge_method=S256');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completeLogin() rejects a state that does not match the one stored before redirecting', async () => {
|
||||||
|
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
|
||||||
|
sessionStorage.setItem('auth.state', 'expected-state');
|
||||||
|
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=wrong-state' });
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
await expect(service.completeLogin()).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('completeLogin() exchanges the code via the backend and stores the resulting access token', async () => {
|
||||||
|
sessionStorage.setItem('auth.codeVerifier', 'verifier-1');
|
||||||
|
sessionStorage.setItem('auth.state', 'state-1');
|
||||||
|
vi.stubGlobal('location', { ...window.location, search: '?code=abc&state=state-1' });
|
||||||
|
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ accessToken: 'at-1', expiresIn: 3600 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
await service.completeLogin();
|
||||||
|
|
||||||
|
expect(service.isAuthenticated()).toBe(true);
|
||||||
|
await expect(service.getAccessToken()).resolves.toBe('at-1');
|
||||||
|
expect(sessionStorage.getItem('auth.codeVerifier')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logout() clears the stored session', () => {
|
||||||
|
sessionStorage.setItem('auth.accessToken', 'at-1');
|
||||||
|
sessionStorage.setItem('auth.expiresAt', String(Date.now() + 60_000));
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
|
||||||
|
service.logout();
|
||||||
|
|
||||||
|
expect(service.isAuthenticated()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAccessToken() returns undefined once the token has expired', async () => {
|
||||||
|
sessionStorage.setItem('auth.accessToken', 'at-1');
|
||||||
|
sessionStorage.setItem('auth.expiresAt', String(Date.now() - 1000));
|
||||||
|
const service = TestBed.inject(AuthService);
|
||||||
|
|
||||||
|
await expect(service.getAccessToken()).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,103 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, signal } from '@angular/core';
|
||||||
import { UserManager } from 'oidc-client-ts';
|
|
||||||
import { environment } from '../../environments/environment';
|
import { environment } from '../../environments/environment';
|
||||||
|
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'auth.accessToken';
|
||||||
|
const EXPIRES_AT_KEY = 'auth.expiresAt';
|
||||||
|
const CODE_VERIFIER_KEY = 'auth.codeVerifier';
|
||||||
|
const STATE_KEY = 'auth.state';
|
||||||
|
|
||||||
|
interface DiscoveryDocument {
|
||||||
|
authorization_endpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionResponse {
|
||||||
|
accessToken: string;
|
||||||
|
expiresIn: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The IdP client backing this app is confidential (holds a client secret), so
|
||||||
|
* the authorization-code-for-tokens exchange must happen server-side — see
|
||||||
|
* `POST /api/v1/auth/session`. This service only performs the browser-side
|
||||||
|
* Authorization Code + PKCE redirect and hands the resulting code + PKCE
|
||||||
|
* verifier to the backend; it never sees or stores the client secret.
|
||||||
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly userManager = new UserManager({
|
private discoveryPromise: Promise<DiscoveryDocument> | undefined;
|
||||||
authority: environment.oidc.issuer,
|
|
||||||
client_id: environment.oidc.clientId,
|
|
||||||
redirect_uri: environment.oidc.redirectUri,
|
|
||||||
scope: environment.oidc.scope,
|
|
||||||
response_type: 'code',
|
|
||||||
});
|
|
||||||
|
|
||||||
readonly isAuthenticated = signal(false);
|
readonly isAuthenticated = signal(this.hasValidAccessToken());
|
||||||
|
|
||||||
constructor() {
|
private hasValidAccessToken(): boolean {
|
||||||
this.userManager.events.addUserLoaded(() => this.isAuthenticated.set(true));
|
const expiresAt = Number(sessionStorage.getItem(EXPIRES_AT_KEY) ?? 0);
|
||||||
this.userManager.events.addUserUnloaded(() => this.isAuthenticated.set(false));
|
return !!sessionStorage.getItem(ACCESS_TOKEN_KEY) && Date.now() < expiresAt;
|
||||||
void this.userManager.getUser().then((user) => this.isAuthenticated.set(!!user && !user.expired));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
login(): Promise<void> {
|
private discover(): Promise<DiscoveryDocument> {
|
||||||
return this.userManager.signinRedirect();
|
if (!this.discoveryPromise) {
|
||||||
|
const issuer = environment.oidc.issuer.replace(/\/$/, '');
|
||||||
|
this.discoveryPromise = fetch(`${issuer}/.well-known/openid-configuration`).then((response) => response.json());
|
||||||
|
}
|
||||||
|
return this.discoveryPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(): Promise<void> {
|
||||||
|
const codeVerifier = generateRandomString();
|
||||||
|
const state = generateRandomString();
|
||||||
|
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
sessionStorage.setItem(CODE_VERIFIER_KEY, codeVerifier);
|
||||||
|
sessionStorage.setItem(STATE_KEY, state);
|
||||||
|
|
||||||
|
const discovery = await this.discover();
|
||||||
|
const url = new URL(discovery.authorization_endpoint);
|
||||||
|
url.searchParams.set('response_type', 'code');
|
||||||
|
url.searchParams.set('client_id', environment.oidc.clientId);
|
||||||
|
url.searchParams.set('redirect_uri', environment.oidc.redirectUri);
|
||||||
|
url.searchParams.set('scope', environment.oidc.scope);
|
||||||
|
url.searchParams.set('state', state);
|
||||||
|
url.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
url.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
|
||||||
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
async completeLogin(): Promise<void> {
|
async completeLogin(): Promise<void> {
|
||||||
await this.userManager.signinRedirectCallback();
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
const state = params.get('state');
|
||||||
|
const codeVerifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
|
||||||
|
const expectedState = sessionStorage.getItem(STATE_KEY);
|
||||||
|
|
||||||
|
if (!code || !state || !codeVerifier || state !== expectedState) {
|
||||||
|
throw new Error('Invalid or missing OIDC callback parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${environment.apiBaseUrl}/auth/session`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code, codeVerifier, redirectUri: environment.oidc.redirectUri }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to exchange the authorization code for a session');
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = (await response.json()) as SessionResponse;
|
||||||
|
sessionStorage.setItem(ACCESS_TOKEN_KEY, session.accessToken);
|
||||||
|
sessionStorage.setItem(EXPIRES_AT_KEY, String(Date.now() + session.expiresIn * 1000));
|
||||||
|
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||||
|
sessionStorage.removeItem(STATE_KEY);
|
||||||
|
this.isAuthenticated.set(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
logout(): Promise<void> {
|
logout(): void {
|
||||||
return this.userManager.signoutRedirect();
|
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
||||||
|
this.isAuthenticated.set(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccessToken(): Promise<string | undefined> {
|
async getAccessToken(): Promise<string | undefined> {
|
||||||
const user = await this.userManager.getUser();
|
return this.hasValidAccessToken() ? (sessionStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined) : undefined;
|
||||||
return user && !user.expired ? user.access_token : undefined;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
frontend/src/app/auth/pkce.spec.ts
Normal file
21
frontend/src/app/auth/pkce.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateCodeChallenge, generateRandomString } from './pkce';
|
||||||
|
|
||||||
|
describe('pkce', () => {
|
||||||
|
it('computes the RFC 7636 Appendix B S256 test vector', async () => {
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc7636#appendix-B
|
||||||
|
const codeVerifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
||||||
|
const challenge = await generateCodeChallenge(codeVerifier);
|
||||||
|
expect(challenge).toBe('E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates a URL-safe random string of the requested length family', () => {
|
||||||
|
const value = generateRandomString();
|
||||||
|
expect(value).toMatch(/^[A-Za-z0-9_-]+$/);
|
||||||
|
expect(value.length).toBeGreaterThanOrEqual(43);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates different values on each call', () => {
|
||||||
|
expect(generateRandomString()).not.toBe(generateRandomString());
|
||||||
|
});
|
||||||
|
});
|
||||||
16
frontend/src/app/auth/pkce.ts
Normal file
16
frontend/src/app/auth/pkce.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
function base64UrlEncode(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomString(byteLength = 32): string {
|
||||||
|
const bytes = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return base64UrlEncode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCodeChallenge(codeVerifier: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
|
||||||
|
return base64UrlEncode(new Uint8Array(digest));
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@ export const environment = {
|
|||||||
production: false,
|
production: false,
|
||||||
apiBaseUrl: '/api/v1',
|
apiBaseUrl: '/api/v1',
|
||||||
oidc: {
|
oidc: {
|
||||||
issuer: 'https://idp.example.invalid/realms/travel-planner',
|
issuer: 'https://auth.forgecore.work',
|
||||||
clientId: 'travel-planner-web',
|
clientId: 'client_a297fd8d9c1f47a79d3600ea0c96984',
|
||||||
redirectUri: `${window.location.origin}/auth/callback`,
|
redirectUri: `${window.location.origin}/auth/callback`,
|
||||||
scope: 'openid profile email',
|
scope: 'openid profile email',
|
||||||
},
|
},
|
||||||
|
|||||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -141,9 +141,6 @@ importers:
|
|||||||
'@angular/service-worker':
|
'@angular/service-worker':
|
||||||
specifier: ^21.2.0
|
specifier: ^21.2.0
|
||||||
version: 21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2))(rxjs@7.8.2)
|
version: 21.2.20(@angular/core@21.2.20(@angular/compiler@21.2.20)(rxjs@7.8.2))(rxjs@7.8.2)
|
||||||
oidc-client-ts:
|
|
||||||
specifier: ^3.5.0
|
|
||||||
version: 3.5.0
|
|
||||||
rxjs:
|
rxjs:
|
||||||
specifier: ~7.8.0
|
specifier: ~7.8.0
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
@@ -3537,10 +3534,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||||
engines: {'0': node >= 0.2.0}
|
engines: {'0': node >= 0.2.0}
|
||||||
|
|
||||||
jwt-decode@4.0.0:
|
|
||||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -3876,10 +3869,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||||
engines: {node: '>=12.20.0'}
|
engines: {node: '>=12.20.0'}
|
||||||
|
|
||||||
oidc-client-ts@3.5.0:
|
|
||||||
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
|
|
||||||
engines: {node: '>=18'}
|
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@@ -8690,8 +8679,6 @@ snapshots:
|
|||||||
|
|
||||||
jsonparse@1.3.1: {}
|
jsonparse@1.3.1: {}
|
||||||
|
|
||||||
jwt-decode@4.0.0: {}
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -9041,10 +9028,6 @@ snapshots:
|
|||||||
|
|
||||||
obug@2.1.4: {}
|
obug@2.1.4: {}
|
||||||
|
|
||||||
oidc-client-ts@3.5.0:
|
|
||||||
dependencies:
|
|
||||||
jwt-decode: 4.0.0
|
|
||||||
|
|
||||||
on-finished@2.4.1:
|
on-finished@2.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ee-first: 1.1.1
|
ee-first: 1.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user