@ -5,7 +5,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
/* eslint-disable @typescript-eslint/require-await */
|
||||
import { ContextFunction } from 'apollo-server-core'
|
||||
import { Claims, ClaimsToSet, ResolverContext } from './resolvers/types'
|
||||
import { ClaimsToSet, ResolverContext } from './resolvers/types'
|
||||
import { SetClaimsRole } from './utils/dictionary'
|
||||
import Knex, { Transaction } from 'knex'
|
||||
import { ExpressContext } from 'apollo-server-express/dist/ApolloServer'
|
||||
@ -24,6 +24,7 @@ import ScalarResolvers from './scalars'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import { createPubSubClient } from './datalayer/pubsub'
|
||||
import { initModels } from './server'
|
||||
import { getClaimsByToken } from './utils/auth'
|
||||
|
||||
const signToken = promisify(jwt.sign)
|
||||
const logger = buildLogger('app.dispatch')
|
||||
@ -38,18 +39,13 @@ const contextFunc: ContextFunction<ExpressContext, ResolverContext> = async ({
|
||||
req,
|
||||
res,
|
||||
}) => {
|
||||
let claims: Claims | undefined
|
||||
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
|
||||
logger.info(`handling gql request`, {
|
||||
query: req.body.query,
|
||||
variables: req.body.variables,
|
||||
})
|
||||
|
||||
if (token && jwt.verify(token, env.server.jwtSecret)) {
|
||||
claims = jwt.decode(token) as Claims
|
||||
}
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
const claims = await getClaimsByToken(token)
|
||||
|
||||
async function setClaims(
|
||||
tx: Transaction,
|
||||
|
||||
37
packages/api/src/entity/api_key.ts
Normal file
37
packages/api/src/entity/api_key.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm'
|
||||
import { User } from './user'
|
||||
|
||||
@Entity()
|
||||
export class ApiKey {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User
|
||||
|
||||
@Column('text')
|
||||
name!: string
|
||||
|
||||
@Column('text')
|
||||
key!: string
|
||||
|
||||
@Column('text', { array: true })
|
||||
scopes?: string[]
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Date
|
||||
|
||||
@Column('timestamp')
|
||||
expiresAt!: Date
|
||||
|
||||
@Column('timestamp', { nullable: true })
|
||||
usedAt!: Date | null
|
||||
}
|
||||
@ -34,6 +34,34 @@ export type AddPopularReadSuccess = {
|
||||
pageId: Scalars['String'];
|
||||
};
|
||||
|
||||
export type ApiKey = {
|
||||
__typename?: 'ApiKey';
|
||||
createdAt: Scalars['Date'];
|
||||
expiresAt: Scalars['Date'];
|
||||
id: Scalars['ID'];
|
||||
key?: Maybe<Scalars['String']>;
|
||||
name: Scalars['String'];
|
||||
scopes?: Maybe<Array<Scalars['String']>>;
|
||||
usedAt?: Maybe<Scalars['Date']>;
|
||||
};
|
||||
|
||||
export type ApiKeysError = {
|
||||
__typename?: 'ApiKeysError';
|
||||
errorCodes: Array<ApiKeysErrorCode>;
|
||||
};
|
||||
|
||||
export enum ApiKeysErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type ApiKeysResult = ApiKeysError | ApiKeysSuccess;
|
||||
|
||||
export type ApiKeysSuccess = {
|
||||
__typename?: 'ApiKeysSuccess';
|
||||
apiKeys: Array<ApiKey>;
|
||||
};
|
||||
|
||||
export type ArchiveLinkError = {
|
||||
__typename?: 'ArchiveLinkError';
|
||||
errorCodes: Array<ArchiveLinkErrorCode>;
|
||||
@ -570,19 +598,22 @@ export type GenerateApiKeyError = {
|
||||
};
|
||||
|
||||
export enum GenerateApiKeyErrorCode {
|
||||
BadRequest = 'BAD_REQUEST'
|
||||
AlreadyExists = 'ALREADY_EXISTS',
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type GenerateApiKeyInput = {
|
||||
expiredAt?: InputMaybe<Scalars['Date']>;
|
||||
scope?: InputMaybe<Scalars['String']>;
|
||||
expiresAt: Scalars['Date'];
|
||||
name: Scalars['String'];
|
||||
scopes?: InputMaybe<Array<Scalars['String']>>;
|
||||
};
|
||||
|
||||
export type GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess;
|
||||
|
||||
export type GenerateApiKeySuccess = {
|
||||
__typename?: 'GenerateApiKeySuccess';
|
||||
apiKey: Scalars['String'];
|
||||
apiKey: ApiKey;
|
||||
};
|
||||
|
||||
export type GetFollowersError = {
|
||||
@ -843,6 +874,7 @@ export type Mutation = {
|
||||
logOut: LogOutResult;
|
||||
mergeHighlight: MergeHighlightResult;
|
||||
reportItem: ReportItemResult;
|
||||
revokeApiKey: RevokeApiKeyResult;
|
||||
saveArticleReadingProgress: SaveArticleReadingProgressResult;
|
||||
saveFile: SaveResult;
|
||||
savePage: SaveResult;
|
||||
@ -977,6 +1009,11 @@ export type MutationReportItemArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationRevokeApiKeyArgs = {
|
||||
id: Scalars['ID'];
|
||||
};
|
||||
|
||||
|
||||
export type MutationSaveArticleReadingProgressArgs = {
|
||||
input: SaveArticleReadingProgressInput;
|
||||
};
|
||||
@ -1192,6 +1229,7 @@ export type Profile = {
|
||||
|
||||
export type Query = {
|
||||
__typename?: 'Query';
|
||||
apiKeys: ApiKeysResult;
|
||||
article: ArticleResult;
|
||||
articles: ArticlesResult;
|
||||
articleSavingRequest: ArticleSavingRequestResult;
|
||||
@ -1365,6 +1403,24 @@ export enum ReportType {
|
||||
Spam = 'SPAM'
|
||||
}
|
||||
|
||||
export type RevokeApiKeyError = {
|
||||
__typename?: 'RevokeApiKeyError';
|
||||
errorCodes: Array<RevokeApiKeyErrorCode>;
|
||||
};
|
||||
|
||||
export enum RevokeApiKeyErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED'
|
||||
}
|
||||
|
||||
export type RevokeApiKeyResult = RevokeApiKeyError | RevokeApiKeySuccess;
|
||||
|
||||
export type RevokeApiKeySuccess = {
|
||||
__typename?: 'RevokeApiKeySuccess';
|
||||
apiKey: ApiKey;
|
||||
};
|
||||
|
||||
export type SaveArticleReadingProgressError = {
|
||||
__typename?: 'SaveArticleReadingProgressError';
|
||||
errorCodes: Array<SaveArticleReadingProgressErrorCode>;
|
||||
@ -2272,6 +2328,11 @@ export type ResolversTypes = {
|
||||
AddPopularReadErrorCode: AddPopularReadErrorCode;
|
||||
AddPopularReadResult: ResolversTypes['AddPopularReadError'] | ResolversTypes['AddPopularReadSuccess'];
|
||||
AddPopularReadSuccess: ResolverTypeWrapper<AddPopularReadSuccess>;
|
||||
ApiKey: ResolverTypeWrapper<ApiKey>;
|
||||
ApiKeysError: ResolverTypeWrapper<ApiKeysError>;
|
||||
ApiKeysErrorCode: ApiKeysErrorCode;
|
||||
ApiKeysResult: ResolversTypes['ApiKeysError'] | ResolversTypes['ApiKeysSuccess'];
|
||||
ApiKeysSuccess: ResolverTypeWrapper<ApiKeysSuccess>;
|
||||
ArchiveLinkError: ResolverTypeWrapper<ArchiveLinkError>;
|
||||
ArchiveLinkErrorCode: ArchiveLinkErrorCode;
|
||||
ArchiveLinkInput: ArchiveLinkInput;
|
||||
@ -2444,6 +2505,10 @@ export type ResolversTypes = {
|
||||
ReportItemInput: ReportItemInput;
|
||||
ReportItemResult: ResolverTypeWrapper<ReportItemResult>;
|
||||
ReportType: ReportType;
|
||||
RevokeApiKeyError: ResolverTypeWrapper<RevokeApiKeyError>;
|
||||
RevokeApiKeyErrorCode: RevokeApiKeyErrorCode;
|
||||
RevokeApiKeyResult: ResolversTypes['RevokeApiKeyError'] | ResolversTypes['RevokeApiKeySuccess'];
|
||||
RevokeApiKeySuccess: ResolverTypeWrapper<RevokeApiKeySuccess>;
|
||||
SaveArticleReadingProgressError: ResolverTypeWrapper<SaveArticleReadingProgressError>;
|
||||
SaveArticleReadingProgressErrorCode: SaveArticleReadingProgressErrorCode;
|
||||
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
|
||||
@ -2608,6 +2673,10 @@ export type ResolversParentTypes = {
|
||||
AddPopularReadError: AddPopularReadError;
|
||||
AddPopularReadResult: ResolversParentTypes['AddPopularReadError'] | ResolversParentTypes['AddPopularReadSuccess'];
|
||||
AddPopularReadSuccess: AddPopularReadSuccess;
|
||||
ApiKey: ApiKey;
|
||||
ApiKeysError: ApiKeysError;
|
||||
ApiKeysResult: ResolversParentTypes['ApiKeysError'] | ResolversParentTypes['ApiKeysSuccess'];
|
||||
ApiKeysSuccess: ApiKeysSuccess;
|
||||
ArchiveLinkError: ArchiveLinkError;
|
||||
ArchiveLinkInput: ArchiveLinkInput;
|
||||
ArchiveLinkResult: ResolversParentTypes['ArchiveLinkError'] | ResolversParentTypes['ArchiveLinkSuccess'];
|
||||
@ -2745,6 +2814,9 @@ export type ResolversParentTypes = {
|
||||
ReminderSuccess: ReminderSuccess;
|
||||
ReportItemInput: ReportItemInput;
|
||||
ReportItemResult: ReportItemResult;
|
||||
RevokeApiKeyError: RevokeApiKeyError;
|
||||
RevokeApiKeyResult: ResolversParentTypes['RevokeApiKeyError'] | ResolversParentTypes['RevokeApiKeySuccess'];
|
||||
RevokeApiKeySuccess: RevokeApiKeySuccess;
|
||||
SaveArticleReadingProgressError: SaveArticleReadingProgressError;
|
||||
SaveArticleReadingProgressInput: SaveArticleReadingProgressInput;
|
||||
SaveArticleReadingProgressResult: ResolversParentTypes['SaveArticleReadingProgressError'] | ResolversParentTypes['SaveArticleReadingProgressSuccess'];
|
||||
@ -2891,6 +2963,31 @@ export type AddPopularReadSuccessResolvers<ContextType = ResolverContext, Parent
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ApiKeyResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKey'] = ResolversParentTypes['ApiKey']> = {
|
||||
createdAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
expiresAt?: Resolver<ResolversTypes['Date'], ParentType, ContextType>;
|
||||
id?: Resolver<ResolversTypes['ID'], ParentType, ContextType>;
|
||||
key?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
scopes?: Resolver<Maybe<Array<ResolversTypes['String']>>, ParentType, ContextType>;
|
||||
usedAt?: Resolver<Maybe<ResolversTypes['Date']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ApiKeysErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysError'] = ResolversParentTypes['ApiKeysError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['ApiKeysErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ApiKeysResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysResult'] = ResolversParentTypes['ApiKeysResult']> = {
|
||||
__resolveType: TypeResolveFn<'ApiKeysError' | 'ApiKeysSuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ApiKeysSuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ApiKeysSuccess'] = ResolversParentTypes['ApiKeysSuccess']> = {
|
||||
apiKeys?: Resolver<Array<ResolversTypes['ApiKey']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type ArchiveLinkErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['ArchiveLinkError'] = ResolversParentTypes['ArchiveLinkError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['ArchiveLinkErrorCode']>, ParentType, ContextType>;
|
||||
message?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
@ -3276,7 +3373,7 @@ export type GenerateApiKeyResultResolvers<ContextType = ResolverContext, ParentT
|
||||
};
|
||||
|
||||
export type GenerateApiKeySuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['GenerateApiKeySuccess'] = ResolversParentTypes['GenerateApiKeySuccess']> = {
|
||||
apiKey?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
apiKey?: Resolver<ResolversTypes['ApiKey'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
@ -3482,6 +3579,7 @@ export type MutationResolvers<ContextType = ResolverContext, ParentType extends
|
||||
logOut?: Resolver<ResolversTypes['LogOutResult'], ParentType, ContextType>;
|
||||
mergeHighlight?: Resolver<ResolversTypes['MergeHighlightResult'], ParentType, ContextType, RequireFields<MutationMergeHighlightArgs, 'input'>>;
|
||||
reportItem?: Resolver<ResolversTypes['ReportItemResult'], ParentType, ContextType, RequireFields<MutationReportItemArgs, 'input'>>;
|
||||
revokeApiKey?: Resolver<ResolversTypes['RevokeApiKeyResult'], ParentType, ContextType, RequireFields<MutationRevokeApiKeyArgs, 'id'>>;
|
||||
saveArticleReadingProgress?: Resolver<ResolversTypes['SaveArticleReadingProgressResult'], ParentType, ContextType, RequireFields<MutationSaveArticleReadingProgressArgs, 'input'>>;
|
||||
saveFile?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSaveFileArgs, 'input'>>;
|
||||
savePage?: Resolver<ResolversTypes['SaveResult'], ParentType, ContextType, RequireFields<MutationSavePageArgs, 'input'>>;
|
||||
@ -3567,6 +3665,7 @@ export type ProfileResolvers<ContextType = ResolverContext, ParentType extends R
|
||||
};
|
||||
|
||||
export type QueryResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['Query'] = ResolversParentTypes['Query']> = {
|
||||
apiKeys?: Resolver<ResolversTypes['ApiKeysResult'], ParentType, ContextType>;
|
||||
article?: Resolver<ResolversTypes['ArticleResult'], ParentType, ContextType, RequireFields<QueryArticleArgs, 'slug' | 'username'>>;
|
||||
articles?: Resolver<ResolversTypes['ArticlesResult'], ParentType, ContextType, Partial<QueryArticlesArgs>>;
|
||||
articleSavingRequest?: Resolver<ResolversTypes['ArticleSavingRequestResult'], ParentType, ContextType, RequireFields<QueryArticleSavingRequestArgs, 'id'>>;
|
||||
@ -3633,6 +3732,20 @@ export type ReportItemResultResolvers<ContextType = ResolverContext, ParentType
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RevokeApiKeyErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeyError'] = ResolversParentTypes['RevokeApiKeyError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['RevokeApiKeyErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RevokeApiKeyResultResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeyResult'] = ResolversParentTypes['RevokeApiKeyResult']> = {
|
||||
__resolveType: TypeResolveFn<'RevokeApiKeyError' | 'RevokeApiKeySuccess', ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type RevokeApiKeySuccessResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['RevokeApiKeySuccess'] = ResolversParentTypes['RevokeApiKeySuccess']> = {
|
||||
apiKey?: Resolver<ResolversTypes['ApiKey'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
};
|
||||
|
||||
export type SaveArticleReadingProgressErrorResolvers<ContextType = ResolverContext, ParentType extends ResolversParentTypes['SaveArticleReadingProgressError'] = ResolversParentTypes['SaveArticleReadingProgressError']> = {
|
||||
errorCodes?: Resolver<Array<ResolversTypes['SaveArticleReadingProgressErrorCode']>, ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
@ -4163,6 +4276,10 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
AddPopularReadError?: AddPopularReadErrorResolvers<ContextType>;
|
||||
AddPopularReadResult?: AddPopularReadResultResolvers<ContextType>;
|
||||
AddPopularReadSuccess?: AddPopularReadSuccessResolvers<ContextType>;
|
||||
ApiKey?: ApiKeyResolvers<ContextType>;
|
||||
ApiKeysError?: ApiKeysErrorResolvers<ContextType>;
|
||||
ApiKeysResult?: ApiKeysResultResolvers<ContextType>;
|
||||
ApiKeysSuccess?: ApiKeysSuccessResolvers<ContextType>;
|
||||
ArchiveLinkError?: ArchiveLinkErrorResolvers<ContextType>;
|
||||
ArchiveLinkResult?: ArchiveLinkResultResolvers<ContextType>;
|
||||
ArchiveLinkSuccess?: ArchiveLinkSuccessResolvers<ContextType>;
|
||||
@ -4279,6 +4396,9 @@ export type Resolvers<ContextType = ResolverContext> = {
|
||||
ReminderResult?: ReminderResultResolvers<ContextType>;
|
||||
ReminderSuccess?: ReminderSuccessResolvers<ContextType>;
|
||||
ReportItemResult?: ReportItemResultResolvers<ContextType>;
|
||||
RevokeApiKeyError?: RevokeApiKeyErrorResolvers<ContextType>;
|
||||
RevokeApiKeyResult?: RevokeApiKeyResultResolvers<ContextType>;
|
||||
RevokeApiKeySuccess?: RevokeApiKeySuccessResolvers<ContextType>;
|
||||
SaveArticleReadingProgressError?: SaveArticleReadingProgressErrorResolvers<ContextType>;
|
||||
SaveArticleReadingProgressResult?: SaveArticleReadingProgressResultResolvers<ContextType>;
|
||||
SaveArticleReadingProgressSuccess?: SaveArticleReadingProgressSuccessResolvers<ContextType>;
|
||||
|
||||
@ -16,6 +16,31 @@ type AddPopularReadSuccess {
|
||||
pageId: String!
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
createdAt: Date!
|
||||
expiresAt: Date!
|
||||
id: ID!
|
||||
key: String
|
||||
name: String!
|
||||
scopes: [String!]
|
||||
usedAt: Date
|
||||
}
|
||||
|
||||
type ApiKeysError {
|
||||
errorCodes: [ApiKeysErrorCode!]!
|
||||
}
|
||||
|
||||
enum ApiKeysErrorCode {
|
||||
BAD_REQUEST
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
union ApiKeysResult = ApiKeysError | ApiKeysSuccess
|
||||
|
||||
type ApiKeysSuccess {
|
||||
apiKeys: [ApiKey!]!
|
||||
}
|
||||
|
||||
type ArchiveLinkError {
|
||||
errorCodes: [ArchiveLinkErrorCode!]!
|
||||
message: String!
|
||||
@ -500,18 +525,21 @@ type GenerateApiKeyError {
|
||||
}
|
||||
|
||||
enum GenerateApiKeyErrorCode {
|
||||
ALREADY_EXISTS
|
||||
BAD_REQUEST
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
input GenerateApiKeyInput {
|
||||
expiredAt: Date
|
||||
scope: String
|
||||
expiresAt: Date!
|
||||
name: String!
|
||||
scopes: [String!]
|
||||
}
|
||||
|
||||
union GenerateApiKeyResult = GenerateApiKeyError | GenerateApiKeySuccess
|
||||
|
||||
type GenerateApiKeySuccess {
|
||||
apiKey: String!
|
||||
apiKey: ApiKey!
|
||||
}
|
||||
|
||||
type GetFollowersError {
|
||||
@ -749,6 +777,7 @@ type Mutation {
|
||||
logOut: LogOutResult!
|
||||
mergeHighlight(input: MergeHighlightInput!): MergeHighlightResult!
|
||||
reportItem(input: ReportItemInput!): ReportItemResult!
|
||||
revokeApiKey(id: ID!): RevokeApiKeyResult!
|
||||
saveArticleReadingProgress(input: SaveArticleReadingProgressInput!): SaveArticleReadingProgressResult!
|
||||
saveFile(input: SaveFileInput!): SaveResult!
|
||||
savePage(input: SavePageInput!): SaveResult!
|
||||
@ -856,6 +885,7 @@ type Profile {
|
||||
}
|
||||
|
||||
type Query {
|
||||
apiKeys: ApiKeysResult!
|
||||
article(slug: String!, username: String!): ArticleResult!
|
||||
articles(after: String, first: Int, includePending: Boolean, query: String, sharedOnly: Boolean, sort: SortParams): ArticlesResult!
|
||||
articleSavingRequest(id: ID!): ArticleSavingRequestResult!
|
||||
@ -944,6 +974,22 @@ enum ReportType {
|
||||
SPAM
|
||||
}
|
||||
|
||||
type RevokeApiKeyError {
|
||||
errorCodes: [RevokeApiKeyErrorCode!]!
|
||||
}
|
||||
|
||||
enum RevokeApiKeyErrorCode {
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
union RevokeApiKeyResult = RevokeApiKeyError | RevokeApiKeySuccess
|
||||
|
||||
type RevokeApiKeySuccess {
|
||||
apiKey: ApiKey!
|
||||
}
|
||||
|
||||
type SaveArticleReadingProgressError {
|
||||
errorCodes: [SaveArticleReadingProgressErrorCode!]!
|
||||
}
|
||||
|
||||
@ -1,43 +1,156 @@
|
||||
import {
|
||||
ApiKeysError,
|
||||
ApiKeysErrorCode,
|
||||
ApiKeysSuccess,
|
||||
GenerateApiKeyError,
|
||||
GenerateApiKeyErrorCode,
|
||||
GenerateApiKeySuccess,
|
||||
MutationGenerateApiKeyArgs,
|
||||
MutationRevokeApiKeyArgs,
|
||||
RevokeApiKeyError,
|
||||
RevokeApiKeyErrorCode,
|
||||
RevokeApiKeySuccess,
|
||||
} from '../../generated/graphql'
|
||||
import { generateApiKey } from '../../utils/auth'
|
||||
import { analytics } from '../../utils/analytics'
|
||||
import { env } from '../../env'
|
||||
import { authorized } from '../../utils/helpers'
|
||||
import { getRepository } from '../../entity/utils'
|
||||
import { User } from '../../entity/user'
|
||||
import { ApiKey } from '../../entity/api_key'
|
||||
import { generateApiKey, hashApiKey } from '../../utils/auth'
|
||||
|
||||
export const apiKeysResolver = authorized<ApiKeysSuccess, ApiKeysError>(
|
||||
async (_, __, { claims: { uid }, log }) => {
|
||||
log.info('apiKeysResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [ApiKeysErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const apiKeys = await getRepository(ApiKey).find({
|
||||
select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'],
|
||||
where: { user: { id: uid } },
|
||||
order: { usedAt: 'DESC', createdAt: 'DESC' },
|
||||
})
|
||||
|
||||
return {
|
||||
apiKeys,
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
|
||||
return {
|
||||
errorCodes: [ApiKeysErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const generateApiKeyResolver = authorized<
|
||||
GenerateApiKeySuccess,
|
||||
GenerateApiKeyError,
|
||||
MutationGenerateApiKeyArgs
|
||||
>((_, { input: { scope, expiredAt } }, { claims }) => {
|
||||
>(async (_, { input: { name, expiresAt } }, { claims: { uid }, log }) => {
|
||||
try {
|
||||
console.log('generateApiKeyResolver', scope, expiredAt)
|
||||
log.info('generateApiKeyResolver')
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [GenerateApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const exp = expiredAt ? new Date(expiredAt).getTime() / 1000 : null
|
||||
const apiKey = generateApiKey({
|
||||
iat: new Date().getTime(),
|
||||
scope: scope || 'all',
|
||||
uid: claims.uid,
|
||||
...(exp && { exp }),
|
||||
const existingApiKey = await getRepository(ApiKey).findOneBy({
|
||||
user: { id: uid },
|
||||
name,
|
||||
})
|
||||
if (existingApiKey) {
|
||||
return {
|
||||
errorCodes: [GenerateApiKeyErrorCode.AlreadyExists],
|
||||
}
|
||||
}
|
||||
|
||||
const exp = new Date(expiresAt)
|
||||
const apiKey = generateApiKey()
|
||||
const apiKeyData = await getRepository(ApiKey).save({
|
||||
user: { id: uid },
|
||||
name,
|
||||
key: hashApiKey(apiKey),
|
||||
expiresAt: exp,
|
||||
})
|
||||
|
||||
analytics.track({
|
||||
userId: claims.uid,
|
||||
event: 'generate_api_key',
|
||||
userId: uid,
|
||||
event: 'api_key_generated',
|
||||
properties: {
|
||||
scope,
|
||||
expiredAt: exp,
|
||||
name,
|
||||
expiresAt: exp,
|
||||
env: env.server.apiEnv,
|
||||
},
|
||||
})
|
||||
|
||||
return { apiKey }
|
||||
return {
|
||||
apiKey: {
|
||||
...apiKeyData,
|
||||
key: apiKey,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
||||
return { errorCodes: [GenerateApiKeyErrorCode.BadRequest] }
|
||||
}
|
||||
})
|
||||
|
||||
export const revokeApiKeyResolver = authorized<
|
||||
RevokeApiKeySuccess,
|
||||
RevokeApiKeyError,
|
||||
MutationRevokeApiKeyArgs
|
||||
>(async (_, { id }, { claims: { uid }, log }) => {
|
||||
log.info('RevokeApiKeyResolver')
|
||||
|
||||
try {
|
||||
const user = await getRepository(User).findOneBy({ id: uid })
|
||||
if (!user) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = await getRepository(ApiKey).findOne({
|
||||
where: { id },
|
||||
relations: ['user'],
|
||||
})
|
||||
if (!apiKey) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.NotFound],
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKey.user.id !== uid) {
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.Unauthorized],
|
||||
}
|
||||
}
|
||||
|
||||
const deletedApiKey = await getRepository(ApiKey).remove(apiKey)
|
||||
|
||||
return {
|
||||
apiKey: {
|
||||
...deletedApiKey,
|
||||
id,
|
||||
key: null,
|
||||
},
|
||||
}
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
|
||||
return {
|
||||
errorCodes: [RevokeApiKeyErrorCode.BadRequest],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@ -21,6 +21,8 @@ import {
|
||||
} from './../generated/graphql'
|
||||
|
||||
import {
|
||||
addPopularReadResolver,
|
||||
apiKeysResolver,
|
||||
articleSavingRequestResolver,
|
||||
createArticleResolver,
|
||||
createArticleSavingRequestResolver,
|
||||
@ -33,6 +35,7 @@ import {
|
||||
deleteNewsletterEmailResolver,
|
||||
deleteReminderResolver,
|
||||
deleteWebhookResolver,
|
||||
generateApiKeyResolver,
|
||||
getAllUsersResolver,
|
||||
getArticleResolver,
|
||||
getArticlesResolver,
|
||||
@ -52,6 +55,7 @@ import {
|
||||
newsletterEmailsResolver,
|
||||
reminderResolver,
|
||||
reportItemResolver,
|
||||
revokeApiKeyResolver,
|
||||
saveArticleReadingProgressResolver,
|
||||
saveFileResolver,
|
||||
savePageResolver,
|
||||
@ -80,7 +84,6 @@ import {
|
||||
updateUserResolver,
|
||||
uploadFileRequestResolver,
|
||||
validateUsernameResolver,
|
||||
addPopularReadResolver,
|
||||
webhookResolver,
|
||||
webhooksResolver,
|
||||
} from './index'
|
||||
@ -90,7 +93,6 @@ import {
|
||||
generateUploadFilePathName,
|
||||
} from '../utils/uploads'
|
||||
import { getPageByParam } from '../elastic/pages'
|
||||
import { generateApiKeyResolver } from './api_key'
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
type ResultResolveType = {
|
||||
@ -157,6 +159,7 @@ export const functionResolvers = {
|
||||
addPopularRead: addPopularReadResolver,
|
||||
setWebhook: setWebhookResolver,
|
||||
deleteWebhook: deleteWebhookResolver,
|
||||
revokeApiKey: revokeApiKeyResolver,
|
||||
},
|
||||
Query: {
|
||||
me: getMeUserResolver,
|
||||
@ -178,6 +181,7 @@ export const functionResolvers = {
|
||||
subscriptions: subscriptionsResolver,
|
||||
webhooks: webhooksResolver,
|
||||
webhook: webhookResolver,
|
||||
apiKeys: apiKeysResolver,
|
||||
},
|
||||
User: {
|
||||
async sharedArticles(
|
||||
@ -575,4 +579,6 @@ export const functionResolvers = {
|
||||
...resultResolveTypeResolver('Webhooks'),
|
||||
...resultResolveTypeResolver('DeleteWebhook'),
|
||||
...resultResolveTypeResolver('Webhook'),
|
||||
...resultResolveTypeResolver('ApiKeys'),
|
||||
...resultResolveTypeResolver('RevokeApiKey'),
|
||||
}
|
||||
|
||||
@ -18,3 +18,4 @@ export * from './subscriptions'
|
||||
export * from './update'
|
||||
export * from './popular_reads'
|
||||
export * from './webhooks'
|
||||
export * from './api_key'
|
||||
|
||||
@ -310,7 +310,7 @@ export const loginResolver: ResolverFn<
|
||||
}
|
||||
|
||||
// check if password is correct
|
||||
const validPassword = comparePassword(password, user.password)
|
||||
const validPassword = await comparePassword(password, user.password)
|
||||
if (!validPassword) {
|
||||
return { errorCodes: [LoginErrorCode.InvalidCredentials] }
|
||||
}
|
||||
@ -331,7 +331,7 @@ export const signupResolver: ResolverFn<
|
||||
|
||||
try {
|
||||
// hash password
|
||||
const hashedPassword = hashPassword(password)
|
||||
const hashedPassword = await hashPassword(password)
|
||||
|
||||
const [user, profile] = await createUser({
|
||||
email,
|
||||
|
||||
@ -6,13 +6,12 @@ import express from 'express'
|
||||
import { CreateArticleErrorCode } from './../generated/graphql'
|
||||
import { isSiteBlockedForParse } from './../utils/blocked'
|
||||
import cors from 'cors'
|
||||
import { env } from './../env'
|
||||
import { buildLogger } from './../utils/logger'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { corsConfig } from '../utils/corsConfig'
|
||||
import { createPageSaveRequest } from '../services/create_page_save_request'
|
||||
import { initModels } from '../server'
|
||||
import { kx } from '../datalayer/knex_config'
|
||||
import { getClaimsByToken } from '../utils/auth'
|
||||
|
||||
const logger = buildLogger('app.dispatch')
|
||||
|
||||
@ -26,11 +25,12 @@ export function articleRouter() {
|
||||
}
|
||||
|
||||
const token = req?.cookies?.auth || req?.headers?.authorization
|
||||
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
|
||||
return res.status(401).send({ errorCode: 'UNAUTHORIZED' })
|
||||
const claims = await getClaimsByToken(token)
|
||||
if (!claims) {
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
const { uid } = (jwt.decode(token) || {}) as { uid: string }
|
||||
const { uid } = claims
|
||||
|
||||
logger.info('Article saving request', {
|
||||
body: req.body,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import express from 'express'
|
||||
import { env } from '../../env'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { PageType, UploadFileStatus } from '../../generated/graphql'
|
||||
import {
|
||||
generateUploadFilePathName,
|
||||
@ -17,6 +16,7 @@ import { generateSlug } from '../../utils/helpers'
|
||||
import { createPubSubClient } from '../../datalayer/pubsub'
|
||||
import { ArticleSavingRequestStatus, Page } from '../../elastic/types'
|
||||
import { createPage } from '../../elastic/pages'
|
||||
import { getClaimsByToken } from '../../utils/auth'
|
||||
|
||||
export function pdfAttachmentsRouter() {
|
||||
const router = express.Router()
|
||||
@ -31,7 +31,7 @@ export function pdfAttachmentsRouter() {
|
||||
}
|
||||
|
||||
const token = req?.headers?.authorization
|
||||
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
|
||||
if (!(await getClaimsByToken(token))) {
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
@ -94,7 +94,7 @@ export function pdfAttachmentsRouter() {
|
||||
}
|
||||
|
||||
const token = req?.headers?.authorization
|
||||
if (!token || !jwt.verify(token, env.server.jwtSecret)) {
|
||||
if (!(await getClaimsByToken(token))) {
|
||||
return res.status(401).send('UNAUTHORIZED')
|
||||
}
|
||||
|
||||
|
||||
@ -1415,14 +1415,15 @@ const schema = gql`
|
||||
}
|
||||
|
||||
input GenerateApiKeyInput {
|
||||
scope: String
|
||||
expiredAt: Date
|
||||
name: String!
|
||||
scopes: [String!]
|
||||
expiresAt: Date!
|
||||
}
|
||||
|
||||
union GenerateApiKeyResult = GenerateApiKeySuccess | GenerateApiKeyError
|
||||
|
||||
type GenerateApiKeySuccess {
|
||||
apiKey: String!
|
||||
apiKey: ApiKey!
|
||||
}
|
||||
|
||||
type GenerateApiKeyError {
|
||||
@ -1431,6 +1432,8 @@ const schema = gql`
|
||||
|
||||
enum GenerateApiKeyErrorCode {
|
||||
BAD_REQUEST
|
||||
ALREADY_EXISTS
|
||||
UNAUTHORIZED
|
||||
}
|
||||
|
||||
# Query: search
|
||||
@ -1670,6 +1673,47 @@ const schema = gql`
|
||||
BAD_REQUEST
|
||||
}
|
||||
|
||||
union ApiKeysResult = ApiKeysSuccess | ApiKeysError
|
||||
|
||||
type ApiKeysSuccess {
|
||||
apiKeys: [ApiKey!]!
|
||||
}
|
||||
|
||||
type ApiKey {
|
||||
id: ID!
|
||||
name: String!
|
||||
key: String
|
||||
scopes: [String!]
|
||||
createdAt: Date!
|
||||
expiresAt: Date!
|
||||
usedAt: Date
|
||||
}
|
||||
|
||||
type ApiKeysError {
|
||||
errorCodes: [ApiKeysErrorCode!]!
|
||||
}
|
||||
|
||||
enum ApiKeysErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
}
|
||||
|
||||
union RevokeApiKeyResult = RevokeApiKeySuccess | RevokeApiKeyError
|
||||
|
||||
type RevokeApiKeySuccess {
|
||||
apiKey: ApiKey!
|
||||
}
|
||||
|
||||
type RevokeApiKeyError {
|
||||
errorCodes: [RevokeApiKeyErrorCode!]!
|
||||
}
|
||||
|
||||
enum RevokeApiKeyErrorCode {
|
||||
UNAUTHORIZED
|
||||
BAD_REQUEST
|
||||
NOT_FOUND
|
||||
}
|
||||
|
||||
# Mutations
|
||||
type Mutation {
|
||||
googleLogin(input: GoogleLoginInput!): LoginResult!
|
||||
@ -1737,6 +1781,7 @@ const schema = gql`
|
||||
addPopularRead(name: String!): AddPopularReadResult!
|
||||
setWebhook(input: SetWebhookInput!): SetWebhookResult!
|
||||
deleteWebhook(id: ID!): DeleteWebhookResult!
|
||||
revokeApiKey(id: ID!): RevokeApiKeyResult!
|
||||
}
|
||||
|
||||
# FIXME: remove sort from feedArticles after all cached tabs are closed
|
||||
@ -1778,6 +1823,7 @@ const schema = gql`
|
||||
subscriptions(sort: SortParams): SubscriptionsResult!
|
||||
webhooks: WebhooksResult!
|
||||
webhook(id: ID!): WebhookResult!
|
||||
apiKeys: ApiKeysResult!
|
||||
}
|
||||
`
|
||||
|
||||
|
||||
@ -1,16 +1,81 @@
|
||||
import * as bcrypt from 'bcryptjs'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { Claims } from '../resolvers/types'
|
||||
import { getRepository } from '../entity/utils'
|
||||
import { ApiKey } from '../entity/api_key'
|
||||
import crypto from 'crypto'
|
||||
import * as jwt from 'jsonwebtoken'
|
||||
import { env } from '../env'
|
||||
import { Claims } from '../resolvers/types'
|
||||
|
||||
export const hashPassword = (password: string) => {
|
||||
return bcrypt.hashSync(password, 10)
|
||||
export const hashPassword = async (password: string, salt = 10) => {
|
||||
return bcrypt.hash(password, salt)
|
||||
}
|
||||
|
||||
export const comparePassword = (password: string, hash: string) => {
|
||||
return bcrypt.compareSync(password, hash)
|
||||
export const comparePassword = async (password: string, hash: string) => {
|
||||
return bcrypt.compare(password, hash)
|
||||
}
|
||||
|
||||
export const generateApiKey = (claims: Claims): string => {
|
||||
return jwt.sign(claims, env.server.jwtSecret)
|
||||
export const generateApiKey = (): string => {
|
||||
// TODO: generate random string key
|
||||
return uuidv4()
|
||||
}
|
||||
|
||||
export const hashApiKey = (apiKey: string) => {
|
||||
return crypto.createHash('sha256').update(apiKey).digest('hex')
|
||||
}
|
||||
|
||||
export const claimsFromApiKey = async (key: string): Promise<Claims> => {
|
||||
const hashedKey = hashApiKey(key)
|
||||
const apiKey = await getRepository(ApiKey).findOne({
|
||||
where: {
|
||||
key: hashedKey,
|
||||
},
|
||||
relations: ['user'],
|
||||
})
|
||||
if (!apiKey) {
|
||||
throw new Error('api key not found')
|
||||
}
|
||||
|
||||
const iat = Math.floor(Date.now() / 1000)
|
||||
const exp = Math.floor(new Date(apiKey.expiresAt).getTime() / 1000)
|
||||
if (exp < iat) {
|
||||
throw new Error('api key expired')
|
||||
}
|
||||
|
||||
// update last used
|
||||
await getRepository(ApiKey).update(apiKey.id, { usedAt: new Date() })
|
||||
|
||||
return {
|
||||
uid: apiKey.user.id,
|
||||
iat,
|
||||
exp,
|
||||
}
|
||||
}
|
||||
|
||||
// verify jwt token first
|
||||
// if valid then decode and return claims
|
||||
// if expired then throw error
|
||||
// if not valid then verify api key
|
||||
export const getClaimsByToken = async (
|
||||
token: string | undefined
|
||||
): Promise<Claims | undefined> => {
|
||||
let claims: Claims | undefined
|
||||
|
||||
if (!token) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
jwt.verify(token, env.server.jwtSecret) &&
|
||||
(claims = jwt.decode(token) as Claims)
|
||||
} catch (e) {
|
||||
if (e instanceof jwt.JsonWebTokenError) {
|
||||
console.log(`not a jwt token, checking api key`, { token })
|
||||
claims = await claimsFromApiKey(token)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
return claims
|
||||
}
|
||||
|
||||
@ -12,7 +12,7 @@ describe('Sanitize Directive', () => {
|
||||
let user: User
|
||||
|
||||
before(async () => {
|
||||
const hashedPassword = hashPassword(correctPassword)
|
||||
const hashedPassword = await hashPassword(correctPassword)
|
||||
user = await createTestUser(username, '', hashedPassword)
|
||||
const res = await request
|
||||
.post('/local/debug/fake-user-login')
|
||||
|
||||
@ -22,13 +22,15 @@ const testAPIKey = (apiKey: string): supertest.Test => {
|
||||
return graphqlRequest(query, apiKey)
|
||||
}
|
||||
|
||||
describe('generate api key', () => {
|
||||
describe('Api Key resolver', () => {
|
||||
const username = 'fake_user'
|
||||
|
||||
let authToken: string
|
||||
let user: User
|
||||
let query: string
|
||||
let expiredAt: string
|
||||
let expiresAt: string
|
||||
let name: string
|
||||
let apiKeyId: string
|
||||
|
||||
before(async () => {
|
||||
// create test user and login
|
||||
@ -45,14 +47,18 @@ describe('generate api key', () => {
|
||||
await deleteTestUser(username)
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
query = `
|
||||
describe('generate api key', () => {
|
||||
beforeEach(() => {
|
||||
query = `
|
||||
mutation {
|
||||
generateApiKey(input: {
|
||||
expiredAt: "${expiredAt}"
|
||||
name: "${name}"
|
||||
expiresAt: "${expiresAt}"
|
||||
}) {
|
||||
... on GenerateApiKeySuccess {
|
||||
apiKey
|
||||
apiKey {
|
||||
key
|
||||
}
|
||||
}
|
||||
... on GenerateApiKeyError {
|
||||
errorCodes
|
||||
@ -60,44 +66,146 @@ describe('generate api key', () => {
|
||||
}
|
||||
}
|
||||
`
|
||||
})
|
||||
|
||||
context('when no expiredAt is specified', () => {
|
||||
before(() => {
|
||||
expiredAt = ''
|
||||
})
|
||||
|
||||
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')
|
||||
context('when api key is not expired', () => {
|
||||
before(() => {
|
||||
name = 'test'
|
||||
expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
|
||||
})
|
||||
|
||||
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200)
|
||||
it('should generate an api key', async () => {
|
||||
const response = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(response.body.data.generateApiKey.apiKey.key).to.be.a('string')
|
||||
|
||||
return testAPIKey(response.body.data.generateApiKey.apiKey.key).expect(
|
||||
200
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('when api key is expired', () => {
|
||||
before(() => {
|
||||
name = 'test-expired'
|
||||
expiresAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString()
|
||||
})
|
||||
|
||||
it('should generate an expired api key', async () => {
|
||||
const response = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(response.body.data.generateApiKey.apiKey.key).to.be.a('string')
|
||||
|
||||
return testAPIKey(response.body.data.generateApiKey.apiKey.key).expect(
|
||||
500
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('when api key is not expired', () => {
|
||||
before(() => {
|
||||
expiredAt = new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString()
|
||||
describe('revoke api key', () => {
|
||||
let apiKey: string
|
||||
|
||||
before(async () => {
|
||||
query = `
|
||||
mutation {
|
||||
generateApiKey(input: {
|
||||
name: "test-revoke"
|
||||
expiresAt: "${new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24
|
||||
).toISOString()}"
|
||||
}) {
|
||||
... on GenerateApiKeySuccess {
|
||||
apiKey {
|
||||
id
|
||||
key
|
||||
}
|
||||
}
|
||||
... on GenerateApiKeyError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const response = await graphqlRequest(query, authToken)
|
||||
apiKey = response.body.data.generateApiKey.apiKey.key
|
||||
apiKeyId = response.body.data.generateApiKey.apiKey.id
|
||||
})
|
||||
|
||||
it('should generate an api key', async () => {
|
||||
const response = await graphqlRequest(query, authToken)
|
||||
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
|
||||
it('should revoke an api key', async () => {
|
||||
query = `
|
||||
mutation {
|
||||
revokeApiKey(id: "${apiKeyId}") {
|
||||
... on RevokeApiKeySuccess {
|
||||
apiKey {
|
||||
id
|
||||
}
|
||||
}
|
||||
... on RevokeApiKeyError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(200)
|
||||
const response = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(response.body.data.revokeApiKey.apiKey.id).to.be.a('string')
|
||||
|
||||
return testAPIKey(apiKey).expect(500)
|
||||
})
|
||||
})
|
||||
|
||||
context('when api key is expired', () => {
|
||||
before(() => {
|
||||
expiredAt = new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString()
|
||||
describe('get api keys', () => {
|
||||
before(async () => {
|
||||
name = 'test-get-api-keys'
|
||||
query = `
|
||||
mutation {
|
||||
generateApiKey(input: {
|
||||
name: "${name}"
|
||||
expiresAt: "${new Date(
|
||||
Date.now() + 1000 * 60 * 60 * 24
|
||||
).toISOString()}"
|
||||
}) {
|
||||
... on GenerateApiKeySuccess {
|
||||
apiKey {
|
||||
id
|
||||
key
|
||||
}
|
||||
}
|
||||
... on GenerateApiKeyError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const response = await graphqlRequest(query, authToken)
|
||||
apiKeyId = response.body.data.generateApiKey.apiKey.id
|
||||
})
|
||||
|
||||
it('should generate an expired api key', async () => {
|
||||
const response = await graphqlRequest(query, authToken)
|
||||
expect(response.body.data.generateApiKey.apiKey).to.be.a('string')
|
||||
it('should get api keys', async () => {
|
||||
query = `
|
||||
query {
|
||||
apiKeys {
|
||||
... on ApiKeysSuccess {
|
||||
apiKeys {
|
||||
id
|
||||
name
|
||||
expiresAt
|
||||
usedAt
|
||||
}
|
||||
}
|
||||
... on ApiKeysError {
|
||||
errorCodes
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
return testAPIKey(response.body.data.generateApiKey.apiKey).expect(500)
|
||||
const response = await graphqlRequest(query, authToken).expect(200)
|
||||
expect(response.body.data.apiKeys.apiKeys).to.be.an('array')
|
||||
expect(response.body.data.apiKeys.apiKeys[0].id).to.eql(apiKeyId)
|
||||
expect(response.body.data.apiKeys.apiKeys[0].name).to.eql(name)
|
||||
expect(response.body.data.apiKeys.apiKeys[0].usedAt).to.be.null
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -21,7 +21,7 @@ describe('User API', () => {
|
||||
let anotherUser: User
|
||||
|
||||
before(async () => {
|
||||
const hashedPassword = hashPassword(correctPassword)
|
||||
const hashedPassword = await hashPassword(correctPassword)
|
||||
// create test user and login
|
||||
user = await createTestUser(username, '', hashedPassword)
|
||||
const res = await request
|
||||
|
||||
21
packages/db/migrations/0085.do.api_key.sql
Executable file
21
packages/db/migrations/0085.do.api_key.sql
Executable file
@ -0,0 +1,21 @@
|
||||
-- Type: DO
|
||||
-- Name: api_key
|
||||
-- Description: api_key model
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE omnivore.api_key (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v1mc(),
|
||||
user_id uuid NOT NULL REFERENCES omnivore.user (id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
key text NOT NULL,
|
||||
scopes text[] NOT NULL DEFAULT '{}',
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT current_timestamp,
|
||||
used_at timestamptz,
|
||||
UNIQUE (user_id, name)
|
||||
);
|
||||
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON omnivore.api_key TO omnivore_user;
|
||||
|
||||
COMMIT;
|
||||
9
packages/db/migrations/0085.undo.api_key.sql
Executable file
9
packages/db/migrations/0085.undo.api_key.sql
Executable file
@ -0,0 +1,9 @@
|
||||
-- Type: UNDO
|
||||
-- Name: api_key
|
||||
-- Description: api_key model
|
||||
|
||||
BEGIN;
|
||||
|
||||
DROP TABLE omnivore.api_key;
|
||||
|
||||
COMMIT;
|
||||
Reference in New Issue
Block a user