Skip to content

Commit 12e1b17

Browse files
committed
feat: initial version
1 parent 510a672 commit 12e1b17

File tree

6 files changed

+191
-0
lines changed

6 files changed

+191
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
declare module "@sagi.io/cfw-jwt" {
2+
type PrivateKey = string;
3+
type AppId = number;
4+
type Expiration = number;
5+
type Token = string;
6+
7+
type Result = {
8+
appId: AppId;
9+
expiration: Expiration;
10+
token: Token;
11+
};
12+
13+
type Payload = {
14+
iat: number;
15+
exp: number;
16+
iss: number;
17+
};
18+
19+
type GetTokenOptions = {
20+
privateKeyPEM: PrivateKey;
21+
payload: Payload;
22+
alg: "RS256";
23+
cryptoImpl: Crypto;
24+
headerAdditions: {};
25+
};
26+
27+
export function getToken(options: GetTokenOptions): Token;
28+
}

src/get-token-browser.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { GetTokenOptions, Token } from "./types";
2+
import {
3+
getEncodedMessage,
4+
getDERfromPEM,
5+
string2ArrayBuffer,
6+
base64encode
7+
} from "./utils";
8+
9+
export const getToken = async ({
10+
privateKey,
11+
payload
12+
}: GetTokenOptions): Promise<Token> => {
13+
// WebCrypto only supports PKCS#8, unfortunately
14+
if (/BEGIN RSA PRIVATE KEY/.test(privateKey)) {
15+
throw new Error(
16+
"[universal-github-app-jwt] Private Key is in PKCS#1 format, but only PKCS#8 is supported. See https://github.com/gr2m/universal-github-app-jwt#readme"
17+
);
18+
}
19+
20+
const algorithm = {
21+
name: "RSASSA-PKCS1-v1_5",
22+
hash: { name: "SHA-256" }
23+
};
24+
const header = { alg: "RS256", typ: "JWT" };
25+
26+
const privateKeyDER = getDERfromPEM(privateKey);
27+
const importedKey = await crypto.subtle.importKey(
28+
"pkcs8",
29+
privateKeyDER,
30+
algorithm,
31+
false,
32+
["sign"]
33+
);
34+
35+
const encodedMessage = getEncodedMessage(header, payload);
36+
const encodedMessageArrBuf = string2ArrayBuffer(encodedMessage);
37+
38+
const signatureArrBuf = await crypto.subtle.sign(
39+
algorithm.name,
40+
importedKey,
41+
encodedMessageArrBuf
42+
);
43+
44+
const encodedSignature = base64encode(signatureArrBuf);
45+
46+
return `${encodedMessage}.${encodedSignature}`;
47+
};

src/get-token.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import jsonwebtoken from "jsonwebtoken";
2+
3+
import { GetTokenOptions, Token } from "./types";
4+
5+
export async function getToken({
6+
privateKey,
7+
payload
8+
}: GetTokenOptions): Promise<Token> {
9+
return jsonwebtoken.sign(payload, privateKey, {
10+
algorithm: "RS256"
11+
});
12+
}

src/index.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { getToken } from "./get-token";
2+
3+
import { Options, Result } from "./types";
4+
5+
export async function githubAppJwt({
6+
id,
7+
privateKey
8+
}: Options): Promise<Result> {
9+
// When creating a JSON Web Token, it sets the "issued at time" (iat) to 30s
10+
// in the past as we have seen people running situations where the GitHub API
11+
// claimed the iat would be in future. It turned out the clocks on the
12+
// different machine were not in sync.
13+
const now = Math.floor(Date.now() / 1000) - 30;
14+
const expiration = now + 60 * 10; // JWT expiration time (10 minute maximum)
15+
16+
const payload = {
17+
iat: now, // Issued at time
18+
exp: expiration,
19+
iss: id
20+
};
21+
22+
const token = await getToken({
23+
privateKey,
24+
payload
25+
});
26+
27+
return {
28+
appId: id,
29+
expiration,
30+
token
31+
};
32+
}

src/types.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export type PrivateKey = string;
2+
export type AppId = number;
3+
export type Expiration = number;
4+
export type Token = string;
5+
6+
export type Options = {
7+
id: AppId;
8+
privateKey: PrivateKey;
9+
crypto?: Crypto;
10+
};
11+
12+
export type Result = {
13+
appId: AppId;
14+
expiration: Expiration;
15+
token: Token;
16+
};
17+
18+
export type Payload = {
19+
iat: number;
20+
exp: number;
21+
iss: number;
22+
};
23+
24+
export type GetTokenOptions = {
25+
privateKey: PrivateKey;
26+
payload: Payload;
27+
};

src/utils.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
export function string2ArrayBuffer(str: string) {
2+
const buf = new ArrayBuffer(str.length);
3+
const bufView = new Uint8Array(buf);
4+
for (let i = 0, strLen = str.length; i < strLen; i++) {
5+
bufView[i] = str.charCodeAt(i);
6+
}
7+
return buf;
8+
}
9+
10+
export function getDERfromPEM(pem: string): ArrayBuffer {
11+
const pemB64 = pem
12+
.trim()
13+
.split("\n")
14+
.slice(1, -1) // Remove the --- BEGIN / END PRIVATE KEY ---
15+
.join("");
16+
17+
const decoded = atob(pemB64);
18+
return string2ArrayBuffer(decoded);
19+
}
20+
21+
export function getEncodedMessage(header: object, payload: object): string {
22+
return `${base64encodeJSON(header)}.${base64encodeJSON(payload)}`;
23+
}
24+
25+
export function base64encode(buffer: ArrayBuffer): string {
26+
var binary = "";
27+
var bytes = new Uint8Array(buffer);
28+
var len = bytes.byteLength;
29+
for (var i = 0; i < len; i++) {
30+
binary += String.fromCharCode(bytes[i]);
31+
}
32+
33+
return fromBase64(btoa(binary));
34+
}
35+
36+
function fromBase64(base64: string): string {
37+
return base64
38+
.replace(/=/g, "")
39+
.replace(/\+/g, "-")
40+
.replace(/\//g, "_");
41+
}
42+
43+
function base64encodeJSON(obj: object) {
44+
return fromBase64(btoa(JSON.stringify(obj)));
45+
}

0 commit comments

Comments
 (0)