@celilo/console-server 0.4.4 → 0.5.1
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 +1 -1
- package/spa/assets/index-C31pEQ-J.js +26 -0
- package/spa/index.html +1 -1
- package/src/main.ts +18 -4
- package/src/oidc-login.ts +343 -0
- package/src/serve.ts +36 -2
- package/spa/assets/index-DJJXEYrW.js +0 -26
package/spa/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<title>celilo</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-C31pEQ-J.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-eLF_dgP-.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
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:
|
|
117
|
-
|
|
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
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
+
import { API_BASE_PATH } from '@celilo/console-protocol';
|
|
19
20
|
import type { ServiceType } from '@celilo/console-protocol';
|
|
21
|
+
import { CALLBACK_PATH } from './oidc-login';
|
|
20
22
|
import { Upstream, type UpstreamOptions, UpstreamUnavailable } from './upstream';
|
|
21
23
|
import {
|
|
22
24
|
ackAlert,
|
|
@@ -47,6 +49,23 @@ export interface ServeOptions {
|
|
|
47
49
|
spaDir?: string;
|
|
48
50
|
/** Decides who is asking. See `AuthGate`. */
|
|
49
51
|
authenticate: AuthGate;
|
|
52
|
+
/**
|
|
53
|
+
* The browser login, when the deployment runs the OIDC flow (D8).
|
|
54
|
+
*
|
|
55
|
+
* Two hooks, and the split is the whole design. `handleCallback` answers the
|
|
56
|
+
* provider's redirect — it must run BEFORE the gate, because the one request
|
|
57
|
+
* whose job is to CREATE a session arrives without one. `redirectToLogin`
|
|
58
|
+
* is how a page navigation is refused: a person at a browser gets the login
|
|
59
|
+
* form, not an error page.
|
|
60
|
+
*
|
|
61
|
+
* API calls are refused differently — 401, below — because a `fetch()` that
|
|
62
|
+
* follows a 302 to an HTML login page reports a JSON parse error and sends
|
|
63
|
+
* the reader looking at the wrong layer.
|
|
64
|
+
*/
|
|
65
|
+
login?: {
|
|
66
|
+
handleCallback: (request: Request) => Promise<Response | null>;
|
|
67
|
+
redirectToLogin: (request: Request) => Promise<Response>;
|
|
68
|
+
};
|
|
50
69
|
cacheTtlMs?: number;
|
|
51
70
|
/**
|
|
52
71
|
* Injectable so tests drive the whole surface without SSH, matching
|
|
@@ -236,19 +255,34 @@ export function createHandler(options: ServeOptions): (request: Request) => Prom
|
|
|
236
255
|
return async (request: Request): Promise<Response> => {
|
|
237
256
|
const url = new URL(request.url);
|
|
238
257
|
|
|
258
|
+
// The callback runs before any gate. It arrives from a browser that has
|
|
259
|
+
// completed the provider's login and holds no session yet, so gating it
|
|
260
|
+
// would refuse the only request that can create one.
|
|
261
|
+
if (options.login) {
|
|
262
|
+
const answered = await options.login.handleCallback(request);
|
|
263
|
+
if (answered !== null) return answered;
|
|
264
|
+
}
|
|
265
|
+
|
|
239
266
|
// The gate runs FIRST, for every path including the SPA. Serving the shell
|
|
240
267
|
// to an unauthenticated caller would leak the fleet's shape through the
|
|
241
268
|
// bundle even with every API call refused.
|
|
242
269
|
const subject = await options.authenticate(request);
|
|
243
270
|
if (subject === null) {
|
|
271
|
+
if (
|
|
272
|
+
options.login &&
|
|
273
|
+
!url.pathname.startsWith(`${API_BASE_PATH}/`) &&
|
|
274
|
+
url.pathname !== CALLBACK_PATH
|
|
275
|
+
) {
|
|
276
|
+
return options.login.redirectToLogin(request);
|
|
277
|
+
}
|
|
244
278
|
return new Response('Unauthorized', {
|
|
245
279
|
status: 401,
|
|
246
280
|
headers: { 'WWW-Authenticate': 'Bearer' },
|
|
247
281
|
});
|
|
248
282
|
}
|
|
249
283
|
|
|
250
|
-
if (url.pathname.startsWith(
|
|
251
|
-
const name = url.pathname.slice(
|
|
284
|
+
if (url.pathname.startsWith(`${API_BASE_PATH}/`)) {
|
|
285
|
+
const name = url.pathname.slice(API_BASE_PATH.length + 1) as ApiName;
|
|
252
286
|
if (!SERVED.includes(name)) {
|
|
253
287
|
return Response.json(
|
|
254
288
|
{ isSucc: false, err: { message: `Unknown verb: ${name}`, type: 'ApiError' } },
|