@webpieces/gcp-identity 0.3.307 → 0.3.309

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/gcp-identity",
3
- "version": "0.3.307",
3
+ "version": "0.3.309",
4
4
  "description": "GCP runtime identity: project/region metadata, Cloud Run URLs, OIDC mint/verify",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -23,7 +23,7 @@
23
23
  "access": "public"
24
24
  },
25
25
  "dependencies": {
26
- "@webpieces/core-util": "0.3.307",
26
+ "@webpieces/core-util": "0.3.309",
27
27
  "google-auth-library": "9.15.1",
28
28
  "gcp-metadata": "6.1.1"
29
29
  }
package/src/oidc.js CHANGED
@@ -59,12 +59,63 @@ async function verifyOidcFromCallers(idToken, callers) {
59
59
  if (!email) {
60
60
  return new OidcVerifyResult(false, undefined, 'token invalid or missing email claim');
61
61
  }
62
+ // EMPTY allow-list = TRUST THE EDGE: accept any genuine Google-signed OIDC caller, because a
63
+ // PRIVATE Cloud Run service's edge already gated WHO via run.invoker IAM (managed in terraform,
64
+ // one source of truth). Warn loudly (once) if the service is actually PUBLIC — then the edge is
65
+ // NOT filtering callers and this is insecure. Non-empty list = explicit app-level allow-list.
66
+ if (callers.length === 0) {
67
+ await warnIfPublicOnce();
68
+ return new OidcVerifyResult(true, email);
69
+ }
62
70
  const allowed = await resolveCallers(callers);
63
71
  if (!allowed.includes(email)) {
64
72
  return new OidcVerifyResult(false, email, `caller '${email}' is not in the allow-list [${allowed.join(', ')}]`);
65
73
  }
66
74
  return new OidcVerifyResult(true, email);
67
75
  }
76
+ let publicPostureChecked = false;
77
+ /**
78
+ * @AuthOidc() (trust-the-edge) is only secure when the Cloud Run service is PRIVATE — the edge
79
+ * enforces run.invoker. If it is actually PUBLIC, warn LOUDLY (once): we still admit only
80
+ * Google-signed callers, but the edge is not filtering WHO. Never fails — this is advisory.
81
+ */
82
+ async function warnIfPublicOnce() {
83
+ if (publicPostureChecked) {
84
+ return;
85
+ }
86
+ publicPostureChecked = true; // attempt at most once, even if the check itself errors
87
+ if (!(await (0, metadata_1.isOnGcp)())) {
88
+ return; // off-GCP: there is no Cloud Run edge; public/private does not apply.
89
+ }
90
+ if (await isServicePublic()) {
91
+ log.error('This endpoint is currently public but marked @AuthOidc which requires the service to be ' +
92
+ 'private. You are currently running in a very insecure mode; however, we are only allowing ' +
93
+ 'google-signed callers in. The edge will validate callers are allowed in (make the Cloud Run ' +
94
+ 'service private — remove the allUsers run.invoker binding) to reduce your attack surface.');
95
+ }
96
+ }
97
+ /**
98
+ * True when THIS Cloud Run service grants run.invoker to allUsers/allAuthenticatedUsers (public).
99
+ * Reads the service's OWN IAM policy via the Run Admin API (needs run.services.getIamPolicy on the
100
+ * runtime SA). On any failure we cannot tell → false (no false alarm).
101
+ */
102
+ async function isServicePublic() {
103
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a failed self-IAM read just means "posture unknown" → no warning
104
+ try {
105
+ const [project, region] = await Promise.all([(0, urls_1.getProjectId)(), (0, urls_1.getRegion)()]);
106
+ const service = process.env['K_SERVICE'] ?? '';
107
+ const url = `https://run.googleapis.com/v2/projects/${project}/locations/${region}/services/${service}:getIamPolicy`;
108
+ const client = await reusableAuth.getClient();
109
+ const res = await client.request({ url: url });
110
+ return (res.data.bindings ?? []).some((binding) => binding.role === 'roles/run.invoker' &&
111
+ (binding.members ?? []).some((m) => m === 'allUsers' || m === 'allAuthenticatedUsers'));
112
+ }
113
+ catch (err) {
114
+ const error = (0, core_util_2.toError)(err);
115
+ log.debug(`Could not read own Cloud Run IAM policy (need run.services.getIamPolicy): ${error.message}`);
116
+ return false;
117
+ }
118
+ }
68
119
  function makeDevToken(email, audience) {
69
120
  const payload = JSON.stringify({ email: email, aud: audience });
70
121
  return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
@@ -73,7 +124,7 @@ async function extractVerifiedEmail(idToken) {
73
124
  if (idToken.startsWith(DEV_TOKEN_PREFIX)) {
74
125
  return decodeDevTokenEmail(idToken);
75
126
  }
76
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an unverifiable token is simply unauthenticated → undefined → 401 upstream
127
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a genuinely unverifiable token is unauthenticated (→ undefined → 401); infra failures are re-thrown below
77
128
  try {
78
129
  const ticket = await reusableVerifier.verifyIdToken({ idToken: idToken });
79
130
  const payload = ticket.getPayload();
@@ -81,10 +132,29 @@ async function extractVerifiedEmail(idToken) {
81
132
  }
82
133
  catch (err) {
83
134
  const error = (0, core_util_2.toError)(err);
84
- log.debug(`OIDC token verify failed: ${error.message}`);
135
+ if (isInfrastructureError(error)) {
136
+ // A network/system failure fetching Google's certs (or a bug) is NOT an auth failure.
137
+ // Do NOT mask it as a 401 — re-throw so it surfaces as a 5xx (alertable / retryable)
138
+ // instead of looking like a caller sent a bad token.
139
+ throw error;
140
+ }
141
+ // Bad signature / expired / malformed token → genuinely unauthenticated → 401 upstream.
142
+ log.debug(`OIDC token rejected (unauthenticated): ${error.message}`);
85
143
  return undefined;
86
144
  }
87
145
  }
146
+ /**
147
+ * True when an error thrown while verifying a token is INFRASTRUCTURE (network/system), not a bad
148
+ * token — e.g. the metadata/cert fetch failed. These must NOT be swallowed as a 401 (that would
149
+ * both mislead the caller and hide real outages/bugs behind a client-error status).
150
+ */
151
+ function isInfrastructureError(error) {
152
+ const code = error.code;
153
+ const networkCodes = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNRESET', 'EAI_AGAIN'];
154
+ return ((code !== undefined && networkCodes.includes(code)) ||
155
+ error.name === 'GaxiosError' ||
156
+ error.name === 'FetchError');
157
+ }
88
158
  function decodeDevTokenEmail(idToken) {
89
159
  const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);
90
160
  const json = Buffer.from(encoded, 'base64url').toString('utf8');
package/src/oidc.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"oidc.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/oidc.ts"],"names":[],"mappings":"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","sourcesContent":["import { GoogleAuth, OAuth2Client } from 'google-auth-library';\nimport { LogManager } from '@webpieces/core-util';\nimport { toError } from '@webpieces/core-util';\nimport { isOnGcp } from './metadata';\nimport { getProjectId, getRuntimeServiceAccountEmail } from './urls';\n\nconst log = LogManager.getLogger('gcp-identity-oidc');\n\n/**\n * Prefix marking a deterministic local (off-GCP) OIDC token. Real Google-signed\n * tokens never start with this, so verify can tell them apart. This is what keeps\n * the @AuthOidc code path fully exercised in tests without any GCP round-trip.\n */\nconst DEV_TOKEN_PREFIX = 'dev-oidc.';\n\nconst reusableAuth = new GoogleAuth();\nconst reusableVerifier = new OAuth2Client();\n\n/** Outcome of verifying an inbound OIDC token against an allow-list of callers. */\nexport class OidcVerifyResult {\n /** True when the token is valid AND its caller SA is allowed. */\n ok: boolean;\n /** The verified caller email (present when ok). */\n email?: string;\n /** Human-readable reason when not ok (for logs / 401 message). */\n reason?: string;\n\n constructor(ok: boolean, email?: string, reason?: string) {\n this.ok = ok;\n this.email = email;\n this.reason = reason;\n }\n}\n\n/**\n * Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as\n * this service's runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token\n * that verifyOidcFromCallers accepts locally.\n */\nexport async function mintIdToken(audience: string): Promise<string> {\n if (!(await isOnGcp())) {\n const email = await getRuntimeServiceAccountEmail();\n return makeDevToken(email, audience);\n }\n const client = await reusableAuth.getIdTokenClient(audience);\n return client.idTokenProvider.fetchIdToken(audience);\n}\n\n/**\n * Verify an inbound OIDC token and require its caller SA to be in the allow-list.\n * Audience is deliberately NOT gated (mirrors the platform's cross-service model —\n * the callee's own `aud` passes). Never throws — an invalid token or a disallowed\n * caller comes back as `ok:false` so callers map it to a 401 without a try/catch.\n *\n * `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →\n * `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.\n */\nexport async function verifyOidcFromCallers(\n idToken: string,\n callers: string[],\n): Promise<OidcVerifyResult> {\n const email = await extractVerifiedEmail(idToken);\n if (!email) {\n return new OidcVerifyResult(false, undefined, 'token invalid or missing email claim');\n }\n const allowed = await resolveCallers(callers);\n if (!allowed.includes(email)) {\n return new OidcVerifyResult(\n false,\n email,\n `caller '${email}' is not in the allow-list [${allowed.join(', ')}]`,\n );\n }\n return new OidcVerifyResult(true, email);\n}\n\nfunction makeDevToken(email: string, audience: string): string {\n const payload = JSON.stringify({ email: email, aud: audience });\n return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');\n}\n\nasync function extractVerifiedEmail(idToken: string): Promise<string | undefined> {\n if (idToken.startsWith(DEV_TOKEN_PREFIX)) {\n return decodeDevTokenEmail(idToken);\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an unverifiable token is simply unauthenticated → undefined → 401 upstream\n try {\n const ticket = await reusableVerifier.verifyIdToken({ idToken: idToken });\n const payload = ticket.getPayload();\n return payload?.email ?? undefined;\n } catch (err: unknown) {\n const error = toError(err);\n log.debug(`OIDC token verify failed: ${error.message}`);\n return undefined;\n }\n}\n\nfunction decodeDevTokenEmail(idToken: string): string | undefined {\n const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);\n const json = Buffer.from(encoded, 'base64url').toString('utf8');\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed dev token is just unauthenticated → undefined\n try {\n const parsed = JSON.parse(json) as DevTokenPayload;\n return parsed.email ?? undefined;\n } catch (err: unknown) {\n const error = toError(err);\n log.debug(`dev-oidc token decode failed: ${error.message}`);\n return undefined;\n }\n}\n\nasync function resolveCallers(callers: string[]): Promise<string[]> {\n return Promise.all(callers.map((caller: string) => resolveCaller(caller)));\n}\n\nasync function resolveCaller(caller: string): Promise<string> {\n if (caller === 'self') {\n return getRuntimeServiceAccountEmail();\n }\n if (caller.includes('@')) {\n return caller;\n }\n const projectId = await getProjectId();\n return `${caller}@${projectId}.iam.gserviceaccount.com`;\n}\n\n/** Shape of the decoded dev-oidc token payload. */\nclass DevTokenPayload {\n email!: string;\n aud!: string;\n}\n"]}
1
+ {"version":3,"file":"oidc.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/oidc.ts"],"names":[],"mappings":";;;AAuCA,kCAOC;AAWD,sDAyBC;AAlFD,6DAA+D;AAC/D,oDAAkD;AAClD,oDAA+C;AAC/C,yCAAqC;AACrC,iCAAgF;AAEhF,MAAM,GAAG,GAAG,sBAAU,CAAC,SAAS,CAAC,mBAAmB,CAAC,CAAC;AAEtD;;;;GAIG;AACH,MAAM,gBAAgB,GAAG,WAAW,CAAC;AAErC,MAAM,YAAY,GAAG,IAAI,gCAAU,EAAE,CAAC;AACtC,MAAM,gBAAgB,GAAG,IAAI,kCAAY,EAAE,CAAC;AAE5C,mFAAmF;AACnF,MAAa,gBAAgB;IACzB,iEAAiE;IACjE,EAAE,CAAU;IACZ,mDAAmD;IACnD,KAAK,CAAU;IACf,kEAAkE;IAClE,MAAM,CAAU;IAEhB,YAAY,EAAW,EAAE,KAAc,EAAE,MAAe;QACpD,IAAI,CAAC,EAAE,GAAG,EAAE,CAAC;QACb,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACzB,CAAC;CACJ;AAbD,4CAaC;AAED;;;;GAIG;AACI,KAAK,UAAU,WAAW,CAAC,QAAgB;IAC9C,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,MAAM,KAAK,GAAG,MAAM,IAAA,oCAA6B,GAAE,CAAC;QACpD,OAAO,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IACzC,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,gBAAgB,CAAC,QAAQ,CAAC,CAAC;IAC7D,OAAO,MAAM,CAAC,eAAe,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;AACzD,CAAC;AAED;;;;;;;;GAQG;AACI,KAAK,UAAU,qBAAqB,CACvC,OAAe,EACf,OAAiB;IAEjB,MAAM,KAAK,GAAG,MAAM,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAClD,IAAI,CAAC,KAAK,EAAE,CAAC;QACT,OAAO,IAAI,gBAAgB,CAAC,KAAK,EAAE,SAAS,EAAE,sCAAsC,CAAC,CAAC;IAC1F,CAAC;IACD,6FAA6F;IAC7F,gGAAgG;IAChG,gGAAgG;IAChG,8FAA8F;IAC9F,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,gBAAgB,EAAE,CAAC;QACzB,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;IAC7C,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,cAAc,CAAC,OAAO,CAAC,CAAC;IAC9C,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC;QAC3B,OAAO,IAAI,gBAAgB,CACvB,KAAK,EACL,KAAK,EACL,WAAW,KAAK,+BAA+B,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CACvE,CAAC;IACN,CAAC;IACD,OAAO,IAAI,gBAAgB,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;AAC7C,CAAC;AAED,IAAI,oBAAoB,GAAG,KAAK,CAAC;AAEjC;;;;GAIG;AACH,KAAK,UAAU,gBAAgB;IAC3B,IAAI,oBAAoB,EAAE,CAAC;QACvB,OAAO;IACX,CAAC;IACD,oBAAoB,GAAG,IAAI,CAAC,CAAC,wDAAwD;IACrF,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,OAAO,CAAC,sEAAsE;IAClF,CAAC;IACD,IAAI,MAAM,eAAe,EAAE,EAAE,CAAC;QAC1B,GAAG,CAAC,KAAK,CACL,0FAA0F;YACtF,4FAA4F;YAC5F,8FAA8F;YAC9F,2FAA2F,CAClG,CAAC;IACN,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,KAAK,UAAU,eAAe;IAC1B,kIAAkI;IAClI,IAAI,CAAC;QACD,MAAM,CAAC,OAAO,EAAE,MAAM,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC,CAAC,IAAA,mBAAY,GAAE,EAAE,IAAA,gBAAS,GAAE,CAAC,CAAC,CAAC;QAC3E,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,IAAI,EAAE,CAAC;QAC/C,MAAM,GAAG,GAAG,0CAA0C,OAAO,cAAc,MAAM,aAAa,OAAO,eAAe,CAAC;QACrH,MAAM,MAAM,GAAG,MAAM,YAAY,CAAC,SAAS,EAAE,CAAC;QAC9C,MAAM,GAAG,GAAG,MAAM,MAAM,CAAC,OAAO,CAAY,EAAE,GAAG,EAAE,GAAG,EAAE,CAAC,CAAC;QAC1D,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,IAAI,CACjC,CAAC,OAAmB,EAAE,EAAE,CACpB,OAAO,CAAC,IAAI,KAAK,mBAAmB;YACpC,CAAC,OAAO,CAAC,OAAO,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,KAAK,UAAU,IAAI,CAAC,KAAK,uBAAuB,CAAC,CACrG,CAAC;IACN,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,GAAG,CAAC,KAAK,CAAC,6EAA6E,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QACxG,OAAO,KAAK,CAAC;IACjB,CAAC;AACL,CAAC;AAWD,SAAS,YAAY,CAAC,KAAa,EAAE,QAAgB;IACjD,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,EAAE,QAAQ,EAAE,CAAC,CAAC;IAChE,OAAO,gBAAgB,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAC;AACjF,CAAC;AAED,KAAK,UAAU,oBAAoB,CAAC,OAAe;IAC/C,IAAI,OAAO,CAAC,UAAU,CAAC,gBAAgB,CAAC,EAAE,CAAC;QACvC,OAAO,mBAAmB,CAAC,OAAO,CAAC,CAAC;IACxC,CAAC;IACD,2KAA2K;IAC3K,IAAI,CAAC;QACD,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,aAAa,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;QAC1E,MAAM,OAAO,GAAG,MAAM,CAAC,UAAU,EAAE,CAAC;QACpC,OAAO,OAAO,EAAE,KAAK,IAAI,SAAS,CAAC;IACvC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,qBAAqB,CAAC,KAAK,CAAC,EAAE,CAAC;YAC/B,sFAAsF;YACtF,qFAAqF;YACrF,qDAAqD;YACrD,MAAM,KAAK,CAAC;QAChB,CAAC;QACD,wFAAwF;QACxF,GAAG,CAAC,KAAK,CAAC,0CAA0C,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QACrE,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED;;;;GAIG;AACH,SAAS,qBAAqB,CAAC,KAAY;IACvC,MAAM,IAAI,GAAI,KAAuB,CAAC,IAAI,CAAC;IAC3C,MAAM,YAAY,GAAG,CAAC,WAAW,EAAE,WAAW,EAAE,cAAc,EAAE,YAAY,EAAE,WAAW,CAAC,CAAC;IAC3F,OAAO,CACH,CAAC,IAAI,KAAK,SAAS,IAAI,YAAY,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QACnD,KAAK,CAAC,IAAI,KAAK,aAAa;QAC5B,KAAK,CAAC,IAAI,KAAK,YAAY,CAC9B,CAAC;AACN,CAAC;AAED,SAAS,mBAAmB,CAAC,OAAe;IACxC,MAAM,OAAO,GAAG,OAAO,CAAC,SAAS,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;IAC3D,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;IAChE,2HAA2H;IAC3H,IAAI,CAAC;QACD,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAI,CAAoB,CAAC;QACnD,OAAO,MAAM,CAAC,KAAK,IAAI,SAAS,CAAC;IACrC,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,GAAG,CAAC,KAAK,CAAC,iCAAiC,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC;QAC5D,OAAO,SAAS,CAAC;IACrB,CAAC;AACL,CAAC;AAED,KAAK,UAAU,cAAc,CAAC,OAAiB;IAC3C,OAAO,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,MAAc,EAAE,EAAE,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED,KAAK,UAAU,aAAa,CAAC,MAAc;IACvC,IAAI,MAAM,KAAK,MAAM,EAAE,CAAC;QACpB,OAAO,IAAA,oCAA6B,GAAE,CAAC;IAC3C,CAAC;IACD,IAAI,MAAM,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QACvB,OAAO,MAAM,CAAC;IAClB,CAAC;IACD,MAAM,SAAS,GAAG,MAAM,IAAA,mBAAY,GAAE,CAAC;IACvC,OAAO,GAAG,MAAM,IAAI,SAAS,0BAA0B,CAAC;AAC5D,CAAC;AAED,mDAAmD;AACnD,MAAM,eAAe;IACjB,KAAK,CAAU;IACf,GAAG,CAAU;CAChB","sourcesContent":["import { GoogleAuth, OAuth2Client } from 'google-auth-library';\nimport { LogManager } from '@webpieces/core-util';\nimport { toError } from '@webpieces/core-util';\nimport { isOnGcp } from './metadata';\nimport { getProjectId, getRegion, getRuntimeServiceAccountEmail } from './urls';\n\nconst log = LogManager.getLogger('gcp-identity-oidc');\n\n/**\n * Prefix marking a deterministic local (off-GCP) OIDC token. Real Google-signed\n * tokens never start with this, so verify can tell them apart. This is what keeps\n * the @AuthOidc code path fully exercised in tests without any GCP round-trip.\n */\nconst DEV_TOKEN_PREFIX = 'dev-oidc.';\n\nconst reusableAuth = new GoogleAuth();\nconst reusableVerifier = new OAuth2Client();\n\n/** Outcome of verifying an inbound OIDC token against an allow-list of callers. */\nexport class OidcVerifyResult {\n /** True when the token is valid AND its caller SA is allowed. */\n ok: boolean;\n /** The verified caller email (present when ok). */\n email?: string;\n /** Human-readable reason when not ok (for logs / 401 message). */\n reason?: string;\n\n constructor(ok: boolean, email?: string, reason?: string) {\n this.ok = ok;\n this.email = email;\n this.reason = reason;\n }\n}\n\n/**\n * Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as\n * this service's runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token\n * that verifyOidcFromCallers accepts locally.\n */\nexport async function mintIdToken(audience: string): Promise<string> {\n if (!(await isOnGcp())) {\n const email = await getRuntimeServiceAccountEmail();\n return makeDevToken(email, audience);\n }\n const client = await reusableAuth.getIdTokenClient(audience);\n return client.idTokenProvider.fetchIdToken(audience);\n}\n\n/**\n * Verify an inbound OIDC token and require its caller SA to be in the allow-list.\n * Audience is deliberately NOT gated (mirrors the platform's cross-service model —\n * the callee's own `aud` passes). Never throws — an invalid token or a disallowed\n * caller comes back as `ok:false` so callers map it to a 401 without a try/catch.\n *\n * `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →\n * `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.\n */\nexport async function verifyOidcFromCallers(\n idToken: string,\n callers: string[],\n): Promise<OidcVerifyResult> {\n const email = await extractVerifiedEmail(idToken);\n if (!email) {\n return new OidcVerifyResult(false, undefined, 'token invalid or missing email claim');\n }\n // EMPTY allow-list = TRUST THE EDGE: accept any genuine Google-signed OIDC caller, because a\n // PRIVATE Cloud Run service's edge already gated WHO via run.invoker IAM (managed in terraform,\n // one source of truth). Warn loudly (once) if the service is actually PUBLIC — then the edge is\n // NOT filtering callers and this is insecure. Non-empty list = explicit app-level allow-list.\n if (callers.length === 0) {\n await warnIfPublicOnce();\n return new OidcVerifyResult(true, email);\n }\n const allowed = await resolveCallers(callers);\n if (!allowed.includes(email)) {\n return new OidcVerifyResult(\n false,\n email,\n `caller '${email}' is not in the allow-list [${allowed.join(', ')}]`,\n );\n }\n return new OidcVerifyResult(true, email);\n}\n\nlet publicPostureChecked = false;\n\n/**\n * @AuthOidc() (trust-the-edge) is only secure when the Cloud Run service is PRIVATE — the edge\n * enforces run.invoker. If it is actually PUBLIC, warn LOUDLY (once): we still admit only\n * Google-signed callers, but the edge is not filtering WHO. Never fails — this is advisory.\n */\nasync function warnIfPublicOnce(): Promise<void> {\n if (publicPostureChecked) {\n return;\n }\n publicPostureChecked = true; // attempt at most once, even if the check itself errors\n if (!(await isOnGcp())) {\n return; // off-GCP: there is no Cloud Run edge; public/private does not apply.\n }\n if (await isServicePublic()) {\n log.error(\n 'This endpoint is currently public but marked @AuthOidc which requires the service to be ' +\n 'private. You are currently running in a very insecure mode; however, we are only allowing ' +\n 'google-signed callers in. The edge will validate callers are allowed in (make the Cloud Run ' +\n 'service private — remove the allUsers run.invoker binding) to reduce your attack surface.',\n );\n }\n}\n\n/**\n * True when THIS Cloud Run service grants run.invoker to allUsers/allAuthenticatedUsers (public).\n * Reads the service's OWN IAM policy via the Run Admin API (needs run.services.getIamPolicy on the\n * runtime SA). On any failure we cannot tell → false (no false alarm).\n */\nasync function isServicePublic(): Promise<boolean> {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a failed self-IAM read just means \"posture unknown\" → no warning\n try {\n const [project, region] = await Promise.all([getProjectId(), getRegion()]);\n const service = process.env['K_SERVICE'] ?? '';\n const url = `https://run.googleapis.com/v2/projects/${project}/locations/${region}/services/${service}:getIamPolicy`;\n const client = await reusableAuth.getClient();\n const res = await client.request<IamPolicy>({ url: url });\n return (res.data.bindings ?? []).some(\n (binding: IamBinding) =>\n binding.role === 'roles/run.invoker' &&\n (binding.members ?? []).some((m: string) => m === 'allUsers' || m === 'allAuthenticatedUsers'),\n );\n } catch (err: unknown) {\n const error = toError(err);\n log.debug(`Could not read own Cloud Run IAM policy (need run.services.getIamPolicy): ${error.message}`);\n return false;\n }\n}\n\n/** Minimal shape of a Cloud Run getIamPolicy response. */\ninterface IamBinding {\n role: string;\n members?: string[];\n}\ninterface IamPolicy {\n bindings?: IamBinding[];\n}\n\nfunction makeDevToken(email: string, audience: string): string {\n const payload = JSON.stringify({ email: email, aud: audience });\n return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');\n}\n\nasync function extractVerifiedEmail(idToken: string): Promise<string | undefined> {\n if (idToken.startsWith(DEV_TOKEN_PREFIX)) {\n return decodeDevTokenEmail(idToken);\n }\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a genuinely unverifiable token is unauthenticated (→ undefined → 401); infra failures are re-thrown below\n try {\n const ticket = await reusableVerifier.verifyIdToken({ idToken: idToken });\n const payload = ticket.getPayload();\n return payload?.email ?? undefined;\n } catch (err: unknown) {\n const error = toError(err);\n if (isInfrastructureError(error)) {\n // A network/system failure fetching Google's certs (or a bug) is NOT an auth failure.\n // Do NOT mask it as a 401 — re-throw so it surfaces as a 5xx (alertable / retryable)\n // instead of looking like a caller sent a bad token.\n throw error;\n }\n // Bad signature / expired / malformed token → genuinely unauthenticated → 401 upstream.\n log.debug(`OIDC token rejected (unauthenticated): ${error.message}`);\n return undefined;\n }\n}\n\n/**\n * True when an error thrown while verifying a token is INFRASTRUCTURE (network/system), not a bad\n * token — e.g. the metadata/cert fetch failed. These must NOT be swallowed as a 401 (that would\n * both mislead the caller and hide real outages/bugs behind a client-error status).\n */\nfunction isInfrastructureError(error: Error): boolean {\n const code = (error as ErrorWithCode).code;\n const networkCodes = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNRESET', 'EAI_AGAIN'];\n return (\n (code !== undefined && networkCodes.includes(code)) ||\n error.name === 'GaxiosError' ||\n error.name === 'FetchError'\n );\n}\n\nfunction decodeDevTokenEmail(idToken: string): string | undefined {\n const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);\n const json = Buffer.from(encoded, 'base64url').toString('utf8');\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed dev token is just unauthenticated → undefined\n try {\n const parsed = JSON.parse(json) as DevTokenPayload;\n return parsed.email ?? undefined;\n } catch (err: unknown) {\n const error = toError(err);\n log.debug(`dev-oidc token decode failed: ${error.message}`);\n return undefined;\n }\n}\n\nasync function resolveCallers(callers: string[]): Promise<string[]> {\n return Promise.all(callers.map((caller: string) => resolveCaller(caller)));\n}\n\nasync function resolveCaller(caller: string): Promise<string> {\n if (caller === 'self') {\n return getRuntimeServiceAccountEmail();\n }\n if (caller.includes('@')) {\n return caller;\n }\n const projectId = await getProjectId();\n return `${caller}@${projectId}.iam.gserviceaccount.com`;\n}\n\n/** Shape of the decoded dev-oidc token payload. */\nclass DevTokenPayload {\n email!: string;\n aud!: string;\n}\n\n/** A thrown error carrying an optional Node/system error code (e.g. 'ETIMEDOUT'). */\ninterface ErrorWithCode {\n code?: string;\n}\n"]}