1import { ConfigError } from "@distilled.cloud/core/errors";
2import {
3 Credentials,
4 type Config as CredentialsConfig,
5} from "@distilled.cloud/gcp/Credentials";
6import * as Config from "effect/Config";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Redacted from "effect/Redacted";
12import * as HttpClient from "effect/http/HttpClient";
13import {
14 deferUntilFirstUse,
15 orDieCredentialsUnavailable,
16 resolveProviderConfig,
17} from "../Auth/Resolve.ts";
18import {
19 GCP_AUTH_PROVIDER_NAME,
20 type GcpAuthConfig,
21 type GcpResolvedCredentials,
22} from "./AuthProvider.ts";
23import {
24 cacheCredentials,
25 fetchMetadataProject,
26 fetchMetadataToken,
27} from "./MetadataCredentials.ts";
28import { mintAccessToken, parseServiceAccountKey } from "./Token.ts";
29
30export {
31 Credentials,
32 CredentialsFromEnv,
33 fromAccessToken,
34 type Config as CredentialsConfig,
35} from "@distilled.cloud/gcp/Credentials";
36
37/**
38 * Build a `Credentials` layer that resolves GCP credentials via the
39 * Alchemy AuthProvider using the configured profile (defaults to "default",
40 * overridable with the `ALCHEMY_PROFILE` env/config value).
41 *
42 * Maps onto `@distilled.cloud/gcp`'s `{ accessToken, project }` shape.
43 * Access tokens are minted from a service-account key when
44 * `GOOGLE_APPLICATION_CREDENTIALS` is set.
45 */
46export const fromAuthProvider = () =>
47 Layer.effect(
48 Credentials,
49 Effect.gen(function* () {
50 // Defer the profile lookup until first use, so building the provider
51 // layers never requires a configured profile. Only the lookup is
52 // cached: distilled yields `Credentials` then the inner Effect on
53 // every call so SA tokens can refresh from AuthProvider's cache.
54 const lookup = yield* resolveProviderConfig<
55 GcpAuthConfig,
56 GcpResolvedCredentials
57 >(GCP_AUTH_PROVIDER_NAME).pipe(
58 deferUntilFirstUse,
59 Effect.flatMap(Effect.cached),
60 );
61 return lookup.pipe(
62 Effect.flatMap(({ profileName, resolve }) =>
63 resolve.pipe(
64 Effect.map((creds) => ({
65 accessToken: creds.accessToken,
66 project: creds.project,
67 region: creds.region,
68 })),
69 Effect.mapError(
70 (e) =>
71 new ConfigError({
72 message: `Failed to resolve GCP credentials from ${profileName === undefined ? "the environment" : `profile '${profileName}'`}: ${(e as { message?: string }).message ?? String(e)}`,
73 }),
74 ),
75 ),
76 ),
77 orDieCredentialsUnavailable(GCP_AUTH_PROVIDER_NAME),
78 );
79 }),
80 );
81
82/**
83 * GCP credential chain for Effect-native GKE workload pods (and local
84 * processes): `GOOGLE_ACCESS_TOKEN`, then
85 * `GOOGLE_APPLICATION_CREDENTIALS`, then the GCE metadata server
86 * (Workload Identity). Minted tokens are cached until shortly before they
87 * expire.
88 */
89export const fromChain = () =>
90 Layer.effect(
91 Credentials,
92 Effect.gen(function* () {
93 const fs = yield* FileSystem.FileSystem;
94 const http = yield* HttpClient.HttpClient;
95 const cached = yield* cacheCredentials(
96 Effect.gen(function* () {
97 const envToken = yield* Config.option(
98 Config.String("GOOGLE_ACCESS_TOKEN"),
99 );
100 const envProject = yield* Config.option(
101 Config.String("GOOGLE_PROJECT_ID").pipe(
102 Config.orElse(() => Config.String("GOOGLE_CLOUD_PROJECT")),
103 ),
104 );
105 if (Option.isSome(envToken)) {
106 return {
107 config: {
108 accessToken: Redacted.make(envToken.value),
109 project: Option.getOrUndefined(envProject),
110 },
111 expiresAt: undefined,
112 };
113 }
114
115 const keyFile = yield* Config.option(
116 Config.String("GOOGLE_APPLICATION_CREDENTIALS"),
117 );
118 if (Option.isSome(keyFile)) {
119 const raw = yield* fs.readFileString(keyFile.value).pipe(
120 Effect.mapError(
121 (cause) =>
122 new ConfigError({
123 message: `Failed to read GOOGLE_APPLICATION_CREDENTIALS at ${keyFile.value}: ${cause.message}`,
124 }),
125 ),
126 );
127 const sa = yield* parseServiceAccountKey(raw).pipe(
128 Effect.mapError(
129 (cause) =>
130 new ConfigError({
131 message: `Invalid GOOGLE_APPLICATION_CREDENTIALS JSON: ${cause.message}`,
132 }),
133 ),
134 );
135 const minted = yield* mintAccessToken(sa).pipe(
136 Effect.mapError(
137 (cause) =>
138 new ConfigError({
139 message: `Failed to mint a Google access token from the service-account key: ${cause.message}`,
140 }),
141 ),
142 );
143 return {
144 config: {
145 accessToken: minted.accessToken,
146 project:
147 Option.getOrUndefined(envProject) ??
148 sa.project_id ??
149 minted.project,
150 },
151 expiresAt: minted.expirationMs,
152 };
153 }
154
155 const token = yield* fetchMetadataToken(http);
156 const project =
157 Option.getOrUndefined(envProject) ??
158 (yield* fetchMetadataProject(http).pipe(
159 Effect.orElseSucceed(() => undefined),
160 ));
161 return {
162 config: { accessToken: token.accessToken, project },
163 expiresAt: token.expiresAt,
164 };
165 }),
166 );
167 // Distilled `Credentials` is `Effect<Config>` (error `never`).
168 return cached.pipe(Effect.orDie) as Effect.Effect<CredentialsConfig>;
169 }),
170 );