Skip to content

Commit

Permalink
iterate on feature
Browse files Browse the repository at this point in the history
  • Loading branch information
Betree committed Jul 15, 2024
1 parent d7f0f68 commit e26ab2a
Show file tree
Hide file tree
Showing 4 changed files with 139 additions and 16 deletions.
22 changes: 22 additions & 0 deletions server/graphql/schemaV2.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -17904,6 +17904,16 @@ type Mutation {
currentPassword: String
): SetPasswordResponse!

"""
Confirm email for Individual. Scope: "account".
"""
confirmEmail(

Check notice on line 17910 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Field 'confirmEmail' was added to object type 'Mutation'

Field 'confirmEmail' was added to object type 'Mutation'
"""
The token to confirm the email.
"""
token: String!
): IndividualConfirmEmailResponse!

"""
Submit a legal document
"""
Expand Down Expand Up @@ -20004,6 +20014,18 @@ type SetPasswordResponse {
token: String
}

type IndividualConfirmEmailResponse {

Check notice on line 20017 in server/graphql/schemaV2.graphql

View workflow job for this annotation

GitHub Actions / GraphQL Inspector - Schema v2

Type 'IndividualConfirmEmailResponse' was added

Type 'IndividualConfirmEmailResponse' was added
"""
The account that was confirmed
"""
individual: Individual!

"""
A new session token to use for the account. Only returned if user is signed in already.
"""
sessionToken: String
}

"""
The `Upload` scalar type represents a file upload.
"""
Expand Down
1 change: 1 addition & 0 deletions server/graphql/v1/mutations.js
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const mutations = {
confirmUserEmail: {
type: UserType,
description: 'Confirm the new user email from confirmation token',
deprecationReason: '2024-07-15: Please use the mutation `confirmEmail` from GQLV2',
args: {
token: {
type: new GraphQLNonNull(GraphQLString),
Expand Down
27 changes: 15 additions & 12 deletions server/graphql/v2/mutation/IndividualMutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import bcrypt from 'bcrypt';
import config from 'config';
import express from 'express';
import { GraphQLBoolean, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';
import { GraphQLDateTime } from 'graphql-scalars';
import { GraphQLDateTime, GraphQLNonEmptyString } from 'graphql-scalars';

import RateLimit, { ONE_HOUR_IN_SECONDS } from '../../../lib/rate-limit';
import TwoFactorAuthLib from '../../../lib/two-factor-authentication';
import { checkRemoteUserCanUseAccount, enforceScope } from '../../common/scope-check';
import { checkRemoteUserCanUseAccount } from '../../common/scope-check';
import { confirmUserEmail } from '../../common/user';
import { RateLimitExceeded, Unauthorized } from '../../errors';
import { GraphQLIndividual } from '../object/Individual';
Expand Down Expand Up @@ -115,39 +115,42 @@ const individualMutations = {
type: new GraphQLNonNull(GraphQLIndividual),
description: 'The account that was confirmed',
},
token: {
sessionToken: {
type: GraphQLString,
description: 'A new session token to use for the account. Only returned if not using OAuth.',
description: 'A new session token to use for the account. Only returned if user is signed in already.',
},
},
}),
),
args: {
token: {
type: new GraphQLNonNull(GraphQLString),
type: new GraphQLNonNull(GraphQLNonEmptyString),
description: 'The token to confirm the email.',
},
},
resolve: async (_, { token: confirmEmailToken }, req) => {
enforceScope(req, 'account');
// Forbid this route for OAuth and Personal Tokens. Remember to check the scope if you want to allow it.
// Also make sure to prevent exchanging OAuth/Personal tokens for session tokens.
if (req.userToken || req.personalToken) {
throw new Unauthorized('OAuth and Personal Tokens are not allowed for this route');
}

const user = await confirmUserEmail(confirmEmailToken);
const individual = await user.getCollective({ loaders: req.loaders });

// The sign-in token
let token;
let sessionToken;

// We don't want OAuth tokens to be exchanged against a session token
if (req.remoteUser && !req.userToken && !req.personalToken) {
// Context: this is token generation when updating password
token = await user.generateSessionToken({
// Re-generate the session token if the user is already signed in
if (req.remoteUser && req.remoteUser.id === user.id) {
sessionToken = await user.generateSessionToken({
sessionId: req.jwtPayload?.sessionId,
createActivity: false,
updateLastLoginAt: false,
});
}

return { individual, token };
return { individual, sessionToken };
},
},
};
Expand Down
105 changes: 101 additions & 4 deletions test/server/graphql/v2/mutation/IndividualMutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@ import { expect } from 'chai';
import config from 'config';
import crypto from 'crypto-js';
import gql from 'fake-tag';
import jwt from 'jsonwebtoken';
import { createSandbox } from 'sinon';
import speakeasy from 'speakeasy';
import request from 'supertest';

import { idEncode } from '../../../../../server/graphql/v2/identifiers';
import { fakeApplication, fakeUser, fakeUserToken, randEmail } from '../../../../test-helpers/fake-data';
import {
fakeApplication,
fakePersonalToken,
fakeUser,
fakeUserToken,
randEmail,
randStr,
} from '../../../../test-helpers/fake-data';
import { startTestServer, stopTestServer } from '../../../../test-helpers/server';
import { graphqlQueryV2 } from '../../../../utils';

Expand Down Expand Up @@ -192,10 +200,10 @@ describe('server/graphql/v2/mutation/IndividualMutations', () => {
.post('/graphql')
.send({ query: resetPasswordMutation, variables: { password: 'newpassword' } })
.set('Authorization', `Bearer ${token}`)
.expect(200);
.expect(401);

expect(res.body.errors).to.exist;
expect(res.body.errors[0].message).to.equal('This token has expired');
expect(res.body.error.code).to.equal(401);
expect(res.body.error.message).to.equal('This token has expired');
});
});

Expand Down Expand Up @@ -252,4 +260,93 @@ describe('server/graphql/v2/mutation/IndividualMutations', () => {
});
});
});

describe('confirmEmail', () => {
const confirmEmailMutation = gql`
mutation ConfirmEmail($token: NonEmptyString!) {
confirmEmail(token: $token) {
sessionToken
individual {
id
slug
}
}
}
`;

it('should error if the token is invalid', async () => {
const result = await graphqlQueryV2(confirmEmailMutation, { token: 'invalidtoken' });
expect(result.errors).to.exist;
expect(result.errors[0].message).to.equal('Invalid email confirmation token');
});

it('cannot be used with a OAuth or personal token', async () => {
const user = await fakeUser({ emailWaitingForValidation: randEmail(), emailConfirmationToken: randStr() });

const userToken = await fakeUserToken({ type: 'OAUTH', UserId: user.id });
const resultOauth = await graphqlQueryV2(
confirmEmailMutation,
{ token: user.emailConfirmationToken },
user,
null,
null,
userToken,
);
expect(resultOauth.errors).to.exist;
expect(resultOauth.errors[0].message).to.equal('OAuth and Personal Tokens are not allowed for this route');

const personalToken = await fakePersonalToken({ UserId: user.id });
const resultPersonalToken = await graphqlQueryV2(
confirmEmailMutation,
{ token: user.emailConfirmationToken },
user,
null,
null,
null,
personalToken,
);
expect(resultPersonalToken.errors).to.exist;
expect(resultPersonalToken.errors[0].message).to.equal(
'OAuth and Personal Tokens are not allowed for this route',
);
});

it('should confirm the new email', async () => {
const newEmail = randEmail();
const user = await fakeUser({ emailWaitingForValidation: newEmail, emailConfirmationToken: randStr() });
const result = await graphqlQueryV2(confirmEmailMutation, { token: user.emailConfirmationToken }); // Unauthenticated
expect(result.errors).to.not.exist;
expect(result.data.confirmEmail.sessionToken).to.not.exist; // Do not log in if not authenticated already
expect(result.data.confirmEmail.individual.slug).to.equal(user.collective.slug);

await user.reload();
expect(user.email).to.equal(newEmail);
expect(user.emailWaitingForValidation).to.be.null;
expect(user.emailConfirmationToken).to.be.null;
});

it('should confirm the new email and return a session token if logged in', async () => {
const newEmail = randEmail();
const user = await fakeUser({ emailWaitingForValidation: newEmail, emailConfirmationToken: randStr() });
const result = await graphqlQueryV2(confirmEmailMutation, { token: user.emailConfirmationToken }, user); // Authenticated
expect(result.errors).to.not.exist;
expect(result.data.confirmEmail.sessionToken).to.exist;
expect(result.data.confirmEmail.individual.slug).to.equal(user.collective.slug);

await user.reload();
expect(user.email).to.equal(newEmail);
expect(user.emailWaitingForValidation).to.be.null;
expect(user.emailConfirmationToken).to.be.null;

const decodedToken = jwt.decode(result.data.confirmEmail.sessionToken, { complete: true });
expect(decodedToken).to.containSubset({
header: { alg: 'HS256', typ: 'JWT', kid: 'HS256-2019-09-02' },
payload: {
scope: 'session',
email: newEmail,
sub: user.id.toString(),
},
});
});
});
});

0 comments on commit e26ab2a

Please sign in to comment.