bazis 0.98.25 → 0.98.26

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,4 +1,5 @@
1
- import { ForbiddenError } from "../Errors/HttpError";
1
+ import { JwtError } from "../../../library/jwt/errors";
2
+ import { ForbiddenError, UnauthorizedError } from "../Errors/HttpError";
2
3
  import type { HttpMiddleware } from "../Middleware/types";
3
4
  import type { RouteMiddlewareComposer } from "../options";
4
5
  import { resolveAuthorizeMeta } from "./metadata";
@@ -8,7 +9,8 @@ import { resolveAuthorizeMeta } from "./metadata";
8
9
  * the per-route composers. For routes that require authorization it returns
9
10
  * middleware that runs all checks in order: any that returns `false` → `403`;
10
11
  * a check may throw an `HttpError` itself (for example
11
- * `UnauthorizedError` → `401`).
12
+ * `UnauthorizedError` → `401`). A `JwtError` (missing, malformed, expired or
13
+ * forged token) escaping a check is a 401 as well, not a server error.
12
14
  */
13
15
  export function createAuthorizeComposer(): RouteMiddlewareComposer {
14
16
  return (controllerClass, methodName) => {
@@ -20,7 +22,18 @@ export function createAuthorizeComposer(): RouteMiddlewareComposer {
20
22
  const { checks } = meta.authorize;
21
23
  const middleware: HttpMiddleware = async (ctx, next) => {
22
24
  for (const check of checks) {
23
- if (!(await check(ctx))) {
25
+ let allowed: boolean;
26
+ try {
27
+ allowed = await check(ctx);
28
+ } catch (error) {
29
+ if (error instanceof JwtError) {
30
+ const unauthorized = new UnauthorizedError();
31
+ Object.defineProperty(unauthorized, "cause", { value: error, configurable: true, writable: true });
32
+ throw unauthorized;
33
+ }
34
+ throw error;
35
+ }
36
+ if (!allowed) {
24
37
  throw new ForbiddenError();
25
38
  }
26
39
  }
@@ -0,0 +1,11 @@
1
+ import type { HttpContext } from "../HttpContext/HttpContext";
2
+
3
+ /**
4
+ * The token of an `Authorization: Bearer <token>` header, or `undefined`
5
+ * without the header, with another scheme or with an empty token. The scheme
6
+ * is case-insensitive (RFC 7235); surrounding spaces are trimmed.
7
+ */
8
+ export function bearerToken(ctx: Pick<HttpContext, "header">): string | undefined {
9
+ const match = /^\s*bearer\s+(\S+)\s*$/i.exec(ctx.header("authorization") ?? "");
10
+ return match?.[1];
11
+ }
@@ -34,6 +34,7 @@ export { PRINCIPAL_STATE_KEY, type RequestPrincipal } from "./HttpContext/princi
34
34
  // Authorization
35
35
  export { Authorize, AllowAnonymous } from "./Authorization/Authorize";
36
36
  export { createAuthorizeComposer } from "./Authorization/authorizeComposer";
37
+ export { bearerToken } from "./Authorization/bearerToken";
37
38
  export {
38
39
  resolveAuthorizeMeta,
39
40
  type AuthorizeCheck,
@@ -232,7 +232,7 @@ export class CodexAppServerClient implements CodexClient {
232
232
  if (method === "account/updated") this.modelCache = undefined;
233
233
  }, () => { this.clearLogin(); this.modelCache = undefined; });
234
234
  const initialized = await client.request("initialize", {
235
- clientInfo: { name: "bazis", title: "Bazis", version: "0.98.25" }, capabilities: { experimentalApi: true },
235
+ clientInfo: { name: "bazis", title: "Bazis", version: "0.98.26" }, capabilities: { experimentalApi: true },
236
236
  }, 15_000, signal);
237
237
  if (resolve(text(initialized.codexHome, 4096)) !== this.paths.home) throw new CodexError("PROTOCOL_ERROR");
238
238
  client.notify("initialized");
@@ -34,14 +34,19 @@ export class Rs256Algorithm implements SigningAlgorithm {
34
34
  if (keys === null || typeof keys !== "object" || Array.isArray(keys)) {
35
35
  throw new TypeError("RS256 key material must be an object");
36
36
  }
37
- this.keys = Object.freeze({ ...keys });
38
37
  for (const field of ["publicKeyPem", "privateKeyPem", "keyId"] as const) {
39
- const value = this.keys[field];
38
+ const value = keys[field];
40
39
  if (value === undefined && field !== "publicKeyPem") continue;
41
40
  if (typeof value !== "string" || value.length === 0) {
42
41
  throw new TypeError(`RS256 ${field} must be a non-empty string`);
43
42
  }
44
43
  }
44
+ // A wrong key format must fail here, at startup, not on the first sign-in.
45
+ this.keys = Object.freeze({
46
+ ...keys,
47
+ publicKeyPem: checkedPem("publicKeyPem", keys.publicKeyPem),
48
+ ...(keys.privateKeyPem === undefined ? {} : { privateKeyPem: checkedPem("privateKeyPem", keys.privateKeyPem) }),
49
+ });
45
50
  this.canSign = this.keys.privateKeyPem !== undefined;
46
51
  this.keyId = this.keys.keyId;
47
52
  }
@@ -78,13 +83,13 @@ export class Rs256Algorithm implements SigningAlgorithm {
78
83
  if (this.keys.privateKeyPem === undefined) {
79
84
  throw new Error("RS256 private key PEM is required for signing");
80
85
  }
81
- const key = await crypto.subtle.importKey(
86
+ const key = await importKey("private", () => crypto.subtle.importKey(
82
87
  "pkcs8",
83
- decodePemToBuffer(this.keys.privateKeyPem),
88
+ decodePemToBuffer(this.keys.privateKeyPem!),
84
89
  RSA_PARAMS,
85
90
  false,
86
91
  ["sign"],
87
- );
92
+ ));
88
93
  return this.cachedPrivate = this.requireStrongKey(key);
89
94
  }
90
95
 
@@ -92,13 +97,13 @@ export class Rs256Algorithm implements SigningAlgorithm {
92
97
  if (this.cachedPublic !== undefined) {
93
98
  return this.cachedPublic;
94
99
  }
95
- const key = await crypto.subtle.importKey(
100
+ const key = await importKey("public", () => crypto.subtle.importKey(
96
101
  "spki",
97
102
  decodePemToBuffer(this.keys.publicKeyPem),
98
103
  RSA_PARAMS,
99
104
  true,
100
105
  ["verify"],
101
- );
106
+ ));
102
107
  return this.cachedPublic = this.requireStrongKey(key);
103
108
  }
104
109
 
@@ -112,6 +117,39 @@ export class Rs256Algorithm implements SigningAlgorithm {
112
117
  }
113
118
  }
114
119
 
120
+ const PEM = /^-----BEGIN ([A-Z0-9 ]+)-----\n([\s\S]*?)\n?-----END \1-----$/;
121
+
122
+ /**
123
+ * Normalizes and checks one PEM string: literal `\n` (a key stored on one line
124
+ * in .env or a CI secret) and CRLF become line breaks; the label must be the
125
+ * one Web Crypto imports (PKCS#8 private, SPKI public), with a fix otherwise.
126
+ */
127
+ function checkedPem(field: "publicKeyPem" | "privateKeyPem", value: string): string {
128
+ // A backslash never occurs in base64 or PEM armor, so this cannot change a valid key.
129
+ const pem = value.replaceAll("\\n", "\n").replaceAll("\r\n", "\n").trim();
130
+ const expected = field === "publicKeyPem" ? "PUBLIC KEY" : "PRIVATE KEY";
131
+ const match = PEM.exec(pem);
132
+ const label = match?.[1] ?? /^-----BEGIN ([A-Z0-9 ]+)-----/.exec(pem)?.[1];
133
+ if (label === undefined) throw new TypeError(`RS256 ${field} is not a PEM: expected "-----BEGIN ${expected}-----"`);
134
+ if (field === "privateKeyPem" && label === "RSA PRIVATE KEY") throw new TypeError("RS256 privateKeyPem is a PKCS#1 key (BEGIN RSA PRIVATE KEY); convert it to PKCS#8: openssl pkcs8 -topk8 -nocrypt -in key.pem -out key-pkcs8.pem");
135
+ if (field === "privateKeyPem" && label === "ENCRYPTED PRIVATE KEY") throw new TypeError("RS256 privateKeyPem is encrypted; remove the passphrase first: openssl pkcs8 -in key.pem -out key-plain.pem");
136
+ if (field === "privateKeyPem" && label.endsWith("PUBLIC KEY")) throw new TypeError("RS256 privateKeyPem contains a PUBLIC KEY; pass the private key (BEGIN PRIVATE KEY)");
137
+ if (field === "publicKeyPem" && label === "RSA PUBLIC KEY") throw new TypeError("RS256 publicKeyPem is a PKCS#1 public key (BEGIN RSA PUBLIC KEY); convert it to SPKI: openssl rsa -RSAPublicKey_in -in public.pem -pubout -out public-spki.pem");
138
+ if (field === "publicKeyPem" && label.endsWith("PRIVATE KEY")) throw new TypeError("RS256 publicKeyPem contains a private key; give verifiers only the public key: openssl pkey -in key.pem -pubout -out public.pem");
139
+ if (label !== expected || match === undefined || match === null) throw new TypeError(`RS256 ${field} is not a PEM: expected "-----BEGIN ${expected}-----"`);
140
+ if (!/^[A-Za-z0-9+/=\s]+$/.test(match[2]!)) throw new TypeError(`RS256 ${field} has an invalid base64 body`);
141
+ return pem;
142
+ }
143
+
144
+ /** Web Crypto says only "Invalid keyData"; name the key that failed. */
145
+ async function importKey(which: "private" | "public", work: () => Promise<CryptoKey>): Promise<CryptoKey> {
146
+ try {
147
+ return await work();
148
+ } catch (error) {
149
+ throw new Error(`RS256 ${which} key could not be imported (${error instanceof Error ? error.message : String(error)}); check that it is an RSA ${which === "private" ? "PKCS#8 private" : "SPKI public"} key in PEM`, { cause: error });
150
+ }
151
+ }
152
+
115
153
  /** Convenience factory for {@link Rs256Algorithm}. */
116
154
  export function rs256(keys: Rs256KeyMaterial): Rs256Algorithm {
117
155
  return new Rs256Algorithm(keys);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bazis",
3
- "version": "0.98.25",
3
+ "version": "0.98.26",
4
4
  "description": "Modular backend framework for Bun in TypeScript: DI wired by codegen, HTTP, a PostgreSQL ORM, JWT, WebSocket, agents and a CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Alexander Alenin",