-
Notifications
You must be signed in to change notification settings - Fork 1
/
jwt.ts
140 lines (122 loc) · 4.04 KB
/
jwt.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
import * as Base64Url from "std/encoding/base64url.ts";
import { sign, verify } from "ed25519";
import { check, failWith, JsonValue } from "./utils.ts";
export type ClaimSet = Record<string, JsonValue>;
export type ParseResult<Key> = {
key: Key;
claimSet: ClaimSet;
};
export const alwaysPass = Symbol();
export type AlwaysPass = typeof alwaysPass;
export async function parseJwt<
Key extends { publicKey: Uint8Array | AlwaysPass },
>(
jwt: string,
getKey: (kid: string) => Key,
flags: { allowUnsigned: boolean },
): Promise<ParseResult<Key>> {
const [headerText, claimSetText, signatureText, tail] = jwt.split(".", 4);
check(
signatureText !== undefined && tail === undefined,
"token must have three segments",
);
const header = decodeObject(headerText);
const kid = header["kid"];
check(
typeof kid === "string" || typeof kid === "number",
"invalid or missing kid in token",
);
const key = getKey(String(kid));
switch (header.alg) {
case "EdDSA":
{
const crv = header["crv"];
check(
crv === "Ed25519" || crv === undefined,
"unsupported crv in token",
);
if (key.publicKey !== alwaysPass) {
const encoder = new TextEncoder();
const bytes = encoder.encode(`${headerText}.${claimSetText}`);
const signature = Base64Url.decode(signatureText);
const isValid = await verify(signature, bytes, key.publicKey);
check(isValid, "invalid signature in token");
}
}
break;
case "none":
check(flags.allowUnsigned, "token has not been signed");
break;
default:
failWith("unsupported alg in token");
}
const claimSet = decodeObject(claimSetText);
return { key, claimSet };
}
export async function createJwt(
claimSet: ClaimSet,
privateKey: Uint8Array,
kid: string,
): Promise<string> {
const header = { "alg": "EdDSA", kid };
const body = `${encodeObject(header)}.${encodeObject(claimSet)}`;
const signature = await sign(
new TextEncoder().encode(body),
privateKey,
);
return `${body}.${Base64Url.encode(signature)}`;
}
export function validateSubject(claims: ClaimSet) {
const { sub } = claims;
check(isUndefinedOrStringOrStringArray(sub), "invalid sub in token");
}
export function validateAudience(
claims: ClaimSet,
audience: string,
) {
const { aud } = claims;
check(isUndefinedOrStringOrStringArray(aud), "invalid aud in token");
check(
aud === undefined ||
(Array.isArray(aud) && aud.includes(audience)) ||
aud === audience,
"token audience does not match",
);
}
export function validateExpired(claims: ClaimSet, now = Date.now()) {
const { exp, nbf } = claims;
check(isUndefinedOrNumber(exp), "invalid exp in token");
check(isUndefinedOrNumber(nbf), "invalid nbf in token");
now = Math.floor(now / 1000); // milliseconds to seconds
const leeway = 300; // 5min in seconds
check(exp === undefined || now - exp <= leeway, "token has expired");
check(nbf === undefined || nbf - now <= leeway, "token not yet valid");
}
function decodeObject(text: string): Record<string, JsonValue> {
let object;
try {
object = JSON.parse(new TextDecoder().decode(Base64Url.decode(text)));
} catch {
failWith("invalid json in token");
}
check(isObject(object), "json, in token, must be a object");
return object;
}
function encodeObject(object: Record<string, JsonValue>): string {
return Base64Url.encode(new TextEncoder().encode(JSON.stringify(object)));
}
function isObject(o: unknown): o is Record<string, JsonValue> {
return o != null && Array.isArray(o) == false && typeof o === "object";
}
function isUndefinedOrString(o: unknown): o is undefined | string {
return o === undefined || typeof o === "string";
}
function isUndefinedOrNumber(o: unknown): o is undefined | number {
return o === undefined || Number.isSafeInteger(o);
}
function isUndefinedOrStringOrStringArray(
o: unknown,
): o is undefined | string | string[] {
return isUndefinedOrString(o) ||
(Array.isArray(o) && o.every((e) => typeof e === "string"));
}