@hogsend/engine 0.19.0 → 0.20.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 +7 -7
- package/src/container.ts +10 -3
- package/src/destinations/presets/posthog.ts +132 -30
- package/src/index.ts +26 -0
- package/src/lib/analytics-providers-from-env.ts +48 -5
- package/src/lib/oauth-token-manager.ts +353 -0
- package/src/lib/provider-credentials.ts +250 -0
- package/src/lib/provision-posthog-loop.ts +722 -0
- package/src/lib/seed-posthog-destination.ts +38 -7
- package/src/routes/admin/analytics.ts +261 -0
- package/src/routes/admin/index.ts +4 -0
- package/src/routes/admin/provider-credentials.ts +184 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import type { Database } from "@hogsend/db";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import type { Logger } from "./logger.js";
|
|
4
|
+
import {
|
|
5
|
+
getProviderCredential,
|
|
6
|
+
type OAuthCredentialPayload,
|
|
7
|
+
saveProviderCredential,
|
|
8
|
+
} from "./provider-credentials.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The CIMD document URL — doubles as the OAuth `client_id` (PostHog public
|
|
12
|
+
* client, `token_endpoint_auth_method: "none"`). Sent on every refresh.
|
|
13
|
+
*
|
|
14
|
+
* LOCKSTEP (M5): this URL is deliberately re-typed in THREE places — here,
|
|
15
|
+
* the CLI's `POSTHOG_CLIENT_ID` (`packages/cli/src/lib/oauth.ts`), and the
|
|
16
|
+
* `client_id` field inside the hosted CIMD document
|
|
17
|
+
* (`apps/docs/public/.well-known/hogsend-posthog-client.json`). The CLI has
|
|
18
|
+
* no engine dependency, so there is no single importable source of truth;
|
|
19
|
+
* grep all three before changing any of them.
|
|
20
|
+
*/
|
|
21
|
+
export const HOGSEND_POSTHOG_CLIENT_ID =
|
|
22
|
+
"https://hogsend.com/.well-known/hogsend-posthog-client.json";
|
|
23
|
+
|
|
24
|
+
/** Refresh when fewer than 60s of access-token life remain. */
|
|
25
|
+
export const EXPIRY_SKEW_MS = 60_000;
|
|
26
|
+
/** Negative-cache window for "no credential row" — runtime-connect pickup. */
|
|
27
|
+
export const ABSENT_RECHECK_MS = 30_000;
|
|
28
|
+
/** Minimum gap between failed refresh attempts. */
|
|
29
|
+
export const FAILURE_BACKOFF_MS = 60_000;
|
|
30
|
+
const REFRESH_TIMEOUT_MS = 10_000;
|
|
31
|
+
|
|
32
|
+
// The canonical OAuth credential payload (SYNTHESIS §0) — keep textually
|
|
33
|
+
// identical to the admin route's PUT body schema
|
|
34
|
+
// (`routes/admin/provider-credentials.ts`). Output type matches the store's
|
|
35
|
+
// `OAuthCredentialPayload` interface exactly.
|
|
36
|
+
export const oauthCredentialPayloadSchema = z.object({
|
|
37
|
+
accessToken: z.string().min(1),
|
|
38
|
+
refreshToken: z.string().min(1),
|
|
39
|
+
expiresAt: z.string().datetime({ offset: true }),
|
|
40
|
+
tokenEndpoint: z.string().url(),
|
|
41
|
+
clientId: z.string().url(),
|
|
42
|
+
scopes: z.array(z.string()).default([]),
|
|
43
|
+
scopedTeams: z.array(z.number().int()).default([]),
|
|
44
|
+
scopedOrganizations: z.array(z.string()).default([]),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Test/cross-lane seam over the credential store. */
|
|
48
|
+
export interface CredentialStore {
|
|
49
|
+
/** Decrypted payload JSON of the (providerId, "oauth") row, or null. */
|
|
50
|
+
load(): Promise<Record<string, unknown> | null>;
|
|
51
|
+
save(payload: OAuthCredentialPayload): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export type CredentialState = "unknown" | "present" | "absent";
|
|
55
|
+
|
|
56
|
+
export interface TokenManager {
|
|
57
|
+
/**
|
|
58
|
+
* Resolve a live access token, refreshing if necessary. NEVER throws.
|
|
59
|
+
* Returns null when no usable credential exists (absent, malformed, or
|
|
60
|
+
* refresh failed and the old token is hard-expired) — callers degrade.
|
|
61
|
+
*/
|
|
62
|
+
getAccessToken(): Promise<string | null>;
|
|
63
|
+
/** Synchronous best-known state — drives `capabilities.personReads`. */
|
|
64
|
+
credentialState(): CredentialState;
|
|
65
|
+
/** Load-only warm-up (no refresh). Fire-and-forget at boot. */
|
|
66
|
+
prime(): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Drop the in-memory payload and force a refresh attempt on the next
|
|
69
|
+
* getAccessToken (still subject to failure backoff).
|
|
70
|
+
*/
|
|
71
|
+
invalidate(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface RefreshResponseBody {
|
|
75
|
+
access_token?: unknown;
|
|
76
|
+
refresh_token?: unknown;
|
|
77
|
+
expires_in?: unknown;
|
|
78
|
+
scope?: unknown;
|
|
79
|
+
scoped_teams?: unknown;
|
|
80
|
+
scoped_organizations?: unknown;
|
|
81
|
+
error?: unknown;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultStore(db: Database, providerId: string): CredentialStore {
|
|
85
|
+
return {
|
|
86
|
+
load: async () =>
|
|
87
|
+
((await getProviderCredential(db, providerId, "oauth"))?.payload ??
|
|
88
|
+
null) as Record<string, unknown> | null,
|
|
89
|
+
save: async (payload) => {
|
|
90
|
+
await saveProviderCredential(db, { providerId, payload });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Per-process OAuth access-token manager: in-memory cache + single-flight
|
|
97
|
+
* refresh against the credential payload's stored `tokenEndpoint`, persisting
|
|
98
|
+
* rotations back through the credential store. One instance per process (API
|
|
99
|
+
* and worker each have their own) — the DB row is the shared truth, so the
|
|
100
|
+
* manager ALWAYS re-loads from the store before refreshing (a sibling process
|
|
101
|
+
* may have refreshed first; adopting its result avoids a cross-process
|
|
102
|
+
* stampede against an endpoint whose rotation semantics are undocumented).
|
|
103
|
+
*/
|
|
104
|
+
export function createTokenManager(opts: {
|
|
105
|
+
providerId: string;
|
|
106
|
+
db?: Database;
|
|
107
|
+
store?: CredentialStore;
|
|
108
|
+
logger?: Logger;
|
|
109
|
+
fetchImpl?: typeof fetch;
|
|
110
|
+
now?: () => number;
|
|
111
|
+
}): TokenManager {
|
|
112
|
+
const { providerId, logger } = opts;
|
|
113
|
+
const resolvedStore =
|
|
114
|
+
opts.store ?? (opts.db ? defaultStore(opts.db, providerId) : undefined);
|
|
115
|
+
if (!resolvedStore) {
|
|
116
|
+
throw new Error("createTokenManager requires db or store");
|
|
117
|
+
}
|
|
118
|
+
// Hoisted inner functions can't see the narrowing above — re-bind.
|
|
119
|
+
const store: CredentialStore = resolvedStore;
|
|
120
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
121
|
+
const now = opts.now ?? Date.now;
|
|
122
|
+
|
|
123
|
+
let payload: OAuthCredentialPayload | null = null;
|
|
124
|
+
let absentCheckedAt: number | null = null;
|
|
125
|
+
let lastFailureAt: number | null = null;
|
|
126
|
+
let warnedFailure = false;
|
|
127
|
+
let warnedInvalid = false;
|
|
128
|
+
let forceRefresh = false;
|
|
129
|
+
let inflight: Promise<string | null> | null = null;
|
|
130
|
+
|
|
131
|
+
const expiresAtMs = (p: OAuthCredentialPayload) => Date.parse(p.expiresAt);
|
|
132
|
+
|
|
133
|
+
// Warn-once: first occurrence per failure streak logs at warn (the message
|
|
134
|
+
// carries the reconnect remediation), repeats drop to debug. Latches reset
|
|
135
|
+
// on the next success so a NEW streak warns again.
|
|
136
|
+
const warnOnce = (kind: "failure" | "invalid", message: string) => {
|
|
137
|
+
const latched = kind === "failure" ? warnedFailure : warnedInvalid;
|
|
138
|
+
if (latched) {
|
|
139
|
+
logger?.debug(message);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
logger?.warn(message);
|
|
143
|
+
if (kind === "failure") warnedFailure = true;
|
|
144
|
+
else warnedInvalid = true;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const errMsg = (err: unknown) =>
|
|
148
|
+
err instanceof Error ? err.message : String(err);
|
|
149
|
+
|
|
150
|
+
async function refresh(
|
|
151
|
+
old: OAuthCredentialPayload,
|
|
152
|
+
t: number,
|
|
153
|
+
): Promise<string | null> {
|
|
154
|
+
let detail: string;
|
|
155
|
+
try {
|
|
156
|
+
const response = await fetchImpl(old.tokenEndpoint, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: {
|
|
159
|
+
// No Authorization header — public client; client_id in the body.
|
|
160
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
161
|
+
Accept: "application/json",
|
|
162
|
+
},
|
|
163
|
+
body: new URLSearchParams({
|
|
164
|
+
grant_type: "refresh_token",
|
|
165
|
+
refresh_token: old.refreshToken,
|
|
166
|
+
client_id: old.clientId,
|
|
167
|
+
}).toString(),
|
|
168
|
+
signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (response.ok) {
|
|
172
|
+
const body = (await response.json()) as RefreshResponseBody;
|
|
173
|
+
if (
|
|
174
|
+
typeof body.access_token === "string" &&
|
|
175
|
+
typeof body.expires_in === "number"
|
|
176
|
+
) {
|
|
177
|
+
const next: OAuthCredentialPayload = {
|
|
178
|
+
...old,
|
|
179
|
+
accessToken: body.access_token,
|
|
180
|
+
// ROTATION RULE: PostHog's refresh-token rotation behavior is
|
|
181
|
+
// undocumented — always store a returned refresh token, KEEP the
|
|
182
|
+
// old one when the response omits it.
|
|
183
|
+
refreshToken:
|
|
184
|
+
typeof body.refresh_token === "string"
|
|
185
|
+
? body.refresh_token
|
|
186
|
+
: old.refreshToken,
|
|
187
|
+
expiresAt: new Date(t + body.expires_in * 1000).toISOString(),
|
|
188
|
+
scopes:
|
|
189
|
+
typeof body.scope === "string"
|
|
190
|
+
? body.scope.split(" ")
|
|
191
|
+
: old.scopes,
|
|
192
|
+
scopedTeams: Array.isArray(body.scoped_teams)
|
|
193
|
+
? (body.scoped_teams as number[])
|
|
194
|
+
: old.scopedTeams,
|
|
195
|
+
scopedOrganizations: Array.isArray(body.scoped_organizations)
|
|
196
|
+
? (body.scoped_organizations as string[])
|
|
197
|
+
: old.scopedOrganizations,
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
await store.save(next);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// Persistence hiccup must not kill the send path — adopt the
|
|
203
|
+
// refreshed token in memory; a process restart re-refreshes.
|
|
204
|
+
logger?.warn(
|
|
205
|
+
`${providerId} oauth credential save failed after refresh ` +
|
|
206
|
+
`(token kept in memory): ${errMsg(err)}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
payload = next;
|
|
210
|
+
lastFailureAt = null;
|
|
211
|
+
warnedFailure = false;
|
|
212
|
+
forceRefresh = false;
|
|
213
|
+
return next.accessToken;
|
|
214
|
+
}
|
|
215
|
+
detail = "unparseable token response";
|
|
216
|
+
} else {
|
|
217
|
+
detail = `HTTP ${response.status}`;
|
|
218
|
+
try {
|
|
219
|
+
const errBody = (await response.json()) as RefreshResponseBody;
|
|
220
|
+
if (typeof errBody.error === "string") detail = errBody.error;
|
|
221
|
+
} catch {
|
|
222
|
+
// keep the HTTP status detail
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (err) {
|
|
226
|
+
detail = errMsg(err);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
lastFailureAt = t;
|
|
230
|
+
forceRefresh = false;
|
|
231
|
+
warnOnce(
|
|
232
|
+
"failure",
|
|
233
|
+
`${providerId} oauth token refresh failed (${detail}) — analytics ` +
|
|
234
|
+
"reads degrade (personal key fallback or disabled); run " +
|
|
235
|
+
`\`hogsend connect ${providerId}\` to reconnect`,
|
|
236
|
+
);
|
|
237
|
+
// Inside the skew window the old token is technically still live.
|
|
238
|
+
return expiresAtMs(old) > t ? old.accessToken : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function run(): Promise<string | null> {
|
|
242
|
+
const t = now();
|
|
243
|
+
|
|
244
|
+
// 1. Fresh in-memory token → fast path, no IO.
|
|
245
|
+
if (!forceRefresh && payload && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
|
|
246
|
+
return payload.accessToken;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 2. Known-absent within the negative-cache window → cheap null.
|
|
250
|
+
if (
|
|
251
|
+
!payload &&
|
|
252
|
+
absentCheckedAt !== null &&
|
|
253
|
+
t - absentCheckedAt < ABSENT_RECHECK_MS
|
|
254
|
+
) {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 3. (Re)load from the store. ALWAYS re-load before refreshing — another
|
|
259
|
+
// process (API vs worker) may have already refreshed; adopting its row
|
|
260
|
+
// avoids a cross-process refresh stampede.
|
|
261
|
+
let raw: Record<string, unknown> | null;
|
|
262
|
+
try {
|
|
263
|
+
raw = await store.load();
|
|
264
|
+
} catch (err) {
|
|
265
|
+
// Surfaces ProviderCredentialDecryptError.message verbatim — it
|
|
266
|
+
// carries the reconnect remediation.
|
|
267
|
+
warnOnce(
|
|
268
|
+
"invalid",
|
|
269
|
+
`${providerId} oauth credential load failed: ${errMsg(err)}`,
|
|
270
|
+
);
|
|
271
|
+
payload = null;
|
|
272
|
+
absentCheckedAt = t;
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
if (raw === null) {
|
|
276
|
+
// Absent is NORMAL (provider not connected) — no warn.
|
|
277
|
+
payload = null;
|
|
278
|
+
absentCheckedAt = t;
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
const parsed = oauthCredentialPayloadSchema.safeParse(raw);
|
|
282
|
+
if (!parsed.success) {
|
|
283
|
+
warnOnce(
|
|
284
|
+
"invalid",
|
|
285
|
+
`${providerId} oauth credential payload is malformed — re-run ` +
|
|
286
|
+
`\`hogsend connect ${providerId}\``,
|
|
287
|
+
);
|
|
288
|
+
payload = null;
|
|
289
|
+
absentCheckedAt = t;
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
payload = parsed.data;
|
|
293
|
+
absentCheckedAt = null;
|
|
294
|
+
warnedInvalid = false;
|
|
295
|
+
|
|
296
|
+
// 4. Reloaded token already fresh (e.g. the other process refreshed).
|
|
297
|
+
if (!forceRefresh && expiresAtMs(payload) - EXPIRY_SKEW_MS > t) {
|
|
298
|
+
return payload.accessToken;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 5. Failure backoff: don't hammer the endpoint. Inside the skew window
|
|
302
|
+
// the old token is technically still live — return it.
|
|
303
|
+
if (lastFailureAt !== null && t - lastFailureAt < FAILURE_BACKOFF_MS) {
|
|
304
|
+
return expiresAtMs(payload) > t ? payload.accessToken : null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 6. Refresh.
|
|
308
|
+
return refresh(payload, t);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
getAccessToken() {
|
|
313
|
+
if (inflight) return inflight;
|
|
314
|
+
inflight = run().finally(() => {
|
|
315
|
+
inflight = null;
|
|
316
|
+
});
|
|
317
|
+
return inflight;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
credentialState() {
|
|
321
|
+
return payload
|
|
322
|
+
? "present"
|
|
323
|
+
: absentCheckedAt !== null
|
|
324
|
+
? "absent"
|
|
325
|
+
: "unknown";
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
// Load-only by design: a refreshing prime would make the API and worker
|
|
329
|
+
// race a simultaneous refresh on every deploy. The first real read pays
|
|
330
|
+
// the refresh; step 3's reload-before-refresh heals the residual race.
|
|
331
|
+
async prime() {
|
|
332
|
+
if (payload || absentCheckedAt !== null) return;
|
|
333
|
+
try {
|
|
334
|
+
const raw = await store.load();
|
|
335
|
+
if (raw === null) {
|
|
336
|
+
absentCheckedAt = now();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const parsed = oauthCredentialPayloadSchema.safeParse(raw);
|
|
340
|
+
if (parsed.success) payload = parsed.data;
|
|
341
|
+
else absentCheckedAt = now();
|
|
342
|
+
} catch {
|
|
343
|
+
absentCheckedAt = now();
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
|
|
347
|
+
invalidate() {
|
|
348
|
+
payload = null;
|
|
349
|
+
absentCheckedAt = null;
|
|
350
|
+
forceRefresh = true;
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
randomBytes,
|
|
6
|
+
} from "node:crypto";
|
|
7
|
+
import { type Database, providerCredentials } from "@hogsend/db";
|
|
8
|
+
import { and, eq } from "drizzle-orm";
|
|
9
|
+
import { env } from "../env.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Provider-neutral credential storage: encrypted-at-rest OAuth tokens (and,
|
|
13
|
+
* later, API keys) in the `provider_credentials` table. The crypto is a
|
|
14
|
+
* PRIVATE mirror of `identity-token.ts` (same AES-256-GCM construction, no
|
|
15
|
+
* shared module — identity tokens bake an `exp` into the payload; credentials
|
|
16
|
+
* don't, so the two stay independent).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** Only "oauth" today; widen the union when an "api_key" kind lands. */
|
|
20
|
+
export type CredentialKind = "oauth";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The decrypted shape stored for kind="oauth". Provider-neutral: nothing in
|
|
24
|
+
* here is PostHog-specific. `expiresAt` is ISO-8601 with offset (access-token
|
|
25
|
+
* expiry); `tokenEndpoint` is captured at connect time so refresh never
|
|
26
|
+
* re-runs discovery; `clientId` is the client identifier used at connect so
|
|
27
|
+
* refresh can re-send it; `scopedTeams`/`scopedOrganizations` mirror what an
|
|
28
|
+
* authorization server reports at consent time (PostHog: numeric team ids,
|
|
29
|
+
* UUID organization ids) — empty when the grant is unscoped.
|
|
30
|
+
*/
|
|
31
|
+
export interface OAuthCredentialPayload {
|
|
32
|
+
accessToken: string;
|
|
33
|
+
refreshToken: string;
|
|
34
|
+
expiresAt: string;
|
|
35
|
+
tokenEndpoint: string;
|
|
36
|
+
clientId: string;
|
|
37
|
+
scopes: string[];
|
|
38
|
+
scopedTeams: number[];
|
|
39
|
+
scopedOrganizations: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Row metadata — everything EXCEPT token material. Safe to surface. */
|
|
43
|
+
export interface ProviderCredentialMeta {
|
|
44
|
+
providerId: string;
|
|
45
|
+
kind: CredentialKind;
|
|
46
|
+
scopes: string[];
|
|
47
|
+
expiresAt: Date;
|
|
48
|
+
scopedTeams: number[];
|
|
49
|
+
createdAt: Date;
|
|
50
|
+
updatedAt: Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Full decrypted record for engine-internal callers (token lifecycle). */
|
|
54
|
+
export interface DecryptedProviderCredential {
|
|
55
|
+
providerId: string;
|
|
56
|
+
kind: CredentialKind;
|
|
57
|
+
payload: OAuthCredentialPayload;
|
|
58
|
+
createdAt: Date;
|
|
59
|
+
updatedAt: Date;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Thrown when a stored payload fails to decrypt or parse — in practice this
|
|
64
|
+
* means BETTER_AUTH_SECRET rotated (or the row was tampered with). LOUD by
|
|
65
|
+
* design: callers must not silently fall back, the operator needs to
|
|
66
|
+
* reconnect (`hogsend connect <providerId>`) or DELETE the credential.
|
|
67
|
+
*/
|
|
68
|
+
export class ProviderCredentialDecryptError extends Error {
|
|
69
|
+
constructor(providerId: string) {
|
|
70
|
+
super(
|
|
71
|
+
`Stored credential for "${providerId}" cannot be decrypted — ` +
|
|
72
|
+
`BETTER_AUTH_SECRET may have rotated. Re-connect the provider or ` +
|
|
73
|
+
`delete the credential.`,
|
|
74
|
+
);
|
|
75
|
+
this.name = "ProviderCredentialDecryptError";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- crypto (mirrors lib/identity-token.ts: AES-256-GCM, sha256-derived key,
|
|
80
|
+
// --- base64url(iv || ciphertext || tag)) ----------------------------------
|
|
81
|
+
const IV_LENGTH = 12;
|
|
82
|
+
const TAG_LENGTH = 16;
|
|
83
|
+
|
|
84
|
+
function deriveKey(secret: string): Buffer {
|
|
85
|
+
return createHash("sha256").update(secret).digest();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function encryptPayload(
|
|
89
|
+
payload: OAuthCredentialPayload,
|
|
90
|
+
secret: string,
|
|
91
|
+
): string {
|
|
92
|
+
const iv = randomBytes(IV_LENGTH);
|
|
93
|
+
const cipher = createCipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
94
|
+
const ciphertext = Buffer.concat([
|
|
95
|
+
cipher.update(JSON.stringify(payload), "utf-8"),
|
|
96
|
+
cipher.final(),
|
|
97
|
+
]);
|
|
98
|
+
return Buffer.concat([iv, ciphertext, cipher.getAuthTag()]).toString(
|
|
99
|
+
"base64url",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function decryptPayload(
|
|
104
|
+
blob: string,
|
|
105
|
+
secret: string,
|
|
106
|
+
providerId: string,
|
|
107
|
+
): OAuthCredentialPayload {
|
|
108
|
+
let raw: Buffer;
|
|
109
|
+
try {
|
|
110
|
+
raw = Buffer.from(blob, "base64url");
|
|
111
|
+
} catch {
|
|
112
|
+
throw new ProviderCredentialDecryptError(providerId);
|
|
113
|
+
}
|
|
114
|
+
if (raw.length <= IV_LENGTH + TAG_LENGTH) {
|
|
115
|
+
throw new ProviderCredentialDecryptError(providerId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const iv = raw.subarray(0, IV_LENGTH);
|
|
119
|
+
const ciphertext = raw.subarray(IV_LENGTH, raw.length - TAG_LENGTH);
|
|
120
|
+
const tag = raw.subarray(raw.length - TAG_LENGTH);
|
|
121
|
+
|
|
122
|
+
let payload: OAuthCredentialPayload;
|
|
123
|
+
try {
|
|
124
|
+
const decipher = createDecipheriv("aes-256-gcm", deriveKey(secret), iv);
|
|
125
|
+
decipher.setAuthTag(tag);
|
|
126
|
+
const plaintext = Buffer.concat([
|
|
127
|
+
decipher.update(ciphertext),
|
|
128
|
+
decipher.final(),
|
|
129
|
+
]).toString("utf-8");
|
|
130
|
+
payload = JSON.parse(plaintext);
|
|
131
|
+
} catch {
|
|
132
|
+
throw new ProviderCredentialDecryptError(providerId);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
typeof payload.accessToken !== "string" ||
|
|
137
|
+
typeof payload.tokenEndpoint !== "string"
|
|
138
|
+
) {
|
|
139
|
+
throw new ProviderCredentialDecryptError(providerId);
|
|
140
|
+
}
|
|
141
|
+
return payload;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Meta projection — keeps the tokens-never-surfaced shape in ONE place. */
|
|
145
|
+
export function toCredentialMeta(
|
|
146
|
+
record: DecryptedProviderCredential,
|
|
147
|
+
): ProviderCredentialMeta {
|
|
148
|
+
return {
|
|
149
|
+
providerId: record.providerId,
|
|
150
|
+
kind: record.kind,
|
|
151
|
+
scopes: record.payload.scopes,
|
|
152
|
+
expiresAt: new Date(record.payload.expiresAt),
|
|
153
|
+
scopedTeams: record.payload.scopedTeams,
|
|
154
|
+
createdAt: record.createdAt,
|
|
155
|
+
updatedAt: record.updatedAt,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Read + decrypt one credential. `null` when none stored; throws
|
|
161
|
+
* `ProviderCredentialDecryptError` when a row exists but cannot be decrypted.
|
|
162
|
+
*/
|
|
163
|
+
export async function getProviderCredential(
|
|
164
|
+
db: Database,
|
|
165
|
+
providerId: string,
|
|
166
|
+
kind: CredentialKind = "oauth",
|
|
167
|
+
): Promise<DecryptedProviderCredential | null> {
|
|
168
|
+
const [row] = await db
|
|
169
|
+
.select()
|
|
170
|
+
.from(providerCredentials)
|
|
171
|
+
.where(
|
|
172
|
+
and(
|
|
173
|
+
eq(providerCredentials.providerId, providerId),
|
|
174
|
+
eq(providerCredentials.kind, kind),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
.limit(1);
|
|
178
|
+
|
|
179
|
+
if (!row) return null;
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
providerId: row.providerId,
|
|
183
|
+
kind: row.kind as CredentialKind,
|
|
184
|
+
payload: decryptPayload(row.payload, env.BETTER_AUTH_SECRET, providerId),
|
|
185
|
+
createdAt: row.createdAt,
|
|
186
|
+
updatedAt: row.updatedAt,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Encrypt + UPSERT (full-payload overwrite, deliberately: "keep the old
|
|
192
|
+
* refresh token when the token response omits one" is the CALLER's merge job
|
|
193
|
+
* — the store stays dumb). Returns the safe meta projection.
|
|
194
|
+
*/
|
|
195
|
+
export async function saveProviderCredential(
|
|
196
|
+
db: Database,
|
|
197
|
+
opts: {
|
|
198
|
+
providerId: string;
|
|
199
|
+
kind?: CredentialKind;
|
|
200
|
+
payload: OAuthCredentialPayload;
|
|
201
|
+
},
|
|
202
|
+
): Promise<ProviderCredentialMeta> {
|
|
203
|
+
const kind = opts.kind ?? "oauth";
|
|
204
|
+
const encrypted = encryptPayload(opts.payload, env.BETTER_AUTH_SECRET);
|
|
205
|
+
|
|
206
|
+
const [row] = await db
|
|
207
|
+
.insert(providerCredentials)
|
|
208
|
+
.values({ providerId: opts.providerId, kind, payload: encrypted })
|
|
209
|
+
.onConflictDoUpdate({
|
|
210
|
+
target: [providerCredentials.providerId, providerCredentials.kind],
|
|
211
|
+
set: { payload: encrypted, updatedAt: new Date() },
|
|
212
|
+
})
|
|
213
|
+
.returning();
|
|
214
|
+
|
|
215
|
+
if (!row) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Failed to save provider credential for "${opts.providerId}"`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return toCredentialMeta({
|
|
222
|
+
providerId: row.providerId,
|
|
223
|
+
kind: row.kind as CredentialKind,
|
|
224
|
+
payload: opts.payload,
|
|
225
|
+
createdAt: row.createdAt,
|
|
226
|
+
updatedAt: row.updatedAt,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Hard-delete. `true` iff a row was removed. Never decrypts — this is the
|
|
232
|
+
* operator's escape hatch after a secret rotation.
|
|
233
|
+
*/
|
|
234
|
+
export async function deleteProviderCredential(
|
|
235
|
+
db: Database,
|
|
236
|
+
providerId: string,
|
|
237
|
+
kind: CredentialKind = "oauth",
|
|
238
|
+
): Promise<boolean> {
|
|
239
|
+
const deleted = await db
|
|
240
|
+
.delete(providerCredentials)
|
|
241
|
+
.where(
|
|
242
|
+
and(
|
|
243
|
+
eq(providerCredentials.providerId, providerId),
|
|
244
|
+
eq(providerCredentials.kind, kind),
|
|
245
|
+
),
|
|
246
|
+
)
|
|
247
|
+
.returning({ id: providerCredentials.id });
|
|
248
|
+
|
|
249
|
+
return deleted.length > 0;
|
|
250
|
+
}
|