@webpieces/gcp-identity 0.3.353 → 0.3.355
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 +6 -3
- package/src/index.d.ts +1 -1
- package/src/index.js +2 -3
- package/src/index.js.map +1 -1
- package/src/oidc.d.ts +50 -13
- package/src/oidc.js +161 -144
- package/src/oidc.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/gcp-identity",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.355",
|
|
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,8 +23,11 @@
|
|
|
23
23
|
"access": "public"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@webpieces/core-
|
|
26
|
+
"@webpieces/core-context": "0.3.355",
|
|
27
|
+
"@webpieces/core-util": "0.3.355",
|
|
27
28
|
"google-auth-library": "9.15.1",
|
|
28
|
-
"gcp-metadata": "6.1.1"
|
|
29
|
+
"gcp-metadata": "6.1.1",
|
|
30
|
+
"inversify": "7.10.4",
|
|
31
|
+
"reflect-metadata": "0.2.2"
|
|
29
32
|
}
|
|
30
33
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export { isOnGcp } from './metadata';
|
|
12
12
|
export { getServiceName, getProjectId, getRegion, getRuntimeServiceAccountEmail, getSelfCloudRunUrl, getCloudRunUrl, resolveTargetUrl, LOCAL_SERVICE_ACCOUNT_EMAIL, } from './urls';
|
|
13
|
-
export {
|
|
13
|
+
export { GcpOidc, OidcVerifyResult } from './oidc';
|
package/src/index.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* via an app-bound AuthConfig) and the RPC + Cloud Tasks clients.
|
|
11
11
|
*/
|
|
12
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
-
exports.OidcVerifyResult = exports.
|
|
13
|
+
exports.OidcVerifyResult = exports.GcpOidc = exports.LOCAL_SERVICE_ACCOUNT_EMAIL = exports.resolveTargetUrl = exports.getCloudRunUrl = exports.getSelfCloudRunUrl = exports.getRuntimeServiceAccountEmail = exports.getRegion = exports.getProjectId = exports.getServiceName = exports.isOnGcp = void 0;
|
|
14
14
|
var metadata_1 = require("./metadata");
|
|
15
15
|
Object.defineProperty(exports, "isOnGcp", { enumerable: true, get: function () { return metadata_1.isOnGcp; } });
|
|
16
16
|
var urls_1 = require("./urls");
|
|
@@ -23,7 +23,6 @@ Object.defineProperty(exports, "getCloudRunUrl", { enumerable: true, get: functi
|
|
|
23
23
|
Object.defineProperty(exports, "resolveTargetUrl", { enumerable: true, get: function () { return urls_1.resolveTargetUrl; } });
|
|
24
24
|
Object.defineProperty(exports, "LOCAL_SERVICE_ACCOUNT_EMAIL", { enumerable: true, get: function () { return urls_1.LOCAL_SERVICE_ACCOUNT_EMAIL; } });
|
|
25
25
|
var oidc_1 = require("./oidc");
|
|
26
|
-
Object.defineProperty(exports, "
|
|
27
|
-
Object.defineProperty(exports, "verifyOidcFromCallers", { enumerable: true, get: function () { return oidc_1.verifyOidcFromCallers; } });
|
|
26
|
+
Object.defineProperty(exports, "GcpOidc", { enumerable: true, get: function () { return oidc_1.GcpOidc; } });
|
|
28
27
|
Object.defineProperty(exports, "OidcVerifyResult", { enumerable: true, get: function () { return oidc_1.OidcVerifyResult; } });
|
|
29
28
|
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;;AAEH,uCAAqC;AAA5B,mGAAA,OAAO,OAAA;AAChB,+BASgB;AARZ,sGAAA,cAAc,OAAA;AACd,oGAAA,YAAY,OAAA;AACZ,iGAAA,SAAS,OAAA;AACT,qHAAA,6BAA6B,OAAA;AAC7B,0GAAA,kBAAkB,OAAA;AAClB,sGAAA,cAAc,OAAA;AACd,wGAAA,gBAAgB,OAAA;AAChB,mHAAA,2BAA2B,OAAA;AAE/B,+
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;;AAEH,uCAAqC;AAA5B,mGAAA,OAAO,OAAA;AAChB,+BASgB;AARZ,sGAAA,cAAc,OAAA;AACd,oGAAA,YAAY,OAAA;AACZ,iGAAA,SAAS,OAAA;AACT,qHAAA,6BAA6B,OAAA;AAC7B,0GAAA,kBAAkB,OAAA;AAClB,sGAAA,cAAc,OAAA;AACd,wGAAA,gBAAgB,OAAA;AAChB,mHAAA,2BAA2B,OAAA;AAE/B,+BAAmD;AAA1C,+FAAA,OAAO,OAAA;AAAE,wGAAA,gBAAgB,OAAA","sourcesContent":["/**\n * @webpieces/gcp-identity\n *\n * GCP runtime identity (Node-only). Metadata, Cloud Run URLs, and OIDC mint/verify,\n * all read from the metadata server / ADC at runtime with deterministic localhost\n * fallbacks off-GCP so local dev and tests never touch GCP.\n *\n * Underpins the @AuthOidc service-to-service auth mode (enforced by the framework AuthFilter\n * via an app-bound AuthConfig) and the RPC + Cloud Tasks clients.\n */\n\nexport { isOnGcp } from './metadata';\nexport {\n getServiceName,\n getProjectId,\n getRegion,\n getRuntimeServiceAccountEmail,\n getSelfCloudRunUrl,\n getCloudRunUrl,\n resolveTargetUrl,\n LOCAL_SERVICE_ACCOUNT_EMAIL,\n} from './urls';\nexport { GcpOidc, OidcVerifyResult } from './oidc';\n"]}
|
package/src/oidc.d.ts
CHANGED
|
@@ -9,18 +9,55 @@ export declare class OidcVerifyResult {
|
|
|
9
9
|
constructor(ok: boolean, email?: string, reason?: string);
|
|
10
10
|
}
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
|
|
16
|
-
export declare function mintIdToken(audience: string): Promise<string>;
|
|
17
|
-
/**
|
|
18
|
-
* Verify an inbound OIDC token and require its caller SA to be in the allow-list.
|
|
19
|
-
* Audience is deliberately NOT gated (mirrors the platform's cross-service model —
|
|
20
|
-
* the callee's own `aud` passes). Never throws — an invalid token or a disallowed
|
|
21
|
-
* caller comes back as `ok:false` so callers map it to a 401 without a try/catch.
|
|
12
|
+
* GcpOidc - Google OIDC service-to-service tokens: MINT an outbound token as this service's runtime
|
|
13
|
+
* SA, and VERIFY an inbound token against a caller allow-list. Injected by type (@provideSingleton /
|
|
14
|
+
* inject-by-type) into the RPC + Cloud Tasks clients (mint) and the framework auth verifier (verify),
|
|
15
|
+
* so no DI-managed code calls gcp-identity as a free function.
|
|
22
16
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
17
|
+
* Holds the reusable google-auth-library clients as instance state (one per process, since this is a
|
|
18
|
+
* framework singleton) plus the "warned about public posture once" flag.
|
|
25
19
|
*/
|
|
26
|
-
export declare
|
|
20
|
+
export declare class GcpOidc {
|
|
21
|
+
private readonly auth;
|
|
22
|
+
private readonly verifier;
|
|
23
|
+
private publicPostureChecked;
|
|
24
|
+
/**
|
|
25
|
+
* Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as this service's
|
|
26
|
+
* runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token that {@link verifyFromCallers}
|
|
27
|
+
* accepts locally.
|
|
28
|
+
*/
|
|
29
|
+
mintIdToken(audience: string): Promise<string>;
|
|
30
|
+
/**
|
|
31
|
+
* Verify an inbound OIDC token and require its caller SA to be in the allow-list. Audience is
|
|
32
|
+
* deliberately NOT gated (mirrors the platform's cross-service model — the callee's own `aud`
|
|
33
|
+
* passes). Never throws — an invalid token or a disallowed caller comes back as `ok:false` so
|
|
34
|
+
* callers map it to a 401 without a try/catch.
|
|
35
|
+
*
|
|
36
|
+
* `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →
|
|
37
|
+
* `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.
|
|
38
|
+
*/
|
|
39
|
+
verifyFromCallers(idToken: string, callers: string[]): Promise<OidcVerifyResult>;
|
|
40
|
+
/**
|
|
41
|
+
* @AuthOidc() (trust-the-edge) is only secure when the Cloud Run service is PRIVATE — the edge
|
|
42
|
+
* enforces run.invoker. If it is actually PUBLIC, warn LOUDLY (once): we still admit only
|
|
43
|
+
* Google-signed callers, but the edge is not filtering WHO. Never fails — this is advisory.
|
|
44
|
+
*/
|
|
45
|
+
private warnIfPublicOnce;
|
|
46
|
+
/**
|
|
47
|
+
* True when THIS Cloud Run service grants run.invoker to allUsers/allAuthenticatedUsers (public).
|
|
48
|
+
* Reads the service's OWN IAM policy via the Run Admin API (needs run.services.getIamPolicy on the
|
|
49
|
+
* runtime SA). On any failure we cannot tell → false (no false alarm).
|
|
50
|
+
*/
|
|
51
|
+
private isServicePublic;
|
|
52
|
+
private makeDevToken;
|
|
53
|
+
private extractVerifiedEmail;
|
|
54
|
+
/**
|
|
55
|
+
* True when an error thrown while verifying a token is INFRASTRUCTURE (network/system), not a bad
|
|
56
|
+
* token — e.g. the metadata/cert fetch failed. These must NOT be swallowed as a 401 (that would
|
|
57
|
+
* both mislead the caller and hide real outages/bugs behind a client-error status).
|
|
58
|
+
*/
|
|
59
|
+
private isInfrastructureError;
|
|
60
|
+
private decodeDevTokenEmail;
|
|
61
|
+
private resolveCallers;
|
|
62
|
+
private resolveCaller;
|
|
63
|
+
}
|
package/src/oidc.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OidcVerifyResult = void 0;
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
exports.GcpOidc = exports.OidcVerifyResult = void 0;
|
|
4
|
+
const tslib_1 = require("tslib");
|
|
5
|
+
const inversify_1 = require("inversify");
|
|
6
6
|
const google_auth_library_1 = require("google-auth-library");
|
|
7
|
+
const core_context_1 = require("@webpieces/core-context");
|
|
7
8
|
const core_util_1 = require("@webpieces/core-util");
|
|
8
9
|
const core_util_2 = require("@webpieces/core-util");
|
|
9
10
|
const metadata_1 = require("./metadata");
|
|
@@ -15,8 +16,6 @@ const log = core_util_1.LogManager.getLogger('gcp-identity-oidc');
|
|
|
15
16
|
* the @AuthOidc code path fully exercised in tests without any GCP round-trip.
|
|
16
17
|
*/
|
|
17
18
|
const DEV_TOKEN_PREFIX = 'dev-oidc.';
|
|
18
|
-
const reusableAuth = new google_auth_library_1.GoogleAuth();
|
|
19
|
-
const reusableVerifier = new google_auth_library_1.OAuth2Client();
|
|
20
19
|
/** Outcome of verifying an inbound OIDC token against an allow-list of callers. */
|
|
21
20
|
class OidcVerifyResult {
|
|
22
21
|
/** True when the token is valid AND its caller SA is allowed. */
|
|
@@ -33,155 +32,173 @@ class OidcVerifyResult {
|
|
|
33
32
|
}
|
|
34
33
|
exports.OidcVerifyResult = OidcVerifyResult;
|
|
35
34
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
async function mintIdToken(audience) {
|
|
41
|
-
if (!(await (0, metadata_1.isOnGcp)())) {
|
|
42
|
-
const email = await (0, urls_1.getRuntimeServiceAccountEmail)();
|
|
43
|
-
return makeDevToken(email, audience);
|
|
44
|
-
}
|
|
45
|
-
const client = await reusableAuth.getIdTokenClient(audience);
|
|
46
|
-
return client.idTokenProvider.fetchIdToken(audience);
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* Verify an inbound OIDC token and require its caller SA to be in the allow-list.
|
|
50
|
-
* Audience is deliberately NOT gated (mirrors the platform's cross-service model —
|
|
51
|
-
* the callee's own `aud` passes). Never throws — an invalid token or a disallowed
|
|
52
|
-
* caller comes back as `ok:false` so callers map it to a 401 without a try/catch.
|
|
35
|
+
* GcpOidc - Google OIDC service-to-service tokens: MINT an outbound token as this service's runtime
|
|
36
|
+
* SA, and VERIFY an inbound token against a caller allow-list. Injected by type (@provideSingleton /
|
|
37
|
+
* inject-by-type) into the RPC + Cloud Tasks clients (mint) and the framework auth verifier (verify),
|
|
38
|
+
* so no DI-managed code calls gcp-identity as a free function.
|
|
53
39
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
40
|
+
* Holds the reusable google-auth-library clients as instance state (one per process, since this is a
|
|
41
|
+
* framework singleton) plus the "warned about public posture once" flag.
|
|
56
42
|
*/
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
await
|
|
43
|
+
let GcpOidc = class GcpOidc {
|
|
44
|
+
auth = new google_auth_library_1.GoogleAuth();
|
|
45
|
+
verifier = new google_auth_library_1.OAuth2Client();
|
|
46
|
+
publicPostureChecked = false;
|
|
47
|
+
/**
|
|
48
|
+
* Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as this service's
|
|
49
|
+
* runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token that {@link verifyFromCallers}
|
|
50
|
+
* accepts locally.
|
|
51
|
+
*/
|
|
52
|
+
async mintIdToken(audience) {
|
|
53
|
+
if (!(await (0, metadata_1.isOnGcp)())) {
|
|
54
|
+
const email = await (0, urls_1.getRuntimeServiceAccountEmail)();
|
|
55
|
+
return this.makeDevToken(email, audience);
|
|
56
|
+
}
|
|
57
|
+
const client = await this.auth.getIdTokenClient(audience);
|
|
58
|
+
return client.idTokenProvider.fetchIdToken(audience);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Verify an inbound OIDC token and require its caller SA to be in the allow-list. Audience is
|
|
62
|
+
* deliberately NOT gated (mirrors the platform's cross-service model — the callee's own `aud`
|
|
63
|
+
* passes). Never throws — an invalid token or a disallowed caller comes back as `ok:false` so
|
|
64
|
+
* callers map it to a 401 without a try/catch.
|
|
65
|
+
*
|
|
66
|
+
* `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →
|
|
67
|
+
* `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.
|
|
68
|
+
*/
|
|
69
|
+
async verifyFromCallers(idToken, callers) {
|
|
70
|
+
const email = await this.extractVerifiedEmail(idToken);
|
|
71
|
+
if (!email) {
|
|
72
|
+
return new OidcVerifyResult(false, undefined, 'token invalid or missing email claim');
|
|
73
|
+
}
|
|
74
|
+
// EMPTY allow-list = TRUST THE EDGE: accept any genuine Google-signed OIDC caller, because a
|
|
75
|
+
// PRIVATE Cloud Run service's edge already gated WHO via run.invoker IAM (managed in terraform,
|
|
76
|
+
// one source of truth). Warn loudly (once) if the service is actually PUBLIC — then the edge is
|
|
77
|
+
// NOT filtering callers and this is insecure. Non-empty list = explicit app-level allow-list.
|
|
78
|
+
if (callers.length === 0) {
|
|
79
|
+
await this.warnIfPublicOnce();
|
|
80
|
+
return new OidcVerifyResult(true, email);
|
|
81
|
+
}
|
|
82
|
+
const allowed = await this.resolveCallers(callers);
|
|
83
|
+
if (!allowed.includes(email)) {
|
|
84
|
+
return new OidcVerifyResult(false, email, `caller '${email}' is not in the allow-list [${allowed.join(', ')}]`);
|
|
85
|
+
}
|
|
68
86
|
return new OidcVerifyResult(true, email);
|
|
69
87
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
}
|
|
119
|
-
function makeDevToken(email, audience) {
|
|
120
|
-
const payload = JSON.stringify({ email: email, aud: audience });
|
|
121
|
-
return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
|
|
122
|
-
}
|
|
123
|
-
async function extractVerifiedEmail(idToken) {
|
|
124
|
-
if (idToken.startsWith(DEV_TOKEN_PREFIX)) {
|
|
125
|
-
return decodeDevTokenEmail(idToken);
|
|
126
|
-
}
|
|
127
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a genuinely unverifiable token is unauthenticated (→ undefined → 401); infra failures are re-thrown below
|
|
128
|
-
try {
|
|
129
|
-
const ticket = await reusableVerifier.verifyIdToken({ idToken: idToken });
|
|
130
|
-
const payload = ticket.getPayload();
|
|
131
|
-
return payload?.email ?? undefined;
|
|
88
|
+
/**
|
|
89
|
+
* @AuthOidc() (trust-the-edge) is only secure when the Cloud Run service is PRIVATE — the edge
|
|
90
|
+
* enforces run.invoker. If it is actually PUBLIC, warn LOUDLY (once): we still admit only
|
|
91
|
+
* Google-signed callers, but the edge is not filtering WHO. Never fails — this is advisory.
|
|
92
|
+
*/
|
|
93
|
+
async warnIfPublicOnce() {
|
|
94
|
+
if (this.publicPostureChecked) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.publicPostureChecked = true; // attempt at most once, even if the check itself errors
|
|
98
|
+
if (!(await (0, metadata_1.isOnGcp)())) {
|
|
99
|
+
return; // off-GCP: there is no Cloud Run edge; public/private does not apply.
|
|
100
|
+
}
|
|
101
|
+
if (await this.isServicePublic()) {
|
|
102
|
+
log.error('This endpoint is currently public but marked @AuthOidc which requires the service to be ' +
|
|
103
|
+
'private. You are currently running in a very insecure mode; however, we are only allowing ' +
|
|
104
|
+
'google-signed callers in. The edge will validate callers are allowed in (make the Cloud Run ' +
|
|
105
|
+
'service private — remove the allUsers run.invoker binding) to reduce your attack surface.');
|
|
106
|
+
}
|
|
132
107
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
108
|
+
/**
|
|
109
|
+
* True when THIS Cloud Run service grants run.invoker to allUsers/allAuthenticatedUsers (public).
|
|
110
|
+
* Reads the service's OWN IAM policy via the Run Admin API (needs run.services.getIamPolicy on the
|
|
111
|
+
* runtime SA). On any failure we cannot tell → false (no false alarm).
|
|
112
|
+
*/
|
|
113
|
+
async isServicePublic() {
|
|
114
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a failed self-IAM read just means "posture unknown" → no warning
|
|
115
|
+
try {
|
|
116
|
+
const [project, region] = await Promise.all([(0, urls_1.getProjectId)(), (0, urls_1.getRegion)()]);
|
|
117
|
+
const service = process.env['K_SERVICE'] ?? '';
|
|
118
|
+
const url = `https://run.googleapis.com/v2/projects/${project}/locations/${region}/services/${service}:getIamPolicy`;
|
|
119
|
+
const client = await this.auth.getClient();
|
|
120
|
+
const res = await client.request({ url: url });
|
|
121
|
+
return (res.data.bindings ?? []).some((binding) => binding.role === 'roles/run.invoker' &&
|
|
122
|
+
(binding.members ?? []).some((m) => m === 'allUsers' || m === 'allAuthenticatedUsers'));
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
const error = (0, core_util_2.toError)(err);
|
|
126
|
+
log.debug(`Could not read own Cloud Run IAM policy (need run.services.getIamPolicy): ${error.message}`);
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
144
129
|
}
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
}
|
|
158
|
-
function decodeDevTokenEmail(idToken) {
|
|
159
|
-
const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);
|
|
160
|
-
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
161
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed dev token is just unauthenticated → undefined
|
|
162
|
-
try {
|
|
163
|
-
const parsed = JSON.parse(json);
|
|
164
|
-
return parsed.email ?? undefined;
|
|
130
|
+
makeDevToken(email, audience) {
|
|
131
|
+
const payload = JSON.stringify({ email: email, aud: audience });
|
|
132
|
+
return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
|
|
165
133
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
134
|
+
async extractVerifiedEmail(idToken) {
|
|
135
|
+
if (idToken.startsWith(DEV_TOKEN_PREFIX)) {
|
|
136
|
+
return this.decodeDevTokenEmail(idToken);
|
|
137
|
+
}
|
|
138
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a genuinely unverifiable token is unauthenticated (→ undefined → 401); infra failures are re-thrown below
|
|
139
|
+
try {
|
|
140
|
+
const ticket = await this.verifier.verifyIdToken({ idToken: idToken });
|
|
141
|
+
const payload = ticket.getPayload();
|
|
142
|
+
return payload?.email ?? undefined;
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
const error = (0, core_util_2.toError)(err);
|
|
146
|
+
if (this.isInfrastructureError(error)) {
|
|
147
|
+
// A network/system failure fetching Google's certs (or a bug) is NOT an auth failure.
|
|
148
|
+
// Do NOT mask it as a 401 — re-throw so it surfaces as a 5xx (alertable / retryable)
|
|
149
|
+
// instead of looking like a caller sent a bad token.
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
// Bad signature / expired / malformed token → genuinely unauthenticated → 401 upstream.
|
|
153
|
+
log.debug(`OIDC token rejected (unauthenticated): ${error.message}`);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
170
156
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
157
|
+
/**
|
|
158
|
+
* True when an error thrown while verifying a token is INFRASTRUCTURE (network/system), not a bad
|
|
159
|
+
* token — e.g. the metadata/cert fetch failed. These must NOT be swallowed as a 401 (that would
|
|
160
|
+
* both mislead the caller and hide real outages/bugs behind a client-error status).
|
|
161
|
+
*/
|
|
162
|
+
isInfrastructureError(error) {
|
|
163
|
+
const code = error.code;
|
|
164
|
+
const networkCodes = ['ENOTFOUND', 'ETIMEDOUT', 'ECONNREFUSED', 'ECONNRESET', 'EAI_AGAIN'];
|
|
165
|
+
return ((code !== undefined && networkCodes.includes(code)) ||
|
|
166
|
+
error.name === 'GaxiosError' ||
|
|
167
|
+
error.name === 'FetchError');
|
|
168
|
+
}
|
|
169
|
+
decodeDevTokenEmail(idToken) {
|
|
170
|
+
const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);
|
|
171
|
+
const json = Buffer.from(encoded, 'base64url').toString('utf8');
|
|
172
|
+
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed dev token is just unauthenticated → undefined
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(json);
|
|
175
|
+
return parsed.email ?? undefined;
|
|
176
|
+
}
|
|
177
|
+
catch (err) {
|
|
178
|
+
const error = (0, core_util_2.toError)(err);
|
|
179
|
+
log.debug(`dev-oidc token decode failed: ${error.message}`);
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
178
182
|
}
|
|
179
|
-
|
|
180
|
-
return caller;
|
|
183
|
+
async resolveCallers(callers) {
|
|
184
|
+
return Promise.all(callers.map((caller) => this.resolveCaller(caller)));
|
|
181
185
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
186
|
+
async resolveCaller(caller) {
|
|
187
|
+
if (caller === 'self') {
|
|
188
|
+
return (0, urls_1.getRuntimeServiceAccountEmail)();
|
|
189
|
+
}
|
|
190
|
+
if (caller.includes('@')) {
|
|
191
|
+
return caller;
|
|
192
|
+
}
|
|
193
|
+
const projectId = await (0, urls_1.getProjectId)();
|
|
194
|
+
return `${caller}@${projectId}.iam.gserviceaccount.com`;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
exports.GcpOidc = GcpOidc;
|
|
198
|
+
exports.GcpOidc = GcpOidc = tslib_1.__decorate([
|
|
199
|
+
(0, core_context_1.provideFrameworkSingleton)(),
|
|
200
|
+
(0, inversify_1.injectable)()
|
|
201
|
+
], GcpOidc);
|
|
185
202
|
/** Shape of the decoded dev-oidc token payload. */
|
|
186
203
|
class DevTokenPayload {
|
|
187
204
|
email;
|
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, 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"]}
|
|
1
|
+
{"version":3,"file":"oidc.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/oidc.ts"],"names":[],"mappings":"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","sourcesContent":["import { injectable } from 'inversify';\nimport { GoogleAuth, OAuth2Client } from 'google-auth-library';\nimport { provideFrameworkSingleton } from '@webpieces/core-context';\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\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 * GcpOidc - Google OIDC service-to-service tokens: MINT an outbound token as this service's runtime\n * SA, and VERIFY an inbound token against a caller allow-list. Injected by type (@provideSingleton /\n * inject-by-type) into the RPC + Cloud Tasks clients (mint) and the framework auth verifier (verify),\n * so no DI-managed code calls gcp-identity as a free function.\n *\n * Holds the reusable google-auth-library clients as instance state (one per process, since this is a\n * framework singleton) plus the \"warned about public posture once\" flag.\n */\n@provideFrameworkSingleton()\n@injectable()\nexport class GcpOidc {\n private readonly auth = new GoogleAuth();\n private readonly verifier = new OAuth2Client();\n private publicPostureChecked = false;\n\n /**\n * Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as this service's\n * runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token that {@link verifyFromCallers}\n * accepts locally.\n */\n async mintIdToken(audience: string): Promise<string> {\n if (!(await isOnGcp())) {\n const email = await getRuntimeServiceAccountEmail();\n return this.makeDevToken(email, audience);\n }\n const client = await this.auth.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. Audience is\n * deliberately NOT gated (mirrors the platform's cross-service model — the callee's own `aud`\n * passes). Never throws — an invalid token or a disallowed caller comes back as `ok:false` so\n * 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 */\n async verifyFromCallers(idToken: string, callers: string[]): Promise<OidcVerifyResult> {\n const email = await this.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 this.warnIfPublicOnce();\n return new OidcVerifyResult(true, email);\n }\n const allowed = await this.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\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 */\n private async warnIfPublicOnce(): Promise<void> {\n if (this.publicPostureChecked) {\n return;\n }\n this.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 this.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 */\n private async 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 this.auth.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 private 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\n private async extractVerifiedEmail(idToken: string): Promise<string | undefined> {\n if (idToken.startsWith(DEV_TOKEN_PREFIX)) {\n return this.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 this.verifier.verifyIdToken({ idToken: idToken });\n const payload = ticket.getPayload();\n return payload?.email ?? undefined;\n } catch (err: unknown) {\n const error = toError(err);\n if (this.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 */\n private 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\n private 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\n private async resolveCallers(callers: string[]): Promise<string[]> {\n return Promise.all(callers.map((caller: string) => this.resolveCaller(caller)));\n }\n\n private async 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\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\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"]}
|