@atproto/oauth-provider 0.17.0-next.0 → 0.18.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.
@@ -8,6 +8,7 @@ import { DeviceId } from '../device/device-id.js'
8
8
  import { InvalidCredentialsError } from '../errors/invalid-credentials-error.js'
9
9
  import { InvalidRequestError } from '../errors/invalid-request-error.js'
10
10
  import { HCaptchaClient, HcaptchaVerifyResult } from '../lib/hcaptcha.js'
11
+ import { callAsync } from '../lib/util/function.js'
11
12
  import { constantTime } from '../lib/util/time.js'
12
13
  import { OAuthHooks, RequestMetadata } from '../oauth-hooks.js'
13
14
  import { Customization } from '../oauth-provider.js'
@@ -20,6 +21,10 @@ import {
20
21
  ResetPasswordConfirmInput,
21
22
  ResetPasswordRequestInput,
22
23
  SignUpData,
24
+ UpdateEmailConfirmInput,
25
+ UpdateEmailRequestInput,
26
+ VerifyEmailConfirmInput,
27
+ VerifyEmailRequestInput,
23
28
  } from './account-store.js'
24
29
  import { SignInData } from './sign-in-data.js'
25
30
  import { SignUpInput } from './sign-up-input.js'
@@ -27,6 +32,8 @@ import { SignUpInput } from './sign-up-input.js'
27
32
  const TIMING_ATTACK_MITIGATION_DELAY = 400
28
33
  const BRUTE_FORCE_MITIGATION_DELAY = 300
29
34
 
35
+ // @TODO Add rate limit to all the OAuth routes.
36
+
30
37
  export class AccountManager {
31
38
  protected readonly inviteCodeRequired: boolean
32
39
  protected readonly hcaptchaClient?: HCaptchaClient
@@ -119,42 +126,39 @@ export class AccountManager {
119
126
  deviceMetadata: RequestMetadata,
120
127
  input: SignUpInput,
121
128
  ): Promise<Account> {
122
- await this.hooks.onSignUpAttempt?.call(null, {
123
- input,
124
- deviceId,
125
- deviceMetadata,
126
- })
127
-
128
- const data = await this.buildSignupData(input, deviceId, deviceMetadata)
129
-
130
- // Mitigation against brute forcing email of users.
131
- // @TODO Add rate limit to all the OAuth routes.
132
- const account = await constantTime(
133
- BRUTE_FORCE_MITIGATION_DELAY,
134
- async () => {
135
- return this.store.createAccount(data)
136
- },
137
- ).catch((err) => {
138
- throw InvalidRequestError.from(err, 'Account creation failed')
139
- })
140
-
141
- try {
142
- await this.hooks.onSignedUp?.call(null, {
143
- data,
144
- account,
129
+ return constantTime(BRUTE_FORCE_MITIGATION_DELAY, async () => {
130
+ await this.hooks.onSignUpAttempt?.call(null, {
131
+ input,
145
132
  deviceId,
146
133
  deviceMetadata,
147
134
  })
148
135
 
149
- return account
150
- } catch (err) {
151
- await this.removeDeviceAccount(deviceId, account.sub)
136
+ const data = await this.buildSignupData(input, deviceId, deviceMetadata)
152
137
 
153
- throw InvalidRequestError.from(
154
- err,
155
- 'The account was successfully created but something went wrong, try signing-in.',
156
- )
157
- }
138
+ const account = await callAsync(() =>
139
+ this.store.createAccount(data),
140
+ ).catch((err) => {
141
+ throw InvalidRequestError.from(err, 'Account creation failed')
142
+ })
143
+
144
+ try {
145
+ await this.hooks.onSignedUp?.call(null, {
146
+ data,
147
+ account,
148
+ deviceId,
149
+ deviceMetadata,
150
+ })
151
+
152
+ return account
153
+ } catch (err) {
154
+ await this.removeDeviceAccount(deviceId, account.sub)
155
+
156
+ throw InvalidRequestError.from(
157
+ err,
158
+ 'The account was successfully created but something went wrong, try signing-in.',
159
+ )
160
+ }
161
+ })
158
162
  }
159
163
 
160
164
  public async authenticateAccount(
@@ -163,7 +167,7 @@ export class AccountManager {
163
167
  data: SignInData,
164
168
  clientId?: ClientId,
165
169
  ): Promise<Account> {
166
- try {
170
+ return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
167
171
  await this.hooks.onSignInAttempt?.call(null, {
168
172
  data,
169
173
  deviceId,
@@ -171,15 +175,9 @@ export class AccountManager {
171
175
  clientId,
172
176
  })
173
177
 
174
- let account: Account
175
- try {
176
- account = await constantTime(
177
- TIMING_ATTACK_MITIGATION_DELAY,
178
- async () => {
179
- return this.store.authenticateAccount(data)
180
- },
181
- )
182
- } catch (err) {
178
+ const account = await callAsync(() =>
179
+ this.store.authenticateAccount(data),
180
+ ).catch(async (err) => {
183
181
  // Only notify for credential failures (e.g. unknown identifier, wrong
184
182
  // password). Server errors and flows that require an additional factor
185
183
  // (e.g. SecondAuthenticationFactorRequiredError) are not "failed
@@ -213,8 +211,9 @@ export class AccountManager {
213
211
  throw new InvalidRequestError(err.error_description)
214
212
  }
215
213
  }
214
+
216
215
  throw err
217
- }
216
+ })
218
217
 
219
218
  await this.hooks.onSignedIn?.call(null, {
220
219
  data,
@@ -225,12 +224,12 @@ export class AccountManager {
225
224
  })
226
225
 
227
226
  return account
228
- } catch (err) {
227
+ }).catch((err) => {
229
228
  throw InvalidRequestError.from(
230
229
  err,
231
230
  'Unable to sign-in due to an unexpected server error',
232
231
  )
233
- }
232
+ })
234
233
  }
235
234
 
236
235
  public async upsertDeviceAccount(
@@ -294,18 +293,16 @@ export class AccountManager {
294
293
  deviceMetadata: RequestMetadata,
295
294
  input: ResetPasswordRequestInput,
296
295
  ) {
297
- await this.hooks.onResetPasswordRequest?.call(null, {
298
- input,
299
- deviceId,
300
- deviceMetadata,
301
- })
302
-
303
296
  return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
297
+ await this.hooks.onResetPasswordRequest?.call(null, {
298
+ input,
299
+ deviceId,
300
+ deviceMetadata,
301
+ })
302
+
304
303
  const account = await this.store.resetPasswordRequest(input)
305
304
 
306
- if (!account) {
307
- return // Silently ignore to prevent user enumeration
308
- }
305
+ // @NOTE Do not throw here, to prevent user enumeration
309
306
 
310
307
  await this.hooks.onResetPasswordRequested?.call(null, {
311
308
  input,
@@ -321,13 +318,13 @@ export class AccountManager {
321
318
  deviceMetadata: RequestMetadata,
322
319
  input: ResetPasswordConfirmInput,
323
320
  ) {
324
- await this.hooks.onResetPasswordConfirm?.call(null, {
325
- input,
326
- deviceId,
327
- deviceMetadata,
328
- })
329
-
330
321
  return constantTime(TIMING_ATTACK_MITIGATION_DELAY, async () => {
322
+ await this.hooks.onResetPasswordConfirm?.call(null, {
323
+ input,
324
+ deviceId,
325
+ deviceMetadata,
326
+ })
327
+
331
328
  const account = await this.store.resetPasswordConfirm(input)
332
329
 
333
330
  if (!account) {
@@ -340,6 +337,8 @@ export class AccountManager {
340
337
  deviceMetadata,
341
338
  account,
342
339
  })
340
+
341
+ return account
343
342
  })
344
343
  }
345
344
 
@@ -348,4 +347,110 @@ export class AccountManager {
348
347
  return this.store.verifyHandleAvailability(handle)
349
348
  })
350
349
  }
350
+
351
+ public async updateEmailRequest(
352
+ deviceId: DeviceId,
353
+ deviceMetadata: RequestMetadata,
354
+ input: UpdateEmailRequestInput,
355
+ account: Account,
356
+ ): Promise<{ tokenRequired: boolean }> {
357
+ await this.hooks.onChangeEmailRequest?.call(null, {
358
+ deviceId,
359
+ deviceMetadata,
360
+ input,
361
+ account,
362
+ })
363
+
364
+ const { tokenRequired } = await this.store.updateEmailRequest(input)
365
+
366
+ await this.hooks.onChangeEmailRequested?.call(null, {
367
+ deviceId,
368
+ deviceMetadata,
369
+ input,
370
+ account,
371
+ })
372
+
373
+ return { tokenRequired: tokenRequired === true }
374
+ }
375
+
376
+ public async updateEmailConfirm(
377
+ deviceId: DeviceId,
378
+ deviceMetadata: RequestMetadata,
379
+ input: UpdateEmailConfirmInput,
380
+ account: Account,
381
+ ): Promise<Account> {
382
+ await this.hooks.onUpdateEmailConfirm?.call(null, {
383
+ deviceId,
384
+ deviceMetadata,
385
+ input,
386
+ account,
387
+ })
388
+
389
+ const updatedAccount = await this.store.updateEmailConfirm(input)
390
+
391
+ if (!updatedAccount) {
392
+ throw new InvalidRequestError('Invalid token')
393
+ }
394
+
395
+ await this.hooks.onUpdateEmailConfirmed?.call(null, {
396
+ deviceId,
397
+ deviceMetadata,
398
+ input,
399
+ account: updatedAccount,
400
+ })
401
+
402
+ return updatedAccount
403
+ }
404
+
405
+ public async verifyEmailRequest(
406
+ deviceId: DeviceId,
407
+ deviceMetadata: RequestMetadata,
408
+ input: VerifyEmailRequestInput,
409
+ account: Account,
410
+ ): Promise<void> {
411
+ await this.hooks.onVerifyEmailRequest?.call(null, {
412
+ deviceId,
413
+ deviceMetadata,
414
+ input,
415
+ account,
416
+ })
417
+
418
+ await this.store.verifyEmailRequest(input)
419
+
420
+ await this.hooks.onVerifyEmailRequested?.call(null, {
421
+ deviceId,
422
+ deviceMetadata,
423
+ input,
424
+ account,
425
+ })
426
+ }
427
+
428
+ public async verifyEmailConfirm(
429
+ deviceId: DeviceId,
430
+ deviceMetadata: RequestMetadata,
431
+ input: VerifyEmailConfirmInput,
432
+ account: Account,
433
+ ): Promise<Account> {
434
+ await this.hooks.onVerifyEmailConfirm?.call(null, {
435
+ deviceId,
436
+ deviceMetadata,
437
+ input,
438
+ account,
439
+ })
440
+
441
+ const updatedAccount = await this.store.verifyEmailConfirm(input)
442
+
443
+ if (!updatedAccount) {
444
+ throw new InvalidRequestError('Invalid token')
445
+ }
446
+
447
+ await this.hooks.onVerifyEmailConfirmed?.call(null, {
448
+ deviceId,
449
+ deviceMetadata,
450
+ input,
451
+ account: updatedAccount,
452
+ })
453
+
454
+ return updatedAccount
455
+ }
351
456
  }
@@ -1,6 +1,11 @@
1
1
  import type {
2
2
  Account,
3
+ ConfirmEmailUpdateInput,
4
+ ConfirmEmailVerificationInput,
3
5
  ConfirmResetPasswordInput,
6
+ InitiateEmailUpdateInput,
7
+ InitiateEmailUpdateOutput,
8
+ InitiateEmailVerificationInput,
4
9
  InitiatePasswordResetInput,
5
10
  } from '@atproto/oauth-provider-api'
6
11
  import { OAuthScope } from '@atproto/oauth-types'
@@ -45,6 +50,12 @@ export {
45
50
  export type ResetPasswordRequestInput = InitiatePasswordResetInput
46
51
  export type ResetPasswordConfirmInput = ConfirmResetPasswordInput
47
52
 
53
+ export type UpdateEmailRequestInput = InitiateEmailUpdateInput
54
+ export type UpdateEmailRequestOutput = InitiateEmailUpdateOutput
55
+ export type UpdateEmailConfirmInput = ConfirmEmailUpdateInput
56
+ export type VerifyEmailRequestInput = InitiateEmailVerificationInput
57
+ export type VerifyEmailConfirmInput = ConfirmEmailVerificationInput
58
+
48
59
  export type CreateAccountData = {
49
60
  locale: string
50
61
  email: string
@@ -187,6 +198,20 @@ export interface AccountStore {
187
198
  data: ResetPasswordConfirmInput,
188
199
  ): Awaitable<null | Account>
189
200
 
201
+ updateEmailRequest(
202
+ data: UpdateEmailRequestInput,
203
+ ): Awaitable<UpdateEmailRequestOutput>
204
+ /**
205
+ * Must trigger a verification email to be sent to the new email address, that
206
+ * will then be confirmed through {@link updateEmailConfirm}. The account's
207
+ * `email_verified` field is expected to become `false` until the new email is
208
+ * confirmed.
209
+ */
210
+ updateEmailConfirm(data: UpdateEmailConfirmInput): Awaitable<Account | null>
211
+
212
+ verifyEmailRequest(data: VerifyEmailRequestInput): Awaitable<void>
213
+ verifyEmailConfirm(data: VerifyEmailConfirmInput): Awaitable<Account | null>
214
+
190
215
  /**
191
216
  * @throws {HandleUnavailableError} - To indicate that the handle is already taken
192
217
  */
@@ -204,6 +229,10 @@ export const isAccountStore = buildInterfaceChecker<AccountStore>([
204
229
  'listDeviceAccounts',
205
230
  'resetPasswordRequest',
206
231
  'resetPasswordConfirm',
232
+ 'updateEmailRequest',
233
+ 'updateEmailConfirm',
234
+ 'verifyEmailRequest',
235
+ 'verifyEmailConfirm',
207
236
  'verifyHandleAvailability',
208
237
  ])
209
238
 
@@ -12,6 +12,10 @@ import {
12
12
  ResetPasswordConfirmInput,
13
13
  ResetPasswordRequestInput,
14
14
  SignUpData,
15
+ UpdateEmailConfirmInput,
16
+ UpdateEmailRequestInput,
17
+ VerifyEmailConfirmInput,
18
+ VerifyEmailRequestInput,
15
19
  } from './account/account-store.js'
16
20
  import { SignInData } from './account/sign-in-data.js'
17
21
  import { SignUpInput } from './account/sign-up-input.js'
@@ -87,6 +91,98 @@ export type OAuthHooks = {
87
91
  data: { metadata: OAuthClientMetadata; jwks?: Jwks },
88
92
  ) => Awaitable<undefined | Partial<ClientInfo>>
89
93
 
94
+ /**
95
+ * This hook is called when a user requests an email change, before the email
96
+ * change request is triggered on the account store. Only triggered with
97
+ * authenticated sessions, so the `account` is always available.
98
+ */
99
+ onChangeEmailRequest?: (data: {
100
+ input: UpdateEmailRequestInput
101
+ deviceId: DeviceId
102
+ deviceMetadata: RequestMetadata
103
+ account: Account
104
+ }) => Awaitable<void>
105
+
106
+ /**
107
+ * This hook is called after a user requests an email change, and the email
108
+ * change request was successfully triggered on the account store.
109
+ */
110
+ onChangeEmailRequested?: (data: {
111
+ input: UpdateEmailRequestInput
112
+ deviceId: DeviceId
113
+ deviceMetadata: RequestMetadata
114
+ account: Account
115
+ }) => Awaitable<void>
116
+
117
+ /**
118
+ * This hook is called when a user confirms an email change, before the email
119
+ * change is actually confirmed on the account store. Only triggered with
120
+ * authenticated sessions, so the `account` is always available.
121
+ */
122
+ onUpdateEmailConfirm?: (data: {
123
+ input: UpdateEmailConfirmInput
124
+ deviceId: DeviceId
125
+ deviceMetadata: RequestMetadata
126
+ account: Account
127
+ }) => Awaitable<void>
128
+
129
+ /**
130
+ * This hook is called after a user confirms an email change, and the email
131
+ * change was successfully confirmed on the account store.
132
+ */
133
+ onUpdateEmailConfirmed?: (data: {
134
+ input: UpdateEmailConfirmInput
135
+ deviceId: DeviceId
136
+ deviceMetadata: RequestMetadata
137
+ account: Account
138
+ }) => Awaitable<void>
139
+
140
+ /**
141
+ * This hook is called when a user requests an email verification, before the
142
+ * verification request is triggered on the account store. Only triggered with
143
+ * authenticated sessions, so the `account` is always available.
144
+ */
145
+ onVerifyEmailRequest?: (data: {
146
+ input: VerifyEmailRequestInput
147
+ deviceId: DeviceId
148
+ deviceMetadata: RequestMetadata
149
+ account: Account
150
+ }) => Awaitable<void>
151
+
152
+ /**
153
+ * This hook is called after a user requests an email verification, and the
154
+ * verification request was successfully triggered on the account store.
155
+ */
156
+ onVerifyEmailRequested?: (data: {
157
+ input: VerifyEmailRequestInput
158
+ deviceId: DeviceId
159
+ deviceMetadata: RequestMetadata
160
+ account: Account
161
+ }) => Awaitable<void>
162
+
163
+ /**
164
+ * This hook is called when a user confirms an email verification, before the
165
+ * verification is actually confirmed on the account store. Only triggered
166
+ * with authenticated sessions, so the `account` is always available.
167
+ */
168
+ onVerifyEmailConfirm?: (data: {
169
+ input: VerifyEmailConfirmInput
170
+ deviceId: DeviceId
171
+ deviceMetadata: RequestMetadata
172
+ account: Account
173
+ }) => Awaitable<void>
174
+
175
+ /**
176
+ * This hook is called after a user confirms an email verification, and the
177
+ * verification was successfully confirmed on the account store.
178
+ */
179
+ onVerifyEmailConfirmed?: (data: {
180
+ input: VerifyEmailConfirmInput
181
+ deviceId: DeviceId
182
+ deviceMetadata: RequestMetadata
183
+ account: Account
184
+ }) => Awaitable<void>
185
+
90
186
  /**
91
187
  * This hook is called when a user attempts to sign up, after every validation
92
188
  * has passed (including hcaptcha).
@@ -111,7 +207,8 @@ export type OAuthHooks = {
111
207
 
112
208
  /**
113
209
  * This hook is called when a user requests a password reset, before the
114
- * reset password request is triggered on the account store.
210
+ * reset password request is triggered on the account store. Use this to
211
+ * potentially cancel the password reset.
115
212
  */
116
213
  onResetPasswordRequest?: (data: {
117
214
  input: ResetPasswordRequestInput
@@ -121,13 +218,14 @@ export type OAuthHooks = {
121
218
 
122
219
  /**
123
220
  * This hook is called when a user requests a password reset, before the
124
- * reset password request is triggered on the account store.
221
+ * reset password request is triggered on the account store. If not account
222
+ * was found for the provided identifier, the `account` field will be `null`.
125
223
  */
126
224
  onResetPasswordRequested?: (data: {
127
225
  input: ResetPasswordRequestInput
128
226
  deviceId: DeviceId
129
227
  deviceMetadata: RequestMetadata
130
- account: Account
228
+ account: Account | null
131
229
  }) => Awaitable<void>
132
230
 
133
231
  /**
@@ -269,6 +269,110 @@ export function createApiMiddleware<
269
269
  }),
270
270
  )
271
271
 
272
+ router.use(
273
+ apiRoute({
274
+ method: 'POST',
275
+ endpoint: '/update-email-request',
276
+ schema: z
277
+ .object({
278
+ sub: subSchema,
279
+ locale: localeSchema.optional(),
280
+ })
281
+ .strict(),
282
+ async handler(req, res) {
283
+ const { account } = await authenticate.call(this, req, res)
284
+
285
+ const { tokenRequired } =
286
+ await server.accountManager.updateEmailRequest(
287
+ this.deviceId,
288
+ this.deviceMetadata,
289
+ this.input,
290
+ account,
291
+ )
292
+
293
+ return { json: { tokenRequired } }
294
+ },
295
+ }),
296
+ )
297
+
298
+ router.use(
299
+ apiRoute({
300
+ method: 'POST',
301
+ endpoint: '/update-email-confirm',
302
+ schema: z
303
+ .object({
304
+ sub: subSchema,
305
+ token: emailOtpSchema,
306
+ email: emailSchema,
307
+ locale: localeSchema.optional(),
308
+ })
309
+ .strict(),
310
+ async handler(req, res) {
311
+ const { account } = await authenticate.call(this, req, res)
312
+
313
+ await server.accountManager.updateEmailConfirm(
314
+ this.deviceId,
315
+ this.deviceMetadata,
316
+ this.input,
317
+ account,
318
+ )
319
+
320
+ return { json: { success: true } }
321
+ },
322
+ }),
323
+ )
324
+
325
+ router.use(
326
+ apiRoute({
327
+ method: 'POST',
328
+ endpoint: '/verify-email-request',
329
+ schema: z
330
+ .object({
331
+ sub: subSchema,
332
+ locale: localeSchema.optional(),
333
+ })
334
+ .strict(),
335
+ async handler(req, res) {
336
+ const { account } = await authenticate.call(this, req, res)
337
+
338
+ await server.accountManager.verifyEmailRequest(
339
+ this.deviceId,
340
+ this.deviceMetadata,
341
+ this.input,
342
+ account,
343
+ )
344
+
345
+ return { json: { success: true } }
346
+ },
347
+ }),
348
+ )
349
+
350
+ router.use(
351
+ apiRoute({
352
+ method: 'POST',
353
+ endpoint: '/verify-email-confirm',
354
+ schema: z
355
+ .object({
356
+ sub: subSchema,
357
+ token: emailOtpSchema,
358
+ email: emailSchema,
359
+ })
360
+ .strict(),
361
+ async handler(req, res) {
362
+ const { account } = await authenticate.call(this, req, res)
363
+
364
+ await server.accountManager.verifyEmailConfirm(
365
+ this.deviceId,
366
+ this.deviceMetadata,
367
+ this.input,
368
+ account,
369
+ )
370
+
371
+ return { json: { success: true } }
372
+ },
373
+ }),
374
+ )
375
+
272
376
  router.use(
273
377
  apiRoute({
274
378
  method: 'GET',