feat(server): support access token (#13372)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Yii
2025-07-31 13:55:10 +08:00
committed by GitHub
parent feb42e34be
commit 49e8f339d4
21 changed files with 564 additions and 9 deletions

View File

@@ -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;

View File

@@ -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")
}

View File

@@ -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)

View File

@@ -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 }),
},
});
}
}

View File

@@ -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 };

View File

@@ -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)

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AccessTokenResolver } from './resolver';
@Module({
providers: [AccessTokenResolver],
})
export class AccessTokenModule {}

View File

@@ -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<AccessToken[]> {
return await this.models.accessToken.list(user.id);
}
@Mutation(() => RevealedAccessToken)
async generateUserAccessToken(
@CurrentUser() user: CurrentUser,
@Args('input') input: GenerateAccessTokenInput
): Promise<RevealedAccessToken> {
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<boolean> {
await this.models.accessToken.revoke(id, user.id);
return true;
}
}

View File

@@ -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<boolean>(
@@ -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<Session | null> {
async signIn(
req: Request,
res?: Response
): Promise<Session | TokenSession | null> {
const userSession = await this.signInWithCookie(req, res);
if (userSession) {
return userSession;
}
return await this.signInWithAccessToken(req);
}
async signInWithCookie(
req: Request,
res?: Response
): Promise<Session | null> {
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<TokenSession | null> {
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;
}
}
/**

View File

@@ -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

View File

@@ -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;
};

View File

@@ -1,6 +1,7 @@
declare namespace Express {
interface Request {
session?: import('./core/auth/session').Session;
token?: import('./core/auth/session').TokenSession;
}
}

View File

@@ -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);
});

View File

@@ -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(),
},
},
],
},
});
}
}

View File

@@ -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 = {

View File

@@ -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!