Merge pull request #6141 from mozilla/gql-session

feat(settings): add session verified state to graphql-api
This commit is contained in:
Danny Coates 2020-08-05 16:46:38 -07:00 коммит произвёл GitHub
Родитель a5a78e42f7 42a6f8a395
Коммит b43e911d9c
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
18 изменённых файлов: 223 добавлений и 53 удалений

Просмотреть файл

@ -531,7 +531,9 @@ export default class AuthClient {
return this.sessionPost('/session/destroy', sessionToken, options); return this.sessionPost('/session/destroy', sessionToken, options);
} }
async sessionStatus(sessionToken: string) { async sessionStatus(
sessionToken: string
): Promise<{ state: 'verified' | 'unverified'; uid: string }> {
return this.sessionGet('/session/status', sessionToken); return this.sessionGet('/session/status', sessionToken);
} }
@ -933,7 +935,9 @@ export default class AuthClient {
return this.sessionPost('/totp/destroy', sessionToken, {}); return this.sessionPost('/totp/destroy', sessionToken, {});
} }
async checkTotpTokenExists(sessionToken: string): Promise<{ exists: boolean, verified: boolean }> { async checkTotpTokenExists(
sessionToken: string
): Promise<{ exists: boolean; verified: boolean }> {
return this.sessionGet('/totp/exists', sessionToken); return this.sessionGet('/totp/exists', sessionToken);
} }

Просмотреть файл

@ -13,10 +13,9 @@ export class SessionTokenAuth {
@Inject(fxAccountClientToken) @Inject(fxAccountClientToken)
private authClient!: AuthClient; private authClient!: AuthClient;
public async lookupUserId(sessionToken: string): Promise<string> { public async getSessionStatus(sessionToken: string) {
try { try {
const result = await this.authClient.sessionStatus(sessionToken); return await this.authClient.sessionStatus(sessionToken);
return result.uid;
} catch (err) { } catch (err) {
throw new AuthenticationError('Invalid session token'); throw new AuthenticationError('Invalid session token');
} }

Просмотреть файл

@ -215,7 +215,7 @@ export class AccountResolver {
@Query((returns) => AccountType, { nullable: true }) @Query((returns) => AccountType, { nullable: true })
public account(@Ctx() context: Context, @Info() info: GraphQLResolveInfo) { public account(@Ctx() context: Context, @Info() info: GraphQLResolveInfo) {
context.logger.info('account', { uid: context.authUser }); context.logger.info('account', { uid: context.session.uid });
// Introspect the query to determine if we should load the emails // Introspect the query to determine if we should load the emails
const parsed: any = parseResolveInfo(info); const parsed: any = parseResolveInfo(info);
@ -228,7 +228,7 @@ export class AccountResolver {
const options: AccountOptions = includeEmails const options: AccountOptions = includeEmails
? { include: ['emails'] } ? { include: ['emails'] }
: {}; : {};
return accountByUid(context.authUser, options); return accountByUid(context.session.uid, options);
} }
@FieldResolver() @FieldResolver()

Просмотреть файл

@ -0,0 +1,25 @@
/* 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 { GraphQLResolveInfo } from 'graphql';
import {
Ctx,
Info,
Query,
Resolver,
} from 'type-graphql';
import { Context } from '../server';
import { Session as SessionType } from './types/session'
@Resolver((of) => SessionType)
export class SessionResolver {
@Query((returns) => SessionType)
session(@Ctx() context: Context, @Info() info: GraphQLResolveInfo) {
context.logger.info('session', { uid: context.session.uid });
return {
verified: context.session.state === 'verified'
} as SessionType
}
}

Просмотреть файл

@ -0,0 +1,14 @@
/* 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({ description: 'Session (token) info' })
export class Session {
@Field({
description:
'Whether the current session is verified',
})
public verified!: boolean;
}

Просмотреть файл

@ -12,6 +12,7 @@ import { SessionTokenAuth } from './auth';
import { AuthServerSource } from './datasources/authServer'; import { AuthServerSource } from './datasources/authServer';
import { ProfileServerSource } from './datasources/profileServer'; import { ProfileServerSource } from './datasources/profileServer';
import { AccountResolver } from './resolvers/account-resolver'; import { AccountResolver } from './resolvers/account-resolver';
import { SessionResolver } from './resolvers/session-resolver';
import { reportGraphQLError } from './sentry'; import { reportGraphQLError } from './sentry';
type ServerConfig = { type ServerConfig = {
@ -39,7 +40,7 @@ export function formatError(debug: boolean, logger: Logger, err: GraphQLError) {
* Context available to resolvers * Context available to resolvers
*/ */
export type Context = { export type Context = {
authUser: string; session: { uid: string; state: 'verified' | 'unverified' };
token: string; token: string;
dataSources: DataSources; dataSources: DataSources;
logger: Logger; logger: Logger;
@ -57,11 +58,11 @@ export async function createServer(
): Promise<ApolloServer> { ): Promise<ApolloServer> {
const schema = await TypeGraphQL.buildSchema({ const schema = await TypeGraphQL.buildSchema({
container: Container, container: Container,
resolvers: [AccountResolver], resolvers: [AccountResolver, SessionResolver],
validate: false, validate: false,
}); });
const authHeader = config.authHeader.toLowerCase(); const authHeader = config.authHeader.toLowerCase();
const authUser = Container.get(SessionTokenAuth); const authToken = Container.get(SessionTokenAuth);
const debugMode = config.env !== 'production'; const debugMode = config.env !== 'production';
const defaultContext = async ({ req }: { req: Request }) => { const defaultContext = async ({ req }: { req: Request }) => {
const bearerToken = req.headers[authHeader]; const bearerToken = req.headers[authHeader];
@ -70,13 +71,12 @@ export async function createServer(
'Invalid authentcation header found at: ' + authHeader 'Invalid authentcation header found at: ' + authHeader
); );
} }
const userId = await authUser.lookupUserId(bearerToken); const session = await authToken.getSessionStatus(bearerToken);
return { return {
authUser: userId,
token: bearerToken, token: bearerToken,
session,
logger, logger,
}; } as Context;
'';
}; };
return new ApolloServer({ return new ApolloServer({

Просмотреть файл

@ -25,18 +25,21 @@ describe('SessionTokenAuth', () => {
sandbox.resetHistory(); sandbox.resetHistory();
}); });
describe('lookupUserId', async () => { describe('getSessionStatus', async () => {
it('looks up user successfully', async () => { it('looks up user successfully', async () => {
fxAccountClient.sessionStatus.resolves({ uid: '9001xyz', state: 'test' }); fxAccountClient.sessionStatus.resolves({
const result = await sessionAuth.lookupUserId('token'); uid: '9001xyz',
assert.equal(result, '9001xyz'); state: 'unverified',
});
const result = await sessionAuth.getSessionStatus('token');
assert.equal(result.uid, '9001xyz');
}); });
it('throws when the authClient throws', async () => { it('throws when the authClient throws', async () => {
fxAccountClient.sessionStatus.rejects(new Error('boom')); fxAccountClient.sessionStatus.rejects(new Error('boom'));
try { try {
await sessionAuth.lookupUserId('token'); await sessionAuth.getSessionStatus('token');
assert.fail('lookupUserId should have thrown'); assert.fail('getSessionStatus should have thrown');
} catch (e) { } catch (e) {
assert.instanceOf(e, AuthenticationError); assert.instanceOf(e, AuthenticationError);
} }

Просмотреть файл

@ -84,7 +84,7 @@ describe('AuthServerSource', () => {
recoveryEmailSecondaryResendCode: sandbox.stub(), recoveryEmailSecondaryResendCode: sandbox.stub(),
}; };
Container.set(fxAccountClientToken, authClient); Container.set(fxAccountClientToken, authClient);
context = mockContext(); context = mockContext() as Context;
authSource = new AuthServerSource(); authSource = new AuthServerSource();
const config = stubInterface<DataSourceConfig<Context>>(); const config = stubInterface<DataSourceConfig<Context>>();
config.context = context; config.context = context;

Просмотреть файл

@ -43,7 +43,7 @@ describe('ProfileServerSource', () => {
}; };
Container.set(fxAccountClientToken, authClient); Container.set(fxAccountClientToken, authClient);
Container.set(configContainerToken, config); Container.set(configContainerToken, config);
context = mockContext(); context = mockContext() as Context;
profileSource = new ProfileServerSource(); profileSource = new ProfileServerSource();
const pluginConfig = stubInterface<DataSourceConfig<Context>>(); const pluginConfig = stubInterface<DataSourceConfig<Context>>();
pluginConfig.context = context; pluginConfig.context = context;

Просмотреть файл

@ -13,7 +13,10 @@ export function mockContext() {
profileAPI: stubInterface<ProfileServerSource>(), profileAPI: stubInterface<ProfileServerSource>(),
}; };
return { return {
authUser: '', session: {
uid: '',
state: 'unverified',
},
token: 'test', token: 'test',
logger: stubInterface<Logger>(), logger: stubInterface<Logger>(),
dataSources: ds, dataSources: ds,

Просмотреть файл

@ -56,7 +56,7 @@ describe('accountResolver', () => {
accountCreated accountCreated
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -78,7 +78,7 @@ describe('accountResolver', () => {
uid uid
} }
}`; }`;
context.authUser = USER_2.uid; context.session.uid = USER_2.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -101,7 +101,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -125,7 +125,7 @@ describe('accountResolver', () => {
displayName displayName
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -155,7 +155,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -188,7 +188,7 @@ describe('accountResolver', () => {
recoveryCodes recoveryCodes
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -217,7 +217,7 @@ describe('accountResolver', () => {
recoveryCodes recoveryCodes
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -244,7 +244,7 @@ describe('accountResolver', () => {
success success
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -268,7 +268,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -291,7 +291,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -314,7 +314,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,
@ -339,7 +339,7 @@ describe('accountResolver', () => {
clientMutationId clientMutationId
} }
}`; }`;
context.authUser = USER_1.uid; context.session.uid = USER_1.uid;
const result = (await graphql( const result = (await graphql(
schema, schema,
query, query,

Просмотреть файл

@ -29,20 +29,22 @@ describe('Schema', () => {
schemaIntrospection = builtSchema.data!.__schema as IntrospectionSchema; schemaIntrospection = builtSchema.data!.__schema as IntrospectionSchema;
assert.isDefined(schemaIntrospection); assert.isDefined(schemaIntrospection);
queryType = schemaIntrospection.types.find( queryType = schemaIntrospection.types.find(
type => type.name === (schemaIntrospection as IntrospectionSchema).queryType.name (type) =>
type.name ===
(schemaIntrospection as IntrospectionSchema).queryType.name
) as IntrospectionObjectType; ) as IntrospectionObjectType;
const mutationTypeNameRef = schemaIntrospection.mutationType; const mutationTypeNameRef = schemaIntrospection.mutationType;
if (mutationTypeNameRef) { if (mutationTypeNameRef) {
mutationType = schemaIntrospection.types.find( mutationType = schemaIntrospection.types.find(
type => type.name === mutationTypeNameRef.name (type) => type.name === mutationTypeNameRef.name
) as IntrospectionObjectType; ) as IntrospectionObjectType;
} }
}); });
function findTypeByName(name: string) { function findTypeByName(name: string) {
return schemaIntrospection.types.find( return schemaIntrospection.types.find(
type => type.kind === TypeKind.OBJECT && type.name === name (type) => type.kind === TypeKind.OBJECT && type.name === name
) as IntrospectionObjectType; ) as IntrospectionObjectType;
} }
@ -50,13 +52,13 @@ describe('Schema', () => {
const typ = findTypeByName(name); const typ = findTypeByName(name);
assert.isDefined(typ); assert.isDefined(typ);
assert.lengthOf(typ.fields, members.length); assert.lengthOf(typ.fields, members.length);
const typNames = typ.fields.map(it => it.name); const typNames = typ.fields.map((it) => it.name);
assert.sameMembers(typNames, members); assert.sameMembers(typNames, members);
} }
it('is created with expected types', async () => { it('is created with expected types', async () => {
const queryNames = queryType.fields.map(it => it.name); const queryNames = queryType.fields.map((it) => it.name);
assert.sameMembers(queryNames, ['account']); assert.sameMembers(queryNames, ['account', 'session']);
verifyTypeMembers('Account', [ verifyTypeMembers('Account', [
'uid', 'uid',
@ -106,5 +108,6 @@ describe('Schema', () => {
'os', 'os',
]); ]);
verifyTypeMembers('Location', ['city', 'country', 'state', 'stateCode']); verifyTypeMembers('Location', ['city', 'country', 'state', 'stateCode']);
verifyTypeMembers('Session', ['verified']);
}); });
}); });

Просмотреть файл

@ -20,7 +20,7 @@ import { createServer } from '../../lib/server';
const sandbox = sinon.createSandbox(); const sandbox = sinon.createSandbox();
const sessionAuth = { lookupUserId: sandbox.stub() }; const sessionAuth = { getSessionStatus: sandbox.stub() };
const mockLogger = ({ info: () => {} } as unknown) as Logger; const mockLogger = ({ info: () => {} } as unknown) as Logger;
@ -89,7 +89,7 @@ describe('createServer', () => {
}); });
it('should throw an AuthenticationError when auth server has auth error', async () => { it('should throw an AuthenticationError when auth server has auth error', async () => {
sessionAuth.lookupUserId.rejects( sessionAuth.getSessionStatus.rejects(
new AuthenticationError('Invalid token') new AuthenticationError('Invalid token')
); );
try { try {
@ -101,12 +101,16 @@ describe('createServer', () => {
}); });
it('should return a user and the bearer token', async () => { it('should return a user and the bearer token', async () => {
sessionAuth.lookupUserId.resolves('9001xyz'); sessionAuth.getSessionStatus.resolves({
uid: '9001xyz',
state: 'unverified',
});
try { try {
const context = await (server as any).context({ const context = await (server as any).context({
req: { headers: { authorization: 'lolcatz' } }, req: { headers: { authorization: 'lolcatz' } },
}); });
assert.equal(context.authUser, '9001xyz'); assert.equal(context.session.uid, '9001xyz');
assert.equal(context.session.state, 'unverified');
assert.equal(context.token, 'lolcatz'); assert.equal(context.token, 'lolcatz');
} catch (e) { } catch (e) {
assert.fail('Should not have thrown an exception: ' + e); assert.fail('Should not have thrown an exception: ' + e);

Просмотреть файл

@ -15,9 +15,6 @@ type AccountDataHOCProps = {
children: (props: AccountDataHOCChildrenProps) => React.ReactNode; children: (props: AccountDataHOCChildrenProps) => React.ReactNode;
}; };
// A data extraction layer for initial load.
// TODO: If an account is unverified we'll need to
// requery with less requested properties.
export const AccountDataHOC = ({ children }: AccountDataHOCProps) => { export const AccountDataHOC = ({ children }: AccountDataHOCProps) => {
const { loading, error, data } = useQuery(GET_ACCOUNT); const { loading, error, data } = useQuery(GET_ACCOUNT);

Просмотреть файл

@ -0,0 +1,78 @@
/* 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 React from 'react';
import { InMemoryCache } from '@apollo/client';
import '@testing-library/jest-dom/extend-expect';
import { render, act, wait, screen } from '@testing-library/react';
import { MockedProvider, MockedResponse } from '@apollo/client/testing';
import { VerifiedSessionGuard, GET_SESSION } from '.';
const verifiedCache = new InMemoryCache();
verifiedCache.writeQuery({
query: GET_SESSION,
data: {
session: {
verified: true,
},
},
});
const unverifiedCache = new InMemoryCache();
unverifiedCache.writeQuery({
query: GET_SESSION,
data: {
session: {
verified: false,
},
},
});
it('renders the content when verified', async () => {
render(
<MockedProvider addTypename={false} cache={verifiedCache}>
<VerifiedSessionGuard guard={<div data-testid="guard">oops</div>}>
<div data-testid="children">Content</div>
</VerifiedSessionGuard>
</MockedProvider>
);
await wait();
expect(screen.getByTestId('children')).toBeInTheDocument();
});
it('renders the guard when unverified', async () => {
async () => {
render(
<MockedProvider addTypename={false} cache={unverifiedCache}>
<VerifiedSessionGuard guard={<div data-testid="guard">oops</div>}>
<div>Content</div>
</VerifiedSessionGuard>
</MockedProvider>
);
await wait();
expect(screen.getByTestId('guard')).toBeInTheDocument();
};
});
it('renders the guard when loading fails', async () => {
// don't pollute the test output with console logs
const consoleError = console.error;
console.error = () => {};
render(
<MockedProvider>
<VerifiedSessionGuard guard={<div data-testid="guard">oops</div>}>
<div>Content</div>
</VerifiedSessionGuard>
</MockedProvider>
);
await wait();
expect(screen.getByTestId('guard')).toBeInTheDocument();
console.error = consoleError;
});

Просмотреть файл

@ -0,0 +1,37 @@
/* 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 React from 'react';
import { gql, useQuery } from '@apollo/client';
import sentryMetrics from 'fxa-shared/lib/sentry';
export const GET_SESSION = gql`
query GetSession {
session {
verified
}
}
`;
export const VerifiedSessionGuard = ({
guard,
children,
}: {
guard: React.ReactElement;
children: React.ReactElement;
}) => {
const { error, data } = useQuery(GET_SESSION, { fetchPolicy: 'cache-only' });
if (error || !data) {
// idk if this'll ever happen irl
const e = error || new Error('VerifiedSessionGuard missing data');
console.error(e);
sentryMetrics.captureException(e);
return guard;
}
return data.session.verified ? children : guard;
};
export default VerifiedSessionGuard;

Просмотреть файл

@ -15,3 +15,6 @@ export function useAuth() {
} }
return auth!; return auth!;
} }
// NOTE for future self
// In cases where the sessionToken changes we should also flush the session { verified } value from apollo cache

Просмотреть файл

@ -17,18 +17,18 @@ export function sessionToken(newToken?: string) {
} }
} }
// sessionToken is added as a local field in the cache as an example. // sessionToken is added as a local field as an example.
export const typeDefs = gql` export const typeDefs = gql`
extend type Query { extend type Session {
sessionToken: String! token: String!
} }
`; `;
export const cache = new InMemoryCache({ export const cache = new InMemoryCache({
typePolicies: { typePolicies: {
Query: { Session: {
fields: { fields: {
sessionToken: { token: {
read() { read() {
return sessionToken(); return sessionToken();
}, },