@celilo/console-server 0.4.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/console-server",
3
- "version": "0.4.4",
3
+ "version": "0.5.0",
4
4
  "description": "HTTP surface for the celilo web console. Serves the SPA and hosts the tsrpc API. Holds no database: it reaches celilo-mgr over the SSH remote API as a read-only principal.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/main.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
15
+ import { oidcBrowserGate } from './oidc-login';
15
16
  import { type AuthGate, type AuthSubject, serve } from './serve';
16
17
 
17
18
  /**
@@ -108,15 +109,28 @@ export function startFromEnv(env: Record<string, string | undefined> = process.e
108
109
  port: number;
109
110
  stop: () => void;
110
111
  } {
112
+ // The Bearer half of the gate. The browser half — the authorization-code +
113
+ // PKCE flow — is layered in front of it by `oidcBrowserGate`, which
114
+ // delegates bearer verification here so the JWKS policy stays in one place.
115
+ const bearerGate = oidcGate({
116
+ issuer: required(env, 'OIDC_ISSUER'),
117
+ clientId: required(env, 'OIDC_CLIENT_ID'),
118
+ });
119
+ const login = oidcBrowserGate(
120
+ {
121
+ issuer: required(env, 'OIDC_ISSUER'),
122
+ clientId: required(env, 'OIDC_CLIENT_ID'),
123
+ clientSecret: required(env, 'OIDC_CLIENT_SECRET'),
124
+ },
125
+ bearerGate,
126
+ );
111
127
  const server = serve({
112
128
  port: Number(env.PORT ?? 8443),
113
129
  dest: env.CELILO_API_DEST ?? DEFAULT_DEST,
114
130
  identityFile: env.CELILO_IDENTITY_FILE ?? DEFAULT_IDENTITY_FILE,
115
131
  ...(env.CONSOLE_SPA_DIR ? { spaDir: env.CONSOLE_SPA_DIR } : {}),
116
- authenticate: oidcGate({
117
- issuer: required(env, 'OIDC_ISSUER'),
118
- clientId: required(env, 'OIDC_CLIENT_ID'),
119
- }),
132
+ authenticate: login.authenticate,
133
+ login: { handleCallback: login.handleCallback, redirectToLogin: login.redirectUri },
120
134
  });
121
135
  console.log(`[console] listening on :${server.port}`);
122
136
  return server;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The console's login: the OIDC authorization-code + PKCE flow, run by the app.
3
+ *
4
+ * D8 settles both halves of this. The console authenticates PEOPLE, and it
5
+ * authenticates them itself: an unauthenticated browser is redirected to the
6
+ * identity provider, the provider sends back a code, the app exchanges it
7
+ * (confidential client, so the secret stays server-side) and verifies the
8
+ * resulting id_token against the provider's JWKS. Nothing ever trusts a header
9
+ * or a client-supplied assertion.
10
+ *
11
+ * What OIDC is NOT here is worth restating, because claiming more for it is
12
+ * how the caddy-internal mistake gets made a second time (design.md D7, D8):
13
+ * the primary access control is the control-plane VPN key. OIDC supplies
14
+ * identity — an acknowledgement records WHO clicked — and a second factor
15
+ * against a forgotten unlocked laptop. It is not the wall.
16
+ *
17
+ * ## Why a cookie session rather than the id_token itself
18
+ *
19
+ * The id_token is a JWT meant for the app, not for the browser. Storing it in
20
+ * a cookie would hand a token that satisfies `oidcGate`'s Bearer check to
21
+ * JavaScript, and a shorter-lived opaque session token is the cheaper secret
22
+ * to lose. The session maps to the subject the token was verified against,
23
+ * and nothing else is carried.
24
+ *
25
+ * ## Why the pending-flow state is in memory
26
+ *
27
+ * A `state`/PKCE-verifier pair waiting for its callback lives only as long as
28
+ * the login it belongs to, and the console is one process. A store that
29
+ * survives restart would let a half-finished login outlive the key it was
30
+ * minted against. The maps are bounded by expiry sweeps, so a client that
31
+ * starts flows and never finishes them cannot grow them without limit.
32
+ */
33
+
34
+ import { randomBytes } from 'node:crypto';
35
+ import { createRemoteJWKSet, customFetch, jwtVerify } from 'jose';
36
+ import type { AuthGate, AuthSubject } from './serve';
37
+
38
+ /** The path the identity provider sends the browser back to. Must be one of the redirect_uris registered at client creation. */
39
+ export const CALLBACK_PATH = '/auth/callback';
40
+
41
+ /** Name of the session cookie the callback sets. */
42
+ export const SESSION_COOKIE = 'console_session';
43
+
44
+ /** A login in progress: the state that identifies it and the PKCE verifier that completes it. */
45
+ interface PendingLogin {
46
+ verifier: string;
47
+ expiresAt: number;
48
+ }
49
+
50
+ /** A completed, verified login. */
51
+ interface Session {
52
+ subject: string;
53
+ name?: string;
54
+ expiresAt: number;
55
+ }
56
+
57
+ /** Milliseconds a completed session stays valid. */
58
+ const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
59
+ /** Milliseconds a started-but-never-finished login is kept. */
60
+ const PENDING_TTL_MS = 10 * 60 * 1000;
61
+
62
+ /**
63
+ * The OIDC endpoints the flow needs, read from discovery.
64
+ *
65
+ * Per-application issuer for authentik is `https://auth.<domain>/application/o/<slug>/`,
66
+ * and its discovery document lives one level below that. Deriving endpoint
67
+ * URLs by appending known paths works on some providers and silently fails on
68
+ * this one, which is why `oidcGate` already discovers — the same reasoning
69
+ * applies to the authorization and token endpoints.
70
+ */
71
+ interface DiscoveryDocument {
72
+ authorization_endpoint?: string;
73
+ token_endpoint?: string;
74
+ jwks_uri?: string;
75
+ }
76
+
77
+ export interface OidcBrowserGateOptions {
78
+ /** The `iss` claim exactly — authentik's per-application issuer. */
79
+ issuer: string;
80
+ /** Also the expected audience: authentik puts the client id in `aud`. */
81
+ clientId: string;
82
+ /** The confidential client's secret, needed to exchange the code. */
83
+ clientSecret: string;
84
+ /** Injectable so tests drive the flow without a live provider. */
85
+ fetchImpl?: typeof fetch;
86
+ /** Clock injection for expiry sweeps in tests. */
87
+ now?: () => number;
88
+ }
89
+
90
+ export interface OidcBrowserGate {
91
+ /**
92
+ * Who is asking, from a session cookie or a Bearer token.
93
+ *
94
+ * The Bearer half is `oidcGate`'s contract unchanged: an API client that
95
+ * presents a provider-verified token directly is answered, and the shape is
96
+ * what tests already drive.
97
+ */
98
+ authenticate: AuthGate;
99
+ /**
100
+ * Answer the callback, or `null` when the request is not one.
101
+ *
102
+ * Runs BEFORE `authenticate` on every request: the callback arrives from a
103
+ * browser that has no session yet, so running the gate first would refuse
104
+ * the one request whose job is to create one.
105
+ */
106
+ handleCallback: (request: Request) => Promise<Response | null>;
107
+ /**
108
+ * Send an unauthenticated browser to the identity provider.
109
+ *
110
+ * A page navigation rather than an API call, so a redirect is the right
111
+ * refusal: the person ends up on a login form instead of an error.
112
+ */
113
+ redirectUri: (request: Request) => Promise<Response>;
114
+ }
115
+
116
+ /**
117
+ * The discovery document, fetched lazily and memoized — the same policy
118
+ * `oidcGate` applies to the JWKS, for the same reason: an eager fetch turns a
119
+ * slow provider into a console that will not boot.
120
+ */
121
+ export class OidcLoginFlow {
122
+ private readonly doFetch: typeof fetch;
123
+ private readonly nowFn: () => number;
124
+ private readonly pending = new Map<string, PendingLogin>();
125
+ private readonly sessions = new Map<string, Session>();
126
+ private discovery: DiscoveryDocument | undefined;
127
+ private keys: ReturnType<typeof createRemoteJWKSet> | undefined;
128
+
129
+ constructor(
130
+ private readonly options: OidcBrowserGateOptions,
131
+ private readonly jwksFallback: AuthGate,
132
+ ) {
133
+ this.doFetch = options.fetchImpl ?? fetch;
134
+ this.nowFn = options.now ?? Date.now;
135
+ }
136
+
137
+ private async discoveryDoc(): Promise<DiscoveryDocument> {
138
+ if (this.discovery === undefined) {
139
+ const url = new URL('.well-known/openid-configuration', this.options.issuer);
140
+ const response = await this.doFetch(url);
141
+ if (!response.ok) {
142
+ throw new Error(`OIDC discovery at ${url} returned ${response.status}`);
143
+ }
144
+ const document = (await response.json()) as DiscoveryDocument;
145
+ if (
146
+ typeof document.authorization_endpoint !== 'string' ||
147
+ typeof document.token_endpoint !== 'string' ||
148
+ typeof document.jwks_uri !== 'string'
149
+ ) {
150
+ throw new Error(
151
+ `OIDC discovery at ${url} carried no authorization, token or jwks endpoint`,
152
+ );
153
+ }
154
+ this.discovery = document;
155
+ }
156
+ return this.discovery;
157
+ }
158
+
159
+ private async endpoints(): Promise<{ authorize: string; token: string }> {
160
+ const document = await this.discoveryDoc();
161
+ return {
162
+ authorize: document.authorization_endpoint as string,
163
+ token: document.token_endpoint as string,
164
+ };
165
+ }
166
+
167
+ private async keysFor(): Promise<ReturnType<typeof createRemoteJWKSet>> {
168
+ if (this.keys !== undefined) return this.keys;
169
+ const document = await this.discoveryDoc();
170
+ this.keys = createRemoteJWKSet(new URL(document.jwks_uri as string), {
171
+ [customFetch]: (target, init) => this.doFetch(target, init),
172
+ });
173
+ return this.keys;
174
+ }
175
+
176
+ /** Verify a provider token, or say nothing about why it failed (an expired token is a refusal, not an error report). */
177
+ private async verify(idToken: string): Promise<AuthSubject | null> {
178
+ try {
179
+ const { payload } = await jwtVerify(idToken, await this.keysFor(), {
180
+ issuer: this.options.issuer,
181
+ audience: this.options.clientId,
182
+ });
183
+ const name = [payload.preferred_username, payload.name, payload.email].find(
184
+ (value): value is string => typeof value === 'string' && value.length > 0,
185
+ );
186
+ return { subject: String(payload.sub), ...(name === undefined ? {} : { name }) };
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ private sweep(): void {
193
+ const now = this.nowFn();
194
+ for (const [key, login] of this.pending) {
195
+ if (login.expiresAt <= now) this.pending.delete(key);
196
+ }
197
+ for (const [key, session] of this.sessions) {
198
+ if (session.expiresAt <= now) this.sessions.delete(key);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Start a login: build the provider redirect with PKCE + state.
204
+ *
205
+ * The redirect_uri is derived from the REQUEST, not from config. The
206
+ * console serves whatever address the VPN peer dialed — a zone-side IP and
207
+ * port — and authentik refuses any redirect it was not registered with, so
208
+ * inventing the origin here would be wrong exactly when the request is the
209
+ * evidence of what was registered.
210
+ */
211
+ async redirect(request: Request): Promise<Response> {
212
+ this.sweep();
213
+ const { authorize } = await this.endpoints();
214
+ const verifier = randomBytes(32).toString('base64url');
215
+ const challenge = Buffer.from(
216
+ await crypto.subtle.digest('SHA-256', Buffer.from(verifier)),
217
+ ).toString('base64url');
218
+ const state = randomBytes(16).toString('base64url');
219
+ this.pending.set(state, { verifier, expiresAt: this.nowFn() + PENDING_TTL_MS });
220
+
221
+ const origin = new URL(request.url).origin;
222
+ const target = new URL(authorize);
223
+ target.searchParams.set('response_type', 'code');
224
+ target.searchParams.set('client_id', this.options.clientId);
225
+ target.searchParams.set('redirect_uri', `${origin}${CALLBACK_PATH}`);
226
+ target.searchParams.set('scope', 'openid profile');
227
+ target.searchParams.set('state', state);
228
+ target.searchParams.set('code_challenge', challenge);
229
+ target.searchParams.set('code_challenge_method', 'S256');
230
+ return new Response(null, { status: 302, headers: { location: target.toString() } });
231
+ }
232
+
233
+ /**
234
+ * Finish a login, or `null` when this request is not a callback.
235
+ *
236
+ * Every failure inside a real callback is a redirect to the provider to try
237
+ * again rather than an error page, for the same reason the initial refusal
238
+ * is a redirect: the person at the browser has no other way forward.
239
+ */
240
+ async handleCallback(request: Request): Promise<Response | null> {
241
+ const url = new URL(request.url);
242
+ if (url.pathname !== CALLBACK_PATH) return null;
243
+
244
+ const code = url.searchParams.get('code');
245
+ const state = url.searchParams.get('state');
246
+ const pending = typeof state === 'string' ? this.pending.get(state) : undefined;
247
+ if (typeof state === 'string' && pending !== undefined) this.pending.delete(state);
248
+ if (code === null || pending === undefined) {
249
+ return this.redirect(request);
250
+ }
251
+ if (pending.expiresAt <= this.nowFn()) {
252
+ return this.redirect(request);
253
+ }
254
+
255
+ const { token } = await this.endpoints();
256
+ const origin = url.origin;
257
+ const form = new URLSearchParams({
258
+ grant_type: 'authorization_code',
259
+ code,
260
+ redirect_uri: `${origin}${CALLBACK_PATH}`,
261
+ client_id: this.options.clientId,
262
+ client_secret: this.options.clientSecret,
263
+ code_verifier: pending.verifier,
264
+ });
265
+ const response = await this.doFetch(token, {
266
+ method: 'POST',
267
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
268
+ body: form.toString(),
269
+ });
270
+ if (!response.ok) {
271
+ return this.redirect(request);
272
+ }
273
+ const tokens = (await response.json()) as { id_token?: string };
274
+ if (typeof tokens.id_token !== 'string') {
275
+ return this.redirect(request);
276
+ }
277
+ const subject = await this.verify(tokens.id_token);
278
+ if (subject === null) {
279
+ return this.redirect(request);
280
+ }
281
+
282
+ const sessionToken = randomBytes(32).toString('base64url');
283
+ this.sessions.set(sessionToken, {
284
+ subject: subject.subject,
285
+ ...(subject.name === undefined ? {} : { name: subject.name }),
286
+ expiresAt: this.nowFn() + SESSION_TTL_MS,
287
+ });
288
+ return new Response(null, {
289
+ status: 303,
290
+ headers: {
291
+ location: '/',
292
+ 'set-cookie': `${SESSION_COOKIE}=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_MS / 1000}`,
293
+ },
294
+ });
295
+ }
296
+
297
+ /** The session a request carries, if any. Used by `authenticate`. */
298
+ private session(request: Request): AuthSubject | null {
299
+ const header = request.headers.get('cookie') ?? '';
300
+ const match = header
301
+ .split(';')
302
+ .map((part) => part.trim())
303
+ .find((part) => part.startsWith(`${SESSION_COOKIE}=`));
304
+ if (match === undefined) return null;
305
+ const token = match.slice(SESSION_COOKIE.length + 1);
306
+ const session = this.sessions.get(token);
307
+ if (session === undefined) return null;
308
+ if (session.expiresAt <= this.nowFn()) {
309
+ this.sessions.delete(token);
310
+ return null;
311
+ }
312
+ return {
313
+ subject: session.subject,
314
+ ...(session.name === undefined ? {} : { name: session.name }),
315
+ };
316
+ }
317
+
318
+ authenticate: AuthGate = async (request) => {
319
+ this.sweep();
320
+ const fromSession = this.session(request);
321
+ if (fromSession !== null) return fromSession;
322
+ return this.jwksFallback(request);
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Build the login flow and the two entry points `serve` drives.
328
+ *
329
+ * `jwksFallback` is the Bearer path (`oidcGate`): the flow verifies the
330
+ * id_token it exchanges itself, and delegates bearer verification to the same
331
+ * gate the process already had, so there is one JWKS policy, not two.
332
+ */
333
+ export function oidcBrowserGate(
334
+ options: OidcBrowserGateOptions,
335
+ jwksFallback: AuthGate,
336
+ ): OidcBrowserGate {
337
+ const flow = new OidcLoginFlow(options, jwksFallback);
338
+ return {
339
+ authenticate: flow.authenticate,
340
+ handleCallback: (request) => flow.handleCallback(request),
341
+ redirectUri: (request) => flow.redirect(request),
342
+ };
343
+ }
package/src/serve.ts CHANGED
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { join } from 'node:path';
19
19
  import type { ServiceType } from '@celilo/console-protocol';
20
+ import { CALLBACK_PATH } from './oidc-login';
20
21
  import { Upstream, type UpstreamOptions, UpstreamUnavailable } from './upstream';
21
22
  import {
22
23
  ackAlert,
@@ -47,6 +48,23 @@ export interface ServeOptions {
47
48
  spaDir?: string;
48
49
  /** Decides who is asking. See `AuthGate`. */
49
50
  authenticate: AuthGate;
51
+ /**
52
+ * The browser login, when the deployment runs the OIDC flow (D8).
53
+ *
54
+ * Two hooks, and the split is the whole design. `handleCallback` answers the
55
+ * provider's redirect — it must run BEFORE the gate, because the one request
56
+ * whose job is to CREATE a session arrives without one. `redirectToLogin`
57
+ * is how a page navigation is refused: a person at a browser gets the login
58
+ * form, not an error page.
59
+ *
60
+ * API calls are refused differently — 401, below — because a `fetch()` that
61
+ * follows a 302 to an HTML login page reports a JSON parse error and sends
62
+ * the reader looking at the wrong layer.
63
+ */
64
+ login?: {
65
+ handleCallback: (request: Request) => Promise<Response | null>;
66
+ redirectToLogin: (request: Request) => Promise<Response>;
67
+ };
50
68
  cacheTtlMs?: number;
51
69
  /**
52
70
  * Injectable so tests drive the whole surface without SSH, matching
@@ -236,11 +254,22 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
236
254
  return async (request: Request): Promise<Response> => {
237
255
  const url = new URL(request.url);
238
256
 
257
+ // The callback runs before any gate. It arrives from a browser that has
258
+ // completed the provider's login and holds no session yet, so gating it
259
+ // would refuse the only request that can create one.
260
+ if (options.login) {
261
+ const answered = await options.login.handleCallback(request);
262
+ if (answered !== null) return answered;
263
+ }
264
+
239
265
  // The gate runs FIRST, for every path including the SPA. Serving the shell
240
266
  // to an unauthenticated caller would leak the fleet's shape through the
241
267
  // bundle even with every API call refused.
242
268
  const subject = await options.authenticate(request);
243
269
  if (subject === null) {
270
+ if (options.login && !url.pathname.startsWith('/api/') && url.pathname !== CALLBACK_PATH) {
271
+ return options.login.redirectToLogin(request);
272
+ }
244
273
  return new Response('Unauthorized', {
245
274
  status: 401,
246
275
  headers: { 'WWW-Authenticate': 'Bearer' },