1import {
2 anonymous,
3 DEFAULT_API_BASE_URL,
4 loginWithOidcAuth,
5 loginWithUniversalAuth,
6} from "@distilled.cloud/infisical";
7import * as Retry from "@distilled.cloud/infisical/Retry";
8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";
10import * as Redacted from "effect/Redacted";
11import * as Schema from "effect/Schema";
12import type * as HttpClient from "effect/http/HttpClient";
13import {
14 AuthError,
15 AuthProviderLayer,
16 type ConfigureField,
17} from "../Auth/AuthProvider.ts";
18import { displayRedacted } from "../Auth/Credentials.ts";
19import { getEnv, getEnvRedacted, mapPromptCancellation } from "../Auth/Env.ts";
20import {
21 collectFieldValues,
22 storedValueText,
23 validateFieldValues,
24 type StoredValues,
25} from "../Auth/StoredAuthProvider.ts";
26import * as Interaction from "../Interaction.ts";
27import {
28 detectOidcToken,
29 SUPPORTED_OIDC_PLATFORMS,
30} from "../Auth/OidcToken.ts";
31
32export const INFISICAL_TOKEN_ENV = "INFISICAL_TOKEN";
33/** The machine identity to log into with a platform OIDC token. */
34export const INFISICAL_IDENTITY_ID_ENV = "INFISICAL_IDENTITY_ID";
35/** Same name the Infisical CLI uses for self-hosted instances. */
36export const INFISICAL_API_URL_ENV = "INFISICAL_API_URL";
37/** Explicit OIDC token for platforms that are not auto-detected. */
38export const INFISICAL_OIDC_TOKEN_ENV = "INFISICAL_OIDC_TOKEN";
39/** Optional audience to request in the platform's OIDC token. */
40export const INFISICAL_OIDC_AUDIENCE_ENV = "INFISICAL_OIDC_AUDIENCE";
41
42const PROVIDER_NAME = "Infisical";
43const 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 */
50const 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. */
58const AccessTokenConfig = Schema.Struct({
59 method: Schema.Literal("access-token"),
60 token: Schema.NonEmptyString,
61 apiBaseUrl: Schema.optional(Schema.String),
62});
63
64export const InfisicalAuthConfigSchema = Schema.Union([
65 UniversalAuthConfig,
66 AccessTokenConfig,
67]);
68export type InfisicalAuthConfig = typeof InfisicalAuthConfigSchema.Type;
69
70export interface InfisicalResolvedCredentials {
71 readonly token: Redacted.Redacted<string>;
72 readonly apiBaseUrl: string;
73}
74
75const 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
84const 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
99const 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
110const 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 */
124export 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 */
151export 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 */
178const 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. */
193const 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 */
212const verify = (config: InfisicalAuthConfig) =>
213 resolve(config).pipe(Effect.as(config));
214
215const fieldsFor = (method: InfisicalAuthConfig["method"]) =>
216 method === "universal-auth" ? universalAuthFields : accessTokenFields;
217
218const 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 */
242const 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 */
274export 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});