Repository navigation
Expand file tree
/
Copy pathauthSecretStore.ts
More file actions
162 lines (139 loc) · 5.91 KB
/
Copy pathauthSecretStore.ts
File metadata and controls
162 lines (139 loc) · 5.91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import {
Filters,
logger,
type AdminUser,
type HttpExtra,
type IAdminForth,
} from 'adminforth';
import { UNKNOWN_CLIENT } from './clientInfo.js';
import { KeyedLock } from './keyedLock.js';
import type { AuthSecret, AuthSecretRepository } from './repositories/authSecret.js';
import type { McpClientInfo } from './types.js';
export const SECRET_PREFIX = 'afmcp_';
type AdminUserWithExecutor = AdminUser & { executedBy?: string };
export interface OAuthGrant {
id: string;
userId: string;
clientId: string;
clientName: string;
readOnly: boolean;
}
export interface AuthenticatedMcpSecret {
adminUser: AdminUserWithExecutor;
client: McpClientInfo | null;
name: string;
readOnly: boolean;
recordId: string;
}
function hashSecret(secret: string): string {
return createHash('sha256').update(secret).digest('hex');
}
export class McpAuthSecretStore {
private readonly refreshLock = new KeyedLock();
constructor(
private readonly adminforth: IAdminForth,
private readonly repository: AuthSecretRepository,
) {}
async list(adminUser: AdminUser) {
const authSecrets = await this.repository.listByUser(adminUser.pk!);
return authSecrets.map(({ secretHash, userId, ...authSecret }) => authSecret);
}
async create(name: string, readOnly: boolean, adminUser: AdminUser, extra: HttpExtra) {
const secret = `${SECRET_PREFIX}${randomBytes(32).toString('base64url')}`;
const result = await this.repository.create({
id: randomUUID(),
name,
secretHash: hashSecret(secret),
userId: adminUser.pk!,
readOnly,
oauthClientId: null,
}, adminUser, extra);
return result.error ? { error: result.error } : { secret };
}
async revoke(id: string, adminUser: AdminUser, extra: HttpExtra) {
return await this.repository.deleteByIdAndUser(id, adminUser, extra) ?? { error: 'MCP auth secret not found' };
}
async authenticate(secret: string): Promise<AuthenticatedMcpSecret | null> {
const authSecret = await this.repository.findBySecretHash(hashSecret(secret));
return authSecret ? this.toAuthenticated(authSecret) : null;
}
/**
* Stores the connection an MCP client got through OAuth as an auth secret record, so it is listed and revoked
* like personal secrets. The record id is the authorization code id, which makes every code redeemable once.
*/
async createOAuthGrant(grant: OAuthGrant, refreshToken: string): Promise<{ error?: string }> {
if (await this.repository.findById(grant.id)) {
return { error: 'Authorization code was already used' };
}
const adminUser = await this.loadAdminUser(grant.userId);
if (!adminUser) return { error: 'User of the authorization code no longer exists' };
const result = await this.repository.create({
id: grant.id,
name: grant.clientName,
secretHash: hashSecret(refreshToken),
userId: grant.userId,
readOnly: grant.readOnly,
oauthClientId: grant.clientId,
}, adminUser, {} as HttpExtra);
return result.error ? { error: result.error } : {};
}
/**
* Swaps the refresh token of a grant for a new one and returns the grant user id. The caller has verified the
* token signature, so a token that is not the current one was issued for this grant before and is used again:
* two parties hold it, a client and whoever stole it, and which one is the attacker is unknown, so the whole
* grant is revoked (OAuth 2.1 refresh token rotation). The lock keeps two concurrent refreshes with one token
* from both passing, within this process.
*/
async rotateRefreshToken(
grantId: string,
refreshToken: string,
clientId: string,
newRefreshToken: string,
): Promise<string | null> {
return this.refreshLock.run(grantId, async () => {
const grant = await this.repository.findById(grantId);
if (grant?.oauthClientId !== clientId) return null;
if (grant.secretHash !== hashSecret(refreshToken)) {
logger.warn(`AdminForthMcpPlugin: a rotated refresh token of OAuth grant ${grantId} was used again, revoking the grant`);
await this.repository.deleteById(grantId);
return null;
}
await this.repository.updateSecretHash(grantId, hashSecret(newRefreshToken));
return grant.userId;
});
}
/** Access tokens are checked against their grant on every request, so revoking the grant applies at once. */
async authenticateOAuthGrant(grantId: string, userId: string): Promise<AuthenticatedMcpSecret | null> {
const grant = await this.repository.findById(grantId);
return grant?.userId === userId && grant.oauthClientId ? this.toAuthenticated(grant) : null;
}
private async loadAdminUser(pk: string): Promise<AdminUser | null> {
const auth = this.adminforth.config.auth!;
const usersResource = this.adminforth.config.resources.find(
(resource) => resource.resourceId === auth.usersResourceId,
)!;
const userPrimaryKeyField = usersResource.columns.find((column) => column.primaryKey)!.name;
const dbUser = await this.adminforth.resource(usersResource.resourceId).get(
Filters.EQ(userPrimaryKeyField, pk),
);
return dbUser ? { pk, username: dbUser[auth.usernameField], dbUser } : null;
}
private async toAuthenticated(authSecret: AuthSecret): Promise<AuthenticatedMcpSecret | null> {
const adminUser = await this.loadAdminUser(authSecret.userId);
if (!adminUser) return null;
return {
adminUser,
client: authSecret.lastUsedByAgent,
name: authSecret.name,
readOnly: authSecret.readOnly,
recordId: authSecret.id,
};
}
touch(recordId: string, client: McpClientInfo): void {
const knownClient = client.client === UNKNOWN_CLIENT ? undefined : client;
void this.repository.updateUsage(recordId, knownClient).catch((error) => {
logger.error(`AdminForthMcpPlugin: failed to update MCP auth secret usage: ${String(error)}`);
});
}
}