diff --git a/packages/api/src/generated/graphql.ts b/packages/api/src/generated/graphql.ts index 435d1e2ab..3d392ec03 100644 --- a/packages/api/src/generated/graphql.ts +++ b/packages/api/src/generated/graphql.ts @@ -521,6 +521,22 @@ export type FeedArticlesSuccess = { pageInfo: PageInfo; }; +export type GenerateApiKeyError = { + __typename?: 'GenerateApiKeyError'; + errorCodes: Array; +}; + +export enum GenerateApiKeyErrorCode { + BadRequest = 'BAD_REQUEST' +} + +export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess; + +export type GenerateApiKeySuccess = { + __typename?: 'GenerateApiKeySuccess'; + apiKey: Scalars['String']; +}; + export type GetFollowersError = { __typename?: 'GetFollowersError'; errorCodes: Array; @@ -771,6 +787,7 @@ export type Mutation = { deleteNewsletterEmail: DeleteNewsletterEmailResult; deleteReaction: DeleteReactionResult; deleteReminder: DeleteReminderResult; + generateApiKey: GenerateApiKeyResult; googleLogin: LoginResult; googleSignup: GoogleSignupResult; login: LoginResult; @@ -867,6 +884,11 @@ export type MutationDeleteReminderArgs = { }; +export type MutationGenerateApiKeyArgs = { + scope?: InputMaybe; +}; + + export type MutationGoogleLoginArgs = { input: GoogleLoginInput; }; @@ -1989,6 +2011,10 @@ export type ResolversTypes = { FeedArticlesResult: ResolversTypes['FeedArticlesError'] | ResolversTypes['FeedArticlesSuccess']; FeedArticlesSuccess: ResolverTypeWrapper; Float: ResolverTypeWrapper; + GenerateApiKeyError: ResolverTypeWrapper; + GenerateApiKeyErrorCode: GenerateApiKeyErrorCode; + GenerateApiKeyResult: ResolversTypes['GenerateApiKeyError'] | ResolversTypes['GenerateApiKeySuccess']; + GenerateApiKeySuccess: ResolverTypeWrapper; GetFollowersError: ResolverTypeWrapper; GetFollowersErrorCode: GetFollowersErrorCode; GetFollowersResult: ResolversTypes['GetFollowersError'] | ResolversTypes['GetFollowersSuccess']; @@ -2252,6 +2278,9 @@ export type ResolversParentTypes = { FeedArticlesResult: ResolversParentTypes['FeedArticlesError'] | ResolversParentTypes['FeedArticlesSuccess']; FeedArticlesSuccess: FeedArticlesSuccess; Float: Scalars['Float']; + GenerateApiKeyError: GenerateApiKeyError; + GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess']; + GenerateApiKeySuccess: GenerateApiKeySuccess; GetFollowersError: GetFollowersError; GetFollowersResult: ResolversParentTypes['GetFollowersError'] | ResolversParentTypes['GetFollowersSuccess']; GetFollowersSuccess: GetFollowersSuccess; @@ -2763,6 +2792,20 @@ export type FeedArticlesSuccessResolvers; }; +export type GenerateApiKeyErrorResolvers = { + errorCodes?: Resolver, ParentType, ContextType>; + __isTypeOf?: IsTypeOfResolverFn; +}; + +export type GenerateApiKeyResultResolvers = { + __resolveType: TypeResolveFn<'GenerateApiKeyError' | 'GenerateApiKeySuccess', ParentType, ContextType>; +}; + +export type GenerateApiKeySuccessResolvers = { + apiKey?: Resolver; + __isTypeOf?: IsTypeOfResolverFn; +}; + export type GetFollowersErrorResolvers = { errorCodes?: Resolver, ParentType, ContextType>; __isTypeOf?: IsTypeOfResolverFn; @@ -2957,6 +3000,7 @@ export type MutationResolvers>; deleteReaction?: Resolver>; deleteReminder?: Resolver>; + generateApiKey?: Resolver>; googleLogin?: Resolver>; googleSignup?: Resolver>; login?: Resolver>; @@ -3521,6 +3565,9 @@ export type Resolvers = { FeedArticlesError?: FeedArticlesErrorResolvers; FeedArticlesResult?: FeedArticlesResultResolvers; FeedArticlesSuccess?: FeedArticlesSuccessResolvers; + GenerateApiKeyError?: GenerateApiKeyErrorResolvers; + GenerateApiKeyResult?: GenerateApiKeyResultResolvers; + GenerateApiKeySuccess?: GenerateApiKeySuccessResolvers; GetFollowersError?: GetFollowersErrorResolvers; GetFollowersResult?: GetFollowersResultResolvers; GetFollowersSuccess?: GetFollowersSuccessResolvers; diff --git a/packages/api/src/generated/schema.graphql b/packages/api/src/generated/schema.graphql index 20055efb9..74a59f9de 100644 --- a/packages/api/src/generated/schema.graphql +++ b/packages/api/src/generated/schema.graphql @@ -457,6 +457,20 @@ type FeedArticlesSuccess { pageInfo: PageInfo! } +type GenerateApiKeyError { + errorCodes: [GenerateApiKeyErrorCode!]! +} + +enum GenerateApiKeyErrorCode { + BAD_REQUEST +} + +union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess + +type GenerateApiKeySuccess { + apiKey: String! +} + type GetFollowersError { errorCodes: [GetFollowersErrorCode!]! } @@ -684,6 +698,7 @@ type Mutation { deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult! deleteReaction(id: ID!): DeleteReactionResult! deleteReminder(id: ID!): DeleteReminderResult! + generateApiKey(scope: String): GenerateApiKeyResult! googleLogin(input: GoogleLoginInput!): LoginResult! googleSignup(input: GoogleSignupInput!): GoogleSignupResult! login(input: LoginInput!): LoginResult! diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts new file mode 100644 index 000000000..9b70b59b6 --- /dev/null +++ b/packages/api/src/resolvers/api_key/index.ts @@ -0,0 +1,40 @@ +import { + GenerateApiKeyError, + GenerateApiKeyErrorCode, + GenerateApiKeySuccess, + MutationGenerateApiKeyArgs, +} from '../../generated/graphql' +import { generateApiKey } from '../../utils/auth' +import { analytics } from '../../utils/analytics' +import { env } from '../../env' +import { authorized } from '../../utils/helpers' + +export const generateApiKeyResolver = authorized< + GenerateApiKeySuccess, + GenerateApiKeyError, + MutationGenerateApiKeyArgs +>((_, { scope }, { claims }) => { + try { + console.log('generateApiKeyResolver', scope) + + analytics.track({ + userId: claims.uid, + event: 'generate_api_key', + properties: { + scope, + env: env.server.apiEnv, + }, + }) + + const apiKey = generateApiKey({ + iat: new Date().getTime(), + scope: scope || 'all', + uid: claims.uid, + }) + + return { apiKey } + } catch (error) { + console.error(error) + return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] } + } +}) diff --git a/packages/api/src/resolvers/function_resolvers.ts b/packages/api/src/resolvers/function_resolvers.ts index 2186f701b..000ccf22e 100644 --- a/packages/api/src/resolvers/function_resolvers.ts +++ b/packages/api/src/resolvers/function_resolvers.ts @@ -79,6 +79,7 @@ import { generateUploadFilePathName, } from '../utils/uploads' import { getPageById, getPageByParam } from '../elastic' +import { generateApiKeyResolver } from './api_key' /* eslint-disable @typescript-eslint/naming-convention */ type ResultResolveType = { @@ -138,6 +139,7 @@ export const functionResolvers = { login: loginResolver, signup: signupResolver, setLabels: setLabelsResolver, + generateApiKey: generateApiKeyResolver, }, Query: { me: getMeUserResolver, @@ -529,4 +531,5 @@ export const functionResolvers = { ...resultResolveTypeResolver('Login'), ...resultResolveTypeResolver('Signup'), ...resultResolveTypeResolver('SetLabels'), + ...resultResolveTypeResolver('GenerateApiKey'), } diff --git a/packages/api/src/resolvers/types.ts b/packages/api/src/resolvers/types.ts index d9590f5fc..723346ff2 100644 --- a/packages/api/src/resolvers/types.ts +++ b/packages/api/src/resolvers/types.ts @@ -20,6 +20,7 @@ export interface Claims { uid: string iat: number userRole?: string + scope?: string // scope is used for api key like page:search } export type ClaimsToSet = { diff --git a/packages/api/src/schema.ts b/packages/api/src/schema.ts index c31bf5838..f18a3786b 100755 --- a/packages/api/src/schema.ts +++ b/packages/api/src/schema.ts @@ -1379,6 +1379,20 @@ const schema = gql` NOT_FOUND } + union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError + + type GenerateApiKeySuccess { + apiKey: String! + } + + type GenerateApiKeyError { + errorCodes: [GenerateApiKeyErrorCode!]! + } + + enum GenerateApiKeyErrorCode { + BAD_REQUEST + } + # Mutations type Mutation { googleLogin(input: GoogleLoginInput!): LoginResult! @@ -1439,6 +1453,7 @@ const schema = gql` login(input: LoginInput!): LoginResult! signup(input: SignupInput!): SignupResult! setLabels(input: SetLabelsInput!): SetLabelsResult! + generateApiKey(scope: String): GenerateApiKeyResult! } # FIXME: remove sort from feedArticles after all cahced tabs are closed diff --git a/packages/api/src/utils/auth.ts b/packages/api/src/utils/auth.ts index d397f1685..e86eb80eb 100644 --- a/packages/api/src/utils/auth.ts +++ b/packages/api/src/utils/auth.ts @@ -1,4 +1,7 @@ import * as bcrypt from 'bcryptjs' +import * as jwt from 'jsonwebtoken' +import { env } from '../env' +import { Claims } from '../resolvers/types' export const hashPassword = (password: string) => { return bcrypt.hashSync(password, 10) @@ -7,3 +10,7 @@ export const hashPassword = (password: string) => { export const comparePassword = (password: string, hash: string) => { return bcrypt.compareSync(password, hash) } + +export const generateApiKey = (claims: Claims): string => { + return jwt.sign(claims, env.server.jwtSecret) +} diff --git a/packages/api/test/resolvers/api_key.test.ts b/packages/api/test/resolvers/api_key.test.ts new file mode 100644 index 000000000..8045b7ad7 --- /dev/null +++ b/packages/api/test/resolvers/api_key.test.ts @@ -0,0 +1,47 @@ +import { User } from '../../src/entity/user' +import { createTestUser, deleteTestUser } from '../db' +import { graphqlRequest, request } from '../util' +import { expect } from 'chai' + +describe('generate api key', () => { + const username = 'fake_user' + + let authToken: string + let user: User + + before(async () => { + // create test user and login + user = await createTestUser(username) + const res = await request + .post('/local/debug/fake-user-login') + .send({ fakeEmail: user.email }) + + authToken = res.body.authToken + }) + + after(async () => { + // clean up + await deleteTestUser(username) + }) + + it('should return api key which could be used to make api calls', async () => { + const query = ` + mutation { + generateApiKey { + ... on GenerateApiKeySuccess { + apiKey + } + ... on GenerateApiKeyError { + errorCodes + } + } + } + ` + const response = await graphqlRequest(query, authToken).expect(200) + + expect(response.body.data.generateApiKey.apiKey).to.be.a('string') + + const apiKey = response.body.data.generateApiKey.apiKey + return graphqlRequest(query, apiKey).expect(200) + }) +})