-
Notifications
You must be signed in to change notification settings - Fork 148
Expand file tree
/
Copy pathgg-token.ts
More file actions
197 lines (181 loc) · 6.72 KB
/
Copy pathgg-token.ts
File metadata and controls
197 lines (181 loc) · 6.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
// GRIDA-SEC-006 — see /SECURITY.md
// GRIDA-GG: token — see docs/wg/platform/hosted-ai.md
/**
* Scoped AI-access token — mint + verify.
*
* The desktop app's hosted-AI calls are authenticated by a short-lived,
* purpose-scoped JWT (aud `gg:ai`, 15-minute expiry, org bound at mint
* time) — never by a Supabase access token and never by cookies. This
* module is the single mint/verify point: the same-origin
* `/desktop/auth/token` route signs, the `/api/v1/ai/*` handlers verify.
* A future non-desktop client (CLI) would mint the same audience, which
* is why this lives in `@/lib/auth`, not `@/lib/desktop`.
*
* Secret: `GG_TOKEN_SECRET` (server-only, >= 32 bytes). Rotation:
* verification also accepts `GG_TOKEN_SECRET_PREVIOUS`; signing
* always uses the current secret. Rotate by moving current -> previous,
* setting a new current, and dropping previous after > 15 minutes.
* Fail-closed: when unset (or too short) sign/verify throw
* `not_configured` and callers respond 503 — hosted AI is unavailable,
* nothing falls back to a weaker credential.
*/
import "server-only";
import { SignJWT, jwtVerify, errors as joseErrors } from "jose";
export const GG_TOKEN_AUDIENCE = "gg:ai";
export const GG_TOKEN_TTL_SECONDS = 900;
/** HS256 needs a key at least as long as the hash output. */
const MIN_SECRET_BYTES = 32;
/** Absorbs daemon/server clock skew without materially extending the window. */
const CLOCK_TOLERANCE_SECONDS = 60;
export type GgTokenClaims = {
/** User uuid. */
sub: string;
/** Organization id — membership was verified at mint time. */
org: number;
aud: typeof GG_TOKEN_AUDIENCE;
iat: number;
exp: number;
};
export class GgTokenError extends Error {
readonly code: "token_expired" | "invalid_token" | "not_configured";
constructor(
code: "token_expired" | "invalid_token" | "not_configured",
message?: string
) {
super(message ?? code);
this.name = "GgTokenError";
this.code = code;
}
}
function secretFromEnv(name: string): Uint8Array | null {
const raw = process.env[name]?.trim();
if (!raw) return null;
const bytes = new TextEncoder().encode(raw);
if (bytes.byteLength < MIN_SECRET_BYTES) return null;
return bytes;
}
/** Read per call (not cached): per-request cost is nil and tests stay honest. */
function currentSecret(): Uint8Array {
const secret = secretFromEnv("GG_TOKEN_SECRET");
if (!secret) {
throw new GgTokenError(
"not_configured",
"GG_TOKEN_SECRET is unset or shorter than 32 bytes"
);
}
return secret;
}
export async function signGgToken(
sub: string,
org: number
): Promise<{ token: string; expiresAt: Date }> {
if (typeof sub !== "string" || sub.length === 0) {
throw new GgTokenError("invalid_token", "signGgToken: missing sub");
}
if (!Number.isInteger(org) || org <= 0) {
throw new GgTokenError("invalid_token", "signGgToken: invalid org id");
}
const secret = currentSecret();
const iat = Math.floor(Date.now() / 1000);
const exp = iat + GG_TOKEN_TTL_SECONDS;
const token = await new SignJWT({ org })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(sub)
.setAudience(GG_TOKEN_AUDIENCE)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(secret);
return { token, expiresAt: new Date(exp * 1000) };
}
/**
* Verifies the `Authorization: Bearer <token>` header of a hosted-AI
* request. Only this token kind is ever accepted — a Supabase access
* token structurally fails (different issuer/keys/audience), which is
* the point: a leaked AI token buys <= 15 minutes of AI calls, nothing
* else, and nothing else can be presented here.
*/
export async function verifyGgToken(request: Request): Promise<GgTokenClaims> {
const header = request.headers.get("authorization") ?? "";
const match = /^Bearer\s+(.+)$/i.exec(header);
if (!match) {
throw new GgTokenError("invalid_token", "missing bearer token");
}
const token = match[1]!;
const payload = await verifyWithRotation(token);
if (typeof payload.sub !== "string" || payload.sub.length === 0) {
throw new GgTokenError("invalid_token", "missing sub claim");
}
const org = payload.org;
if (typeof org !== "number" || !Number.isInteger(org) || org <= 0) {
throw new GgTokenError("invalid_token", "invalid org claim");
}
return {
sub: payload.sub,
org,
aud: GG_TOKEN_AUDIENCE,
iat: payload.iat as number,
exp: payload.exp as number,
};
}
async function verifyWithRotation(token: string) {
const options = {
audience: GG_TOKEN_AUDIENCE,
algorithms: ["HS256"],
clockTolerance: CLOCK_TOLERANCE_SECONDS,
};
const current = currentSecret();
try {
return (await jwtVerify(token, current, options)).payload;
} catch (err) {
if (err instanceof joseErrors.JWTExpired) {
throw new GgTokenError("token_expired");
}
// Signature mismatch may mean the token was signed with the
// previous secret mid-rotation — try it before rejecting.
const previous = secretFromEnv("GG_TOKEN_SECRET_PREVIOUS");
if (previous && err instanceof joseErrors.JWSSignatureVerificationFailed) {
try {
return (await jwtVerify(token, previous, options)).payload;
} catch (prevErr) {
if (prevErr instanceof joseErrors.JWTExpired) {
throw new GgTokenError("token_expired");
}
throw new GgTokenError("invalid_token");
}
}
throw new GgTokenError("invalid_token");
}
}
// ---------------------------------------------------------------------------
// Mint rate limit — abuse damping on the token mint itself. The billing
// gate on the AI endpoints is the real spend control; this only bounds
// mint-endpoint hammering. Fail-open when Upstash is unconfigured
// (local dev), same posture as the library search limiter.
// ---------------------------------------------------------------------------
type MintLimiter = { limit(key: string): Promise<{ success: boolean }> };
let _mintLimiter: MintLimiter | null | undefined;
async function mintLimiter(): Promise<MintLimiter | null> {
if (_mintLimiter !== undefined) return _mintLimiter;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) {
_mintLimiter = null;
return _mintLimiter;
}
const [{ Ratelimit }, { Redis }] = await Promise.all([
import("@upstash/ratelimit"),
import("@upstash/redis"),
]);
_mintLimiter = new Ratelimit({
redis: new Redis({ url, token }),
limiter: Ratelimit.slidingWindow(10, "60 s"),
prefix: "rl:v1-ai:mint",
});
return _mintLimiter;
}
export async function allowGgTokenMint(userId: string): Promise<boolean> {
const limiter = await mintLimiter();
if (!limiter) return true;
const { success } = await limiter.limit(userId);
return success;
}