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_AUDIENCE=travel-planner-api
|
||||
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
|
||||
pnpm install
|
||||
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
|
||||
export DATABASE_URL=postgresql://travel_planner:travel_planner_dev@localhost:5432/travel_planner
|
||||
export REDIS_URL=redis://localhost:6379
|
||||
export OIDC_ISSUER=https://idp.example.invalid/realms/travel-planner
|
||||
export OIDC_AUDIENCE=travel-planner-api
|
||||
pnpm --filter backend build:api && node backend/dist/apps/api/src/migration.js
|
||||
export OIDC_ISSUER=https://auth.forgecore.work
|
||||
export OIDC_CLIENT_ID=client_a297fd8d9c1f47a79d3600ea0c96984
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
- 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.
|
||||
- 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).
|
||||
- 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.
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
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 { OidcDiscoveryService } from './oidc-discovery.service';
|
||||
import { OidcAuthGuard } from './oidc-auth.guard';
|
||||
import { TokenExchangeService } from './token-exchange.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [UsersLibModule],
|
||||
providers: [OidcDiscoveryService, OidcAuthGuard],
|
||||
exports: [OidcDiscoveryService, OidcAuthGuard, UsersLibModule],
|
||||
providers: [OidcDiscoveryService, OidcAuthGuard, TokenExchangeService],
|
||||
exports: [
|
||||
OidcDiscoveryService,
|
||||
OidcAuthGuard,
|
||||
TokenExchangeService,
|
||||
UsersLibModule,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './oidc-discovery.service';
|
||||
export * from './oidc-auth.guard';
|
||||
export * from './token-exchange.service';
|
||||
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 {
|
||||
jwks_uri: string;
|
||||
token_endpoint: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OidcDiscoveryService implements OnModuleInit {
|
||||
private verificationKeySet: JWTVerifyGetKey | undefined;
|
||||
private tokenEndpoint: string | undefined;
|
||||
|
||||
constructor(
|
||||
@Inject(APP_ENVIRONMENT) private readonly environment: AppEnvironment,
|
||||
@@ -26,6 +28,7 @@ export class OidcDiscoveryService implements OnModuleInit {
|
||||
}
|
||||
const document = (await response.json()) as OidcDiscoveryDocument;
|
||||
this.verificationKeySet = createRemoteJWKSet(new URL(document.jwks_uri));
|
||||
this.tokenEndpoint = document.token_endpoint;
|
||||
}
|
||||
|
||||
getIssuer(): string {
|
||||
@@ -42,4 +45,11 @@ export class OidcDiscoveryService implements OnModuleInit {
|
||||
}
|
||||
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',
|
||||
OIDC_ISSUER: 'https://idp.example.test/',
|
||||
OIDC_AUDIENCE: 'travel-planner-api',
|
||||
OIDC_CLIENT_ID: 'test-client',
|
||||
OIDC_CLIENT_SECRET: 'test-secret',
|
||||
APP_VERSION: '1.2.3',
|
||||
TEAMCITY_BUILD_NUMBER: '42',
|
||||
SOURCE_REVISION: 'abc123',
|
||||
@@ -21,6 +23,8 @@ describe('loadEnvironment', () => {
|
||||
redisUrl: 'redis://redis:6379',
|
||||
oidcIssuer: 'https://idp.example.test/',
|
||||
oidcAudience: 'travel-planner-api',
|
||||
oidcClientId: 'test-client',
|
||||
oidcClientSecret: 'test-secret',
|
||||
appVersion: '1.2.3',
|
||||
teamCityBuildNumber: '42',
|
||||
sourceRevision: 'abc123',
|
||||
|
||||
@@ -3,6 +3,8 @@ export interface AppEnvironment {
|
||||
redisUrl: string;
|
||||
oidcIssuer: string;
|
||||
oidcAudience: string;
|
||||
oidcClientId: string;
|
||||
oidcClientSecret: string;
|
||||
appVersion: string;
|
||||
teamCityBuildNumber: string;
|
||||
sourceRevision: string;
|
||||
@@ -20,6 +22,8 @@ export function loadEnvironment(env: NodeJS.ProcessEnv): AppEnvironment {
|
||||
redisUrl: required(env, 'REDIS_URL'),
|
||||
oidcIssuer: required(env, 'OIDC_ISSUER'),
|
||||
oidcAudience: required(env, 'OIDC_AUDIENCE'),
|
||||
oidcClientId: required(env, 'OIDC_CLIENT_ID'),
|
||||
oidcClientSecret: required(env, 'OIDC_CLIENT_SECRET'),
|
||||
appVersion: env.APP_VERSION?.trim() || 'dev',
|
||||
teamCityBuildNumber: env.TEAMCITY_BUILD_NUMBER?.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.OIDC_ISSUER ??= 'https://idp.example.test/';
|
||||
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"
|
||||
OIDC_ISSUER: "${OIDC_ISSUER}"
|
||||
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||
APP_VERSION: "${APP_VERSION:-dev}"
|
||||
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-local}"
|
||||
SOURCE_REVISION: "${SOURCE_REVISION:-local}"
|
||||
@@ -57,6 +59,8 @@ services:
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
OIDC_ISSUER: "${OIDC_ISSUER}"
|
||||
OIDC_AUDIENCE: "${OIDC_AUDIENCE}"
|
||||
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}"
|
||||
APP_VERSION: "${APP_VERSION:-dev}"
|
||||
TEAMCITY_BUILD_NUMBER: "${TEAMCITY_BUILD_NUMBER:-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).
|
||||
|
||||
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": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"@angular/platform-browser": "^21.2.0",
|
||||
"@angular/router": "^21.2.0",
|
||||
"@angular/service-worker": "^21.2.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"rxjs": "~7.8.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 { describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
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);
|
||||
await Promise.resolve();
|
||||
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 { UserManager } from 'oidc-client-ts';
|
||||
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' })
|
||||
export class AuthService {
|
||||
private readonly userManager = new UserManager({
|
||||
authority: environment.oidc.issuer,
|
||||
client_id: environment.oidc.clientId,
|
||||
redirect_uri: environment.oidc.redirectUri,
|
||||
scope: environment.oidc.scope,
|
||||
response_type: 'code',
|
||||
});
|
||||
private discoveryPromise: Promise<DiscoveryDocument> | undefined;
|
||||
|
||||
readonly isAuthenticated = signal(false);
|
||||
readonly isAuthenticated = signal(this.hasValidAccessToken());
|
||||
|
||||
constructor() {
|
||||
this.userManager.events.addUserLoaded(() => this.isAuthenticated.set(true));
|
||||
this.userManager.events.addUserUnloaded(() => this.isAuthenticated.set(false));
|
||||
void this.userManager.getUser().then((user) => this.isAuthenticated.set(!!user && !user.expired));
|
||||
private hasValidAccessToken(): boolean {
|
||||
const expiresAt = Number(sessionStorage.getItem(EXPIRES_AT_KEY) ?? 0);
|
||||
return !!sessionStorage.getItem(ACCESS_TOKEN_KEY) && Date.now() < expiresAt;
|
||||
}
|
||||
|
||||
login(): Promise<void> {
|
||||
return this.userManager.signinRedirect();
|
||||
private discover(): Promise<DiscoveryDocument> {
|
||||
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> {
|
||||
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> {
|
||||
return this.userManager.signoutRedirect();
|
||||
logout(): void {
|
||||
sessionStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
sessionStorage.removeItem(EXPIRES_AT_KEY);
|
||||
this.isAuthenticated.set(false);
|
||||
}
|
||||
|
||||
async getAccessToken(): Promise<string | undefined> {
|
||||
const user = await this.userManager.getUser();
|
||||
return user && !user.expired ? user.access_token : undefined;
|
||||
return this.hasValidAccessToken() ? (sessionStorage.getItem(ACCESS_TOKEN_KEY) ?? undefined) : 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,
|
||||
apiBaseUrl: '/api/v1',
|
||||
oidc: {
|
||||
issuer: 'https://idp.example.invalid/realms/travel-planner',
|
||||
clientId: 'travel-planner-web',
|
||||
issuer: 'https://auth.forgecore.work',
|
||||
clientId: 'client_a297fd8d9c1f47a79d3600ea0c96984',
|
||||
redirectUri: `${window.location.origin}/auth/callback`,
|
||||
scope: 'openid profile email',
|
||||
},
|
||||
|
||||
17
pnpm-lock.yaml
generated
17
pnpm-lock.yaml
generated
@@ -141,9 +141,6 @@ importers:
|
||||
'@angular/service-worker':
|
||||
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)
|
||||
oidc-client-ts:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
rxjs:
|
||||
specifier: ~7.8.0
|
||||
version: 7.8.2
|
||||
@@ -3537,10 +3534,6 @@ packages:
|
||||
resolution: {integrity: sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==}
|
||||
engines: {'0': node >= 0.2.0}
|
||||
|
||||
jwt-decode@4.0.0:
|
||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -3876,10 +3869,6 @@ packages:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -8690,8 +8679,6 @@ snapshots:
|
||||
|
||||
jsonparse@1.3.1: {}
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
@@ -9041,10 +9028,6 @@ snapshots:
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
oidc-client-ts@3.5.0:
|
||||
dependencies:
|
||||
jwt-decode: 4.0.0
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
|
||||
Reference in New Issue
Block a user