Add expiration date to api key generation (#495)

* Add exp to jwt token

* Add expiredAt in generate api key input

* Check exp in claim

* Add tests
This commit is contained in:
Hongbo Wu 2022-04-28 09:09:40 +08:00 committed by GitHub
parent 6440709e20
commit d1c2721b95
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 115 additions and 20 deletions

View file

@ -69,7 +69,10 @@ export type Article = {
siteIcon?: Maybe<Scalars['String']>;
siteName?: Maybe<Scalars['String']>;
slug: Scalars['String'];
subscription?: Maybe<Scalars['String']>;
title: Scalars['String'];
unsubHttpUrl?: Maybe<Scalars['String']>;
unsubMailTo?: Maybe<Scalars['String']>;
uploadFileId?: Maybe<Scalars['ID']>;
url: Scalars['String'];
};
@ -530,6 +533,11 @@ export enum GenerateApiKeyErrorCode {
BadRequest = 'BAD_REQUEST'
}
export type GenerateApiKeyInput = {
expiredAt?: InputMaybe<Scalars['Date']>;
scope?: InputMaybe<Scalars['String']>;
};
export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess;
export type GenerateApiKeySuccess = {
@ -885,7 +893,7 @@ export type MutationDeleteReminderArgs = {
export type MutationGenerateApiKeyArgs = {
scope?: InputMaybe<Scalars['String']>;
input: GenerateApiKeyInput;
};
@ -1373,7 +1381,10 @@ export type SearchItem = {
readingProgressPercent?: Maybe<Scalars['Float']>;
shortId?: Maybe<Scalars['String']>;
slug: Scalars['String'];
subscription?: Maybe<Scalars['String']>;
title: Scalars['String'];
unsubHttpUrl?: Maybe<Scalars['String']>;
unsubMailTo?: Maybe<Scalars['String']>;
uploadFileId?: Maybe<Scalars['ID']>;
url: Scalars['String'];
};
@ -2139,6 +2150,7 @@ export type ResolversTypes = {
Float: ResolverTypeWrapper<Scalars['Float']>;
GenerateApiKeyError: ResolverTypeWrapper<GenerateApiKeyError>;
GenerateApiKeyErrorCode: GenerateApiKeyErrorCode;
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversTypes['GenerateApiKeyError'] | ResolversTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: ResolverTypeWrapper<GenerateApiKeySuccess>;
GetFollowersError: ResolverTypeWrapper<GetFollowersError>;
@ -2421,6 +2433,7 @@ export type ResolversParentTypes = {
FeedArticlesSuccess: FeedArticlesSuccess;
Float: Scalars['Float'];
GenerateApiKeyError: GenerateApiKeyError;
GenerateApiKeyInput: GenerateApiKeyInput;
GenerateApiKeyResult: ResolversParentTypes['GenerateApiKeyError'] | ResolversParentTypes['GenerateApiKeySuccess'];
GenerateApiKeySuccess: GenerateApiKeySuccess;
GetFollowersError: GetFollowersError;
@ -2636,7 +2649,10 @@ export type ArticleResolvers<ContextType = ResolverContext, ParentType extends R
siteIcon?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
siteName?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
subscription?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
@ -3154,7 +3170,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
deleteNewsletterEmail?: Resolver<ResolversTypes['DeleteNewsletterEmailResult'], ParentType, ContextType, RequireFields<MutationDeleteNewsletterEmailArgs, 'newsletterEmailId'>>;
deleteReaction?: Resolver<ResolversTypes['DeleteReactionResult'], ParentType, ContextType, RequireFields<MutationDeleteReactionArgs, 'id'>>;
deleteReminder?: Resolver<ResolversTypes['DeleteReminderResult'], ParentType, ContextType, RequireFields<MutationDeleteReminderArgs, 'id'>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, Partial<MutationGenerateApiKeyArgs>>;
generateApiKey?: Resolver<ResolversTypes['GenerateApiKeyResult'], ParentType, ContextType, RequireFields<MutationGenerateApiKeyArgs, 'input'>>;
googleLogin?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationGoogleLoginArgs, 'input'>>;
googleSignup?: Resolver<ResolversTypes['GoogleSignupResult'], ParentType, ContextType, RequireFields<MutationGoogleSignupArgs, 'input'>>;
login?: Resolver<ResolversTypes['LoginResult'], ParentType, ContextType, RequireFields<MutationLoginArgs, 'input'>>;
@ -3362,7 +3378,10 @@ export type SearchItemResolvers<ContextType = ResolverContext, ParentType extend
readingProgressPercent?: Resolver<Maybe<ResolversTypes['Float']>, ParentType, ContextType>;
shortId?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
slug?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
subscription?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
title?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
unsubHttpUrl?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
unsubMailTo?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
uploadFileId?: Resolver<Maybe<ResolversTypes['ID']>, ParentType, ContextType>;
url?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;

View file

@ -50,7 +50,10 @@ type Article {
siteIcon: String
siteName: String
slug: String!
subscription: String
title: String!
unsubHttpUrl: String
unsubMailTo: String
uploadFileId: ID
url: String!
}
@ -465,6 +468,11 @@ enum GenerateApiKeyErrorCode {
BAD_REQUEST
}
input GenerateApiKeyInput {
expiredAt: Date
scope: String
}
union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess
type GenerateApiKeySuccess {
@ -697,7 +705,7 @@ type Mutation {
deleteNewsletterEmail(newsletterEmailId: ID!): DeleteNewsletterEmailResult!
deleteReaction(id: ID!): DeleteReactionResult!
deleteReminder(id: ID!): DeleteReminderResult!
generateApiKey(scope: String): GenerateApiKeyResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
googleLogin(input: GoogleLoginInput!): LoginResult!
googleSignup(input: GoogleSignupInput!): GoogleSignupResult!
login(input: LoginInput!): LoginResult!
@ -982,7 +990,10 @@ type SearchItem {
readingProgressPercent: Float
shortId: String
slug: String!
subscription: String
title: String!
unsubHttpUrl: String
unsubMailTo: String
uploadFileId: ID
url: String!
}

View file

@ -13,25 +13,28 @@ export const generateApiKeyResolver = authorized<
GenerateApiKeySuccess,
GenerateApiKeyError,
MutationGenerateApiKeyArgs
>((_, { scope }, { claims }) => {
>((_, { input: { scope, expiredAt } }, { claims }) => {
try {
console.log('generateApiKeyResolver', scope)
console.log('generateApiKeyResolver', scope, expiredAt)
const exp = expiredAt ? new Date(expiredAt).getTime() / 1000 : null
const apiKey = generateApiKey({
iat: new Date().getTime(),
scope: scope || 'all',
uid: claims.uid,
...(exp && { exp }),
})
analytics.track({
userId: claims.uid,
event: 'generate_api_key',
properties: {
scope,
expiredAt: exp,
env: env.server.apiEnv,
},
})
const apiKey = generateApiKey({
iat: new Date().getTime(),
scope: scope || 'all',
uid: claims.uid,
})
return { apiKey }
} catch (error) {
console.error(error)

View file

@ -21,6 +21,7 @@ export interface Claims {
iat: number
userRole?: string
scope?: string // scope is used for api key like page:search
exp?: number
}
export type ClaimsToSet = {

View file

@ -1383,6 +1383,11 @@ const schema = gql`
NOT_FOUND
}
input GenerateApiKeyInput {
scope: String
expiredAt: Date
}
union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError
type GenerateApiKeySuccess {
@ -1562,11 +1567,11 @@ const schema = gql`
login(input: LoginInput!): LoginResult!
signup(input: SignupInput!): SignupResult!
setLabels(input: SetLabelsInput!): SetLabelsResult!
generateApiKey(scope: String): GenerateApiKeyResult!
generateApiKey(input: GenerateApiKeyInput!): GenerateApiKeyResult!
unsubscribe(name: String!): UnsubscribeResult!
}
# FIXME: remove sort from feedArticles after all cahced tabs are closed
# FIXME: remove sort from feedArticles after all cached tabs are closed
# FIXME: sharedOnly is legacy
type Query {
hello: String

View file

@ -2,12 +2,33 @@ import { User } from '../../src/entity/user'
import { createTestUser, deleteTestUser } from '../db'
import { graphqlRequest, request } from '../util'
import { expect } from 'chai'
import supertest from 'supertest'
const testAPIKey = (apiKey: string): supertest.Test => {
const query = `
query {
articles(first: 1) {
... on ArticlesSuccess {
edges {
cursor
}
}
... on ArticlesError {
errorCodes
}
}
}
`
return graphqlRequest(query, apiKey)
}
describe('generate api key', () => {
const username = 'fake_user'
let authToken: string
let user: User
let query: string
let expiredAt: string
before(async () => {
// create test user and login
@ -24,10 +45,12 @@ describe('generate api key', () => {
await deleteTestUser(username)
})
it('should return api key which could be used to make api calls', async () => {
const query = `
beforeEach(() => {
query = `
mutation {
generateApiKey {
generateApiKey(input: {
expiredAt: "${expiredAt}"
}) {
... on GenerateApiKeySuccess {
apiKey
}
@ -37,11 +60,44 @@ describe('generate api key', () => {
}
}
`
const response = await graphqlRequest(query, authToken).expect(200)
})
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
context('when no expiredAt is specified', () => {
before(() => {
expiredAt = ''
})
const apiKey = response.body.data.generateApiKey.apiKey
return graphqlRequest(query, apiKey).expect(200)
it('should generate an api key with no expiration date', async () => {
const response = await graphqlRequest(query, authToken)
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200)
})
})
context('when api key is not expired', () => {
before(() => {
expiredAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
})
it('should generate an api key', async () => {
const response = await graphqlRequest(query, authToken)
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200)
})
})
context('when api key is expired', () => {
before(() => {
expiredAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString()
})
it('should generate an expired api key', async () => {
const response = await graphqlRequest(query, authToken)
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(500)
})
})
})