@steve31415/baselib 2.5.0 → 3.0.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/dist/auth.d.ts +20 -7
- package/dist/auth.js +90 -16
- package/dist/db.d.ts +4 -0
- package/dist/db.js +2 -1
- package/package.json +1 -1
package/dist/auth.d.ts
CHANGED
|
@@ -3,11 +3,20 @@ import type { Logger } from './log-core.js';
|
|
|
3
3
|
import { ServiceCaller, S2sOptions } from './s2s.js';
|
|
4
4
|
export declare const DEFAULT_AUTH_URL = "https://auth.apps.snewman.net";
|
|
5
5
|
export declare const SESSION_COOKIE = "pw_session";
|
|
6
|
+
/** The `code` in a 403 JSON body when a signed-in user is refused by the
|
|
7
|
+
* app's userAccess policy (as opposed to any other 403). */
|
|
8
|
+
export declare const ACCESS_DENIED_CODE = "app_access_denied";
|
|
9
|
+
/** A user's fleet role, assigned in auth2's users table. */
|
|
10
|
+
export type UserRole = 'owner' | 'invited';
|
|
11
|
+
/** Which signed-in users an app admits. 'owner' (the default): only the
|
|
12
|
+
* fleet owner(s). 'invited': any active fleet user. */
|
|
13
|
+
export type UserAccessPolicy = 'owner' | 'invited';
|
|
6
14
|
export interface AuthedUser {
|
|
7
15
|
type: 'user';
|
|
8
16
|
email: string;
|
|
9
17
|
name?: string;
|
|
10
18
|
userId?: string;
|
|
19
|
+
role: UserRole;
|
|
11
20
|
}
|
|
12
21
|
export type Identity = AuthedUser | ServiceCaller;
|
|
13
22
|
declare module 'hono' {
|
|
@@ -22,16 +31,20 @@ export interface RequireIdentityOptions extends S2sOptions {
|
|
|
22
31
|
/** Injectable for tests (defaults to global fetch). */
|
|
23
32
|
whoamiFetch?: typeof fetch;
|
|
24
33
|
logger?: Logger;
|
|
25
|
-
/** Distinguishes API requests (401) from page requests (redirect
|
|
26
|
-
* Default: path starts with /api/ or client prefers JSON. */
|
|
34
|
+
/** Distinguishes API requests (401/403 JSON) from page requests (redirect
|
|
35
|
+
* / denial page). Default: path starts with /api/ or client prefers JSON. */
|
|
27
36
|
isApiRequest?: (c: Context) => boolean;
|
|
28
37
|
cacheTtl?: {
|
|
29
38
|
hitMs?: number;
|
|
30
39
|
missMs?: number;
|
|
31
40
|
};
|
|
41
|
+
/** Which signed-in users this app admits. Default 'owner': only users
|
|
42
|
+
* whose auth2 role is owner. Pass 'invited' only for an app that is
|
|
43
|
+
* multi-user by design, and say so in its SPEC. Applies to browser
|
|
44
|
+
* sessions and the test bypass alike; service callers are governed by
|
|
45
|
+
* grants, not by this. */
|
|
46
|
+
userAccess?: UserAccessPolicy;
|
|
32
47
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
error: string;
|
|
37
|
-
}, 503, "json">)>;
|
|
48
|
+
/** The one place the policy is evaluated. */
|
|
49
|
+
export declare function userAllowed(policy: UserAccessPolicy, user: Pick<AuthedUser, 'role'>): boolean;
|
|
50
|
+
export declare function requireIdentity(opts: RequireIdentityOptions): import("hono").MiddlewareHandler<any, string, {}, Response>;
|
package/dist/auth.js
CHANGED
|
@@ -1,27 +1,49 @@
|
|
|
1
|
-
// Identity middleware for consuming apps (design §3.4
|
|
1
|
+
// Identity middleware for consuming apps (design §3.4; per-app access:
|
|
2
|
+
// ~/migration/research/per-app-access-design.md).
|
|
2
3
|
//
|
|
3
4
|
// Resolves who is calling, in order:
|
|
4
5
|
// 1. Test bypass (AUTH_MODE=test): X-Test-S2S-Caller → service caller;
|
|
5
|
-
// X-Test-User header or TEST_USER env → user
|
|
6
|
-
//
|
|
6
|
+
// X-Test-User header or TEST_USER env → user, with the fleet role from
|
|
7
|
+
// X-Test-User-Role / TEST_USER_ROLE (default 'invited'). Credential
|
|
8
|
+
// verification is bypassed; authorization (grants, the userAccess
|
|
9
|
+
// policy, app-level checks) still runs.
|
|
7
10
|
// 2. Authorization: Bearer → service-to-service OIDC (s2s.ts).
|
|
8
11
|
// 3. pw_session cookie → whoami call to the auth service, cached
|
|
9
12
|
// in-instance (10 min hits / 30 s misses — the old-world numbers).
|
|
10
13
|
//
|
|
14
|
+
// Then the user-access policy: by default an app admits only users whose
|
|
15
|
+
// auth2 role is 'owner'; `userAccess: 'invited'` admits any active fleet
|
|
16
|
+
// user. Being on the fleet sign-in allowlist never grants access by itself,
|
|
17
|
+
// so a port that forgets the question is closed, not open. Service callers
|
|
18
|
+
// are governed by grants, not by this policy.
|
|
19
|
+
//
|
|
11
20
|
// Unauthenticated: APIs get 401 JSON, pages redirect to the auth service's
|
|
12
|
-
// login with a return URL.
|
|
13
|
-
//
|
|
21
|
+
// login with a return URL. Denied users get 403 (JSON with a stable `code`
|
|
22
|
+
// on APIs, a plain "not enabled" page otherwise). Auth-service *failures*
|
|
23
|
+
// (network, 5xx, a whoami answer with no role) return 503 and never
|
|
24
|
+
// redirect — a redirect on failure loops forever (old-world lesson).
|
|
14
25
|
import { createHash } from 'node:crypto';
|
|
15
26
|
import { createMiddleware } from 'hono/factory';
|
|
16
27
|
import { authMode } from './config.js';
|
|
17
28
|
import { RateLimiter, resolveS2sCaller } from './s2s.js';
|
|
18
29
|
export const DEFAULT_AUTH_URL = 'https://auth.apps.snewman.net';
|
|
19
30
|
export const SESSION_COOKIE = 'pw_session';
|
|
31
|
+
/** The `code` in a 403 JSON body when a signed-in user is refused by the
|
|
32
|
+
* app's userAccess policy (as opposed to any other 403). */
|
|
33
|
+
export const ACCESS_DENIED_CODE = 'app_access_denied';
|
|
34
|
+
/** The one place the policy is evaluated. */
|
|
35
|
+
export function userAllowed(policy, user) {
|
|
36
|
+
return policy === 'invited' || user.role === 'owner';
|
|
37
|
+
}
|
|
38
|
+
function parseRole(value) {
|
|
39
|
+
return value === 'owner' || value === 'invited' ? value : undefined;
|
|
40
|
+
}
|
|
20
41
|
export function requireIdentity(opts) {
|
|
21
42
|
const authUrl = (opts.authUrl ?? DEFAULT_AUTH_URL).replace(/\/$/, '');
|
|
22
43
|
const whoamiFetch = opts.whoamiFetch ?? fetch;
|
|
23
44
|
const hitMs = opts.cacheTtl?.hitMs ?? 600_000;
|
|
24
45
|
const missMs = opts.cacheTtl?.missMs ?? 30_000;
|
|
46
|
+
const policy = opts.userAccess ?? 'owner';
|
|
25
47
|
const limiter = new RateLimiter(opts.rateLimitPerMin ?? 600);
|
|
26
48
|
const cache = new Map();
|
|
27
49
|
const isApi = opts.isApiRequest ??
|
|
@@ -37,17 +59,33 @@ export function requireIdentity(opts) {
|
|
|
37
59
|
}
|
|
38
60
|
return c.redirect(`${authUrl}/login?redirect=${encodeURIComponent(returnUrl)}`);
|
|
39
61
|
};
|
|
62
|
+
// WARN, not ERROR: a fleet user opening an owner-only app is the policy
|
|
63
|
+
// working as designed (an s2s grant denial is ERROR because it usually
|
|
64
|
+
// means a missing grant during development).
|
|
65
|
+
const accessDenied = (c, user) => {
|
|
66
|
+
opts.logger?.warn('user access denied', { email: user.email, policy, path: c.req.path });
|
|
67
|
+
c.header('cache-control', 'no-store');
|
|
68
|
+
if (isApi(c)) {
|
|
69
|
+
return c.json({ error: 'not enabled for this account', code: ACCESS_DENIED_CODE, email: user.email }, 403);
|
|
70
|
+
}
|
|
71
|
+
return c.html(accessDeniedPage({ host: c.req.header('host') ?? 'This app', email: user.email, authUrl }), 403);
|
|
72
|
+
};
|
|
40
73
|
return createMiddleware(async (c, next) => {
|
|
74
|
+
const admit = (user) => {
|
|
75
|
+
if (!userAllowed(policy, user))
|
|
76
|
+
return accessDenied(c, user);
|
|
77
|
+
c.set('identity', user);
|
|
78
|
+
c.set('user', user);
|
|
79
|
+
return next();
|
|
80
|
+
};
|
|
41
81
|
// Test-mode user bypass (service-caller test bypass is handled by
|
|
42
82
|
// resolveS2sCaller below, so both paths stay testable).
|
|
43
83
|
if (authMode() === 'test' && !c.req.header('x-test-s2s-caller')) {
|
|
44
84
|
const email = c.req.header('x-test-user') ?? process.env.TEST_USER;
|
|
45
85
|
if (!email)
|
|
46
86
|
return unauthenticated(c);
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
c.set('user', user);
|
|
50
|
-
return next();
|
|
87
|
+
const role = parseRole(c.req.header('x-test-user-role') ?? process.env.TEST_USER_ROLE) ?? 'invited';
|
|
88
|
+
return admit({ type: 'user', email, role });
|
|
51
89
|
}
|
|
52
90
|
// Service-to-service path.
|
|
53
91
|
if (c.req.header('authorization') || c.req.header('x-test-s2s-caller')) {
|
|
@@ -72,9 +110,7 @@ export function requireIdentity(opts) {
|
|
|
72
110
|
if (cached && cached.expires > Date.now()) {
|
|
73
111
|
if (!cached.user)
|
|
74
112
|
return unauthenticated(c);
|
|
75
|
-
|
|
76
|
-
c.set('user', cached.user);
|
|
77
|
-
return next();
|
|
113
|
+
return admit(cached.user);
|
|
78
114
|
}
|
|
79
115
|
let res;
|
|
80
116
|
try {
|
|
@@ -86,11 +122,17 @@ export function requireIdentity(opts) {
|
|
|
86
122
|
}
|
|
87
123
|
if (res.ok) {
|
|
88
124
|
const data = (await res.json());
|
|
89
|
-
const
|
|
125
|
+
const role = parseRole(data.role);
|
|
126
|
+
if (!role) {
|
|
127
|
+
// An auth service from before roles existed (a rollback), or a
|
|
128
|
+
// protocol change: a fault, not an "invited" answer. Fail closed,
|
|
129
|
+
// loudly, and without caching so recovery is instant.
|
|
130
|
+
opts.logger?.error('whoami answer has no role', { email: data.email, role: data.role });
|
|
131
|
+
return c.json({ error: 'auth service unavailable' }, 503);
|
|
132
|
+
}
|
|
133
|
+
const user = { type: 'user', email: data.email, name: data.name, userId: data.user_id, role };
|
|
90
134
|
cache.set(key, { expires: Date.now() + hitMs, user });
|
|
91
|
-
|
|
92
|
-
c.set('user', user);
|
|
93
|
-
return next();
|
|
135
|
+
return admit(user);
|
|
94
136
|
}
|
|
95
137
|
if (res.status === 401) {
|
|
96
138
|
cache.set(key, { expires: Date.now() + missMs });
|
|
@@ -100,3 +142,35 @@ export function requireIdentity(opts) {
|
|
|
100
142
|
return c.json({ error: 'auth service unavailable' }, 503);
|
|
101
143
|
});
|
|
102
144
|
}
|
|
145
|
+
// The fleet-wide "not enabled" page, in the auth service's plain style so
|
|
146
|
+
// every app shows the same thing. Carries nothing but the request's host,
|
|
147
|
+
// the user's address, and the configured auth URL.
|
|
148
|
+
function accessDeniedPage(p) {
|
|
149
|
+
return `<!doctype html>
|
|
150
|
+
<html lang="en">
|
|
151
|
+
<head>
|
|
152
|
+
<meta charset="utf-8">
|
|
153
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
154
|
+
<title>Not enabled — Plasticine</title>
|
|
155
|
+
<style>
|
|
156
|
+
:root { --accent: #23a55a; --ink: #1a1a1a; --muted: #6b7280; }
|
|
157
|
+
body { font-family: system-ui, sans-serif; color: var(--ink); background: #fff;
|
|
158
|
+
display: flex; min-height: 100vh; margin: 0; align-items: center; justify-content: center; }
|
|
159
|
+
main { text-align: center; padding: 2rem; max-width: 32rem; }
|
|
160
|
+
h1 { font-size: 1.4rem; margin-bottom: 0.5rem; }
|
|
161
|
+
h1 .dot { color: var(--accent); }
|
|
162
|
+
p.muted { color: var(--muted); font-size: 0.95rem; }
|
|
163
|
+
a { color: var(--accent); }
|
|
164
|
+
</style>
|
|
165
|
+
</head>
|
|
166
|
+
<body><main>
|
|
167
|
+
<h1>Plasticine<span class="dot">.</span></h1>
|
|
168
|
+
<p><strong>${escapeHtml(p.host)}</strong> is not enabled for your account.</p>
|
|
169
|
+
<p class="muted">You are signed in as ${escapeHtml(p.email)}. To use a different account,
|
|
170
|
+
<a href="${escapeHtml(p.authUrl)}/">sign out</a> first.</p>
|
|
171
|
+
</main></body>
|
|
172
|
+
</html>`;
|
|
173
|
+
}
|
|
174
|
+
function escapeHtml(s) {
|
|
175
|
+
return s.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]);
|
|
176
|
+
}
|
package/dist/db.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ export interface PoolOptions {
|
|
|
4
4
|
/** Override the database name (default: env DB_NAME / DATABASE_URL path). */
|
|
5
5
|
database?: string;
|
|
6
6
|
max?: number;
|
|
7
|
+
/** How long a checkout waits for a free connection before failing (pg's
|
|
8
|
+
* default is to wait forever, which turns pool starvation into a silent
|
|
9
|
+
* hang instead of an error). */
|
|
10
|
+
connectionTimeoutMillis?: number;
|
|
7
11
|
/** Receives idle-client errors (see logPoolErrors). Without one they go to
|
|
8
12
|
* stderr — on the Cloud Logging backstop, but not in Axiom. */
|
|
9
13
|
logger?: Logger;
|
package/dist/db.js
CHANGED
|
@@ -49,12 +49,13 @@ async function newPool(opts) {
|
|
|
49
49
|
user: requireEnv('DB_IAM_USER'),
|
|
50
50
|
database: opts.database ?? requireEnv('DB_NAME'),
|
|
51
51
|
max: opts.max ?? 3, // shared instance: default max_connections is low
|
|
52
|
+
connectionTimeoutMillis: opts.connectionTimeoutMillis,
|
|
52
53
|
});
|
|
53
54
|
}
|
|
54
55
|
const url = new URL(requireEnv('DATABASE_URL'));
|
|
55
56
|
if (opts.database)
|
|
56
57
|
url.pathname = `/${opts.database}`;
|
|
57
|
-
return new pg.Pool({ connectionString: url.toString(), max: opts.max ?? 5 });
|
|
58
|
+
return new pg.Pool({ connectionString: url.toString(), max: opts.max ?? 5, connectionTimeoutMillis: opts.connectionTimeoutMillis });
|
|
58
59
|
}
|
|
59
60
|
/** End a pool and resolve only once every client's connection has actually
|
|
60
61
|
* closed. pg-pool's own `end()` resolves as soon as it has *asked* its idle
|
package/package.json
CHANGED