Skip to content

Support for KMS #982

Open
Open

Description

@JakubBiel

Prerequisites:

  • Is the functionality available in the GitHub UI? If so, please provide a link to information about the feature.

New Feature

Please describe the desired new functionality:

As it stands there are only 2 options for GitHub App private key management:

  1. PRIVATE_KEY - private key is supplied as env variable, least secure
  2. PRIVATE_KEY_PATH - path to the private key, slightly more secure

Given the nature of Safe Settings the corresponding GitHub App is usually highly privileged and its private key is probably the biggest security concern. If that key is compromised the impact can be severe. Therefore, I'd like to have an option to store the key in a Key Management Service (e.g. AWS KMS) so the key material never leaves KMS and there are no static credentials for the safe-settings app to handle. The app would only send the payload (or payload digest) to KMS (sign operation) and get the signature back so it can create a JWT.

Activity

  1. decyjphr commented on May 22, 2026

    @decyjphr
    Collaborator

    The code change would be a bit more complicated because of the way Probot has Octokit built-in.

    The Core Challenge

    @octokit/auth-app expects one of:

    // What it normally wants:
    {
      appId: 123,
      privateKey: "-----BEGIN RSA PRIVATE KEY-----\n..."  // raw PEM string
    }

    It handles JWT creation internally using the universal-github-app-jwt package, which calls Node's crypto.createSign() directly. There's no built-in hook to swap in a custom signer.

    The solution is to use the auth-app.js option /**/@ts-ignore — just kidding. The real solution is the createOAuthAppAuth escape hatch: pass a pre-signed JWT via a custom request wrapper, or better, use the token override pattern via a custom authStrategy.


    Two Approaches

    Approach 1 — Custom authStrategy (Cleanest)

    Probot and Octokit both accept an authStrategy option that completely replaces the auth layer. You provide a factory that returns an auth function conforming to Octokit's auth interface.

    ┌──────────────────────────────────────────┐
    │              Probot App                  │
    │                                          │
    │  Octokit (authStrategy: kmsAuthStrategy) │
    │       │                                  │
    │       ▼                                  │
    │  kmsAuthStrategy()                       │
    │       │ calls createGitHubJwt()          │
    │       │ calls signer.sign()  ────────────┼──▶ KMS
    │       │ returns { token, type }          │
    └──────────────────────────────────────────┘
    

    Step 1 — Build the custom auth strategy

    // kmsAuthStrategy.js
    const { createGitHubJwt } = require("./githubJwt"); // your provider-agnostic JWT builder
    const { getSigner } = require("./signerFactory");
    
    /**
     * Returns an Octokit-compatible authStrategy that signs JWTs via KMS
     * and exchanges them for installation tokens.
     *
     * @param {object} options
     * @param {string|number} options.appId
     * @param {string} options.installationId  - required for installation tokens
     * @param {import('./signer').AsymmetricSigner} [options.signer] - injectable for testing
     */
    function createKmsAuthStrategy(options) {
      // authStrategy must return a factory with this exact signature
      return (authOptions) => {
        const signer = options.signer ?? getSigner();
    
        return async (requestOptions = {}) => {
          const type = requestOptions.type ?? "installation";
    
          if (type === "app") {
            // Caller wants a raw app JWT (e.g. to list installations)
            const token = await createGitHubJwt(options.appId, signer);
            return { type: "token", tokenType: "app", token };
          }
    
          if (type === "installation") {
            // 1. Mint a JWT
            const jwt = await createGitHubJwt(options.appId, signer);
    
            // 2. Exchange for an installation access token
            const installationId = requestOptions.installationId
              ?? options.installationId;
    
            if (!installationId) {
              throw new Error("installationId is required for installation auth");
            }
    
            const response = await fetch(
              `https://api.github.com/app/installations/${installationId}/access_tokens`,
              {
                method: "POST",
                headers: {
                  Authorization: `Bearer ${jwt}`,
                  Accept: "application/vnd.github+json",
                  "X-GitHub-Api-Version": "2022-11-28",
                },
              }
            );
    
            if (!response.ok) {
              const body = await response.text();
              throw new Error(`Failed to get installation token: ${body}`);
            }
    
            const { token, expires_at, permissions, repository_selection } =
              await response.json();
    
            return {
              type: "token",
              tokenType: "installation",
              token,
              expiresAt: expires_at,
              permissions,
              repositorySelection: repository_selection,
              installationId,
            };
          }
    
          throw new Error(`Unsupported auth type: "${type}"`);
        };
      };
    }
    
    module.exports = { createKmsAuthStrategy };

    Step 2 — Wire into Probot

    // app.js
    const { createKmsAuthStrategy } = require("./kmsAuthStrategy");
    const { getSigner } = require("./signerFactory");
    
    /**
     * @param {import('probot').Probot} app
     */
    module.exports = (app) => {
      app.on("pull_request.opened", async (context) => {
        // context.octokit is already wired to KMS auth via the strategy
        // — use it exactly as you would in any Probot handler
        await context.octokit.issues.createComment(
          context.issue({ body: "Thanks for the PR!" })
        );
      });
    };
    // server.js (entrypoint)
    const { createProbot } = require("probot");
    const { createKmsAuthStrategy } = require("./kmsAuthStrategy");
    const { getSigner } = require("./signerFactory");
    
    const signer = getSigner(); // instantiate once at startup
    
    const probot = createProbot({
      overrides: {
        Octokit: require("@octokit/core").Octokit.defaults({
          authStrategy: createKmsAuthStrategy({
            appId: process.env.APP_ID,
            signer,
            // installationId can also be resolved dynamically per-event;
            // Probot normally resolves this from the webhook payload
          }),
        }),
      },
    });
    
    probot.load(require("./app"));
    probot.start();

    Approach 2 — Monkey-patch universal-github-app-jwt (Quick but Fragile)

    If you need to keep using createAppAuth from auth-app.js as-is, you can intercept the JWT module it depends on:

    // patchJwt.js
    const { getSigner } = require("./signerFactory");
    const { createGitHubJwt } = require("./githubJwt");
    
    /**
     * Patches the `universal-github-app-jwt` module that auth-app.js uses
     * internally, replacing its signing logic with your KMS signer.
     *
     * Call this ONCE before requiring @octokit/auth-app anywhere.
     * This is a last resort — prefer the authStrategy approach.
     */
    function patchUniversalJwt() {
      const signer = getSigner();
      const mod = require("universal-github-app-jwt");
    
      // Replace the default export with our KMS-backed version
      mod.default = async ({ appId }) => {
        const token = await createGitHubJwt(appId, signer);
        const [, rawPayload] = token.split(".");
        const payload = JSON.parse(
          Buffer.from(rawPayload, "base64url").toString()
        );
        return { token, appId, expiration: payload.exp };
      };
    }
    
    module.exports = { patchUniversalJwt };
    // server.js — must patch BEFORE any octokit/auth-app import
    require("./patchJwt").patchUniversalJwt();
    
    const { createProbot } = require("probot"); // safe to import now
    // ... rest of setup

    Avoid this approach if possible — it breaks if auth-app.js updates its internal module structure and it's difficult to test cleanly.


    Recommended Full Project Layout

    probot-kms-app/
    ├── signer.js                  # Abstract base class
    ├── signerFactory.js           # Env-driven factory
    ├── githubJwt.js               # Provider-agnostic JWT builder
    ├── kmsAuthStrategy.js         # Octokit/Probot auth strategy
    ├── providers/
    │   ├── awsKms.js
    │   ├── azureKeyVault.js
    │   ├── gcpKms.js
    │   └── local.js
    ├── app.js                     # Probot event handlers (pure business logic)
    └── server.js                  # Entrypoint: wires KMS + Probot together
    

    Testing the Auth Strategy in Isolation

    Because the signer is injected, you can unit test the full auth flow with no cloud dependency:

    // __tests__/kmsAuthStrategy.test.js
    const { createKmsAuthStrategy } = require("../kmsAuthStrategy");
    
    // A fake signer that returns a deterministic signature
    const mockSigner = {
      sign: async (payload) => Buffer.alloc(256, 0xab), // fixed fake sig
      getPublicKeyPem: async () => Buffer.from("fake-pem"),
    };
    
    // Mock the installation token exchange
    global.fetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        token: "ghs_test_token",
        expires_at: "2099-01-01T00:00:00Z",
        permissions: { contents: "read" },
        repository_selection: "all",
      }),
    });
    
    test("returns installation token", async () => {
      const strategy = createKmsAuthStrategy({
        appId: "42",
        installationId: "99",
        signer: mockSigner,
      });
    
      const auth = strategy({});
      const result = await auth({ type: "installation" });
    
      expect(result.tokenType).toBe("installation");
      expect(result.token).toBe("ghs_test_token");
      expect(fetch).toHaveBeenCalledWith(
        expect.stringContaining("/installations/99/access_tokens"),
        expect.objectContaining({ method: "POST" })
      );
    });
    
    test("returns app JWT for type=app", async () => {
      const strategy = createKmsAuthStrategy({ appId: "42", signer: mockSigner });
      const auth = strategy({});
      const result = await auth({ type: "app" });
    
      expect(result.tokenType).toBe("app");
      expect(result.token.split(".")).toHaveLength(3); // valid JWT structure
    });

    Key Decisions Summary

    Decision Recommendation Why
    Integration method authStrategy override Clean, testable, survives auth-app.js updates
    Signer instantiation Once at startup in server.js Avoids reconnecting to KMS on every webhook
    installationId resolution From webhook payload via Probot context Probot already extracts this; no need to hardcode
    Monkey-patching Avoid unless absolutely necessary Fragile against upstream changes
    Token caching Add in kmsAuthStrategy.js around the /access_tokens call Installation tokens are valid for 1 hour; no need to re-mint per request
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions