Skip to content

Commit 233a47c

Browse files
author
David Ruzicka
committed
docs(planning): narrow phase 3 client auth gate to inline API keys only
1 parent 03e8ace commit 233a47c

5 files changed

Lines changed: 95 additions & 215 deletions

File tree

.planning/ROADMAP.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ Plans:
6060
**Depends on**: Phase 1
6161
**Requirements**: AUTH-02, AUTH-03 (partial)
6262
**Success Criteria** (what must be TRUE):
63-
1. An inbound M2M client presenting an API key is validated against the configured key store (inline env-var keys or Sasanka) and resolved to a client identity before session establishment
63+
1. An inbound M2M client presenting an API key is validated against the configured key store (inline env-var keys) and resolved to a client identity before session establishment; Sasanka token-passthrough store added in Phase 4
6464
2. An invalid or missing API key when mode=required is rejected with HTTP 401 before any upstream connection
6565
3. The resolved client identity (API key path) is attached to the session as clientPrincipal and included in session-creation log entries
6666
**Plans**: 3 plans
6767

6868
Plans:
6969
- [ ] 03-01-PLAN.md - Types (ApiKeyStoreConfig, ClientAuthGateConfig without jwt), ClientAuthGateError, schema sync, and profile-load-time validator (AUTH-02, AUTH-03)
70-
- [ ] 03-02-PLAN.md - ApiKeyStore interface, InlineApiKeyStore, SasankaApiKeyStore, and factory (AUTH-02)
70+
- [ ] 03-02-PLAN.md - ApiKeyStore interface, InlineApiKeyStore, and factory (AUTH-02; SasankaApiKeyStore deferred to Phase 4)
7171
- [ ] 03-03-PLAN.md - ClientAuthGate orchestrator (API key path only), http-transport wiring, session clientPrincipal attachment (AUTH-02, AUTH-03)
7272

7373
### Phase 4: Client Authentication Gate (OIDC JWT)
@@ -83,6 +83,7 @@ Plans:
8383
Plans:
8484
- [ ] 04-01: ClientAuthJwtConfig types, oidc-discovery utility, EnterpriseAuthProvider refactor (AUTH-01)
8585
- [ ] 04-02: JWT path in ClientAuthGate, JwksCache wiring, integration tests (AUTH-01, AUTH-03)
86+
- [ ] 04-03: SasankaApiKeyStore (token-passthrough via /api/v1/users/me), sasanka variant in ApiKeyStoreConfig, factory extension (AUTH-02)
8687

8788
### Phase 5: Observability
8889
**Goal**: Every tool call is audited with identity and outcome; operators have metrics and health endpoints to monitor the gateway

.planning/phases/03-client-authentication-gate/03-01-PLAN.md

Lines changed: 26 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,8 @@ export function validateEnterpriseAuthorizationProfile(profile: Profile): Enterp
139139
}
140140

141141
export type ApiKeyStoreConfig =
142-
| { type: 'inline'; keys: InlineApiKeyEntry[] }
143-
| {
144-
type: 'sasanka';
145-
/** Base URL for the Sasanka API. Defaults to https://sasanka.seznam.net */
146-
base_url?: string;
147-
/** Resolve base_url from env var. */
148-
base_url_from_env?: string;
149-
/** Request timeout in ms for /users/me. Default: 5000. */
150-
timeout_ms?: number;
151-
};
142+
| { type: 'inline'; keys: InlineApiKeyEntry[] };
143+
// Phase 4 adds: | { type: 'sasanka'; base_url?: string; base_url_from_env?: string; timeout_ms?: number }
152144

153145
export interface ClientAuthGateConfig {
154146
/** 'required' (default): reject session if no valid identity resolved. 'optional': allow anonymous sessions. */
@@ -205,20 +197,23 @@ export function validateEnterpriseAuthorizationProfile(profile: Profile): Enterp
205197
<verify>
206198
<automated>npm run generate-schemas && npm run check-schema-sync && npm run typecheck</automated>
207199
</verify>
208-
<done>All three commands exit 0. ClientAuthGateConfig, ApiKeyStoreConfig, ClientAuthJwtConfig appear in src/generated-schemas.ts. SessionData.clientPrincipal typed as AuthorizedPrincipal. ClientAuthGateError exported from src/core/errors.ts. HttpProfileContext.client_auth_gate and HttpTransportConfig.client_auth_gate present.</done>
200+
<done>All three commands exit 0. ClientAuthGateConfig and ApiKeyStoreConfig appear in src/generated-schemas.ts (NO ClientAuthJwtConfigPhase 4). SessionData.clientPrincipal typed as AuthorizedPrincipal. ClientAuthGateError exported from src/core/errors.ts. HttpProfileContext.client_auth_gate and HttpTransportConfig.client_auth_gate present.</done>
209201
</task>
210202

211203
<task type="auto" tdd="true">
212204
<name>Task 2: Implement validateClientAuthGateProfile() validator and wire into profile-loader</name>
213205
<files>src/profile/client-auth-gate-validator.ts, src/profile/client-auth-gate-validator.test.ts, src/profile/profile-loader.ts</files>
214206
<behavior>
215-
- Valid profile with api_keys inline (non-empty keys array, each entry has key_from_env + subject) returns config
216-
- Valid profile with api_keys sasanka returns config
207+
- Valid profile with api_keys inline (non-empty keys array, each entry has key_from_env + subject, env vars set) returns config
208+
- Profile with api_keys.type='sasanka' throws ClientAuthGateError (not supported until Phase 4)
217209
- Profile with api_keys.type='vault' throws ClientAuthGateError (unknown backend)
218210
- Profile with api_keys inline and empty keys[] throws ClientAuthGateError
211+
- Profile with api_keys inline where key_from_env is "" throws ClientAuthGateError (trim check)
212+
- Profile with api_keys inline where key_from_env env var is not set throws ClientAuthGateError (fail-fast)
219213
- Profile with no client_auth_gate field returns undefined (no-op)
214+
- Profile with OAuth interceptor AND client_auth_gate throws ClientAuthGateError (mutual exclusion)
220215
- env var resolution — mode_from_env: set to 'optional'mode resolved to 'optional'; unsetthrows ClientAuthGateError
221-
- env var resolution — base_url_from_env (sasanka): set base_url resolved; unsetthrows ClientAuthGateError
216+
- env var resolution — mode_from_env set to invalid value 'admin' → throws ClientAuthGateError
222217
</behavior>
223218
<action>
224219
Create src/profile/client-auth-gate-validator.ts following the pattern of enterprise-profile-validator.ts:
@@ -229,9 +224,8 @@ export function validateEnterpriseAuthorizationProfile(profile: Profile): Enterp
229224
import { ClientAuthGateError } from '../core/errors.js';
230225
import type { ClientAuthGateConfig, Profile } from '../types/profile.js';
231226
232-
const DEFAULT_SASANKA_BASE_URL = 'https://sasanka.seznam.net';
233-
const DEFAULT_TIMEOUT_MS = 5000;
234-
const ALLOWED_API_KEY_TYPES = ['inline', 'sasanka'] as const;
227+
const ALLOWED_API_KEY_TYPES = ['inline'] as const;
228+
// Phase 4 adds 'sasanka' to ALLOWED_API_KEY_TYPES
235229
236230
function resolveEnv(value: string | undefined, fromEnv: string | undefined, fieldPath: string): string | undefined {
237231
if (value) return value;
@@ -247,6 +241,14 @@ export function validateEnterpriseAuthorizationProfile(profile: Profile): Enterp
247241
const config = profile.client_auth_gate;
248242
if (!config) return undefined;
249243

244+
// Mutual exclusion: client_auth_gate cannot coexist with OAuth interceptors
245+
const auths = profile.interceptors?.auth
246+
? (Array.isArray(profile.interceptors.auth) ? profile.interceptors.auth : [profile.interceptors.auth])
247+
: [];
248+
if (auths.some(a => a.type === 'oauth')) {
249+
throw new ClientAuthGateError('client_auth_gate cannot be combined with OAuth interceptors; configure one inbound auth method per profile');
250+
}
251+
250252
// Resolve mode
251253
const mode = resolveEnv(config.mode, config.mode_from_env, 'client_auth_gate.mode') ?? 'required';
252254
if (mode !== 'required' && mode !== 'optional') {
@@ -266,20 +268,16 @@ export function validateEnterpriseAuthorizationProfile(profile: Profile): Enterp
266268
throw new ClientAuthGateError('client_auth_gate.api_keys.keys must be a non-empty array for type=inline');
267269
}
268270
for (const entry of apiKeys.keys) {
269-
if (!entry.key_from_env) throw new ClientAuthGateError('client_auth_gate.api_keys.keys[].key_from_env is required');
270-
if (!entry.subject) throw new ClientAuthGateError('client_auth_gate.api_keys.keys[].subject is required');
271-
}
272-
}
273-
if (apiKeys.type === 'sasanka') {
274-
const baseUrl = resolveEnv(apiKeys.base_url, apiKeys.base_url_from_env, 'client_auth_gate.api_keys.base_url') ?? DEFAULT_SASANKA_BASE_URL;
275-
if (process.env.NODE_ENV !== 'test') {
276-
const parsed = new URL(baseUrl);
277-
if (parsed.protocol !== 'https:') {
278-
throw new ClientAuthGateError('client_auth_gate.api_keys.base_url must use https');
271+
if (!entry.key_from_env?.trim()) throw new ClientAuthGateError('client_auth_gate.api_keys.keys[].key_from_env is required');
272+
if (!entry.subject?.trim()) throw new ClientAuthGateError('client_auth_gate.api_keys.keys[].subject is required');
273+
// Fail-fast: catch misconfigured env vars at load time so operators get
274+
// an actionable error instead of silent all-key rejection at runtime.
275+
if (!process.env[entry.key_from_env]) {
276+
throw new ClientAuthGateError(`client_auth_gate.api_keys.keys[].key_from_env: env var '${entry.key_from_env}' is not set`);
279277
}
280278
}
281-
apiKeys = { ...apiKeys, base_url: baseUrl, timeout_ms: apiKeys.timeout_ms ?? DEFAULT_TIMEOUT_MS };
282279
}
280+
// Phase 4 adds sasanka branch here
283281
}
284282

285283
if (!apiKeys && mode === 'required') {

0 commit comments

Comments
 (0)