1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Match from "effect/Match";
4import * as Redacted from "effect/Redacted";
5import * as Ref from "effect/Ref";
6import * as Schema from "effect/Schema";
7import {
8 AuthError,
9 AuthProviderLayer,
10 NeedsReauth,
11 refreshHint,
12 type ConfigureField,
13 type ConfigureMethod,
14 type ProviderDetails,
15} from "../Auth/AuthProvider.ts";
16import { CredentialsStore, displayRedacted } from "../Auth/Credentials.ts";
17import { getEnv, getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts";
18import * as Interaction from "../Interaction.ts";
19import {
20 mintAccessToken,
21 parseServiceAccountKey,
22 type ServiceAccountKey,
23} from "./Token.ts";
24
25export const GCP_AUTH_PROVIDER_NAME = "GCP";
26export const GOOGLE_ACCESS_TOKEN_ENV = "GOOGLE_ACCESS_TOKEN";
27export const GOOGLE_PROJECT_ID_ENV = "GOOGLE_PROJECT_ID";
28export const GOOGLE_CLOUD_PROJECT_ENV = "GOOGLE_CLOUD_PROJECT";
29export const GOOGLE_APPLICATION_CREDENTIALS_ENV =
30 "GOOGLE_APPLICATION_CREDENTIALS";
31/** Region when neither the environment nor the profile names one. */
32export const DEFAULT_GCP_REGION = "us-central1";
33
34/**
35 * Default region for regional resources created without an explicit
36 * location. Like the project, it is part of the profile.
37 */
38const region = Schema.optionalKey(Schema.String);
39
40export const GcpAuthConfigSchema = Schema.Union([
41 Schema.Struct({ method: Schema.Literal("env"), region }),
42 Schema.Struct({
43 method: Schema.Literal("serviceAccount"),
44 credentialsFile: Schema.optionalKey(Schema.String),
45 region,
46 }),
47 Schema.Struct({ method: Schema.Literal("stored"), region }),
48]);
49export type GcpAuthConfig = typeof GcpAuthConfigSchema.Type;
50
51export const GcpStoredCredentialsSchema = Schema.Union([
52 Schema.Struct({
53 type: Schema.Literal("token"),
54 accessToken: Schema.String,
55 project: Schema.String,
56 }),
57 Schema.Struct({
58 type: Schema.Literal("serviceAccount"),
59 json: Schema.String,
60 project: Schema.optionalKey(Schema.String),
61 }),
62]);
63export type GcpStoredCredentials = typeof GcpStoredCredentialsSchema.Type;
64
65export type GcpResolvedCredentials = {
66 type: "token";
67 accessToken: Redacted.Redacted<string>;
68 project: string;
69 /** Default region: env override, else the profile, else `us-central1`. */
70 region: string;
71 source: { type: GcpAuthConfig["method"] | "env"; details?: string };
72};
73
74const options: Array<{
75 value: GcpAuthConfig["method"];
76 label: string;
77 description?: string;
78}> = [
79 {
80 value: "env",
81 label: "Environment Variables",
82 description: `${GOOGLE_ACCESS_TOKEN_ENV} or ${GOOGLE_APPLICATION_CREDENTIALS_ENV} + ${GOOGLE_PROJECT_ID_ENV}`,
83 },
84 {
85 value: "serviceAccount",
86 label: "Service account JSON",
87 description: "path to a service-account key file",
88 },
89 {
90 value: "stored",
91 label: "Stored",
92 description: "token or key stored in ~/.alchemy/credentials",
93 },
94];
95
96const REFRESH_WINDOW_MS = 5 * 60 * 1000;
97
98export const GcpAuth = AuthProviderLayer<
99 GcpAuthConfig,
100 GcpResolvedCredentials
101>()(
102 GCP_AUTH_PROVIDER_NAME,
103 Effect.gen(function* () {
104 const store = yield* CredentialsStore;
105 const fs = yield* FileSystem.FileSystem;
106 const interaction = Interaction.accessors;
107 const tokenCache = yield* Ref.make<
108 { accessToken: string; expirationMs: number; project: string } | undefined
109 >(undefined);
110
111 const readKeyFile = (
112 path: string,
113 ): Effect.Effect<ServiceAccountKey, AuthError> =>
114 fs.readFileString(path).pipe(
115 Effect.flatMap(parseServiceAccountKey),
116 Effect.mapError(
117 (e) =>
118 new AuthError({
119 message: `Failed to read service-account key at ${path}`,
120 cause: e,
121 }),
122 ),
123 );
124
125 const mintCached = (
126 sa: ServiceAccountKey,
127 project: string,
128 ): Effect.Effect<Omit<GcpResolvedCredentials, "region">, AuthError> =>
129 Effect.gen(function* () {
130 const now = yield* Effect.sync(() => Date.now());
131 const cached = yield* Ref.get(tokenCache);
132 if (
133 cached &&
134 cached.project === project &&
135 cached.expirationMs - now > REFRESH_WINDOW_MS
136 ) {
137 return {
138 type: "token" as const,
139 accessToken: Redacted.make(cached.accessToken),
140 project,
141 source: {
142 type: "serviceAccount" as const,
143 details: sa.client_email,
144 },
145 };
146 }
147 const minted = yield* mintAccessToken(sa);
148 yield* Ref.set(tokenCache, {
149 accessToken: Redacted.value(minted.accessToken),
150 expirationMs: minted.expirationMs,
151 project,
152 });
153 return {
154 type: "token" as const,
155 accessToken: minted.accessToken,
156 project,
157 source: {
158 type: "serviceAccount" as const,
159 details: sa.client_email,
160 },
161 };
162 });
163
164 const resolveFromServiceAccount = (
165 sa: ServiceAccountKey,
166 explicitProject?: string,
167 ): Effect.Effect<Omit<GcpResolvedCredentials, "region">, AuthError> => {
168 const project = explicitProject ?? sa.project_id;
169 if (!project) {
170 return Effect.fail(
171 new AuthError({
172 message: `Set ${GOOGLE_PROJECT_ID_ENV} (service-account JSON has no project_id)`,
173 }),
174 );
175 }
176 return mintCached(sa, project);
177 };
178
179 const loginStored = Effect.fn(function* (profileName: string) {
180 const kind = yield* interaction.prompt
181 .select({
182 message: "GCP stored credential type",
183 options: [
184 {
185 value: "token" as const,
186 label: "Access token",
187 description:
188 "paste a bearer token from gcloud auth print-access-token",
189 },
190 {
191 value: "serviceAccount" as const,
192 label: "Service account JSON",
193 description: "paste the key file contents",
194 },
195 ],
196 })
197 .pipe(mapPromptCancellation);
198
199 const project = yield* interaction.prompt
200 .text({
201 message: "GCP project id",
202 validate: (v) => (v.length === 0 ? "Required" : undefined),
203 })
204 .pipe(mapPromptCancellation);
205
206 if (kind === "token") {
207 const accessToken = yield* interaction.prompt
208 .password({
209 message: "Google access token",
210 validate: (v) => (v.length === 0 ? "Required" : undefined),
211 })
212 .pipe(mapPromptCancellation);
213 yield* store.write(
214 profileName,
215 GCP_AUTH_PROVIDER_NAME,
216 GcpStoredCredentialsSchema,
217 {
218 type: "token",
219 accessToken,
220 project,
221 },
222 );
223 } else {
224 const json = yield* interaction.prompt
225 .password({
226 message: "Service account JSON",
227 validate: (v) => (v.length === 0 ? "Required" : undefined),
228 })
229 .pipe(mapPromptCancellation);
230 yield* parseServiceAccountKey(json);
231 yield* store.write(
232 profileName,
233 GCP_AUTH_PROVIDER_NAME,
234 GcpStoredCredentialsSchema,
235 {
236 type: "serviceAccount",
237 json,
238 project,
239 },
240 );
241 }
242 yield* interaction.output.success("GCP: credentials saved.");
243 return { method: "stored" as const };
244 });
245
246 const configureInteractive = (profileName: string) =>
247 interaction.prompt
248 .select({
249 message: "GCP authentication method",
250 options,
251 })
252 .pipe(
253 mapPromptCancellation,
254 Effect.flatMap((method) =>
255 Match.value(method).pipe(
256 Match.when("env", () =>
257 Effect.succeed({ method: "env" as const }),
258 ),
259 Match.when("serviceAccount", () =>
260 interaction.prompt
261 .text({
262 message: "Path to service-account JSON (Enter for ADC env)",
263 placeholder: `$${GOOGLE_APPLICATION_CREDENTIALS_ENV}`,
264 })
265 .pipe(
266 mapPromptCancellation,
267 Effect.map((path) => {
268 const trimmed = (path ?? "").trim();
269 return {
270 method: "serviceAccount" as const,
271 ...(trimmed.length > 0
272 ? { credentialsFile: trimmed }
273 : {}),
274 };
275 }),
276 ),
277 ),
278 Match.when("stored", () => loginStored(profileName)),
279 Match.exhaustive,
280 ),
281 ),
282 );
283
284 const promptRegion = interaction.prompt
285 .text({
286 message: "Default GCP region",
287 placeholder: DEFAULT_GCP_REGION,
288 })
289 .pipe(
290 mapPromptCancellation,
291 Effect.map((value) => (value ?? "").trim() || DEFAULT_GCP_REGION),
292 );
293
294 const configureCredentials = (_profileName: string) =>
295 configureInteractive(_profileName).pipe(
296 Effect.flatMap((config) =>
297 promptRegion.pipe(Effect.map((region) => ({ ...config, region }))),
298 ),
299 Effect.mapError(
300 (e) =>
301 new AuthError({
302 message: "failed to configure credentials",
303 cause: e,
304 }),
305 ),
306 );
307
308 const regionField: ConfigureField = {
309 name: "region",
310 label: "Default GCP region",
311 placeholder: DEFAULT_GCP_REGION,
312 optional: true,
313 };
314
315 const serviceAccountFields: ReadonlyArray<ConfigureField> = [
316 {
317 name: "credentialsFile",
318 label: "Path to service-account JSON",
319 optional: true,
320 },
321 regionField,
322 ];
323
324 const configureMethods: ReadonlyArray<ConfigureMethod> = [
325 { method: "env", fields: [regionField] },
326 { method: "serviceAccount", fields: serviceAccountFields },
327 ];
328
329 const regionOf = (values: Record<string, string>) => {
330 const value = (values.region ?? "").trim();
331 return value.length > 0 ? { region: value } : {};
332 };
333
334 const configureWith = (
335 _profileName: string,
336 input: {
337 readonly method: string;
338 readonly values: Record<string, string>;
339 },
340 ): Effect.Effect<GcpAuthConfig, AuthError> => {
341 if (input.method === "env") {
342 return Effect.succeed({
343 method: "env" as const,
344 ...regionOf(input.values),
345 });
346 }
347 if (input.method === "serviceAccount") {
348 const trimmed = (input.values.credentialsFile ?? "").trim();
349 return Effect.succeed({
350 method: "serviceAccount" as const,
351 ...(trimmed.length > 0 ? { credentialsFile: trimmed } : {}),
352 ...regionOf(input.values),
353 });
354 }
355 return Effect.fail(
356 new AuthError({
357 message: `GCP: unknown method '${input.method}'. Valid methods: env, serviceAccount. (stored is interactive-only.)`,
358 }),
359 );
360 };
361
362 const resolveFromEnv = (): Effect.Effect<
363 Omit<GcpResolvedCredentials, "region">,
364 AuthError
365 > =>
366 Effect.gen(function* () {
367 const project =
368 (yield* getEnv(GOOGLE_PROJECT_ID_ENV)) ??
369 (yield* getEnv(GOOGLE_CLOUD_PROJECT_ENV));
370 const token = yield* getEnvRedacted(GOOGLE_ACCESS_TOKEN_ENV);
371 if (token) {
372 if (!project) {
373 return yield* new AuthError({
374 message: `GCP env credentials missing ${GOOGLE_PROJECT_ID_ENV}`,
375 });
376 }
377 return {
378 type: "token" as const,
379 accessToken: token,
380 project,
381 source: { type: "env" as const, details: GOOGLE_ACCESS_TOKEN_ENV },
382 };
383 }
384 const keyPath = yield* getEnv(GOOGLE_APPLICATION_CREDENTIALS_ENV);
385 if (!keyPath) {
386 return yield* new AuthError({
387 message: `GCP env credentials not found. Set ${GOOGLE_ACCESS_TOKEN_ENV}+${GOOGLE_PROJECT_ID_ENV} or ${GOOGLE_APPLICATION_CREDENTIALS_ENV}.`,
388 });
389 }
390 const sa = yield* readKeyFile(keyPath);
391 return yield* resolveFromServiceAccount(sa, project);
392 });
393
394 const resolveFromServiceAccountFile = (
395 credentialsFile: string | undefined,
396 ): Effect.Effect<Omit<GcpResolvedCredentials, "region">, AuthError> =>
397 Effect.gen(function* () {
398 const fromEnv = yield* getEnv(GOOGLE_APPLICATION_CREDENTIALS_ENV);
399 const path = credentialsFile ?? fromEnv;
400 if (!path) {
401 return yield* new AuthError({
402 message: `GCP service-account key not found. Set ${GOOGLE_APPLICATION_CREDENTIALS_ENV}.`,
403 });
404 }
405 const sa = yield* readKeyFile(path);
406 const project =
407 (yield* getEnv(GOOGLE_PROJECT_ID_ENV)) ??
408 (yield* getEnv(GOOGLE_CLOUD_PROJECT_ENV));
409 return yield* resolveFromServiceAccount(sa, project);
410 });
411
412 const resolveFromStored = (
413 profileName: string,
414 ): Effect.Effect<
415 Omit<GcpResolvedCredentials, "region">,
416 AuthError | NeedsReauth
417 > =>
418 Effect.gen(function* () {
419 const creds = yield* store.read(
420 profileName,
421 GCP_AUTH_PROVIDER_NAME,
422 GcpStoredCredentialsSchema,
423 );
424 if (creds == null) {
425 return yield* new NeedsReauth({
426 provider: GCP_AUTH_PROVIDER_NAME,
427 profile: profileName,
428 message: `GCP stored credentials not found. ${refreshHint(GCP_AUTH_PROVIDER_NAME, profileName)}`,
429 });
430 }
431 if (creds.type === "token") {
432 return {
433 type: "token" as const,
434 accessToken: Redacted.make(creds.accessToken),
435 project: creds.project,
436 source: { type: "stored" as const },
437 };
438 }
439 const sa = yield* parseServiceAccountKey(creds.json);
440 return yield* resolveFromServiceAccount(sa, creds.project);
441 });
442
443 const resolveToken = (
444 profileName: string,
445 config: GcpAuthConfig,
446 ): Effect.Effect<
447 Omit<GcpResolvedCredentials, "region">,
448 AuthError | NeedsReauth
449 > => {
450 switch (config.method) {
451 case "env":
452 return resolveFromEnv();
453 case "serviceAccount":
454 return resolveFromServiceAccountFile(config.credentialsFile);
455 case "stored":
456 return resolveFromStored(profileName);
457 }
458 };
459
460 // The region is part of the credential (the profile's `region`).
461 // Google SDKs define no default-region environment variable; override
462 // per stack with the `GCP.Region(...)` layer.
463 const resolveCredentials = (
464 profileName: string,
465 config: GcpAuthConfig,
466 ): Effect.Effect<GcpResolvedCredentials, AuthError | NeedsReauth> =>
467 Effect.gen(function* () {
468 const token = yield* resolveToken(profileName, config);
469 return { ...token, region: config.region ?? DEFAULT_GCP_REGION };
470 });
471
472 const logout = (profileName: string, config: GcpAuthConfig) =>
473 Match.value(config).pipe(
474 Match.when({ method: "env" }, () => Effect.void),
475 Match.when({ method: "serviceAccount" }, () => Effect.void),
476 Match.when({ method: "stored" }, () =>
477 store
478 .delete(profileName, GCP_AUTH_PROVIDER_NAME)
479 .pipe(
480 Effect.andThen(
481 interaction.output.success("GCP: stored credentials removed"),
482 ),
483 ),
484 ),
485 Match.exhaustive,
486 );
487
488 const login = (profileName: string, config: GcpAuthConfig) =>
489 Match.value(config)
490 .pipe(
491 Match.when({ method: "env" }, () =>
492 resolveFromEnv().pipe(Effect.as(config)),
493 ),
494 Match.when({ method: "serviceAccount" }, (cfg) =>
495 resolveFromServiceAccountFile(cfg.credentialsFile).pipe(
496 Effect.as(cfg),
497 ),
498 ),
499 Match.when({ method: "stored" }, () =>
500 store
501 .read(
502 profileName,
503 GCP_AUTH_PROVIDER_NAME,
504 GcpStoredCredentialsSchema,
505 )
506 .pipe(
507 Effect.flatMap((creds) =>
508 creds == null
509 ? loginStored(profileName).pipe(
510 Effect.map((stored) => ({ ...config, ...stored })),
511 )
512 : Effect.succeed(config),
513 ),
514 ),
515 ),
516 Match.exhaustive,
517 )
518 .pipe(
519 Effect.mapError(
520 (e) => new AuthError({ message: "login failed", cause: e }),
521 ),
522 );
523
524 const details = (
525 profileName: string,
526 config: GcpAuthConfig,
527 ): Effect.Effect<ProviderDetails, AuthError | NeedsReauth> =>
528 resolveCredentials(profileName, config).pipe(
529 Effect.map((creds) => {
530 const sourceStr = creds.source.details
531 ? `${creds.source.type} - ${creds.source.details}`
532 : creds.source.type;
533 return {
534 lines: [
535 { key: "project", value: creds.project },
536 { key: "region", value: creds.region },
537 {
538 key: "accessToken",
539 value: displayRedacted(creds.accessToken, 8),
540 },
541 { key: "source", value: sourceStr },
542 ],
543 };
544 }),
545 );
546
547 const readEnvironment = resolveFromEnv().pipe(
548 Effect.map((token) => ({ ...token, region: DEFAULT_GCP_REGION })),
549 );
550
551 return {
552 configSchema: GcpAuthConfigSchema,
553 configure: configureCredentials,
554 configureWith,
555 configureMethods,
556 logout,
557 login,
558 details,
559 read: resolveCredentials,
560 readEnvironment,
561 environment: [
562 {
563 name: GOOGLE_ACCESS_TOKEN_ENV,
564 required: false,
565 secret: true,
566 description:
567 "Bearer access token (alternative to a service-account key).",
568 },
569 {
570 name: GOOGLE_APPLICATION_CREDENTIALS_ENV,
571 required: false,
572 description: "Path to a service-account JSON key.",
573 },
574 {
575 name: GOOGLE_PROJECT_ID_ENV,
576 required: false,
577 alternatives: [GOOGLE_CLOUD_PROJECT_ENV],
578 description: "GCP project id.",
579 },
580 ],
581 };
582 }),
583);