hammerkit 1.27.0-next.29 → 1.27.0-next.30
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/dist/server/endpoints.js +160 -36
- package/dist/server/server-config.js +12 -2
- package/package.json +1 -1
package/dist/server/endpoints.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ownPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
|
|
3
|
+
exports.ownPath = exports.returnPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
|
|
4
4
|
const crypto_1 = require("crypto");
|
|
5
5
|
const promises_1 = require("fs/promises");
|
|
6
6
|
const http_1 = require("http");
|
|
@@ -15,6 +15,18 @@ const units_1 = require("../utils/units");
|
|
|
15
15
|
// interface, for testing) goes without the prefix.
|
|
16
16
|
const SESSION_COOKIE = '__Host-hammerkit-session';
|
|
17
17
|
const PLAIN_SESSION_COOKIE = 'hammerkit-session';
|
|
18
|
+
// The cookie of a sign-in under way, on the login host, one per sign-in (by its state): the
|
|
19
|
+
// browser keeps what the callback needs, so a caller who is not signed in fills no table of
|
|
20
|
+
// the server's.
|
|
21
|
+
const LOGIN_COOKIE = '__Host-hammerkit-login-';
|
|
22
|
+
const PLAIN_LOGIN_COOKIE = 'hammerkit-login-';
|
|
23
|
+
// The cookie of the endpoint host that a sign-in started from, which the one-time code that
|
|
24
|
+
// comes back has to match: a code is for the browser that asked for it, not one it is sent to.
|
|
25
|
+
const BIND_COOKIE = '__Host-hammerkit-bind-';
|
|
26
|
+
const PLAIN_BIND_COOKIE = 'hammerkit-bind-';
|
|
27
|
+
// the most of the page to go back to that a sign-in keeps: its cookie stays below what a
|
|
28
|
+
// browser takes (4096 bytes for name and value)
|
|
29
|
+
const MAX_RETURN_PATH = 1024;
|
|
18
30
|
// the host of the domain that signs people in, the one redirect URI registered at the issuer
|
|
19
31
|
exports.LOGIN_HOST = 'login';
|
|
20
32
|
// the paths of an endpoint host that are the server's, never the app's
|
|
@@ -22,7 +34,9 @@ const OWN_PATH = '/.hammerkit/';
|
|
|
22
34
|
// how long a sign-in may take at the issuer, and how long its one-time code holds
|
|
23
35
|
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
36
|
const CODE_TIMEOUT_MS = 60 * 1000;
|
|
25
|
-
|
|
37
|
+
// the states of sign-ins that came back, so one is taken once; the oldest goes first when
|
|
38
|
+
// there are more, then only the issuer's one-time code holds
|
|
39
|
+
const MAX_SPENT_LOGINS = 10000;
|
|
26
40
|
const DISCOVERY_TTL_MS = 10 * 60 * 1000;
|
|
27
41
|
const FETCH_TIMEOUT_MS = 10000;
|
|
28
42
|
/**
|
|
@@ -37,7 +51,8 @@ class EndpointGateway {
|
|
|
37
51
|
this.config = config;
|
|
38
52
|
this.registry = registry;
|
|
39
53
|
this.options = options;
|
|
40
|
-
|
|
54
|
+
// the state of each sign-in that came back, until it would have expired
|
|
55
|
+
this.spent = new Map();
|
|
41
56
|
this.codes = new Map();
|
|
42
57
|
this.base = { scheme: 'https', port: '' };
|
|
43
58
|
this.fetcher = options.fetch ?? fetch;
|
|
@@ -105,11 +120,17 @@ class EndpointGateway {
|
|
|
105
120
|
}
|
|
106
121
|
const principal = await this.caller(label, req);
|
|
107
122
|
if (!principal) {
|
|
108
|
-
if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers)) {
|
|
123
|
+
if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers) && isPageLoad(req.headers)) {
|
|
124
|
+
const bind = (0, crypto_1.randomBytes)(18).toString('base64url');
|
|
109
125
|
const start = new URL(this.url(exports.LOGIN_HOST, '/start'));
|
|
110
126
|
start.searchParams.set('host', label);
|
|
111
127
|
start.searchParams.set('path', url.pathname + url.search);
|
|
112
|
-
|
|
128
|
+
start.searchParams.set('bind', bind);
|
|
129
|
+
res.writeHead(302, {
|
|
130
|
+
location: start.toString(),
|
|
131
|
+
'set-cookie': this.cookie(this.bindCookieName(bind), bind, LOGIN_TIMEOUT_MS),
|
|
132
|
+
'cache-control': 'no-store',
|
|
133
|
+
});
|
|
113
134
|
res.end();
|
|
114
135
|
return;
|
|
115
136
|
}
|
|
@@ -359,24 +380,31 @@ ${run.state === 'up'
|
|
|
359
380
|
if (url.pathname === '/start') {
|
|
360
381
|
const host = url.searchParams.get('host') ?? '';
|
|
361
382
|
const path = url.searchParams.get('path') ?? '/';
|
|
383
|
+
const bind = url.searchParams.get('bind') ?? '';
|
|
362
384
|
if (!/^[a-z0-9-]+$/.test(host) || host === exports.LOGIN_HOST || !this.registry.endpoint(host)) {
|
|
363
385
|
return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
|
|
364
386
|
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
387
|
+
if (!BIND_PATTERN.test(bind)) {
|
|
388
|
+
return page(res, 400, 'Not signed in', '<p>Open the endpoint to sign in.</p>');
|
|
389
|
+
}
|
|
390
|
+
// a sign-in starts when a person goes to it, not from a request of a page, which could
|
|
391
|
+
// pile up cookies of sign-ins in their browser
|
|
392
|
+
if (req.headers['sec-fetch-mode'] !== undefined && req.headers['sec-fetch-mode'] !== 'navigate') {
|
|
393
|
+
return page(res, 403, 'Not allowed', '<p>Open the endpoint to sign in.</p>');
|
|
369
394
|
}
|
|
395
|
+
const endpoints = await this.endpointsOf(login.issuer);
|
|
370
396
|
const state = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
371
397
|
const verifier = (0, crypto_1.randomBytes)(32).toString('base64url');
|
|
372
398
|
const nonce = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
373
|
-
|
|
399
|
+
const pending = {
|
|
400
|
+
state,
|
|
401
|
+
bind,
|
|
374
402
|
host,
|
|
375
|
-
path:
|
|
403
|
+
path: returnPath(path),
|
|
376
404
|
verifier,
|
|
377
405
|
nonce,
|
|
378
406
|
expires: this.now() + LOGIN_TIMEOUT_MS,
|
|
379
|
-
}
|
|
407
|
+
};
|
|
380
408
|
const authorize = new URL(endpoints.authorize);
|
|
381
409
|
authorize.searchParams.set('response_type', 'code');
|
|
382
410
|
authorize.searchParams.set('client_id', login.clientId);
|
|
@@ -386,22 +414,28 @@ ${run.state === 'up'
|
|
|
386
414
|
authorize.searchParams.set('nonce', nonce);
|
|
387
415
|
authorize.searchParams.set('code_challenge', (0, crypto_1.createHash)('sha256').update(verifier).digest('base64url'));
|
|
388
416
|
authorize.searchParams.set('code_challenge_method', 'S256');
|
|
389
|
-
res.writeHead(302, {
|
|
417
|
+
res.writeHead(302, {
|
|
418
|
+
location: authorize.toString(),
|
|
419
|
+
'set-cookie': this.loginCookie(pending),
|
|
420
|
+
'cache-control': 'no-store',
|
|
421
|
+
});
|
|
390
422
|
res.end();
|
|
391
423
|
return;
|
|
392
424
|
}
|
|
393
425
|
if (url.pathname === '/callback') {
|
|
394
426
|
const state = url.searchParams.get('state') ?? '';
|
|
395
|
-
const pending = this.
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
|
|
427
|
+
const pending = this.readLogin(state, req.headers.cookie);
|
|
428
|
+
if (pending) {
|
|
429
|
+
res.setHeader('set-cookie', this.clearLoginCookie(state));
|
|
399
430
|
}
|
|
400
431
|
const code = url.searchParams.get('code');
|
|
401
|
-
if (!code) {
|
|
432
|
+
if (pending && !code) {
|
|
402
433
|
const error = url.searchParams.get('error') ?? 'no code';
|
|
403
434
|
return page(res, 400, 'Not signed in', `<p>The sign-in did not succeed: ${escape(error.slice(0, 200))}.</p>`);
|
|
404
435
|
}
|
|
436
|
+
if (!pending || !code || !this.spend(pending)) {
|
|
437
|
+
return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
|
|
438
|
+
}
|
|
405
439
|
let principal;
|
|
406
440
|
try {
|
|
407
441
|
principal = await this.exchange(code, pending);
|
|
@@ -417,6 +451,7 @@ ${run.state === 'up'
|
|
|
417
451
|
}
|
|
418
452
|
const issued = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
419
453
|
this.codes.set(issued, {
|
|
454
|
+
bind: pending.bind,
|
|
420
455
|
host: pending.host,
|
|
421
456
|
path: pending.path,
|
|
422
457
|
principal,
|
|
@@ -432,6 +467,61 @@ ${run.state === 'up'
|
|
|
432
467
|
return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
|
|
433
468
|
}
|
|
434
469
|
// The code of the issuer for its tokens, and the ID token for who signed in.
|
|
470
|
+
// A sign-in comes back once: its state is kept as spent until it would have expired. Past
|
|
471
|
+
// MAX_SPENT_LOGINS the oldest are forgotten, which anybody can bring about with sign-ins of
|
|
472
|
+
// their own; a state forgotten that way still needs its cookie and a code the issuer takes
|
|
473
|
+
// a second time, which it doesn't (codes of the authorization code flow are one-time).
|
|
474
|
+
spend(pending) {
|
|
475
|
+
const now = this.now();
|
|
476
|
+
if (pending.expires < now || this.spent.has(pending.state)) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
this.sweep();
|
|
480
|
+
while (this.spent.size >= MAX_SPENT_LOGINS) {
|
|
481
|
+
const oldest = this.spent.keys().next().value;
|
|
482
|
+
this.spent.delete(oldest);
|
|
483
|
+
}
|
|
484
|
+
this.spent.set(pending.state, pending.expires);
|
|
485
|
+
return true;
|
|
486
|
+
}
|
|
487
|
+
loginCookieName(state) {
|
|
488
|
+
// the state is base64url, which a cookie name may carry
|
|
489
|
+
return `${this.base.scheme === 'https' ? LOGIN_COOKIE : PLAIN_LOGIN_COOKIE}${state.slice(0, 16)}`;
|
|
490
|
+
}
|
|
491
|
+
bindCookieName(bind) {
|
|
492
|
+
return `${this.base.scheme === 'https' ? BIND_COOKIE : PLAIN_BIND_COOKIE}${bind.slice(0, 16)}`;
|
|
493
|
+
}
|
|
494
|
+
// A cookie of the host the request is for (no Domain), for so long; 0 clears it.
|
|
495
|
+
cookie(name, value, ms) {
|
|
496
|
+
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.max(0, Math.floor(ms / 1000))}${this.base.scheme === 'https' ? '; Secure' : ''}`;
|
|
497
|
+
}
|
|
498
|
+
loginCookie(pending) {
|
|
499
|
+
const payload = Buffer.from(JSON.stringify(pending)).toString('base64url');
|
|
500
|
+
return this.cookie(this.loginCookieName(pending.state), `${payload}.${this.sign(`login\0${payload}`)}`, LOGIN_TIMEOUT_MS);
|
|
501
|
+
}
|
|
502
|
+
clearLoginCookie(state) {
|
|
503
|
+
return this.cookie(this.loginCookieName(state), '', 0);
|
|
504
|
+
}
|
|
505
|
+
// The sign-in of this state, from its cookie, when the server signed it.
|
|
506
|
+
readLogin(state, header) {
|
|
507
|
+
if (!/^[A-Za-z0-9_-]{32}$/.test(state)) {
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
const value = readCookie(header, this.loginCookieName(state));
|
|
511
|
+
const [payload, signature] = (value ?? '').split('.');
|
|
512
|
+
if (!payload || !signature || !safeEqual(signature, this.sign(`login\0${payload}`))) {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
const pending = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
517
|
+
return pending.state === state && typeof pending.verifier === 'string' && typeof pending.bind === 'string'
|
|
518
|
+
? pending
|
|
519
|
+
: null;
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
435
525
|
async exchange(code, pending) {
|
|
436
526
|
const login = this.config.endpoints?.login;
|
|
437
527
|
if (!login || !this.loginVerifier) {
|
|
@@ -484,23 +574,35 @@ ${run.state === 'up'
|
|
|
484
574
|
const code = url.searchParams.get('code') ?? '';
|
|
485
575
|
const issued = this.codes.get(code);
|
|
486
576
|
this.codes.delete(code);
|
|
487
|
-
|
|
577
|
+
const bind = issued ? readCookie(req.headers.cookie, this.bindCookieName(issued.bind)) : undefined;
|
|
578
|
+
if (issued) {
|
|
579
|
+
res.setHeader('set-cookie', this.cookie(this.bindCookieName(issued.bind), '', 0));
|
|
580
|
+
}
|
|
581
|
+
if (!issued ||
|
|
582
|
+
issued.host !== label ||
|
|
583
|
+
issued.expires < this.now() ||
|
|
584
|
+
// the browser that started the sign-in at this host, no other one the link was sent to
|
|
585
|
+
!bind ||
|
|
586
|
+
!safeEqual(bind, issued.bind)) {
|
|
488
587
|
return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
|
|
489
588
|
}
|
|
490
589
|
const found = this.registry.endpoint(label);
|
|
491
590
|
const ends = Date.parse(found?.run.expiresAt ?? '');
|
|
492
|
-
const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '
|
|
591
|
+
const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '1h');
|
|
493
592
|
const exp = Number.isFinite(ends) ? Math.min(session, Math.max(ends, this.now() + LOGIN_TIMEOUT_MS)) : session;
|
|
494
593
|
res.writeHead(303, {
|
|
495
594
|
location: issued.path,
|
|
496
|
-
'set-cookie':
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
595
|
+
'set-cookie': [
|
|
596
|
+
this.cookie(this.bindCookieName(issued.bind), '', 0),
|
|
597
|
+
this.sessionCookie({
|
|
598
|
+
host: label,
|
|
599
|
+
iss: issued.principal.iss,
|
|
600
|
+
sub: issued.principal.sub,
|
|
601
|
+
groups: issued.principal.groups,
|
|
602
|
+
claims: this.matchedClaims(issued.principal),
|
|
603
|
+
exp,
|
|
604
|
+
}),
|
|
605
|
+
],
|
|
504
606
|
'cache-control': 'no-store',
|
|
505
607
|
});
|
|
506
608
|
res.end();
|
|
@@ -523,11 +625,7 @@ ${run.state === 'up'
|
|
|
523
625
|
return `${this.cookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${this.base.scheme === 'https' ? '; Secure' : ''}`;
|
|
524
626
|
}
|
|
525
627
|
readSession(label, header) {
|
|
526
|
-
const
|
|
527
|
-
const value = (header ?? '')
|
|
528
|
-
.split(/;\s*/)
|
|
529
|
-
.find((cookie) => cookie.startsWith(`${name}=`))
|
|
530
|
-
?.slice(name.length + 1);
|
|
628
|
+
const value = readCookie(header, this.cookieName());
|
|
531
629
|
const [payload, signature] = (value ?? '').split('.');
|
|
532
630
|
if (!payload || !signature || !safeEqual(signature, this.sign(payload))) {
|
|
533
631
|
return null;
|
|
@@ -555,9 +653,9 @@ ${run.state === 'up'
|
|
|
555
653
|
}
|
|
556
654
|
sweep() {
|
|
557
655
|
const now = this.now();
|
|
558
|
-
for (const [state,
|
|
559
|
-
if (
|
|
560
|
-
this.
|
|
656
|
+
for (const [state, expires] of this.spent) {
|
|
657
|
+
if (expires < now) {
|
|
658
|
+
this.spent.delete(state);
|
|
561
659
|
}
|
|
562
660
|
}
|
|
563
661
|
for (const [code, issued] of this.codes) {
|
|
@@ -568,11 +666,37 @@ ${run.state === 'up'
|
|
|
568
666
|
}
|
|
569
667
|
}
|
|
570
668
|
exports.EndpointGateway = EndpointGateway;
|
|
669
|
+
const BIND_PATTERN = /^[A-Za-z0-9_-]{24}$/;
|
|
670
|
+
// The page a sign-in goes back to: a path of its own host, and only the path when the query
|
|
671
|
+
// is too long for the cookie of the sign-in.
|
|
672
|
+
function returnPath(path) {
|
|
673
|
+
const own = ownPath(path);
|
|
674
|
+
if (own.length <= MAX_RETURN_PATH) {
|
|
675
|
+
return own;
|
|
676
|
+
}
|
|
677
|
+
const pathname = own.split('?')[0];
|
|
678
|
+
return pathname.length <= MAX_RETURN_PATH ? pathname : '/';
|
|
679
|
+
}
|
|
680
|
+
exports.returnPath = returnPath;
|
|
681
|
+
function readCookie(header, name) {
|
|
682
|
+
return (header ?? '')
|
|
683
|
+
.split(/;\s*/)
|
|
684
|
+
.find((cookie) => cookie.startsWith(`${name}=`))
|
|
685
|
+
?.slice(name.length + 1);
|
|
686
|
+
}
|
|
571
687
|
function safeEqual(a, b) {
|
|
572
688
|
const left = Buffer.from(a);
|
|
573
689
|
const right = Buffer.from(b);
|
|
574
690
|
return left.length === right.length && (0, crypto_1.timingSafeEqual)(left, right);
|
|
575
691
|
}
|
|
692
|
+
// Whether the browser loads a page of its own, which a sign-in can go through: not a request
|
|
693
|
+
// of a page (fetch, htmx, a frame), which would only pile up cookies of sign-ins. Without
|
|
694
|
+
// Sec-Fetch-*, an older browser or another client, by its Accept alone.
|
|
695
|
+
function isPageLoad(headers) {
|
|
696
|
+
const mode = headers['sec-fetch-mode'];
|
|
697
|
+
const dest = headers['sec-fetch-dest'];
|
|
698
|
+
return (mode === undefined || mode === 'navigate') && (dest === undefined || dest === 'document');
|
|
699
|
+
}
|
|
576
700
|
function acceptsHtml(headers) {
|
|
577
701
|
return (headers.accept ?? '').includes('text/html');
|
|
578
702
|
}
|
|
@@ -615,8 +615,9 @@ exports.serverConfigSchema = zod_1.z
|
|
|
615
615
|
})
|
|
616
616
|
.strict()
|
|
617
617
|
.optional(),
|
|
618
|
-
// how long a sign-in at one endpoint lasts, at most until its run ends
|
|
619
|
-
|
|
618
|
+
// how long a sign-in at one endpoint lasts, at most until its run ends: the groups and
|
|
619
|
+
// claims of the sign-in hold that long, a change at the issuer counts at the next one
|
|
620
|
+
session: duration.default('1h'),
|
|
620
621
|
})
|
|
621
622
|
.strict()
|
|
622
623
|
.optional(),
|
|
@@ -930,6 +931,15 @@ exports.serverConfigSchema = zod_1.z
|
|
|
930
931
|
});
|
|
931
932
|
}
|
|
932
933
|
}
|
|
934
|
+
// only the server is to reach the services behind endpoints, which the NetworkPolicy of
|
|
935
|
+
// a service run says: without it, any pod of the cluster would get past the sign-in
|
|
936
|
+
if (backend.type === 'kubernetes' && backend.networkPolicy !== 'enforced') {
|
|
937
|
+
ctx.addIssue({
|
|
938
|
+
code: zod_1.z.ZodIssueCode.custom,
|
|
939
|
+
path: ['backend', 'networkPolicy'],
|
|
940
|
+
message: 'endpoints keep the services of a run to the server with a NetworkPolicy, set networkPolicy: enforced once the network plugin of the cluster enforces them',
|
|
941
|
+
});
|
|
942
|
+
}
|
|
933
943
|
if (backend.type === 'kubernetes' && backend.kubeconfig) {
|
|
934
944
|
ctx.addIssue({
|
|
935
945
|
code: zod_1.z.ZodIssueCode.custom,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hammerkit",
|
|
3
3
|
"description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
|
|
4
|
-
"version": "1.27.0-next.
|
|
4
|
+
"version": "1.27.0-next.30",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"build",
|
|
7
7
|
"build-tool",
|