@atproto/oauth-provider 0.19.6 → 0.19.8

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 (165) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/errors/error-parser.js +5 -5
  3. package/dist/errors/error-parser.js.map +1 -1
  4. package/package.json +22 -18
  5. package/src/access-token/access-token-mode.ts +0 -4
  6. package/src/account/account-manager.ts +0 -591
  7. package/src/account/account-store.ts +0 -305
  8. package/src/account/sign-in-data.ts +0 -15
  9. package/src/account/sign-up-input.ts +0 -20
  10. package/src/client/client-auth.ts +0 -64
  11. package/src/client/client-data.ts +0 -9
  12. package/src/client/client-id.ts +0 -4
  13. package/src/client/client-info.ts +0 -13
  14. package/src/client/client-manager.ts +0 -772
  15. package/src/client/client-store.ts +0 -35
  16. package/src/client/client-utils.ts +0 -37
  17. package/src/client/client.ts +0 -394
  18. package/src/constants.ts +0 -80
  19. package/src/customization/branding.ts +0 -12
  20. package/src/customization/build-customization-css.ts +0 -55
  21. package/src/customization/build-customization-data.ts +0 -24
  22. package/src/customization/colors.ts +0 -48
  23. package/src/customization/customization.ts +0 -29
  24. package/src/customization/links.ts +0 -10
  25. package/src/device/device-data.ts +0 -11
  26. package/src/device/device-id.ts +0 -27
  27. package/src/device/device-manager.ts +0 -292
  28. package/src/device/device-store.ts +0 -31
  29. package/src/device/session-id.ts +0 -21
  30. package/src/dpop/dpop-manager.ts +0 -214
  31. package/src/dpop/dpop-nonce.ts +0 -121
  32. package/src/dpop/dpop-proof.ts +0 -6
  33. package/src/errors/access-denied-error.ts +0 -12
  34. package/src/errors/account-selection-required-error.ts +0 -12
  35. package/src/errors/authorization-error.ts +0 -45
  36. package/src/errors/consent-required-error.ts +0 -12
  37. package/src/errors/error-parser.ts +0 -147
  38. package/src/errors/handle-unavailable-error.ts +0 -23
  39. package/src/errors/invalid-authorization-details-error.ts +0 -27
  40. package/src/errors/invalid-client-error.ts +0 -20
  41. package/src/errors/invalid-client-id-error.ts +0 -31
  42. package/src/errors/invalid-client-metadata-error.ts +0 -68
  43. package/src/errors/invalid-credentials-error.ts +0 -29
  44. package/src/errors/invalid-dpop-key-binding-error.ts +0 -21
  45. package/src/errors/invalid-dpop-proof-error.ts +0 -13
  46. package/src/errors/invalid-grant-error.ts +0 -21
  47. package/src/errors/invalid-invite-code-error.ts +0 -10
  48. package/src/errors/invalid-redirect-uri-error.ts +0 -17
  49. package/src/errors/invalid-request-error.ts +0 -32
  50. package/src/errors/invalid-scope-error.ts +0 -15
  51. package/src/errors/invalid-token-error.ts +0 -60
  52. package/src/errors/login-required-error.ts +0 -12
  53. package/src/errors/oauth-error.ts +0 -28
  54. package/src/errors/second-authentication-factor-required-error.ts +0 -25
  55. package/src/errors/unauthorized-client-error.ts +0 -20
  56. package/src/errors/use-dpop-nonce-error.ts +0 -32
  57. package/src/errors/www-authenticate-error.ts +0 -64
  58. package/src/index.ts +0 -16
  59. package/src/lexicon/lexicon-data.ts +0 -11
  60. package/src/lexicon/lexicon-getter.ts +0 -55
  61. package/src/lexicon/lexicon-manager.ts +0 -116
  62. package/src/lexicon/lexicon-store.ts +0 -35
  63. package/src/lib/csp/index.ts +0 -101
  64. package/src/lib/hcaptcha.ts +0 -228
  65. package/src/lib/html/README.md +0 -9
  66. package/src/lib/html/build-document.ts +0 -151
  67. package/src/lib/html/escapers.ts +0 -66
  68. package/src/lib/html/html.ts +0 -56
  69. package/src/lib/html/hydration-data.ts +0 -19
  70. package/src/lib/html/index.ts +0 -5
  71. package/src/lib/html/tags.ts +0 -67
  72. package/src/lib/html/util.ts +0 -17
  73. package/src/lib/http/README.md +0 -11
  74. package/src/lib/http/accept.ts +0 -90
  75. package/src/lib/http/context.ts +0 -42
  76. package/src/lib/http/headers.ts +0 -15
  77. package/src/lib/http/index.ts +0 -10
  78. package/src/lib/http/method.ts +0 -18
  79. package/src/lib/http/middleware.ts +0 -169
  80. package/src/lib/http/parser.ts +0 -101
  81. package/src/lib/http/path.ts +0 -82
  82. package/src/lib/http/request.ts +0 -256
  83. package/src/lib/http/response.ts +0 -122
  84. package/src/lib/http/route.ts +0 -60
  85. package/src/lib/http/router.ts +0 -117
  86. package/src/lib/http/security-headers.ts +0 -100
  87. package/src/lib/http/stream.ts +0 -57
  88. package/src/lib/http/types.ts +0 -16
  89. package/src/lib/http/url.ts +0 -23
  90. package/src/lib/nsid.ts +0 -10
  91. package/src/lib/redis.ts +0 -25
  92. package/src/lib/util/authorization-header.ts +0 -28
  93. package/src/lib/util/cast.ts +0 -18
  94. package/src/lib/util/color.ts +0 -168
  95. package/src/lib/util/crypto.ts +0 -32
  96. package/src/lib/util/date.ts +0 -7
  97. package/src/lib/util/error.ts +0 -7
  98. package/src/lib/util/function.ts +0 -39
  99. package/src/lib/util/locale.ts +0 -12
  100. package/src/lib/util/object.ts +0 -23
  101. package/src/lib/util/redirect-uri.ts +0 -46
  102. package/src/lib/util/time.ts +0 -49
  103. package/src/lib/util/type.ts +0 -182
  104. package/src/lib/util/ui8.ts +0 -14
  105. package/src/lib/util/well-known.ts +0 -8
  106. package/src/lib/util/zod-error.ts +0 -26
  107. package/src/lib/write-form-redirect.ts +0 -58
  108. package/src/lib/write-html.ts +0 -70
  109. package/src/metadata/build-metadata.ts +0 -141
  110. package/src/oauth-client.ts +0 -3
  111. package/src/oauth-dpop.ts +0 -2
  112. package/src/oauth-errors.ts +0 -26
  113. package/src/oauth-hooks.ts +0 -519
  114. package/src/oauth-middleware.ts +0 -53
  115. package/src/oauth-provider.ts +0 -1110
  116. package/src/oauth-store.ts +0 -12
  117. package/src/oauth-verifier.ts +0 -260
  118. package/src/replay/replay-manager.ts +0 -51
  119. package/src/replay/replay-store-memory.ts +0 -36
  120. package/src/replay/replay-store-redis.ts +0 -30
  121. package/src/replay/replay-store.ts +0 -44
  122. package/src/request/code.ts +0 -23
  123. package/src/request/request-data.ts +0 -36
  124. package/src/request/request-id.ts +0 -22
  125. package/src/request/request-manager.ts +0 -521
  126. package/src/request/request-store.ts +0 -60
  127. package/src/request/request-uri.ts +0 -42
  128. package/src/result/authorization-redirect-parameters.ts +0 -24
  129. package/src/result/authorization-result-authorize-page.ts +0 -16
  130. package/src/result/authorization-result-redirect.ts +0 -8
  131. package/src/router/assets/assets-manifest.ts +0 -117
  132. package/src/router/assets/assets.ts +0 -124
  133. package/src/router/assets/csrf.ts +0 -79
  134. package/src/router/assets/send-account-page.ts +0 -23
  135. package/src/router/assets/send-authorization-page.ts +0 -42
  136. package/src/router/assets/send-cookie-error-page.ts +0 -21
  137. package/src/router/assets/send-error-page.ts +0 -25
  138. package/src/router/assets/send-redirect.ts +0 -140
  139. package/src/router/create-account-page-middleware.ts +0 -88
  140. package/src/router/create-api-middleware.ts +0 -1051
  141. package/src/router/create-authorization-page-middleware.ts +0 -281
  142. package/src/router/create-oauth-middleware.ts +0 -257
  143. package/src/router/error-handler.ts +0 -6
  144. package/src/router/middleware-options.ts +0 -9
  145. package/src/signer/access-token-payload.ts +0 -25
  146. package/src/signer/api-token-payload.ts +0 -18
  147. package/src/signer/signer.ts +0 -121
  148. package/src/token/refresh-token.ts +0 -30
  149. package/src/token/token-claims.ts +0 -22
  150. package/src/token/token-data.ts +0 -40
  151. package/src/token/token-id.ts +0 -25
  152. package/src/token/token-manager.ts +0 -427
  153. package/src/token/token-store.ts +0 -88
  154. package/src/types/authorization-response-error.ts +0 -27
  155. package/src/types/color-hue.ts +0 -3
  156. package/src/types/email-otp.ts +0 -3
  157. package/src/types/email.ts +0 -26
  158. package/src/types/handle.ts +0 -23
  159. package/src/types/invite-code.ts +0 -4
  160. package/src/types/par-response-error.ts +0 -25
  161. package/src/types/password.ts +0 -4
  162. package/src/types/rgb-color.ts +0 -21
  163. package/tsconfig.build.json +0 -8
  164. package/tsconfig.build.tsbuildinfo +0 -1
  165. package/tsconfig.json +0 -4
@@ -1,29 +0,0 @@
1
- import { z } from 'zod'
2
- import { hcaptchaConfigSchema } from '../lib/hcaptcha.js'
3
- import { brandingSchema } from './branding.js'
4
-
5
- export const customizationSchema = z.object({
6
- /**
7
- * Available user domains that can be used to sign up. A non-empty array
8
- * is required to enable the sign-up feature.
9
- */
10
- availableUserDomains: z.array(z.string()).optional(),
11
- /**
12
- * UI customizations
13
- */
14
- branding: brandingSchema.optional(),
15
- /**
16
- * Is an invite code required to sign up?
17
- */
18
- inviteCodeRequired: z.boolean().optional(),
19
- /**
20
- * Show a warning about 2FA being disabled when updating email address
21
- */
22
- show2FaWarningOnEmailUpdate: z.boolean().optional(),
23
- /**
24
- * Enables hCaptcha during sign-up.
25
- */
26
- hcaptcha: hcaptchaConfigSchema.optional(),
27
- })
28
- export type CustomizationInput = z.input<typeof customizationSchema>
29
- export type Customization = z.infer<typeof customizationSchema>
@@ -1,10 +0,0 @@
1
- import { z } from 'zod'
2
- import { isLinkRel } from '../lib/html/build-document.js'
3
- import { multiLangStringSchema } from '../lib/util/locale.js'
4
-
5
- export const linksSchema = z.object({
6
- title: z.union([z.string(), multiLangStringSchema]),
7
- href: z.string().url(),
8
- rel: z.string().refine(isLinkRel, 'Invalid link rel').optional(),
9
- })
10
- export type Links = z.infer<typeof linksSchema>
@@ -1,11 +0,0 @@
1
- import { z } from 'zod'
2
- import { sessionIdSchema } from './session-id.js'
3
-
4
- export const deviceDataSchema = z.object({
5
- sessionId: sessionIdSchema,
6
- lastSeenAt: z.date(),
7
- userAgent: z.string().nullable(),
8
- ipAddress: z.string(),
9
- })
10
-
11
- export type DeviceData = z.infer<typeof deviceDataSchema>
@@ -1,27 +0,0 @@
1
- import { z } from 'zod'
2
- import { DEVICE_ID_BYTES_LENGTH, DEVICE_ID_PREFIX } from '../constants.js'
3
- import { randomHexId } from '../lib/util/crypto.js'
4
-
5
- export const DEVICE_ID_LENGTH =
6
- DEVICE_ID_PREFIX.length + DEVICE_ID_BYTES_LENGTH * 2 // hex encoding
7
-
8
- export const deviceIdSchema = z
9
- .string()
10
- .length(DEVICE_ID_LENGTH)
11
- .refine(
12
- (v): v is `${typeof DEVICE_ID_PREFIX}${string}` =>
13
- v.startsWith(DEVICE_ID_PREFIX),
14
- {
15
- message: `Invalid device ID format`,
16
- },
17
- )
18
-
19
- export type DeviceId = z.infer<typeof deviceIdSchema>
20
-
21
- export function isDeviceId(value: unknown): value is DeviceId {
22
- return deviceIdSchema.safeParse(value).success
23
- }
24
-
25
- export const generateDeviceId = async (): Promise<DeviceId> => {
26
- return `${DEVICE_ID_PREFIX}${await randomHexId(DEVICE_ID_BYTES_LENGTH)}`
27
- }
@@ -1,292 +0,0 @@
1
- import type { IncomingMessage, ServerResponse } from 'node:http'
2
- import { z } from 'zod'
3
- import { SESSION_FIXATION_MAX_AGE } from '../constants.js'
4
- import { parseHttpCookies } from '../lib/http/index.js'
5
- import {
6
- RequestMetadata,
7
- extractRequestMetadata,
8
- setCookie,
9
- } from '../lib/http/request.js'
10
- import { DeviceData } from './device-data.js'
11
- import { DeviceId, deviceIdSchema, generateDeviceId } from './device-id.js'
12
- import { DeviceStore } from './device-store.js'
13
- import { generateSessionId, sessionIdSchema } from './session-id.js'
14
-
15
- /**
16
- * @see {@link https://www.npmjs.com/package/keygrip | Keygrip}
17
- */
18
- export const keygripSchema = z.object({
19
- sign: z.function().args(z.any()).returns(z.string()),
20
- verify: z.function().args(z.any(), z.string()).returns(z.boolean()),
21
- index: z.function().args(z.any(), z.string()).returns(z.number()),
22
- })
23
-
24
- export const deviceManagerOptionsSchema = z.object({
25
- /**
26
- * Controls whether the IP address is read from the `X-Forwarded-For` header
27
- * (if `true`), or from the `req.socket.remoteAddress` property (if `false`).
28
- */
29
- trustProxy: z
30
- .function()
31
- .args<[addr: z.ZodString, i: z.ZodNumber]>(z.string(), z.number())
32
- .returns(z.boolean())
33
- .optional(),
34
-
35
- /**
36
- * Amount of time (in ms) after which session IDs will be rotated
37
- *
38
- * @default 300e3 // (5 minutes)
39
- */
40
- rotationRate: z.number().default(300e3),
41
- /**
42
- * Cookie options
43
- */
44
- cookie: z
45
- .object({
46
- keys: keygripSchema.optional(),
47
- /**
48
- * Amount of time (in ms) after which the session cookie will expire.
49
- * If set to `null`, the cookie will be a session cookie (deleted when the
50
- * browser is closed).
51
- *
52
- * @default 10 years
53
- */
54
- age: z
55
- .number()
56
- .nullable()
57
- .default(10 * 365.2 * 24 * 60 * 60e3),
58
- /**
59
- * Controls whether the cookie is only sent over HTTPS (if `true`), or also
60
- * over HTTP (if `false`). This should **NOT** be set to `false` in
61
- * production.
62
- */
63
- secure: z.boolean().default(true),
64
- /**
65
- * Controls whether the cookie is sent along with cross-site requests.
66
- *
67
- * @default 'lax'
68
- */
69
- sameSite: z.enum(['lax', 'strict']).default('lax'),
70
- })
71
- .default({}),
72
- })
73
-
74
- export type DeviceManagerOptions = z.input<typeof deviceManagerOptionsSchema>
75
-
76
- type CookieValue = {
77
- deviceId: DeviceId
78
- sessionId: string
79
- }
80
-
81
- export type DeviceInfo = {
82
- deviceId: DeviceId
83
- deviceMetadata: RequestMetadata
84
- }
85
-
86
- /**
87
- * This class provides an abstraction for keeping track of DEVICE sessions. It
88
- * relies on a {@link DeviceStore} to persist session data and a cookie to
89
- * identify the session.
90
- */
91
- export class DeviceManager {
92
- private readonly options: z.output<typeof deviceManagerOptionsSchema>
93
-
94
- constructor(
95
- private readonly store: DeviceStore,
96
- options: DeviceManagerOptions = {},
97
- ) {
98
- this.options = deviceManagerOptionsSchema.parse(options)
99
- }
100
-
101
- public async hasSession(req: IncomingMessage): Promise<boolean> {
102
- const cookies = await this.getCookies(req)
103
- return cookies !== null
104
- }
105
-
106
- public async load(
107
- req: IncomingMessage,
108
- res: ServerResponse,
109
- forceRotate = false,
110
- ): Promise<DeviceInfo> {
111
- const cookie = await this.getCookies(req)
112
- if (cookie) {
113
- return this.refresh(
114
- req,
115
- res,
116
- cookie.value,
117
- forceRotate || cookie.mustRotate,
118
- )
119
- } else {
120
- return this.create(req, res)
121
- }
122
- }
123
-
124
- private async create(
125
- req: IncomingMessage,
126
- res: ServerResponse,
127
- ): Promise<DeviceInfo> {
128
- const deviceMetadata = this.getRequestMetadata(req)
129
-
130
- const [deviceId, sessionId] = await Promise.all([
131
- generateDeviceId(),
132
- generateSessionId(),
133
- ] as const)
134
-
135
- await this.store.createDevice(deviceId, {
136
- sessionId,
137
- lastSeenAt: new Date(),
138
- userAgent: deviceMetadata.userAgent ?? null,
139
- ipAddress: deviceMetadata.ipAddress,
140
- })
141
-
142
- await this.setCookies(req, res, { deviceId, sessionId })
143
-
144
- return { deviceId, deviceMetadata }
145
- }
146
-
147
- private async refresh(
148
- req: IncomingMessage,
149
- res: ServerResponse,
150
- { deviceId, sessionId }: CookieValue,
151
- forceRotate = false,
152
- ): Promise<DeviceInfo> {
153
- const data = await this.store.readDevice(deviceId)
154
- if (!data) return this.create(req, res)
155
-
156
- const lastSeenAt = new Date(data.lastSeenAt)
157
- const age = Date.now() - lastSeenAt.getTime()
158
-
159
- if (sessionId !== data.sessionId) {
160
- if (age <= SESSION_FIXATION_MAX_AGE) {
161
- // The cookie was probably rotated by a concurrent request. Let's
162
- // update the cookie with the new sessionId.
163
- forceRotate = true
164
- } else {
165
- // Something's wrong. Let's create a new session.
166
- await this.store.deleteDevice(deviceId)
167
- return this.create(req, res)
168
- }
169
- }
170
-
171
- const deviceMetadata = this.getRequestMetadata(req)
172
-
173
- const shouldRotate =
174
- forceRotate ||
175
- deviceMetadata.ipAddress !== data.ipAddress ||
176
- deviceMetadata.userAgent !== data.userAgent ||
177
- age > this.options.rotationRate
178
-
179
- if (shouldRotate) {
180
- await this.rotate(req, res, deviceId, {
181
- ipAddress: deviceMetadata.ipAddress,
182
- userAgent: deviceMetadata.userAgent || data.userAgent,
183
- })
184
- }
185
-
186
- return { deviceId, deviceMetadata }
187
- }
188
-
189
- private async rotate(
190
- req: IncomingMessage,
191
- res: ServerResponse,
192
- deviceId: DeviceId,
193
- data?: Partial<Omit<DeviceData, 'sessionId' | 'lastSeenAt'>>,
194
- ): Promise<void> {
195
- const sessionId = await generateSessionId()
196
-
197
- await this.store.updateDevice(deviceId, {
198
- ...data,
199
- sessionId,
200
- lastSeenAt: new Date(),
201
- })
202
-
203
- await this.setCookies(req, res, { deviceId, sessionId })
204
- }
205
-
206
- private async getCookies(
207
- req: IncomingMessage,
208
- ): Promise<{ value: CookieValue; mustRotate: boolean } | null> {
209
- const cookies = parseHttpCookies(req)
210
-
211
- const device = this.parseCookie(cookies, `dev-id`, deviceIdSchema)
212
- const session = this.parseCookie(cookies, `ses-id`, sessionIdSchema)
213
-
214
- const deviceId = device?.value
215
- const sessionId = session?.value
216
-
217
- // Silently ignore invalid cookies
218
- if (!deviceId || !sessionId) {
219
- // If the device cookie is still present, let's cleanup the DB
220
- if (deviceId) await this.store.deleteDevice(deviceId)
221
-
222
- return null
223
- }
224
-
225
- return {
226
- value: { deviceId, sessionId },
227
- mustRotate: device.mustRotate || session.mustRotate,
228
- }
229
- }
230
-
231
- private parseCookie<T>(
232
- cookies: Record<string, string | undefined>,
233
- name: string,
234
- schema: z.ZodType<T> | z.ZodEffects<z.ZodTypeAny, T, string>,
235
- ): null | { value: T; mustRotate: boolean } {
236
- const rawValue = Object.hasOwn(cookies, name) ? cookies[name] : null
237
- if (!rawValue) return null
238
-
239
- const result = schema.safeParse(rawValue)
240
- if (!result.success) return null
241
-
242
- const value = result.data
243
-
244
- if (this.options.cookie.keys) {
245
- const hashName = `${name}:hash`
246
-
247
- const hash = Object.hasOwn(cookies, hashName) ? cookies[hashName] : null
248
- if (!hash) return null
249
-
250
- const idx = this.options.cookie.keys.index(rawValue, hash)
251
- if (idx < 0) return null
252
-
253
- return { value, mustRotate: idx !== 0 }
254
- }
255
-
256
- return { value, mustRotate: false }
257
- }
258
-
259
- private async setCookies(
260
- req: IncomingMessage,
261
- res: ServerResponse,
262
- { deviceId, sessionId }: CookieValue,
263
- ) {
264
- this.writeCookie(res, `dev-id`, deviceId)
265
- this.writeCookie(res, `ses-id`, sessionId)
266
- }
267
-
268
- private writeCookie(res: ServerResponse, name: string, value?: string) {
269
- const cookieOptions = {
270
- maxAge: value
271
- ? this.options.cookie.age == null
272
- ? undefined
273
- : this.options.cookie.age / 1000
274
- : 0,
275
- httpOnly: true,
276
- path: '/',
277
- secure: this.options.cookie.secure !== false,
278
- sameSite: this.options.cookie.sameSite,
279
- } as const
280
-
281
- setCookie(res, name, value || '', cookieOptions)
282
-
283
- if (this.options.cookie.keys) {
284
- const hash = value ? this.options.cookie.keys.sign(value) : ''
285
- setCookie(res, `${name}:hash`, hash, cookieOptions)
286
- }
287
- }
288
-
289
- public getRequestMetadata(req: IncomingMessage) {
290
- return extractRequestMetadata(req, this.options)
291
- }
292
- }
@@ -1,31 +0,0 @@
1
- import { Awaitable, buildInterfaceChecker } from '../lib/util/type.js'
2
- import { DeviceData } from './device-data.js'
3
- import { DeviceId } from './device-id.js'
4
-
5
- // Export all types needed to implement the DeviceStore interface
6
- export * from './device-data.js'
7
- export * from './device-id.js'
8
- export * from './session-id.js'
9
-
10
- export type { Awaitable }
11
-
12
- export interface DeviceStore {
13
- createDevice(deviceId: DeviceId, data: DeviceData): Awaitable<void>
14
- readDevice(deviceId: DeviceId): Awaitable<DeviceData | null>
15
- updateDevice(deviceId: DeviceId, data: Partial<DeviceData>): Awaitable<void>
16
- deleteDevice(deviceId: DeviceId): Awaitable<void>
17
- }
18
-
19
- export const isDeviceStore = buildInterfaceChecker<DeviceStore>([
20
- 'createDevice',
21
- 'deleteDevice',
22
- 'readDevice',
23
- 'updateDevice',
24
- ])
25
-
26
- export function asDeviceStore<V>(implementation: V): V & DeviceStore {
27
- if (!implementation || !isDeviceStore(implementation)) {
28
- throw new Error('Invalid DeviceStore implementation')
29
- }
30
- return implementation
31
- }
@@ -1,21 +0,0 @@
1
- import { z } from 'zod'
2
- import { SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX } from '../constants.js'
3
- import { randomHexId } from '../lib/util/crypto.js'
4
-
5
- export const SESSION_ID_LENGTH =
6
- SESSION_ID_PREFIX.length + SESSION_ID_BYTES_LENGTH * 2 // hex encoding
7
-
8
- export const sessionIdSchema = z
9
- .string()
10
- .length(SESSION_ID_LENGTH)
11
- .refine(
12
- (v): v is `${typeof SESSION_ID_PREFIX}${string}` =>
13
- v.startsWith(SESSION_ID_PREFIX),
14
- {
15
- message: `Invalid session ID format`,
16
- },
17
- )
18
- export type SessionId = z.infer<typeof sessionIdSchema>
19
- export const generateSessionId = async (): Promise<SessionId> => {
20
- return `${SESSION_ID_PREFIX}${await randomHexId(SESSION_ID_BYTES_LENGTH)}`
21
- }
@@ -1,214 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose'
3
- import { z } from 'zod'
4
- import { ValidationError } from '@atproto/jwk'
5
- import { DPOP_NONCE_MAX_AGE } from '../constants.js'
6
- import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
7
- import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js'
8
- import { ifURL } from '../lib/util/cast.js'
9
- import {
10
- DpopNonce,
11
- DpopSecret,
12
- dpopSecretSchema,
13
- rotationIntervalSchema,
14
- } from './dpop-nonce.js'
15
- import { DpopProof } from './dpop-proof.js'
16
-
17
- const { JOSEError } = errors
18
-
19
- export { DpopNonce, type DpopSecret }
20
-
21
- export const dpopManagerOptionsSchema = z.object({
22
- /**
23
- * Set this to `false` to disable the use of nonces in DPoP proofs. Set this
24
- * to a secret Uint8Array or hex encoded string to use a predictable seed for
25
- * all nonces (typically useful when multiple instances are running). Leave
26
- * undefined to generate a random seed at startup.
27
- */
28
- dpopSecret: z.union([z.literal(false), dpopSecretSchema]).optional(),
29
- dpopRotationInterval: rotationIntervalSchema.optional(),
30
- })
31
- export type DpopManagerOptions = z.input<typeof dpopManagerOptionsSchema>
32
-
33
- export class DpopManager {
34
- protected readonly dpopNonce?: DpopNonce
35
-
36
- constructor(options: DpopManagerOptions = {}) {
37
- const { dpopSecret, dpopRotationInterval } =
38
- dpopManagerOptionsSchema.parse(options)
39
- this.dpopNonce =
40
- dpopSecret === false
41
- ? undefined
42
- : new DpopNonce(dpopSecret, dpopRotationInterval)
43
- }
44
-
45
- nextNonce(): string | undefined {
46
- return this.dpopNonce?.next()
47
- }
48
-
49
- /**
50
- * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
51
- */
52
- async checkProof(
53
- httpMethod: string,
54
- httpUrl: Readonly<URL>,
55
- httpHeaders: Record<string, undefined | string | string[]>,
56
- accessToken?: string,
57
- ): Promise<null | DpopProof> {
58
- // Fool proofing against use of empty string
59
- if (!httpMethod) {
60
- throw new TypeError('HTTP method is required')
61
- }
62
-
63
- const proof = extractProof(httpHeaders)
64
- if (!proof) return null
65
-
66
- const { protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, {
67
- typ: 'dpop+jwt',
68
- maxTokenAge: 10, // Will ensure presence & validity of "iat" claim
69
- clockTolerance: DPOP_NONCE_MAX_AGE / 1e3,
70
- }).catch((err) => {
71
- throw wrapInvalidDpopProofError(err, 'Failed to verify DPoP proof')
72
- })
73
-
74
- // @NOTE For legacy & backwards compatibility reason, we cannot use
75
- // `jwtPayloadSchema` here as it will reject DPoP proofs containing a query
76
- // or fragment component in the "htu" claim.
77
-
78
- // const { ath, htm, htu, jti, nonce } = await jwtPayloadSchema
79
- // .parseAsync(payload)
80
- // .catch((err) => {
81
- // throw buildInvalidDpopProofError('Invalid DPoP proof', err)
82
- // })
83
-
84
- // @TODO Uncomment previous lines (and remove redundant checks bellow) once
85
- // we decide to drop legacy support.
86
- const { ath, htm, htu, jti, nonce } = payload
87
-
88
- if (nonce !== undefined && typeof nonce !== 'string') {
89
- throw new InvalidDpopProofError('Invalid DPoP "nonce" type')
90
- }
91
-
92
- if (!jti || typeof jti !== 'string') {
93
- throw new InvalidDpopProofError('DPoP "jti" missing')
94
- }
95
-
96
- // Note rfc9110#section-9.1 states that the method name is case-sensitive
97
- if (!htm || htm !== httpMethod) {
98
- throw new InvalidDpopProofError('DPoP "htm" mismatch')
99
- }
100
-
101
- if (!htu || typeof htu !== 'string') {
102
- throw new InvalidDpopProofError('Invalid DPoP "htu" type')
103
- }
104
-
105
- // > To reduce the likelihood of false negatives, servers SHOULD employ
106
- // > syntax-based normalization (Section 6.2.2 of [RFC3986]) and
107
- // > scheme-based normalization (Section 6.2.3 of [RFC3986]) before
108
- // > comparing the htu claim.
109
- //
110
- // RFC9449 section 4.3. Checking DPoP Proofs - https://datatracker.ietf.org/doc/html/rfc9449#section-4.3
111
- if (!htu || parseHtu(htu) !== normalizeHtuUrl(httpUrl)) {
112
- throw new InvalidDpopProofError('DPoP "htu" mismatch')
113
- }
114
-
115
- if (!nonce && this.dpopNonce) {
116
- throw new UseDpopNonceError()
117
- }
118
-
119
- if (nonce && !this.dpopNonce?.check(nonce)) {
120
- throw new UseDpopNonceError('DPoP "nonce" mismatch')
121
- }
122
-
123
- if (accessToken) {
124
- const accessTokenHash = createHash('sha256').update(accessToken).digest()
125
- if (ath !== accessTokenHash.toString('base64url')) {
126
- throw new InvalidDpopProofError('DPoP "ath" mismatch')
127
- }
128
- } else if (ath !== undefined) {
129
- throw new InvalidDpopProofError('DPoP "ath" claim not allowed')
130
- }
131
-
132
- // @NOTE we can assert there is a jwk because the jwtVerify used the
133
- // EmbeddedJWK key getter mechanism.
134
- const jwk = protectedHeader.jwk!
135
- const jkt = await calculateJwkThumbprint(jwk, 'sha256').catch((err) => {
136
- throw wrapInvalidDpopProofError(err, 'Failed to calculate jkt')
137
- })
138
-
139
- // @NOTE We freeze the proof to prevent accidental modification (esp. from
140
- // hooks).
141
- return Object.freeze({ jti, jkt, htm, htu })
142
- }
143
- }
144
-
145
- function extractProof(
146
- httpHeaders: Record<string, undefined | string | string[]>,
147
- ): string | null {
148
- const dpopHeader = httpHeaders['dpop']
149
- switch (typeof dpopHeader) {
150
- case 'string':
151
- if (dpopHeader) return dpopHeader
152
- throw new InvalidDpopProofError('DPoP header cannot be empty')
153
- case 'object':
154
- // @NOTE the "0" case should never happen a node.js HTTP server will only
155
- // return an array if the header is set multiple times.
156
- if (dpopHeader.length === 1 && dpopHeader[0]) return dpopHeader[0]!
157
- throw new InvalidDpopProofError('DPoP header must contain a single proof')
158
- default:
159
- return null
160
- }
161
- }
162
-
163
- /**
164
- * Constructs the HTTP URI (htu) claim as defined in RFC9449.
165
- *
166
- * The htu claim is the normalized URL of the HTTP request, excluding the query
167
- * string and fragment. This function ensures that the URL is normalized by
168
- * removing the search and hash components, as well as by using an URL object to
169
- * simplify the pathname (e.g. removing dot segments).
170
- *
171
- * @returns The normalized URL as a string.
172
- * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
173
- */
174
- function normalizeHtuUrl(url: Readonly<URL>): string {
175
- // NodeJS's `URL` normalizes the pathname, so we can just use that.
176
- return url.origin + url.pathname
177
- }
178
-
179
- function parseHtu(htu: string): string {
180
- const url = ifURL(htu)
181
- if (!url) {
182
- throw new InvalidDpopProofError('DPoP "htu" is not a valid URL')
183
- }
184
-
185
- // @NOTE the checks bellow can be removed once once jwtPayloadSchema is used
186
- // to validate the DPoP proof payload as it already performs these checks
187
- // (though the htuSchema).
188
-
189
- if (url.password || url.username) {
190
- throw new InvalidDpopProofError('DPoP "htu" must not contain credentials')
191
- }
192
-
193
- if (url.protocol !== 'http:' && url.protocol !== 'https:') {
194
- throw new InvalidDpopProofError('DPoP "htu" must be http or https')
195
- }
196
-
197
- // @NOTE For legacy & backwards compatibility reason, we allow a query and
198
- // fragment in the DPoP proof's htu. This is not a standard behavior as the
199
- // htu is not supposed to contain query or fragment.
200
-
201
- // NodeJS's `URL` normalizes the pathname.
202
- return normalizeHtuUrl(url)
203
- }
204
-
205
- function wrapInvalidDpopProofError(
206
- err: unknown,
207
- title: string,
208
- ): InvalidDpopProofError {
209
- const msg =
210
- err instanceof JOSEError || err instanceof ValidationError
211
- ? `${title}: ${err.message}`
212
- : title
213
- return new InvalidDpopProofError(msg, err)
214
- }