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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/core/http/index.ts
CHANGED
|
@@ -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.
|
|
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 =
|
|
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.
|
|
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",
|