| 1 | import { |
| 2 | anonymous, |
| 3 | DEFAULT_API_BASE_URL, |
| 4 | loginWithOidcAuth, |
| 5 | loginWithUniversalAuth, |
| 6 | } from "@distilled.cloud/infisical"; |
| 7 | import * as Retry from "@distilled.cloud/infisical/Retry"; |
| 8 | import * as Duration from "effect/Duration"; |
| 9 | import * as Effect from "effect/Effect"; |
| 10 | import * as Redacted from "effect/Redacted"; |
| 11 | import * as Schema from "effect/Schema"; |
| 12 | import type * as HttpClient from "effect/http/HttpClient"; |
| 13 | import { |
| 14 | AuthError, |
| 15 | AuthProviderLayer, |
| 16 | type ConfigureField, |
| 17 | } from "../Auth/AuthProvider.ts"; |
| 18 | import { displayRedacted } from "../Auth/Credentials.ts"; |
| 19 | import { getEnv, getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts"; |
| 20 | import { |
| 21 | collectFieldValues, |
| 22 | storedValueText, |
| 23 | validateFieldValues, |
| 24 | type StoredValues, |
| 25 | } from "../Auth/StoredAuthProvider.ts"; |
| 26 | import * as Interaction from "../Interaction.ts"; |
| 27 | import { |
| 28 | detectOidcToken, |
| 29 | SUPPORTED_OIDC_PLATFORMS, |
| 30 | } from "../Auth/OidcToken.ts"; |
| 31 | |
| 32 | export const INFISICAL_TOKEN_ENV = "INFISICAL_TOKEN"; |
| 33 | /** The machine identity to log into with a platform OIDC token. */ |
| 34 | export const INFISICAL_IDENTITY_ID_ENV = "INFISICAL_IDENTITY_ID"; |
| 35 | /** Same name the Infisical CLI uses for self-hosted instances. */ |
| 36 | export const INFISICAL_API_URL_ENV = "INFISICAL_API_URL"; |
| 37 | /** Explicit OIDC token for platforms that are not auto-detected. */ |
| 38 | export const INFISICAL_OIDC_TOKEN_ENV = "INFISICAL_OIDC_TOKEN"; |
| 39 | /** Optional audience to request in the platform's OIDC token. */ |
| 40 | export const INFISICAL_OIDC_AUDIENCE_ENV = "INFISICAL_OIDC_AUDIENCE"; |
| 41 | |
| 42 | const PROVIDER_NAME = "Infisical"; |
| 43 | const API_TIMEOUT = Duration.seconds(30); |
| 44 | |
| 45 | /** |
| 46 | * A machine identity's client id + secret. The secret never expires unless |
| 47 | * given a TTL in Infisical, and Alchemy exchanges it for a short-lived |
| 48 | * access token on every read, so this is the method for local development. |
| 49 | */ |
| 50 | const UniversalAuthConfig = Schema.Struct({ |
| 51 | method: Schema.Literal("universal-auth"), |
| 52 | clientId: Schema.NonEmptyString, |
| 53 | clientSecret: Schema.NonEmptyString, |
| 54 | apiBaseUrl: Schema.optional(Schema.String), |
| 55 | }); |
| 56 | |
| 57 | /** A user token from the dashboard's "Copy Token" menu item. Valid for 10 days. */ |
| 58 | const AccessTokenConfig = Schema.Struct({ |
| 59 | method: Schema.Literal("access-token"), |
| 60 | token: Schema.NonEmptyString, |
| 61 | apiBaseUrl: Schema.optional(Schema.String), |
| 62 | }); |
| 63 | |
| 64 | export const InfisicalAuthConfigSchema = Schema.Union([ |
| 65 | UniversalAuthConfig, |
| 66 | AccessTokenConfig, |
| 67 | ]); |
| 68 | export type InfisicalAuthConfig = typeof InfisicalAuthConfigSchema.Type; |
| 69 | |
| 70 | export interface InfisicalResolvedCredentials { |
| 71 | readonly token: Redacted.Redacted<string>; |
| 72 | readonly apiBaseUrl: string; |
| 73 | } |
| 74 | |
| 75 | const apiBaseUrlField: ConfigureField = { |
| 76 | name: "apiBaseUrl", |
| 77 | label: "Infisical API URL", |
| 78 | description: |
| 79 | "For the EU cloud (https://eu.infisical.com) or a self-hosted instance.", |
| 80 | placeholder: DEFAULT_API_BASE_URL, |
| 81 | optional: true, |
| 82 | }; |
| 83 | |
| 84 | const universalAuthFields: ReadonlyArray<ConfigureField> = [ |
| 85 | { |
| 86 | name: "clientId", |
| 87 | label: "Machine identity client ID", |
| 88 | description: |
| 89 | "From the machine identity's Universal Auth settings in Infisical. See https://infisical.com/docs/documentation/platform/identities/universal-auth", |
| 90 | }, |
| 91 | { |
| 92 | name: "clientSecret", |
| 93 | label: "Machine identity client secret", |
| 94 | secret: true, |
| 95 | }, |
| 96 | apiBaseUrlField, |
| 97 | ]; |
| 98 | |
| 99 | const accessTokenFields: ReadonlyArray<ConfigureField> = [ |
| 100 | { |
| 101 | name: "token", |
| 102 | label: "Infisical user token", |
| 103 | description: |
| 104 | 'From the account menu in the Infisical dashboard ("Copy Token"). Valid for 10 days.', |
| 105 | secret: true, |
| 106 | }, |
| 107 | apiBaseUrlField, |
| 108 | ]; |
| 109 | |
| 110 | const toCredentials = ( |
| 111 | response: { readonly accessToken: string | Redacted.Redacted<string> }, |
| 112 | apiBaseUrl: string | undefined, |
| 113 | ): InfisicalResolvedCredentials => ({ |
| 114 | token: Redacted.isRedacted(response.accessToken) |
| 115 | ? response.accessToken |
| 116 | : Redacted.make(response.accessToken), |
| 117 | apiBaseUrl: apiBaseUrl ?? DEFAULT_API_BASE_URL, |
| 118 | }); |
| 119 | |
| 120 | /** |
| 121 | * Exchange a machine identity's client credentials for an access token. |
| 122 | * Infisical answers 401 when either half is wrong or the secret is spent. |
| 123 | */ |
| 124 | export const mintAccessToken = (config: { |
| 125 | readonly clientId: string; |
| 126 | readonly clientSecret: string; |
| 127 | readonly apiBaseUrl?: string; |
| 128 | }) => |
| 129 | loginWithUniversalAuth({ |
| 130 | clientId: config.clientId, |
| 131 | clientSecret: Redacted.make(config.clientSecret), |
| 132 | }).pipe( |
| 133 | Retry.none, |
| 134 | Effect.provide(anonymous({ apiBaseUrl: config.apiBaseUrl })), |
| 135 | Effect.timeout(API_TIMEOUT), |
| 136 | Effect.map((response) => toCredentials(response, config.apiBaseUrl)), |
| 137 | Effect.mapError( |
| 138 | (cause) => |
| 139 | new AuthError({ |
| 140 | message: |
| 141 | "Infisical rejected the machine identity credentials. Check the client ID and secret, and that the secret has not expired or hit its use limit.", |
| 142 | cause, |
| 143 | }), |
| 144 | ), |
| 145 | ); |
| 146 | |
| 147 | /** |
| 148 | * Exchange a platform-issued OIDC token for an access token of the machine |
| 149 | * identity whose OIDC auth trusts that platform's issuer. |
| 150 | */ |
| 151 | export const mintAccessTokenFromOidc = (config: { |
| 152 | readonly identityId: string; |
| 153 | readonly jwt: Redacted.Redacted<string>; |
| 154 | readonly apiBaseUrl?: string; |
| 155 | }) => |
| 156 | loginWithOidcAuth({ |
| 157 | identityId: config.identityId, |
| 158 | jwt: Redacted.value(config.jwt), |
| 159 | }).pipe( |
| 160 | Retry.none, |
| 161 | Effect.provide(anonymous({ apiBaseUrl: config.apiBaseUrl })), |
| 162 | Effect.timeout(API_TIMEOUT), |
| 163 | Effect.map((response) => toCredentials(response, config.apiBaseUrl)), |
| 164 | Effect.mapError( |
| 165 | (cause) => |
| 166 | new AuthError({ |
| 167 | message: `Infisical rejected the OIDC login for identity '${config.identityId}': ${cause.message}. Check that the identity's OIDC auth trusts this platform's issuer, subject, and audience.`, |
| 168 | cause, |
| 169 | }), |
| 170 | ), |
| 171 | ); |
| 172 | |
| 173 | /** |
| 174 | * Resolve the token a stored configuration grants. Annotated because the two |
| 175 | * branches are differently shaped Effects and a union of Effects does not |
| 176 | * flow through `flatMap`. |
| 177 | */ |
| 178 | const resolve = ( |
| 179 | config: InfisicalAuthConfig, |
| 180 | ): Effect.Effect< |
| 181 | InfisicalResolvedCredentials, |
| 182 | AuthError, |
| 183 | HttpClient.HttpClient |
| 184 | > => |
| 185 | config.method === "universal-auth" |
| 186 | ? mintAccessToken(config) |
| 187 | : Effect.succeed({ |
| 188 | token: Redacted.make(config.token), |
| 189 | apiBaseUrl: config.apiBaseUrl ?? DEFAULT_API_BASE_URL, |
| 190 | }); |
| 191 | |
| 192 | /** Turn collected field values into a stored configuration. */ |
| 193 | const toConfig = ( |
| 194 | method: InfisicalAuthConfig["method"], |
| 195 | values: StoredValues, |
| 196 | ): InfisicalAuthConfig => { |
| 197 | const apiBaseUrl = storedValueText(values.apiBaseUrl); |
| 198 | return method === "access-token" |
| 199 | ? { method, token: storedValueText(values.token)!, apiBaseUrl } |
| 200 | : { |
| 201 | method, |
| 202 | clientId: storedValueText(values.clientId)!, |
| 203 | clientSecret: storedValueText(values.clientSecret)!, |
| 204 | apiBaseUrl, |
| 205 | }; |
| 206 | }; |
| 207 | |
| 208 | /** |
| 209 | * Verify Universal Auth with a login before storing it. Pasted access |
| 210 | * tokens are used as supplied and checked when secrets are requested. |
| 211 | */ |
| 212 | const verify = (config: InfisicalAuthConfig) => |
| 213 | resolve(config).pipe(Effect.as(config)); |
| 214 | |
| 215 | const fieldsFor = (method: InfisicalAuthConfig["method"]) => |
| 216 | method === "universal-auth" ? universalAuthFields : accessTokenFields; |
| 217 | |
| 218 | const chooseMethod = Interaction.accessors.prompt |
| 219 | .select({ |
| 220 | message: "Infisical authentication method", |
| 221 | options: [ |
| 222 | { |
| 223 | value: "universal-auth" as const, |
| 224 | label: "Machine identity", |
| 225 | description: |
| 226 | "Client ID and secret; mints a fresh access token on each run", |
| 227 | }, |
| 228 | { |
| 229 | value: "access-token" as const, |
| 230 | label: "User token", |
| 231 | description: "Copied from the dashboard; valid for 10 days", |
| 232 | }, |
| 233 | ], |
| 234 | }) |
| 235 | .pipe(mapPromptCancellation); |
| 236 | |
| 237 | /** |
| 238 | * How CI authenticates, decided from the environment: a ready token, or a |
| 239 | * machine identity to log into with the platform's OIDC token. Environment |
| 240 | * credentials take precedence over stored profiles, including outside CI. |
| 241 | */ |
| 242 | const readEnvironment = Effect.gen(function* () { |
| 243 | const apiBaseUrl = yield* getEnv(INFISICAL_API_URL_ENV); |
| 244 | const token = yield* getEnvRedacted(INFISICAL_TOKEN_ENV); |
| 245 | if (token !== undefined && Redacted.value(token).length > 0) { |
| 246 | return { token, apiBaseUrl: apiBaseUrl ?? DEFAULT_API_BASE_URL }; |
| 247 | } |
| 248 | const identityId = yield* getEnv(INFISICAL_IDENTITY_ID_ENV); |
| 249 | if (!identityId) { |
| 250 | return yield* new AuthError({ |
| 251 | message: `Infisical credentials are missing. In CI set ${INFISICAL_TOKEN_ENV}, or set ${INFISICAL_IDENTITY_ID_ENV} to log in with the platform's OIDC token; locally run \`alchemy profile edit --add Infisical\`.`, |
| 252 | }); |
| 253 | } |
| 254 | const oidc = yield* detectOidcToken({ |
| 255 | token: INFISICAL_OIDC_TOKEN_ENV, |
| 256 | audience: INFISICAL_OIDC_AUDIENCE_ENV, |
| 257 | }); |
| 258 | if (oidc === undefined) { |
| 259 | return yield* new AuthError({ |
| 260 | message: `${INFISICAL_IDENTITY_ID_ENV} is set but no platform OIDC token was found. Supported: ${SUPPORTED_OIDC_PLATFORMS}; elsewhere pass the token in ${INFISICAL_OIDC_TOKEN_ENV}.`, |
| 261 | }); |
| 262 | } |
| 263 | return yield* mintAccessTokenFromOidc({ |
| 264 | identityId, |
| 265 | jwt: oidc.token, |
| 266 | apiBaseUrl, |
| 267 | }); |
| 268 | }); |
| 269 | |
| 270 | /** |
| 271 | * Infisical profile authentication: a machine identity's universal-auth |
| 272 | * credentials (recommended) or a pasted access token. |
| 273 | */ |
| 274 | export const InfisicalAuth = AuthProviderLayer< |
| 275 | InfisicalAuthConfig, |
| 276 | InfisicalResolvedCredentials |
| 277 | >()(PROVIDER_NAME, { |
| 278 | configSchema: InfisicalAuthConfigSchema, |
| 279 | configure: () => |
| 280 | chooseMethod.pipe( |
| 281 | Effect.flatMap((method) => |
| 282 | collectFieldValues(fieldsFor(method)).pipe( |
| 283 | Effect.map((values) => toConfig(method, values)), |
| 284 | ), |
| 285 | ), |
| 286 | Effect.flatMap(verify), |
| 287 | ), |
| 288 | configureMethods: [ |
| 289 | { method: "universal-auth", fields: universalAuthFields }, |
| 290 | { method: "access-token", fields: accessTokenFields }, |
| 291 | ], |
| 292 | configureWith: (_, input) => { |
| 293 | if (input.method !== "universal-auth" && input.method !== "access-token") { |
| 294 | return Effect.fail( |
| 295 | new AuthError({ |
| 296 | message: `Infisical: unknown method '${input.method}'. Valid methods: universal-auth, access-token.`, |
| 297 | }), |
| 298 | ); |
| 299 | } |
| 300 | const method = input.method; |
| 301 | return validateFieldValues( |
| 302 | PROVIDER_NAME, |
| 303 | fieldsFor(method), |
| 304 | input.values, |
| 305 | ).pipe( |
| 306 | Effect.map((values) => toConfig(method, values)), |
| 307 | Effect.flatMap(verify), |
| 308 | ); |
| 309 | }, |
| 310 | // Nothing to re-authenticate: stored credentials are long-lived (or, for |
| 311 | // access tokens, replaced by reconfiguring). Verifying them is enough. |
| 312 | login: (_, config) => verify(config), |
| 313 | // Client secrets are revoked from the Infisical dashboard by an admin; |
| 314 | // there is nothing session-like to tear down. |
| 315 | logout: () => Effect.void, |
| 316 | read: (_, config) => resolve(config), |
| 317 | details: (_, config) => |
| 318 | Effect.succeed({ |
| 319 | lines: [ |
| 320 | { key: "method", value: config.method }, |
| 321 | config.method === "universal-auth" |
| 322 | ? { key: "clientId", value: config.clientId } |
| 323 | : { |
| 324 | key: "token", |
| 325 | value: displayRedacted(Redacted.make(config.token)), |
| 326 | }, |
| 327 | { key: "apiBaseUrl", value: config.apiBaseUrl ?? DEFAULT_API_BASE_URL }, |
| 328 | ], |
| 329 | }), |
| 330 | readEnvironment, |
| 331 | environment: [ |
| 332 | { |
| 333 | name: INFISICAL_TOKEN_ENV, |
| 334 | required: true, |
| 335 | secret: true, |
| 336 | alternatives: [INFISICAL_IDENTITY_ID_ENV], |
| 337 | description: |
| 338 | "A machine identity access token; or set INFISICAL_IDENTITY_ID instead to log in with the platform's OIDC token (Vercel, GitHub Actions, GitLab CI, GCP)", |
| 339 | }, |
| 340 | { |
| 341 | name: INFISICAL_OIDC_TOKEN_ENV, |
| 342 | required: false, |
| 343 | secret: true, |
| 344 | description: |
| 345 | "Explicit OIDC token for platforms that are not auto-detected", |
| 346 | }, |
| 347 | { |
| 348 | name: INFISICAL_OIDC_AUDIENCE_ENV, |
| 349 | required: false, |
| 350 | description: "Audience to request in the platform's OIDC token", |
| 351 | }, |
| 352 | { |
| 353 | name: INFISICAL_API_URL_ENV, |
| 354 | required: false, |
| 355 | description: "Base URL of a self-hosted Infisical instance", |
| 356 | }, |
| 357 | ], |
| 358 | }); |