diff --git a/packages/fxa-graphql-api/src/lib/datasources/authServer.ts b/packages/fxa-graphql-api/src/lib/datasources/authServer.ts index e3aee7a768..8aae13331d 100644 --- a/packages/fxa-graphql-api/src/lib/datasources/authServer.ts +++ b/packages/fxa-graphql-api/src/lib/datasources/authServer.ts @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { DataSource, DataSourceConfig } from 'apollo-datasource'; -import { Client } from 'fxa-js-client'; +import { Client, MetricContext } from 'fxa-js-client'; import { Container } from 'typedi'; import { fxAccountClientToken } from '../constants'; @@ -49,6 +49,16 @@ export class AuthServerSource extends DataSource { return this.authClient.attachedClientDestroy(this.token, clientInfo); } + public createTotpToken( + options: MetricContext + ): ReturnType { + return this.authClient.createTotpToken(this.token, options); + } + + public destroyTotpToken(): Promise { + return this.authClient.deleteTotpToken(this.token); + } + public totp(): Promise { return this.authClient.checkTotpTokenExists(this.token); } @@ -73,4 +83,12 @@ export class AuthServerSource extends DataSource { public recoveryEmailSecondaryResendCode(email: string): Promise { return this.authClient.recoveryEmailSecondaryResendCode(this.token, email); } + + public replaceRecoveryCodes() { + return this.authClient.replaceRecoveryCodes(this.token); + } + + public verifyTotp(code: string, options?: { service: string }) { + return this.authClient.verifyTotpCode(this.token, code, options); + } } diff --git a/packages/fxa-graphql-api/src/lib/resolvers/account-resolver.ts b/packages/fxa-graphql-api/src/lib/resolvers/account-resolver.ts index 0d70d17b54..1b452bbc0a 100644 --- a/packages/fxa-graphql-api/src/lib/resolvers/account-resolver.ts +++ b/packages/fxa-graphql-api/src/lib/resolvers/account-resolver.ts @@ -24,19 +24,96 @@ import { CatchGatewayError } from '../error'; import { Context } from '../server'; import { Account as AccountType } from './types/account'; import { + AttachedClientDisconnectInput, + ChangeRecoveryCodesInput, + CreateTotpInput, + DeleteTotpInput, EmailInput, UpdateAvatarInput, UpdateDisplayNameInput, + VerifyTotpInput, } from './types/input'; import { BasicPayload, + ChangeRecoveryCodesPayload, + CreateTotpPayload, UpdateAvatarPayload, UpdateDisplayNamePayload, + VerifyTotpPayload, } from './types/payload'; -import { AttachedClientDisconnectInput } from './types/input/attached-client-disconnect'; @Resolver((of) => AccountType) export class AccountResolver { + @Mutation((returns) => CreateTotpPayload, { + description: + 'Create a new randomly generated TOTP token for a user if they do not currently have one.', + }) + @CatchGatewayError + public async createTotp( + @Ctx() context: Context, + @Arg('input', (type) => CreateTotpInput) + input: CreateTotpInput + ): Promise { + const result = await context.dataSources.authAPI.createTotpToken( + input.metricsContext + ); + return { + clientMutationId: input.clientMutationId, + ...result, + }; + } + + @Mutation((returns) => VerifyTotpPayload, { + description: + 'Verifies the current session if the passed TOTP code is valid.', + }) + @CatchGatewayError + public async verifyTotp( + @Ctx() context: Context, + @Arg('input', (type) => VerifyTotpInput) + input: VerifyTotpInput + ): Promise { + const result = await context.dataSources.authAPI.verifyTotp( + input.code, + input.service ? { service: input.service } : undefined + ); + return { + clientMutationId: input.clientMutationId, + ...result, + }; + } + + @Mutation((returns) => BasicPayload, { + description: 'Deletes the current TOTP token for the user.', + }) + @CatchGatewayError + public async deleteTotp( + @Ctx() context: Context, + @Arg('input', (type) => DeleteTotpInput) + input: DeleteTotpInput + ): Promise { + await context.dataSources.authAPI.destroyTotpToken(); + return { + clientMutationId: input.clientMutationId, + }; + } + + @Mutation((returns) => ChangeRecoveryCodesPayload, { + description: 'Return new recovery codes while removing old ones.', + }) + @CatchGatewayError + public async changeRecoveryCodes( + @Ctx() context: Context, + @Arg('input', (type) => ChangeRecoveryCodesInput) + input: ChangeRecoveryCodesInput + ): Promise { + const result = await context.dataSources.authAPI.replaceRecoveryCodes(); + return { + clientMutationId: input.clientMutationId, + recoveryCodes: result.recoveryCodes, + }; + } + @Mutation((returns) => UpdateDisplayNamePayload, { description: 'Update the display name.', }) diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/change-recovery-codes.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/change-recovery-codes.ts new file mode 100644 index 0000000000..16daa08e4a --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/change-recovery-codes.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, InputType } from 'type-graphql'; + +@InputType() +export class ChangeRecoveryCodesInput { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/create-totp.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/create-totp.ts new file mode 100644 index 0000000000..1f81690c1b --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/create-totp.ts @@ -0,0 +1,18 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, InputType } from 'type-graphql'; + +import { MetricsContext } from './metrics-context'; + +@InputType() +export class CreateTotpInput { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; + + @Field((type) => MetricsContext, { nullable: true }) + public metricsContext!: MetricsContext; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/delete-totp.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/delete-totp.ts new file mode 100644 index 0000000000..631ddf78cb --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/delete-totp.ts @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, InputType } from 'type-graphql'; + +@InputType() +export class DeleteTotpInput { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/index.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/index.ts index 94642c53d7..02e75f15c0 100644 --- a/packages/fxa-graphql-api/src/lib/resolvers/types/input/index.ts +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/index.ts @@ -4,3 +4,8 @@ export { EmailInput } from './email'; export { UpdateAvatarInput } from './update-avatar'; export { UpdateDisplayNameInput } from './update-display-name'; +export { CreateTotpInput } from './create-totp'; +export { AttachedClientDisconnectInput } from './attached-client-disconnect'; +export { ChangeRecoveryCodesInput } from './change-recovery-codes'; +export { DeleteTotpInput } from './delete-totp'; +export { VerifyTotpInput } from './verify-totp'; diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/metrics-context.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/metrics-context.ts new file mode 100644 index 0000000000..1bf2d40517 --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/metrics-context.ts @@ -0,0 +1,50 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, InputType } from 'type-graphql'; + +@InputType({ description: 'Metrics context.' }) +export class MetricsContext { + @Field({ + description: + "The id of the client's device record, if it has registered one.", + nullable: true, + }) + public deviceId?: string; + + @Field({ nullable: true }) + public entrypoint?: string; + + @Field({ nullable: true }) + public entrypointExperiment?: string; + + @Field({ nullable: true }) + public entrypointVariation?: string; + + @Field({ nullable: true }) + public flowId?: string; + + @Field({ nullable: true }) + public flowBeginTime?: number; + + @Field({ nullable: true }) + public productId?: string; + + @Field({ nullable: true }) + public planId?: string; + + @Field({ nullable: true }) + public utmCampaign?: number; + + @Field({ nullable: true }) + public utmContent?: number; + + @Field({ nullable: true }) + public utmMedium?: number; + + @Field({ nullable: true }) + public utmSource?: number; + + @Field({ nullable: true }) + public utmTerm?: number; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/input/verify-totp.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/input/verify-totp.ts new file mode 100644 index 0000000000..5212aff0f8 --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/input/verify-totp.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, InputType } from 'type-graphql'; + +@InputType() +export class VerifyTotpInput { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; + + @Field({ description: 'The TOTP code to check' }) + public code!: string; + + @Field({ nullable: true }) + public service?: string; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/payload/change-recovery-codes.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/change-recovery-codes.ts new file mode 100644 index 0000000000..8984ca9617 --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/change-recovery-codes.ts @@ -0,0 +1,16 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, ObjectType } from 'type-graphql'; + +@ObjectType() +export class ChangeRecoveryCodesPayload { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; + + @Field((returns) => [String]) + public recoveryCodes!: string[]; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/payload/create-totp.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/create-totp.ts new file mode 100644 index 0000000000..13b3007a33 --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/create-totp.ts @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Field, ObjectType } from 'type-graphql'; + +@ObjectType() +export class CreateTotpPayload { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; + + @Field() + public qrCodeUrl!: string; + + @Field() + public secret!: string; + + @Field((returns) => [String]) + public recoveryCodes!: string[]; +} diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/payload/index.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/index.ts index 0ce3b00363..1e90232a90 100644 --- a/packages/fxa-graphql-api/src/lib/resolvers/types/payload/index.ts +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/index.ts @@ -4,3 +4,6 @@ export { BasicPayload } from './basic'; export { UpdateAvatarPayload } from './update-avatar'; export { UpdateDisplayNamePayload } from './update-display-name'; +export { ChangeRecoveryCodesPayload } from './change-recovery-codes'; +export { CreateTotpPayload } from './create-totp'; +export { VerifyTotpPayload } from './verify-totp'; diff --git a/packages/fxa-graphql-api/src/lib/resolvers/types/payload/verify-totp.ts b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/verify-totp.ts new file mode 100644 index 0000000000..57308c7132 --- /dev/null +++ b/packages/fxa-graphql-api/src/lib/resolvers/types/payload/verify-totp.ts @@ -0,0 +1,19 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +import { Field, ObjectType } from 'type-graphql'; + +@ObjectType() +export class VerifyTotpPayload { + @Field({ + description: 'A unique identifier for the client performing the mutation.', + nullable: true, + }) + public clientMutationId?: string; + + @Field() + public success!: boolean; + + @Field((returns) => [String], { nullable: true }) + public recoveryCodes?: string[]; +} diff --git a/packages/fxa-graphql-api/src/test/lib/resolvers/account-resolver.spec.ts b/packages/fxa-graphql-api/src/test/lib/resolvers/account-resolver.spec.ts index 1892381e75..9a6bfaa2cf 100644 --- a/packages/fxa-graphql-api/src/test/lib/resolvers/account-resolver.spec.ts +++ b/packages/fxa-graphql-api/src/test/lib/resolvers/account-resolver.spec.ts @@ -170,6 +170,119 @@ describe('accountResolver', () => { }); }); + describe('createTotp', async () => { + it('succeeds', async () => { + context.dataSources.authAPI.createTotpToken.resolves({ + qrCodeUrl: 'testurl', + recoveryCodes: ['test1', 'test2'], + secret: 'secretData', + }); + const query = `mutation { + createTotp(input: {clientMutationId: "testid", metricsContext: { + deviceId: "device1", + flowBeginTime: 4238248 + }}) { + clientMutationId + qrCodeUrl + secret + recoveryCodes + } + }`; + context.authUser = USER_1.uid; + const result = (await graphql( + schema, + query, + undefined, + context + )) as any; + assert.isDefined(result.data); + assert.isDefined(result.data.createTotp); + assert.deepEqual(result.data.createTotp, { + clientMutationId: 'testid', + qrCodeUrl: 'testurl', + recoveryCodes: ['test1', 'test2'], + secret: 'secretData', + }); + }); + }); + + describe('changeRecoveryCodes', async () => { + it('succeeds', async () => { + context.dataSources.authAPI.replaceRecoveryCodes.resolves({ + recoveryCodes: ['test1', 'test2'], + }); + const query = `mutation { + changeRecoveryCodes(input: {clientMutationId: "testid"}) { + clientMutationId + recoveryCodes + } + }`; + context.authUser = USER_1.uid; + const result = (await graphql( + schema, + query, + undefined, + context + )) as any; + assert.isDefined(result.data); + assert.isDefined(result.data.changeRecoveryCodes); + assert.deepEqual(result.data.changeRecoveryCodes, { + clientMutationId: 'testid', + recoveryCodes: ['test1', 'test2'], + }); + }); + }); + + describe('verifyTotp', async () => { + it('succeeds', async () => { + context.dataSources.authAPI.verifyTotp.resolves({ + success: true, + }); + const query = `mutation { + verifyTotp(input: {clientMutationId: "testid", code: "code1234"}) { + clientMutationId + success + } + }`; + context.authUser = USER_1.uid; + const result = (await graphql( + schema, + query, + undefined, + context + )) as any; + assert.isDefined(result.data); + assert.isDefined(result.data.verifyTotp); + assert.deepEqual(result.data.verifyTotp, { + clientMutationId: 'testid', + success: true, + }); + }); + }); + + describe('deleteTotp', async () => { + it('succeeds', async () => { + context.dataSources.authAPI.destroyTotpToken.resolves(true); + const query = `mutation { + deleteTotp(input: {clientMutationId: "testid"}) { + clientMutationId + } + }`; + context.authUser = USER_1.uid; + const result = (await graphql( + schema, + query, + undefined, + context + )) as any; + assert.isDefined(result.data); + assert.isDefined(result.data.deleteTotp); + assert.deepEqual(result.data.deleteTotp, { + clientMutationId: 'testid', + }); + }); + }); + describe('deleteSecondaryEmail', async () => { it('succeeds', async () => { context.dataSources.authAPI.recoveryEmailDestroy.resolves(true); diff --git a/types/fxa-js-client/index.d.ts b/types/fxa-js-client/index.d.ts index 81ff8fe2b2..f308205531 100644 --- a/types/fxa-js-client/index.d.ts +++ b/types/fxa-js-client/index.d.ts @@ -8,6 +8,22 @@ declare function FxAccountClient( ): FxAccountClient.Client; declare namespace FxAccountClient { + export type MetricContext = { + deviceId?: string; + entrypoint?: string; + entrypointExperiment?: string; + entrypointVariation?: string; + flowId?: string; + flowBeginTime?: number; + productId?: string; + planId?: string; + utmCampaign?: number; + utmContent?: number; + utmMedium?: number; + utmSource?: number; + utmTerm?: number; + }; + export interface Client { sessionStatus( sessionToken: string @@ -36,6 +52,11 @@ declare namespace FxAccountClient { token_type: string; expires_in: number; }>; + createTotpToken( + sessionToken: string, + metricOptions: MetricContext + ): Promise<{ qrCodeUrl: string; secret: string; recoveryCodes: string[] }>; + deleteTotpToken(sessionToken: string): Promise; recoveryEmailCreate(sessionToken: string, email: string): Promise; recoveryEmailDestroy(sessionToken: string, email: string): Promise; recoveryEmailSetPrimaryEmail( @@ -46,5 +67,13 @@ declare namespace FxAccountClient { sessionToken: string, email: string ): Promise; + replaceRecoveryCodes( + sessionToken: string + ): Promise<{ recoveryCodes: string[] }>; + verifyTotpCode( + sessionToken: string, + code: string, + options?: { service: string } + ): Promise<{ success: boolean }>; } }