| 1 | import { hostname } from "node:os"; |
| 2 | import packageJson from "../../package.json" with { type: "json" }; |
| 3 | import { |
| 4 | authOidc, |
| 5 | authorizeCliAuth, |
| 6 | generateCliAuth, |
| 7 | revokeCliAuth, |
| 8 | } from "@distilled.cloud/doppler"; |
| 9 | import * as Retry from "@distilled.cloud/doppler/Retry"; |
| 10 | import * as Duration from "effect/Duration"; |
| 11 | import * as Effect from "effect/Effect"; |
| 12 | import * as Redacted from "effect/Redacted"; |
| 13 | import * as Schedule from "effect/Schedule"; |
| 14 | import * as Schema from "effect/Schema"; |
| 15 | import { |
| 16 | AuthError, |
| 17 | AuthProviderLayer, |
| 18 | type ConfigureField, |
| 19 | } from "../Auth/AuthProvider.ts"; |
| 20 | import { getEnv, getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts"; |
| 21 | import { |
| 22 | detectOidcToken, |
| 23 | SUPPORTED_OIDC_PLATFORMS, |
| 24 | } from "../Auth/OidcToken.ts"; |
| 25 | import { |
| 26 | storedValueText, |
| 27 | validateFieldValues, |
| 28 | } from "../Auth/StoredAuthProvider.ts"; |
| 29 | import * as Interaction from "../Interaction.ts"; |
| 30 | |
| 31 | export const DopplerAuthConfigSchema = Schema.Struct({ |
| 32 | method: Schema.Literals(["login", "api-token"]), |
| 33 | token: Schema.NonEmptyString, |
| 34 | }); |
| 35 | export type DopplerAuthConfig = typeof DopplerAuthConfigSchema.Type; |
| 36 | |
| 37 | export interface DopplerResolvedCredentials { |
| 38 | readonly token: Redacted.Redacted<string>; |
| 39 | } |
| 40 | |
| 41 | const PROVIDER_NAME = "Doppler"; |
| 42 | export const DOPPLER_TOKEN_ENV = "DOPPLER_TOKEN"; |
| 43 | /** A service account identity to log into with a platform OIDC token. */ |
| 44 | export const DOPPLER_IDENTITY_ID_ENV = "DOPPLER_IDENTITY_ID"; |
| 45 | /** Explicit OIDC token for platforms that are not auto-detected. */ |
| 46 | export const DOPPLER_OIDC_TOKEN_ENV = "DOPPLER_OIDC_TOKEN"; |
| 47 | /** Optional audience to request in the platform's OIDC token. */ |
| 48 | export const DOPPLER_OIDC_AUDIENCE_ENV = "DOPPLER_OIDC_AUDIENCE"; |
| 49 | |
| 50 | /** How long the user has to approve the browser login. */ |
| 51 | const LOGIN_TIMEOUT = Duration.minutes(5); |
| 52 | const APPROVAL_POLL_INTERVAL = Duration.seconds(2); |
| 53 | const 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 | */ |
| 59 | const 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 | */ |
| 80 | const 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 | */ |
| 97 | export 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 | |
| 145 | const 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. */ |
| 150 | const 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 | |
| 165 | const 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. */ |
| 187 | const 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 | */ |
| 208 | export 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 | */ |
| 236 | const 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. */ |
| 260 | export 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 | }); |