@atproto/jwk 0.1.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/CHANGELOG.md +7 -0
- package/LICENSE.txt +7 -0
- package/dist/alg.d.ts +3 -0
- package/dist/alg.d.ts.map +1 -0
- package/dist/alg.js +90 -0
- package/dist/alg.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +62 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/jwk.d.ts +2424 -0
- package/dist/jwk.d.ts.map +1 -0
- package/dist/jwk.js +112 -0
- package/dist/jwk.js.map +1 -0
- package/dist/jwks.d.ts +1770 -0
- package/dist/jwks.d.ts.map +1 -0
- package/dist/jwks.js +12 -0
- package/dist/jwks.js.map +1 -0
- package/dist/jwt-decode.d.ts +6 -0
- package/dist/jwt-decode.d.ts.map +1 -0
- package/dist/jwt-decode.js +20 -0
- package/dist/jwt-decode.js.map +1 -0
- package/dist/jwt-verify.d.ts +20 -0
- package/dist/jwt-verify.d.ts.map +1 -0
- package/dist/jwt-verify.js +3 -0
- package/dist/jwt-verify.js.map +1 -0
- package/dist/jwt.d.ts +1785 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +150 -0
- package/dist/jwt.js.map +1 -0
- package/dist/key.d.ts +38 -0
- package/dist/key.d.ts.map +1 -0
- package/dist/key.js +131 -0
- package/dist/key.js.map +1 -0
- package/dist/keyset.d.ts +41 -0
- package/dist/keyset.d.ts.map +1 -0
- package/dist/keyset.js +234 -0
- package/dist/keyset.js.map +1 -0
- package/dist/util.d.ts +48 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +143 -0
- package/dist/util.js.map +1 -0
- package/package.json +38 -0
- package/src/alg.ts +98 -0
- package/src/errors.ts +56 -0
- package/src/index.ts +10 -0
- package/src/jwk.ts +141 -0
- package/src/jwks.ts +15 -0
- package/src/jwt-decode.ts +27 -0
- package/src/jwt-verify.ts +22 -0
- package/src/jwt.ts +173 -0
- package/src/key.ts +93 -0
- package/src/keyset.ts +240 -0
- package/src/util.ts +181 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +4 -0
package/src/jwt.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
import { jwkPubSchema } from './jwk.js'
|
|
4
|
+
import { jwtCharsRefinement, segmentedStringRefinementFactory } from './util.js'
|
|
5
|
+
|
|
6
|
+
export const signedJwtSchema = z
|
|
7
|
+
.string()
|
|
8
|
+
.superRefine(jwtCharsRefinement)
|
|
9
|
+
.superRefine(segmentedStringRefinementFactory(3))
|
|
10
|
+
|
|
11
|
+
export type SignedJwt = z.infer<typeof signedJwtSchema>
|
|
12
|
+
export const isSignedJwt = (data: unknown): data is SignedJwt =>
|
|
13
|
+
signedJwtSchema.safeParse(data).success
|
|
14
|
+
|
|
15
|
+
export const unsignedJwtSchema = z
|
|
16
|
+
.string()
|
|
17
|
+
.superRefine(jwtCharsRefinement)
|
|
18
|
+
.superRefine(segmentedStringRefinementFactory(2))
|
|
19
|
+
|
|
20
|
+
export type UnsignedJwt = z.infer<typeof unsignedJwtSchema>
|
|
21
|
+
export const isUnsignedJwt = (data: unknown): data is UnsignedJwt =>
|
|
22
|
+
unsignedJwtSchema.safeParse(data).success
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4}
|
|
26
|
+
*/
|
|
27
|
+
export const jwtHeaderSchema = z.object({
|
|
28
|
+
/** "alg" (Algorithm) Header Parameter */
|
|
29
|
+
alg: z.string(),
|
|
30
|
+
/** "jku" (JWK Set URL) Header Parameter */
|
|
31
|
+
jku: z.string().url().optional(),
|
|
32
|
+
/** "jwk" (JSON Web Key) Header Parameter */
|
|
33
|
+
jwk: z
|
|
34
|
+
.object({
|
|
35
|
+
kty: z.string(),
|
|
36
|
+
crv: z.string().optional(),
|
|
37
|
+
x: z.string().optional(),
|
|
38
|
+
y: z.string().optional(),
|
|
39
|
+
e: z.string().optional(),
|
|
40
|
+
n: z.string().optional(),
|
|
41
|
+
})
|
|
42
|
+
.optional(),
|
|
43
|
+
/** "kid" (Key ID) Header Parameter */
|
|
44
|
+
kid: z.string().optional(),
|
|
45
|
+
/** "x5u" (X.509 URL) Header Parameter */
|
|
46
|
+
x5u: z.string().optional(),
|
|
47
|
+
/** "x5c" (X.509 Certificate Chain) Header Parameter */
|
|
48
|
+
x5c: z.array(z.string()).optional(),
|
|
49
|
+
/** "x5t" (X.509 Certificate SHA-1 Thumbprint) Header Parameter */
|
|
50
|
+
x5t: z.string().optional(),
|
|
51
|
+
/** "x5t#S256" (X.509 Certificate SHA-256 Thumbprint) Header Parameter */
|
|
52
|
+
'x5t#S256': z.string().optional(),
|
|
53
|
+
/** "typ" (Type) Header Parameter */
|
|
54
|
+
typ: z.string().optional(),
|
|
55
|
+
/** "cty" (Content Type) Header Parameter */
|
|
56
|
+
cty: z.string().optional(),
|
|
57
|
+
/** "crit" (Critical) Header Parameter */
|
|
58
|
+
crit: z.array(z.string()).optional(),
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
export type JwtHeader = z.infer<typeof jwtHeaderSchema>
|
|
62
|
+
|
|
63
|
+
// https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
64
|
+
export const jwtPayloadSchema = z.object({
|
|
65
|
+
iss: z.string().optional(),
|
|
66
|
+
aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(),
|
|
67
|
+
sub: z.string().optional(),
|
|
68
|
+
exp: z.number().int().optional(),
|
|
69
|
+
nbf: z.number().int().optional(),
|
|
70
|
+
iat: z.number().int().optional(),
|
|
71
|
+
jti: z.string().optional(),
|
|
72
|
+
htm: z.string().optional(),
|
|
73
|
+
htu: z.string().optional(),
|
|
74
|
+
ath: z.string().optional(),
|
|
75
|
+
acr: z.string().optional(),
|
|
76
|
+
azp: z.string().optional(),
|
|
77
|
+
amr: z.array(z.string()).optional(),
|
|
78
|
+
// https://datatracker.ietf.org/doc/html/rfc7800
|
|
79
|
+
cnf: z
|
|
80
|
+
.object({
|
|
81
|
+
kid: z.string().optional(), // Key ID
|
|
82
|
+
jwk: jwkPubSchema.optional(), // JWK
|
|
83
|
+
jwe: z.string().optional(), // Encrypted key
|
|
84
|
+
jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided)
|
|
85
|
+
|
|
86
|
+
// https://datatracker.ietf.org/doc/html/rfc9449#section-6.1
|
|
87
|
+
jkt: z.string().optional(),
|
|
88
|
+
|
|
89
|
+
// https://datatracker.ietf.org/doc/html/rfc8705
|
|
90
|
+
'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint
|
|
91
|
+
|
|
92
|
+
// https://datatracker.ietf.org/doc/html/rfc9203
|
|
93
|
+
osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation
|
|
94
|
+
})
|
|
95
|
+
.optional(),
|
|
96
|
+
|
|
97
|
+
client_id: z.string().optional(),
|
|
98
|
+
|
|
99
|
+
scope: z.string().optional(),
|
|
100
|
+
nonce: z.string().optional(),
|
|
101
|
+
|
|
102
|
+
at_hash: z.string().optional(),
|
|
103
|
+
c_hash: z.string().optional(),
|
|
104
|
+
s_hash: z.string().optional(),
|
|
105
|
+
auth_time: z.number().int().optional(),
|
|
106
|
+
|
|
107
|
+
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
108
|
+
|
|
109
|
+
// OpenID: "profile" scope
|
|
110
|
+
name: z.string().optional(),
|
|
111
|
+
family_name: z.string().optional(),
|
|
112
|
+
given_name: z.string().optional(),
|
|
113
|
+
middle_name: z.string().optional(),
|
|
114
|
+
nickname: z.string().optional(),
|
|
115
|
+
preferred_username: z.string().optional(),
|
|
116
|
+
gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values
|
|
117
|
+
picture: z.string().url().optional(),
|
|
118
|
+
profile: z.string().url().optional(),
|
|
119
|
+
website: z.string().url().optional(),
|
|
120
|
+
birthdate: z
|
|
121
|
+
.string()
|
|
122
|
+
.regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD
|
|
123
|
+
.optional(),
|
|
124
|
+
zoneinfo: z
|
|
125
|
+
.string()
|
|
126
|
+
.regex(/^[A-Za-z0-9_/]+$/)
|
|
127
|
+
.optional(),
|
|
128
|
+
locale: z
|
|
129
|
+
.string()
|
|
130
|
+
.regex(/^[a-z]{2}(-[A-Z]{2})?$/)
|
|
131
|
+
.optional(),
|
|
132
|
+
updated_at: z.number().int().optional(),
|
|
133
|
+
|
|
134
|
+
// OpenID: "email" scope
|
|
135
|
+
email: z.string().optional(),
|
|
136
|
+
email_verified: z.boolean().optional(),
|
|
137
|
+
|
|
138
|
+
// OpenID: "phone" scope
|
|
139
|
+
phone_number: z.string().optional(),
|
|
140
|
+
phone_number_verified: z.boolean().optional(),
|
|
141
|
+
|
|
142
|
+
// OpenID: "address" scope
|
|
143
|
+
// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim
|
|
144
|
+
address: z
|
|
145
|
+
.object({
|
|
146
|
+
formatted: z.string().optional(),
|
|
147
|
+
street_address: z.string().optional(),
|
|
148
|
+
locality: z.string().optional(),
|
|
149
|
+
region: z.string().optional(),
|
|
150
|
+
postal_code: z.string().optional(),
|
|
151
|
+
country: z.string().optional(),
|
|
152
|
+
})
|
|
153
|
+
.optional(),
|
|
154
|
+
|
|
155
|
+
// https://datatracker.ietf.org/doc/html/rfc9396#section-14.2
|
|
156
|
+
authorization_details: z
|
|
157
|
+
.array(
|
|
158
|
+
z
|
|
159
|
+
.object({
|
|
160
|
+
type: z.string(),
|
|
161
|
+
// https://datatracker.ietf.org/doc/html/rfc9396#section-2.2
|
|
162
|
+
locations: z.array(z.string()).optional(),
|
|
163
|
+
actions: z.array(z.string()).optional(),
|
|
164
|
+
datatypes: z.array(z.string()).optional(),
|
|
165
|
+
identifier: z.string().optional(),
|
|
166
|
+
privileges: z.array(z.string()).optional(),
|
|
167
|
+
})
|
|
168
|
+
.passthrough(),
|
|
169
|
+
)
|
|
170
|
+
.optional(),
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
export type JwtPayload = z.infer<typeof jwtPayloadSchema>
|
package/src/key.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { jwkAlgorithms } from './alg.js'
|
|
2
|
+
import { JwkError } from './errors.js'
|
|
3
|
+
import { Jwk, jwkSchema } from './jwk.js'
|
|
4
|
+
import { VerifyOptions, VerifyPayload, VerifyResult } from './jwt-verify.js'
|
|
5
|
+
import { JwtHeader, JwtPayload, SignedJwt } from './jwt.js'
|
|
6
|
+
import { cachedGetter } from './util.js'
|
|
7
|
+
|
|
8
|
+
export abstract class Key {
|
|
9
|
+
constructor(protected readonly jwk: Readonly<Jwk>) {
|
|
10
|
+
// A key should always be used either for signing or encryption.
|
|
11
|
+
if (!jwk.use) throw new JwkError('Missing "use" Parameter value')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
get isPrivate(): boolean {
|
|
15
|
+
const { jwk } = this
|
|
16
|
+
if ('d' in jwk && jwk.d !== undefined) return true
|
|
17
|
+
if ('k' in jwk && jwk.k !== undefined) return true
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
get isSymetric(): boolean {
|
|
22
|
+
const { jwk } = this
|
|
23
|
+
if ('k' in jwk && jwk.k !== undefined) return true
|
|
24
|
+
return false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get privateJwk(): Jwk | undefined {
|
|
28
|
+
return this.isPrivate ? this.jwk : undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@cachedGetter
|
|
32
|
+
get publicJwk(): Jwk | undefined {
|
|
33
|
+
if (this.isSymetric) return undefined
|
|
34
|
+
if (this.isPrivate) {
|
|
35
|
+
const { d: _, ...jwk } = this.jwk as any
|
|
36
|
+
return jwk
|
|
37
|
+
}
|
|
38
|
+
return this.jwk
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@cachedGetter
|
|
42
|
+
get bareJwk(): Jwk | undefined {
|
|
43
|
+
if (this.isSymetric) return undefined
|
|
44
|
+
const { kty, crv, e, n, x, y } = this.jwk as any
|
|
45
|
+
return jwkSchema.parse({ crv, e, kty, n, x, y })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
get use() {
|
|
49
|
+
return this.jwk.use!
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The (forced) algorithm to use. If not provided, the key will be usable with
|
|
54
|
+
* any of the algorithms in {@link algorithms}.
|
|
55
|
+
*
|
|
56
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 | "alg" (Algorithm) Header Parameter Values for JWS}
|
|
57
|
+
*/
|
|
58
|
+
get alg() {
|
|
59
|
+
return this.jwk.alg
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get kid() {
|
|
63
|
+
return this.jwk.kid
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get crv() {
|
|
67
|
+
return (this.jwk as { crv: undefined } | Extract<Jwk, { crv: unknown }>).crv
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* All the algorithms that this key can be used with. If `alg` is provided,
|
|
72
|
+
* this set will only contain that algorithm.
|
|
73
|
+
*/
|
|
74
|
+
@cachedGetter
|
|
75
|
+
get algorithms(): readonly string[] {
|
|
76
|
+
return Array.from(jwkAlgorithms(this.jwk))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Create a signed JWT
|
|
81
|
+
*/
|
|
82
|
+
abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise<SignedJwt>
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Verify the signature, headers and payload of a JWT
|
|
86
|
+
*
|
|
87
|
+
* @throws {JwtVerifyError} if the JWT is invalid
|
|
88
|
+
*/
|
|
89
|
+
abstract verifyJwt<
|
|
90
|
+
P extends VerifyPayload = JwtPayload,
|
|
91
|
+
C extends string = string,
|
|
92
|
+
>(token: SignedJwt, options?: VerifyOptions<C>): Promise<VerifyResult<P, C>>
|
|
93
|
+
}
|
package/src/keyset.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ERR_JWKS_NO_MATCHING_KEY,
|
|
3
|
+
ERR_JWK_NOT_FOUND,
|
|
4
|
+
ERR_JWT_INVALID,
|
|
5
|
+
JwkError,
|
|
6
|
+
JwtCreateError,
|
|
7
|
+
JwtVerifyError,
|
|
8
|
+
} from './errors.js'
|
|
9
|
+
import { Jwk } from './jwk.js'
|
|
10
|
+
import { Jwks } from './jwks.js'
|
|
11
|
+
import { unsafeDecodeJwt } from './jwt-decode.js'
|
|
12
|
+
import { VerifyOptions, VerifyResult } from './jwt-verify.js'
|
|
13
|
+
import { JwtHeader, JwtPayload, SignedJwt } from './jwt.js'
|
|
14
|
+
import { Key } from './key.js'
|
|
15
|
+
import {
|
|
16
|
+
Override,
|
|
17
|
+
cachedGetter,
|
|
18
|
+
isDefined,
|
|
19
|
+
matchesAny,
|
|
20
|
+
preferredOrderCmp,
|
|
21
|
+
} from './util.js'
|
|
22
|
+
|
|
23
|
+
export type JwtSignHeader = Override<JwtHeader, Pick<KeySearch, 'alg' | 'kid'>>
|
|
24
|
+
|
|
25
|
+
export type JwtPayloadGetter<P = JwtPayload> = (
|
|
26
|
+
header: JwtHeader,
|
|
27
|
+
key: Key,
|
|
28
|
+
) => P | PromiseLike<P>
|
|
29
|
+
|
|
30
|
+
export type KeySearch = {
|
|
31
|
+
use?: 'sig' | 'enc'
|
|
32
|
+
kid?: string | string[]
|
|
33
|
+
alg?: string | string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const extractPrivateJwk = (key: Key): Jwk | undefined => key.privateJwk
|
|
37
|
+
const extractPublicJwk = (key: Key): Jwk | undefined => key.publicJwk
|
|
38
|
+
|
|
39
|
+
export class Keyset<K extends Key = Key> implements Iterable<K> {
|
|
40
|
+
private readonly keys: readonly K[]
|
|
41
|
+
|
|
42
|
+
constructor(
|
|
43
|
+
iterable: Iterable<K | null | undefined | false>,
|
|
44
|
+
/**
|
|
45
|
+
* The preferred algorithms to use when signing a JWT using this keyset.
|
|
46
|
+
*
|
|
47
|
+
* @see {@link https://datatracker.ietf.org/doc/html/rfc7518#section-3.1}
|
|
48
|
+
*/
|
|
49
|
+
public readonly preferredSigningAlgorithms: readonly string[] = iterable instanceof
|
|
50
|
+
Keyset
|
|
51
|
+
? [...iterable.preferredSigningAlgorithms]
|
|
52
|
+
: [
|
|
53
|
+
// Prefer elliptic curve algorithms
|
|
54
|
+
'EdDSA',
|
|
55
|
+
'ES256K',
|
|
56
|
+
'ES256',
|
|
57
|
+
// https://datatracker.ietf.org/doc/html/rfc7518#section-3.5
|
|
58
|
+
'PS256',
|
|
59
|
+
'PS384',
|
|
60
|
+
'PS512',
|
|
61
|
+
'HS256',
|
|
62
|
+
'HS384',
|
|
63
|
+
'HS512',
|
|
64
|
+
],
|
|
65
|
+
) {
|
|
66
|
+
const keys: K[] = []
|
|
67
|
+
|
|
68
|
+
const kids = new Set<string>()
|
|
69
|
+
for (const key of iterable) {
|
|
70
|
+
if (!key) continue
|
|
71
|
+
|
|
72
|
+
keys.push(key)
|
|
73
|
+
|
|
74
|
+
if (key.kid) {
|
|
75
|
+
if (kids.has(key.kid)) throw new JwkError(`Duplicate key: ${key.kid}`)
|
|
76
|
+
else kids.add(key.kid)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
this.keys = Object.freeze(keys)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@cachedGetter
|
|
84
|
+
get signAlgorithms(): readonly string[] {
|
|
85
|
+
const algorithms = new Set<string>()
|
|
86
|
+
for (const key of this) {
|
|
87
|
+
if (key.use !== 'sig') continue
|
|
88
|
+
for (const alg of key.algorithms) {
|
|
89
|
+
algorithms.add(alg)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return Object.freeze(
|
|
93
|
+
[...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@cachedGetter
|
|
98
|
+
get publicJwks(): Jwks {
|
|
99
|
+
return {
|
|
100
|
+
keys: Array.from(this, extractPublicJwk).filter(isDefined),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
@cachedGetter
|
|
105
|
+
get privateJwks(): Jwks {
|
|
106
|
+
return {
|
|
107
|
+
keys: Array.from(this, extractPrivateJwk).filter(isDefined),
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
has(kid: string): boolean {
|
|
112
|
+
return this.keys.some((key) => key.kid === kid)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
get(search: KeySearch): K {
|
|
116
|
+
for (const key of this.list(search)) {
|
|
117
|
+
return key
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
throw new JwkError(
|
|
121
|
+
`Key not found ${search.kid || search.alg || '<unknown>'}`,
|
|
122
|
+
ERR_JWK_NOT_FOUND,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
*list(search: KeySearch): Generator<K> {
|
|
127
|
+
// Optimization: Empty string or empty array will not match any key
|
|
128
|
+
if (search.kid?.length === 0) return
|
|
129
|
+
if (search.alg?.length === 0) return
|
|
130
|
+
|
|
131
|
+
for (const key of this) {
|
|
132
|
+
if (search.use && key.use !== search.use) continue
|
|
133
|
+
|
|
134
|
+
if (Array.isArray(search.kid)) {
|
|
135
|
+
if (!key.kid || !search.kid.includes(key.kid)) continue
|
|
136
|
+
} else if (search.kid) {
|
|
137
|
+
if (key.kid !== search.kid) continue
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (Array.isArray(search.alg)) {
|
|
141
|
+
if (!search.alg.some((a) => key.algorithms.includes(a))) continue
|
|
142
|
+
} else if (typeof search.alg === 'string') {
|
|
143
|
+
if (!key.algorithms.includes(search.alg)) continue
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
yield key
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
findKey({ kid, alg, use }: KeySearch): [key: Key, alg: string] {
|
|
151
|
+
const matchingKeys: Key[] = []
|
|
152
|
+
|
|
153
|
+
for (const key of this.list({ kid, alg, use })) {
|
|
154
|
+
// Not a signing key
|
|
155
|
+
if (!key.isPrivate) continue
|
|
156
|
+
|
|
157
|
+
// Skip negotiation if a specific "alg" was provided
|
|
158
|
+
if (typeof alg === 'string') return [key, alg]
|
|
159
|
+
|
|
160
|
+
matchingKeys.push(key)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isAllowedAlg = matchesAny(alg)
|
|
164
|
+
const candidates = matchingKeys.map(
|
|
165
|
+
(key) => [key, key.algorithms.filter(isAllowedAlg)] as const,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
// Return the first candidates that matches the preferred algorithms
|
|
169
|
+
for (const prefAlg of this.preferredSigningAlgorithms) {
|
|
170
|
+
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
171
|
+
if (matchingAlgs.includes(prefAlg)) return [matchingKey, prefAlg]
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Return any candidate
|
|
176
|
+
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
177
|
+
for (const alg of matchingAlgs) {
|
|
178
|
+
return [matchingKey, alg]
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw new JwkError(
|
|
183
|
+
`No singing key found for ${kid || alg || use || '<unknown>'}`,
|
|
184
|
+
ERR_JWK_NOT_FOUND,
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
[Symbol.iterator](): IterableIterator<K> {
|
|
189
|
+
return this.keys.values()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async createJwt(
|
|
193
|
+
{ alg: sAlg, kid: sKid, ...header }: JwtSignHeader,
|
|
194
|
+
payload: JwtPayload | JwtPayloadGetter,
|
|
195
|
+
): Promise<SignedJwt> {
|
|
196
|
+
try {
|
|
197
|
+
const [key, alg] = this.findKey({ alg: sAlg, kid: sKid, use: 'sig' })
|
|
198
|
+
const protectedHeader = { ...header, alg, kid: key.kid }
|
|
199
|
+
|
|
200
|
+
if (typeof payload === 'function') {
|
|
201
|
+
payload = await payload(protectedHeader, key)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return await key.createJwt(protectedHeader, payload)
|
|
205
|
+
} catch (err) {
|
|
206
|
+
throw JwtCreateError.from(err)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async verifyJwt<
|
|
211
|
+
P extends Record<string, unknown> = JwtPayload,
|
|
212
|
+
C extends string = string,
|
|
213
|
+
>(
|
|
214
|
+
token: SignedJwt,
|
|
215
|
+
options?: VerifyOptions<C>,
|
|
216
|
+
): Promise<VerifyResult<P, C> & { key: K }> {
|
|
217
|
+
const { header } = unsafeDecodeJwt(token)
|
|
218
|
+
const { kid, alg } = header
|
|
219
|
+
|
|
220
|
+
const errors: unknown[] = []
|
|
221
|
+
|
|
222
|
+
for (const key of this.list({ kid, alg })) {
|
|
223
|
+
try {
|
|
224
|
+
const result = await key.verifyJwt<P, C>(token, options)
|
|
225
|
+
return { ...result, key }
|
|
226
|
+
} catch (err) {
|
|
227
|
+
errors.push(err)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
switch (errors.length) {
|
|
232
|
+
case 0:
|
|
233
|
+
throw new JwtVerifyError('No key matched', ERR_JWKS_NO_MATCHING_KEY)
|
|
234
|
+
case 1:
|
|
235
|
+
throw JwtVerifyError.from(errors[0], ERR_JWT_INVALID)
|
|
236
|
+
default:
|
|
237
|
+
throw JwtVerifyError.from(errors, ERR_JWT_INVALID)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/util.ts
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { base64url } from 'multiformats/bases/base64'
|
|
2
|
+
import { RefinementCtx, ZodIssueCode } from 'zod'
|
|
3
|
+
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/ban-types
|
|
5
|
+
export type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
|
6
|
+
export type Override<T, V> = Simplify<V & Omit<T, keyof V>>
|
|
7
|
+
|
|
8
|
+
export type RequiredKey<T, K extends string> = Simplify<
|
|
9
|
+
string extends K
|
|
10
|
+
? T
|
|
11
|
+
: {
|
|
12
|
+
[L in K]: Exclude<L extends keyof T ? T[L] : unknown, undefined>
|
|
13
|
+
} & Omit<T, K>
|
|
14
|
+
>
|
|
15
|
+
|
|
16
|
+
export const isDefined = <T>(i: T | undefined): i is T => i !== undefined
|
|
17
|
+
|
|
18
|
+
export const preferredOrderCmp =
|
|
19
|
+
<T>(order: readonly T[]) =>
|
|
20
|
+
(a: T, b: T) => {
|
|
21
|
+
const aIdx = order.indexOf(a)
|
|
22
|
+
const bIdx = order.indexOf(b)
|
|
23
|
+
if (aIdx === bIdx) return 0
|
|
24
|
+
if (aIdx === -1) return 1
|
|
25
|
+
if (bIdx === -1) return -1
|
|
26
|
+
return aIdx - bIdx
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function matchesAny<T extends string | number | symbol | boolean>(
|
|
30
|
+
value: null | undefined | T | readonly T[],
|
|
31
|
+
): (v: unknown) => v is T {
|
|
32
|
+
return value == null
|
|
33
|
+
? (v): v is T => true
|
|
34
|
+
: Array.isArray(value)
|
|
35
|
+
? (v): v is T => value.includes(v)
|
|
36
|
+
: (v): v is T => v === value
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Decorator to cache the result of a getter on a class instance.
|
|
41
|
+
*/
|
|
42
|
+
export const cachedGetter = <T extends object, V>(
|
|
43
|
+
target: (this: T) => V,
|
|
44
|
+
_context: ClassGetterDecoratorContext<T, V>,
|
|
45
|
+
) => {
|
|
46
|
+
return function (this: T) {
|
|
47
|
+
const value = target.call(this)
|
|
48
|
+
Object.defineProperty(this, target.name, {
|
|
49
|
+
get: () => value,
|
|
50
|
+
enumerable: true,
|
|
51
|
+
configurable: true,
|
|
52
|
+
})
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const decoder = new TextDecoder()
|
|
58
|
+
export function parseB64uJson(input: string): unknown {
|
|
59
|
+
const inputBytes = base64url.baseDecode(input)
|
|
60
|
+
const json = decoder.decode(inputBytes)
|
|
61
|
+
return JSON.parse(json)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* // jwtSchema will only allow base64url chars & "." (dot)
|
|
68
|
+
* const jwtSchema = z.string().superRefine(jwtCharsRefinement)
|
|
69
|
+
* ```
|
|
70
|
+
*/
|
|
71
|
+
export const jwtCharsRefinement = (data: string, ctx: RefinementCtx): void => {
|
|
72
|
+
// Note: this is a hot path, let's avoid using a RegExp
|
|
73
|
+
let char
|
|
74
|
+
|
|
75
|
+
for (let i = 0; i < data.length; i++) {
|
|
76
|
+
char = data.charCodeAt(i)
|
|
77
|
+
|
|
78
|
+
if (
|
|
79
|
+
// Base64 URL encoding (most frequent)
|
|
80
|
+
(65 <= char && char <= 90) || // A-Z
|
|
81
|
+
(97 <= char && char <= 122) || // a-z
|
|
82
|
+
(48 <= char && char <= 57) || // 0-9
|
|
83
|
+
char === 45 || // -
|
|
84
|
+
char === 95 || // _
|
|
85
|
+
// Boundary (least frequent, check last)
|
|
86
|
+
char === 46 // .
|
|
87
|
+
) {
|
|
88
|
+
// continue
|
|
89
|
+
} else {
|
|
90
|
+
// Invalid char might be a surrogate pair
|
|
91
|
+
const invalidChar = String.fromCodePoint(data.codePointAt(i)!)
|
|
92
|
+
return ctx.addIssue({
|
|
93
|
+
code: ZodIssueCode.custom,
|
|
94
|
+
message: `Invalid character "${invalidChar}" in JWT at position ${i}`,
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* type SegmentedString3 = SegmentedString<3> // `${string}.${string}.${string}`
|
|
104
|
+
* type SegmentedString4 = SegmentedString<4> // `${string}.${string}.${string}.${string}`
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* @note
|
|
108
|
+
* This utility only provides one way type safety (A SegmentedString<4> can be
|
|
109
|
+
* assigned to SegmentedString<3> but not vice versa). The purpose of this
|
|
110
|
+
* utility is to improve DX by avoiding as many potential errors as build time.
|
|
111
|
+
* DO NOT rely on this to enforce security or data integrity.
|
|
112
|
+
*/
|
|
113
|
+
type SegmentedString<
|
|
114
|
+
C extends number,
|
|
115
|
+
Acc extends string[] = [string],
|
|
116
|
+
> = Acc['length'] extends C
|
|
117
|
+
? `${Acc[0]}`
|
|
118
|
+
: `${Acc[0]}.${SegmentedString<C, [string, ...Acc]>}`
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @example
|
|
122
|
+
* ```ts
|
|
123
|
+
* const jwtSchema = z.string().superRefine(segmentedStringRefinementFactory(3))
|
|
124
|
+
* type Jwt = z.infer<typeof jwtSchema> // `${string}.${string}.${string}`
|
|
125
|
+
* ```
|
|
126
|
+
*/
|
|
127
|
+
export const segmentedStringRefinementFactory = <C extends number>(
|
|
128
|
+
count: C,
|
|
129
|
+
minPartLength = 2,
|
|
130
|
+
) => {
|
|
131
|
+
if (!Number.isFinite(count) || count < 1 || (count | 0) !== count) {
|
|
132
|
+
throw new TypeError(`Count must be a natural number (got ${count})`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const minTotalLength = count * minPartLength + (count - 1)
|
|
136
|
+
const errorPrefix = `Invalid JWT format`
|
|
137
|
+
|
|
138
|
+
return (data: string, ctx: RefinementCtx): data is SegmentedString<C> => {
|
|
139
|
+
if (data.length < minTotalLength) {
|
|
140
|
+
ctx.addIssue({
|
|
141
|
+
code: ZodIssueCode.custom,
|
|
142
|
+
message: `${errorPrefix}: too short`,
|
|
143
|
+
})
|
|
144
|
+
return false
|
|
145
|
+
}
|
|
146
|
+
let currentStart = 0
|
|
147
|
+
for (let i = 0; i < count - 1; i++) {
|
|
148
|
+
const nextDot = data.indexOf('.', currentStart)
|
|
149
|
+
if (nextDot === -1) {
|
|
150
|
+
ctx.addIssue({
|
|
151
|
+
code: ZodIssueCode.custom,
|
|
152
|
+
message: `${errorPrefix}: expected ${count} segments, got ${i + 1}`,
|
|
153
|
+
})
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
if (nextDot - currentStart < minPartLength) {
|
|
157
|
+
ctx.addIssue({
|
|
158
|
+
code: ZodIssueCode.custom,
|
|
159
|
+
message: `${errorPrefix}: segment ${i + 1} is too short`,
|
|
160
|
+
})
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
currentStart = nextDot + 1
|
|
164
|
+
}
|
|
165
|
+
if (data.indexOf('.', currentStart) !== -1) {
|
|
166
|
+
ctx.addIssue({
|
|
167
|
+
code: ZodIssueCode.custom,
|
|
168
|
+
message: `${errorPrefix}: too many segments`,
|
|
169
|
+
})
|
|
170
|
+
return false
|
|
171
|
+
}
|
|
172
|
+
if (data.length - currentStart < minPartLength) {
|
|
173
|
+
ctx.addIssue({
|
|
174
|
+
code: ZodIssueCode.custom,
|
|
175
|
+
message: `${errorPrefix}: last segment is too short`,
|
|
176
|
+
})
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
return true
|
|
180
|
+
}
|
|
181
|
+
}
|
package/tsconfig.json
ADDED