@webpieces/gcp-identity 0.3.269

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/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @webpieces/gcp-identity
2
+
3
+ GCP runtime identity for webpieces services (Node-only). Everything is read from the
4
+ GCP metadata server / ADC at runtime — nothing is configured. Off-GCP (local dev,
5
+ tests) every call falls back to a deterministic localhost value so no GCP is needed.
6
+
7
+ - `getProjectId()` / `getNumericProjectId()` / `getRegion()` — cached metadata lookups
8
+ - `getServiceName()` — logical name from `K_SERVICE` (strips a `tf-` prefix), else `'local'`
9
+ - `getSelfCloudRunUrl()` / `getCloudRunUrl(name)` — deterministic Cloud Run URLs
10
+ - `getRuntimeServiceAccountEmail()` — the SA this process runs as
11
+ - `mintIdToken(audience)` — Google-signed OIDC ID token (a `dev-oidc.*` token off-GCP)
12
+ - `verifyOidcFromCallers(idToken, callers)` — verify + allow-list the caller SA
13
+
14
+ Underpins the `@AuthOidc` service-to-service auth mode enforced by `ServiceAuthFilter`
15
+ and used by `@webpieces/http-client` (RPC) and `@webpieces/cloudtasks-client`.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@webpieces/gcp-identity",
3
+ "version": "0.3.269",
4
+ "description": "GCP runtime identity: project/region metadata, Cloud Run URLs, OIDC mint/verify",
5
+ "type": "commonjs",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "author": "Dean Hiller",
9
+ "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "https://github.com/deanhiller/webpieces-ts.git",
13
+ "directory": "packages/cloud/gcp-identity"
14
+ },
15
+ "keywords": [
16
+ "webpieces",
17
+ "gcp",
18
+ "oidc",
19
+ "cloud-run",
20
+ "identity"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "@webpieces/core-util": "0.3.269",
27
+ "google-auth-library": "9.15.1",
28
+ "gcp-metadata": "6.1.1"
29
+ }
30
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @webpieces/gcp-identity
3
+ *
4
+ * GCP runtime identity (Node-only). Metadata, Cloud Run URLs, and OIDC mint/verify,
5
+ * all read from the metadata server / ADC at runtime with deterministic localhost
6
+ * fallbacks off-GCP so local dev and tests never touch GCP.
7
+ *
8
+ * Underpins the @AuthOidc service-to-service auth mode (ServiceAuthFilter) and the
9
+ * RPC (@webpieces/http-client) + Cloud Tasks (@webpieces/cloudtasks-client) clients.
10
+ */
11
+ export { isOnGcp } from './metadata';
12
+ export { getServiceName, getProjectId, getRegion, getRuntimeServiceAccountEmail, getSelfCloudRunUrl, getCloudRunUrl, LOCAL_SERVICE_ACCOUNT_EMAIL, } from './urls';
13
+ export { mintIdToken, verifyOidcFromCallers, OidcVerifyResult } from './oidc';
package/src/index.js ADDED
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ /**
3
+ * @webpieces/gcp-identity
4
+ *
5
+ * GCP runtime identity (Node-only). Metadata, Cloud Run URLs, and OIDC mint/verify,
6
+ * all read from the metadata server / ADC at runtime with deterministic localhost
7
+ * fallbacks off-GCP so local dev and tests never touch GCP.
8
+ *
9
+ * Underpins the @AuthOidc service-to-service auth mode (ServiceAuthFilter) and the
10
+ * RPC (@webpieces/http-client) + Cloud Tasks (@webpieces/cloudtasks-client) clients.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.OidcVerifyResult = exports.verifyOidcFromCallers = exports.mintIdToken = exports.LOCAL_SERVICE_ACCOUNT_EMAIL = exports.getCloudRunUrl = exports.getSelfCloudRunUrl = exports.getRuntimeServiceAccountEmail = exports.getRegion = exports.getProjectId = exports.getServiceName = exports.isOnGcp = void 0;
14
+ var metadata_1 = require("./metadata");
15
+ Object.defineProperty(exports, "isOnGcp", { enumerable: true, get: function () { return metadata_1.isOnGcp; } });
16
+ var urls_1 = require("./urls");
17
+ Object.defineProperty(exports, "getServiceName", { enumerable: true, get: function () { return urls_1.getServiceName; } });
18
+ Object.defineProperty(exports, "getProjectId", { enumerable: true, get: function () { return urls_1.getProjectId; } });
19
+ Object.defineProperty(exports, "getRegion", { enumerable: true, get: function () { return urls_1.getRegion; } });
20
+ Object.defineProperty(exports, "getRuntimeServiceAccountEmail", { enumerable: true, get: function () { return urls_1.getRuntimeServiceAccountEmail; } });
21
+ Object.defineProperty(exports, "getSelfCloudRunUrl", { enumerable: true, get: function () { return urls_1.getSelfCloudRunUrl; } });
22
+ Object.defineProperty(exports, "getCloudRunUrl", { enumerable: true, get: function () { return urls_1.getCloudRunUrl; } });
23
+ Object.defineProperty(exports, "LOCAL_SERVICE_ACCOUNT_EMAIL", { enumerable: true, get: function () { return urls_1.LOCAL_SERVICE_ACCOUNT_EMAIL; } });
24
+ var oidc_1 = require("./oidc");
25
+ Object.defineProperty(exports, "mintIdToken", { enumerable: true, get: function () { return oidc_1.mintIdToken; } });
26
+ Object.defineProperty(exports, "verifyOidcFromCallers", { enumerable: true, get: function () { return oidc_1.verifyOidcFromCallers; } });
27
+ Object.defineProperty(exports, "OidcVerifyResult", { enumerable: true, get: function () { return oidc_1.OidcVerifyResult; } });
28
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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,+BAQgB;AAPZ,sGAAA,cAAc,OAAA;AACd,oGAAA,YAAY,OAAA;AACZ,iGAAA,SAAS,OAAA;AACT,qHAAA,6BAA6B,OAAA;AAC7B,0GAAA,kBAAkB,OAAA;AAClB,sGAAA,cAAc,OAAA;AACd,mHAAA,2BAA2B,OAAA;AAE/B,+BAA8E;AAArE,mGAAA,WAAW,OAAA;AAAE,6GAAA,qBAAqB,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 (ServiceAuthFilter) and the\n * RPC (@webpieces/http-client) + Cloud Tasks (@webpieces/cloudtasks-client) clients.\n */\n\nexport { isOnGcp } from './metadata';\nexport {\n getServiceName,\n getProjectId,\n getRegion,\n getRuntimeServiceAccountEmail,\n getSelfCloudRunUrl,\n getCloudRunUrl,\n LOCAL_SERVICE_ACCOUNT_EMAIL,\n} from './urls';\nexport { mintIdToken, verifyOidcFromCallers, OidcVerifyResult } from './oidc';\n"]}
@@ -0,0 +1,10 @@
1
+ /** True when running on GCP (Cloud Run / GCE) with a reachable metadata server. */
2
+ export declare function isOnGcp(): Promise<boolean>;
3
+ /** GCP project id (e.g. 'my-project'). Only call when isOnGcp() is true. */
4
+ export declare function readProjectId(): Promise<string>;
5
+ /** Numeric project id (used to build Cloud Run URLs). */
6
+ export declare function readNumericProjectId(): Promise<string>;
7
+ /** Cloud Run region (e.g. 'us-central1'), parsed from 'projects/<num>/regions/<region>'. */
8
+ export declare function readRegion(): Promise<string>;
9
+ /** The service-account email this process runs as. */
10
+ export declare function readRuntimeServiceAccountEmail(): Promise<string>;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isOnGcp = isOnGcp;
4
+ exports.readProjectId = readProjectId;
5
+ exports.readNumericProjectId = readNumericProjectId;
6
+ exports.readRegion = readRegion;
7
+ exports.readRuntimeServiceAccountEmail = readRuntimeServiceAccountEmail;
8
+ const tslib_1 = require("tslib");
9
+ const gcpMetadata = tslib_1.__importStar(require("gcp-metadata"));
10
+ /**
11
+ * Cached reads of the GCP metadata server. Every value is fetched at most once per
12
+ * process (the metadata server is stable for the life of the instance). Off-GCP
13
+ * `isOnGcp()` is false and the callers fall back to localhost values, so nothing
14
+ * here is ever reached in local dev / tests.
15
+ */
16
+ let cachedOnGcp;
17
+ let cachedProjectId;
18
+ let cachedNumericProjectId;
19
+ let cachedRegion;
20
+ let cachedSaEmail;
21
+ /** True when running on GCP (Cloud Run / GCE) with a reachable metadata server. */
22
+ function isOnGcp() {
23
+ if (!cachedOnGcp) {
24
+ cachedOnGcp = gcpMetadata.isAvailable();
25
+ }
26
+ return cachedOnGcp;
27
+ }
28
+ // gcp-metadata returns loosely-typed values; coerce every metadata read to a string
29
+ // through this one helper so the rest of the file stays strongly typed.
30
+ // webpieces-disable no-any-unknown -- single coercion point for gcp-metadata's loose return type
31
+ function asString(value) {
32
+ return String(value);
33
+ }
34
+ /** GCP project id (e.g. 'my-project'). Only call when isOnGcp() is true. */
35
+ function readProjectId() {
36
+ if (!cachedProjectId) {
37
+ cachedProjectId = gcpMetadata.project('project-id').then(asString);
38
+ }
39
+ return cachedProjectId;
40
+ }
41
+ /** Numeric project id (used to build Cloud Run URLs). */
42
+ function readNumericProjectId() {
43
+ if (!cachedNumericProjectId) {
44
+ cachedNumericProjectId = gcpMetadata.project('numeric-project-id').then(asString);
45
+ }
46
+ return cachedNumericProjectId;
47
+ }
48
+ /** Cloud Run region (e.g. 'us-central1'), parsed from 'projects/<num>/regions/<region>'. */
49
+ function readRegion() {
50
+ if (!cachedRegion) {
51
+ cachedRegion = gcpMetadata.instance('region').then(asString).then((raw) => {
52
+ const idx = raw.lastIndexOf('/');
53
+ return idx >= 0 ? raw.substring(idx + 1) : raw;
54
+ });
55
+ }
56
+ return cachedRegion;
57
+ }
58
+ /** The service-account email this process runs as. */
59
+ function readRuntimeServiceAccountEmail() {
60
+ if (!cachedSaEmail) {
61
+ cachedSaEmail = gcpMetadata.instance('service-accounts/default/email').then(asString);
62
+ }
63
+ return cachedSaEmail;
64
+ }
65
+ //# sourceMappingURL=metadata.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metadata.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/metadata.ts"],"names":[],"mappings":";;AAgBA,0BAKC;AAUD,sCAKC;AAGD,oDAKC;AAGD,gCAQC;AAGD,wEAKC;;AA/DD,kEAA4C;AAE5C;;;;;GAKG;AAEH,IAAI,WAAyC,CAAC;AAC9C,IAAI,eAA4C,CAAC;AACjD,IAAI,sBAAmD,CAAC;AACxD,IAAI,YAAyC,CAAC;AAC9C,IAAI,aAA0C,CAAC;AAE/C,mFAAmF;AACnF,SAAgB,OAAO;IACnB,IAAI,CAAC,WAAW,EAAE,CAAC;QACf,WAAW,GAAG,WAAW,CAAC,WAAW,EAAE,CAAC;IAC5C,CAAC;IACD,OAAO,WAAW,CAAC;AACvB,CAAC;AAED,oFAAoF;AACpF,wEAAwE;AACxE,iGAAiG;AACjG,SAAS,QAAQ,CAAC,KAAc;IAC5B,OAAO,MAAM,CAAC,KAAK,CAAC,CAAC;AACzB,CAAC;AAED,4EAA4E;AAC5E,SAAgB,aAAa;IACzB,IAAI,CAAC,eAAe,EAAE,CAAC;QACnB,eAAe,GAAG,WAAW,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACvE,CAAC;IACD,OAAO,eAAe,CAAC;AAC3B,CAAC;AAED,yDAAyD;AACzD,SAAgB,oBAAoB;IAChC,IAAI,CAAC,sBAAsB,EAAE,CAAC;QAC1B,sBAAsB,GAAG,WAAW,CAAC,OAAO,CAAC,oBAAoB,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IACtF,CAAC;IACD,OAAO,sBAAsB,CAAC;AAClC,CAAC;AAED,4FAA4F;AAC5F,SAAgB,UAAU;IACtB,IAAI,CAAC,YAAY,EAAE,CAAC;QAChB,YAAY,GAAG,WAAW,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,GAAW,EAAE,EAAE;YAC9E,MAAM,GAAG,GAAG,GAAG,CAAC,WAAW,CAAC,GAAG,CAAC,CAAC;YACjC,OAAO,GAAG,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC;QACnD,CAAC,CAAC,CAAC;IACP,CAAC;IACD,OAAO,YAAY,CAAC;AACxB,CAAC;AAED,sDAAsD;AACtD,SAAgB,8BAA8B;IAC1C,IAAI,CAAC,aAAa,EAAE,CAAC;QACjB,aAAa,GAAG,WAAW,CAAC,QAAQ,CAAC,gCAAgC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1F,CAAC;IACD,OAAO,aAAa,CAAC;AACzB,CAAC","sourcesContent":["import * as gcpMetadata from 'gcp-metadata';\n\n/**\n * Cached reads of the GCP metadata server. Every value is fetched at most once per\n * process (the metadata server is stable for the life of the instance). Off-GCP\n * `isOnGcp()` is false and the callers fall back to localhost values, so nothing\n * here is ever reached in local dev / tests.\n */\n\nlet cachedOnGcp: Promise<boolean> | undefined;\nlet cachedProjectId: Promise<string> | undefined;\nlet cachedNumericProjectId: Promise<string> | undefined;\nlet cachedRegion: Promise<string> | undefined;\nlet cachedSaEmail: Promise<string> | undefined;\n\n/** True when running on GCP (Cloud Run / GCE) with a reachable metadata server. */\nexport function isOnGcp(): Promise<boolean> {\n if (!cachedOnGcp) {\n cachedOnGcp = gcpMetadata.isAvailable();\n }\n return cachedOnGcp;\n}\n\n// gcp-metadata returns loosely-typed values; coerce every metadata read to a string\n// through this one helper so the rest of the file stays strongly typed.\n// webpieces-disable no-any-unknown -- single coercion point for gcp-metadata's loose return type\nfunction asString(value: unknown): string {\n return String(value);\n}\n\n/** GCP project id (e.g. 'my-project'). Only call when isOnGcp() is true. */\nexport function readProjectId(): Promise<string> {\n if (!cachedProjectId) {\n cachedProjectId = gcpMetadata.project('project-id').then(asString);\n }\n return cachedProjectId;\n}\n\n/** Numeric project id (used to build Cloud Run URLs). */\nexport function readNumericProjectId(): Promise<string> {\n if (!cachedNumericProjectId) {\n cachedNumericProjectId = gcpMetadata.project('numeric-project-id').then(asString);\n }\n return cachedNumericProjectId;\n}\n\n/** Cloud Run region (e.g. 'us-central1'), parsed from 'projects/<num>/regions/<region>'. */\nexport function readRegion(): Promise<string> {\n if (!cachedRegion) {\n cachedRegion = gcpMetadata.instance('region').then(asString).then((raw: string) => {\n const idx = raw.lastIndexOf('/');\n return idx >= 0 ? raw.substring(idx + 1) : raw;\n });\n }\n return cachedRegion;\n}\n\n/** The service-account email this process runs as. */\nexport function readRuntimeServiceAccountEmail(): Promise<string> {\n if (!cachedSaEmail) {\n cachedSaEmail = gcpMetadata.instance('service-accounts/default/email').then(asString);\n }\n return cachedSaEmail;\n}\n"]}
package/src/oidc.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /** Outcome of verifying an inbound OIDC token against an allow-list of callers. */
2
+ export declare class OidcVerifyResult {
3
+ /** True when the token is valid AND its caller SA is allowed. */
4
+ ok: boolean;
5
+ /** The verified caller email (present when ok). */
6
+ email?: string;
7
+ /** Human-readable reason when not ok (for logs / 401 message). */
8
+ reason?: string;
9
+ constructor(ok: boolean, email?: string, reason?: string);
10
+ }
11
+ /**
12
+ * Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as
13
+ * this service's runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token
14
+ * that verifyOidcFromCallers accepts locally.
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.
22
+ *
23
+ * `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →
24
+ * `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.
25
+ */
26
+ export declare function verifyOidcFromCallers(idToken: string, callers: string[]): Promise<OidcVerifyResult>;
package/src/oidc.js ADDED
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.OidcVerifyResult = void 0;
4
+ exports.mintIdToken = mintIdToken;
5
+ exports.verifyOidcFromCallers = verifyOidcFromCallers;
6
+ const google_auth_library_1 = require("google-auth-library");
7
+ const core_util_1 = require("@webpieces/core-util");
8
+ const core_util_2 = require("@webpieces/core-util");
9
+ const metadata_1 = require("./metadata");
10
+ const urls_1 = require("./urls");
11
+ const log = core_util_1.LogManager.getLogger('gcp-identity-oidc');
12
+ /**
13
+ * Prefix marking a deterministic local (off-GCP) OIDC token. Real Google-signed
14
+ * tokens never start with this, so verify can tell them apart. This is what keeps
15
+ * the @AuthOidc code path fully exercised in tests without any GCP round-trip.
16
+ */
17
+ 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
+ /** Outcome of verifying an inbound OIDC token against an allow-list of callers. */
21
+ class OidcVerifyResult {
22
+ /** True when the token is valid AND its caller SA is allowed. */
23
+ ok;
24
+ /** The verified caller email (present when ok). */
25
+ email;
26
+ /** Human-readable reason when not ok (for logs / 401 message). */
27
+ reason;
28
+ constructor(ok, email, reason) {
29
+ this.ok = ok;
30
+ this.email = email;
31
+ this.reason = reason;
32
+ }
33
+ }
34
+ exports.OidcVerifyResult = OidcVerifyResult;
35
+ /**
36
+ * Mint a Google-signed OIDC ID token for `audience` (the callee's base URL), as
37
+ * this service's runtime SA. Off-GCP returns a self-describing `dev-oidc.*` token
38
+ * that verifyOidcFromCallers accepts locally.
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.
53
+ *
54
+ * `callers` entries resolve as: 'self' → this service's runtime SA; a bare id →
55
+ * `<id>@<project>.iam.gserviceaccount.com`; anything containing '@' → verbatim.
56
+ */
57
+ async function verifyOidcFromCallers(idToken, callers) {
58
+ const email = await extractVerifiedEmail(idToken);
59
+ if (!email) {
60
+ return new OidcVerifyResult(false, undefined, 'token invalid or missing email claim');
61
+ }
62
+ const allowed = await resolveCallers(callers);
63
+ if (!allowed.includes(email)) {
64
+ return new OidcVerifyResult(false, email, `caller '${email}' is not in the allow-list [${allowed.join(', ')}]`);
65
+ }
66
+ return new OidcVerifyResult(true, email);
67
+ }
68
+ function makeDevToken(email, audience) {
69
+ const payload = JSON.stringify({ email: email, aud: audience });
70
+ return DEV_TOKEN_PREFIX + Buffer.from(payload, 'utf8').toString('base64url');
71
+ }
72
+ async function extractVerifiedEmail(idToken) {
73
+ if (idToken.startsWith(DEV_TOKEN_PREFIX)) {
74
+ return decodeDevTokenEmail(idToken);
75
+ }
76
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- an unverifiable token is simply unauthenticated → undefined → 401 upstream
77
+ try {
78
+ const ticket = await reusableVerifier.verifyIdToken({ idToken: idToken });
79
+ const payload = ticket.getPayload();
80
+ return payload?.email ?? undefined;
81
+ }
82
+ catch (err) {
83
+ const error = (0, core_util_2.toError)(err);
84
+ log.debug(`OIDC token verify failed: ${error.message}`);
85
+ return undefined;
86
+ }
87
+ }
88
+ function decodeDevTokenEmail(idToken) {
89
+ const encoded = idToken.substring(DEV_TOKEN_PREFIX.length);
90
+ const json = Buffer.from(encoded, 'base64url').toString('utf8');
91
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- a malformed dev token is just unauthenticated → undefined
92
+ try {
93
+ const parsed = JSON.parse(json);
94
+ return parsed.email ?? undefined;
95
+ }
96
+ catch (err) {
97
+ const error = (0, core_util_2.toError)(err);
98
+ log.debug(`dev-oidc token decode failed: ${error.message}`);
99
+ return undefined;
100
+ }
101
+ }
102
+ async function resolveCallers(callers) {
103
+ return Promise.all(callers.map((caller) => resolveCaller(caller)));
104
+ }
105
+ async function resolveCaller(caller) {
106
+ if (caller === 'self') {
107
+ return (0, urls_1.getRuntimeServiceAccountEmail)();
108
+ }
109
+ if (caller.includes('@')) {
110
+ return caller;
111
+ }
112
+ const projectId = await (0, urls_1.getProjectId)();
113
+ return `${caller}@${projectId}.iam.gserviceaccount.com`;
114
+ }
115
+ /** Shape of the decoded dev-oidc token payload. */
116
+ class DevTokenPayload {
117
+ email;
118
+ aud;
119
+ }
120
+ //# sourceMappingURL=oidc.js.map
@@ -0,0 +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"]}
package/src/urls.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ /** Local fallback SA email used off-GCP so 'self' caller checks are deterministic. */
2
+ export declare const LOCAL_SERVICE_ACCOUNT_EMAIL = "local@localhost.invalid";
3
+ /**
4
+ * Logical service name from K_SERVICE (Cloud Run sets this), stripping a leading
5
+ * 'tf-' Terraform prefix. Off-GCP returns 'local'. Synchronous — env only.
6
+ */
7
+ export declare function getServiceName(): string;
8
+ /** GCP project id, or 'local-project' off-GCP. */
9
+ export declare function getProjectId(): Promise<string>;
10
+ /** Cloud Run region, or 'local' off-GCP. */
11
+ export declare function getRegion(): Promise<string>;
12
+ /** The runtime SA email, or the local placeholder off-GCP. */
13
+ export declare function getRuntimeServiceAccountEmail(): Promise<string>;
14
+ /**
15
+ * This service's own base URL (its Cloud Tasks self-enqueue target / public base).
16
+ * Off-GCP → http://localhost:<PORT>.
17
+ */
18
+ export declare function getSelfCloudRunUrl(): Promise<string>;
19
+ /**
20
+ * Deterministic base URL for another Cloud Run service in the same project/region.
21
+ * Off-GCP returns a non-routable placeholder (in-memory task/rpc dispatch never
22
+ * actually fetches it during tests); a real local multi-service run can override
23
+ * via the CLOUD_RUN_URL_<UPPER_SNAKE_NAME> env var.
24
+ */
25
+ export declare function getCloudRunUrl(serviceName: string): Promise<string>;
package/src/urls.js ADDED
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOCAL_SERVICE_ACCOUNT_EMAIL = void 0;
4
+ exports.getServiceName = getServiceName;
5
+ exports.getProjectId = getProjectId;
6
+ exports.getRegion = getRegion;
7
+ exports.getRuntimeServiceAccountEmail = getRuntimeServiceAccountEmail;
8
+ exports.getSelfCloudRunUrl = getSelfCloudRunUrl;
9
+ exports.getCloudRunUrl = getCloudRunUrl;
10
+ const metadata_1 = require("./metadata");
11
+ /** Local fallback SA email used off-GCP so 'self' caller checks are deterministic. */
12
+ exports.LOCAL_SERVICE_ACCOUNT_EMAIL = 'local@localhost.invalid';
13
+ /**
14
+ * Logical service name from K_SERVICE (Cloud Run sets this), stripping a leading
15
+ * 'tf-' Terraform prefix. Off-GCP returns 'local'. Synchronous — env only.
16
+ */
17
+ function getServiceName() {
18
+ const kService = process.env['K_SERVICE'];
19
+ if (!kService) {
20
+ return 'local';
21
+ }
22
+ return kService.startsWith('tf-') ? kService.substring('tf-'.length) : kService;
23
+ }
24
+ /** GCP project id, or 'local-project' off-GCP. */
25
+ async function getProjectId() {
26
+ if (!(await (0, metadata_1.isOnGcp)())) {
27
+ return 'local-project';
28
+ }
29
+ return (0, metadata_1.readProjectId)();
30
+ }
31
+ /** Cloud Run region, or 'local' off-GCP. */
32
+ async function getRegion() {
33
+ if (!(await (0, metadata_1.isOnGcp)())) {
34
+ return 'local';
35
+ }
36
+ return (0, metadata_1.readRegion)();
37
+ }
38
+ /** The runtime SA email, or the local placeholder off-GCP. */
39
+ async function getRuntimeServiceAccountEmail() {
40
+ if (!(await (0, metadata_1.isOnGcp)())) {
41
+ return exports.LOCAL_SERVICE_ACCOUNT_EMAIL;
42
+ }
43
+ return (0, metadata_1.readRuntimeServiceAccountEmail)();
44
+ }
45
+ /**
46
+ * This service's own base URL (its Cloud Tasks self-enqueue target / public base).
47
+ * Off-GCP → http://localhost:<PORT>.
48
+ */
49
+ async function getSelfCloudRunUrl() {
50
+ const kService = process.env['K_SERVICE'];
51
+ if (!(await (0, metadata_1.isOnGcp)()) || !kService) {
52
+ const port = process.env['PORT'] ?? '8080';
53
+ return `http://localhost:${port}`;
54
+ }
55
+ const numericProjectId = await (0, metadata_1.readNumericProjectId)();
56
+ const region = await (0, metadata_1.readRegion)();
57
+ return `https://${kService}-${numericProjectId}.${region}.run.app`;
58
+ }
59
+ /**
60
+ * Deterministic base URL for another Cloud Run service in the same project/region.
61
+ * Off-GCP returns a non-routable placeholder (in-memory task/rpc dispatch never
62
+ * actually fetches it during tests); a real local multi-service run can override
63
+ * via the CLOUD_RUN_URL_<UPPER_SNAKE_NAME> env var.
64
+ */
65
+ async function getCloudRunUrl(serviceName) {
66
+ const override = process.env[`CLOUD_RUN_URL_${toEnvKey(serviceName)}`];
67
+ if (override) {
68
+ return override;
69
+ }
70
+ if (!(await (0, metadata_1.isOnGcp)())) {
71
+ return `http://${serviceName}.localhost.invalid`;
72
+ }
73
+ const numericProjectId = await (0, metadata_1.readNumericProjectId)();
74
+ const region = await (0, metadata_1.readRegion)();
75
+ return `https://${serviceName}-${numericProjectId}.${region}.run.app`;
76
+ }
77
+ function toEnvKey(serviceName) {
78
+ return serviceName.toUpperCase().replace(/[^A-Z0-9]+/g, '_');
79
+ }
80
+ //# sourceMappingURL=urls.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"urls.js","sourceRoot":"","sources":["../../../../../packages/cloud/gcp-identity/src/urls.ts"],"names":[],"mappings":";;;AASA,wCAMC;AAGD,oCAKC;AAGD,8BAKC;AAGD,sEAKC;AAMD,gDASC;AAQD,wCAWC;AAzED,yCAAsH;AAEtH,sFAAsF;AACzE,QAAA,2BAA2B,GAAG,yBAAyB,CAAC;AAErE;;;GAGG;AACH,SAAgB,cAAc;IAC1B,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC1C,IAAI,CAAC,QAAQ,EAAE,CAAC;QACZ,OAAO,OAAO,CAAC;IACnB,CAAC;IACD,OAAO,QAAQ,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC;AACpF,CAAC;AAED,kDAAkD;AAC3C,KAAK,UAAU,YAAY;IAC9B,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,OAAO,eAAe,CAAC;IAC3B,CAAC;IACD,OAAO,IAAA,wBAAa,GAAE,CAAC;AAC3B,CAAC;AAED,4CAA4C;AACrC,KAAK,UAAU,SAAS;IAC3B,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,OAAO,OAAO,CAAC;IACnB,CAAC;IACD,OAAO,IAAA,qBAAU,GAAE,CAAC;AACxB,CAAC;AAED,8DAA8D;AACvD,KAAK,UAAU,6BAA6B;IAC/C,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,OAAO,mCAA2B,CAAC;IACvC,CAAC;IACD,OAAO,IAAA,yCAA8B,GAAE,CAAC;AAC5C,CAAC;AAED;;;GAGG;AACI,KAAK,UAAU,kBAAkB;IACpC,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,WAAW,CAAC,CAAC;IAC1C,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC;QAC3C,OAAO,oBAAoB,IAAI,EAAE,CAAC;IACtC,CAAC;IACD,MAAM,gBAAgB,GAAG,MAAM,IAAA,+BAAoB,GAAE,CAAC;IACtD,MAAM,MAAM,GAAG,MAAM,IAAA,qBAAU,GAAE,CAAC;IAClC,OAAO,WAAW,QAAQ,IAAI,gBAAgB,IAAI,MAAM,UAAU,CAAC;AACvE,CAAC;AAED;;;;;GAKG;AACI,KAAK,UAAU,cAAc,CAAC,WAAmB;IACpD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IACvE,IAAI,QAAQ,EAAE,CAAC;QACX,OAAO,QAAQ,CAAC;IACpB,CAAC;IACD,IAAI,CAAC,CAAC,MAAM,IAAA,kBAAO,GAAE,CAAC,EAAE,CAAC;QACrB,OAAO,UAAU,WAAW,oBAAoB,CAAC;IACrD,CAAC;IACD,MAAM,gBAAgB,GAAG,MAAM,IAAA,+BAAoB,GAAE,CAAC;IACtD,MAAM,MAAM,GAAG,MAAM,IAAA,qBAAU,GAAE,CAAC;IAClC,OAAO,WAAW,WAAW,IAAI,gBAAgB,IAAI,MAAM,UAAU,CAAC;AAC1E,CAAC;AAED,SAAS,QAAQ,CAAC,WAAmB;IACjC,OAAO,WAAW,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;AACjE,CAAC","sourcesContent":["import { isOnGcp, readNumericProjectId, readRegion, readProjectId, readRuntimeServiceAccountEmail } from './metadata';\n\n/** Local fallback SA email used off-GCP so 'self' caller checks are deterministic. */\nexport const LOCAL_SERVICE_ACCOUNT_EMAIL = 'local@localhost.invalid';\n\n/**\n * Logical service name from K_SERVICE (Cloud Run sets this), stripping a leading\n * 'tf-' Terraform prefix. Off-GCP returns 'local'. Synchronous — env only.\n */\nexport function getServiceName(): string {\n const kService = process.env['K_SERVICE'];\n if (!kService) {\n return 'local';\n }\n return kService.startsWith('tf-') ? kService.substring('tf-'.length) : kService;\n}\n\n/** GCP project id, or 'local-project' off-GCP. */\nexport async function getProjectId(): Promise<string> {\n if (!(await isOnGcp())) {\n return 'local-project';\n }\n return readProjectId();\n}\n\n/** Cloud Run region, or 'local' off-GCP. */\nexport async function getRegion(): Promise<string> {\n if (!(await isOnGcp())) {\n return 'local';\n }\n return readRegion();\n}\n\n/** The runtime SA email, or the local placeholder off-GCP. */\nexport async function getRuntimeServiceAccountEmail(): Promise<string> {\n if (!(await isOnGcp())) {\n return LOCAL_SERVICE_ACCOUNT_EMAIL;\n }\n return readRuntimeServiceAccountEmail();\n}\n\n/**\n * This service's own base URL (its Cloud Tasks self-enqueue target / public base).\n * Off-GCP → http://localhost:<PORT>.\n */\nexport async function getSelfCloudRunUrl(): Promise<string> {\n const kService = process.env['K_SERVICE'];\n if (!(await isOnGcp()) || !kService) {\n const port = process.env['PORT'] ?? '8080';\n return `http://localhost:${port}`;\n }\n const numericProjectId = await readNumericProjectId();\n const region = await readRegion();\n return `https://${kService}-${numericProjectId}.${region}.run.app`;\n}\n\n/**\n * Deterministic base URL for another Cloud Run service in the same project/region.\n * Off-GCP returns a non-routable placeholder (in-memory task/rpc dispatch never\n * actually fetches it during tests); a real local multi-service run can override\n * via the CLOUD_RUN_URL_<UPPER_SNAKE_NAME> env var.\n */\nexport async function getCloudRunUrl(serviceName: string): Promise<string> {\n const override = process.env[`CLOUD_RUN_URL_${toEnvKey(serviceName)}`];\n if (override) {\n return override;\n }\n if (!(await isOnGcp())) {\n return `http://${serviceName}.localhost.invalid`;\n }\n const numericProjectId = await readNumericProjectId();\n const region = await readRegion();\n return `https://${serviceName}-${numericProjectId}.${region}.run.app`;\n}\n\nfunction toEnvKey(serviceName: string): string {\n return serviceName.toUpperCase().replace(/[^A-Z0-9]+/g, '_');\n}\n"]}