@atproto/jwk 0.7.2 → 0.7.3
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 +8 -0
- package/package.json +8 -5
- package/src/alg.ts +0 -111
- package/src/errors.ts +0 -56
- package/src/index.ts +0 -14
- package/src/jwk.ts +0 -263
- package/src/jwks.ts +0 -39
- package/src/jwt-decode.ts +0 -27
- package/src/jwt-verify.ts +0 -20
- package/src/jwt.ts +0 -220
- package/src/key.ts +0 -211
- package/src/keyset.ts +0 -254
- package/src/util.ts +0 -188
- package/tsconfig.build.json +0 -8
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -4
package/src/jwt.ts
DELETED
|
@@ -1,220 +0,0 @@
|
|
|
1
|
-
import { z } from 'zod'
|
|
2
|
-
import { jwkPubSchema } from './jwk.js'
|
|
3
|
-
import { jwtCharsRefinement, segmentedStringRefinementFactory } from './util.js'
|
|
4
|
-
|
|
5
|
-
export const signedJwtSchema = z
|
|
6
|
-
.string()
|
|
7
|
-
.superRefine(jwtCharsRefinement)
|
|
8
|
-
.superRefine(segmentedStringRefinementFactory(3))
|
|
9
|
-
|
|
10
|
-
export type SignedJwt = z.infer<typeof signedJwtSchema>
|
|
11
|
-
export const isSignedJwt = (data: unknown): data is SignedJwt =>
|
|
12
|
-
signedJwtSchema.safeParse(data).success
|
|
13
|
-
|
|
14
|
-
export const unsignedJwtSchema = z
|
|
15
|
-
.string()
|
|
16
|
-
.superRefine(jwtCharsRefinement)
|
|
17
|
-
.superRefine(segmentedStringRefinementFactory(2))
|
|
18
|
-
|
|
19
|
-
export type UnsignedJwt = z.infer<typeof unsignedJwtSchema>
|
|
20
|
-
export const isUnsignedJwt = (data: unknown): data is UnsignedJwt =>
|
|
21
|
-
unsignedJwtSchema.safeParse(data).success
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* @see {@link https://www.rfc-editor.org/rfc/rfc7515.html#section-4}
|
|
25
|
-
*/
|
|
26
|
-
export const jwtHeaderSchema = z
|
|
27
|
-
.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
|
-
.passthrough()
|
|
61
|
-
|
|
62
|
-
export type JwtHeader = z.infer<typeof jwtHeaderSchema>
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6}
|
|
66
|
-
* @see {@link https://www.rfc-editor.org/rfc/rfc9110#section-7.1}
|
|
67
|
-
*/
|
|
68
|
-
export const htuSchema = z.string().superRefine((value, ctx) => {
|
|
69
|
-
try {
|
|
70
|
-
const url = new URL(value)
|
|
71
|
-
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
72
|
-
ctx.addIssue({
|
|
73
|
-
code: z.ZodIssueCode.custom,
|
|
74
|
-
message: 'Only http: and https: protocols are allowed',
|
|
75
|
-
})
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (url.username || url.password) {
|
|
79
|
-
ctx.addIssue({
|
|
80
|
-
code: z.ZodIssueCode.custom,
|
|
81
|
-
message: 'Credentials not allowed',
|
|
82
|
-
})
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (url.search) {
|
|
86
|
-
ctx.addIssue({
|
|
87
|
-
code: z.ZodIssueCode.custom,
|
|
88
|
-
message: 'Query string not allowed',
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (url.hash) {
|
|
93
|
-
ctx.addIssue({
|
|
94
|
-
code: z.ZodIssueCode.custom,
|
|
95
|
-
message: 'Fragment not allowed',
|
|
96
|
-
})
|
|
97
|
-
}
|
|
98
|
-
} catch (err) {
|
|
99
|
-
ctx.addIssue({
|
|
100
|
-
code: z.ZodIssueCode.invalid_string,
|
|
101
|
-
validation: 'url',
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
return value
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
// https://www.iana.org/assignments/jwt/jwt.xhtml
|
|
109
|
-
export const jwtPayloadSchema = z
|
|
110
|
-
.object({
|
|
111
|
-
iss: z.string().optional(),
|
|
112
|
-
aud: z.union([z.string(), z.array(z.string()).nonempty()]).optional(),
|
|
113
|
-
sub: z.string().optional(),
|
|
114
|
-
exp: z.number().int().optional(),
|
|
115
|
-
nbf: z.number().int().optional(),
|
|
116
|
-
iat: z.number().int().optional(),
|
|
117
|
-
jti: z.string().optional(),
|
|
118
|
-
htm: z.string().optional(),
|
|
119
|
-
htu: htuSchema.optional(),
|
|
120
|
-
ath: z.string().optional(),
|
|
121
|
-
acr: z.string().optional(),
|
|
122
|
-
azp: z.string().optional(),
|
|
123
|
-
amr: z.array(z.string()).optional(),
|
|
124
|
-
// https://datatracker.ietf.org/doc/html/rfc7800
|
|
125
|
-
cnf: z
|
|
126
|
-
.object({
|
|
127
|
-
kid: z.string().optional(), // Key ID
|
|
128
|
-
jwk: jwkPubSchema.optional(), // JWK
|
|
129
|
-
jwe: z.string().optional(), // Encrypted key
|
|
130
|
-
jku: z.string().url().optional(), // JWK Set URI ("kid" should also be provided)
|
|
131
|
-
|
|
132
|
-
// https://datatracker.ietf.org/doc/html/rfc9449#section-6.1
|
|
133
|
-
jkt: z.string().optional(),
|
|
134
|
-
|
|
135
|
-
// https://datatracker.ietf.org/doc/html/rfc8705
|
|
136
|
-
'x5t#S256': z.string().optional(), // X.509 Certificate SHA-256 Thumbprint
|
|
137
|
-
|
|
138
|
-
// https://datatracker.ietf.org/doc/html/rfc9203
|
|
139
|
-
osc: z.string().optional(), // OSCORE_Input_Material carrying the parameters for using OSCORE per-message security with implicit key confirmation
|
|
140
|
-
})
|
|
141
|
-
.optional(),
|
|
142
|
-
|
|
143
|
-
client_id: z.string().optional(),
|
|
144
|
-
|
|
145
|
-
scope: z.string().optional(),
|
|
146
|
-
nonce: z.string().optional(),
|
|
147
|
-
|
|
148
|
-
at_hash: z.string().optional(),
|
|
149
|
-
c_hash: z.string().optional(),
|
|
150
|
-
s_hash: z.string().optional(),
|
|
151
|
-
auth_time: z.number().int().optional(),
|
|
152
|
-
|
|
153
|
-
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
|
154
|
-
|
|
155
|
-
// OpenID: "profile" scope
|
|
156
|
-
name: z.string().optional(),
|
|
157
|
-
family_name: z.string().optional(),
|
|
158
|
-
given_name: z.string().optional(),
|
|
159
|
-
middle_name: z.string().optional(),
|
|
160
|
-
nickname: z.string().optional(),
|
|
161
|
-
preferred_username: z.string().optional(),
|
|
162
|
-
gender: z.string().optional(), // OpenID only defines "male" and "female" without forbidding other values
|
|
163
|
-
picture: z.string().url().optional(),
|
|
164
|
-
profile: z.string().url().optional(),
|
|
165
|
-
website: z.string().url().optional(),
|
|
166
|
-
birthdate: z
|
|
167
|
-
.string()
|
|
168
|
-
.regex(/\d{4}-\d{2}-\d{2}/) // YYYY-MM-DD
|
|
169
|
-
.optional(),
|
|
170
|
-
zoneinfo: z
|
|
171
|
-
.string()
|
|
172
|
-
.regex(/^[A-Za-z0-9_/]+$/)
|
|
173
|
-
.optional(),
|
|
174
|
-
locale: z
|
|
175
|
-
.string()
|
|
176
|
-
.regex(/^[a-z]{2,3}(-[A-Z]{2})?$/)
|
|
177
|
-
.optional(),
|
|
178
|
-
updated_at: z.number().int().optional(),
|
|
179
|
-
|
|
180
|
-
// OpenID: "email" scope
|
|
181
|
-
email: z.string().optional(),
|
|
182
|
-
email_verified: z.boolean().optional(),
|
|
183
|
-
|
|
184
|
-
// OpenID: "phone" scope
|
|
185
|
-
phone_number: z.string().optional(),
|
|
186
|
-
phone_number_verified: z.boolean().optional(),
|
|
187
|
-
|
|
188
|
-
// OpenID: "address" scope
|
|
189
|
-
// https://openid.net/specs/openid-connect-core-1_0.html#AddressClaim
|
|
190
|
-
address: z
|
|
191
|
-
.object({
|
|
192
|
-
formatted: z.string().optional(),
|
|
193
|
-
street_address: z.string().optional(),
|
|
194
|
-
locality: z.string().optional(),
|
|
195
|
-
region: z.string().optional(),
|
|
196
|
-
postal_code: z.string().optional(),
|
|
197
|
-
country: z.string().optional(),
|
|
198
|
-
})
|
|
199
|
-
.optional(),
|
|
200
|
-
|
|
201
|
-
// https://datatracker.ietf.org/doc/html/rfc9396#section-14.2
|
|
202
|
-
authorization_details: z
|
|
203
|
-
.array(
|
|
204
|
-
z
|
|
205
|
-
.object({
|
|
206
|
-
type: z.string(),
|
|
207
|
-
// https://datatracker.ietf.org/doc/html/rfc9396#section-2.2
|
|
208
|
-
locations: z.array(z.string()).optional(),
|
|
209
|
-
actions: z.array(z.string()).optional(),
|
|
210
|
-
datatypes: z.array(z.string()).optional(),
|
|
211
|
-
identifier: z.string().optional(),
|
|
212
|
-
privileges: z.array(z.string()).optional(),
|
|
213
|
-
})
|
|
214
|
-
.passthrough(),
|
|
215
|
-
)
|
|
216
|
-
.optional(),
|
|
217
|
-
})
|
|
218
|
-
.passthrough()
|
|
219
|
-
|
|
220
|
-
export type JwtPayload = z.infer<typeof jwtPayloadSchema>
|
package/src/key.ts
DELETED
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
import { jwkAlgorithms } from './alg.js'
|
|
2
|
-
import {
|
|
3
|
-
Jwk,
|
|
4
|
-
KeyUsage,
|
|
5
|
-
PUBLIC_KEY_USAGE,
|
|
6
|
-
PrivateJwk,
|
|
7
|
-
PublicJwk,
|
|
8
|
-
PublicKeyUsage,
|
|
9
|
-
hasSharedSecretJwk,
|
|
10
|
-
isEncKeyUsage,
|
|
11
|
-
isPrivateJwk,
|
|
12
|
-
isPublicKeyUsage,
|
|
13
|
-
isSigKeyUsage,
|
|
14
|
-
jwkPubSchema,
|
|
15
|
-
jwkSchema,
|
|
16
|
-
} from './jwk.js'
|
|
17
|
-
import { VerifyOptions, VerifyResult } from './jwt-verify.js'
|
|
18
|
-
import { JwtHeader, JwtPayload, SignedJwt } from './jwt.js'
|
|
19
|
-
import { cachedGetter } from './util.js'
|
|
20
|
-
|
|
21
|
-
export type KeyMatchOptions = {
|
|
22
|
-
usage?: KeyUsage
|
|
23
|
-
kid?: string | string[]
|
|
24
|
-
alg?: string | string[]
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export type ActivityCheckOptions = {
|
|
28
|
-
allowRevoked?: boolean
|
|
29
|
-
clockTolerance?: number
|
|
30
|
-
currentDate?: Date
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export abstract class Key<J extends Jwk = Jwk> {
|
|
34
|
-
constructor(readonly jwk: Readonly<J>) {}
|
|
35
|
-
|
|
36
|
-
@cachedGetter
|
|
37
|
-
get isPrivate(): boolean {
|
|
38
|
-
return isPrivateJwk(this.jwk)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
@cachedGetter
|
|
42
|
-
get isSymetric(): boolean {
|
|
43
|
-
return hasSharedSecretJwk(this.jwk)
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
get privateJwk(): Readonly<PrivateJwk> | undefined {
|
|
47
|
-
if (!this.isPrivate) return undefined
|
|
48
|
-
|
|
49
|
-
return this.jwk as Readonly<PrivateJwk>
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
@cachedGetter
|
|
53
|
-
get publicJwk(): Readonly<PublicJwk> | undefined {
|
|
54
|
-
if (this.isSymetric) return undefined
|
|
55
|
-
if (!this.isPrivate) return this.jwk as Readonly<PublicJwk>
|
|
56
|
-
|
|
57
|
-
const validated = jwkPubSchema.safeParse({
|
|
58
|
-
...this.jwk,
|
|
59
|
-
d: undefined,
|
|
60
|
-
k: undefined,
|
|
61
|
-
use: undefined,
|
|
62
|
-
key_ops: buildPublicKeyOps(this.keyOps) ?? PUBLIC_KEY_USAGE,
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
// One reason why the parsing might fail is if key_ops is empty. This check
|
|
66
|
-
// also allows to future proof the code (e.g if another type of private key
|
|
67
|
-
// is added that uses a different property than "d" or "k" to store its
|
|
68
|
-
// private value).
|
|
69
|
-
if (!validated.success) return undefined
|
|
70
|
-
|
|
71
|
-
return Object.freeze(validated.data)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
@cachedGetter
|
|
75
|
-
get bareJwk(): Readonly<Jwk> | undefined {
|
|
76
|
-
if (this.isSymetric) return undefined
|
|
77
|
-
const { kty, crv, e, n, x, y } = this.jwk as any
|
|
78
|
-
return Object.freeze(jwkSchema.parse({ crv, e, kty, n, x, y }))
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* @note Only defined on public keys
|
|
83
|
-
*/
|
|
84
|
-
get use(): 'sig' | 'enc' | undefined {
|
|
85
|
-
return this.jwk.use
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
get keyOps(): readonly KeyUsage[] | undefined {
|
|
89
|
-
return this.jwk.key_ops
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* The (forced) algorithm to use. If not provided, the key will be usable with
|
|
94
|
-
* any of the algorithms in {@link algorithms}.
|
|
95
|
-
*
|
|
96
|
-
* @see {@link https://datatracker.ietf.org/doc/html/rfc7518#section-3.1 | "alg" (Algorithm) Header Parameter Values for JWS}
|
|
97
|
-
*/
|
|
98
|
-
get alg() {
|
|
99
|
-
return this.jwk.alg
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
get kid() {
|
|
103
|
-
return this.jwk.kid
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
get crv() {
|
|
107
|
-
return (this.jwk as { crv: undefined } | Extract<J, { crv: unknown }>).crv
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* All the algorithms that this key can be used with. If `alg` is provided,
|
|
112
|
-
* this set will only contain that algorithm.
|
|
113
|
-
*/
|
|
114
|
-
@cachedGetter
|
|
115
|
-
get algorithms(): readonly string[] {
|
|
116
|
-
return Object.freeze(Array.from(jwkAlgorithms(this.jwk)))
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
get isRevoked() {
|
|
120
|
-
return this.jwk.revoked != null
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
isActive(options?: ActivityCheckOptions) {
|
|
124
|
-
if (!options?.allowRevoked && this.isRevoked) return false
|
|
125
|
-
|
|
126
|
-
const tolerance = options?.clockTolerance ?? 0
|
|
127
|
-
if (tolerance !== Infinity) {
|
|
128
|
-
const now = options?.currentDate?.getTime() ?? Date.now()
|
|
129
|
-
const { exp, nbf } = this.jwk
|
|
130
|
-
|
|
131
|
-
if (nbf != null && !(now >= nbf * 1e3 - tolerance)) return false
|
|
132
|
-
if (exp != null && !(now < exp * 1e3 + tolerance)) return false
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return true
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
matches(opts: KeyMatchOptions): boolean {
|
|
139
|
-
if (opts.kid != null) {
|
|
140
|
-
const matchesKid = Array.isArray(opts.kid)
|
|
141
|
-
? this.kid != null && opts.kid.includes(this.kid)
|
|
142
|
-
: this.kid === opts.kid
|
|
143
|
-
if (!matchesKid) return false
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
if (opts.alg != null) {
|
|
147
|
-
const matchesAlg = Array.isArray(opts.alg)
|
|
148
|
-
? opts.alg.some((a) => this.algorithms.includes(a))
|
|
149
|
-
: this.algorithms.includes(opts.alg)
|
|
150
|
-
if (!matchesAlg) return false
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
if (opts.usage != null) {
|
|
154
|
-
const matchesOps =
|
|
155
|
-
this.keyOps == null ||
|
|
156
|
-
this.keyOps.includes(opts.usage) ||
|
|
157
|
-
// @NOTE Because this.jwk represents the private key (typically used for
|
|
158
|
-
// private operations), the public counterpart operations are allowed.
|
|
159
|
-
(opts.usage === 'verify' && this.keyOps.includes('sign')) ||
|
|
160
|
-
(opts.usage === 'encrypt' && this.keyOps.includes('decrypt')) ||
|
|
161
|
-
(opts.usage === 'wrapKey' && this.keyOps.includes('unwrapKey'))
|
|
162
|
-
if (!matchesOps) return false
|
|
163
|
-
|
|
164
|
-
const matchesUse =
|
|
165
|
-
this.use == null ||
|
|
166
|
-
(this.use === 'sig' && isSigKeyUsage(opts.usage)) ||
|
|
167
|
-
(this.use === 'enc' && isEncKeyUsage(opts.usage))
|
|
168
|
-
if (!matchesUse) return false
|
|
169
|
-
|
|
170
|
-
// @NOTE This is only relevant when "key_ops" and "use" are undefined.
|
|
171
|
-
// This line also ensures that when "opts.usage" is a private key usage
|
|
172
|
-
// (e.g. "sign"), the key is indeed a private key.
|
|
173
|
-
const matchesKeyType = this.isPrivate || isPublicKeyUsage(opts.usage)
|
|
174
|
-
if (!matchesKeyType) return false
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return true
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Create a signed JWT
|
|
182
|
-
*/
|
|
183
|
-
abstract createJwt(header: JwtHeader, payload: JwtPayload): Promise<SignedJwt>
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Verify the signature, headers and payload of a JWT
|
|
187
|
-
*
|
|
188
|
-
* @throws {JwtVerifyError} if the JWT is invalid
|
|
189
|
-
*/
|
|
190
|
-
abstract verifyJwt<C extends string = never>(
|
|
191
|
-
token: SignedJwt,
|
|
192
|
-
options?: VerifyOptions<C>,
|
|
193
|
-
): Promise<VerifyResult<C>>
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function buildPublicKeyOps(
|
|
197
|
-
keyUsages?: readonly KeyUsage[],
|
|
198
|
-
): PublicKeyUsage[] | undefined {
|
|
199
|
-
if (keyUsages == null) return undefined
|
|
200
|
-
|
|
201
|
-
// https://datatracker.ietf.org/doc/html/rfc7517#section-4.3
|
|
202
|
-
// > Duplicate key operation values MUST NOT be present in the array.
|
|
203
|
-
const publicOps = new Set(keyUsages.filter(isPublicKeyUsage))
|
|
204
|
-
|
|
205
|
-
// @NOTE Translating private key usage into public key usage
|
|
206
|
-
if (keyUsages.includes('sign')) publicOps.add('verify')
|
|
207
|
-
if (keyUsages.includes('decrypt')) publicOps.add('encrypt')
|
|
208
|
-
if (keyUsages.includes('unwrapKey')) publicOps.add('wrapKey')
|
|
209
|
-
|
|
210
|
-
return Array.from(publicOps)
|
|
211
|
-
}
|
package/src/keyset.ts
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
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 { PrivateKeyUsage } from './jwk.js'
|
|
10
|
-
import { JwksPub } 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 { ActivityCheckOptions, Key, KeyMatchOptions } from './key.js'
|
|
15
|
-
import {
|
|
16
|
-
Override,
|
|
17
|
-
cachedGetter,
|
|
18
|
-
isDefined,
|
|
19
|
-
matchesAny,
|
|
20
|
-
preferredOrderCmp,
|
|
21
|
-
} from './util.js'
|
|
22
|
-
|
|
23
|
-
export type { ActivityCheckOptions, KeyMatchOptions }
|
|
24
|
-
export type FindKeyOptions = KeyMatchOptions & ActivityCheckOptions
|
|
25
|
-
|
|
26
|
-
export type JwtSignHeader = Override<
|
|
27
|
-
JwtHeader,
|
|
28
|
-
Pick<FindKeyOptions, 'alg' | 'kid'>
|
|
29
|
-
>
|
|
30
|
-
|
|
31
|
-
export type JwtPayloadGetter<P = JwtPayload> = (
|
|
32
|
-
header: JwtHeader,
|
|
33
|
-
key: Key,
|
|
34
|
-
) => P | PromiseLike<P>
|
|
35
|
-
|
|
36
|
-
const extractPrivateJwk = (key: Key) => key.privateJwk
|
|
37
|
-
const extractPublicJwk = (key: Key) => 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 keyIds = 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 (keyIds.has(key.kid)) throw new JwkError(`Duplicate key: ${key.kid}`)
|
|
76
|
-
else keyIds.add(key.kid)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
this.keys = Object.freeze(keys)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
get size(): number {
|
|
84
|
-
return this.keys.length
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
@cachedGetter
|
|
88
|
-
get signAlgorithms(): readonly string[] {
|
|
89
|
-
const algorithms = new Set<string>()
|
|
90
|
-
for (const key of this) {
|
|
91
|
-
if (key.use !== 'sig') continue
|
|
92
|
-
for (const alg of key.algorithms) {
|
|
93
|
-
algorithms.add(alg)
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return Object.freeze(
|
|
97
|
-
[...algorithms].sort(preferredOrderCmp(this.preferredSigningAlgorithms)),
|
|
98
|
-
)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
@cachedGetter
|
|
102
|
-
get publicJwks() {
|
|
103
|
-
return Object.freeze({
|
|
104
|
-
keys: Object.freeze(Array.from(this, extractPublicJwk).filter(isDefined)),
|
|
105
|
-
})
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
@cachedGetter
|
|
109
|
-
get privateJwks() {
|
|
110
|
-
return Object.freeze({
|
|
111
|
-
keys: Object.freeze(
|
|
112
|
-
Array.from(this, extractPrivateJwk).filter(isDefined),
|
|
113
|
-
),
|
|
114
|
-
})
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
has(kid: string): boolean {
|
|
118
|
-
return this.keys.some((key) => key.kid === kid)
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
get(options: FindKeyOptions): K {
|
|
122
|
-
const key = this.find(options)
|
|
123
|
-
if (key) return key
|
|
124
|
-
|
|
125
|
-
throw new JwkError(
|
|
126
|
-
`Key not found ${options.kid ?? options.alg ?? options.usage ?? '<unknown>'}`,
|
|
127
|
-
ERR_JWK_NOT_FOUND,
|
|
128
|
-
)
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
find(options: FindKeyOptions): K | undefined {
|
|
132
|
-
for (const key of this.list(options)) {
|
|
133
|
-
return key
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return undefined
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
*list<O extends FindKeyOptions>(options: O) {
|
|
140
|
-
for (const key of this) {
|
|
141
|
-
if (key.isActive(options) && key.matches(options)) {
|
|
142
|
-
yield key
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
findPrivateKey({
|
|
148
|
-
kid,
|
|
149
|
-
alg,
|
|
150
|
-
usage,
|
|
151
|
-
...options
|
|
152
|
-
}: FindKeyOptions & { usage: PrivateKeyUsage }): {
|
|
153
|
-
key: Key
|
|
154
|
-
alg: string
|
|
155
|
-
} {
|
|
156
|
-
const matchingKeys: Key[] = []
|
|
157
|
-
|
|
158
|
-
// Allow the loop bellow to return early when a single "alg" is provided
|
|
159
|
-
if (Array.isArray(alg) && alg.length === 1) alg = alg[0]
|
|
160
|
-
|
|
161
|
-
for (const key of this.list({ ...options, kid, alg, usage })) {
|
|
162
|
-
// Skip negotiation if a single "alg" was provided
|
|
163
|
-
if (typeof alg === 'string') return { key, alg }
|
|
164
|
-
|
|
165
|
-
matchingKeys.push(key)
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
const isAllowedAlg = matchesAny(alg)
|
|
169
|
-
const candidates = matchingKeys.map(
|
|
170
|
-
(key) => [key, key.algorithms.filter(isAllowedAlg)] as const,
|
|
171
|
-
)
|
|
172
|
-
|
|
173
|
-
// Return the first candidates that matches the preferred algorithms
|
|
174
|
-
for (const prefAlg of this.preferredSigningAlgorithms) {
|
|
175
|
-
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
176
|
-
if (matchingAlgs.includes(prefAlg)) {
|
|
177
|
-
return { key: matchingKey, alg: prefAlg }
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Return any candidate
|
|
183
|
-
for (const [matchingKey, matchingAlgs] of candidates) {
|
|
184
|
-
for (const alg of matchingAlgs) {
|
|
185
|
-
return { key: matchingKey, alg }
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
throw new JwkError(
|
|
190
|
-
`No private key found for ${kid || alg || usage}`,
|
|
191
|
-
ERR_JWK_NOT_FOUND,
|
|
192
|
-
)
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
[Symbol.iterator](): IterableIterator<K> {
|
|
196
|
-
return this.keys.values()
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
async createJwt(
|
|
200
|
-
{ alg: sAlg, kid: sKid, ...header }: JwtSignHeader,
|
|
201
|
-
payload: JwtPayload | JwtPayloadGetter,
|
|
202
|
-
): Promise<SignedJwt> {
|
|
203
|
-
try {
|
|
204
|
-
const { key, alg } = this.findPrivateKey({
|
|
205
|
-
alg: sAlg,
|
|
206
|
-
kid: sKid,
|
|
207
|
-
usage: 'sign',
|
|
208
|
-
allowRevoked: false, // For explicitness (default value is false)
|
|
209
|
-
})
|
|
210
|
-
const protectedHeader = { ...header, alg, kid: key.kid }
|
|
211
|
-
|
|
212
|
-
if (typeof payload === 'function') {
|
|
213
|
-
payload = await payload(protectedHeader, key)
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return await key.createJwt(protectedHeader, payload)
|
|
217
|
-
} catch (err) {
|
|
218
|
-
throw JwtCreateError.from(err)
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
async verifyJwt<C extends string = never>(
|
|
223
|
-
token: SignedJwt,
|
|
224
|
-
options?: ActivityCheckOptions & VerifyOptions<C>,
|
|
225
|
-
): Promise<VerifyResult<C> & { key: K }> {
|
|
226
|
-
const { header } = unsafeDecodeJwt(token)
|
|
227
|
-
const { kid, alg } = header
|
|
228
|
-
|
|
229
|
-
const errors: unknown[] = []
|
|
230
|
-
|
|
231
|
-
for (const key of this.list({ ...options, kid, alg, usage: 'verify' })) {
|
|
232
|
-
try {
|
|
233
|
-
const result = await key.verifyJwt<C>(token, options)
|
|
234
|
-
return { ...result, key }
|
|
235
|
-
} catch (err) {
|
|
236
|
-
errors.push(err)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
switch (errors.length) {
|
|
241
|
-
case 0:
|
|
242
|
-
throw new JwtVerifyError('No key matched', ERR_JWKS_NO_MATCHING_KEY)
|
|
243
|
-
case 1:
|
|
244
|
-
throw JwtVerifyError.from(errors[0], ERR_JWT_INVALID)
|
|
245
|
-
default:
|
|
246
|
-
throw JwtVerifyError.from(errors, ERR_JWT_INVALID)
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
toJSON() {
|
|
251
|
-
// Make a copy to allow mutation of the result
|
|
252
|
-
return structuredClone(this.publicJwks) as JwksPub
|
|
253
|
-
}
|
|
254
|
-
}
|