cortad 0.3.11 → 0.4.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.
@@ -1,689 +0,0 @@
1
- // The same file lives on the Cortad side. Keep the two in step: the recipes the
2
- // cloud sends are gated here again, on the machine that holds the secrets.
3
- // Signing in as the customer's own callers, from inside the box, through their own doors.
4
- //
5
- // A recipe (src/setup/road-identities.ts) names env variables, routes and body fields; never a
6
- // value. This program runs where the app's environment file is, opens the values itself, creates
7
- // an account through their admin door or exchanges their shared password for a token, and keeps
8
- // what their door issued in a header file only the box can read. What it prints is roles and
9
- // statuses: a token the run needs is fetched from the box when a turn first speaks as that role,
10
- // and a credential that IS a sealed value (an admin key) is marked sealed and never leaves. A
11
- // caller Firebase verifies is signed up at the Auth emulator the boot raised in place of their
12
- // project; without that emulator the role stays upstream, verified outside the box.
13
-
14
- export const IDENTITY_PATH = "/tmp/bl-identities.py";
15
- export const IDENTITY_OUT = "/tmp/bl-identities.json";
16
- const HEADER_FILE = "/tmp/bl-identity-%s.%s";
17
-
18
- const ROLE = /^[a-z][a-z0-9_-]{0,30}(?::[a-z][a-z0-9_-]{0,30})?$/;
19
- const HEADER = /^[a-z][a-z0-9-]{0,40}$/;
20
- const ENV_NAME = /^[A-Z_][A-Z0-9_]*$/;
21
- const ROUTE = /^\/[\w./-]{0,200}$/;
22
- const OPENS = /^\/[\w./{}-]{0,200}$/;
23
- const SOURCE = /^(?:env:[A-Z_][A-Z0-9_]*(?:\|[A-Z_][A-Z0-9_]*)*|gen:(?:username|email|password|name)|role)$/;
24
- // A Next.js route file is spelled with brackets and route groups with parentheses: `app/api/auth/
25
- // [...all]/route.ts (better-auth)` failed the old gate and every catch-all auth recipe was dropped
26
- // before the program saw it. Still no quote, angle bracket, `$`, backtick or semicolon.
27
- const WHERE = /^[\w./:@()[\] -]{1,300}$/;
28
- const BASE = /^http:\/\/(?:127\.0\.0\.1|\[::1\]|localhost):\d{2,5}$/;
29
- const KINDS = new Set(["header", "login", "jwt", "upstream", "firebase", "authjs", "supabase", "basic", "clerk", "supabase-admin"]);
30
- const STATUSES = new Set(["minted", "sealed", "absent", "refused", "unreachable", "upstream"]);
31
-
32
- // A credential the app's own environment names for its own account: a user-side name and a
33
- // password-side name that share a prefix. The prefix is the name with its trailing token removed,
34
- // so USER_USERNAME pairs with USER_PASSWORD and TUDUDI_USER_EMAIL with TUDUDI_USER_PASSWORD.
35
- const USER_TOKEN = /_(?:USERNAME|USER|EMAIL|LOGIN)$/;
36
- const SECRET_TOKEN = /_(?:PASSWORD|PASS|SECRET)$/;
37
- // A pair that opens a store or a mail server is never the app's own account: PG_DB_USER with
38
- // PG_DB_PASSWORD signs in to Postgres, and offered to a sign-in form it sent a database password
39
- // to the app's login route.
40
- const SERVICE_PREFIX = /(?:^|_)(?:DB|DATABASE|SQL|PG|PSQL|POSTGRES(?:QL)?|MYSQL|MARIADB|MSSQL|MONGO(?:DB)?|REDIS|VALKEY|ELASTIC(?:SEARCH)?|OPENSEARCH|ES|QDRANT|NEO4J|CLICKHOUSE|RABBIT(?:MQ)?|AMQP|KAFKA|SMTP|MAIL|EMAIL|S3|MINIO|FTP|SFTP|LDAP|PROXY)(?:_|$)/;
41
- const CRED_USER_FIELD = /^(?:e-?mail|user(?:_?name)?|login|account|identifier)$/i;
42
- const CRED_PASS_FIELD = /^(?:password|passcode|pass|pwd)$/i;
43
- const SIGN_IN_ROUTE = /(?:^|\/)(?:login|signin|sign-in|sign_in|session|token|authenticate)(?:\/|$)/i;
44
-
45
- const slug = (role) => role.replace(/[^a-z0-9-]/g, "-");
46
- export const identityHeaderFile = (role) => (ROLE.test(role) ? HEADER_FILE.replace("%s.%s", `${slug(role)}.header`) : null);
47
- // The curl argument a probe inside the box adds to speak as a role: the header line is read there,
48
- // so the value never rides in a command this process composes.
49
- export function identityCurlArg(as) {
50
- if (as === "browser" || as === "bare" || !ROLE.test(as)) return "";
51
- return `-H "$(cat ${HEADER_FILE.replace("%s.%s", `${slug(as)}.header`)} ${HEADER_FILE.replace("%s.%s", `${slug(as)}.sealed`)} 2>/dev/null)"`;
52
- }
53
-
54
- // Every string the recipes carry reaches the program as base64 JSON, never as source, and only
55
- // after the charset gate: a route or a role is read from a repository.
56
- export function identityScript(recipes, target, accounts = {}) {
57
- const safe = recipes.filter(safeRecipe).slice(0, 12);
58
- const headers = Object.fromEntries(Object.entries(target.headers ?? {}).filter(([k, v]) => HEADER.test(k) && typeof v === "string" && v.length <= 300 && !/[\r\n]/.test(v)));
59
- const kept = Object.fromEntries(Object.entries(accounts).filter(([role, a]) => ROLE.test(role) && a
60
- && ["username", "email", "password", "name"].every((f) => typeof a[f] === "string" && a[f].length <= 120 && !/[\r\n]/.test(a[f]))));
61
- const plan = { envPath: /^[\w./-]+$/.test(target.envPath) ? target.envPath : "/workspace/repo/.env", base: BASE.test(target.base) ? target.base : "http://127.0.0.1:0", headers, recipes: safe, accounts: kept };
62
- return PROGRAM
63
- .replace("__PLAN__", Buffer.from(JSON.stringify(plan), "utf8").toString("base64"))
64
- .replace("__OUT__", IDENTITY_OUT)
65
- .replace("__FILE__", HEADER_FILE);
66
- }
67
-
68
- function safeRecipe(r) {
69
- const step = (s) => s && ROUTE.test(s.route)
70
- && Object.entries(s.headers ?? {}).every(([k, v]) => HEADER.test(k) && SOURCE.test(v))
71
- && Object.entries(s.body ?? {}).every(([k, v]) => /^[A-Za-z_][\w]{0,40}$/.test(k) && SOURCE.test(v));
72
- return r && KINDS.has(r.kind) && ROLE.test(r.role) && HEADER.test(r.header) && WHERE.test(r.where)
73
- && (r.env ?? []).every((n) => ENV_NAME.test(n))
74
- && (r.roles ?? []).every((v) => /^[a-z][a-z0-9_-]{0,30}$/.test(v))
75
- && (r.opens ?? []).every((o) => OPENS.test(o))
76
- && (r.steps ?? []).every(step) && (r.alt ?? []).every(step)
77
- && (r.claim === undefined || /^[a-z_][a-z0-9_]{0,30}$/i.test(r.claim));
78
- }
79
-
80
- const PROGRAM = String.raw`import base64, hashlib, hmac, http.cookiejar, json, os, re, secrets, time, urllib.error, urllib.request
81
-
82
- PLAN = json.loads(base64.b64decode("__PLAN__").decode("utf-8"))
83
- OUT = "__OUT__"
84
- FILE = "__FILE__"
85
- TTL = 6 * 3600
86
- # Digits only: an account column is an integer in Strapi, Django and Rails, and a word here makes
87
- # the database itself raise. Nonerds crashed its whole process on "cortad-test-user". Digits are a
88
- # valid string id too, so this one value fits both kinds of account table.
89
- TEST = "9000001"
90
- # A dev server compiles its auth route on the first request: better-chatbot's sign-in timed out at 10s
91
- # and the caller was filed unreachable. The whole program stays inside the step's two minutes.
92
- DEADLINE = time.time() + 100
93
- SIGNUP = "/identitytoolkit.googleapis.com/v1/accounts:signUp?key=standin"
94
-
95
-
96
- def read_env(path):
97
- out = {}
98
- try:
99
- with open(path, encoding="utf-8", errors="replace") as f:
100
- for line in f:
101
- m = re.match(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$", line)
102
- if not m:
103
- continue
104
- v = m.group(2).strip()
105
- if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
106
- v = v[1:-1]
107
- out[m.group(1)] = v
108
- except OSError:
109
- pass
110
- return out
111
-
112
-
113
- def slug(role):
114
- return re.sub(r"[^a-z0-9-]", "-", role)
115
-
116
-
117
- # One account per role, made once and signed into on every later run: a fresh account per run left
118
- # 1,600 active test logins in one app's own account store.
119
- def gen(role):
120
- if role in PLAN.get("accounts", {}):
121
- return PLAN["accounts"][role]
122
- tag = secrets.token_hex(4)
123
- return {
124
- "username": "cortad-%s-%s" % (slug(role), tag),
125
- "email": "cortad-%s-%s@example.invalid" % (slug(role), tag),
126
- "password": secrets.token_urlsafe(18) + "A1!",
127
- "name": "Cortad test %s" % role,
128
- }
129
-
130
-
131
- def source(spec, env, g, role):
132
- if spec.startswith("env:"):
133
- for name in spec[4:].split("|"):
134
- if env.get(name):
135
- return env[name]
136
- return None
137
- return {"gen:username": g["username"], "gen:email": g["email"], "gen:password": g["password"], "gen:name": g["name"], "role": role}.get(spec)
138
-
139
-
140
- def names(spec):
141
- return spec[4:].replace("|", " or ") if spec.startswith("env:") else spec
142
-
143
-
144
- def call(path, headers, body, jar, base=None):
145
- opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
146
- req = urllib.request.Request((base or PLAN["base"]) + path, data=json.dumps(body).encode("utf-8"), method="POST")
147
- for k, v in list(PLAN["headers"].items()) + list(headers.items()):
148
- req.add_header(k, v)
149
- req.add_header("content-type", "application/json")
150
- try:
151
- with opener.open(req, timeout=40) as r:
152
- return r.status, r.read(200000).decode("utf-8", "replace")
153
- except urllib.error.HTTPError as e:
154
- # The door's own sentence rides the refusal: a 500 with no body told nobody what the
155
- # sign-up died on.
156
- try:
157
- return e.code, e.read(4000).decode("utf-8", "replace")
158
- except Exception:
159
- return e.code, ""
160
- except Exception as e:
161
- return 0, type(e).__name__
162
-
163
-
164
- def token_in(value, depth=0):
165
- if isinstance(value, dict):
166
- for k, v in value.items():
167
- if isinstance(v, str) and v and re.fullmatch(r"(?i)(access_?token|token|jwt|session_?token|id_?token|api_?key)", k):
168
- return v
169
- if depth < 2:
170
- for v in value.values():
171
- found = token_in(v, depth + 1)
172
- if found:
173
- return found
174
- return None
175
-
176
-
177
- def bearer(header, value):
178
- if header == "authorization" and not value.lower().startswith("bearer "):
179
- return "Bearer " + value
180
- return value
181
-
182
-
183
- def minted(header, value, ttl=TTL):
184
- return {"status": "minted", "header": header, "line": "%s: %s" % (header, bearer(header, value)), "expires": int(time.time()) + ttl}
185
-
186
-
187
- # The account is named after the identity, not the role slot: staff:sales and sales are two callers
188
- # at two doors, and their door reads the name back on every login.
189
- def login(recipe, role, role_value, env):
190
- g = gen(role)
191
- ways = [recipe["steps"]] + ([recipe["alt"]] if recipe.get("alt") else [])
192
- for way in ways:
193
- last = ways[-1] is way
194
- got = walk(way, recipe, env, g, role_value)
195
- if got.get("status") != "refused" or last:
196
- return got
197
- return got
198
-
199
-
200
- def walk(steps, recipe, env, g, role_value):
201
- jar = http.cookiejar.CookieJar()
202
- status, token = 0, None
203
- for step in steps:
204
- headers = {}
205
- # A token the previous step minted signs this one: the key route wants the session that
206
- # just logged in, and a cookie jar alone does not carry a bearer.
207
- if token:
208
- headers[recipe["header"]] = bearer(recipe["header"], token)
209
- for h, spec in step.get("headers", {}).items():
210
- v = source(spec, env, g, role_value)
211
- if v is None:
212
- return {"status": "absent", "note": names(spec)}
213
- headers[h] = v
214
- body = {}
215
- unset = None
216
- for field, spec in step["body"].items():
217
- v = source(spec, env, g, role_value)
218
- if v is None:
219
- if not spec.startswith("env:"):
220
- return {"status": "absent", "note": names(spec)}
221
- # A switch in their code may make this one optional: ask without it, and a refusal
222
- # is then the variable they did not seal, not their door.
223
- unset = names(spec)
224
- continue
225
- body[field] = v
226
- status, text = call(step["route"], headers, body, jar)
227
- if status == 0:
228
- return {"status": "unreachable", "note": text}
229
- if status >= 400 and unset:
230
- return {"status": "absent", "note": unset}
231
- # The account this role was given on an earlier run is still there: go on and sign in.
232
- if status in (400, 409, 422) and step is not steps[-1] and re.search(r"exist|taken|already|duplicate|in use|registered", text or "", re.I):
233
- continue
234
- if status >= 400:
235
- said = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", "", text or "")).strip()[:160]
236
- return {"status": "refused", "note": "HTTP %d at %s%s" % (status, step["route"], (": " + said) if said else "")}
237
- try:
238
- found = token_in(json.loads(text))
239
- except ValueError:
240
- found = None
241
- # A step that mints nothing keeps what the step before it minted.
242
- token = found or token
243
- cookies = "; ".join("%s=%s" % (c.name, c.value) for c in jar)
244
- # A cookie door reads its cookie by name. better-auth's sign-in answers a token AND sets
245
- # better-auth.session_token; sending the bare token as the cookie header named nothing, so its
246
- # proxy redirected every signed-in knock to the sign-in page.
247
- if recipe["header"] == "cookie" and cookies:
248
- return minted("cookie", cookies)
249
- if token:
250
- return minted(recipe["header"], token)
251
- if cookies:
252
- # ponytail: the cookie alone; a CSRF token their form also wants is the next thing to carry.
253
- return minted("cookie", cookies)
254
- return {"status": "refused", "note": "HTTP %d with no token in the reply" % status}
255
-
256
-
257
- def b64url(raw):
258
- return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")
259
-
260
-
261
- def hs256(secret, claims):
262
- head = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode("utf-8"))
263
- body = b64url(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
264
- sig = b64url(hmac.new(secret.encode("utf-8"), ("%s.%s" % (head, body)).encode("ascii"), hashlib.sha256).digest())
265
- return "%s.%s.%s" % (head, body, sig)
266
-
267
-
268
- def jwt(recipe, env):
269
- secret = source("env:" + "|".join(recipe["env"]), env, None, None)
270
- if not secret:
271
- return {"status": "absent", "note": " or ".join(recipe["env"])}
272
- now = int(time.time())
273
- # On their own machine the database is theirs and live: the token names a test id that belongs
274
- # to nobody, never account 1, which is usually the person who built the app. (The sandbox copy
275
- # of this program signs account 1 of a restored copy; this one differs on purpose.)
276
- claims = {"sub": TEST, "id": TEST, "iat": now, "exp": now + TTL}
277
- claim = recipe.get("claim") or "sub"
278
- if claim not in claims:
279
- claims[claim] = TEST
280
- return minted("authorization", hs256(secret, claims))
281
-
282
-
283
- # GoTrue signs its access token HS256 with the project's JWT secret; RLS reads sub as auth.uid()
284
- # and role as auth.role(). The id is a well-formed uuid that belongs to nobody, never a real row.
285
- SUPA_UID = "00000000-0000-4000-8000-000000009001"
286
-
287
-
288
- def supabase(recipe, env):
289
- secret = source("env:" + "|".join(recipe["env"]), env, None, None)
290
- if not secret:
291
- return {"status": "absent", "note": " or ".join(recipe["env"])}
292
- now = int(time.time())
293
- claims = {"sub": SUPA_UID, "aud": "authenticated", "role": "authenticated", "iat": now, "exp": now + TTL, "is_anonymous": False}
294
- return minted("authorization", hs256(secret, claims))
295
-
296
-
297
- # Auth.js v5 (and next-auth v5) seals its session as a JWE, not a signed JWT: dir key management,
298
- # A256CBC-HS512 content encryption, the key derived from the secret with HKDF over the cookie name.
299
- # Verified against the @auth/core jwt.ts encode path. next-auth v4 (A256GCM) is not minted here.
300
- def hkdf_sha256(secret, salt, info, length):
301
- salt = salt or bytes(hashlib.sha256().digest_size)
302
- prk = hmac.new(salt, secret, hashlib.sha256).digest()
303
- out, block, i = b"", b"", 1
304
- while len(out) < length:
305
- block = hmac.new(prk, block + info + bytes([i]), hashlib.sha256).digest()
306
- out += block
307
- i += 1
308
- return out[:length]
309
-
310
-
311
- AES_SBOX = bytes.fromhex(
312
- "637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
313
- "b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
314
- "09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
315
- "d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
316
- "cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
317
- "e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
318
- "ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
319
- "e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16")
320
- AES_RCON = (0, 1, 2, 4, 8, 16, 32, 64)
321
-
322
-
323
- def _aes_mul(a, b):
324
- r = 0
325
- for _ in range(8):
326
- if b & 1:
327
- r ^= a
328
- hi = a & 0x80
329
- a = (a << 1) & 0xFF
330
- if hi:
331
- a ^= 0x1B
332
- b >>= 1
333
- return r
334
-
335
-
336
- def _aes_expand(key):
337
- w = [list(key[i:i + 4]) for i in range(0, 32, 4)]
338
- for i in range(8, 60):
339
- t = list(w[i - 1])
340
- if i % 8 == 0:
341
- t = [AES_SBOX[b] for b in t[1:] + t[:1]]
342
- t[0] ^= AES_RCON[i // 8]
343
- elif i % 8 == 4:
344
- t = [AES_SBOX[b] for b in t]
345
- w.append([w[i - 8][j] ^ t[j] for j in range(4)])
346
- return [b for word in w for b in word]
347
-
348
-
349
- def _aes_shift_rows(s):
350
- o = list(s)
351
- for r in range(1, 4):
352
- vals = [s[c * 4 + r] for c in range(4)]
353
- vals = vals[r:] + vals[:r]
354
- for c in range(4):
355
- o[c * 4 + r] = vals[c]
356
- return o
357
-
358
-
359
- def _aes_encrypt_block(block, rk):
360
- s = list(block)
361
-
362
- def add(rnd):
363
- for i in range(16):
364
- s[i] ^= rk[rnd * 16 + i]
365
-
366
- add(0)
367
- for rnd in range(1, 14):
368
- s = [AES_SBOX[b] for b in s]
369
- s = _aes_shift_rows(s)
370
- mixed = []
371
- for c in range(4):
372
- a, b, d, e = s[c * 4:c * 4 + 4]
373
- mixed += [
374
- _aes_mul(a, 2) ^ _aes_mul(b, 3) ^ d ^ e,
375
- a ^ _aes_mul(b, 2) ^ _aes_mul(d, 3) ^ e,
376
- a ^ b ^ _aes_mul(d, 2) ^ _aes_mul(e, 3),
377
- _aes_mul(a, 3) ^ b ^ d ^ _aes_mul(e, 2),
378
- ]
379
- s = mixed
380
- add(rnd)
381
- s = _aes_shift_rows([AES_SBOX[b] for b in s])
382
- add(14)
383
- return bytes(s)
384
-
385
-
386
- def _aes_cbc(key, iv, data):
387
- rk = _aes_expand(key)
388
- out, prev = b"", iv
389
- for i in range(0, len(data), 16):
390
- block = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
391
- prev = _aes_encrypt_block(block, rk)
392
- out += prev
393
- return out
394
-
395
-
396
- def _authjs_cookie(env_path):
397
- root = os.path.dirname(os.path.abspath(env_path))
398
- for _ in range(5):
399
- nm = os.path.join(root, "node_modules")
400
- if os.path.isdir(os.path.join(nm, "@auth", "core")):
401
- return "authjs.session-token"
402
- try:
403
- with open(os.path.join(nm, "next-auth", "package.json"), encoding="utf-8") as f:
404
- major = json.load(f).get("version", "").lstrip("^~").split(".")[0]
405
- return "authjs.session-token" if major.isdigit() and int(major) >= 5 else None
406
- except OSError:
407
- pass
408
- parent = os.path.dirname(root)
409
- if parent == root:
410
- break
411
- root = parent
412
- # The dependency was detected upstream but the tree is not on disk: v5 is the current default.
413
- return "authjs.session-token"
414
-
415
-
416
- def authjs(recipe, env):
417
- secret = source("env:" + "|".join(recipe["env"]), env, None, None)
418
- if not secret:
419
- return {"status": "absent", "note": " or ".join(recipe["env"])}
420
- cookie = _authjs_cookie(PLAN["envPath"])
421
- if not cookie:
422
- return {"status": "upstream", "note": "Auth.js v4"}
423
- key = hkdf_sha256(secret.encode("utf-8"), cookie.encode("utf-8"), ("Auth.js Generated Encryption Key (%s)" % cookie).encode("utf-8"), 64)
424
- mac_key, enc_key = key[:32], key[32:]
425
- now = int(time.time())
426
- claims = {"sub": TEST, "name": "Cortad test", "email": "cortad@example.invalid", "iat": now, "exp": now + TTL, "jti": secrets.token_hex(16)}
427
- header = b64url(json.dumps({"alg": "dir", "enc": "A256CBC-HS512"}, separators=(",", ":")).encode("utf-8"))
428
- plaintext = json.dumps(claims, separators=(",", ":")).encode("utf-8")
429
- pad = 16 - (len(plaintext) % 16)
430
- iv = secrets.token_bytes(16)
431
- ct = _aes_cbc(enc_key, iv, plaintext + bytes([pad]) * pad)
432
- aad = header.encode("ascii")
433
- al = (len(aad) * 8).to_bytes(8, "big")
434
- tag = hmac.new(mac_key, aad + iv + ct + al, hashlib.sha512).digest()[:32]
435
- jwe = "%s..%s.%s.%s" % (header, b64url(iv), b64url(ct), b64url(tag))
436
- return minted("cookie", "%s=%s" % (cookie, jwe))
437
-
438
-
439
- # The Auth emulator the boot raised in place of their Firebase project: it signs any address up
440
- # under any key, and firebase-admin accepts its tokens while FIREBASE_AUTH_EMULATOR_HOST is set.
441
- def firebase(recipe, role, env):
442
- host = env.get("FIREBASE_AUTH_EMULATOR_HOST", "")
443
- if not re.fullmatch(r"[\w.-]+:\d{2,5}", host):
444
- return {"status": "upstream", "note": "Firebase"}
445
- g = gen(role)
446
- status, text = call(SIGNUP, {}, {"email": g["email"], "password": g["password"], "returnSecureToken": True}, http.cookiejar.CookieJar(), "http://" + host)
447
- if status == 0:
448
- return {"status": "unreachable", "note": text}
449
- try:
450
- reply = json.loads(text)
451
- except ValueError:
452
- reply = {}
453
- token = reply.get("idToken") if isinstance(reply, dict) else None
454
- if not isinstance(token, str) or not token:
455
- return {"status": "refused", "note": "HTTP %d at the Firebase emulator" % status}
456
- return minted(recipe["header"], token, int(reply.get("expiresIn") or 3600))
457
-
458
-
459
- def sealed(recipe, env):
460
- v = source("env:" + "|".join(recipe["env"]), env, None, None)
461
- if not v:
462
- return {"status": "absent", "note": " or ".join(recipe["env"])}
463
- return {"status": "sealed", "header": recipe["header"], "line": "%s: %s" % (recipe["header"], bearer(recipe["header"], v)), "note": " or ".join(recipe["env"])}
464
-
465
-
466
- # HTTP Basic from the username and password the app's own environment names: env[0] is the user,
467
- # env[1] the password. The line carries the scheme verbatim, so it is not run through bearer().
468
- def basic(recipe, env):
469
- names = recipe.get("env", [])
470
- user = env.get(names[0]) if len(names) > 0 else None
471
- pw = env.get(names[1]) if len(names) > 1 else None
472
- if not user or not pw:
473
- return {"status": "absent", "note": " and ".join(names)}
474
- raw = base64.b64encode(("%s:%s" % (user, pw)).encode("utf-8")).decode("ascii")
475
- return {"status": "minted", "header": recipe["header"], "line": "%s: Basic %s" % (recipe["header"], raw), "expires": int(time.time()) + TTL, "note": " and ".join(names)}
476
-
477
-
478
- def keep(path, line):
479
- fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
480
- with os.fdopen(fd, "w", encoding="utf-8") as f:
481
- f.write(line + "\n")
482
-
483
-
484
- # A token their door issued earlier and still honours is reused: sign-in doors are rate limited,
485
- # and a run re-minting on every boot would spend that budget on itself.
486
- def previous():
487
- try:
488
- with open(OUT, encoding="utf-8") as f:
489
- rows = json.load(f)["identities"]
490
- except Exception:
491
- return {}
492
- now = time.time()
493
- out = {}
494
- for r in rows:
495
- if r.get("status") not in ("minted", "sealed"):
496
- continue
497
- if not os.path.exists(FILE % (slug(r["role"]), "header" if r["status"] == "minted" else "sealed")):
498
- continue
499
- if r.get("expires") is not None and r["expires"] < now + 60:
500
- continue
501
- out[r["role"]] = r
502
- return out
503
-
504
-
505
- def main():
506
- env = read_env(PLAN["envPath"])
507
- kept = previous()
508
- rows = []
509
- for recipe in PLAN["recipes"]:
510
- for role_value in recipe.get("roles") or [None]:
511
- role = "%s:%s" % (recipe["role"], role_value) if role_value else recipe["role"]
512
- steps = recipe.get("steps") or []
513
- base = {
514
- "role": role, "kind": recipe["kind"], "header": recipe["header"], "where": recipe["where"],
515
- "opens": [o.replace("{role}", role_value or "") for o in recipe.get("opens", [])],
516
- "door": steps[-1]["route"] if steps else SIGNUP if recipe["kind"] == "firebase" else recipe["header"],
517
- }
518
- if role in kept:
519
- rows.append(dict(base, **{k: v for k, v in kept[role].items() if k in ("status", "header", "expires", "note")}))
520
- continue
521
- if time.time() > DEADLINE:
522
- rows.append(dict(base, status="unreachable", note="out of time"))
523
- continue
524
- try:
525
- if recipe["kind"] == "header":
526
- got = sealed(recipe, env)
527
- elif recipe["kind"] == "jwt":
528
- got = jwt(recipe, env)
529
- elif recipe["kind"] == "supabase":
530
- got = supabase(recipe, env)
531
- elif recipe["kind"] == "authjs":
532
- got = authjs(recipe, env)
533
- elif recipe["kind"] == "login":
534
- got = login(recipe, role, role_value, env)
535
- elif recipe["kind"] == "firebase":
536
- got = firebase(recipe, role, env)
537
- elif recipe["kind"] == "basic":
538
- got = basic(recipe, env)
539
- else:
540
- got = {"status": "upstream", "note": " or ".join(recipe["env"]) or recipe["role"]}
541
- except Exception as err:
542
- got = {"status": "unreachable", "note": type(err).__name__}
543
- # A sign-in the environment named its own credential for records which names it used.
544
- if recipe.get("envCred") and got.get("status") == "minted" and not got.get("note"):
545
- got["note"] = " and ".join(recipe.get("env", []))
546
- line = got.pop("line", None)
547
- if line:
548
- keep(FILE % (slug(role), "header" if got["status"] == "minted" else "sealed"), line)
549
- rows.append(dict(base, **got))
550
- report = {"identities": rows}
551
- with open(OUT, "w", encoding="utf-8") as f:
552
- json.dump(report, f)
553
- print("BL_IDENTITIES " + json.dumps(report))
554
-
555
-
556
- main()
557
- `;
558
-
559
- // The one line the program wrote, as typed rows. Anything malformed is dropped rather than read,
560
- // and nothing here is a value: a role, a header name, a status, a door.
561
- export function parseIdentityReport(text) {
562
- const line = String(text ?? "").split("\n").find((l) => l.startsWith("BL_IDENTITIES {")) ?? (String(text ?? "").trim().startsWith("{") ? `BL_IDENTITIES ${String(text).trim()}` : null);
563
- if (!line) return null;
564
- let raw;
565
- try {
566
- raw = JSON.parse(line.slice("BL_IDENTITIES ".length));
567
- } catch {
568
- return null;
569
- }
570
- if (!Array.isArray(raw?.identities)) return null;
571
- const identities = raw.identities.flatMap((r) => {
572
- if (!r || !ROLE.test(String(r.role)) || !KINDS.has(r.kind) || !HEADER.test(String(r.header)) || !STATUSES.has(r.status)) return [];
573
- if (typeof r.where !== "string" || !WHERE.test(r.where) || typeof r.door !== "string" || r.door.length > 200) return [];
574
- const opens = (Array.isArray(r.opens) ? r.opens : []).filter((o) => typeof o === "string" && OPENS.test(o)).slice(0, 8);
575
- return [{
576
- role: r.role, kind: r.kind, header: r.header, status: r.status, opens, door: r.door, where: r.where,
577
- ...(Number.isFinite(r.expires) ? { expires: Number(r.expires) } : {}),
578
- ...(typeof r.note === "string" ? { note: r.note.slice(0, 200) } : {}),
579
- }];
580
- });
581
- return { identities };
582
- }
583
-
584
- // The credential pairs an environment names, by shared prefix. Names only, never values.
585
- export function credentialPairs(names) {
586
- const list = (Array.isArray(names) ? names : []).filter((n) => typeof n === "string" && ENV_NAME.test(n));
587
- const users = list.filter((n) => USER_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(USER_TOKEN, "") }));
588
- const secrets = list.filter((n) => SECRET_TOKEN.test(n)).map((n) => ({ name: n, prefix: n.replace(SECRET_TOKEN, "") }));
589
- const pairs = [];
590
- const seen = new Set();
591
- for (const u of users) {
592
- const s = secrets.find((x) => x.prefix === u.prefix);
593
- if (s && !seen.has(u.prefix) && !SERVICE_PREFIX.test(u.prefix)) { seen.add(u.prefix); pairs.push({ user: u.name, secret: s.name, prefix: u.prefix }); }
594
- }
595
- return pairs;
596
- }
597
-
598
- const routeRole = (route) => {
599
- const seg = route.split("/").filter((s) => s && !/^(?:api|v\d+|auth)$/i.test(s)).pop() ?? "user";
600
- const r = seg.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 20);
601
- return /^[a-z]/.test(r) ? r : "user";
602
- };
603
- const uniqueRole = (base, used) => {
604
- const stem = /^[a-z]/.test(base) ? base : "account";
605
- let role = stem;
606
- for (let n = 2; used.has(role); n += 1) role = `${stem}-${n}`;
607
- used.add(role);
608
- return role;
609
- };
610
-
611
- // When the app's own environment names a credential pair, the box can sign in with it. Each login
612
- // recipe whose sign-in form takes a username or email and a password is filled from the pair
613
- // instead of a generated account; each discovered sign-in route with no recipe becomes one; and an
614
- // HTTP Basic identity is minted for a door that answers 401 with a Basic challenge. Values are
615
- // opened by the mint program on the machine, never here: this only rewrites which env name a field
616
- // reads. A register step (one of several, not a sign-in) keeps its fresh account.
617
- export function envCredentialRecipes(recipes, names, loginRoutes = []) {
618
- const list = Array.isArray(recipes) ? recipes : [];
619
- const pairs = credentialPairs(names);
620
- if (!pairs.length) return list;
621
- const pair = pairs[0];
622
- const userSrc = `env:${pair.user}`;
623
- const passSrc = `env:${pair.secret}`;
624
- const used = new Set(list.map((r) => r?.role).filter(Boolean));
625
- const covered = new Set();
626
- const out = list.map((r) => {
627
- if (r?.kind !== "login" || !Array.isArray(r.steps)) return r;
628
- let touched = false;
629
- const steps = r.steps.map((s) => {
630
- if (r.steps.length !== 1 && !SIGN_IN_ROUTE.test(s.route)) return s;
631
- const body = { ...(s.body ?? {}) };
632
- let hit = false;
633
- for (const [f, v] of Object.entries(body)) {
634
- if (typeof v !== "string" || !v.startsWith("gen:")) continue;
635
- if (CRED_USER_FIELD.test(f)) { body[f] = userSrc; hit = true; }
636
- else if (CRED_PASS_FIELD.test(f)) { body[f] = passSrc; hit = true; }
637
- }
638
- if (!hit) return s;
639
- covered.add(s.route);
640
- touched = true;
641
- return { ...s, body };
642
- });
643
- return touched ? { ...r, steps, env: [...new Set([...(r.env ?? []), pair.user, pair.secret])], envCred: true } : r;
644
- });
645
- const known = new Set(out.flatMap((r) => (r?.steps ?? []).map((s) => s.route)));
646
- const userField = /_EMAIL$/.test(pair.user) ? "email" : "username";
647
- for (const route of (Array.isArray(loginRoutes) ? loginRoutes : []).slice(0, 6)) {
648
- if (typeof route !== "string" || !route.startsWith("/") || known.has(route) || covered.has(route)) continue;
649
- out.push({
650
- kind: "login", role: uniqueRole(routeRole(route), used), header: "authorization", env: [pair.user, pair.secret],
651
- steps: [{ route, body: { [userField]: userSrc, password: passSrc } }], opens: [], envCred: true,
652
- where: `your environment (${pair.user} and ${pair.secret})`,
653
- });
654
- }
655
- // A Basic identity for each pair as well: a door that wants a sign-in form ignores it, and a door
656
- // behind HTTP Basic (which declares no route to fill) is reached by nothing else.
657
- for (const p of pairs) {
658
- out.push({
659
- kind: "basic", role: uniqueRole(slug(p.prefix.toLowerCase()) || "account", used), header: "authorization",
660
- env: [p.user, p.secret], opens: [], envCred: true, where: `your environment (${p.user} and ${p.secret})`,
661
- });
662
- }
663
- return out;
664
- }
665
-
666
- // What each row means to the person whose app this is.
667
- export function identitySentence(row) {
668
- switch (row.status) {
669
- case "minted": return row.kind === "firebase"
670
- ? `signed in as ${row.role} through the Firebase emulator standing in for your project; the token stays inside the sandbox`
671
- : row.kind === "basic"
672
- ? `signed in as ${row.role} with the username and password your environment sets (${row.note ?? "two variables"}); the header stays inside the sandbox`
673
- : `signed in as ${row.role} through your own door ${row.door}; the token stays inside the sandbox`;
674
- case "sealed": return `${row.role} is the sealed ${row.note} value itself; it never leaves the sandbox, so only the compile's own probe can speak as it`;
675
- case "absent": return `could not sign in as ${row.role}: ${row.note} is not in the environment you sealed`;
676
- case "refused": return `your door refused the ${row.role} sign-in (${row.note}), so cases behind it are counted and not driven`;
677
- case "unreachable": return `the ${row.role} sign-in at ${row.door} did not answer (${row.note}); that is ours to look at`;
678
- default: return `${row.role} callers are verified by ${row.note}, outside this sandbox; their doors are counted and not driven`;
679
- }
680
- }
681
-
682
- // Write the program, run it detached, read the report back, and leave only the header files and
683
- // the report the next mint reuses.
684
- export async function mintInBox(box, recipes, target) {
685
- if (!(await box.write(IDENTITY_PATH, identityScript(recipes, target)))) return null;
686
- await box.run("bl-identities", `python3 ${IDENTITY_PATH}`).catch(() => null);
687
- const out = await box.exec(`cat ${IDENTITY_OUT} 2>/dev/null; rm -f ${IDENTITY_PATH}`).catch(() => "");
688
- return parseIdentityReport(out);
689
- }