@veducio/keja 0.3.0 → 0.5.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.3.0",
3
+ "version": "0.5.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",
@@ -1,26 +1,21 @@
1
1
  import { decodeJwt, decodeProtectedHeader } from "jose";
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
- import { connectVault, setVault, VaultError } from "../vault/client.ts";
3
+ import { connectVault, setVault } from "../vault/client.ts";
4
4
  import { grant, verifyGrant } from "./grant.ts";
5
-
6
- const settings = {
7
- address: "https://vault.example",
8
- token: "s.token",
9
- namespace: "apps" as string | null,
10
- mount: "secret",
11
- transit_key: "keja-grants" as string | null,
12
- transit_mount: "transit",
13
- grant_max_exp: 900,
14
- };
5
+ import { setGrants } from "./settings.ts";
15
6
 
16
7
  afterEach(() => {
17
8
  setVault(null);
9
+ setGrants(null);
18
10
  });
19
11
 
20
12
  describe("grant", () => {
21
- it("signs a subject, action, expiry, and context with the transit key", async () => {
22
- const calls: { url: string; body: unknown }[] = [];
23
- await connect(calls, { type: "ecdsa-p256", valid: true });
13
+ it("signs with a secret from grants config", async () => {
14
+ setGrants({
15
+ mode: "secret",
16
+ max_exp: 900,
17
+ secret: "test-secret",
18
+ });
24
19
  const now = 1_700_000_000_000;
25
20
  const clock = Date.now;
26
21
  Date.now = () => now;
@@ -32,9 +27,8 @@ describe("grant", () => {
32
27
  context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
33
28
  });
34
29
  expect(decodeProtectedHeader(token)).toEqual({
35
- alg: "ES256",
30
+ alg: "HS256",
36
31
  typ: "JWT",
37
- kid: "1",
38
32
  });
39
33
  expect(decodeJwt(token)).toMatchObject({
40
34
  sub: "tenant_01h455vb4pex5vsknk084sn02q",
@@ -43,6 +37,69 @@ describe("grant", () => {
43
37
  exp: 1_700_000_060,
44
38
  context: { postId: "posts_01h455vb4pex5vsknk084sn02q" },
45
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
+ });
46
103
  const sign = calls.find((call) => call.url.endsWith("/sign/keja-grants"));
47
104
  expect(sign?.body).toMatchObject({
48
105
  hash_algorithm: "sha2-256",
@@ -59,24 +116,61 @@ describe("grant", () => {
59
116
  await expect(
60
117
  verifyGrant(token, { action: "posts.read" }),
61
118
  ).rejects.toThrow("grant is expired");
62
- Date.now = () => now;
63
- await expect(
64
- verifyGrant(token, { action: "posts.write" }),
65
- ).rejects.toThrow("grant action does not match");
66
119
  } finally {
67
120
  Date.now = clock;
68
121
  }
69
122
  });
70
123
 
71
124
  it("rejects a lifetime past the configured maximum", async () => {
72
- await connect([], { type: "rsa-2048", valid: true });
125
+ setGrants({
126
+ mode: "secret",
127
+ max_exp: 900,
128
+ secret: "test-secret",
129
+ });
73
130
  await expect(
74
131
  grant({ subject: "tenant", action: "posts.read", exp: 901 }),
75
132
  ).rejects.toThrow("grant exp must be at most 900 seconds");
76
133
  });
77
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
+
78
141
  it("rejects a signature Vault does not accept", async () => {
79
- await connect([], { type: "rsa-2048", valid: false });
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
+ });
80
174
  const now = Date.now;
81
175
  Date.now = () => 1_700_000_000_000;
82
176
  try {
@@ -92,39 +186,4 @@ describe("grant", () => {
92
186
  Date.now = now;
93
187
  }
94
188
  });
95
-
96
- it("requires vault and a transit key", async () => {
97
- await expect(
98
- grant({ subject: "tenant", action: "posts.read", exp: 60 }),
99
- ).rejects.toThrow("grant requires vault");
100
- await connect([], { type: "rsa-2048", valid: true });
101
- setVault({ ...settings, transit_key: null });
102
- await expect(
103
- grant({ subject: "tenant", action: "posts.read", exp: 60 }),
104
- ).rejects.toBeInstanceOf(VaultError);
105
- });
106
189
  });
107
-
108
- async function connect(
109
- calls: { url: string; body: unknown }[],
110
- result: { type: string; valid: boolean },
111
- ) {
112
- await connectVault(settings, async (url, init) => {
113
- const body = init?.body ? JSON.parse(String(init.body)) : null;
114
- calls.push({ url, body });
115
- if (url.endsWith("/v1/auth/token/lookup-self")) {
116
- return Response.json({ data: { id: "s.token" } });
117
- }
118
- if (url.endsWith("/keys/keja-grants")) {
119
- return Response.json({ data: { type: result.type, latest_version: 1 } });
120
- }
121
- if (url.endsWith("/sign/keja-grants")) {
122
- return Response.json({
123
- data: {
124
- signature: `vault:v1:${Buffer.from("signed").toString("base64")}`,
125
- },
126
- });
127
- }
128
- return Response.json({ data: { valid: result.valid } });
129
- });
130
- }
@@ -1,9 +1,12 @@
1
- import { decodeJwt, decodeProtectedHeader, type JWTPayload } from "jose";
2
1
  import {
3
- openVault,
4
- requireVaultSettings,
5
- VaultError,
6
- } from "../vault/client.ts";
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";
7
10
 
8
11
  export type Grant = {
9
12
  subject: string;
@@ -36,23 +39,30 @@ const keySpecs: Record<string, KeySpec> = {
36
39
  };
37
40
 
38
41
  export async function grant(input: GrantInput) {
39
- const settings = grantSettings("grant");
42
+ const settings = requireGrants("grant");
40
43
  const subject = requiredText(input.subject, "subject");
41
44
  const action = requiredText(input.action, "action");
42
- const lifetime = lifetimeSeconds(input.exp, settings.grant_max_exp);
45
+ const lifetime = lifetimeSeconds(input.exp, settings.max_exp);
43
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
+ }
44
63
  const vault = openVault("grant");
45
64
  const key = await readKey(vault, settings);
46
- const now = Math.floor(Date.now() / 1000);
47
- const body = base64url(
48
- JSON.stringify({
49
- sub: subject,
50
- action,
51
- iat: now,
52
- exp: now + lifetime,
53
- context,
54
- }),
55
- );
65
+ const body = base64url(JSON.stringify(payload));
56
66
  const header = base64url(
57
67
  JSON.stringify({ alg: key.alg, typ: "JWT", kid: String(key.version) }),
58
68
  );
@@ -70,12 +80,27 @@ export async function grant(input: GrantInput) {
70
80
  }
71
81
 
72
82
  export async function verifyGrant(token: string, options: { action: string }) {
73
- const settings = grantSettings("verifyGrant");
83
+ const settings = requireGrants("verifyGrant");
74
84
  const action = requiredText(options.action, "action");
75
85
  const parts = token.split(".");
76
86
  if (parts.length !== 3 || parts.some((part) => part.length === 0)) {
77
87
  throw new VaultError("grant token is unreadable");
78
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
+ }
79
104
  const header = decodeProtectedHeader(token);
80
105
  const payload = decodeJwt(token);
81
106
  const vault = openVault("verifyGrant");
@@ -101,14 +126,6 @@ export async function verifyGrant(token: string, options: { action: string }) {
101
126
  return claims(payload, action);
102
127
  }
103
128
 
104
- function grantSettings(feature: "grant" | "verifyGrant") {
105
- const settings = requireVaultSettings(feature);
106
- if (!settings.transit_key) {
107
- throw new VaultError("vault transit key is required");
108
- }
109
- return settings as typeof settings & { transit_key: string };
110
- }
111
-
112
129
  async function readKey(
113
130
  vault: ReturnType<typeof openVault>,
114
131
  settings: { transit_mount: string; transit_key: string },
@@ -162,6 +179,10 @@ function vaultSignature(value: unknown) {
162
179
  };
163
180
  }
164
181
 
182
+ function secretKey(secret: string) {
183
+ return new TextEncoder().encode(secret);
184
+ }
185
+
165
186
  function claims(payload: JWTPayload, action: string): Grant {
166
187
  const now = Math.floor(Date.now() / 1000);
167
188
  if (
@@ -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
+ }