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:
DarkSky
2026-07-12 19:30:44 +08:00
committed by GitHub
parent abf37d3dfa
commit 9b81c6debd
57 changed files with 2180 additions and 1213 deletions
@@ -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;
}
+4 -29
View File
@@ -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),
});
}
}