@theholocron/github-client 1.19.0 → 1.21.0

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 CHANGED
@@ -90,3 +90,37 @@ A consumer owns what to _do_ with a verified delivery — which event
90
90
  categories matter, how to normalize them into its own domain shape.
91
91
  `@theholocron/sentinel`'s `parseWebhookEvent()` is the reference
92
92
  consumer.
93
+
94
+ ## GitHub App authentication
95
+
96
+ A signed App JWT, exchanged for a short-lived installation access token,
97
+ exchanged for a ready-to-use `GitHubClient` — Web Crypto only
98
+ (`globalThis.crypto`/`CryptoKey`, no `node:crypto`), so it runs unchanged
99
+ on Cloudflare Workers (no `nodejs_compat` flag needed) and in Node.
100
+
101
+ ```ts
102
+ import { createInstallationClient } from "@theholocron/github-client";
103
+
104
+ const client = await createInstallationClient(
105
+ { appId: process.env.GITHUB_APP_ID!, privateKey: process.env.GITHUB_APP_PRIVATE_KEY! },
106
+ installationId // from the webhook payload that triggered this — never hardcoded (D10)
107
+ );
108
+ const repo = await client.repos.getRepo("owner/name");
109
+ ```
110
+
111
+ `privateKey` accepts either PEM format GitHub hands out — PKCS#1 (an
112
+ `RSA PRIVATE KEY`-headered PEM block, the default download) or PKCS#8
113
+ (a plain `PRIVATE KEY`-headered PEM block); `importRsaPrivateKey`
114
+ (internal) wraps a PKCS#1 key in the PKCS#8 DER structure
115
+ `SubtleCrypto.importKey()` requires — Web Crypto only accepts PKCS#8
116
+ directly.
117
+
118
+ Lower-level pieces, if you need to manage the installation token's
119
+ lifetime yourself (it lasts about an hour) rather than fetching one per
120
+ call:
121
+
122
+ - `createAppJWT(creds)` — signs the App-level JWT (not installation-scoped; only good for requesting an installation token, never for calling the REST API directly).
123
+ - `getInstallationAccessToken(creds, installationId)` — exchanges that JWT for `{ token, expiresAt }`.
124
+
125
+ `@theholocron/sentinel`'s webhook handler is the reference consumer —
126
+ one App, N installations across N orgs/accounts, the same registration.
package/dist/index.d.mts CHANGED
@@ -255,6 +255,34 @@ interface WorkflowRunFilter {
255
255
  status?: string;
256
256
  }
257
257
  //#endregion
258
+ //#region src/app-auth/app-auth.d.ts
259
+ interface GitHubAppCredentials {
260
+ /** The App's numeric id (not the client id). */
261
+ appId: string;
262
+ /** PEM-encoded RSA private key — PKCS#1 (GitHub's default download format) or PKCS#8, either works. */
263
+ privateKey: string;
264
+ }
265
+ interface InstallationAccessToken {
266
+ token: string;
267
+ /** ISO 8601 — installation tokens are short-lived, about an hour. */
268
+ expiresAt: string;
269
+ }
270
+ /**
271
+ * Signs a JWT identifying this App (not a specific installation) — the
272
+ * credential used only to request an installation access token, never
273
+ * to call the REST API directly.
274
+ */
275
+ declare function createAppJWT(creds: GitHubAppCredentials, now?: number): Promise<string>;
276
+ /**
277
+ * Exchanges an App JWT for an installation access token — the credential
278
+ * that actually authenticates REST calls, scoped to exactly one
279
+ * installation (D10: never a hardcoded org — the caller supplies which
280
+ * installation, from the webhook payload that triggered this).
281
+ */
282
+ declare function getInstallationAccessToken(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<InstallationAccessToken>;
283
+ /** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
284
+ declare function createInstallationClient(creds: GitHubAppCredentials, installationId: number, opts?: Pick<GitHubClientOptions, "baseUrl" | "fetch">): Promise<GitHubClient>;
285
+ //#endregion
258
286
  //#region src/webhooks/webhooks.d.ts
259
287
  /**
260
288
  * GitHub's own webhook mechanics — signature verification and header/
@@ -429,4 +457,4 @@ declare function createGitHubClient(opts: GitHubClientOptions): {
429
457
  };
430
458
  type GitHubClient = ReturnType<typeof createGitHubClient>;
431
459
  //#endregion
432
- export { type CheckRunConclusion, type CheckRunOutput, type CheckRunStatus, type CodeScanningSetupResult, type CreateCheckRunInput, type CreatePagesPayload, type CreatePullInput, type GitBlob, type GitCommit, type GitContents, type GitHubCheckRun, GitHubClient, type GitHubClientOptions, type GitHubContents, type GitHubEnvironment, type GitHubInstallationWebhookPayload, type GitHubIssue, type GitHubLabel, type GitHubMilestone, type GitHubPages, type GitHubPublicKey, type GitHubPullRequest, type GitHubPullRequestWebhookPayload, type GitHubPushWebhookPayload, type GitHubRepo, type GitHubRuleset, type GitHubUser, type GitHubWebhookHeaders, type GitHubWorkflowRun, type GitPull, type GitRef, type GitTree, type GitTreeItem, type IssueSearchParams, type PagesBuildStatus, type PagesBuildType, type SecretScope, type TeamPermission, type UpdatePagesPayload, type WorkflowRunFilter, createGitHubClient, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
460
+ export { type CheckRunConclusion, type CheckRunOutput, type CheckRunStatus, type CodeScanningSetupResult, type CreateCheckRunInput, type CreatePagesPayload, type CreatePullInput, type GitBlob, type GitCommit, type GitContents, type GitHubAppCredentials, type GitHubCheckRun, GitHubClient, type GitHubClientOptions, type GitHubContents, type GitHubEnvironment, type GitHubInstallationWebhookPayload, type GitHubIssue, type GitHubLabel, type GitHubMilestone, type GitHubPages, type GitHubPublicKey, type GitHubPullRequest, type GitHubPullRequestWebhookPayload, type GitHubPushWebhookPayload, type GitHubRepo, type GitHubRuleset, type GitHubUser, type GitHubWebhookHeaders, type GitHubWorkflowRun, type GitPull, type GitRef, type GitTree, type GitTreeItem, type InstallationAccessToken, type IssueSearchParams, type PagesBuildStatus, type PagesBuildType, type SecretScope, type TeamPermission, type UpdatePagesPayload, type WorkflowRunFilter, createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
package/dist/index.mjs CHANGED
@@ -353,6 +353,164 @@ function workflows(rest) {
353
353
  };
354
354
  }
355
355
  //#endregion
356
+ //#region src/app-auth/pem.ts
357
+ /**
358
+ * PEM/DER handling for GitHub App private keys — Web Crypto only (no
359
+ * `node:crypto`), so this runs unchanged on Cloudflare Workers (no
360
+ * `nodejs_compat` needed) and in Node.
361
+ *
362
+ * GitHub hands out App private keys in PKCS#1 ("-----BEGIN RSA PRIVATE
363
+ * KEY-----") by default; `SubtleCrypto.importKey("pkcs8", …)` only
364
+ * accepts PKCS#8 ("-----BEGIN PRIVATE KEY-----"). `pkcs1ToPkcs8` wraps a
365
+ * PKCS#1 `RSAPrivateKey` DER in the fixed `PrivateKeyInfo` DER structure
366
+ * PKCS#8 requires — a fixed `AlgorithmIdentifier` prefix for
367
+ * `rsaEncryption` (OID 1.2.840.113549.1.1.1) around the unchanged
368
+ * PKCS#1 bytes. Verified against a real generated key pair: imported,
369
+ * signed, and checked against `node:crypto`'s `verify()` on the
370
+ * original key's public half.
371
+ */
372
+ function pemToDer(pem) {
373
+ const base64 = pem.replace(/-----BEGIN [^-]+-----/, "").replace(/-----END [^-]+-----/, "").replace(/\s/g, "");
374
+ return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
375
+ }
376
+ function concat(...arrays) {
377
+ const total = arrays.reduce((sum, a) => sum + a.length, 0);
378
+ const out = new Uint8Array(total);
379
+ let offset = 0;
380
+ for (const a of arrays) {
381
+ out.set(a, offset);
382
+ offset += a.length;
383
+ }
384
+ return out;
385
+ }
386
+ function encodeDerLength(len) {
387
+ /* istanbul ignore next -- see comment above */
388
+ if (len < 128) return new Uint8Array([len]);
389
+ const bytes = [];
390
+ let n = len;
391
+ while (n > 0) {
392
+ bytes.unshift(n & 255);
393
+ n >>= 8;
394
+ }
395
+ return new Uint8Array([128 | bytes.length, ...bytes]);
396
+ }
397
+ function derEncode(tag, content) {
398
+ return concat(new Uint8Array([tag]), encodeDerLength(content.length), content);
399
+ }
400
+ /** SEQUENCE { OID rsaEncryption, NULL } — the fixed PKCS#8 AlgorithmIdentifier for RSA. */
401
+ const RSA_ALGORITHM_IDENTIFIER = new Uint8Array([
402
+ 48,
403
+ 13,
404
+ 6,
405
+ 9,
406
+ 42,
407
+ 134,
408
+ 72,
409
+ 134,
410
+ 247,
411
+ 13,
412
+ 1,
413
+ 1,
414
+ 1,
415
+ 5,
416
+ 0
417
+ ]);
418
+ function pkcs1ToPkcs8(pkcs1) {
419
+ const version = new Uint8Array([
420
+ 2,
421
+ 1,
422
+ 0
423
+ ]);
424
+ const octetString = derEncode(4, pkcs1);
425
+ return derEncode(48, concat(version, RSA_ALGORITHM_IDENTIFIER, octetString));
426
+ }
427
+ /** Imports a PEM-encoded RSA private key — PKCS#1 or PKCS#8, either works — for RS256 signing. */
428
+ async function importRsaPrivateKey(pem) {
429
+ const der = pemToDer(pem);
430
+ const pkcs8Der = (pem.includes("BEGIN RSA PRIVATE KEY") ? pkcs1ToPkcs8(der) : der).slice();
431
+ return crypto.subtle.importKey("pkcs8", pkcs8Der, {
432
+ name: "RSASSA-PKCS1-v1_5",
433
+ hash: "SHA-256"
434
+ }, false, ["sign"]);
435
+ }
436
+ /** Base64url (no padding) — the encoding both JWT segments and the signature use. */
437
+ function base64UrlEncode(bytes) {
438
+ let binary = "";
439
+ for (const byte of bytes) binary += String.fromCharCode(byte);
440
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
441
+ }
442
+ //#endregion
443
+ //#region src/app-auth/app-auth.ts
444
+ /**
445
+ * GitHub App authentication — a signed App JWT, exchanged for a
446
+ * short-lived installation access token, exchanged for a ready-to-use
447
+ * `GitHubClient`. Web Crypto only (see `pem.ts`), so this runs unchanged
448
+ * on Cloudflare Workers and in Node — no vendor-specific JWT library, no
449
+ * `nodejs_compat` flag.
450
+ *
451
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app
452
+ * https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
453
+ */
454
+ const CLOCK_DRIFT_BUFFER_SECONDS = 60;
455
+ /** GitHub rejects an App JWT with more than 10 minutes between iat and exp. */
456
+ const JWT_LIFETIME_SECONDS = 600;
457
+ /**
458
+ * Signs a JWT identifying this App (not a specific installation) — the
459
+ * credential used only to request an installation access token, never
460
+ * to call the REST API directly.
461
+ */
462
+ async function createAppJWT(creds, now = Date.now()) {
463
+ const key = await importRsaPrivateKey(creds.privateKey);
464
+ const iat = Math.floor(now / 1e3) - CLOCK_DRIFT_BUFFER_SECONDS;
465
+ const exp = iat + JWT_LIFETIME_SECONDS;
466
+ const signingInput = `${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
467
+ alg: "RS256",
468
+ typ: "JWT"
469
+ })))}.${base64UrlEncode(new TextEncoder().encode(JSON.stringify({
470
+ iat,
471
+ exp,
472
+ iss: creds.appId
473
+ })))}`;
474
+ const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
475
+ return `${signingInput}.${base64UrlEncode(new Uint8Array(signature))}`;
476
+ }
477
+ /**
478
+ * Exchanges an App JWT for an installation access token — the credential
479
+ * that actually authenticates REST calls, scoped to exactly one
480
+ * installation (D10: never a hardcoded org — the caller supplies which
481
+ * installation, from the webhook payload that triggered this).
482
+ */
483
+ async function getInstallationAccessToken(creds, installationId, opts = {}) {
484
+ const jwt = await createAppJWT(creds);
485
+ const baseUrl = opts.baseUrl ?? "https://api.github.com";
486
+ const res = await (opts.fetch ?? globalThis.fetch)(`${baseUrl}/app/installations/${installationId}/access_tokens`, {
487
+ method: "POST",
488
+ headers: {
489
+ authorization: `Bearer ${jwt}`,
490
+ accept: "application/vnd.github+json",
491
+ "x-github-api-version": "2022-11-28"
492
+ }
493
+ });
494
+ if (!res.ok) {
495
+ const body = await res.text().catch(() => "");
496
+ throw new ProviderApiError(`GitHub POST /app/installations/${installationId}/access_tokens → ${res.status}`, res.status, body);
497
+ }
498
+ const json = await res.json();
499
+ return {
500
+ token: json.token,
501
+ expiresAt: json.expires_at
502
+ };
503
+ }
504
+ /** The whole flow in one call — an already-authenticated `GitHubClient` scoped to one installation. */
505
+ async function createInstallationClient(creds, installationId, opts = {}) {
506
+ const { token } = await getInstallationAccessToken(creds, installationId, opts);
507
+ return createGitHubClient({
508
+ token,
509
+ baseUrl: opts.baseUrl,
510
+ fetch: opts.fetch
511
+ });
512
+ }
513
+ //#endregion
356
514
  //#region src/webhooks/webhooks.ts
357
515
  /**
358
516
  * GitHub's own webhook mechanics — signature verification and header/
@@ -423,4 +581,4 @@ function createGitHubClient(opts) {
423
581
  };
424
582
  }
425
583
  //#endregion
426
- export { createGitHubClient, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
584
+ export { createAppJWT, createGitHubClient, createInstallationClient, getInstallationAccessToken, parseGitHubWebhookHeaders, verifyGitHubWebhookSignature };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/github-client",
3
- "version": "1.19.0",
3
+ "version": "1.21.0",
4
4
  "description": "A TypeScript client for the GitHub REST API",
5
5
  "keywords": [
6
6
  "github",
@@ -33,7 +33,7 @@
33
33
  ],
34
34
  "dependencies": {
35
35
  "@theholocron/observability": "^0.3.0",
36
- "@theholocron/http-client": "^1.19.0"
36
+ "@theholocron/http-client": "^1.21.0"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@theholocron/cli": "5.0.0-alpha.3",