@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.
Files changed (92) hide show
  1. package/bin.js +0 -7
  2. package/dist/endpoint/access.d.ts.map +1 -1
  3. package/dist/endpoint/access.js +15 -3
  4. package/dist/endpoint/access.js.map +1 -1
  5. package/dist/endpoint/index.d.ts.map +1 -1
  6. package/dist/endpoint/index.js +46 -4
  7. package/dist/endpoint/index.js.map +1 -1
  8. package/dist/endpoint/oauth.d.ts +39 -0
  9. package/dist/endpoint/oauth.d.ts.map +1 -0
  10. package/dist/endpoint/oauth.js +92 -0
  11. package/dist/endpoint/oauth.js.map +1 -0
  12. package/dist/endpoint/oauthLoginToken.d.ts +20 -0
  13. package/dist/endpoint/oauthLoginToken.d.ts.map +1 -0
  14. package/dist/endpoint/oauthLoginToken.js +158 -0
  15. package/dist/endpoint/oauthLoginToken.js.map +1 -0
  16. package/dist/endpoint/oauthServer.d.ts +12 -0
  17. package/dist/endpoint/oauthServer.d.ts.map +1 -0
  18. package/dist/endpoint/oauthServer.js +377 -0
  19. package/dist/endpoint/oauthServer.js.map +1 -0
  20. package/dist/index.d.ts +10 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +15 -2
  23. package/dist/index.js.map +1 -1
  24. package/dist/mcp/buildMcpServer.d.ts +1 -1
  25. package/dist/mcp/buildMcpServer.d.ts.map +1 -1
  26. package/dist/mcp/buildMcpServer.js +3 -3
  27. package/dist/mcp/buildMcpServer.js.map +1 -1
  28. package/dist/mcp/builtin/collections/authTools.d.ts.map +1 -1
  29. package/dist/mcp/builtin/collections/authTools.js +4 -3
  30. package/dist/mcp/builtin/collections/authTools.js.map +1 -1
  31. package/dist/mcp/builtin/collections/createTool.d.ts.map +1 -1
  32. package/dist/mcp/builtin/collections/createTool.js +12 -6
  33. package/dist/mcp/builtin/collections/createTool.js.map +1 -1
  34. package/dist/mcp/builtin/collections/deleteTool.js +6 -2
  35. package/dist/mcp/builtin/collections/deleteTool.js.map +1 -1
  36. package/dist/mcp/builtin/collections/duplicateTool.d.ts.map +1 -1
  37. package/dist/mcp/builtin/collections/duplicateTool.js +6 -2
  38. package/dist/mcp/builtin/collections/duplicateTool.js.map +1 -1
  39. package/dist/mcp/builtin/collections/fileInput.d.ts.map +1 -1
  40. package/dist/mcp/builtin/collections/fileInput.js +19 -54
  41. package/dist/mcp/builtin/collections/fileInput.js.map +1 -1
  42. package/dist/mcp/builtin/collections/findTool.js +8 -4
  43. package/dist/mcp/builtin/collections/findTool.js.map +1 -1
  44. package/dist/mcp/builtin/collections/getCollectionSchemaTool.d.ts.map +1 -1
  45. package/dist/mcp/builtin/collections/getCollectionSchemaTool.js +19 -3
  46. package/dist/mcp/builtin/collections/getCollectionSchemaTool.js.map +1 -1
  47. package/dist/mcp/builtin/collections/restoreVersionTool.js.map +1 -1
  48. package/dist/mcp/builtin/collections/updateTool.d.ts.map +1 -1
  49. package/dist/mcp/builtin/collections/updateTool.js +21 -8
  50. package/dist/mcp/builtin/collections/updateTool.js.map +1 -1
  51. package/dist/mcp/builtin/collections/uploadInstructionsTool.js.map +1 -1
  52. package/dist/mcp/builtin/globals/findTool.js.map +1 -1
  53. package/dist/mcp/builtin/globals/getGlobalSchemaTool.d.ts.map +1 -1
  54. package/dist/mcp/builtin/globals/getGlobalSchemaTool.js +19 -3
  55. package/dist/mcp/builtin/globals/getGlobalSchemaTool.js.map +1 -1
  56. package/dist/mcp/builtin/globals/updateTool.js.map +1 -1
  57. package/dist/mcp/sanitizeMCPConfig.d.ts.map +1 -1
  58. package/dist/mcp/sanitizeMCPConfig.js +3 -0
  59. package/dist/mcp/sanitizeMCPConfig.js.map +1 -1
  60. package/dist/stdio.js +4 -3
  61. package/dist/stdio.js.map +1 -1
  62. package/dist/types.d.ts +91 -3
  63. package/dist/types.d.ts.map +1 -1
  64. package/dist/types.js.map +1 -1
  65. package/dist/utils/toStandardSchema.js.map +1 -1
  66. package/package.json +23 -26
  67. package/src/endpoint/access.ts +12 -3
  68. package/src/endpoint/index.ts +41 -3
  69. package/src/endpoint/oauth.ts +91 -0
  70. package/src/endpoint/oauthLoginToken.ts +210 -0
  71. package/src/endpoint/oauthServer.ts +428 -0
  72. package/src/index.ts +40 -5
  73. package/src/mcp/buildMcpServer.ts +5 -5
  74. package/src/mcp/builtin/collections/authTools.ts +7 -4
  75. package/src/mcp/builtin/collections/createTool.ts +26 -6
  76. package/src/mcp/builtin/collections/deleteTool.ts +3 -3
  77. package/src/mcp/builtin/collections/duplicateTool.ts +3 -2
  78. package/src/mcp/builtin/collections/fileInput.ts +17 -59
  79. package/src/mcp/builtin/collections/findTool.ts +4 -4
  80. package/src/mcp/builtin/collections/getCollectionSchemaTool.ts +12 -1
  81. package/src/mcp/builtin/collections/restoreVersionTool.ts +1 -1
  82. package/src/mcp/builtin/collections/updateTool.ts +10 -6
  83. package/src/mcp/builtin/collections/uploadInstructionsTool.ts +1 -1
  84. package/src/mcp/builtin/globals/findTool.ts +1 -1
  85. package/src/mcp/builtin/globals/getGlobalSchemaTool.ts +11 -1
  86. package/src/mcp/builtin/globals/updateTool.ts +1 -1
  87. package/src/mcp/sanitizeMCPConfig.ts +1 -0
  88. package/src/stdio.ts +2 -2
  89. package/src/types.ts +93 -3
  90. package/src/utils/toStandardSchema.ts +1 -1
  91. package/dist/mcp/builtin/collections/fileInput.spec.js +0 -86
  92. 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)};`)