@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE.txt +7 -0
  3. package/dist/alg.d.ts +3 -0
  4. package/dist/alg.d.ts.map +1 -0
  5. package/dist/alg.js +90 -0
  6. package/dist/alg.js.map +1 -0
  7. package/dist/errors.d.ts +24 -0
  8. package/dist/errors.d.ts.map +1 -0
  9. package/dist/errors.js +62 -0
  10. package/dist/errors.js.map +1 -0
  11. package/dist/index.d.ts +11 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +27 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/jwk.d.ts +2424 -0
  16. package/dist/jwk.d.ts.map +1 -0
  17. package/dist/jwk.js +112 -0
  18. package/dist/jwk.js.map +1 -0
  19. package/dist/jwks.d.ts +1770 -0
  20. package/dist/jwks.d.ts.map +1 -0
  21. package/dist/jwks.js +12 -0
  22. package/dist/jwks.js.map +1 -0
  23. package/dist/jwt-decode.d.ts +6 -0
  24. package/dist/jwt-decode.d.ts.map +1 -0
  25. package/dist/jwt-decode.js +20 -0
  26. package/dist/jwt-decode.js.map +1 -0
  27. package/dist/jwt-verify.d.ts +20 -0
  28. package/dist/jwt-verify.d.ts.map +1 -0
  29. package/dist/jwt-verify.js +3 -0
  30. package/dist/jwt-verify.js.map +1 -0
  31. package/dist/jwt.d.ts +1785 -0
  32. package/dist/jwt.d.ts.map +1 -0
  33. package/dist/jwt.js +150 -0
  34. package/dist/jwt.js.map +1 -0
  35. package/dist/key.d.ts +38 -0
  36. package/dist/key.d.ts.map +1 -0
  37. package/dist/key.js +131 -0
  38. package/dist/key.js.map +1 -0
  39. package/dist/keyset.d.ts +41 -0
  40. package/dist/keyset.d.ts.map +1 -0
  41. package/dist/keyset.js +234 -0
  42. package/dist/keyset.js.map +1 -0
  43. package/dist/util.d.ts +48 -0
  44. package/dist/util.d.ts.map +1 -0
  45. package/dist/util.js +143 -0
  46. package/dist/util.js.map +1 -0
  47. package/package.json +38 -0
  48. package/src/alg.ts +98 -0
  49. package/src/errors.ts +56 -0
  50. package/src/index.ts +10 -0
  51. package/src/jwk.ts +141 -0
  52. package/src/jwks.ts +15 -0
  53. package/src/jwt-decode.ts +27 -0
  54. package/src/jwt-verify.ts +22 -0
  55. package/src/jwt.ts +173 -0
  56. package/src/key.ts +93 -0
  57. package/src/keyset.ts +240 -0
  58. package/src/util.ts +181 -0
  59. package/tsconfig.build.json +8 -0
  60. 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
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["../../../tsconfig/isomorphic.json"],
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"]
8
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "include": [],
3
+ "references": [{ "path": "./tsconfig.build.json" }]
4
+ }