@payloadcms/plugin-mcp 4.0.0-internal.9496957 → 4.0.0-internal.970009e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.js +0 -7
- package/dist/endpoint/access.d.ts.map +1 -1
- package/dist/endpoint/access.js +15 -3
- package/dist/endpoint/access.js.map +1 -1
- package/dist/endpoint/index.d.ts.map +1 -1
- package/dist/endpoint/index.js +46 -4
- package/dist/endpoint/index.js.map +1 -1
- package/dist/endpoint/oauth.d.ts +39 -0
- package/dist/endpoint/oauth.d.ts.map +1 -0
- package/dist/endpoint/oauth.js +92 -0
- package/dist/endpoint/oauth.js.map +1 -0
- package/dist/endpoint/oauthLoginToken.d.ts +20 -0
- package/dist/endpoint/oauthLoginToken.d.ts.map +1 -0
- package/dist/endpoint/oauthLoginToken.js +158 -0
- package/dist/endpoint/oauthLoginToken.js.map +1 -0
- package/dist/endpoint/oauthServer.d.ts +12 -0
- package/dist/endpoint/oauthServer.d.ts.map +1 -0
- package/dist/endpoint/oauthServer.js +377 -0
- package/dist/endpoint/oauthServer.js.map +1 -0
- package/dist/index.d.ts +10 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -2
- package/dist/index.js.map +1 -1
- package/dist/mcp/buildMcpServer.d.ts +1 -1
- package/dist/mcp/buildMcpServer.d.ts.map +1 -1
- package/dist/mcp/buildMcpServer.js +3 -3
- package/dist/mcp/buildMcpServer.js.map +1 -1
- package/dist/mcp/builtin/collections/authTools.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/authTools.js +4 -3
- package/dist/mcp/builtin/collections/authTools.js.map +1 -1
- package/dist/mcp/builtin/collections/createTool.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/createTool.js +12 -6
- package/dist/mcp/builtin/collections/createTool.js.map +1 -1
- package/dist/mcp/builtin/collections/deleteTool.js +6 -2
- package/dist/mcp/builtin/collections/deleteTool.js.map +1 -1
- package/dist/mcp/builtin/collections/duplicateTool.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/duplicateTool.js +6 -2
- package/dist/mcp/builtin/collections/duplicateTool.js.map +1 -1
- package/dist/mcp/builtin/collections/fileInput.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/fileInput.js +19 -54
- package/dist/mcp/builtin/collections/fileInput.js.map +1 -1
- package/dist/mcp/builtin/collections/findTool.js +8 -4
- package/dist/mcp/builtin/collections/findTool.js.map +1 -1
- package/dist/mcp/builtin/collections/getCollectionSchemaTool.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/getCollectionSchemaTool.js +19 -3
- package/dist/mcp/builtin/collections/getCollectionSchemaTool.js.map +1 -1
- package/dist/mcp/builtin/collections/restoreVersionTool.js.map +1 -1
- package/dist/mcp/builtin/collections/updateTool.d.ts.map +1 -1
- package/dist/mcp/builtin/collections/updateTool.js +21 -8
- package/dist/mcp/builtin/collections/updateTool.js.map +1 -1
- package/dist/mcp/builtin/collections/uploadInstructionsTool.js.map +1 -1
- package/dist/mcp/builtin/globals/findTool.js.map +1 -1
- package/dist/mcp/builtin/globals/getGlobalSchemaTool.d.ts.map +1 -1
- package/dist/mcp/builtin/globals/getGlobalSchemaTool.js +19 -3
- package/dist/mcp/builtin/globals/getGlobalSchemaTool.js.map +1 -1
- package/dist/mcp/builtin/globals/updateTool.js.map +1 -1
- package/dist/mcp/sanitizeMCPConfig.d.ts.map +1 -1
- package/dist/mcp/sanitizeMCPConfig.js +3 -0
- package/dist/mcp/sanitizeMCPConfig.js.map +1 -1
- package/dist/stdio.js +4 -3
- package/dist/stdio.js.map +1 -1
- package/dist/types.d.ts +91 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/utils/toStandardSchema.js.map +1 -1
- package/package.json +23 -26
- package/src/endpoint/access.ts +12 -3
- package/src/endpoint/index.ts +41 -3
- package/src/endpoint/oauth.ts +91 -0
- package/src/endpoint/oauthLoginToken.ts +210 -0
- package/src/endpoint/oauthServer.ts +428 -0
- package/src/index.ts +40 -5
- package/src/mcp/buildMcpServer.ts +5 -5
- package/src/mcp/builtin/collections/authTools.ts +7 -4
- package/src/mcp/builtin/collections/createTool.ts +26 -6
- package/src/mcp/builtin/collections/deleteTool.ts +3 -3
- package/src/mcp/builtin/collections/duplicateTool.ts +3 -2
- package/src/mcp/builtin/collections/fileInput.ts +17 -59
- package/src/mcp/builtin/collections/findTool.ts +4 -4
- package/src/mcp/builtin/collections/getCollectionSchemaTool.ts +12 -1
- package/src/mcp/builtin/collections/restoreVersionTool.ts +1 -1
- package/src/mcp/builtin/collections/updateTool.ts +10 -6
- package/src/mcp/builtin/collections/uploadInstructionsTool.ts +1 -1
- package/src/mcp/builtin/globals/findTool.ts +1 -1
- package/src/mcp/builtin/globals/getGlobalSchemaTool.ts +11 -1
- package/src/mcp/builtin/globals/updateTool.ts +1 -1
- package/src/mcp/sanitizeMCPConfig.ts +1 -0
- package/src/stdio.ts +2 -2
- package/src/types.ts +93 -3
- package/src/utils/toStandardSchema.ts +1 -1
- package/dist/mcp/builtin/collections/fileInput.spec.js +0 -86
- package/dist/mcp/builtin/collections/fileInput.spec.js.map +0 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type { PayloadRequest, SanitizedCollectionConfig } from 'payload'
|
|
2
|
+
|
|
3
|
+
import { randomUUID } from 'crypto'
|
|
4
|
+
import { APIError, getFieldsToSign, jwtSign } from 'payload'
|
|
5
|
+
import { removeExpiredSessions } from 'payload/shared'
|
|
6
|
+
|
|
7
|
+
import type { MCPOAuthTokens } from '../types.js'
|
|
8
|
+
|
|
9
|
+
/** How long a refresh token stays valid without being used, in milliseconds: 30 days. */
|
|
10
|
+
const refreshTokenExpiration = 30 * 24 * 60 * 60 * 1000
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How long a used refresh token is still accepted, in milliseconds. MCP clients that send
|
|
14
|
+
* requests in parallel can refresh twice at once with the same refresh token.
|
|
15
|
+
*/
|
|
16
|
+
const refreshTokenReuseInterval = 30 * 1000
|
|
17
|
+
|
|
18
|
+
type User = NonNullable<PayloadRequest['user']>
|
|
19
|
+
|
|
20
|
+
type Session = NonNullable<User['sessions']>[number]
|
|
21
|
+
|
|
22
|
+
/** The default refresh token. The OAuth server seals it, so clients can't read or change it. */
|
|
23
|
+
type LoginRefreshToken = {
|
|
24
|
+
collection: string
|
|
25
|
+
/** The session's `expiresAt` when this refresh token was issued. Every refresh moves it. */
|
|
26
|
+
expiresAt: number
|
|
27
|
+
sid: string
|
|
28
|
+
userID: number | string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The default `issueToken`: a Payload login token, which Payload's JWT strategy accepts. When the
|
|
33
|
+
* collection uses sessions, the token gets its own login session, and the client gets a refresh
|
|
34
|
+
* token for it.
|
|
35
|
+
*/
|
|
36
|
+
export const issueLoginToken = async ({
|
|
37
|
+
req,
|
|
38
|
+
}: {
|
|
39
|
+
req: PayloadRequest
|
|
40
|
+
}): Promise<MCPOAuthTokens> => {
|
|
41
|
+
const collection = req.payload.collections[req.user!.collection]!.config
|
|
42
|
+
|
|
43
|
+
if (collection.auth.disableLocalStrategy) {
|
|
44
|
+
throw new APIError(
|
|
45
|
+
`MCP clients can't get Payload login tokens for the "${collection.slug}" collection because its local strategy is disabled. Pass oauth.issueToken to the MCP plugin.`,
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const user = (await findUser({ id: req.user!.id, collection, req }))!
|
|
50
|
+
|
|
51
|
+
if (!collection.auth.useSessions) {
|
|
52
|
+
return await signLoginToken({ collection, req, user })
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const session = await saveSession({ collection, req, sid: randomUUID(), user })
|
|
56
|
+
|
|
57
|
+
return await signLoginToken({ collection, req, session, user })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The default `refreshToken`. Every refresh moves the session's expiry, so a refresh token with an
|
|
62
|
+
* older expiry was already used. Outside of parallel refreshes, that means someone copied it, and
|
|
63
|
+
* the session is signed out, which also revokes the client's access token.
|
|
64
|
+
*/
|
|
65
|
+
export const refreshLoginToken = async ({
|
|
66
|
+
refreshToken,
|
|
67
|
+
req,
|
|
68
|
+
}: {
|
|
69
|
+
refreshToken: string
|
|
70
|
+
req: PayloadRequest
|
|
71
|
+
}): Promise<MCPOAuthTokens | null> => {
|
|
72
|
+
const { collection: slug, expiresAt, sid, userID } = JSON.parse(refreshToken) as LoginRefreshToken
|
|
73
|
+
const collection = req.payload.collections[slug]?.config
|
|
74
|
+
const user = collection && (await findUser({ id: userID, collection, req }))
|
|
75
|
+
const session = removeExpiredSessions(user?.sessions ?? []).find(({ id }) => id === sid)
|
|
76
|
+
|
|
77
|
+
// Signed out, expired, or the user was deleted
|
|
78
|
+
if (!collection || !user || !session) {
|
|
79
|
+
return null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sessionExpiresAt = new Date(session.expiresAt).getTime()
|
|
83
|
+
|
|
84
|
+
if (sessionExpiresAt === expiresAt) {
|
|
85
|
+
return await signLoginToken({
|
|
86
|
+
collection,
|
|
87
|
+
req,
|
|
88
|
+
session: await saveSession({ collection, req, sid, user }),
|
|
89
|
+
user,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// The client refreshed twice at once, so give the second request the current tokens
|
|
94
|
+
const refreshedAt = sessionExpiresAt - refreshTokenExpiration
|
|
95
|
+
|
|
96
|
+
if (Date.now() - refreshedAt < refreshTokenReuseInterval) {
|
|
97
|
+
return await signLoginToken({ collection, req, session, user })
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Someone copied the refresh token
|
|
101
|
+
await saveSessions({
|
|
102
|
+
collection,
|
|
103
|
+
req,
|
|
104
|
+
sessions: user.sessions!.filter(({ id }) => id !== sid),
|
|
105
|
+
user,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// The full document, like the one a password login starts its session on
|
|
112
|
+
const findUser = ({
|
|
113
|
+
id,
|
|
114
|
+
collection,
|
|
115
|
+
req,
|
|
116
|
+
}: {
|
|
117
|
+
collection: SanitizedCollectionConfig
|
|
118
|
+
id: number | string
|
|
119
|
+
req: PayloadRequest
|
|
120
|
+
}): Promise<null | User> =>
|
|
121
|
+
req.payload.db.findOne<User>({ collection: collection.slug, req, where: { id: { equals: id } } })
|
|
122
|
+
|
|
123
|
+
/** Adds or extends the session `sid`, so it expires `refreshTokenExpiration` from now. */
|
|
124
|
+
const saveSession = async ({
|
|
125
|
+
collection,
|
|
126
|
+
req,
|
|
127
|
+
sid,
|
|
128
|
+
user,
|
|
129
|
+
}: {
|
|
130
|
+
collection: SanitizedCollectionConfig
|
|
131
|
+
req: PayloadRequest
|
|
132
|
+
sid: string
|
|
133
|
+
user: User
|
|
134
|
+
}): Promise<Session> => {
|
|
135
|
+
const now = new Date()
|
|
136
|
+
const session: Session = {
|
|
137
|
+
id: sid,
|
|
138
|
+
createdAt: user.sessions?.find(({ id }) => id === sid)?.createdAt ?? now,
|
|
139
|
+
expiresAt: new Date(now.getTime() + refreshTokenExpiration),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await saveSessions({
|
|
143
|
+
collection,
|
|
144
|
+
req,
|
|
145
|
+
sessions: [
|
|
146
|
+
...removeExpiredSessions(user.sessions ?? []).filter(({ id }) => id !== sid),
|
|
147
|
+
session,
|
|
148
|
+
],
|
|
149
|
+
user,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
return session
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const saveSessions = async ({
|
|
156
|
+
collection,
|
|
157
|
+
req,
|
|
158
|
+
sessions,
|
|
159
|
+
user,
|
|
160
|
+
}: {
|
|
161
|
+
collection: SanitizedCollectionConfig
|
|
162
|
+
req: PayloadRequest
|
|
163
|
+
sessions: Session[]
|
|
164
|
+
user: User
|
|
165
|
+
}): Promise<void> => {
|
|
166
|
+
await req.payload.db.updateOne({
|
|
167
|
+
id: user.id,
|
|
168
|
+
collection: collection.slug,
|
|
169
|
+
// Prevent updatedAt from being updated when only changing sessions
|
|
170
|
+
data: { ...user, sessions, updatedAt: null },
|
|
171
|
+
req,
|
|
172
|
+
returning: false,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const signLoginToken = async ({
|
|
177
|
+
collection,
|
|
178
|
+
req,
|
|
179
|
+
session,
|
|
180
|
+
user,
|
|
181
|
+
}: {
|
|
182
|
+
collection: SanitizedCollectionConfig
|
|
183
|
+
req: PayloadRequest
|
|
184
|
+
session?: Session
|
|
185
|
+
user: User
|
|
186
|
+
}): Promise<MCPOAuthTokens> => {
|
|
187
|
+
const { token } = await jwtSign({
|
|
188
|
+
fieldsToSign: getFieldsToSign({
|
|
189
|
+
collectionConfig: collection,
|
|
190
|
+
email: user.email!,
|
|
191
|
+
sid: session?.id,
|
|
192
|
+
user,
|
|
193
|
+
}),
|
|
194
|
+
secret: req.payload.secret,
|
|
195
|
+
tokenExpiration: collection.auth.tokenExpiration,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
accessToken: token,
|
|
200
|
+
expiresIn: collection.auth.tokenExpiration,
|
|
201
|
+
refreshToken:
|
|
202
|
+
session &&
|
|
203
|
+
JSON.stringify({
|
|
204
|
+
collection: collection.slug,
|
|
205
|
+
expiresAt: new Date(session.expiresAt).getTime(),
|
|
206
|
+
sid: session.id,
|
|
207
|
+
userID: user.id,
|
|
208
|
+
} satisfies LoginRefreshToken),
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
OAuthClientInformationFull,
|
|
3
|
+
OAuthTokens,
|
|
4
|
+
OpenIdProviderDiscoveryMetadata,
|
|
5
|
+
} from '@modelcontextprotocol/server'
|
|
6
|
+
import type { Endpoint, PayloadHandler, PayloadRequest } from 'payload'
|
|
7
|
+
|
|
8
|
+
import { OAuthErrorCode } from '@modelcontextprotocol/server'
|
|
9
|
+
import { createHash } from 'crypto'
|
|
10
|
+
import { formatAdminURL } from 'payload/shared'
|
|
11
|
+
|
|
12
|
+
import type { MCPBuiltinOAuthConfig, MCPOAuthTokens } from '../types.js'
|
|
13
|
+
|
|
14
|
+
import { getPluginConfig } from '../utils/getPluginConfig.js'
|
|
15
|
+
import { getMCPURL } from './oauth.js'
|
|
16
|
+
import { issueLoginToken, refreshLoginToken } from './oauthLoginToken.js'
|
|
17
|
+
|
|
18
|
+
type Client = { name: string; redirectURIs: string[] }
|
|
19
|
+
|
|
20
|
+
type Consent = {
|
|
21
|
+
clientID: string
|
|
22
|
+
codeChallenge: string
|
|
23
|
+
collection: string
|
|
24
|
+
redirectURI: string
|
|
25
|
+
state: null | string
|
|
26
|
+
userID: number | string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Code = {
|
|
30
|
+
clientID: string
|
|
31
|
+
codeChallenge: string
|
|
32
|
+
redirectURI: string
|
|
33
|
+
tokens: MCPOAuthTokens
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type RefreshToken = {
|
|
37
|
+
clientID: string
|
|
38
|
+
refreshToken: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turns Payload into the OAuth authorization server for MCP clients when `oauth.type` is
|
|
43
|
+
* `'builtin'`. Clients register, the user allows them after logging in to the admin panel, and
|
|
44
|
+
* the client receives the tokens `issueToken` creates.
|
|
45
|
+
*
|
|
46
|
+
* Clients, consent forms, authorization codes, and refresh tokens are encrypted and handed to the
|
|
47
|
+
* client instead of being stored. Only the default tokens write to the database, to the user's
|
|
48
|
+
* login sessions.
|
|
49
|
+
*/
|
|
50
|
+
export const oauthServerEndpoints: Endpoint[] = [
|
|
51
|
+
{
|
|
52
|
+
disableAuth: true,
|
|
53
|
+
handler: (req) => withIssuer(req, (issuer) => metadataResponse(issuer)),
|
|
54
|
+
method: 'get',
|
|
55
|
+
// OpenID discovery is the only metadata location MCP clients check below the issuer's path,
|
|
56
|
+
// which lets this work under /api without a route at the site root
|
|
57
|
+
path: '/mcp/oauth/.well-known/openid-configuration',
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
disableAuth: true,
|
|
61
|
+
handler: (req) => withIssuer(req, () => Response.json({ keys: [] })),
|
|
62
|
+
method: 'get',
|
|
63
|
+
path: '/mcp/oauth/jwks',
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
disableAuth: true,
|
|
67
|
+
handler: (req) => withIssuer(req, () => register(req)),
|
|
68
|
+
method: 'post',
|
|
69
|
+
path: '/mcp/oauth/register',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
handler: (req) => withIssuer(req, () => authorize(req)),
|
|
73
|
+
method: 'get',
|
|
74
|
+
path: '/mcp/oauth/authorize',
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
handler: (req) => withIssuer(req, () => approve(req)),
|
|
78
|
+
method: 'post',
|
|
79
|
+
path: '/mcp/oauth/authorize',
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
disableAuth: true,
|
|
83
|
+
handler: (req) => withIssuer(req, () => token(req)),
|
|
84
|
+
method: 'post',
|
|
85
|
+
path: '/mcp/oauth/token',
|
|
86
|
+
},
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
/** The authorization server URL, or `undefined` when Payload isn't the authorization server. */
|
|
90
|
+
const getIssuer = (req: PayloadRequest): string | undefined =>
|
|
91
|
+
getPluginConfig({ config: req.payload.config }).oauth?.type === 'builtin'
|
|
92
|
+
? `${getMCPURL({ config: req.payload.config, url: req.url! })}/oauth`
|
|
93
|
+
: undefined
|
|
94
|
+
|
|
95
|
+
const withIssuer = (
|
|
96
|
+
req: PayloadRequest,
|
|
97
|
+
handler: (issuer: string) => Promise<Response> | Response,
|
|
98
|
+
): Promise<Response> | Response => {
|
|
99
|
+
const issuer = getIssuer(req)
|
|
100
|
+
return issuer ? handler(issuer) : new Response(null, { status: 404 })
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const metadataResponse = (issuer: string): Response =>
|
|
104
|
+
Response.json(
|
|
105
|
+
{
|
|
106
|
+
authorization_endpoint: `${issuer}/authorize`,
|
|
107
|
+
code_challenge_methods_supported: ['S256'],
|
|
108
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
109
|
+
// Required by OpenID discovery. No ID tokens are issued.
|
|
110
|
+
id_token_signing_alg_values_supported: ['RS256'],
|
|
111
|
+
issuer,
|
|
112
|
+
jwks_uri: `${issuer}/jwks`,
|
|
113
|
+
registration_endpoint: `${issuer}/register`,
|
|
114
|
+
response_types_supported: ['code'],
|
|
115
|
+
subject_types_supported: ['public'],
|
|
116
|
+
token_endpoint: `${issuer}/token`,
|
|
117
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
118
|
+
} satisfies OpenIdProviderDiscoveryMetadata,
|
|
119
|
+
{ headers: { 'Access-Control-Allow-Origin': '*' } },
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
const register: PayloadHandler = async (req) => {
|
|
123
|
+
const { client_name, redirect_uris } = await req.json!().catch(() => ({}))
|
|
124
|
+
|
|
125
|
+
if (
|
|
126
|
+
!Array.isArray(redirect_uris) ||
|
|
127
|
+
!redirect_uris.length ||
|
|
128
|
+
!redirect_uris.every(
|
|
129
|
+
(uri) => typeof uri === 'string' && URL.canParse(uri) && !uri.includes('#'),
|
|
130
|
+
)
|
|
131
|
+
) {
|
|
132
|
+
return Response.json({ error: OAuthErrorCode.InvalidRedirectUri }, { status: 400 })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const client: Client = {
|
|
136
|
+
name: typeof client_name === 'string' ? client_name : 'An MCP client',
|
|
137
|
+
redirectURIs: redirect_uris,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return Response.json(
|
|
141
|
+
{
|
|
142
|
+
client_id: seal({ type: 'client', data: client, req }),
|
|
143
|
+
client_name: client.name,
|
|
144
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
145
|
+
redirect_uris: client.redirectURIs,
|
|
146
|
+
response_types: ['code'],
|
|
147
|
+
token_endpoint_auth_method: 'none',
|
|
148
|
+
} satisfies OAuthClientInformationFull,
|
|
149
|
+
{ status: 201 },
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const authorize: PayloadHandler = (req) => {
|
|
154
|
+
const url = new URL(req.url!)
|
|
155
|
+
const params = url.searchParams
|
|
156
|
+
const clientID = params.get('client_id')
|
|
157
|
+
const client = unseal<Client>({ type: 'client', req, value: clientID })
|
|
158
|
+
const redirectURI = params.get('redirect_uri')
|
|
159
|
+
|
|
160
|
+
// Never redirect to an address the client didn't register
|
|
161
|
+
if (!client || !redirectURI || !client.redirectURIs.includes(redirectURI)) {
|
|
162
|
+
return page({
|
|
163
|
+
content: '<p>Start the sign-in again from your MCP client.</p>',
|
|
164
|
+
status: 400,
|
|
165
|
+
title: 'This sign-in link is invalid',
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const state = params.get('state')
|
|
170
|
+
const codeChallenge = params.get('code_challenge')
|
|
171
|
+
|
|
172
|
+
if (
|
|
173
|
+
params.get('response_type') !== 'code' ||
|
|
174
|
+
params.get('code_challenge_method') !== 'S256' ||
|
|
175
|
+
!codeChallenge
|
|
176
|
+
) {
|
|
177
|
+
return redirect({ params: { error: OAuthErrorCode.InvalidRequest, state }, redirectURI })
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (!req.user) {
|
|
181
|
+
const loginURL = formatAdminURL({ adminRoute: req.payload.config.routes.admin, path: '/login' })
|
|
182
|
+
|
|
183
|
+
return new Response(null, {
|
|
184
|
+
headers: {
|
|
185
|
+
Location: `${loginURL}?redirect=${encodeURIComponent(url.pathname + url.search)}`,
|
|
186
|
+
},
|
|
187
|
+
status: 302,
|
|
188
|
+
})
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const consent: Consent = {
|
|
192
|
+
clientID: clientID!,
|
|
193
|
+
codeChallenge,
|
|
194
|
+
collection: req.user.collection,
|
|
195
|
+
redirectURI,
|
|
196
|
+
state,
|
|
197
|
+
userID: req.user.id,
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return page({
|
|
201
|
+
content: `<p>It will be able to use this app's MCP tools as ${escapeHTML(String(req.user.email ?? req.user.id))}, and you will return to ${escapeHTML(new URL(redirectURI).host || redirectURI)}.</p>
|
|
202
|
+
<form method="post">
|
|
203
|
+
<input name="consent" type="hidden" value="${seal({ type: 'consent', data: consent, expiresIn: 20 * 60 * 60, req })}">
|
|
204
|
+
<button name="decision" type="submit" value="allow">Allow</button>
|
|
205
|
+
<button name="decision" type="submit" value="deny">Deny</button>
|
|
206
|
+
</form>`,
|
|
207
|
+
status: 200,
|
|
208
|
+
title: `Allow ${client.name} to use your account?`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const approve: PayloadHandler = async (req) => {
|
|
213
|
+
const form = new URLSearchParams(await req.text!())
|
|
214
|
+
const consent = unseal<Consent>({ type: 'consent', req, value: form.get('consent') })
|
|
215
|
+
|
|
216
|
+
// The form is only valid for the user it was shown to
|
|
217
|
+
if (!consent || req.user?.id !== consent.userID || req.user.collection !== consent.collection) {
|
|
218
|
+
return page({
|
|
219
|
+
content: '<p>Start the sign-in again from your MCP client.</p>',
|
|
220
|
+
status: 400,
|
|
221
|
+
title: 'This sign-in request expired',
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { clientID, codeChallenge, redirectURI, state } = consent
|
|
226
|
+
|
|
227
|
+
if (form.get('decision') !== 'allow') {
|
|
228
|
+
return redirect({ params: { error: OAuthErrorCode.AccessDenied, state }, redirectURI })
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const code: Code = {
|
|
232
|
+
clientID,
|
|
233
|
+
codeChallenge,
|
|
234
|
+
redirectURI,
|
|
235
|
+
tokens: await getTokenHooks(req).issueToken({ req }),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return redirect({
|
|
239
|
+
params: { code: seal({ type: 'code', data: code, expiresIn: 60, req }), state },
|
|
240
|
+
redirectURI,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const token: PayloadHandler = async (req) => {
|
|
245
|
+
const form = new URLSearchParams(await req.text!())
|
|
246
|
+
const clientID = form.get('client_id')
|
|
247
|
+
const tokens =
|
|
248
|
+
clientID &&
|
|
249
|
+
(form.get('grant_type') === 'refresh_token'
|
|
250
|
+
? await refresh({ clientID, form, req })
|
|
251
|
+
: redeemCode({ clientID, form, req }))
|
|
252
|
+
|
|
253
|
+
if (!clientID || !tokens) {
|
|
254
|
+
return Response.json({ error: OAuthErrorCode.InvalidGrant }, { status: 400 })
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return Response.json(
|
|
258
|
+
{
|
|
259
|
+
access_token: tokens.accessToken,
|
|
260
|
+
expires_in: tokens.expiresIn,
|
|
261
|
+
refresh_token:
|
|
262
|
+
tokens.refreshToken &&
|
|
263
|
+
seal({
|
|
264
|
+
type: 'refresh',
|
|
265
|
+
data: { clientID, refreshToken: tokens.refreshToken } satisfies RefreshToken,
|
|
266
|
+
req,
|
|
267
|
+
}),
|
|
268
|
+
token_type: 'Bearer',
|
|
269
|
+
} satisfies OAuthTokens,
|
|
270
|
+
{ headers: { 'Cache-Control': 'no-store' } },
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const redeemCode = ({
|
|
275
|
+
clientID,
|
|
276
|
+
form,
|
|
277
|
+
req,
|
|
278
|
+
}: {
|
|
279
|
+
clientID: string
|
|
280
|
+
form: URLSearchParams
|
|
281
|
+
req: PayloadRequest
|
|
282
|
+
}): MCPOAuthTokens | undefined => {
|
|
283
|
+
const code = unseal<Code>({ type: 'code', req, value: form.get('code') })
|
|
284
|
+
|
|
285
|
+
if (
|
|
286
|
+
form.get('grant_type') === 'authorization_code' &&
|
|
287
|
+
code?.clientID === clientID &&
|
|
288
|
+
code.redirectURI === form.get('redirect_uri') &&
|
|
289
|
+
// PKCE: only the client that started the sign-in knows the verifier
|
|
290
|
+
createHash('sha256')
|
|
291
|
+
.update(form.get('code_verifier') ?? '')
|
|
292
|
+
.digest('base64url') === code.codeChallenge
|
|
293
|
+
) {
|
|
294
|
+
return code.tokens
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const refresh = async ({
|
|
299
|
+
clientID,
|
|
300
|
+
form,
|
|
301
|
+
req,
|
|
302
|
+
}: {
|
|
303
|
+
clientID: string
|
|
304
|
+
form: URLSearchParams
|
|
305
|
+
req: PayloadRequest
|
|
306
|
+
}): Promise<MCPOAuthTokens | null | undefined> => {
|
|
307
|
+
const refreshToken = unseal<RefreshToken>({
|
|
308
|
+
type: 'refresh',
|
|
309
|
+
req,
|
|
310
|
+
value: form.get('refresh_token'),
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// Only the client a refresh token was issued to can use it
|
|
314
|
+
if (refreshToken?.clientID === clientID) {
|
|
315
|
+
return await getTokenHooks(req).refreshToken?.({
|
|
316
|
+
refreshToken: refreshToken.refreshToken,
|
|
317
|
+
req,
|
|
318
|
+
})
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** `issueToken` and `refreshToken`, or the defaults for Payload login tokens. */
|
|
323
|
+
const getTokenHooks = (req: PayloadRequest) => {
|
|
324
|
+
const oauth = getPluginConfig({ config: req.payload.config }).oauth as MCPBuiltinOAuthConfig
|
|
325
|
+
|
|
326
|
+
return oauth.issueToken
|
|
327
|
+
? { issueToken: oauth.issueToken, refreshToken: oauth.refreshToken }
|
|
328
|
+
: { issueToken: issueLoginToken, refreshToken: refreshLoginToken }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Encrypts data for the client to hold, so it doesn't need to be stored. `type` keeps one kind
|
|
333
|
+
* from being used as another, e.g. a client ID as an authorization code.
|
|
334
|
+
*/
|
|
335
|
+
const seal = ({
|
|
336
|
+
type,
|
|
337
|
+
data,
|
|
338
|
+
expiresIn,
|
|
339
|
+
req,
|
|
340
|
+
}: {
|
|
341
|
+
data: object
|
|
342
|
+
/** Seconds */
|
|
343
|
+
expiresIn?: number
|
|
344
|
+
req: PayloadRequest
|
|
345
|
+
type: string
|
|
346
|
+
}): string =>
|
|
347
|
+
req.payload.encrypt(
|
|
348
|
+
JSON.stringify({ ...data, type, exp: expiresIn && Date.now() + expiresIn * 1000 }),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
const unseal = <T>({
|
|
352
|
+
type,
|
|
353
|
+
req,
|
|
354
|
+
value,
|
|
355
|
+
}: {
|
|
356
|
+
req: PayloadRequest
|
|
357
|
+
type: string
|
|
358
|
+
value: null | string
|
|
359
|
+
}): T | undefined => {
|
|
360
|
+
// Only the v1 format is authenticated. Older formats decrypt anything without an error.
|
|
361
|
+
if (!value?.startsWith('v1:')) {
|
|
362
|
+
return undefined
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
// Previous secrets can be public, like a default secret from source code, so only client IDs
|
|
367
|
+
// accept them. Client IDs grant nothing, and this keeps clients registered when the secret changes.
|
|
368
|
+
const secret = type === 'client' ? undefined : req.payload.config.secret
|
|
369
|
+
const data = JSON.parse(req.payload.decrypt(value, { secret }))
|
|
370
|
+
return data.type === type && (!data.exp || data.exp > Date.now()) ? data : undefined
|
|
371
|
+
} catch {
|
|
372
|
+
return undefined
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const redirect = ({
|
|
377
|
+
params,
|
|
378
|
+
redirectURI,
|
|
379
|
+
}: {
|
|
380
|
+
params: Record<string, null | string>
|
|
381
|
+
redirectURI: string
|
|
382
|
+
}): Response => {
|
|
383
|
+
const location = new URL(redirectURI)
|
|
384
|
+
|
|
385
|
+
for (const [key, value] of Object.entries(params)) {
|
|
386
|
+
if (value) {
|
|
387
|
+
location.searchParams.set(key, value)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return new Response(null, { headers: { Location: location.href }, status: 302 })
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const page = ({
|
|
395
|
+
content,
|
|
396
|
+
status,
|
|
397
|
+
title,
|
|
398
|
+
}: {
|
|
399
|
+
content: string
|
|
400
|
+
status: number
|
|
401
|
+
title: string
|
|
402
|
+
}): Response =>
|
|
403
|
+
new Response(
|
|
404
|
+
`<!doctype html>
|
|
405
|
+
<html lang="en">
|
|
406
|
+
<head>
|
|
407
|
+
<meta charset="utf-8">
|
|
408
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
409
|
+
<title>${escapeHTML(title)}</title>
|
|
410
|
+
<style>
|
|
411
|
+
body { font-family: system-ui, sans-serif; margin: 4rem auto; max-width: 32rem; overflow-wrap: anywhere; padding: 0 1rem }
|
|
412
|
+
button { font: inherit; margin-inline-end: 0.5rem; padding: 0.5rem 1rem }
|
|
413
|
+
</style>
|
|
414
|
+
</head>
|
|
415
|
+
<body><h1>${escapeHTML(title)}</h1>${content}</body>
|
|
416
|
+
</html>`,
|
|
417
|
+
{
|
|
418
|
+
headers: {
|
|
419
|
+
// Stop other sites from framing the page to trick users into clicking Allow
|
|
420
|
+
'Content-Security-Policy': "frame-ancestors 'none'",
|
|
421
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
422
|
+
},
|
|
423
|
+
status,
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
const escapeHTML = (value: string): string =>
|
|
428
|
+
value.replace(/[&<>"']/g, (character) => `&#${character.charCodeAt(0)};`)
|