@veducio/keja 0.3.0 → 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 +1 -1
- package/src/grant/grant.test.ts +116 -57
- package/src/grant/grant.ts +47 -26
- package/src/grant/settings.ts +67 -0
- package/src/http/batch.ts +307 -0
- package/src/http/create-app.test.ts +141 -0
- package/src/http/create-app.ts +25 -0
- package/src/http/openapi.ts +94 -0
- package/src/project/config.test.ts +39 -3
- package/src/project/config.ts +163 -16
- package/src/project/load.ts +6 -0
- package/src/route/define.ts +14 -0
- package/src/server/index.ts +19 -1
- package/src/vault/client.test.ts +0 -6
package/package.json
CHANGED
package/src/grant/grant.test.ts
CHANGED
|
@@ -1,26 +1,21 @@
|
|
|
1
1
|
import { decodeJwt, decodeProtectedHeader } from "jose";
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
-
import { connectVault, setVault
|
|
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
|
|
22
|
-
|
|
23
|
-
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/grant/grant.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { decodeJwt, decodeProtectedHeader, type JWTPayload } from "jose";
|
|
2
1
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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 =
|
|
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.
|
|
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
|
|
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 =
|
|
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
|
+
}
|