@veducio/keja 0.2.3 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@veducio/keja",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "description": "Veducio Keja - RESTFUL Web Server, Based on Lightweight Hono Framework, Written in TypeScript, Powered by Bun",
6
6
  "license": "MIT",
@@ -0,0 +1,189 @@
1
+ import { decodeJwt, decodeProtectedHeader } from "jose";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { connectVault, setVault } from "../vault/client.ts";
4
+ import { grant, verifyGrant } from "./grant.ts";
5
+ import { setGrants } from "./settings.ts";
6
+
7
+ afterEach(() => {
8
+ setVault(null);
9
+ setGrants(null);
10
+ });
11
+
12
+ describe("grant", () => {
13
+ it("signs with a secret from grants config", async () => {
14
+ setGrants({
15
+ mode: "secret",
16
+ max_exp: 900,
17
+ secret: "test-secret",
18
+ });
19
+ const now = 1_700_000_000_000;
20
+ const clock = Date.now;
21
+ Date.now = () => now;
22
+ try {
23
+ const token = await grant({
24
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
25
+ action: "posts.read",
26
+ exp: 60,
27
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
28
+ });
29
+ expect(decodeProtectedHeader(token)).toEqual({
30
+ alg: "HS256",
31
+ typ: "JWT",
32
+ });
33
+ expect(decodeJwt(token)).toMatchObject({
34
+ sub: "tenant_01h455vb4pex5vsknk084sn02q",
35
+ action: "posts.read",
36
+ iat: 1_700_000_000,
37
+ exp: 1_700_000_060,
38
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
39
+ });
40
+ await expect(
41
+ verifyGrant(token, { action: "posts.read" }),
42
+ ).resolves.toEqual({
43
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
44
+ action: "posts.read",
45
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
46
+ });
47
+ } finally {
48
+ Date.now = clock;
49
+ }
50
+ });
51
+
52
+ it("signs a subject, action, expiry, and context with the transit key", async () => {
53
+ const calls: { url: string; body: unknown }[] = [];
54
+ await connectVault(
55
+ {
56
+ address: "https://vault.example",
57
+ token: "s.token",
58
+ namespace: "apps",
59
+ mount: "secret",
60
+ },
61
+ async (url, init) => {
62
+ const body = init?.body ? JSON.parse(String(init.body)) : null;
63
+ calls.push({ url, body });
64
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
65
+ return Response.json({ data: { id: "s.token" } });
66
+ }
67
+ if (url.endsWith("/keys/keja-grants")) {
68
+ return Response.json({
69
+ data: { type: "ecdsa-p256", latest_version: 1 },
70
+ });
71
+ }
72
+ if (url.endsWith("/sign/keja-grants")) {
73
+ return Response.json({
74
+ data: {
75
+ signature: `vault:v1:${Buffer.from("signed").toString("base64")}`,
76
+ },
77
+ });
78
+ }
79
+ return Response.json({ data: { valid: true } });
80
+ },
81
+ );
82
+ setGrants({
83
+ mode: "transit",
84
+ max_exp: 900,
85
+ transit_key: "keja-grants",
86
+ transit_mount: "transit",
87
+ });
88
+ const now = 1_700_000_000_000;
89
+ const clock = Date.now;
90
+ Date.now = () => now;
91
+ try {
92
+ const token = await grant({
93
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
94
+ action: "posts.read",
95
+ exp: 60,
96
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
97
+ });
98
+ expect(decodeProtectedHeader(token)).toEqual({
99
+ alg: "ES256",
100
+ typ: "JWT",
101
+ kid: "1",
102
+ });
103
+ const sign = calls.find((call) => call.url.endsWith("/sign/keja-grants"));
104
+ expect(sign?.body).toMatchObject({
105
+ hash_algorithm: "sha2-256",
106
+ marshaling_algorithm: "jws",
107
+ });
108
+ await expect(
109
+ verifyGrant(token, { action: "posts.read" }),
110
+ ).resolves.toEqual({
111
+ subject: "tenant_01h455vb4pex5vsknk084sn02q",
112
+ action: "posts.read",
113
+ context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
114
+ });
115
+ Date.now = () => now + 120_000;
116
+ await expect(
117
+ verifyGrant(token, { action: "posts.read" }),
118
+ ).rejects.toThrow("grant is expired");
119
+ } finally {
120
+ Date.now = clock;
121
+ }
122
+ });
123
+
124
+ it("rejects a lifetime past the configured maximum", async () => {
125
+ setGrants({
126
+ mode: "secret",
127
+ max_exp: 900,
128
+ secret: "test-secret",
129
+ });
130
+ await expect(
131
+ grant({ subject: "tenant", action: "posts.read", exp: 901 }),
132
+ ).rejects.toThrow("grant exp must be at most 900 seconds");
133
+ });
134
+
135
+ it("requires grants to be enabled", async () => {
136
+ await expect(
137
+ grant({ subject: "tenant", action: "posts.read", exp: 60 }),
138
+ ).rejects.toThrow("grant requires grants");
139
+ });
140
+
141
+ it("rejects a signature Vault does not accept", async () => {
142
+ await connectVault(
143
+ {
144
+ address: "https://vault.example",
145
+ token: "s.token",
146
+ namespace: null,
147
+ mount: "secret",
148
+ },
149
+ async (url) => {
150
+ if (url.endsWith("/v1/auth/token/lookup-self")) {
151
+ return Response.json({ data: { id: "s.token" } });
152
+ }
153
+ if (url.endsWith("/keys/keja-grants")) {
154
+ return Response.json({
155
+ data: { type: "rsa-2048", latest_version: 1 },
156
+ });
157
+ }
158
+ if (url.endsWith("/sign/keja-grants")) {
159
+ return Response.json({
160
+ data: {
161
+ signature: `vault:v1:${Buffer.from("signed").toString("base64")}`,
162
+ },
163
+ });
164
+ }
165
+ return Response.json({ data: { valid: false } });
166
+ },
167
+ );
168
+ setGrants({
169
+ mode: "transit",
170
+ max_exp: 900,
171
+ transit_key: "keja-grants",
172
+ transit_mount: "transit",
173
+ });
174
+ const now = Date.now;
175
+ Date.now = () => 1_700_000_000_000;
176
+ try {
177
+ const token = await grant({
178
+ subject: "tenant",
179
+ action: "posts.read",
180
+ exp: 60,
181
+ });
182
+ await expect(
183
+ verifyGrant(token, { action: "posts.read" }),
184
+ ).rejects.toThrow("grant signature was rejected");
185
+ } finally {
186
+ Date.now = now;
187
+ }
188
+ });
189
+ });
@@ -0,0 +1,289 @@
1
+ import {
2
+ decodeJwt,
3
+ decodeProtectedHeader,
4
+ type JWTPayload,
5
+ jwtVerify,
6
+ SignJWT,
7
+ } from "jose";
8
+ import { openVault, VaultError } from "../vault/client.ts";
9
+ import { requireGrants } from "./settings.ts";
10
+
11
+ export type Grant = {
12
+ subject: string;
13
+ action: string;
14
+ context: Record<string, unknown>;
15
+ };
16
+
17
+ type GrantInput = {
18
+ subject: string;
19
+ action: string;
20
+ exp: number;
21
+ context?: Record<string, unknown>;
22
+ };
23
+
24
+ type KeySpec = {
25
+ alg: string;
26
+ hash?: string;
27
+ marshaling?: string;
28
+ signature?: string;
29
+ };
30
+
31
+ const keySpecs: Record<string, KeySpec> = {
32
+ "rsa-2048": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
33
+ "rsa-3072": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
34
+ "rsa-4096": { alg: "RS256", hash: "sha2-256", signature: "pkcs1v15" },
35
+ "ecdsa-p256": { alg: "ES256", hash: "sha2-256", marshaling: "jws" },
36
+ "ecdsa-p384": { alg: "ES384", hash: "sha2-384", marshaling: "jws" },
37
+ "ecdsa-p521": { alg: "ES512", hash: "sha2-512", marshaling: "jws" },
38
+ ed25519: { alg: "EdDSA" },
39
+ };
40
+
41
+ export async function grant(input: GrantInput) {
42
+ const settings = requireGrants("grant");
43
+ const subject = requiredText(input.subject, "subject");
44
+ const action = requiredText(input.action, "action");
45
+ const lifetime = lifetimeSeconds(input.exp, settings.max_exp);
46
+ const context = contextObject(input.context);
47
+ const now = Math.floor(Date.now() / 1000);
48
+ const payload = {
49
+ sub: subject,
50
+ action,
51
+ iat: now,
52
+ exp: now + lifetime,
53
+ context,
54
+ };
55
+ if (settings.mode === "secret") {
56
+ return new SignJWT({ action, context })
57
+ .setProtectedHeader({ alg: "HS256", typ: "JWT" })
58
+ .setSubject(subject)
59
+ .setIssuedAt(now)
60
+ .setExpirationTime(now + lifetime)
61
+ .sign(secretKey(settings.secret));
62
+ }
63
+ const vault = openVault("grant");
64
+ const key = await readKey(vault, settings);
65
+ const body = base64url(JSON.stringify(payload));
66
+ const header = base64url(
67
+ JSON.stringify({ alg: key.alg, typ: "JWT", kid: String(key.version) }),
68
+ );
69
+ const signingInput = `${header}.${body}`;
70
+ const signed = await vault.transitSign(
71
+ settings.transit_mount,
72
+ settings.transit_key,
73
+ signBody(key, base64(signingInput)),
74
+ );
75
+ const signature = vaultSignature(signed.signature);
76
+ if (signature.version !== String(key.version)) {
77
+ throw new VaultError("Vault signed the grant with a different key version");
78
+ }
79
+ return `${signingInput}.${signature.segment}`;
80
+ }
81
+
82
+ export async function verifyGrant(token: string, options: { action: string }) {
83
+ const settings = requireGrants("verifyGrant");
84
+ const action = requiredText(options.action, "action");
85
+ const parts = token.split(".");
86
+ if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
87
+ throw new VaultError("grant token is unreadable");
88
+ }
89
+ if (settings.mode === "secret") {
90
+ try {
91
+ const verified = await jwtVerify(token, secretKey(settings.secret), {
92
+ algorithms: ["HS256"],
93
+ clockTolerance: 0,
94
+ currentDate: new Date(Date.now()),
95
+ });
96
+ return claims(verified.payload, action);
97
+ } catch (error) {
98
+ if (error instanceof VaultError) {
99
+ throw error;
100
+ }
101
+ throw new VaultError("grant signature was rejected");
102
+ }
103
+ }
104
+ const header = decodeProtectedHeader(token);
105
+ const payload = decodeJwt(token);
106
+ const vault = openVault("verifyGrant");
107
+ const key = await readKey(vault, settings);
108
+ if (
109
+ header.alg !== key.alg ||
110
+ typeof header.kid !== "string" ||
111
+ !/^\d+$/.test(header.kid)
112
+ ) {
113
+ throw new VaultError("grant token is unreadable");
114
+ }
115
+ const verified = await vault.transitVerify(
116
+ settings.transit_mount,
117
+ settings.transit_key,
118
+ {
119
+ ...signBody(key, base64(`${parts[0]}.${parts[1]}`)),
120
+ signature: `vault:v${header.kid}:${Buffer.from(parts[2], "base64url").toString("base64")}`,
121
+ },
122
+ );
123
+ if (verified.valid !== true) {
124
+ throw new VaultError("grant signature was rejected");
125
+ }
126
+ return claims(payload, action);
127
+ }
128
+
129
+ async function readKey(
130
+ vault: ReturnType<typeof openVault>,
131
+ settings: { transit_mount: string; transit_key: string },
132
+ ) {
133
+ const data = await vault.transitKey(
134
+ settings.transit_mount,
135
+ settings.transit_key,
136
+ );
137
+ if (typeof data.type !== "string" || !(data.type in keySpecs)) {
138
+ throw new VaultError("Vault transit key type cannot sign a grant");
139
+ }
140
+ const version = data.latest_version;
141
+ if (
142
+ typeof version !== "number" ||
143
+ !Number.isInteger(version) ||
144
+ version < 1
145
+ ) {
146
+ throw new VaultError("Vault transit key has no version");
147
+ }
148
+ return { ...keySpecs[data.type], version };
149
+ }
150
+
151
+ function signBody(
152
+ key: KeySpec,
153
+ input: string,
154
+ ): {
155
+ input: string;
156
+ hash_algorithm?: string;
157
+ marshaling_algorithm?: string;
158
+ signature_algorithm?: string;
159
+ } {
160
+ return {
161
+ input,
162
+ ...(key.hash ? { hash_algorithm: key.hash } : {}),
163
+ ...(key.marshaling ? { marshaling_algorithm: key.marshaling } : {}),
164
+ ...(key.signature ? { signature_algorithm: key.signature } : {}),
165
+ };
166
+ }
167
+
168
+ function vaultSignature(value: unknown) {
169
+ if (typeof value !== "string") {
170
+ throw new VaultError("Vault returned an unreadable signature");
171
+ }
172
+ const match = /^vault:v(\d+):([A-Za-z0-9+/=_-]+)$/.exec(value);
173
+ if (!match) {
174
+ throw new VaultError("Vault returned an unreadable signature");
175
+ }
176
+ return {
177
+ version: match[1],
178
+ segment: Buffer.from(match[2], "base64").toString("base64url"),
179
+ };
180
+ }
181
+
182
+ function secretKey(secret: string) {
183
+ return new TextEncoder().encode(secret);
184
+ }
185
+
186
+ function claims(payload: JWTPayload, action: string): Grant {
187
+ const now = Math.floor(Date.now() / 1000);
188
+ if (
189
+ typeof payload.exp !== "number" ||
190
+ !Number.isInteger(payload.exp) ||
191
+ payload.exp <= now
192
+ ) {
193
+ throw new VaultError("grant is expired");
194
+ }
195
+ if (
196
+ typeof payload.iat !== "number" ||
197
+ !Number.isInteger(payload.iat) ||
198
+ payload.iat > now ||
199
+ payload.iat >= payload.exp
200
+ ) {
201
+ throw new VaultError("grant token is unreadable");
202
+ }
203
+ if (typeof payload.sub !== "string" || payload.sub.trim() === "") {
204
+ throw new VaultError("grant token is unreadable");
205
+ }
206
+ if (payload.action !== action) {
207
+ throw new VaultError("grant action does not match");
208
+ }
209
+ if (!isContext(payload.context)) {
210
+ throw new VaultError("grant context must be a plain object");
211
+ }
212
+ return {
213
+ subject: payload.sub,
214
+ action,
215
+ context: payload.context,
216
+ };
217
+ }
218
+
219
+ function requiredText(value: unknown, field: string) {
220
+ if (
221
+ typeof value !== "string" ||
222
+ value.trim() === "" ||
223
+ value.trim() !== value
224
+ ) {
225
+ throw new VaultError(`grant ${field} must be a non-empty string`);
226
+ }
227
+ return value;
228
+ }
229
+
230
+ function lifetimeSeconds(value: unknown, max: number) {
231
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
232
+ throw new VaultError("grant exp must be a whole number of seconds");
233
+ }
234
+ if (value > max) {
235
+ throw new VaultError(`grant exp must be at most ${max} seconds`);
236
+ }
237
+ return value;
238
+ }
239
+
240
+ function contextObject(value: unknown) {
241
+ if (value === undefined) {
242
+ return {};
243
+ }
244
+ if (!isContext(value)) {
245
+ throw new VaultError("grant context must be a plain object");
246
+ }
247
+ return value;
248
+ }
249
+
250
+ function isContext(value: unknown): value is Record<string, unknown> {
251
+ if (!isPlain(value)) {
252
+ return false;
253
+ }
254
+ return Object.values(value).every((item) => isJson(item));
255
+ }
256
+
257
+ function isJson(value: unknown): boolean {
258
+ if (
259
+ value === null ||
260
+ typeof value === "string" ||
261
+ typeof value === "boolean" ||
262
+ (typeof value === "number" && Number.isFinite(value))
263
+ ) {
264
+ return true;
265
+ }
266
+ if (Array.isArray(value)) {
267
+ return value.every((item) => isJson(item));
268
+ }
269
+ if (!isPlain(value)) {
270
+ return false;
271
+ }
272
+ return Object.values(value).every((item) => isJson(item));
273
+ }
274
+
275
+ function isPlain(value: unknown): value is Record<string, unknown> {
276
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
277
+ return false;
278
+ }
279
+ const proto = Object.getPrototypeOf(value);
280
+ return proto === Object.prototype || proto === null;
281
+ }
282
+
283
+ function base64url(value: string) {
284
+ return Buffer.from(value).toString("base64url");
285
+ }
286
+
287
+ function base64(value: string) {
288
+ return Buffer.from(value).toString("base64");
289
+ }
@@ -0,0 +1,67 @@
1
+ import { DependencyNotMet } from "../errors/framework-errors.ts";
2
+ import { processValue } from "../process/slot.ts";
3
+ import type { GrantsConfig } from "../project/config.ts";
4
+ import { VaultError, vaultSecret } from "../vault/client.ts";
5
+
6
+ export type ActiveGrants =
7
+ | {
8
+ mode: "transit";
9
+ max_exp: number;
10
+ transit_key: string;
11
+ transit_mount: string;
12
+ }
13
+ | {
14
+ mode: "secret";
15
+ max_exp: number;
16
+ secret: string;
17
+ };
18
+
19
+ const state = processValue("grants", () => ({
20
+ active: null as ActiveGrants | null,
21
+ }));
22
+
23
+ export function setGrants(settings: ActiveGrants | null) {
24
+ state.active = settings;
25
+ }
26
+
27
+ export function requireGrants(feature: string) {
28
+ if (!state.active) {
29
+ throw new DependencyNotMet(feature, "grants");
30
+ }
31
+ return state.active;
32
+ }
33
+
34
+ export async function connectGrants(config: GrantsConfig) {
35
+ if (!config.enabled) {
36
+ setGrants(null);
37
+ return;
38
+ }
39
+ if (config.mode === "transit") {
40
+ setGrants({
41
+ mode: "transit",
42
+ max_exp: config.max_exp,
43
+ transit_key: config.transit_key,
44
+ transit_mount: config.transit_mount,
45
+ });
46
+ return;
47
+ }
48
+ let secret = config.secret;
49
+ if (config.secret_vault) {
50
+ const data = await vaultSecret(config.secret_vault);
51
+ const value = data[config.secret_field];
52
+ if (typeof value !== "string" || value.trim() === "") {
53
+ throw new VaultError(
54
+ `Vault secret ${config.secret_vault} field ${config.secret_field} is required`,
55
+ );
56
+ }
57
+ secret = value.trim();
58
+ }
59
+ if (typeof secret !== "string" || secret.trim() === "") {
60
+ throw new VaultError("grant secret is required");
61
+ }
62
+ setGrants({
63
+ mode: "secret",
64
+ max_exp: config.max_exp,
65
+ secret: secret.trim(),
66
+ });
67
+ }