@vellumai/vellum-gateway 0.8.9-staging.4 → 0.8.9
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
|
@@ -8,6 +8,8 @@ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
|
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
|
|
11
13
|
import { initSigningKey, mintToken } from "../auth/token-service.js";
|
|
12
14
|
import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
|
|
13
15
|
import type { TokenClaims } from "../auth/types.js";
|
|
@@ -18,7 +20,7 @@ const { initGatewayDb, resetGatewayDb, getGatewayDb } =
|
|
|
18
20
|
await import("../db/connection.js");
|
|
19
21
|
const { actorTokenRecords } = await import("../db/schema.js");
|
|
20
22
|
const { hashToken } = await import("../auth/guardian-bootstrap.js");
|
|
21
|
-
const { isActorTokenRevoked } =
|
|
23
|
+
const { isActorTokenRevoked, actorTokenRecordHash } =
|
|
22
24
|
await import("../auth/actor-token-revocation.js");
|
|
23
25
|
const { createRuntimeProxyHandler } =
|
|
24
26
|
await import("../http/routes/runtime-proxy.js");
|
|
@@ -231,6 +233,47 @@ describe("/auth/token revocation", () => {
|
|
|
231
233
|
|
|
232
234
|
expect(res.status).toBe(401);
|
|
233
235
|
});
|
|
236
|
+
|
|
237
|
+
test("records a derived token so device revocation invalidates it", async () => {
|
|
238
|
+
const { handleCreateToken } = await import("../http/routes/auth-token.js");
|
|
239
|
+
const { revokeActorTokensByDevice } =
|
|
240
|
+
await import("../auth/guardian-bootstrap.js");
|
|
241
|
+
const sourceJwt = mintToken({
|
|
242
|
+
aud: "vellum-gateway",
|
|
243
|
+
sub: ACTOR_SUB,
|
|
244
|
+
scope_profile: "actor_client_v1",
|
|
245
|
+
policy_epoch: CURRENT_POLICY_EPOCH,
|
|
246
|
+
ttlSeconds: 3600,
|
|
247
|
+
});
|
|
248
|
+
insertTokenRecord(sourceJwt, "active");
|
|
249
|
+
|
|
250
|
+
const res = await handleCreateToken(
|
|
251
|
+
new Request("http://127.0.0.1:7830/auth/token", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
authorization: `Bearer ${sourceJwt}`,
|
|
255
|
+
origin: "http://localhost:3000",
|
|
256
|
+
},
|
|
257
|
+
}),
|
|
258
|
+
makeLoopbackServer(),
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
expect(res.status).toBe(200);
|
|
262
|
+
const { token: derivedJwt } = (await res.json()) as { token: string };
|
|
263
|
+
const derivedRecord = getGatewayDb()
|
|
264
|
+
.select({ status: actorTokenRecords.status })
|
|
265
|
+
.from(actorTokenRecords)
|
|
266
|
+
.where(eq(actorTokenRecords.tokenHash, actorTokenRecordHash(derivedJwt)))
|
|
267
|
+
.get();
|
|
268
|
+
|
|
269
|
+
expect(derivedRecord?.status).toBe("derived");
|
|
270
|
+
expect(isActorTokenRevoked(derivedJwt, actorClaims)).toBe(false);
|
|
271
|
+
|
|
272
|
+
revokeActorTokensByDevice("guardian-001", hashToken("device-A"));
|
|
273
|
+
|
|
274
|
+
expect(isActorTokenRevoked(sourceJwt, actorClaims)).toBe(true);
|
|
275
|
+
expect(isActorTokenRevoked(derivedJwt, actorClaims)).toBe(true);
|
|
276
|
+
});
|
|
234
277
|
});
|
|
235
278
|
|
|
236
279
|
describe("m0004 token-hash index migration", () => {
|
|
@@ -73,6 +73,17 @@ function canonicalizeTokenForHash(rawToken: string): string {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* Hash a caller-supplied actor token exactly as revocation lookups do.
|
|
78
|
+
*
|
|
79
|
+
* Use this for DB writes/reads that need to line up with
|
|
80
|
+
* `isActorTokenRevoked`, including token-mint paths that persist derived
|
|
81
|
+
* actor tokens for later device revocation.
|
|
82
|
+
*/
|
|
83
|
+
export function actorTokenRecordHash(rawToken: string): string {
|
|
84
|
+
return hashToken(canonicalizeTokenForHash(rawToken));
|
|
85
|
+
}
|
|
86
|
+
|
|
76
87
|
/**
|
|
77
88
|
* True only when `rawToken` is an actor token with an explicitly revoked record.
|
|
78
89
|
* Fail-open in every other case (non-actor, no record, DB error).
|
|
@@ -86,7 +97,7 @@ export function isActorTokenRevoked(
|
|
|
86
97
|
return false;
|
|
87
98
|
}
|
|
88
99
|
|
|
89
|
-
const tokenHash =
|
|
100
|
+
const tokenHash = actorTokenRecordHash(rawToken);
|
|
90
101
|
|
|
91
102
|
try {
|
|
92
103
|
const record = getGatewayDb()
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { createHash, randomBytes } from "node:crypto";
|
|
10
10
|
|
|
11
|
-
import { and, eq } from "drizzle-orm";
|
|
11
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
12
12
|
|
|
13
13
|
import { getGatewayDb } from "../db/connection.js";
|
|
14
14
|
import {
|
|
@@ -427,7 +427,7 @@ export function revokeActorTokensByDevice(
|
|
|
427
427
|
and(
|
|
428
428
|
eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
|
|
429
429
|
eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
|
|
430
|
-
|
|
430
|
+
inArray(actorTokenRecords.status, ["active", "derived"]),
|
|
431
431
|
),
|
|
432
432
|
)
|
|
433
433
|
.run();
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { randomBytes } from "node:crypto";
|
|
7
7
|
|
|
8
|
-
import { and, eq } from "drizzle-orm";
|
|
8
|
+
import { and, eq, inArray } from "drizzle-orm";
|
|
9
9
|
|
|
10
10
|
import { getGatewayDb } from "../db/connection.js";
|
|
11
11
|
import { actorRefreshTokenRecords, actorTokenRecords } from "../db/schema.js";
|
|
@@ -81,7 +81,7 @@ function revokeFamily(familyId: string): void {
|
|
|
81
81
|
.run();
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
function
|
|
84
|
+
function revokeActiveActorTokensByDevice(
|
|
85
85
|
guardianPrincipalId: string,
|
|
86
86
|
hashedDeviceId: string,
|
|
87
87
|
): void {
|
|
@@ -99,6 +99,24 @@ function revokeActorTokensByDevice(
|
|
|
99
99
|
.run();
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
function revokeAllActorTokensByDevice(
|
|
103
|
+
guardianPrincipalId: string,
|
|
104
|
+
hashedDeviceId: string,
|
|
105
|
+
): void {
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
getGatewayDb()
|
|
108
|
+
.update(actorTokenRecords)
|
|
109
|
+
.set({ status: "revoked", updatedAt: now })
|
|
110
|
+
.where(
|
|
111
|
+
and(
|
|
112
|
+
eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
|
|
113
|
+
eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
|
|
114
|
+
inArray(actorTokenRecords.status, ["active", "derived"]),
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
.run();
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
// ---------------------------------------------------------------------------
|
|
103
121
|
// Token minting (gateway DB)
|
|
104
122
|
// ---------------------------------------------------------------------------
|
|
@@ -232,7 +250,7 @@ export function rotateCredentials(params: {
|
|
|
232
250
|
"Refresh token reuse detected — revoking entire family",
|
|
233
251
|
);
|
|
234
252
|
revokeFamily(record.familyId);
|
|
235
|
-
|
|
253
|
+
revokeAllActorTokensByDevice(
|
|
236
254
|
record.guardianPrincipalId,
|
|
237
255
|
record.hashedDeviceId,
|
|
238
256
|
);
|
|
@@ -261,7 +279,7 @@ export function rotateCredentials(params: {
|
|
|
261
279
|
return { ok: false as const, error: "refresh_reuse_detected" as const };
|
|
262
280
|
}
|
|
263
281
|
|
|
264
|
-
|
|
282
|
+
revokeActiveActorTokensByDevice(
|
|
265
283
|
record.guardianPrincipalId,
|
|
266
284
|
record.hashedDeviceId,
|
|
267
285
|
);
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
actorTokenRecordHash,
|
|
7
|
+
isActorTokenRevoked,
|
|
8
|
+
} from "../../auth/actor-token-revocation.js";
|
|
4
9
|
import { ensureVellumGuardianBinding } from "../../auth/guardian-bootstrap.js";
|
|
5
10
|
import { CURRENT_POLICY_EPOCH } from "../../auth/policy.js";
|
|
6
11
|
import { mintToken, verifyToken } from "../../auth/token-service.js";
|
|
12
|
+
import { getGatewayDb } from "../../db/connection.js";
|
|
13
|
+
import { actorTokenRecords } from "../../db/schema.js";
|
|
7
14
|
import { getLogger } from "../../logger.js";
|
|
8
15
|
import { isLoopbackPeer } from "../../util/is-loopback-address.js";
|
|
9
16
|
|
|
@@ -13,6 +20,69 @@ const WEB_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
|
|
13
20
|
|
|
14
21
|
const TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
15
22
|
|
|
23
|
+
interface SourceActorTokenRecord {
|
|
24
|
+
guardianPrincipalId: string;
|
|
25
|
+
hashedDeviceId: string;
|
|
26
|
+
platform: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findSourceActorTokenRecord(
|
|
30
|
+
sourceToken: string,
|
|
31
|
+
): SourceActorTokenRecord | null {
|
|
32
|
+
try {
|
|
33
|
+
return (
|
|
34
|
+
getGatewayDb()
|
|
35
|
+
.select({
|
|
36
|
+
guardianPrincipalId: actorTokenRecords.guardianPrincipalId,
|
|
37
|
+
hashedDeviceId: actorTokenRecords.hashedDeviceId,
|
|
38
|
+
platform: actorTokenRecords.platform,
|
|
39
|
+
})
|
|
40
|
+
.from(actorTokenRecords)
|
|
41
|
+
.where(
|
|
42
|
+
eq(actorTokenRecords.tokenHash, actorTokenRecordHash(sourceToken)),
|
|
43
|
+
)
|
|
44
|
+
.get() ?? null
|
|
45
|
+
);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
log.warn(
|
|
48
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
49
|
+
"Source actor-token lookup failed — minting unrecorded compatibility token",
|
|
50
|
+
);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function recordDerivedActorToken(
|
|
56
|
+
sourceRecord: SourceActorTokenRecord | null,
|
|
57
|
+
derivedToken: string,
|
|
58
|
+
): void {
|
|
59
|
+
if (!sourceRecord) return;
|
|
60
|
+
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
try {
|
|
63
|
+
getGatewayDb()
|
|
64
|
+
.insert(actorTokenRecords)
|
|
65
|
+
.values({
|
|
66
|
+
id: crypto.randomUUID(),
|
|
67
|
+
tokenHash: actorTokenRecordHash(derivedToken),
|
|
68
|
+
guardianPrincipalId: sourceRecord.guardianPrincipalId,
|
|
69
|
+
hashedDeviceId: sourceRecord.hashedDeviceId,
|
|
70
|
+
platform: sourceRecord.platform,
|
|
71
|
+
status: "derived",
|
|
72
|
+
issuedAt: now,
|
|
73
|
+
expiresAt: now + TOKEN_TTL_SECONDS * 1000,
|
|
74
|
+
createdAt: now,
|
|
75
|
+
updatedAt: now,
|
|
76
|
+
})
|
|
77
|
+
.run();
|
|
78
|
+
} catch (err) {
|
|
79
|
+
log.warn(
|
|
80
|
+
{ err: err instanceof Error ? err.message : String(err) },
|
|
81
|
+
"Derived actor-token record insert failed — minted token remains compatible but unrecorded",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
16
86
|
export async function handleCreateToken(
|
|
17
87
|
req: Request,
|
|
18
88
|
server: Server<unknown> | undefined,
|
|
@@ -59,7 +129,9 @@ export async function handleCreateToken(
|
|
|
59
129
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
60
130
|
}
|
|
61
131
|
|
|
62
|
-
const
|
|
132
|
+
const sourceRecord = findSourceActorTokenRecord(bearerToken);
|
|
133
|
+
const guardianPrincipalId =
|
|
134
|
+
sourceRecord?.guardianPrincipalId ?? (await ensureVellumGuardianBinding());
|
|
63
135
|
|
|
64
136
|
const token = mintToken({
|
|
65
137
|
aud: "vellum-gateway",
|
|
@@ -69,6 +141,8 @@ export async function handleCreateToken(
|
|
|
69
141
|
ttlSeconds: TOKEN_TTL_SECONDS,
|
|
70
142
|
});
|
|
71
143
|
|
|
144
|
+
recordDerivedActorToken(sourceRecord, token);
|
|
145
|
+
|
|
72
146
|
const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
|
|
73
147
|
log.info("Bearer token minted for web local mode");
|
|
74
148
|
|