| 1 | import * as Clock from "effect/Clock"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 5 | import * as HttpClient from "effect/http/HttpClient"; |
| 6 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 7 | import * as NodeCrypto from "node:crypto"; |
| 8 | import { AuthError } from "../Auth/AuthProvider.ts"; |
| 9 | |
| 10 | export 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 | |
| 18 | export interface MintedToken { |
| 19 | readonly accessToken: Redacted.Redacted<string>; |
| 20 | readonly expirationMs: number; |
| 21 | readonly project?: string; |
| 22 | } |
| 23 | |
| 24 | const TOKEN_URL = "https://oauth2.googleapis.com/token"; |
| 25 | const CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform"; |
| 26 | |
| 27 | const 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 | */ |
| 48 | export 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 | |
| 111 | export 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 | }); |