1import * as Clock from "effect/Clock";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as FetchHttpClient from "effect/http/FetchHttpClient";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientRequest from "effect/http/HttpClientRequest";
7import * as NodeCrypto from "node:crypto";
8import { AuthError } from "../Auth/AuthProvider.ts";
9
10export interface ServiceAccountKey {
11 readonly type?: string;
12 readonly project_id?: string;
13 readonly client_email: string;
14 readonly private_key: string;
15 readonly token_uri?: string;
16}
17
18export interface MintedToken {
19 readonly accessToken: Redacted.Redacted<string>;
20 readonly expirationMs: number;
21 readonly project?: string;
22}
23
24const TOKEN_URL = "https://oauth2.googleapis.com/token";
25const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform";
26
27const signJwt = (sa: ServiceAccountKey, nowMs: number): string => {
28 const now = Math.floor(nowMs / 1000);
29 const encode = (value: unknown) =>
30 Buffer.from(JSON.stringify(value)).toString("base64url");
31 const signingInput = `${encode({ alg: "RS256", typ: "JWT" })}.${encode({
32 iss: sa.client_email,
33 scope: CLOUD_PLATFORM_SCOPE,
34 aud: sa.token_uri ?? TOKEN_URL,
35 iat: now,
36 exp: now + 3600,
37 })}`;
38 const signer = NodeCrypto.createSign("RSA-SHA256");
39 signer.update(signingInput);
40 return `${signingInput}.${signer.sign(sa.private_key, "base64url")}`;
41};
42
43/**
44 * Exchange a service-account JSON key for an OAuth2 access token.
45 * Distilled GCP only accepts a bearer token, so Alchemy mints and refreshes
46 * it here.
47 */
48export const mintAccessToken = (
49 sa: ServiceAccountKey,
50): Effect.Effect<MintedToken, AuthError> =>
51 Effect.gen(function* () {
52 const issuedAt = yield* Clock.currentTimeMillis;
53 const jwt = yield* Effect.try({
54 try: () => signJwt(sa, issuedAt),
55 catch: (cause) =>
56 new AuthError({
57 message: "Failed to sign Google service-account JWT",
58 cause,
59 }),
60 });
61 const http = yield* HttpClient.HttpClient;
62 const tokenResponse = yield* http
63 .execute(
64 HttpClientRequest.post(TOKEN_URL).pipe(
65 HttpClientRequest.bodyUrlParams({
66 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
67 assertion: jwt,
68 }),
69 ),
70 )
71 .pipe(
72 Effect.mapError(
73 (cause) =>
74 new AuthError({
75 message: "Failed to mint Google access token",
76 cause,
77 }),
78 ),
79 );
80 const body = yield* tokenResponse.json.pipe(
81 Effect.mapError(
82 (cause) =>
83 new AuthError({
84 message: "Google token response was not JSON",
85 cause,
86 }),
87 ),
88 );
89 if (
90 typeof body !== "object" ||
91 body === null ||
92 typeof (body as { access_token?: unknown }).access_token !== "string"
93 ) {
94 return yield* new AuthError({
95 message: `Google token endpoint returned no access_token: ${JSON.stringify(body)}`,
96 });
97 }
98 const expiresIn =
99 typeof (body as { expires_in?: unknown }).expires_in === "number"
100 ? (body as { expires_in: number }).expires_in
101 : 3600;
102 return {
103 accessToken: Redacted.make(
104 (body as { access_token: string }).access_token,
105 ),
106 expirationMs: issuedAt + expiresIn * 1000,
107 project: sa.project_id,
108 };
109 }).pipe(Effect.provide(FetchHttpClient.layer));
110
111export const parseServiceAccountKey = (
112 raw: string,
113): Effect.Effect<ServiceAccountKey, AuthError> =>
114 Effect.try({
115 try: () => {
116 const parsed = JSON.parse(raw) as ServiceAccountKey;
117 if (!parsed.client_email || !parsed.private_key) {
118 throw new Error("JSON is missing client_email or private_key");
119 }
120 return parsed;
121 },
122 catch: (cause) =>
123 new AuthError({
124 message: "Invalid Google service-account JSON",
125 cause,
126 }),
127 });