mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-04 02:49:57 +08:00
feat(core): improve mcp management (#15221)
#### PR Dependency Tree * **PR #15221** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added MCP credential management (create/reveal, list, rotate, revoke) with expiration and status tracking. * Introduced read-only vs read/write access modes, with read/write tooling enabled only when permitted. * Added workspace MCP credential configuration UI, including token reveal and setup generation. * Added MCP credential GraphQL APIs to back the UI. * **Changes** * Replaced legacy access-token support with MCP credentials across authentication and realtime updates. * **Bug Fixes** * MCP authentication now reliably rejects revoked, rotated, expired, or disabled-user credentials. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,8 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessTokenResolver, UserAccessTokenResolver } from './resolver';
|
||||
|
||||
@Module({
|
||||
providers: [AccessTokenResolver, UserAccessTokenResolver],
|
||||
})
|
||||
export class AccessTokenModule {}
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
InputType,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
import { AccessToken, RevealedAccessToken } from './types';
|
||||
|
||||
@InputType()
|
||||
class GenerateAccessTokenInput {
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
})
|
||||
async revealedAccessTokens(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<RevealedAccessToken[]> {
|
||||
return await this.models.accessToken.list(user.id, true);
|
||||
}
|
||||
|
||||
@Mutation(() => RevealedAccessToken)
|
||||
async generateUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
async revokeUserAccessToken(
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserAccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
@ResolveField(() => [AccessToken])
|
||||
async accessTokens(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Parent() user: UserType
|
||||
): Promise<AccessToken[]> {
|
||||
if (!currentUser || currentUser.id !== user.id) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
return await this.models.accessToken.list(user.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => [RevealedAccessToken])
|
||||
async revealedAccessTokens(
|
||||
@CurrentUser() currentUser: CurrentUser,
|
||||
@Parent() user: UserType
|
||||
): Promise<RevealedAccessToken[]> {
|
||||
if (!currentUser || currentUser.id !== user.id) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
return await this.models.accessToken.list(user.id, true);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
@ObjectType()
|
||||
export class AccessToken {
|
||||
@Field()
|
||||
id!: string;
|
||||
|
||||
@Field()
|
||||
name!: string;
|
||||
|
||||
@Field()
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
expiresAt!: Date | null;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
export class RevealedAccessToken extends AccessToken {
|
||||
@Field()
|
||||
token!: string;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { AuthSessionService } from './auth-session';
|
||||
import { extractTokenFromHeader } from './input';
|
||||
import { AuthService } from './service';
|
||||
import { AuthSessionPrincipal, Session, TokenSession } from './session';
|
||||
import { AuthSessionPrincipal, Session } from './session';
|
||||
import { AuthSessionHttpError } from './session-exchange';
|
||||
|
||||
const PUBLIC_ENTRYPOINT_SYMBOL = Symbol('public');
|
||||
@@ -41,8 +41,7 @@ const INTERNAL_ACCESS_TOKEN_CLOCK_SKEW_MS = 30 * 1000;
|
||||
|
||||
type AuthenticatedRequestSession =
|
||||
| { type: 'jwt'; session: Session }
|
||||
| { type: 'cookie_session'; session: Session }
|
||||
| { type: 'access_token'; token: TokenSession };
|
||||
| { type: 'cookie_session'; session: Session };
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
@@ -126,11 +125,9 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
req: Request,
|
||||
res?: Response,
|
||||
isPublic = false
|
||||
): Promise<Session | TokenSession | null> {
|
||||
): Promise<Session | null> {
|
||||
const result = await this.resolveRequestSession(req, res, isPublic);
|
||||
return result?.type === 'access_token'
|
||||
? result.token
|
||||
: (result?.session ?? null);
|
||||
return result?.session ?? null;
|
||||
}
|
||||
|
||||
private async resolveRequestSession(
|
||||
@@ -153,11 +150,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
if (bearer) {
|
||||
const token = await this.signInWithAccessToken(req);
|
||||
return token ? { type: 'access_token', token } : null;
|
||||
}
|
||||
|
||||
const session = await this.signInWithCookie(req, res, isPublic);
|
||||
return session ? { type: 'cookie_session', session } : null;
|
||||
}
|
||||
@@ -270,23 +262,6 @@ export class AuthGuard implements CanActivate, OnModuleInit {
|
||||
});
|
||||
}
|
||||
|
||||
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 };
|
||||
req.authType = 'access_token';
|
||||
|
||||
return req.token;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private getVersionRange(versionRange: string): semver.Range | null {
|
||||
if (this.cachedVersionRange.has(versionRange)) {
|
||||
// oxlint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createDevUsers } from './dev';
|
||||
import type { VerifiedIdentity } from './identity';
|
||||
import {
|
||||
CSRF_COOKIE_NAME,
|
||||
extractTokenFromHeader,
|
||||
getSessionOptionsFromRequest,
|
||||
SESSION_COOKIE_NAME,
|
||||
USER_COOKIE_NAME,
|
||||
@@ -277,36 +276,6 @@ 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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -42,7 +41,7 @@ import type { User, UserSession } from '../../models';
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(_: unknown, context: ExecutionContext) => {
|
||||
const req = getRequestResponseFromContext(context).req;
|
||||
return req.session?.user ?? req.token?.user;
|
||||
return req.session?.user;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -70,7 +69,3 @@ export type AuthSessionPrincipal = Session & {
|
||||
authSessionId: string;
|
||||
authenticatedAt: Date;
|
||||
};
|
||||
|
||||
export type TokenSession = AccessToken & {
|
||||
user: CurrentUser;
|
||||
};
|
||||
|
||||
@@ -7,9 +7,8 @@ import { Models } from '../../../models';
|
||||
const test = ava as TestFn<{
|
||||
app: TestingApp;
|
||||
models: Models;
|
||||
allowlistedAdminToken: string;
|
||||
nonAllowlistedAdminToken: string;
|
||||
userToken: string;
|
||||
allowlistedAdminEmail: string;
|
||||
userEmail: string;
|
||||
}>;
|
||||
|
||||
test.before(async t => {
|
||||
@@ -34,38 +33,14 @@ test.beforeEach(async t => {
|
||||
'administrator',
|
||||
'test'
|
||||
);
|
||||
const allowlistedAdminToken = await t.context.models.accessToken.create({
|
||||
userId: allowlistedAdmin.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.allowlistedAdminToken = allowlistedAdminToken.token;
|
||||
|
||||
const nonAllowlistedAdmin = await t.context.models.user.create({
|
||||
email: 'admin2@affine.pro',
|
||||
password: '1',
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
await t.context.models.userFeature.add(
|
||||
nonAllowlistedAdmin.id,
|
||||
'administrator',
|
||||
'test'
|
||||
);
|
||||
const nonAllowlistedAdminToken = await t.context.models.accessToken.create({
|
||||
userId: nonAllowlistedAdmin.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.nonAllowlistedAdminToken = nonAllowlistedAdminToken.token;
|
||||
t.context.allowlistedAdminEmail = allowlistedAdmin.email;
|
||||
|
||||
const user = await t.context.models.user.create({
|
||||
email: 'user@affine.pro',
|
||||
password: '1',
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
const userToken = await t.context.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: 'test',
|
||||
});
|
||||
t.context.userToken = userToken.token;
|
||||
t.context.userEmail = user.email;
|
||||
});
|
||||
|
||||
test.after.always(async t => {
|
||||
@@ -74,16 +49,20 @@ test.after.always(async t => {
|
||||
|
||||
test('should return 404 for non-admin user', async t => {
|
||||
await t.context.app
|
||||
.GET('/api/queue')
|
||||
.set('Authorization', `Bearer ${t.context.userToken}`)
|
||||
.expect(404);
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: t.context.userEmail, password: '1' })
|
||||
.expect(200);
|
||||
await t.context.app.GET('/api/queue').expect(404);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
test('should allow allowlisted admin', async t => {
|
||||
await t.context.app
|
||||
.POST('/api/auth/sign-in')
|
||||
.send({ email: t.context.allowlistedAdminEmail, password: '1' })
|
||||
.expect(200);
|
||||
await t.context.app
|
||||
.GET('/api/queue')
|
||||
.set('Authorization', `Bearer ${t.context.allowlistedAdminToken}`)
|
||||
.expect(200)
|
||||
.expect('Content-Type', /text\/html/);
|
||||
t.pass();
|
||||
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
@@ -298,7 +297,6 @@ test('room helpers produce stable realtime room names', t => {
|
||||
t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants');
|
||||
t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile');
|
||||
t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings');
|
||||
t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens');
|
||||
t.is(
|
||||
realtimeTranscriptTaskRoom('space', 'task'),
|
||||
'copilot:transcript:space:task'
|
||||
@@ -775,16 +773,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
userFeature: {
|
||||
list: async () => ['administrator'],
|
||||
},
|
||||
accessToken: {
|
||||
list: async () => [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
new UserRealtimeProvider(models as never, registry).onModuleInit();
|
||||
@@ -814,10 +802,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
registry.getTopic('user.settings.changed').room(user, {}),
|
||||
realtimeUserSettingsRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.access-tokens.changed').room(user, {}),
|
||||
realtimeUserAccessTokensRoom('u1')
|
||||
);
|
||||
t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), {
|
||||
settings: {
|
||||
receiveInvitationEmail: true,
|
||||
@@ -825,19 +809,6 @@ test('user realtime provider snapshots private profile settings and access token
|
||||
receiveCommentEmail: true,
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry.getRequest('user.access-tokens.get').handle(user, {}),
|
||||
{
|
||||
tokens: [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('new realtime providers publish changed events from domain events', t => {
|
||||
@@ -894,13 +865,6 @@ test('new realtime providers publish changed events from domain events', t => {
|
||||
userId: 'u2',
|
||||
});
|
||||
|
||||
const userProvider = new UserRealtimeProvider(
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
userProvider.onUserAccessTokenCreated({ userId: 'u1' });
|
||||
|
||||
t.deepEqual(
|
||||
published.map(args => args[0]),
|
||||
[
|
||||
@@ -909,7 +873,6 @@ test('new realtime providers publish changed events from domain events', t => {
|
||||
'workspace.invite-link.changed',
|
||||
'doc.share-state.changed',
|
||||
'doc.grants.changed',
|
||||
'user.access-tokens.changed',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
@@ -31,7 +31,6 @@ export {
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserQuotaStateRoom,
|
||||
realtimeUserRoom,
|
||||
|
||||
@@ -9,7 +9,6 @@ export const REALTIME_GATEWAY_REQUIRED_REQUESTS = [
|
||||
'doc.grants.get',
|
||||
'user.profile.get',
|
||||
'user.settings.get',
|
||||
'user.access-tokens.get',
|
||||
'notification.count.get',
|
||||
'comment.changes.get',
|
||||
'workspace.embedding.progress.get',
|
||||
@@ -27,7 +26,6 @@ export const REALTIME_GATEWAY_REQUIRED_TOPICS = [
|
||||
'doc.grants.changed',
|
||||
'user.profile.changed',
|
||||
'user.settings.changed',
|
||||
'user.access-tokens.changed',
|
||||
'notification.count.changed',
|
||||
'comment.changed',
|
||||
'workspace.embedding.progress.changed',
|
||||
|
||||
@@ -72,7 +72,3 @@ export function realtimeUserProfileRoom(userId: string) {
|
||||
export function realtimeUserSettingsRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'settings');
|
||||
}
|
||||
|
||||
export function realtimeUserAccessTokensRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'access-tokens');
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ export class SpaceSyncGateway
|
||||
|
||||
private attachPresenceUserId(client: Socket): string | null {
|
||||
const request = client.request as Request;
|
||||
const userId = request.session?.user.id ?? request.token?.user.id;
|
||||
const userId = request.session?.user.id;
|
||||
if (typeof userId !== 'string' || !userId) {
|
||||
this.logger.warn(
|
||||
`Unable to resolve authenticated user id for socket ${client.id}`
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
AccessTokenSnapshot,
|
||||
CurrentUserProfileSnapshot,
|
||||
UserSettingsSnapshot,
|
||||
} from '@affine/realtime';
|
||||
@@ -14,7 +13,6 @@ import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
} from '../realtime/rooms';
|
||||
@@ -85,27 +83,6 @@ export class UserRealtimeProvider
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.access-tokens.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
tokens: await this.getAccessTokens(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.access-tokens.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserAccessTokensRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
@@ -125,16 +102,6 @@ export class UserRealtimeProvider
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.created', { suppressError: true })
|
||||
onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) {
|
||||
this.publishAccessTokens(userId, 'access-token-created');
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.revoked', { suppressError: true })
|
||||
onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) {
|
||||
this.publishAccessTokens(userId, 'access-token-revoked');
|
||||
}
|
||||
|
||||
private async getProfile(
|
||||
userId: string
|
||||
): Promise<CurrentUserProfileSnapshot> {
|
||||
@@ -166,22 +133,4 @@ export class UserRealtimeProvider
|
||||
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
|
||||
return await this.models.userSettings.get(userId);
|
||||
}
|
||||
|
||||
private async getAccessTokens(
|
||||
userId: string
|
||||
): Promise<AccessTokenSnapshot[]> {
|
||||
const tokens = await this.models.accessToken.list(userId);
|
||||
return tokens.map(token => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
createdAt: token.createdAt.toISOString(),
|
||||
expiresAt: token.expiresAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private publishAccessTokens(userId: string, reason: string) {
|
||||
this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, {
|
||||
room: realtimeUserAccessTokensRoom(userId),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user