зеркало из https://github.com/mozilla/fxa.git
Merge pull request #6141 from mozilla/gql-session
feat(settings): add session verified state to graphql-api
This commit is contained in:
Коммит
b43e911d9c
|
@ -531,7 +531,9 @@ export default class AuthClient {
|
|||
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);
|
||||
}
|
||||
|
||||
|
@ -933,7 +935,9 @@ export default class AuthClient {
|
|||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -13,10 +13,9 @@ export class SessionTokenAuth {
|
|||
@Inject(fxAccountClientToken)
|
||||
private authClient!: AuthClient;
|
||||
|
||||
public async lookupUserId(sessionToken: string): Promise<string> {
|
||||
public async getSessionStatus(sessionToken: string) {
|
||||
try {
|
||||
const result = await this.authClient.sessionStatus(sessionToken);
|
||||
return result.uid;
|
||||
return await this.authClient.sessionStatus(sessionToken);
|
||||
} catch (err) {
|
||||
throw new AuthenticationError('Invalid session token');
|
||||
}
|
||||
|
|
|
@ -215,7 +215,7 @@ export class AccountResolver {
|
|||
|
||||
@Query((returns) => AccountType, { nullable: true })
|
||||
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
|
||||
const parsed: any = parseResolveInfo(info);
|
||||
|
@ -228,7 +228,7 @@ export class AccountResolver {
|
|||
const options: AccountOptions = includeEmails
|
||||
? { include: ['emails'] }
|
||||
: {};
|
||||
return accountByUid(context.authUser, options);
|
||||
return accountByUid(context.session.uid, options);
|
||||
}
|
||||
|
||||
@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 { ProfileServerSource } from './datasources/profileServer';
|
||||
import { AccountResolver } from './resolvers/account-resolver';
|
||||
import { SessionResolver } from './resolvers/session-resolver';
|
||||
import { reportGraphQLError } from './sentry';
|
||||
|
||||
type ServerConfig = {
|
||||
|
@ -39,7 +40,7 @@ export function formatError(debug: boolean, logger: Logger, err: GraphQLError) {
|
|||
* Context available to resolvers
|
||||
*/
|
||||
export type Context = {
|
||||
authUser: string;
|
||||
session: { uid: string; state: 'verified' | 'unverified' };
|
||||
token: string;
|
||||
dataSources: DataSources;
|
||||
logger: Logger;
|
||||
|
@ -57,11 +58,11 @@ export async function createServer(
|
|||
): Promise<ApolloServer> {
|
||||
const schema = await TypeGraphQL.buildSchema({
|
||||
container: Container,
|
||||
resolvers: [AccountResolver],
|
||||
resolvers: [AccountResolver, SessionResolver],
|
||||
validate: false,
|
||||
});
|
||||
const authHeader = config.authHeader.toLowerCase();
|
||||
const authUser = Container.get(SessionTokenAuth);
|
||||
const authToken = Container.get(SessionTokenAuth);
|
||||
const debugMode = config.env !== 'production';
|
||||
const defaultContext = async ({ req }: { req: Request }) => {
|
||||
const bearerToken = req.headers[authHeader];
|
||||
|
@ -70,13 +71,12 @@ export async function createServer(
|
|||
'Invalid authentcation header found at: ' + authHeader
|
||||
);
|
||||
}
|
||||
const userId = await authUser.lookupUserId(bearerToken);
|
||||
const session = await authToken.getSessionStatus(bearerToken);
|
||||
return {
|
||||
authUser: userId,
|
||||
token: bearerToken,
|
||||
session,
|
||||
logger,
|
||||
};
|
||||
'';
|
||||
} as Context;
|
||||
};
|
||||
|
||||
return new ApolloServer({
|
||||
|
|
|
@ -25,18 +25,21 @@ describe('SessionTokenAuth', () => {
|
|||
sandbox.resetHistory();
|
||||
});
|
||||
|
||||
describe('lookupUserId', async () => {
|
||||
describe('getSessionStatus', async () => {
|
||||
it('looks up user successfully', async () => {
|
||||
fxAccountClient.sessionStatus.resolves({ uid: '9001xyz', state: 'test' });
|
||||
const result = await sessionAuth.lookupUserId('token');
|
||||
assert.equal(result, '9001xyz');
|
||||
fxAccountClient.sessionStatus.resolves({
|
||||
uid: '9001xyz',
|
||||
state: 'unverified',
|
||||
});
|
||||
const result = await sessionAuth.getSessionStatus('token');
|
||||
assert.equal(result.uid, '9001xyz');
|
||||
});
|
||||
|
||||
it('throws when the authClient throws', async () => {
|
||||
fxAccountClient.sessionStatus.rejects(new Error('boom'));
|
||||
try {
|
||||
await sessionAuth.lookupUserId('token');
|
||||
assert.fail('lookupUserId should have thrown');
|
||||
await sessionAuth.getSessionStatus('token');
|
||||
assert.fail('getSessionStatus should have thrown');
|
||||
} catch (e) {
|
||||
assert.instanceOf(e, AuthenticationError);
|
||||
}
|
||||
|
|
|
@ -84,7 +84,7 @@ describe('AuthServerSource', () => {
|
|||
recoveryEmailSecondaryResendCode: sandbox.stub(),
|
||||
};
|
||||
Container.set(fxAccountClientToken, authClient);
|
||||
context = mockContext();
|
||||
context = mockContext() as Context;
|
||||
authSource = new AuthServerSource();
|
||||
const config = stubInterface<DataSourceConfig<Context>>();
|
||||
config.context = context;
|
||||
|
|
|
@ -43,7 +43,7 @@ describe('ProfileServerSource', () => {
|
|||
};
|
||||
Container.set(fxAccountClientToken, authClient);
|
||||
Container.set(configContainerToken, config);
|
||||
context = mockContext();
|
||||
context = mockContext() as Context;
|
||||
profileSource = new ProfileServerSource();
|
||||
const pluginConfig = stubInterface<DataSourceConfig<Context>>();
|
||||
pluginConfig.context = context;
|
||||
|
|
|
@ -13,7 +13,10 @@ export function mockContext() {
|
|||
profileAPI: stubInterface<ProfileServerSource>(),
|
||||
};
|
||||
return {
|
||||
authUser: '',
|
||||
session: {
|
||||
uid: '',
|
||||
state: 'unverified',
|
||||
},
|
||||
token: 'test',
|
||||
logger: stubInterface<Logger>(),
|
||||
dataSources: ds,
|
||||
|
|
|
@ -56,7 +56,7 @@ describe('accountResolver', () => {
|
|||
accountCreated
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -78,7 +78,7 @@ describe('accountResolver', () => {
|
|||
uid
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_2.uid;
|
||||
context.session.uid = USER_2.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -101,7 +101,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -125,7 +125,7 @@ describe('accountResolver', () => {
|
|||
displayName
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -155,7 +155,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -188,7 +188,7 @@ describe('accountResolver', () => {
|
|||
recoveryCodes
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -217,7 +217,7 @@ describe('accountResolver', () => {
|
|||
recoveryCodes
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -244,7 +244,7 @@ describe('accountResolver', () => {
|
|||
success
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -268,7 +268,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -291,7 +291,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -314,7 +314,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
@ -339,7 +339,7 @@ describe('accountResolver', () => {
|
|||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
context.authUser = USER_1.uid;
|
||||
context.session.uid = USER_1.uid;
|
||||
const result = (await graphql(
|
||||
schema,
|
||||
query,
|
||||
|
|
|
@ -29,20 +29,22 @@ describe('Schema', () => {
|
|||
schemaIntrospection = builtSchema.data!.__schema as IntrospectionSchema;
|
||||
assert.isDefined(schemaIntrospection);
|
||||
queryType = schemaIntrospection.types.find(
|
||||
type => type.name === (schemaIntrospection as IntrospectionSchema).queryType.name
|
||||
(type) =>
|
||||
type.name ===
|
||||
(schemaIntrospection as IntrospectionSchema).queryType.name
|
||||
) as IntrospectionObjectType;
|
||||
|
||||
const mutationTypeNameRef = schemaIntrospection.mutationType;
|
||||
if (mutationTypeNameRef) {
|
||||
mutationType = schemaIntrospection.types.find(
|
||||
type => type.name === mutationTypeNameRef.name
|
||||
(type) => type.name === mutationTypeNameRef.name
|
||||
) as IntrospectionObjectType;
|
||||
}
|
||||
});
|
||||
|
||||
function findTypeByName(name: string) {
|
||||
return schemaIntrospection.types.find(
|
||||
type => type.kind === TypeKind.OBJECT && type.name === name
|
||||
(type) => type.kind === TypeKind.OBJECT && type.name === name
|
||||
) as IntrospectionObjectType;
|
||||
}
|
||||
|
||||
|
@ -50,13 +52,13 @@ describe('Schema', () => {
|
|||
const typ = findTypeByName(name);
|
||||
assert.isDefined(typ);
|
||||
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);
|
||||
}
|
||||
|
||||
it('is created with expected types', async () => {
|
||||
const queryNames = queryType.fields.map(it => it.name);
|
||||
assert.sameMembers(queryNames, ['account']);
|
||||
const queryNames = queryType.fields.map((it) => it.name);
|
||||
assert.sameMembers(queryNames, ['account', 'session']);
|
||||
|
||||
verifyTypeMembers('Account', [
|
||||
'uid',
|
||||
|
@ -106,5 +108,6 @@ describe('Schema', () => {
|
|||
'os',
|
||||
]);
|
||||
verifyTypeMembers('Location', ['city', 'country', 'state', 'stateCode']);
|
||||
verifyTypeMembers('Session', ['verified']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -20,7 +20,7 @@ import { createServer } from '../../lib/server';
|
|||
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
const sessionAuth = { lookupUserId: sandbox.stub() };
|
||||
const sessionAuth = { getSessionStatus: sandbox.stub() };
|
||||
|
||||
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 () => {
|
||||
sessionAuth.lookupUserId.rejects(
|
||||
sessionAuth.getSessionStatus.rejects(
|
||||
new AuthenticationError('Invalid token')
|
||||
);
|
||||
try {
|
||||
|
@ -101,12 +101,16 @@ describe('createServer', () => {
|
|||
});
|
||||
|
||||
it('should return a user and the bearer token', async () => {
|
||||
sessionAuth.lookupUserId.resolves('9001xyz');
|
||||
sessionAuth.getSessionStatus.resolves({
|
||||
uid: '9001xyz',
|
||||
state: 'unverified',
|
||||
});
|
||||
try {
|
||||
const context = await (server as any).context({
|
||||
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');
|
||||
} catch (e) {
|
||||
assert.fail('Should not have thrown an exception: ' + e);
|
||||
|
|
|
@ -15,9 +15,6 @@ type AccountDataHOCProps = {
|
|||
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) => {
|
||||
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!;
|
||||
}
|
||||
|
||||
// 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`
|
||||
extend type Query {
|
||||
sessionToken: String!
|
||||
extend type Session {
|
||||
token: String!
|
||||
}
|
||||
`;
|
||||
|
||||
export const cache = new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
Session: {
|
||||
fields: {
|
||||
sessionToken: {
|
||||
token: {
|
||||
read() {
|
||||
return sessionToken();
|
||||
},
|
||||
|
|
Загрузка…
Ссылка в новой задаче