1import * as Duration from "effect/Duration";
2import * as Effect from "effect/Effect";
3import * as Redacted from "effect/Redacted";
4import * as Schema from "effect/Schema";
5import * as HttpClient from "effect/http/HttpClient";
6import * as HttpClientResponse from "effect/http/HttpClientResponse";
7import { getEnv } from "./Env.ts";
8
9/**
10 * Platform-issued OIDC tokens, for exchanging with a secrets manager's OIDC
11 * identity auth (Doppler service account identities, Infisical machine
12 * identities) so CI never holds a long-lived credential.
13 *
14 * | Platform | Marker variable | Token source |
15 * | -------------- | ----------------- | ------------------------------------------ |
16 * | explicit | the caller's env | the variable itself |
17 * | Vercel | `VERCEL` | `VERCEL_OIDC_TOKEN` |
18 * | GitHub Actions | `GITHUB_ACTIONS` | `ACTIONS_ID_TOKEN_REQUEST_URL` + token |
19 * | GitLab CI | `GITLAB_CI` | `SIGSTORE_ID_TOKEN` from `id_tokens` |
20 * | GCP | `K_SERVICE` etc. | the metadata server's identity endpoint |
21 */
22export type OidcPlatform =
23 | "explicit"
24 | "vercel"
25 | "github-actions"
26 | "gitlab"
27 | "gcp";
28
29export interface OidcToken {
30 readonly platform: OidcPlatform;
31 readonly token: Redacted.Redacted<string>;
32}
33
34/** Which env vars a provider reserves for its explicit token and audience. */
35export interface OidcTokenEnv {
36 /** Explicit override for platforms that are not auto-detected. */
37 readonly token: string;
38 /** Optional `aud` claim to request where the platform supports it. */
39 readonly audience: string;
40}
41
42/** The platforms {@link detectOidcToken} knows, for error messages. */
43export const SUPPORTED_OIDC_PLATFORMS =
44 "Vercel, GitHub Actions (needs `permissions: id-token: write`), GitLab CI (needs `id_tokens`), and GCP";
45
46/** Metadata and internal endpoints answer fast or not at all. */
47const PROBE_TIMEOUT = Duration.seconds(5);
48
49const GitHubTokenResponse = Schema.Struct({
50 value: Schema.optional(Schema.String),
51});
52
53/**
54 * One probe per platform, each gated on a variable the platform's runtime
55 * always sets, so a laptop never waits on a DNS lookup for an internal
56 * endpoint. A probe yields the token, or `undefined` when the platform is
57 * absent or has nothing to give.
58 */
59const PLATFORMS: ReadonlyArray<{
60 readonly name: OidcPlatform;
61 readonly probe: (
62 audience: string | undefined,
63 ) => Effect.Effect<
64 Redacted.Redacted<string> | undefined,
65 unknown,
66 HttpClient.HttpClient
67 >;
68}> = [
69 {
70 name: "vercel",
71 probe: Effect.fn("oidc.vercel")(function* () {
72 if (!(yield* getEnv("VERCEL"))) return undefined;
73 const token = yield* getEnv("VERCEL_OIDC_TOKEN");
74 return token ? Redacted.make(token) : undefined;
75 }),
76 },
77 {
78 // A per-job request URL and bearer, present only with
79 // `permissions: id-token: write` on the workflow or job.
80 name: "github-actions",
81 probe: Effect.fn("oidc.githubActions")(function* (audience) {
82 if (!(yield* getEnv("GITHUB_ACTIONS"))) return undefined;
83 const requestUrl = yield* getEnv("ACTIONS_ID_TOKEN_REQUEST_URL");
84 const bearer = yield* getEnv("ACTIONS_ID_TOKEN_REQUEST_TOKEN");
85 if (!requestUrl || !bearer) return undefined;
86
87 const url = new URL(requestUrl);
88 if (audience !== undefined) url.searchParams.set("audience", audience);
89 const client = yield* HttpClient.HttpClient;
90 const response = yield* client.get(url, {
91 headers: {
92 Authorization: `Bearer ${bearer}`,
93 Accept: "application/json; api-version=2.0",
94 },
95 });
96 if (response.status !== 200) return undefined;
97 const body =
98 yield* HttpClientResponse.schemaBodyJson(GitHubTokenResponse)(response);
99 return body.value ? Redacted.make(body.value) : undefined;
100 }),
101 },
102 {
103 // The job's `id_tokens` block names the variable; these are the common ones.
104 name: "gitlab",
105 probe: Effect.fn("oidc.gitlab")(function* () {
106 if (!(yield* getEnv("GITLAB_CI"))) return undefined;
107 const token = yield* getEnv("SIGSTORE_ID_TOKEN");
108 return token ? Redacted.make(token) : undefined;
109 }),
110 },
111 {
112 // Cloud Run, Cloud Functions, and GCE: the metadata server signs
113 // identity tokens for the instance's service account.
114 name: "gcp",
115 probe: Effect.fn("oidc.gcp")(function* (audience) {
116 const onGcp =
117 (yield* getEnv("K_SERVICE")) ||
118 (yield* getEnv("GCE_METADATA_HOST")) ||
119 (yield* getEnv("GOOGLE_CLOUD_PROJECT"));
120 if (!onGcp) return undefined;
121
122 const url = new URL(
123 "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity",
124 );
125 if (audience !== undefined) url.searchParams.set("audience", audience);
126 const client = yield* HttpClient.HttpClient;
127 const response = yield* client.get(url, {
128 headers: { "Metadata-Flavor": "Google" },
129 });
130 if (response.status !== 200) return undefined;
131 const token = (yield* response.text).trim();
132 return token ? Redacted.make(token) : undefined;
133 }),
134 },
135];
136
137/**
138 * Find the OIDC token of the platform this process is running on. The
139 * caller's explicit variable wins; otherwise platforms are probed in
140 * order and the first token wins. A probe that fails or hangs counts as
141 * "not this platform". `undefined` means nothing was detected.
142 */
143export const detectOidcToken = Effect.fn("detectOidcToken")(function* (
144 env: OidcTokenEnv,
145) {
146 const explicit = yield* getEnv(env.token);
147 if (explicit) {
148 const found: OidcToken = {
149 platform: "explicit",
150 token: Redacted.make(explicit),
151 };
152 return found;
153 }
154
155 const audience = yield* getEnv(env.audience);
156 for (const platform of PLATFORMS) {
157 const token = yield* platform.probe(audience).pipe(
158 Effect.timeout(PROBE_TIMEOUT),
159 Effect.catch(() => Effect.succeed(undefined)),
160 );
161 if (token !== undefined) {
162 const found: OidcToken = { platform: platform.name, token };
163 return found;
164 }
165 }
166 return undefined;
167});