mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
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:
parent
6440709e20
commit
d1c2721b95
6 changed files with 115 additions and 20 deletions
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue