From 49e8f339d4191db17738ec8d9d897dee10dcf04e Mon Sep 17 00:00:00 2001 From: Yii Date: Thu, 31 Jul 2025 13:55:10 +0800 Subject: [PATCH] feat(server): support access token (#13372) ## Summary by CodeRabbit * **New Features** * Introduced user access tokens, enabling users to generate, list, and revoke personal access tokens via the GraphQL API. * Added GraphQL mutations and queries for managing access tokens, including token creation (with optional expiration), listing, and revocation. * Implemented authentication support for private API endpoints using access tokens in addition to session cookies. * **Bug Fixes** * None. * **Tests** * Added comprehensive tests for access token creation, listing, revocation, expiration handling, and authentication using tokens. * **Chores** * Updated backend models, schema, and database migrations to support access token functionality. --- .../20250730104457_access_token/migration.sql | 20 +++++ packages/backend/server/schema.prisma | 15 ++++ .../server/src/__tests__/auth/guard.spec.ts | 15 ++++ .../src/__tests__/mocks/access-token.mock.ts | 27 ++++++ .../server/src/__tests__/mocks/index.ts | 2 + packages/backend/server/src/app.module.ts | 4 +- .../server/src/core/access-token/index.ts | 8 ++ .../server/src/core/access-token/resolver.ts | 73 +++++++++++++++ .../backend/server/src/core/auth/guard.ts | 49 +++++++++-- .../backend/server/src/core/auth/service.ts | 30 +++++++ .../backend/server/src/core/auth/session.ts | 8 +- packages/backend/server/src/global.d.ts | 1 + .../src/models/__tests__/access-token.spec.ts | 82 +++++++++++++++++ .../backend/server/src/models/access-token.ts | 70 +++++++++++++++ packages/backend/server/src/models/index.ts | 2 + packages/backend/server/src/schema.gql | 24 +++++ .../src/graphql/access-token/create.gql | 9 ++ .../graphql/src/graphql/access-token/list.gql | 8 ++ .../src/graphql/access-token/revoke.gql | 3 + packages/common/graphql/src/graphql/index.ts | 35 ++++++++ packages/common/graphql/src/schema.ts | 88 +++++++++++++++++++ 21 files changed, 564 insertions(+), 9 deletions(-) create mode 100644 packages/backend/server/migrations/20250730104457_access_token/migration.sql create mode 100644 packages/backend/server/src/__tests__/mocks/access-token.mock.ts create mode 100644 packages/backend/server/src/core/access-token/index.ts create mode 100644 packages/backend/server/src/core/access-token/resolver.ts create mode 100644 packages/backend/server/src/models/__tests__/access-token.spec.ts create mode 100644 packages/backend/server/src/models/access-token.ts create mode 100644 packages/common/graphql/src/graphql/access-token/create.gql create mode 100644 packages/common/graphql/src/graphql/access-token/list.gql create mode 100644 packages/common/graphql/src/graphql/access-token/revoke.gql diff --git a/packages/backend/server/migrations/20250730104457_access_token/migration.sql b/packages/backend/server/migrations/20250730104457_access_token/migration.sql new file mode 100644 index 0000000000..7dab64bc46 --- /dev/null +++ b/packages/backend/server/migrations/20250730104457_access_token/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "access_tokens" ( + "id" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL, + "token" VARCHAR NOT NULL, + "user_id" VARCHAR NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expires_at" TIMESTAMPTZ(3), + + CONSTRAINT "access_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "access_tokens_token_key" ON "access_tokens"("token"); + +-- CreateIndex +CREATE INDEX "access_tokens_user_id_idx" ON "access_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "access_tokens" ADD CONSTRAINT "access_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index 93b07c7bb1..b61a3f6766 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -49,6 +49,7 @@ model User { comments Comment[] replies Reply[] commentAttachments CommentAttachment[] @relation("createdCommentAttachments") + AccessToken AccessToken[] @@index([email]) @@map("users") @@ -949,3 +950,17 @@ model CommentAttachment { @@id([workspaceId, docId, key]) @@map("comment_attachments") } + +model AccessToken { + id String @id @default(uuid()) @db.VarChar + name String @db.VarChar + token String @unique @db.VarChar + userId String @map("user_id") @db.VarChar + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + expiresAt DateTime? @map("expires_at") @db.Timestamptz(3) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@map("access_tokens") +} diff --git a/packages/backend/server/src/__tests__/auth/guard.spec.ts b/packages/backend/server/src/__tests__/auth/guard.spec.ts index 6a0ebd0500..934b83f09c 100644 --- a/packages/backend/server/src/__tests__/auth/guard.spec.ts +++ b/packages/backend/server/src/__tests__/auth/guard.spec.ts @@ -96,6 +96,21 @@ test('should be able to visit private api if signed in', async t => { t.is(res.body.user.id, u1.id); }); +test('should be able to visit private api with access token', async t => { + const models = t.context.app.get(Models); + const token = await models.accessToken.create({ + userId: u1.id, + name: 'test', + }); + + const res = await request(server) + .get('/private') + .set('Authorization', `Bearer ${token.token}`) + .expect(HttpStatus.OK); + + t.is(res.body.user.id, u1.id); +}); + test('should be able to parse session cookie', async t => { const spy = Sinon.spy(auth, 'getUserSession'); await request(server) diff --git a/packages/backend/server/src/__tests__/mocks/access-token.mock.ts b/packages/backend/server/src/__tests__/mocks/access-token.mock.ts new file mode 100644 index 0000000000..b1bf3315ba --- /dev/null +++ b/packages/backend/server/src/__tests__/mocks/access-token.mock.ts @@ -0,0 +1,27 @@ +import { faker } from '@faker-js/faker'; +import type { AccessToken } from '@prisma/client'; +import { Prisma } from '@prisma/client'; + +import { Mocker } from './factory'; + +export type MockAccessTokenInput = Omit< + Prisma.AccessTokenUncheckedCreateInput, + 'token' +>; + +export type MockedAccessToken = AccessToken; + +export class MockAccessToken extends Mocker< + MockAccessTokenInput, + MockedAccessToken +> { + override async create(input: MockAccessTokenInput) { + return await this.db.accessToken.create({ + data: { + ...input, + name: input.name ?? faker.lorem.word(), + token: 'ut_' + faker.string.hexadecimal({ length: 37 }), + }, + }); + } +} diff --git a/packages/backend/server/src/__tests__/mocks/index.ts b/packages/backend/server/src/__tests__/mocks/index.ts index bc5b4d4241..d37c1d82d1 100644 --- a/packages/backend/server/src/__tests__/mocks/index.ts +++ b/packages/backend/server/src/__tests__/mocks/index.ts @@ -4,6 +4,7 @@ export * from './user.mock'; export * from './workspace.mock'; export * from './workspace-user.mock'; +import { MockAccessToken } from './access-token.mock'; import { MockCopilotProvider } from './copilot.mock'; import { MockDocMeta } from './doc-meta.mock'; import { MockDocSnapshot } from './doc-snapshot.mock'; @@ -26,6 +27,7 @@ export const Mockers = { DocMeta: MockDocMeta, DocSnapshot: MockDocSnapshot, DocUser: MockDocUser, + AccessToken: MockAccessToken, }; export { MockCopilotProvider, MockEventBus, MockJobQueue, MockMailer }; diff --git a/packages/backend/server/src/app.module.ts b/packages/backend/server/src/app.module.ts index 7e4b716fe0..a6038110e3 100644 --- a/packages/backend/server/src/app.module.ts +++ b/packages/backend/server/src/app.module.ts @@ -28,6 +28,7 @@ import { RedisModule } from './base/redis'; import { StorageProviderModule } from './base/storage'; import { RateLimiterModule } from './base/throttler'; import { WebSocketModule } from './base/websocket'; +import { AccessTokenModule } from './core/access-token'; import { AuthModule } from './core/auth'; import { CommentModule } from './core/comment'; import { ServerConfigModule, ServerConfigResolverModule } from './core/config'; @@ -187,7 +188,8 @@ export function buildAppModule(env: Env) { CaptchaModule, OAuthModule, CustomerIoModule, - CommentModule + CommentModule, + AccessTokenModule ) // doc service only .useIf(() => env.flavors.doc, DocServiceModule) diff --git a/packages/backend/server/src/core/access-token/index.ts b/packages/backend/server/src/core/access-token/index.ts new file mode 100644 index 0000000000..6df3b3fbec --- /dev/null +++ b/packages/backend/server/src/core/access-token/index.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; + +import { AccessTokenResolver } from './resolver'; + +@Module({ + providers: [AccessTokenResolver], +}) +export class AccessTokenModule {} diff --git a/packages/backend/server/src/core/access-token/resolver.ts b/packages/backend/server/src/core/access-token/resolver.ts new file mode 100644 index 0000000000..4bedb252f8 --- /dev/null +++ b/packages/backend/server/src/core/access-token/resolver.ts @@ -0,0 +1,73 @@ +import { + Args, + Field, + InputType, + Mutation, + ObjectType, + Query, + Resolver, +} from '@nestjs/graphql'; + +import { Models } from '../../models'; +import { CurrentUser } from '../auth/session'; + +@ObjectType() +class AccessToken { + @Field() + id!: string; + + @Field() + name!: string; + + @Field() + createdAt!: Date; + + @Field(() => Date, { nullable: true }) + expiresAt!: Date | null; +} + +@ObjectType() +class RevealedAccessToken extends AccessToken { + @Field() + token!: string; +} + +@InputType() +class GenerateAccessTokenInput { + @Field() + name!: string; + + @Field(() => Date, { nullable: true }) + expiresAt!: Date | null; +} + +@Resolver(() => AccessToken) +export class AccessTokenResolver { + constructor(private readonly models: Models) {} + + @Query(() => [AccessToken]) + async accessTokens(@CurrentUser() user: CurrentUser): Promise { + return await this.models.accessToken.list(user.id); + } + + @Mutation(() => RevealedAccessToken) + async generateUserAccessToken( + @CurrentUser() user: CurrentUser, + @Args('input') input: GenerateAccessTokenInput + ): Promise { + return await this.models.accessToken.create({ + userId: user.id, + name: input.name, + expiresAt: input.expiresAt, + }); + } + + @Mutation(() => Boolean) + async revokeUserAccessToken( + @CurrentUser() user: CurrentUser, + @Args('id') id: string + ): Promise { + await this.models.accessToken.revoke(id, user.id); + return true; + } +} diff --git a/packages/backend/server/src/core/auth/guard.ts b/packages/backend/server/src/core/auth/guard.ts index cecbdb0f9a..de77ba27da 100644 --- a/packages/backend/server/src/core/auth/guard.ts +++ b/packages/backend/server/src/core/auth/guard.ts @@ -19,7 +19,7 @@ import { } from '../../base'; import { WEBSOCKET_OPTIONS } from '../../base/websocket'; import { AuthService } from './service'; -import { Session } from './session'; +import { Session, TokenSession } from './session'; const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public'); const INTERNAL_ENTRYPOINT_SYMBOL = Symbol('internal'); @@ -56,10 +56,7 @@ export class AuthGuard implements CanActivate, OnModuleInit { throw new AccessDenied('Invalid internal request'); } - const userSession = await this.signIn(req, res); - if (res && userSession && userSession.expiresAt) { - await this.auth.refreshUserSessionIfNeeded(res, userSession); - } + const authedUser = await this.signIn(req, res); // api is public const isPublic = this.reflector.getAllAndOverride( @@ -71,14 +68,29 @@ export class AuthGuard implements CanActivate, OnModuleInit { return true; } - if (!userSession) { + if (!authedUser) { throw new AuthenticationRequired(); } return true; } - async signIn(req: Request, res?: Response): Promise { + async signIn( + req: Request, + res?: Response + ): Promise { + const userSession = await this.signInWithCookie(req, res); + if (userSession) { + return userSession; + } + + return await this.signInWithAccessToken(req); + } + + async signInWithCookie( + req: Request, + res?: Response + ): Promise { if (req.session) { return req.session; } @@ -87,6 +99,10 @@ export class AuthGuard implements CanActivate, OnModuleInit { const userSession = await this.auth.getUserSessionFromRequest(req, res); if (userSession) { + if (res) { + await this.auth.refreshUserSessionIfNeeded(res, userSession.session); + } + req.session = { ...userSession.session, user: userSession.user, @@ -97,6 +113,25 @@ export class AuthGuard implements CanActivate, OnModuleInit { return null; } + + async signInWithAccessToken(req: Request): Promise { + if (req.token) { + return req.token; + } + + const tokenSession = await this.auth.getTokenSessionFromRequest(req); + + if (tokenSession) { + req.token = { + ...tokenSession.token, + user: tokenSession.user, + }; + + return req.token; + } + + return null; + } } /** diff --git a/packages/backend/server/src/core/auth/service.ts b/packages/backend/server/src/core/auth/service.ts index c50273aa94..90ae504216 100644 --- a/packages/backend/server/src/core/auth/service.ts +++ b/packages/backend/server/src/core/auth/service.ts @@ -264,6 +264,36 @@ export class AuthService implements OnApplicationBootstrap { return session; } + async getTokenSessionFromRequest(req: Request) { + const tokenHeader = req.headers.authorization; + if (!tokenHeader) { + return null; + } + + const tokenValue = extractTokenFromHeader(tokenHeader); + + if (!tokenValue) { + return null; + } + + const token = await this.models.accessToken.getByToken(tokenValue); + + if (token) { + const user = await this.models.user.get(token.userId); + + if (!user) { + return null; + } + + return { + token, + user: sessionUser(user), + }; + } + + return null; + } + async changePassword( id: string, newPassword: string diff --git a/packages/backend/server/src/core/auth/session.ts b/packages/backend/server/src/core/auth/session.ts index bdb93d2391..a09e9c355b 100644 --- a/packages/backend/server/src/core/auth/session.ts +++ b/packages/backend/server/src/core/auth/session.ts @@ -1,5 +1,6 @@ import type { ExecutionContext } from '@nestjs/common'; import { createParamDecorator } from '@nestjs/common'; +import { AccessToken } from '@prisma/client'; import { getRequestResponseFromContext } from '../../base'; import type { User, UserSession } from '../../models'; @@ -40,7 +41,8 @@ import type { User, UserSession } from '../../models'; // oxlint-disable-next-line no-redeclare export const CurrentUser = createParamDecorator( (_: unknown, context: ExecutionContext) => { - return getRequestResponseFromContext(context).req.session?.user; + const req = getRequestResponseFromContext(context).req; + return req.session?.user ?? req.token?.user; } ); @@ -61,3 +63,7 @@ export const Session = createParamDecorator( export type Session = UserSession & { user: CurrentUser; }; + +export type TokenSession = AccessToken & { + user: CurrentUser; +}; diff --git a/packages/backend/server/src/global.d.ts b/packages/backend/server/src/global.d.ts index bd734309ba..9d94ed10a3 100644 --- a/packages/backend/server/src/global.d.ts +++ b/packages/backend/server/src/global.d.ts @@ -1,6 +1,7 @@ declare namespace Express { interface Request { session?: import('./core/auth/session').Session; + token?: import('./core/auth/session').TokenSession; } } diff --git a/packages/backend/server/src/models/__tests__/access-token.spec.ts b/packages/backend/server/src/models/__tests__/access-token.spec.ts new file mode 100644 index 0000000000..95541801a6 --- /dev/null +++ b/packages/backend/server/src/models/__tests__/access-token.spec.ts @@ -0,0 +1,82 @@ +import test from 'ava'; + +import { createModule } from '../../__tests__/create-module'; +import { Mockers } from '../../__tests__/mocks'; +import { Due } from '../../base'; +import { Models } from '..'; + +const module = await createModule(); +const models = module.get(Models); + +test.after.always(async () => { + await module.close(); +}); + +test('should create access token', async t => { + const user = await module.create(Mockers.User); + + const token = await models.accessToken.create({ + userId: user.id, + name: 'test', + }); + + t.is(token.userId, user.id); + t.is(token.name, 'test'); + t.truthy(token.token); + t.truthy(token.createdAt); + t.is(token.expiresAt, null); +}); + +test('should create access token with expiration', async t => { + const user = await module.create(Mockers.User); + + const token = await models.accessToken.create({ + userId: user.id, + name: 'test', + expiresAt: Due.after('30d'), + }); + + t.truthy(token.expiresAt); + t.truthy(token.expiresAt! > new Date()); +}); + +test('should list access tokens without token value', async t => { + const user = await module.create(Mockers.User); + await module.create(Mockers.AccessToken, { userId: user.id }, 3); + + const listed = await models.accessToken.list(user.id); + t.is(listed.length, 3); + // @ts-expect-error not exists + t.is(listed[0].token, undefined); +}); + +test('should be able to revoke access token', async t => { + const user = await module.create(Mockers.User); + const token = await module.create(Mockers.AccessToken, { userId: user.id }); + + await models.accessToken.revoke(token.id, user.id); + + const listed = await models.accessToken.list(user.id); + t.is(listed.length, 0); +}); + +test('should be able to get access token by token value', async t => { + const user = await module.create(Mockers.User); + const token = await module.create(Mockers.AccessToken, { userId: user.id }); + + const found = await models.accessToken.getByToken(token.token); + t.is(found?.id, token.id); + t.is(found?.userId, user.id); + t.is(found?.name, token.name); +}); + +test('should not get expired access token', async t => { + const user = await module.create(Mockers.User); + const token = await module.create(Mockers.AccessToken, { + userId: user.id, + expiresAt: Due.before('1s'), + }); + + const found = await models.accessToken.getByToken(token.token); + t.is(found, null); +}); diff --git a/packages/backend/server/src/models/access-token.ts b/packages/backend/server/src/models/access-token.ts new file mode 100644 index 0000000000..c299f9f1b0 --- /dev/null +++ b/packages/backend/server/src/models/access-token.ts @@ -0,0 +1,70 @@ +import { Injectable } from '@nestjs/common'; + +import { CryptoHelper } from '../base'; +import { BaseModel } from './base'; + +export interface CreateAccessTokenInput { + userId: string; + name: string; + expiresAt?: Date | null; +} + +@Injectable() +export class AccessTokenModel extends BaseModel { + constructor(private readonly crypto: CryptoHelper) { + super(); + } + + async list(userId: string) { + return await this.db.accessToken.findMany({ + select: { + id: true, + name: true, + createdAt: true, + expiresAt: true, + }, + where: { + userId, + }, + }); + } + + async create(input: CreateAccessTokenInput) { + let token = 'ut_' + this.crypto.randomBytes(40).toString('hex'); + token = token.substring(0, 40); + + return await this.db.accessToken.create({ + data: { + token, + ...input, + }, + }); + } + + async revoke(id: string, userId: string) { + await this.db.accessToken.deleteMany({ + where: { + id, + userId, + }, + }); + } + + async getByToken(token: string) { + return await this.db.accessToken.findUnique({ + where: { + token, + OR: [ + { + expiresAt: null, + }, + { + expiresAt: { + gt: new Date(), + }, + }, + ], + }, + }); + } +} diff --git a/packages/backend/server/src/models/index.ts b/packages/backend/server/src/models/index.ts index 77bc6774a9..acca6a68ac 100644 --- a/packages/backend/server/src/models/index.ts +++ b/packages/backend/server/src/models/index.ts @@ -7,6 +7,7 @@ import { import { ModuleRef } from '@nestjs/core'; import { ApplyType } from '../base'; +import { AccessTokenModel } from './access-token'; import { BlobModel } from './blob'; import { CommentModel } from './comment'; import { CommentAttachmentModel } from './comment-attachment'; @@ -54,6 +55,7 @@ const MODELS = { comment: CommentModel, commentAttachment: CommentAttachmentModel, blob: BlobModel, + accessToken: AccessTokenModel, }; type ModelsType = { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 9d72250123..61a011bfd8 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -2,6 +2,13 @@ # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) # ------------------------------------------------------ +type AccessToken { + createdAt: DateTime! + expiresAt: DateTime + id: String! + name: String! +} + input AddContextBlobInput { blobId: String! contextId: String! @@ -812,6 +819,11 @@ input ForkChatSessionInput { workspaceId: String! } +input GenerateAccessTokenInput { + expiresAt: DateTime + name: String! +} + input GrantDocUserRolesInput { docId: String! role: DocRole! @@ -1247,6 +1259,7 @@ type Mutation { """Create a chat session""" forkCopilotSession(options: ForkChatSessionInput!): String! generateLicenseKey(sessionId: String!): String! + generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken! grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean! grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean! @@ -1302,6 +1315,7 @@ type Mutation { revokeMember(userId: String!, workspaceId: String!): Boolean! revokePublicDoc(docId: String!, workspaceId: String!): DocType! revokePublicPage(docId: String!, workspaceId: String!): DocType! @deprecated(reason: "use revokePublicDoc instead") + revokeUserAccessToken(id: String!): Boolean! sendChangeEmail(callbackUrl: String!, email: String): Boolean! sendChangePasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! sendSetPasswordEmail(callbackUrl: String!, email: String @deprecated(reason: "fetched from signed in user")): Boolean! @@ -1536,6 +1550,8 @@ type PublicUserType { } type Query { + accessTokens: [AccessToken!]! + """get the whole app configuration""" appConfig: JSONObject! @@ -1685,6 +1701,14 @@ input ReplyUpdateInput { id: ID! } +type RevealedAccessToken { + createdAt: DateTime! + expiresAt: DateTime + id: String! + name: String! + token: String! +} + input RevokeDocUserRoleInput { docId: String! userId: String! diff --git a/packages/common/graphql/src/graphql/access-token/create.gql b/packages/common/graphql/src/graphql/access-token/create.gql new file mode 100644 index 0000000000..00b5e0f039 --- /dev/null +++ b/packages/common/graphql/src/graphql/access-token/create.gql @@ -0,0 +1,9 @@ +mutation generateUserAccessToken($input: GenerateAccessTokenInput!) { + generateUserAccessToken(input: $input) { + id + name + token + createdAt + expiresAt + } +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/access-token/list.gql b/packages/common/graphql/src/graphql/access-token/list.gql new file mode 100644 index 0000000000..e3ebeeec8d --- /dev/null +++ b/packages/common/graphql/src/graphql/access-token/list.gql @@ -0,0 +1,8 @@ +query listUserAccessTokens { + accessTokens { + id + name + createdAt + expiresAt + } +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/access-token/revoke.gql b/packages/common/graphql/src/graphql/access-token/revoke.gql new file mode 100644 index 0000000000..ccd5a126a2 --- /dev/null +++ b/packages/common/graphql/src/graphql/access-token/revoke.gql @@ -0,0 +1,3 @@ +mutation revokeUserAccessToken($id: String!) { + revokeUserAccessToken(id: $id) +} \ No newline at end of file diff --git a/packages/common/graphql/src/graphql/index.ts b/packages/common/graphql/src/graphql/index.ts index 42f62c4475..7baed69040 100644 --- a/packages/common/graphql/src/graphql/index.ts +++ b/packages/common/graphql/src/graphql/index.ts @@ -70,6 +70,41 @@ export const licenseBodyFragment = `fragment licenseBody on License { validatedAt variant }`; +export const generateUserAccessTokenMutation = { + id: 'generateUserAccessTokenMutation' as const, + op: 'generateUserAccessToken', + query: `mutation generateUserAccessToken($input: GenerateAccessTokenInput!) { + generateUserAccessToken(input: $input) { + id + name + token + createdAt + expiresAt + } +}`, +}; + +export const listUserAccessTokensQuery = { + id: 'listUserAccessTokensQuery' as const, + op: 'listUserAccessTokens', + query: `query listUserAccessTokens { + accessTokens { + id + name + createdAt + expiresAt + } +}`, +}; + +export const revokeUserAccessTokenMutation = { + id: 'revokeUserAccessTokenMutation' as const, + op: 'revokeUserAccessToken', + query: `mutation revokeUserAccessToken($id: String!) { + revokeUserAccessToken(id: $id) +}`, +}; + export const adminServerConfigQuery = { id: 'adminServerConfigQuery' as const, op: 'adminServerConfig', diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 6342e79fce..c37e0d79c1 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -37,6 +37,14 @@ export interface Scalars { Upload: { input: File; output: File }; } +export interface AccessToken { + __typename?: 'AccessToken'; + createdAt: Scalars['DateTime']['output']; + expiresAt: Maybe; + id: Scalars['String']['output']; + name: Scalars['String']['output']; +} + export interface AddContextBlobInput { blobId: Scalars['String']['input']; contextId: Scalars['String']['input']; @@ -985,6 +993,11 @@ export interface ForkChatSessionInput { workspaceId: Scalars['String']['input']; } +export interface GenerateAccessTokenInput { + expiresAt?: InputMaybe; + name: Scalars['String']['input']; +} + export interface GrantDocUserRolesInput { docId: Scalars['String']['input']; role: DocRole; @@ -1396,6 +1409,7 @@ export interface Mutation { /** Create a chat session */ forkCopilotSession: Scalars['String']['output']; generateLicenseKey: Scalars['String']['output']; + generateUserAccessToken: RevealedAccessToken; grantDocUserRoles: Scalars['Boolean']['output']; grantMember: Scalars['Boolean']['output']; /** import users */ @@ -1443,6 +1457,7 @@ export interface Mutation { revokePublicDoc: DocType; /** @deprecated use revokePublicDoc instead */ revokePublicPage: DocType; + revokeUserAccessToken: Scalars['Boolean']['output']; sendChangeEmail: Scalars['Boolean']['output']; sendChangePasswordEmail: Scalars['Boolean']['output']; sendSetPasswordEmail: Scalars['Boolean']['output']; @@ -1650,6 +1665,10 @@ export interface MutationGenerateLicenseKeyArgs { sessionId: Scalars['String']['input']; } +export interface MutationGenerateUserAccessTokenArgs { + input: GenerateAccessTokenInput; +} + export interface MutationGrantDocUserRolesArgs { input: GrantDocUserRolesInput; } @@ -1790,6 +1809,10 @@ export interface MutationRevokePublicPageArgs { workspaceId: Scalars['String']['input']; } +export interface MutationRevokeUserAccessTokenArgs { + id: Scalars['String']['input']; +} + export interface MutationSendChangeEmailArgs { callbackUrl: Scalars['String']['input']; email?: InputMaybe; @@ -2094,6 +2117,7 @@ export interface PublicUserType { export interface Query { __typename?: 'Query'; + accessTokens: Array; /** get the whole app configuration */ appConfig: Scalars['JSONObject']['output']; /** Apply updates to a doc using LLM and return the merged markdown. */ @@ -2288,6 +2312,15 @@ export interface ReplyUpdateInput { id: Scalars['ID']['input']; } +export interface RevealedAccessToken { + __typename?: 'RevealedAccessToken'; + createdAt: Scalars['DateTime']['output']; + expiresAt: Maybe; + id: Scalars['String']['output']; + name: Scalars['String']['output']; + token: Scalars['String']['output']; +} + export interface RevokeDocUserRoleInput { docId: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -3010,6 +3043,46 @@ export interface TokenType { token: Scalars['String']['output']; } +export type GenerateUserAccessTokenMutationVariables = Exact<{ + input: GenerateAccessTokenInput; +}>; + +export type GenerateUserAccessTokenMutation = { + __typename?: 'Mutation'; + generateUserAccessToken: { + __typename?: 'RevealedAccessToken'; + id: string; + name: string; + token: string; + createdAt: string; + expiresAt: string | null; + }; +}; + +export type ListUserAccessTokensQueryVariables = Exact<{ + [key: string]: never; +}>; + +export type ListUserAccessTokensQuery = { + __typename?: 'Query'; + accessTokens: Array<{ + __typename?: 'AccessToken'; + id: string; + name: string; + createdAt: string; + expiresAt: string | null; + }>; +}; + +export type RevokeUserAccessTokenMutationVariables = Exact<{ + id: Scalars['String']['input']; +}>; + +export type RevokeUserAccessTokenMutation = { + __typename?: 'Mutation'; + revokeUserAccessToken: boolean; +}; + export type AdminServerConfigQueryVariables = Exact<{ [key: string]: never }>; export type AdminServerConfigQuery = { @@ -6184,6 +6257,11 @@ export type GrantWorkspaceTeamMemberMutation = { }; export type Queries = + | { + name: 'listUserAccessTokensQuery'; + variables: ListUserAccessTokensQueryVariables; + response: ListUserAccessTokensQuery; + } | { name: 'adminServerConfigQuery'; variables: AdminServerConfigQueryVariables; @@ -6536,6 +6614,16 @@ export type Queries = }; export type Mutations = + | { + name: 'generateUserAccessTokenMutation'; + variables: GenerateUserAccessTokenMutationVariables; + response: GenerateUserAccessTokenMutation; + } + | { + name: 'revokeUserAccessTokenMutation'; + variables: RevokeUserAccessTokenMutationVariables; + response: RevokeUserAccessTokenMutation; + } | { name: 'createChangePasswordUrlMutation'; variables: CreateChangePasswordUrlMutationVariables;