1import { hostname } from "node:os";
2import packageJson from "../../package.json" with { type: "json" };
3import {
4 authOidc,
5 authorizeCliAuth,
6 generateCliAuth,
7 revokeCliAuth,
8} from "@distilled.cloud/doppler";
9import * as Retry from "@distilled.cloud/doppler/Retry";
10import * as Duration from "effect/Duration";
11import * as Effect from "effect/Effect";
12import * as Redacted from "effect/Redacted";
13import * as Schedule from "effect/Schedule";
14import * as Schema from "effect/Schema";
15import {
16 AuthError,
17 AuthProviderLayer,
18 type ConfigureField,
19} from "../Auth/AuthProvider.ts";
20import { getEnv, getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts";
21import {
22 detectOidcToken,
23 SUPPORTED_OIDC_PLATFORMS,
24} from "../Auth/OidcToken.ts";
25import {
26 storedValueText,
27 validateFieldValues,
28} from "../Auth/StoredAuthProvider.ts";
29import * as Interaction from "../Interaction.ts";
30
31export const DopplerAuthConfigSchema = Schema.Struct({
32 method: Schema.Literals(["login", "api-token"]),
33 token: Schema.NonEmptyString,
34});
35export type DopplerAuthConfig = typeof DopplerAuthConfigSchema.Type;
36
37export interface DopplerResolvedCredentials {
38 readonly token: Redacted.Redacted<string>;
39}
40
41const PROVIDER_NAME = "Doppler";
42export const DOPPLER_TOKEN_ENV = "DOPPLER_TOKEN";
43/** A service account identity to log into with a platform OIDC token. */
44export const DOPPLER_IDENTITY_ID_ENV = "DOPPLER_IDENTITY_ID";
45/** Explicit OIDC token for platforms that are not auto-detected. */
46export const DOPPLER_OIDC_TOKEN_ENV = "DOPPLER_OIDC_TOKEN";
47/** Optional audience to request in the platform's OIDC token. */
48export const DOPPLER_OIDC_AUDIENCE_ENV = "DOPPLER_OIDC_AUDIENCE";
49
50/** How long the user has to approve the browser login. */
51const LOGIN_TIMEOUT = Duration.minutes(5);
52const APPROVAL_POLL_INTERVAL = Duration.seconds(2);
53const API_TIMEOUT = Duration.seconds(30);
54
55/**
56 * Describe this machine the way Doppler's CLI auth endpoint expects it.
57 * Doppler validates these fields, so the values mirror what its own CLI sends.
58 */
59const client = {
60 // Doppler cuts the token name at the first ".", so drop any domain suffix
61 // (e.g. "Yoru.local" -> "Yoru").
62 hostname: `Alchemy (${hostname().split(".")[0]})`,
63 // Doppler requires vMAJOR.MINOR.PATCH; prerelease suffixes are rejected.
64 version: `v${packageJson.version.split("-")[0]}`,
65 os: process.platform === "win32" ? "windows" : process.platform,
66 // Doppler expects Go-style architecture names.
67 arch:
68 process.arch === "x64"
69 ? "amd64"
70 : process.arch === "ia32"
71 ? "386"
72 : process.arch,
73};
74
75/**
76 * Poll Doppler until the user approves the login in their browser.
77 * Doppler answers `409 Conflict` while approval is still pending; any other
78 * failure (rejected, expired) ends the flow.
79 */
80const awaitApproval = (authorization: {
81 polling_code: string | Redacted.Redacted<string>;
82}) =>
83 authorizeCliAuth({ code: authorization.polling_code }).pipe(
84 Retry.none,
85 Effect.retry({
86 while: (error) => error._tag === "Conflict",
87 schedule: Schedule.spaced(APPROVAL_POLL_INTERVAL).pipe(
88 Schedule.upTo({ duration: LOGIN_TIMEOUT }),
89 ),
90 }),
91 );
92
93/**
94 * Explicit browser login. Only ever run from `alchemy profile edit` /
95 * `profile refresh`; reading credentials or loading secrets never logs in.
96 */
97export const login = Effect.gen(function* () {
98 const interaction = yield* Interaction.Interaction;
99
100 const authorization = yield* generateCliAuth(client).pipe(
101 Retry.none,
102 Effect.timeout(API_TIMEOUT),
103 );
104
105 const openFailed = yield* Interaction.openUrl(authorization.auth_url).pipe(
106 Effect.as(false),
107 Effect.catch(() => Effect.succeed(true)),
108 );
109
110 // Show the "waiting" prompt for as long as polling takes; the prompt itself
111 // never resolves, so the race is decided by approval (or the timeout).
112 const waitingPrompt = interaction.prompt
113 .awaitExternal({
114 message: "Log in to Doppler",
115 waitingLabel: "Waiting for Doppler authorization (up to 5 minutes)…",
116 url: authorization.auth_url,
117 code: authorization.code,
118 openFailed,
119 allowManualInput: false,
120 })
121 .pipe(mapPromptCancellation, Effect.andThen(Effect.never));
122
123 const credentials = yield* Effect.raceFirst(
124 awaitApproval(authorization),
125 waitingPrompt,
126 ).pipe(Effect.timeout(LOGIN_TIMEOUT));
127
128 const config: DopplerAuthConfig = {
129 method: "login",
130 token: Redacted.isRedacted(credentials.token)
131 ? Redacted.value(credentials.token)
132 : credentials.token,
133 };
134 return config;
135}).pipe(
136 Effect.mapError(
137 () =>
138 new AuthError({
139 message:
140 "Doppler login did not complete. Run `alchemy profile edit --add Doppler` to try again, or configure an API token.",
141 }),
142 ),
143);
144
145const tokenFields: ReadonlyArray<ConfigureField> = [
146 { name: "token", label: "Doppler API token", secret: true },
147];
148
149/** Interactive alternative to {@link login}: paste a service or personal token. */
150const promptForToken = Interaction.accessors.prompt
151 .password({
152 message: "Doppler API token",
153 description:
154 "A service token or personal token. Stored in your Alchemy profile.",
155 validate: (value) => (value.trim().length ? undefined : "Required"),
156 })
157 .pipe(
158 mapPromptCancellation,
159 Effect.map((token): DopplerAuthConfig => ({
160 method: "api-token",
161 token: token.trim(),
162 })),
163 );
164
165const chooseMethod = Interaction.accessors.prompt
166 .select({
167 message: "Doppler authentication method",
168 options: [
169 {
170 value: "login" as const,
171 label: "Login",
172 description: "Login using your browser",
173 },
174 {
175 value: "api-token" as const,
176 label: "API token",
177 description: "Use a service token or personal token",
178 },
179 ],
180 })
181 .pipe(
182 mapPromptCancellation,
183 Effect.flatMap((method) => (method === "login" ? login : promptForToken)),
184 );
185
186/** Browser-login tokens are revocable; API tokens were minted elsewhere and are left alone. */
187const revokeLoginToken = (config: DopplerAuthConfig) =>
188 config.method === "login"
189 ? revokeCliAuth({ token: Redacted.make(config.token) }).pipe(
190 Retry.none,
191 Effect.timeout(API_TIMEOUT),
192 Effect.asVoid,
193 Effect.mapError(
194 () =>
195 new AuthError({
196 message:
197 "Could not revoke the Doppler login token. Try logging out again.",
198 }),
199 ),
200 )
201 : Effect.void;
202
203/**
204 * Exchange a platform-issued OIDC token for a short-lived Doppler token via
205 * the service account identity that trusts that platform's issuer.
206 * Identities are a Team/Enterprise plan feature.
207 */
208export const mintTokenFromOidc = (config: {
209 readonly identityId: string;
210 readonly jwt: Redacted.Redacted<string>;
211}) =>
212 authOidc({
213 identity: config.identityId,
214 token: Redacted.value(config.jwt),
215 }).pipe(
216 Retry.none,
217 Effect.timeout(API_TIMEOUT),
218 Effect.map((response): DopplerResolvedCredentials => ({
219 token: Redacted.isRedacted(response.token)
220 ? response.token
221 : Redacted.make(response.token),
222 })),
223 Effect.mapError(
224 (cause) =>
225 new AuthError({
226 message: `Doppler rejected the OIDC login for identity '${config.identityId}': ${cause.message}. Check that the service account identity trusts this platform's issuer, subject, and audience, and that the workplace plan includes identities.`,
227 cause,
228 }),
229 ),
230 );
231
232/**
233 * How CI authenticates, decided from the environment: a ready token, or a
234 * service account identity to log into with the platform's OIDC token.
235 */
236const readEnvironment = Effect.gen(function* () {
237 const token = yield* getEnvRedacted(DOPPLER_TOKEN_ENV);
238 if (token !== undefined && Redacted.value(token).length > 0) {
239 return { token };
240 }
241 const identityId = yield* getEnv(DOPPLER_IDENTITY_ID_ENV);
242 if (!identityId) {
243 return yield* new AuthError({
244 message: `Doppler credentials are missing. In CI set ${DOPPLER_TOKEN_ENV}, or set ${DOPPLER_IDENTITY_ID_ENV} to log in with the platform's OIDC token; locally run \`alchemy profile edit --add Doppler\` and choose Login.`,
245 });
246 }
247 const oidc = yield* detectOidcToken({
248 token: DOPPLER_OIDC_TOKEN_ENV,
249 audience: DOPPLER_OIDC_AUDIENCE_ENV,
250 });
251 if (oidc === undefined) {
252 return yield* new AuthError({
253 message: `${DOPPLER_IDENTITY_ID_ENV} is set but no platform OIDC token was found. Supported: ${SUPPORTED_OIDC_PLATFORMS}; elsewhere pass the token in ${DOPPLER_OIDC_TOKEN_ENV}.`,
254 });
255 }
256 return yield* mintTokenFromOidc({ identityId, jwt: oidc.token });
257});
258
259/** Doppler profile authentication: explicit browser Login or a stored API token. */
260export const DopplerAuth = AuthProviderLayer<
261 DopplerAuthConfig,
262 DopplerResolvedCredentials
263>()(PROVIDER_NAME, {
264 configSchema: DopplerAuthConfigSchema,
265 configure: () => chooseMethod,
266 configureMethods: [{ method: "api-token", fields: tokenFields }],
267 configureWith: (_, input) =>
268 input.method === "api-token"
269 ? validateFieldValues(PROVIDER_NAME, tokenFields, input.values).pipe(
270 Effect.map((values): DopplerAuthConfig => ({
271 method: "api-token",
272 token: storedValueText(values.token)!,
273 })),
274 )
275 : Effect.fail(
276 new AuthError({
277 message:
278 "Doppler: use method 'api-token' for flag-driven configuration. Login requires interactive profile setup.",
279 }),
280 ),
281 login: (_, config) => (config.method === "login" ? login : promptForToken),
282 logout: (_, config) => revokeLoginToken(config),
283 read: (_, config) => Effect.succeed({ token: Redacted.make(config.token) }),
284 details: (_, config) =>
285 Effect.succeed({ lines: [{ key: "method", value: config.method }] }),
286 readEnvironment,
287 environment: [
288 {
289 name: DOPPLER_TOKEN_ENV,
290 required: true,
291 secret: true,
292 alternatives: [DOPPLER_IDENTITY_ID_ENV],
293 description:
294 "A service token or personal token; or set DOPPLER_IDENTITY_ID instead to log in with the platform's OIDC token (Vercel, GitHub Actions, GitLab CI, GCP)",
295 },
296 {
297 name: DOPPLER_OIDC_TOKEN_ENV,
298 required: false,
299 secret: true,
300 description:
301 "Explicit OIDC token for platforms that are not auto-detected",
302 },
303 {
304 name: DOPPLER_OIDC_AUDIENCE_ENV,
305 required: false,
306 description: "Audience to request in the platform's OIDC token",
307 },
308 ],
309});