Skip to content

Commit ef7a95d

Browse files
authored
feat: support using a remote HSM or JWT signing service in lieu of private keys (#712)
To enable the use of Azure Key Vault keys, or other HSMs, this allows for a callback to be provided in lieu of a GitHub App's private certificate. Part of octokit/octokit.js#2623
1 parent 77cd04d commit ef7a95d

6 files changed

Lines changed: 130 additions & 12 deletions

File tree

README.md

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,40 @@ resolves with
107107
}
108108
```
109109

110+
### Authenticate as GitHub App (remotely-signed JSON Web Token)
111+
112+
If your app's private key is stored in a key management service or hardware security
113+
module, you can use the `createJwt` option instead of a private key to remotely sign a
114+
JWT. You provide an async callback to perform the token signing.
115+
116+
```js
117+
const auth = createAppAuth({
118+
appId: 1,
119+
createJwt: async (clientId, timeDifference) => {
120+
// ... sign the JWT remotely
121+
// universal-github-app-jwt accounts for clock skew by issuing at -30s from now
122+
// if a timeDifference is present, add that to the seconds
123+
return { jwt, expiresAt };
124+
}
125+
clientId: "lv1.1234567890abcdef",
126+
clientSecret: "1234567890abcdef12341234567890abcdef1234",
127+
});
128+
129+
// Retrieve JSON Web Token (JWT) to authenticate as app
130+
const appAuthentication = await auth({ type: "app" });
131+
```
132+
133+
resolves with
134+
135+
```json
136+
{
137+
"type": "app",
138+
"token": "jsonwebtoken123",
139+
"appId": 123,
140+
"expiresAt": "2018-07-07T00:09:30.000Z"
141+
}
142+
```
143+
110144
### Authenticate as OAuth App (client ID/client secret)
111145

112146
The [OAuth Application APIs](https://docs.github.com/en/rest/reference/apps#oauth-applications-api) require the app to authenticate using clientID/client as Basic Authentication
@@ -337,7 +371,7 @@ await installationOctokit.request("POST /repos/{owner}/{repo}/issues", {
337371
<code>string</code>
338372
</th>
339373
<td>
340-
<strong>Required</strong>. Content of the <code>*.pem</code> file you downloaded from the app’s about page. You can generate a new private key if needed. If your private key contains escaped newlines (`\\n`), they will be automatically replaced with actual newlines.
374+
<strong>Typically required</strong>. Content of the <code>*.pem</code> file you downloaded from the app’s about page. You can generate a new private key if needed. If your private key contains escaped newlines (`\\n`), they will be automatically replaced with actual newlines. Not required when using an external JWT signing service.
341375
</td>
342376
</tr>
343377
<tr>
@@ -479,7 +513,7 @@ Authenticate as the GitHub app to list installations, repositories, and create i
479513
<code>string</code>
480514
</th>
481515
<td>
482-
<strong>Required</strong>. Must be either <code>"app"</code>.
516+
<strong>Required</strong>. Must be <code>"app"</code>.
483517
</td>
484518
</tr>
485519
</tbody>

src/get-app-authentication.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,24 @@ export async function getAppAuthentication({
66
appId,
77
privateKey,
88
timeDifference,
9+
createJwt,
910
}: State & {
1011
timeDifference?: number | undefined;
1112
}): Promise<AppAuthentication> {
1213
try {
14+
if (createJwt) {
15+
const { jwt, expiresAt } = await createJwt(appId, timeDifference);
16+
return {
17+
type: "app",
18+
token: jwt,
19+
appId,
20+
expiresAt,
21+
};
22+
}
23+
1324
const authOptions = {
1425
id: appId,
15-
privateKey,
26+
privateKey: privateKey as string,
1627
};
1728

1829
if (timeDifference) {

src/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,12 @@ export function createAppAuth(options: StrategyOptions): AuthInterface {
3131
if (!options.appId) {
3232
throw new Error("[@octokit/auth-app] appId option is required");
3333
}
34-
35-
if (!options.privateKey) {
34+
if (!options.privateKey && !options.createJwt) {
3635
throw new Error("[@octokit/auth-app] privateKey option is required");
36+
} else if (options.privateKey && options.createJwt) {
37+
throw new Error(
38+
"[@octokit/auth-app] privateKey and createJwt options are mutually exclusive",
39+
);
3740
}
3841
if ("installationId" in options && !options.installationId) {
3942
throw new Error(

src/types.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,19 @@ type OAuthStrategyOptions = {
99
clientSecret?: string;
1010
};
1111

12+
type AppAuthStrategyOptions = {
13+
privateKey: string;
14+
};
15+
16+
type AppAuthJwtSigningStrategyOptions = {
17+
createJwt: (
18+
clientIdOrAppId: string | number,
19+
timeDifference?: number | undefined,
20+
) => Promise<{ jwt: string; expiresAt: string }>;
21+
};
22+
1223
type CommonStrategyOptions = {
1324
appId: number | string;
14-
privateKey: string;
1525
installationId?: number | string;
1626
request?: OctokitTypes.RequestInterface;
1727
cache?: Cache;
@@ -23,11 +33,12 @@ type CommonStrategyOptions = {
2333

2434
export type StrategyOptions = OAuthStrategyOptions &
2535
CommonStrategyOptions &
36+
(AppAuthStrategyOptions | AppAuthJwtSigningStrategyOptions) &
2637
Record<string, unknown>;
2738

2839
// AUTH OPTIONS
2940

30-
export type AppAuthOptions = {
41+
export type AppAuthOptions = Partial<AppAuthJwtSigningStrategyOptions> & {
3142
type: "app";
3243
};
3344

@@ -87,7 +98,9 @@ export interface FactoryInstallation<T> {
8798

8899
export interface AuthInterface {
89100
// app auth
90-
(options: AppAuthOptions): Promise<AppAuthentication>;
101+
(
102+
options: AppAuthOptions | AppAuthJwtSigningStrategyOptions,
103+
): Promise<AppAuthentication>;
91104
(options: OAuthAppAuthOptions): Promise<OAuthAppAuthentication>;
92105

93106
// installation auth without `factory` option
@@ -196,8 +209,10 @@ export type WithInstallationId = {
196209
installationId: number;
197210
};
198211

199-
export type State = Required<Omit<CommonStrategyOptions, "installationId">> & {
200-
installationId?: number;
201-
} & OAuthStrategyOptions & {
212+
export type State = Required<Omit<CommonStrategyOptions, "installationId">> &
213+
Pick<Partial<AppAuthStrategyOptions>, "privateKey"> &
214+
Pick<Partial<AppAuthJwtSigningStrategyOptions>, "createJwt"> & {
215+
installationId?: number;
216+
} & OAuthStrategyOptions & {
202217
oauthApp: OAuthAppAuth.GitHubAuthInterface;
203218
};

test/index.test.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
3939
// see https://runkit.com/gr2m/reproducable-jwt
4040
const BEARER =
4141
"eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOi0zMCwiZXhwIjo1NzAsImlzcyI6MX0.q3foRa78U3WegM5PrWLEh5N0bH1SD62OqW66ZYzArp95JBNiCbo8KAlGtiRENCIfBZT9ibDUWy82cI4g3F09mdTq3bD1xLavIfmTksIQCz5EymTWR5v6gL14LSmQdWY9lSqkgUG0XCFljWUglEP39H4yeHbFgdjvAYg3ifDS12z9oQz2ACdSpvxPiTuCC804HkPVw8Qoy0OSXvCkFU70l7VXCVUxnuhHnk8-oCGcKUspmeP6UdDnXk-Aus-eGwDfJbU2WritxxaXw6B4a3flTPojkYLSkPBr6Pi0H2-mBsW_Nvs0aLPVLKobQd4gqTkosX3967DoAG8luUMhrnxe8Q";
42+
const SIGN_JWT_CALLBACK = async (/* unused: clientId */) => {
43+
return { jwt: BEARER, expiresAt: "1970-01-01T00:09:30.000Z" };
44+
};
4245

4346
beforeEach(() => {
4447
vi.useFakeTimers().setSystemTime(0);
@@ -60,6 +63,22 @@ test("README example for app auth", async () => {
6063
});
6164
});
6265

66+
test("README example for app auth via external JWT signing", async () => {
67+
const auth = createAppAuth({
68+
appId: APP_ID,
69+
createJwt: SIGN_JWT_CALLBACK,
70+
});
71+
72+
const authentication = await auth({ type: "app" });
73+
74+
expect(authentication).toEqual({
75+
type: "app",
76+
token: BEARER,
77+
appId: 1,
78+
expiresAt: "1970-01-01T00:09:30.000Z",
79+
});
80+
});
81+
6382
test("README example for OAuth app auth", async () => {
6483
const auth = createAppAuth({
6584
appId: APP_ID,
@@ -2429,11 +2448,36 @@ test("throws helpful error if `privateKey` is not set properly (#184)", async ()
24292448
createAppAuth({
24302449
appId: APP_ID,
24312450
// @ts-ignore
2432-
privateKey: undefined,
2451+
privateKey: undefined as string,
24332452
});
24342453
}).toThrowError("[@octokit/auth-app] privateKey option is required");
24352454
});
24362455

2456+
test("throws helpful error if `privateKey` and `createJwt` are both set", async () => {
2457+
expect(() => {
2458+
createAppAuth({
2459+
appId: APP_ID,
2460+
// @ts-ignore
2461+
privateKey: PRIVATE_KEY,
2462+
// @ts-ignore
2463+
createJwt: SIGN_JWT_CALLBACK,
2464+
});
2465+
}).toThrowError(
2466+
"[@octokit/auth-app] privateKey and createJwt options are mutually exclusive",
2467+
);
2468+
});
2469+
2470+
test("does not throw an error if an `createJwt` callback is provided in lieu of a `privateKey`", async () => {
2471+
expect(() => {
2472+
createAppAuth({
2473+
appId: APP_ID,
2474+
createJwt: SIGN_JWT_CALLBACK,
2475+
// // @ts-ignore
2476+
// privateKey: undefined,
2477+
});
2478+
}).not.toThrow();
2479+
});
2480+
24372481
test("throws helpful error if `installationId` is set to a falsy value in createAppAuth() (#184)", async () => {
24382482
expect(() => {
24392483
createAppAuth({

test/typescript-validate.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ export async function readmeExample() {
2222
await auth({ type: "oauth-user", code: "123456" });
2323
}
2424

25+
export async function readmeJwtSigningExample() {
26+
const auth = createAppAuth({
27+
appId: 1,
28+
createJwt: async (clientId: string | number) => {
29+
return { jwt: "", expiresAt: "" };
30+
},
31+
});
32+
33+
await auth({ type: "app" });
34+
}
35+
2536
// https://github.com/octokit/auth-app.js/issues/282
2637
export async function issue282() {
2738
const auth = createAppAuth({

0 commit comments

Comments
 (0)