diff --git a/.changeset/little-fans-obey.md b/.changeset/little-fans-obey.md new file mode 100644 index 00000000000..6fd0265b15b --- /dev/null +++ b/.changeset/little-fans-obey.md @@ -0,0 +1,7 @@ +--- +'@atproto/dev-env': patch +'@atproto/api': patch +'@atproto/pds': patch +--- + +Added email verification and update flows diff --git a/.changeset/witty-islands-burn.md b/.changeset/witty-islands-burn.md new file mode 100644 index 00000000000..b82cd22b8c1 --- /dev/null +++ b/.changeset/witty-islands-burn.md @@ -0,0 +1,5 @@ +--- +'@atproto/common-web': patch +--- + +Added lessThanAgoMs utility diff --git a/lexicons/com/atproto/server/confirmEmail.json b/lexicons/com/atproto/server/confirmEmail.json new file mode 100644 index 00000000000..6c2e4291f76 --- /dev/null +++ b/lexicons/com/atproto/server/confirmEmail.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.confirmEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Confirm an email using a token from com.atproto.server.requestEmailConfirmation.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email", "token"], + "properties": { + "email": { "type": "string" }, + "token": { "type": "string" } + } + } + }, + "errors": [ + { "name": "AccountNotFound" }, + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "InvalidEmail" } + ] + } + } +} diff --git a/lexicons/com/atproto/server/createSession.json b/lexicons/com/atproto/server/createSession.json index fc416ddabae..7d877cec91c 100644 --- a/lexicons/com/atproto/server/createSession.json +++ b/lexicons/com/atproto/server/createSession.json @@ -29,7 +29,8 @@ "refreshJwt": { "type": "string" }, "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, - "email": { "type": "string" } + "email": { "type": "string" }, + "emailConfirmed": { "type": "boolean" } } } }, diff --git a/lexicons/com/atproto/server/getSession.json b/lexicons/com/atproto/server/getSession.json index 55b129be3df..7ff5569eb1b 100644 --- a/lexicons/com/atproto/server/getSession.json +++ b/lexicons/com/atproto/server/getSession.json @@ -13,7 +13,8 @@ "properties": { "handle": { "type": "string", "format": "handle" }, "did": { "type": "string", "format": "did" }, - "email": { "type": "string" } + "email": { "type": "string" }, + "emailConfirmed": { "type": "boolean" } } } } diff --git a/lexicons/com/atproto/server/requestEmailConfirmation.json b/lexicons/com/atproto/server/requestEmailConfirmation.json new file mode 100644 index 00000000000..4b2470bf59b --- /dev/null +++ b/lexicons/com/atproto/server/requestEmailConfirmation.json @@ -0,0 +1,10 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailConfirmation", + "defs": { + "main": { + "type": "procedure", + "description": "Request an email with a code to confirm ownership of email" + } + } +} diff --git a/lexicons/com/atproto/server/requestEmailUpdate.json b/lexicons/com/atproto/server/requestEmailUpdate.json new file mode 100644 index 00000000000..4cc1a86f612 --- /dev/null +++ b/lexicons/com/atproto/server/requestEmailUpdate.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.requestEmailUpdate", + "defs": { + "main": { + "type": "procedure", + "description": "Request a token in order to update email.", + "output": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["tokenRequired"], + "properties": { + "tokenRequired": { "type": "boolean" } + } + } + } + } + } +} diff --git a/lexicons/com/atproto/server/updateEmail.json b/lexicons/com/atproto/server/updateEmail.json new file mode 100644 index 00000000000..88872698910 --- /dev/null +++ b/lexicons/com/atproto/server/updateEmail.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "com.atproto.server.updateEmail", + "defs": { + "main": { + "type": "procedure", + "description": "Update an account's email.", + "input": { + "encoding": "application/json", + "schema": { + "type": "object", + "required": ["email"], + "properties": { + "email": { "type": "string" }, + "token": { + "type": "string", + "description": "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed." + } + } + } + }, + "errors": [ + { "name": "ExpiredToken" }, + { "name": "InvalidToken" }, + { "name": "TokenRequired" } + ] + } + } +} diff --git a/packages/api/src/agent.ts b/packages/api/src/agent.ts index d5af9b63ddc..2cd4c44e7a0 100644 --- a/packages/api/src/agent.ts +++ b/packages/api/src/agent.ts @@ -95,6 +95,7 @@ export class AtpAgent { handle: res.data.handle, did: res.data.did, email: opts.email, + emailConfirmed: false, } return res } catch (e) { @@ -126,6 +127,7 @@ export class AtpAgent { handle: res.data.handle, did: res.data.did, email: res.data.email, + emailConfirmed: res.data.emailConfirmed, } return res } catch (e) { @@ -154,6 +156,7 @@ export class AtpAgent { } this.session.email = res.data.email this.session.handle = res.data.handle + this.session.emailConfirmed = res.data.emailConfirmed return res } catch (e) { this.session = undefined @@ -268,6 +271,7 @@ export class AtpAgent { } else if (isNewSessionObject(this._baseClient, res.body)) { // succeeded, update the session this.session = { + ...(this.session || {}), accessJwt: res.body.accessJwt, refreshJwt: res.body.refreshJwt, handle: res.body.handle, diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 982117cef02..e5286aa2eb1 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -41,6 +41,7 @@ import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -55,9 +56,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -170,6 +174,7 @@ export * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords export * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' export * as ComAtprotoRepoStrongRef from './types/com/atproto/repo/strongRef' export * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +export * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' export * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' export * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' export * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -184,9 +189,12 @@ export * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi export * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' export * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' export * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +export * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +export * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' export * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' export * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' export * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +export * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' export * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' export * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' export * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -712,6 +720,17 @@ export class ServerNS { this._service = service } + confirmEmail( + data?: ComAtprotoServerConfirmEmail.InputSchema, + opts?: ComAtprotoServerConfirmEmail.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.confirmEmail', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerConfirmEmail.toKnownErr(e) + }) + } + createAccount( data?: ComAtprotoServerCreateAccount.InputSchema, opts?: ComAtprotoServerCreateAccount.CallOptions, @@ -855,6 +874,28 @@ export class ServerNS { }) } + requestEmailConfirmation( + data?: ComAtprotoServerRequestEmailConfirmation.InputSchema, + opts?: ComAtprotoServerRequestEmailConfirmation.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.requestEmailConfirmation', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerRequestEmailConfirmation.toKnownErr(e) + }) + } + + requestEmailUpdate( + data?: ComAtprotoServerRequestEmailUpdate.InputSchema, + opts?: ComAtprotoServerRequestEmailUpdate.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.requestEmailUpdate', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerRequestEmailUpdate.toKnownErr(e) + }) + } + requestPasswordReset( data?: ComAtprotoServerRequestPasswordReset.InputSchema, opts?: ComAtprotoServerRequestPasswordReset.CallOptions, @@ -887,6 +928,17 @@ export class ServerNS { throw ComAtprotoServerRevokeAppPassword.toKnownErr(e) }) } + + updateEmail( + data?: ComAtprotoServerUpdateEmail.InputSchema, + opts?: ComAtprotoServerUpdateEmail.CallOptions, + ): Promise { + return this._service.xrpc + .call('com.atproto.server.updateEmail', opts?.qp, data, opts) + .catch((e) => { + throw ComAtprotoServerUpdateEmail.toKnownErr(e) + }) + } } export class SyncNS { diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/api/src/client/types/com/atproto/server/confirmEmail.ts b/packages/api/src/client/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..eb53dc5a0dc --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,61 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export class AccountNotFoundError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class ExpiredTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidEmailError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'AccountNotFound') return new AccountNotFoundError(e) + if (e.error === 'ExpiredToken') return new ExpiredTokenError(e) + if (e.error === 'InvalidToken') return new InvalidTokenError(e) + if (e.error === 'InvalidEmail') return new InvalidEmailError(e) + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/createSession.ts b/packages/api/src/client/types/com/atproto/server/createSession.ts index d86f2aef1d4..08d2bcd6225 100644 --- a/packages/api/src/client/types/com/atproto/server/createSession.ts +++ b/packages/api/src/client/types/com/atproto/server/createSession.ts @@ -22,6 +22,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/getSession.ts b/packages/api/src/client/types/com/atproto/server/getSession.ts index c15836dfb77..91d51860982 100644 --- a/packages/api/src/client/types/com/atproto/server/getSession.ts +++ b/packages/api/src/client/types/com/atproto/server/getSession.ts @@ -15,6 +15,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts b/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..ef2ed1ac47c --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,28 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts b/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..30d84002cf2 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,34 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams +} + +export interface Response { + success: boolean + headers: Headers + data: OutputSchema +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + } + return e +} diff --git a/packages/api/src/client/types/com/atproto/server/updateEmail.ts b/packages/api/src/client/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..92aef734e20 --- /dev/null +++ b/packages/api/src/client/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,55 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { Headers, XRPCError } from '@atproto/xrpc' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { isObj, hasProp } from '../../../../util' +import { lexicons } from '../../../../lexicons' +import { CID } from 'multiformats/cid' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface CallOptions { + headers?: Headers + qp?: QueryParams + encoding: 'application/json' +} + +export interface Response { + success: boolean + headers: Headers +} + +export class ExpiredTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class InvalidTokenError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export class TokenRequiredError extends XRPCError { + constructor(src: XRPCError) { + super(src.status, src.error, src.message, src.headers) + } +} + +export function toKnownErr(e: any) { + if (e instanceof XRPCError) { + if (e.error === 'ExpiredToken') return new ExpiredTokenError(e) + if (e.error === 'InvalidToken') return new InvalidTokenError(e) + if (e.error === 'TokenRequired') return new TokenRequiredError(e) + } + return e +} diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index 10d0bbd90fe..c0f78bfaafc 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -14,6 +14,7 @@ export interface AtpSessionData { handle: string did: string email?: string + emailConfirmed?: boolean } /** diff --git a/packages/api/tests/agent.test.ts b/packages/api/tests/agent.test.ts index c0fb67b9902..82d590ec85b 100644 --- a/packages/api/tests/agent.test.ts +++ b/packages/api/tests/agent.test.ts @@ -48,6 +48,7 @@ describe('agent', () => { expect(agent.session?.handle).toEqual(res.data.handle) expect(agent.session?.did).toEqual(res.data.did) expect(agent.session?.email).toEqual('user1@test.com') + expect(agent.session?.emailConfirmed).toEqual(false) const { data: sessionInfo } = await agent.api.com.atproto.server.getSession( {}, @@ -56,6 +57,7 @@ describe('agent', () => { did: res.data.did, handle: res.data.handle, email: 'user1@test.com', + emailConfirmed: false, }) expect(events.length).toEqual(1) @@ -93,6 +95,7 @@ describe('agent', () => { expect(agent2.session?.handle).toEqual(res1.data.handle) expect(agent2.session?.did).toEqual(res1.data.did) expect(agent2.session?.email).toEqual('user2@test.com') + expect(agent2.session?.emailConfirmed).toEqual(false) const { data: sessionInfo } = await agent2.api.com.atproto.server.getSession({}) @@ -100,6 +103,7 @@ describe('agent', () => { did: res1.data.did, handle: res1.data.handle, email, + emailConfirmed: false, }) expect(events.length).toEqual(2) @@ -142,6 +146,7 @@ describe('agent', () => { did: res1.data.did, handle: res1.data.handle, email: res1.data.email, + emailConfirmed: false, }) expect(events.length).toEqual(2) @@ -206,6 +211,8 @@ describe('agent', () => { expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt) expect(agent.session?.handle).toEqual(session1.handle) expect(agent.session?.did).toEqual(session1.did) + expect(agent.session?.email).toEqual(session1.email) + expect(agent.session?.emailConfirmed).toEqual(session1.emailConfirmed) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') @@ -283,6 +290,8 @@ describe('agent', () => { expect(agent.session?.refreshJwt).not.toEqual(session1.refreshJwt) expect(agent.session?.handle).toEqual(session1.handle) expect(agent.session?.did).toEqual(session1.did) + expect(agent.session?.email).toEqual(session1.email) + expect(agent.session?.emailConfirmed).toEqual(session1.emailConfirmed) expect(events.length).toEqual(2) expect(events[0]).toEqual('create') diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 3dd2b5104cc..ac6ca933fcd 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -39,6 +39,7 @@ import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -52,9 +53,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -557,6 +561,17 @@ export class ServerNS { this._server = server } + confirmEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerConfirmEmail.Handler>, + ComAtprotoServerConfirmEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.confirmEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + createAccount( cfg: ConfigOf< AV, @@ -700,6 +715,28 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + requestEmailConfirmation( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailConfirmation.Handler>, + ComAtprotoServerRequestEmailConfirmation.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailConfirmation' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + requestEmailUpdate( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailUpdate.Handler>, + ComAtprotoServerRequestEmailUpdate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailUpdate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + requestPasswordReset( cfg: ConfigOf< AV, @@ -732,6 +769,17 @@ export class ServerNS { const nsid = 'com.atproto.server.revokeAppPassword' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerUpdateEmail.Handler>, + ComAtprotoServerUpdateEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.updateEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class SyncNS { diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..ffaeeb8fe75 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,40 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountNotFound' | 'ExpiredToken' | 'InvalidToken' | 'InvalidEmail' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts index b836551f301..037900346a1 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/createSession.ts @@ -23,6 +23,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts index 388fb5eae9d..7f066a500bf 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/server/getSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..e4244870425 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..6876d44ca46 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..c88bd3021b2 --- /dev/null +++ b/packages/bsky/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'ExpiredToken' | 'InvalidToken' | 'TokenRequired' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/common-web/src/times.ts b/packages/common-web/src/times.ts index 09bb26efc97..90366277fdf 100644 --- a/packages/common-web/src/times.ts +++ b/packages/common-web/src/times.ts @@ -2,3 +2,7 @@ export const SECOND = 1000 export const MINUTE = SECOND * 60 export const HOUR = MINUTE * 60 export const DAY = HOUR * 24 + +export const lessThanAgoMs = (time: Date, range: number) => { + return Date.now() < time.getTime() + range +} diff --git a/packages/dev-env/src/bin.ts b/packages/dev-env/src/bin.ts index 56f0138b3b2..ae89c9abecb 100644 --- a/packages/dev-env/src/bin.ts +++ b/packages/dev-env/src/bin.ts @@ -1,5 +1,6 @@ import { generateMockSetup } from './mock' import { TestNetwork } from './network' +import { mockMailer } from './util' const run = async () => { console.log(` @@ -23,6 +24,7 @@ const run = async () => { }, plc: { port: 2582 }, }) + mockMailer(network.pds) await generateMockSetup(network) console.log( diff --git a/packages/dev-env/src/util.ts b/packages/dev-env/src/util.ts index 340d0414da1..0e62a733a8a 100644 --- a/packages/dev-env/src/util.ts +++ b/packages/dev-env/src/util.ts @@ -44,6 +44,16 @@ export const mockResolvers = (idResolver: IdResolver, pds: TestPds) => { } } +export const mockMailer = (pds: TestPds) => { + const mailer = pds.ctx.mailer + const _origSendMail = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await _origSendMail.call(mailer.transporter, opts) + console.log(`✉️ Email: ${JSON.stringify(result, null, 2)}`) + return result + } +} + const usedLockIds = new Set() export const uniqueLockId = () => { let lockId: number diff --git a/packages/pds/src/api/com/atproto/server/confirmEmail.ts b/packages/pds/src/api/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..f1452825771 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/confirmEmail.ts @@ -0,0 +1,34 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.confirmEmail({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth, input }) => { + const did = auth.credentials.did + const { token, email } = input.body + + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found', 'AccountNotFound') + } + + if (user.email !== email.toLowerCase()) { + throw new InvalidRequestError('invalid email', 'InvalidEmail') + } + await ctx.services + .account(ctx.db) + .assertValidToken(did, 'confirm_email', token) + + await ctx.db.transaction(async (dbTxn) => { + await ctx.services.account(dbTxn).deleteEmailToken(did, 'confirm_email') + await dbTxn.db + .updateTable('user_account') + .set({ emailConfirmedAt: new Date().toISOString() }) + .where('did', '=', did) + .execute() + }) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/createSession.ts b/packages/pds/src/api/com/atproto/server/createSession.ts index 3769cda08a6..6d8d57e471e 100644 --- a/packages/pds/src/api/com/atproto/server/createSession.ts +++ b/packages/pds/src/api/com/atproto/server/createSession.ts @@ -68,6 +68,7 @@ export default function (server: Server, ctx: AppContext) { did: user.did, handle: user.handle, email: user.email, + emailConfirmed: !!user.emailConfirmedAt, accessJwt: access.jwt, refreshJwt: refresh.jwt, }, diff --git a/packages/pds/src/api/com/atproto/server/getSession.ts b/packages/pds/src/api/com/atproto/server/getSession.ts index bfcccc97657..eb33180e6ff 100644 --- a/packages/pds/src/api/com/atproto/server/getSession.ts +++ b/packages/pds/src/api/com/atproto/server/getSession.ts @@ -15,7 +15,12 @@ export default function (server: Server, ctx: AppContext) { } return { encoding: 'application/json', - body: { handle: user.handle, did: user.did, email: user.email }, + body: { + handle: user.handle, + did: user.did, + email: user.email, + emailConfirmed: !!user.emailConfirmedAt, + }, } }, }) diff --git a/packages/pds/src/api/com/atproto/server/index.ts b/packages/pds/src/api/com/atproto/server/index.ts index 9a49216f71c..210d0f45461 100644 --- a/packages/pds/src/api/com/atproto/server/index.ts +++ b/packages/pds/src/api/com/atproto/server/index.ts @@ -14,6 +14,12 @@ import deleteAccount from './deleteAccount' import requestPasswordReset from './requestPasswordReset' import resetPassword from './resetPassword' +import requestEmailConfirmation from './requestEmailConfirmation' +import confirmEmail from './confirmEmail' + +import requestEmailUpdate from './requestEmailUpdate' +import updateEmail from './updateEmail' + import createSession from './createSession' import deleteSession from './deleteSession' import getSession from './getSession' @@ -33,6 +39,10 @@ export default function (server: Server, ctx: AppContext) { deleteAccount(server, ctx) requestPasswordReset(server, ctx) resetPassword(server, ctx) + requestEmailConfirmation(server, ctx) + confirmEmail(server, ctx) + requestEmailUpdate(server, ctx) + updateEmail(server, ctx) createSession(server, ctx) deleteSession(server, ctx) getSession(server, ctx) diff --git a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts index 61870c8c3ca..a448d97c02e 100644 --- a/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts +++ b/packages/pds/src/api/com/atproto/server/requestAccountDelete.ts @@ -8,12 +8,12 @@ export default function (server: Server, ctx: AppContext) { auth: ctx.accessVerifierCheckTakedown, handler: async ({ auth }) => { const did = auth.credentials.did - const token = getRandomToken().toUpperCase() - const requestedAt = new Date().toISOString() const user = await ctx.services.account(ctx.db).getAccount(did) if (!user) { throw new InvalidRequestError('user not found') } + const token = getRandomToken().toUpperCase() + const requestedAt = new Date().toISOString() await ctx.db.db .insertInto('delete_account_token') .values({ did, token, requestedAt }) diff --git a/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..aa7b632569e --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,20 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.requestEmailConfirmation({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth }) => { + const did = auth.credentials.did + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + const token = await ctx.services + .account(ctx.db) + .createEmailToken(did, 'confirm_email') + await ctx.mailer.sendConfirmEmail({ token }, { to: user.email }) + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..bcc65303f41 --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,31 @@ +import { InvalidRequestError } from '@atproto/xrpc-server' +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.requestEmailUpdate({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth }) => { + const did = auth.credentials.did + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + + const tokenRequired = !!user.emailConfirmedAt + if (tokenRequired) { + const token = await ctx.services + .account(ctx.db) + .createEmailToken(did, 'update_email') + await ctx.mailer.sendUpdateEmail({ token }, { to: user.email }) + } + + return { + encoding: 'application/json', + body: { + tokenRequired, + }, + } + }, + }) +} diff --git a/packages/pds/src/api/com/atproto/server/updateEmail.ts b/packages/pds/src/api/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..e5b013d8eba --- /dev/null +++ b/packages/pds/src/api/com/atproto/server/updateEmail.ts @@ -0,0 +1,40 @@ +import { Server } from '../../../../lexicon' +import AppContext from '../../../../context' +import { InvalidRequestError } from '@atproto/xrpc-server' + +export default function (server: Server, ctx: AppContext) { + server.com.atproto.server.updateEmail({ + auth: ctx.accessVerifierCheckTakedown, + handler: async ({ auth, input }) => { + const did = auth.credentials.did + const { token, email } = input.body + const user = await ctx.services.account(ctx.db).getAccount(did) + if (!user) { + throw new InvalidRequestError('user not found') + } + // require valid token + // @TODO re-enable updating non-verified emails + // if (user.emailConfirmedAt) { + if (!token) { + throw new InvalidRequestError( + 'confirmation token required', + 'TokenRequired', + ) + } + await ctx.services + .account(ctx.db) + .assertValidToken(did, 'update_email', token) + + await ctx.db.transaction(async (dbTxn) => { + const accntSrvce = ctx.services.account(dbTxn) + + if (token) { + await accntSrvce.deleteEmailToken(did, 'update_email') + } + if (user.email !== email) { + await accntSrvce.updateEmail(did, email) + } + }) + }, + }) +} diff --git a/packages/pds/src/db/database-schema.ts b/packages/pds/src/db/database-schema.ts index eda67a4e6fb..ee92742edff 100644 --- a/packages/pds/src/db/database-schema.ts +++ b/packages/pds/src/db/database-schema.ts @@ -15,6 +15,7 @@ import * as notification from './tables/user-notification' import * as blob from './tables/blob' import * as repoBlob from './tables/repo-blob' import * as deleteAccountToken from './tables/delete-account-token' +import * as emailToken from './tables/email-token' import * as moderation from './tables/moderation' import * as mute from './tables/mute' import * as listMute from './tables/list-mute' @@ -40,6 +41,7 @@ export type DatabaseSchemaType = runtimeFlag.PartialDB & blob.PartialDB & repoBlob.PartialDB & deleteAccountToken.PartialDB & + emailToken.PartialDB & moderation.PartialDB & mute.PartialDB & listMute.PartialDB & diff --git a/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts new file mode 100644 index 00000000000..ce8e6574731 --- /dev/null +++ b/packages/pds/src/db/migrations/20230926T195532354Z-email-tokens.ts @@ -0,0 +1,28 @@ +import { Kysely } from 'kysely' +import { Dialect } from '..' + +export async function up(db: Kysely, dialect: Dialect): Promise { + const timestamp = dialect === 'sqlite' ? 'datetime' : 'timestamptz' + await db.schema + .createTable('email_token') + .addColumn('purpose', 'varchar', (col) => col.notNull()) + .addColumn('did', 'varchar', (col) => col.notNull()) + .addColumn('token', 'varchar', (col) => col.notNull()) + .addColumn('requestedAt', timestamp, (col) => col.notNull()) + .addPrimaryKeyConstraint('email_token_pkey', ['purpose', 'did']) + .addUniqueConstraint('email_token_token_unique', ['purpose', 'token']) + .execute() + + await db.schema + .alterTable('user_account') + .addColumn('emailConfirmedAt', 'varchar') + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('email_token').execute() + await db.schema + .alterTable('user_account') + .dropColumn('emailConfirmedAt') + .execute() +} diff --git a/packages/pds/src/db/migrations/index.ts b/packages/pds/src/db/migrations/index.ts index fde3ddd2398..3636d304e46 100644 --- a/packages/pds/src/db/migrations/index.ts +++ b/packages/pds/src/db/migrations/index.ts @@ -66,3 +66,4 @@ export * as _20230824T182048120Z from './20230824T182048120Z-remove-post-hierarc export * as _20230825T142507884Z from './20230825T142507884Z-blob-tempkey-idx' export * as _20230828T153013575Z from './20230828T153013575Z-repo-history-rewrite' export * as _20230922T033938477Z from './20230922T033938477Z-remove-appview' +export * as _20230926T195532354Z from './20230926T195532354Z-email-tokens' diff --git a/packages/pds/src/db/tables/email-token.ts b/packages/pds/src/db/tables/email-token.ts new file mode 100644 index 00000000000..b8f42bde198 --- /dev/null +++ b/packages/pds/src/db/tables/email-token.ts @@ -0,0 +1,16 @@ +export type EmailTokenPurpose = + | 'confirm_email' + | 'update_email' + | 'reset_password' + | 'delete_account' + +export interface EmailToken { + purpose: EmailTokenPurpose + did: string + token: string + requestedAt: Date +} + +export const tableName = 'email_token' + +export type PartialDB = { [tableName]: EmailToken } diff --git a/packages/pds/src/db/tables/user-account.ts b/packages/pds/src/db/tables/user-account.ts index 665521efc08..ef9fdbecb3c 100644 --- a/packages/pds/src/db/tables/user-account.ts +++ b/packages/pds/src/db/tables/user-account.ts @@ -5,6 +5,7 @@ export interface UserAccount { email: string passwordScrypt: string createdAt: string + emailConfirmedAt: string | null passwordResetToken: string | null passwordResetGrantedAt: string | null invitesDisabled: Generated<0 | 1> diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 3dd2b5104cc..ac6ca933fcd 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -39,6 +39,7 @@ import * as ComAtprotoRepoGetRecord from './types/com/atproto/repo/getRecord' import * as ComAtprotoRepoListRecords from './types/com/atproto/repo/listRecords' import * as ComAtprotoRepoPutRecord from './types/com/atproto/repo/putRecord' import * as ComAtprotoRepoUploadBlob from './types/com/atproto/repo/uploadBlob' +import * as ComAtprotoServerConfirmEmail from './types/com/atproto/server/confirmEmail' import * as ComAtprotoServerCreateAccount from './types/com/atproto/server/createAccount' import * as ComAtprotoServerCreateAppPassword from './types/com/atproto/server/createAppPassword' import * as ComAtprotoServerCreateInviteCode from './types/com/atproto/server/createInviteCode' @@ -52,9 +53,12 @@ import * as ComAtprotoServerGetSession from './types/com/atproto/server/getSessi import * as ComAtprotoServerListAppPasswords from './types/com/atproto/server/listAppPasswords' import * as ComAtprotoServerRefreshSession from './types/com/atproto/server/refreshSession' import * as ComAtprotoServerRequestAccountDelete from './types/com/atproto/server/requestAccountDelete' +import * as ComAtprotoServerRequestEmailConfirmation from './types/com/atproto/server/requestEmailConfirmation' +import * as ComAtprotoServerRequestEmailUpdate from './types/com/atproto/server/requestEmailUpdate' import * as ComAtprotoServerRequestPasswordReset from './types/com/atproto/server/requestPasswordReset' import * as ComAtprotoServerResetPassword from './types/com/atproto/server/resetPassword' import * as ComAtprotoServerRevokeAppPassword from './types/com/atproto/server/revokeAppPassword' +import * as ComAtprotoServerUpdateEmail from './types/com/atproto/server/updateEmail' import * as ComAtprotoSyncGetBlob from './types/com/atproto/sync/getBlob' import * as ComAtprotoSyncGetBlocks from './types/com/atproto/sync/getBlocks' import * as ComAtprotoSyncGetCheckout from './types/com/atproto/sync/getCheckout' @@ -557,6 +561,17 @@ export class ServerNS { this._server = server } + confirmEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerConfirmEmail.Handler>, + ComAtprotoServerConfirmEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.confirmEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + createAccount( cfg: ConfigOf< AV, @@ -700,6 +715,28 @@ export class ServerNS { return this._server.xrpc.method(nsid, cfg) } + requestEmailConfirmation( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailConfirmation.Handler>, + ComAtprotoServerRequestEmailConfirmation.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailConfirmation' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + + requestEmailUpdate( + cfg: ConfigOf< + AV, + ComAtprotoServerRequestEmailUpdate.Handler>, + ComAtprotoServerRequestEmailUpdate.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.requestEmailUpdate' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } + requestPasswordReset( cfg: ConfigOf< AV, @@ -732,6 +769,17 @@ export class ServerNS { const nsid = 'com.atproto.server.revokeAppPassword' // @ts-ignore return this._server.xrpc.method(nsid, cfg) } + + updateEmail( + cfg: ConfigOf< + AV, + ComAtprotoServerUpdateEmail.Handler>, + ComAtprotoServerUpdateEmail.HandlerReqCtx> + >, + ) { + const nsid = 'com.atproto.server.updateEmail' // @ts-ignore + return this._server.xrpc.method(nsid, cfg) + } } export class SyncNS { diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 177b63808f4..77b4939d758 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -2242,6 +2242,46 @@ export const schemaDict = { }, }, }, + ComAtprotoServerConfirmEmail: { + lexicon: 1, + id: 'com.atproto.server.confirmEmail', + defs: { + main: { + type: 'procedure', + description: + 'Confirm an email using a token from com.atproto.server.requestEmailConfirmation.', + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email', 'token'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + }, + }, + }, + }, + errors: [ + { + name: 'AccountNotFound', + }, + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'InvalidEmail', + }, + ], + }, + }, + }, ComAtprotoServerCreateAccount: { lexicon: 1, id: 'com.atproto.server.createAccount', @@ -2526,6 +2566,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2756,6 +2799,9 @@ export const schemaDict = { email: { type: 'string', }, + emailConfirmed: { + type: 'boolean', + }, }, }, }, @@ -2854,6 +2900,39 @@ export const schemaDict = { }, }, }, + ComAtprotoServerRequestEmailConfirmation: { + lexicon: 1, + id: 'com.atproto.server.requestEmailConfirmation', + defs: { + main: { + type: 'procedure', + description: + 'Request an email with a code to confirm ownership of email', + }, + }, + }, + ComAtprotoServerRequestEmailUpdate: { + lexicon: 1, + id: 'com.atproto.server.requestEmailUpdate', + defs: { + main: { + type: 'procedure', + description: 'Request a token in order to update email.', + output: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['tokenRequired'], + properties: { + tokenRequired: { + type: 'boolean', + }, + }, + }, + }, + }, + }, + }, ComAtprotoServerRequestPasswordReset: { lexicon: 1, id: 'com.atproto.server.requestPasswordReset', @@ -2931,6 +3010,44 @@ export const schemaDict = { }, }, }, + ComAtprotoServerUpdateEmail: { + lexicon: 1, + id: 'com.atproto.server.updateEmail', + defs: { + main: { + type: 'procedure', + description: "Update an account's email.", + input: { + encoding: 'application/json', + schema: { + type: 'object', + required: ['email'], + properties: { + email: { + type: 'string', + }, + token: { + type: 'string', + description: + "Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed.", + }, + }, + }, + }, + errors: [ + { + name: 'ExpiredToken', + }, + { + name: 'InvalidToken', + }, + { + name: 'TokenRequired', + }, + ], + }, + }, + }, ComAtprotoSyncGetBlob: { lexicon: 1, id: 'com.atproto.sync.getBlob', @@ -7240,6 +7357,7 @@ export const ids = { ComAtprotoRepoPutRecord: 'com.atproto.repo.putRecord', ComAtprotoRepoStrongRef: 'com.atproto.repo.strongRef', ComAtprotoRepoUploadBlob: 'com.atproto.repo.uploadBlob', + ComAtprotoServerConfirmEmail: 'com.atproto.server.confirmEmail', ComAtprotoServerCreateAccount: 'com.atproto.server.createAccount', ComAtprotoServerCreateAppPassword: 'com.atproto.server.createAppPassword', ComAtprotoServerCreateInviteCode: 'com.atproto.server.createInviteCode', @@ -7256,10 +7374,14 @@ export const ids = { ComAtprotoServerRefreshSession: 'com.atproto.server.refreshSession', ComAtprotoServerRequestAccountDelete: 'com.atproto.server.requestAccountDelete', + ComAtprotoServerRequestEmailConfirmation: + 'com.atproto.server.requestEmailConfirmation', + ComAtprotoServerRequestEmailUpdate: 'com.atproto.server.requestEmailUpdate', ComAtprotoServerRequestPasswordReset: 'com.atproto.server.requestPasswordReset', ComAtprotoServerResetPassword: 'com.atproto.server.resetPassword', ComAtprotoServerRevokeAppPassword: 'com.atproto.server.revokeAppPassword', + ComAtprotoServerUpdateEmail: 'com.atproto.server.updateEmail', ComAtprotoSyncGetBlob: 'com.atproto.sync.getBlob', ComAtprotoSyncGetBlocks: 'com.atproto.sync.getBlocks', ComAtprotoSyncGetCheckout: 'com.atproto.sync.getCheckout', diff --git a/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts b/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts new file mode 100644 index 00000000000..ffaeeb8fe75 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/confirmEmail.ts @@ -0,0 +1,40 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + token: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'AccountNotFound' | 'ExpiredToken' | 'InvalidToken' | 'InvalidEmail' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts index b836551f301..037900346a1 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/createSession.ts @@ -23,6 +23,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts index 388fb5eae9d..7f066a500bf 100644 --- a/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts +++ b/packages/pds/src/lexicon/types/com/atproto/server/getSession.ts @@ -16,6 +16,7 @@ export interface OutputSchema { handle: string did: string email?: string + emailConfirmed?: boolean [k: string]: unknown } diff --git a/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts new file mode 100644 index 00000000000..e4244870425 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailConfirmation.ts @@ -0,0 +1,31 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined +export type HandlerInput = undefined + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts new file mode 100644 index 00000000000..6876d44ca46 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/requestEmailUpdate.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export type InputSchema = undefined + +export interface OutputSchema { + tokenRequired: boolean + [k: string]: unknown +} + +export type HandlerInput = undefined + +export interface HandlerSuccess { + encoding: 'application/json' + body: OutputSchema + headers?: { [key: string]: string } +} + +export interface HandlerError { + status: number + message?: string +} + +export type HandlerOutput = HandlerError | HandlerSuccess +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts new file mode 100644 index 00000000000..c88bd3021b2 --- /dev/null +++ b/packages/pds/src/lexicon/types/com/atproto/server/updateEmail.ts @@ -0,0 +1,41 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import express from 'express' +import { ValidationResult, BlobRef } from '@atproto/lexicon' +import { lexicons } from '../../../../lexicons' +import { isObj, hasProp } from '../../../../util' +import { CID } from 'multiformats/cid' +import { HandlerAuth } from '@atproto/xrpc-server' + +export interface QueryParams {} + +export interface InputSchema { + email: string + /** Requires a token from com.atproto.sever.requestEmailUpdate if the account's email has been confirmed. */ + token?: string + [k: string]: unknown +} + +export interface HandlerInput { + encoding: 'application/json' + body: InputSchema +} + +export interface HandlerError { + status: number + message?: string + error?: 'ExpiredToken' | 'InvalidToken' | 'TokenRequired' +} + +export type HandlerOutput = HandlerError | void +export type HandlerReqCtx = { + auth: HA + params: QueryParams + input: HandlerInput + req: express.Request + res: express.Response +} +export type Handler = ( + ctx: HandlerReqCtx, +) => Promise | HandlerOutput diff --git a/packages/pds/src/mailer/index.ts b/packages/pds/src/mailer/index.ts index 99059f6f02e..6c77fc8964c 100644 --- a/packages/pds/src/mailer/index.ts +++ b/packages/pds/src/mailer/index.ts @@ -24,6 +24,8 @@ export class ServerMailer { this.templates = { resetPassword: this.compile('reset-password'), deleteAccount: this.compile('delete-account'), + confirmEmail: this.compile('confirm-email'), + updateEmail: this.compile('update-email'), } } @@ -51,6 +53,20 @@ export class ServerMailer { }) } + async sendConfirmEmail(params: { token: string }, mailOpts: Mail.Options) { + return this.sendTemplate('confirmEmail', params, { + subject: 'Email Confirmation', + ...mailOpts, + }) + } + + async sendUpdateEmail(params: { token: string }, mailOpts: Mail.Options) { + return this.sendTemplate('updateEmail', params, { + subject: 'Email Update Requested', + ...mailOpts, + }) + } + private async sendTemplate(templateName, params, mailOpts: Mail.Options) { const html = this.templates[templateName]({ ...params, diff --git a/packages/pds/src/mailer/templates/confirm-email.hbs b/packages/pds/src/mailer/templates/confirm-email.hbs new file mode 100644 index 00000000000..ee062a40e07 --- /dev/null +++ b/packages/pds/src/mailer/templates/confirm-email.hbs @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/mailer/templates/update-email.hbs b/packages/pds/src/mailer/templates/update-email.hbs new file mode 100644 index 00000000000..f49947125be --- /dev/null +++ b/packages/pds/src/mailer/templates/update-email.hbs @@ -0,0 +1,383 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts index 0cb293ff26c..33978be9b8b 100644 --- a/packages/pds/src/services/account/index.ts +++ b/packages/pds/src/services/account/index.ts @@ -1,4 +1,6 @@ import { sql } from 'kysely' +import { InvalidRequestError } from '@atproto/xrpc-server' +import { MINUTE, lessThanAgoMs } from '@atproto/common' import { dbLogger as log } from '../../logger' import Database from '../../db' import * as scrypt from '../../db/scrypt' @@ -11,7 +13,8 @@ import { paginate, TimeCidKeyset } from '../../db/pagination' import * as sequencer from '../../sequencer' import { AppPassword } from '../../lexicon/types/com/atproto/server/createAppPassword' import { randomStr } from '@atproto/crypto' -import { InvalidRequestError } from '@atproto/xrpc-server' +import { EmailTokenPurpose } from '../../db/tables/email-token' +import { getRandomToken } from '../../api/com/atproto/server/util' export class AccountService { constructor(public db: Database) {} @@ -197,7 +200,7 @@ export class AccountService { async updateEmail(did: string, email: string) { await this.db.db .updateTable('user_account') - .set({ email: email.toLowerCase() }) + .set({ email: email.toLowerCase(), emailConfirmedAt: null }) .where('did', '=', did) .executeTakeFirst() } @@ -529,6 +532,49 @@ export class AccountService { }, {} as Record) } + async createEmailToken( + did: string, + purpose: EmailTokenPurpose, + ): Promise { + const token = getRandomToken().toUpperCase() + await this.db.db + .insertInto('email_token') + .values({ purpose, did, token, requestedAt: new Date() }) + .onConflict((oc) => oc.columns(['purpose', 'did']).doUpdateSet({ token })) + .execute() + return token + } + + async deleteEmailToken(did: string, purpose: EmailTokenPurpose) { + await this.db.db + .deleteFrom('email_token') + .where('did', '=', did) + .where('purpose', '=', purpose) + .executeTakeFirst() + } + + async assertValidToken( + did: string, + purpose: EmailTokenPurpose, + token: string, + expirationLen = 15 * MINUTE, + ) { + const res = await this.db.db + .selectFrom('email_token') + .selectAll() + .where('purpose', '=', purpose) + .where('did', '=', did) + .where('token', '=', token) + .executeTakeFirst() + if (!res) { + throw new InvalidRequestError('Token is invalid', 'InvalidToken') + } + const expired = !lessThanAgoMs(res.requestedAt, expirationLen) + if (expired) { + throw new InvalidRequestError('Token is expired', 'ExpiredToken') + } + } + async getLastSeenNotifs(did: string): Promise { const res = await this.db.db .selectFrom('user_state') diff --git a/packages/pds/tests/account-deletion.test.ts b/packages/pds/tests/account-deletion.test.ts index d6b4aa101ce..6f7573bc423 100644 --- a/packages/pds/tests/account-deletion.test.ts +++ b/packages/pds/tests/account-deletion.test.ts @@ -68,6 +68,7 @@ describe('account deletion', () => { const getMailFrom = async (promise): Promise => { const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + console.log(result) return result[0][0] } @@ -91,6 +92,7 @@ describe('account deletion', () => { return expect(token).toBeDefined() } }) + return it('fails account deletion with a bad token', async () => { const attempt = agent.api.com.atproto.server.deleteAccount({ diff --git a/packages/pds/tests/auth.test.ts b/packages/pds/tests/auth.test.ts index c1883d5a7f7..ae78f3d5619 100644 --- a/packages/pds/tests/auth.test.ts +++ b/packages/pds/tests/auth.test.ts @@ -68,6 +68,7 @@ describe('auth', () => { did: account.did, handle: account.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(account.refreshJwt) @@ -96,6 +97,7 @@ describe('auth', () => { did: session.did, handle: session.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(session.refreshJwt) @@ -139,6 +141,7 @@ describe('auth', () => { did: session.did, handle: session.handle, email, + emailConfirmed: false, }) // Valid refresh token const nextSession = await refreshSession(session.refreshJwt) diff --git a/packages/pds/tests/email-confirmation.test.ts b/packages/pds/tests/email-confirmation.test.ts new file mode 100644 index 00000000000..48a0c375510 --- /dev/null +++ b/packages/pds/tests/email-confirmation.test.ts @@ -0,0 +1,209 @@ +import { once, EventEmitter } from 'events' +import Mail from 'nodemailer/lib/mailer' +import AtpAgent from '@atproto/api' +import { SeedClient } from './seeds/client' +import userSeed from './seeds/users' +import { ServerMailer } from '../src/mailer' +import { TestNetworkNoAppView } from '@atproto/dev-env' +import { + ComAtprotoServerConfirmEmail, + ComAtprotoServerUpdateEmail, +} from '@atproto/api' + +describe('email confirmation', () => { + let network: TestNetworkNoAppView + let agent: AtpAgent + let sc: SeedClient + + let mailer: ServerMailer + const mailCatcher = new EventEmitter() + let _origSendMail + + let alice + + beforeAll(async () => { + network = await TestNetworkNoAppView.create({ + dbPostgresSchema: 'email_confirmation', + }) + mailer = network.pds.ctx.mailer + agent = network.pds.getClient() + sc = new SeedClient(agent) + await userSeed(sc) + alice = sc.accounts[sc.dids.alice] + + // Catch emails for use in tests + _origSendMail = mailer.transporter.sendMail + mailer.transporter.sendMail = async (opts) => { + const result = await _origSendMail.call(mailer.transporter, opts) + mailCatcher.emit('mail', opts) + return result + } + }) + + afterAll(async () => { + mailer.transporter.sendMail = _origSendMail + await network.close() + }) + + const getMailFrom = async (promise): Promise => { + const result = await Promise.all([once(mailCatcher, 'mail'), promise]) + return result[0][0] + } + + const getTokenFromMail = (mail: Mail.Options) => + mail.html?.toString().match(/>([a-z0-9]{5}-[a-z0-9]{5}) { + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.emailConfirmed).toEqual(false) + }) + + it('disallows email update without token when unverified', async () => { + const res = await agent.api.com.atproto.server.requestEmailUpdate( + undefined, + { headers: sc.getHeaders(alice.did) }, + ) + expect(res.data.tokenRequired).toBe(false) + + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice@example.com', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow() + + // await agent.api.com.atproto.server.updateEmail( + // { + // email: 'new-alice@example.com', + // }, + // { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + // ) + // const session = await agent.api.com.atproto.server.getSession( + // {}, + // { headers: sc.getHeaders(alice.did) }, + // ) + // expect(session.data.email).toEqual('new-alice@example.com') + // expect(session.data.emailConfirmed).toEqual(false) + // alice.email = session.data.email + }) + + let confirmToken + + it('requests email confirmation', async () => { + const mail = await getMailFrom( + agent.api.com.atproto.server.requestEmailConfirmation(undefined, { + headers: sc.getHeaders(alice.did), + }), + ) + expect(mail.to).toEqual(alice.email) + expect(mail.html).toContain('Confirm your Bluesky email') + confirmToken = getTokenFromMail(mail) + expect(confirmToken).toBeDefined() + }) + + it('fails email confirmation with a bad token', async () => { + const attempt = agent.api.com.atproto.server.confirmEmail( + { + email: alice.email, + token: '123456', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerConfirmEmail.InvalidTokenError, + ) + }) + + it('fails email confirmation with a bad token', async () => { + const attempt = agent.api.com.atproto.server.confirmEmail( + { + email: 'fake-alice@example.com', + token: confirmToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerConfirmEmail.InvalidEmailError, + ) + }) + + it('confirms email', async () => { + await agent.api.com.atproto.server.confirmEmail( + { + email: alice.email, + token: confirmToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.emailConfirmed).toBe(true) + }) + + it('disallows email update without token when verified', async () => { + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerUpdateEmail.TokenRequiredError, + ) + }) + + let updateToken + + it('requests email update', async () => { + const reqUpdate = async () => { + const res = await agent.api.com.atproto.server.requestEmailUpdate( + undefined, + { + headers: sc.getHeaders(alice.did), + }, + ) + expect(res.data.tokenRequired).toBe(true) + } + const mail = await getMailFrom(reqUpdate()) + expect(mail.to).toEqual(alice.email) + expect(mail.html).toContain('Update your Bluesky email') + updateToken = getTokenFromMail(mail) + expect(updateToken).toBeDefined() + }) + + it('fails email update with a bad token', async () => { + const attempt = agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + token: '123456', + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + await expect(attempt).rejects.toThrow( + ComAtprotoServerUpdateEmail.InvalidTokenError, + ) + }) + + it('updates email', async () => { + await agent.api.com.atproto.server.updateEmail( + { + email: 'new-alice-2@example.com', + token: updateToken, + }, + { headers: sc.getHeaders(alice.did), encoding: 'application/json' }, + ) + + const session = await agent.api.com.atproto.server.getSession( + {}, + { headers: sc.getHeaders(alice.did) }, + ) + expect(session.data.email).toBe('new-alice-2@example.com') + expect(session.data.emailConfirmed).toBe(false) + }) +})