Replace better-auth with Cloudflare for oauth (#174)
This commit is contained in:
parent
db10ffa250
commit
0dd818e114
@ -8,5 +8,5 @@ const baseUrl = process.env.BETTER_AUTH_URL ?? CLI_DEV_BASE_URL;
|
|||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
secret: process.env.BETTER_AUTH_SECRET ?? randomUUID(),
|
||||||
...createBaseAuthConfig(baseUrl),
|
...createBaseAuthConfig(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -46,11 +46,14 @@
|
|||||||
},
|
},
|
||||||
"DATAFORSEO_API_KEY": {
|
"DATAFORSEO_API_KEY": {
|
||||||
"description": "Base64-encoded `login:password` for DataForSEO API access."
|
"description": "Base64-encoded `login:password` for DataForSEO API access."
|
||||||
|
},
|
||||||
|
"OAUTH_KV": {
|
||||||
|
"description": "KV namespace used by the Cloudflare Workers OAuth provider for MCP client registrations, grants, and tokens."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@better-auth/oauth-provider": "^1.5.5",
|
"@cloudflare/workers-oauth-provider": "^0.4.0",
|
||||||
"@every-app/sdk": "^0.1.14",
|
"@every-app/sdk": "^0.1.14",
|
||||||
"@modelcontextprotocol/sdk": "1.29.0",
|
"@modelcontextprotocol/sdk": "1.29.0",
|
||||||
"@tanstack/query-core": "^5.90.9",
|
"@tanstack/query-core": "^5.90.9",
|
||||||
|
|||||||
58
pnpm-lock.yaml
generated
58
pnpm-lock.yaml
generated
@ -8,9 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/oauth-provider':
|
'@cloudflare/workers-oauth-provider':
|
||||||
specifier: ^1.5.5
|
specifier: ^0.4.0
|
||||||
version: 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))
|
version: 0.4.0
|
||||||
'@every-app/sdk':
|
'@every-app/sdk':
|
||||||
specifier: ^0.1.14
|
specifier: ^0.1.14
|
||||||
version: 0.1.14(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(jose@6.1.3)(react@19.2.4)
|
version: 0.1.14(@tanstack/react-router@1.168.10(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(jose@6.1.3)(react@19.2.4)
|
||||||
@ -40,7 +40,7 @@ importers:
|
|||||||
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
agents:
|
agents:
|
||||||
specifier: 0.12.3
|
specifier: 0.12.3
|
||||||
version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.176(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6)
|
version: 0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6)
|
||||||
autumn-js:
|
autumn-js:
|
||||||
specifier: ^1.1.7
|
specifier: ^1.1.7
|
||||||
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
|
version: 1.1.7(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))(express@5.2.1)(hono@4.12.18)(react@19.2.4)
|
||||||
@ -186,8 +186,8 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@ai-sdk/gateway@3.0.111':
|
'@ai-sdk/gateway@3.0.112':
|
||||||
resolution: {integrity: sha512-gzdRuEH9Mqeuu8zG6j4of3EH3fFJUI0UIubyeaA8gep6KzhCJF7uaTfagSE7x2vLAf381g/NrxsXhhH7Hon9iA==}
|
resolution: {integrity: sha512-jiBao9pR4owWyjo0BnuNc7WSQBGOD0thysE4AFgZXaG+zMFbISQXUkJr7ePw/phBvePy7jE5FSA2Lf7lwqUiiQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.76 || ^4.1.8
|
zod: ^3.25.76 || ^4.1.8
|
||||||
@ -398,15 +398,6 @@ packages:
|
|||||||
'@better-auth/utils': ^0.3.0
|
'@better-auth/utils': ^0.3.0
|
||||||
mongodb: ^6.0.0 || ^7.0.0
|
mongodb: ^6.0.0 || ^7.0.0
|
||||||
|
|
||||||
'@better-auth/oauth-provider@1.5.5':
|
|
||||||
resolution: {integrity: sha512-zH2uKtvd6406MysWCTBldPHTKCXEK8caMrNId03bh4ej4f2vU8+GfNGE+IyxARucHGI1T+Og7QrUgKAeA2jQUQ==}
|
|
||||||
peerDependencies:
|
|
||||||
'@better-auth/core': 1.5.5
|
|
||||||
'@better-auth/utils': 0.3.1
|
|
||||||
'@better-fetch/fetch': 1.1.21
|
|
||||||
better-auth: 1.5.5
|
|
||||||
better-call: 1.3.2
|
|
||||||
|
|
||||||
'@better-auth/prisma-adapter@1.5.5':
|
'@better-auth/prisma-adapter@1.5.5':
|
||||||
resolution: {integrity: sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==}
|
resolution: {integrity: sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -483,6 +474,9 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@cloudflare/workers-oauth-provider@0.4.0':
|
||||||
|
resolution: {integrity: sha512-UtbV8hjC2NloB+Ds6J6v/9HiG8rx8MbdeYGCyFwOACT5vANWzDL6SKo3W5UZymsXiameAgC7jAmtUx4cc+Qpaw==}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20260302.0':
|
'@cloudflare/workers-types@4.20260302.0':
|
||||||
resolution: {integrity: sha512-mbFRnlu1lNCScMpXZk/X/uBPufYx5OSbq+euGonGRcY+DgOwm2kczGdK401rUh52NB0fFMEcOy/zqwxv7CdDNA==}
|
resolution: {integrity: sha512-mbFRnlu1lNCScMpXZk/X/uBPufYx5OSbq+euGonGRcY+DgOwm2kczGdK401rUh52NB0fFMEcOy/zqwxv7CdDNA==}
|
||||||
|
|
||||||
@ -2474,8 +2468,8 @@ packages:
|
|||||||
vite:
|
vite:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
ai@6.0.176:
|
ai@6.0.177:
|
||||||
resolution: {integrity: sha512-dhxDef3VCIxaFr6tKyG0BrkkCelmnporlen8nHajIwCk7S4PvIaSVI/iyJenhFOZ9KBoKjCAoUs6TzZ3yrSjxw==}
|
resolution: {integrity: sha512-1xQtbeWwNcLyyM86ixZhkKvT+WRXc1lvarIKqPVtsyn8F9NDikwUMBqYu+aQKDgMht50SMXh4qboYuU8MeHZZA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.25.76 || ^4.1.8
|
zod: ^3.25.76 || ^4.1.8
|
||||||
@ -3246,8 +3240,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
|
|
||||||
get-east-asian-width@1.5.0:
|
get-east-asian-width@1.6.0:
|
||||||
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
|
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
@ -4666,7 +4660,7 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@ai-sdk/gateway@3.0.111(zod@4.3.6)':
|
'@ai-sdk/gateway@3.0.112(zod@4.3.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 3.0.10
|
'@ai-sdk/provider': 3.0.10
|
||||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
||||||
@ -4917,16 +4911,6 @@ snapshots:
|
|||||||
'@better-auth/utils': 0.3.1
|
'@better-auth/utils': 0.3.1
|
||||||
mongodb: 7.2.0
|
mongodb: 7.2.0
|
||||||
|
|
||||||
'@better-auth/oauth-provider@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(better-call@1.3.2(zod@4.3.6))':
|
|
||||||
dependencies:
|
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
|
|
||||||
'@better-auth/utils': 0.3.1
|
|
||||||
'@better-fetch/fetch': 1.1.21
|
|
||||||
better-auth: 1.5.5(@cloudflare/workers-types@4.20260302.0)(@tanstack/react-start@1.167.16(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)))(drizzle-kit@0.31.9)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20260302.0)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.28.12))(mongodb@7.2.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.11)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
|
||||||
better-call: 1.3.2(zod@4.3.6)
|
|
||||||
jose: 6.1.3
|
|
||||||
zod: 4.3.6
|
|
||||||
|
|
||||||
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
'@better-auth/prisma-adapter@1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1))(@better-auth/utils@0.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
|
'@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260302.0)(better-call@1.3.2(zod@4.3.6))(jose@6.1.3)(kysely@0.28.12)(nanostores@1.1.1)
|
||||||
@ -4980,6 +4964,8 @@ snapshots:
|
|||||||
'@cloudflare/workerd-windows-64@1.20260219.0':
|
'@cloudflare/workerd-windows-64@1.20260219.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@cloudflare/workers-oauth-provider@0.4.0': {}
|
||||||
|
|
||||||
'@cloudflare/workers-types@4.20260302.0': {}
|
'@cloudflare/workers-types@4.20260302.0': {}
|
||||||
|
|
||||||
'@cspotcode/source-map-support@0.8.1':
|
'@cspotcode/source-map-support@0.8.1':
|
||||||
@ -6541,13 +6527,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
humanize-ms: 1.2.1
|
humanize-ms: 1.2.1
|
||||||
|
|
||||||
agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.176(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6):
|
agents@0.12.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(@cloudflare/workers-types@4.20260302.0)(ai@6.0.177(zod@4.3.6))(react@19.2.4)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
'@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.0)
|
||||||
'@cfworker/json-schema': 4.1.1
|
'@cfworker/json-schema': 4.1.1
|
||||||
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@4.3.6)
|
||||||
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.0)(@babel/runtime@7.29.2)(rolldown@1.0.0)(vite@7.3.1(@types/node@22.19.11)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))
|
||||||
ai: 6.0.176(zod@4.3.6)
|
ai: 6.0.177(zod@4.3.6)
|
||||||
cron-schedule: 6.0.0
|
cron-schedule: 6.0.0
|
||||||
mimetext: 3.0.28
|
mimetext: 3.0.28
|
||||||
nanoid: 5.1.11
|
nanoid: 5.1.11
|
||||||
@ -6566,9 +6552,9 @@ snapshots:
|
|||||||
- rolldown
|
- rolldown
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
ai@6.0.176(zod@4.3.6):
|
ai@6.0.177(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/gateway': 3.0.111(zod@4.3.6)
|
'@ai-sdk/gateway': 3.0.112(zod@4.3.6)
|
||||||
'@ai-sdk/provider': 3.0.10
|
'@ai-sdk/provider': 3.0.10
|
||||||
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
'@ai-sdk/provider-utils': 4.0.27(zod@4.3.6)
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@ -7269,7 +7255,7 @@ snapshots:
|
|||||||
|
|
||||||
get-caller-file@2.0.5: {}
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-east-asian-width@1.5.0: {}
|
get-east-asian-width@1.6.0: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -8550,7 +8536,7 @@ snapshots:
|
|||||||
string-width@7.2.0:
|
string-width@7.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 10.6.0
|
emoji-regex: 10.6.0
|
||||||
get-east-asian-width: 1.5.0
|
get-east-asian-width: 1.6.0
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
stringify-entities@4.0.4:
|
stringify-entities@4.0.4:
|
||||||
|
|||||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -4,6 +4,7 @@
|
|||||||
declare namespace Cloudflare {
|
declare namespace Cloudflare {
|
||||||
interface Env {
|
interface Env {
|
||||||
R2: R2Bucket;
|
R2: R2Bucket;
|
||||||
|
OAUTH_KV: KVNamespace;
|
||||||
|
|
||||||
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
AUTH_MODE?: "cloudflare_access" | "local_noauth" | "hosted";
|
||||||
TEAM_DOMAIN?: string;
|
TEAM_DOMAIN?: string;
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
inferAdditionalFields,
|
inferAdditionalFields,
|
||||||
organizationClient,
|
organizationClient,
|
||||||
} from "better-auth/client/plugins";
|
} from "better-auth/client/plugins";
|
||||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
|
||||||
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
import { captureClientEvent, resetAnalyticsUser } from "@/client/lib/posthog";
|
||||||
import { userAdditionalFields } from "@/lib/auth-options";
|
import { userAdditionalFields } from "@/lib/auth-options";
|
||||||
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
|
||||||
@ -12,7 +11,6 @@ export const authClient = createAuthClient({
|
|||||||
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
baseURL: typeof window !== "undefined" ? window.location.origin : "",
|
||||||
plugins: [
|
plugins: [
|
||||||
organizationClient(),
|
organizationClient(),
|
||||||
oauthProviderClient(),
|
|
||||||
inferAdditionalFields({ user: userAdditionalFields }),
|
inferAdditionalFields({ user: userAdditionalFields }),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,76 +1,9 @@
|
|||||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
import { organization } from "better-auth/plugins";
|
||||||
import { jwt, organization } from "better-auth/plugins";
|
|
||||||
import { baseAuthOptions } from "@/lib/auth-options";
|
import { baseAuthOptions } from "@/lib/auth-options";
|
||||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
|
||||||
import {
|
|
||||||
getMcpOrganizationIdClaim,
|
|
||||||
getMcpResource,
|
|
||||||
MCP_OAUTH_SCOPES,
|
|
||||||
MCP_SCOPE,
|
|
||||||
} from "@/lib/oauth-resource";
|
|
||||||
|
|
||||||
function assertSingleMcpAudience(audiences: string[]) {
|
|
||||||
if (audiences.length !== 1) {
|
|
||||||
throw new Error(
|
|
||||||
"MCP OAuth resource injection requires exactly one valid audience",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBaseAuthConfig(baseUrl: string) {
|
|
||||||
const mcpResource = getMcpResource(baseUrl);
|
|
||||||
const mcpOrganizationIdClaim = getMcpOrganizationIdClaim(baseUrl);
|
|
||||||
const validAudiences = [mcpResource];
|
|
||||||
|
|
||||||
assertSingleMcpAudience(validAudiences);
|
|
||||||
|
|
||||||
|
export function createBaseAuthConfig() {
|
||||||
return {
|
return {
|
||||||
...baseAuthOptions,
|
...baseAuthOptions,
|
||||||
plugins: [
|
plugins: [organization()],
|
||||||
organization(),
|
|
||||||
jwt(),
|
|
||||||
oauthProvider({
|
|
||||||
loginPage: "/sign-in",
|
|
||||||
consentPage: "/oauth-consent",
|
|
||||||
signup: {
|
|
||||||
page: "/sign-up",
|
|
||||||
},
|
|
||||||
scopes: MCP_OAUTH_SCOPES,
|
|
||||||
// We publish /.well-known/oauth-authorization-server/api/auth via
|
|
||||||
// TanStack routes, so silence Better Auth's metadata reminder.
|
|
||||||
silenceWarnings: {
|
|
||||||
oauthAuthServerConfig: true,
|
|
||||||
},
|
|
||||||
allowDynamicClientRegistration: true,
|
|
||||||
clientRegistrationDefaultScopes: MCP_OAUTH_SCOPES,
|
|
||||||
clientRegistrationAllowedScopes: MCP_OAUTH_SCOPES,
|
|
||||||
// TODO: drop once the MCP spec settles on a replacement for
|
|
||||||
// unauthenticated DCR — better-auth has flagged this option for removal.
|
|
||||||
allowUnauthenticatedClientRegistration: true,
|
|
||||||
// Single allowed audience — see `routes/api/auth/$.ts`, which defaults
|
|
||||||
// missing `resource` on /oauth2/token to this value. Adding a second
|
|
||||||
// audience here would make that injection unsafe (we'd no longer know
|
|
||||||
// which to pick) and require scope-conditional logic in the route.
|
|
||||||
validAudiences,
|
|
||||||
postLogin: {
|
|
||||||
page: "/oauth-consent",
|
|
||||||
shouldRedirect: () => false,
|
|
||||||
consentReferenceId: ({ session, scopes }) => {
|
|
||||||
if (!scopes.includes(MCP_SCOPE)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return getActiveOrganizationId({ session }) ?? undefined;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
customAccessTokenClaims: ({ referenceId, scopes }) => {
|
|
||||||
if (!scopes.includes(MCP_SCOPE)) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
return referenceId ? { [mcpOrganizationIdClaim]: referenceId } : {};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getAuthRedirectFromSearch,
|
getAuthRedirectFromSearch,
|
||||||
|
getCurrentAuthRedirectFromHref,
|
||||||
getOAuthAuthorizeRedirectFromSearch,
|
getOAuthAuthorizeRedirectFromSearch,
|
||||||
getOAuthSignedQuery,
|
getOAuthSignedQuery,
|
||||||
|
getSignInHref,
|
||||||
normalizeAuthRedirect,
|
normalizeAuthRedirect,
|
||||||
} from "./auth-redirect";
|
} from "./auth-redirect";
|
||||||
|
|
||||||
@ -26,6 +28,32 @@ describe("auth redirect helpers", () => {
|
|||||||
expect(normalizeAuthRedirect("//evil.example/app")).toBe("/");
|
expect(normalizeAuthRedirect("//evil.example/app")).toBe("/");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps same-origin relative redirects", () => {
|
||||||
|
expect(
|
||||||
|
normalizeAuthRedirect("/api/auth/oauth2/authorize?client_id=abc"),
|
||||||
|
).toBe("/api/auth/oauth2/authorize?client_id=abc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects external and protocol-relative redirects", () => {
|
||||||
|
expect(normalizeAuthRedirect("https://evil.test")).toBe("/");
|
||||||
|
expect(normalizeAuthRedirect("//evil.test")).toBe("/");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds sign-in links with the redirect query only when needed", () => {
|
||||||
|
expect(getSignInHref("/")).toBe("/sign-in");
|
||||||
|
expect(getSignInHref("/oauth-consent?client_id=abc")).toBe(
|
||||||
|
"/sign-in?redirect=%2Foauth-consent%3Fclient_id%3Dabc",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts the current path, query, and hash from hrefs", () => {
|
||||||
|
expect(
|
||||||
|
getCurrentAuthRedirectFromHref(
|
||||||
|
"https://open-seo.test/projects?tab=keywords#top",
|
||||||
|
),
|
||||||
|
).toBe("/projects?tab=keywords#top");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves safe internal redirects", () => {
|
it("preserves safe internal redirects", () => {
|
||||||
expect(getAuthRedirectFromSearch("", "/app")).toBe("/app");
|
expect(getAuthRedirectFromSearch("", "/app")).toBe("/app");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -25,14 +25,11 @@ const hostedBaseUrlSchema = z
|
|||||||
function createAuth() {
|
function createAuth() {
|
||||||
const baseUrl = getHostedBaseUrl();
|
const baseUrl = getHostedBaseUrl();
|
||||||
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
const bypassEmail = Reflect.get(env, "BYPASS_EMAIL_VERIFICATION") === "true";
|
||||||
const baseAuthConfig = createBaseAuthConfig(baseUrl);
|
const baseAuthConfig = createBaseAuthConfig();
|
||||||
|
|
||||||
const auth = betterAuth({
|
const auth = betterAuth({
|
||||||
baseURL: baseUrl,
|
baseURL: baseUrl,
|
||||||
secret: getHostedSecret(),
|
secret: getHostedSecret(),
|
||||||
// Disable Better Auth's generic /token endpoint so OAuth access tokens only
|
|
||||||
// flow through /oauth2/token, where the MCP resource shim can run.
|
|
||||||
disabledPaths: ["/token"],
|
|
||||||
...baseAuthConfig,
|
...baseAuthConfig,
|
||||||
emailAndPassword: {
|
emailAndPassword: {
|
||||||
...baseAuthConfig.emailAndPassword,
|
...baseAuthConfig.emailAndPassword,
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
|
||||||
import { getAuth } from "@/lib/auth";
|
|
||||||
|
|
||||||
type ResourceClientAuth = Parameters<typeof oauthProviderResourceClient>[0];
|
|
||||||
|
|
||||||
export function getOAuthProviderResourceActions() {
|
|
||||||
// Better Auth documents passing the server auth instance here, but the
|
|
||||||
// resource-client package currently types the generic too narrowly for the
|
|
||||||
// concrete `betterAuth(...)` return type.
|
|
||||||
return oauthProviderResourceClient(
|
|
||||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
||||||
getAuth() as unknown as ResourceClientAuth,
|
|
||||||
).getActions();
|
|
||||||
}
|
|
||||||
@ -5,19 +5,3 @@ export const MCP_OAUTH_SCOPES = ["offline_access", MCP_SCOPE];
|
|||||||
export function getMcpResource(baseUrl: string) {
|
export function getMcpResource(baseUrl: string) {
|
||||||
return new URL(MCP_RESOURCE_PATH, baseUrl).toString();
|
return new URL(MCP_RESOURCE_PATH, baseUrl).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMcpOrganizationIdClaim(baseUrl: string) {
|
|
||||||
return new URL(
|
|
||||||
`${MCP_RESOURCE_PATH}/claims/organization-id`,
|
|
||||||
baseUrl,
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getMcpProtectedResourceMetadataUrl(resource: string) {
|
|
||||||
const url = new URL(resource);
|
|
||||||
const pathname = url.pathname.endsWith("/")
|
|
||||||
? url.pathname.slice(0, -1)
|
|
||||||
: url.pathname;
|
|
||||||
|
|
||||||
return `${url.origin}/.well-known/oauth-protected-resource${pathname}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -25,13 +25,9 @@ import { Route as AppSupportRouteImport } from './routes/_app/support'
|
|||||||
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
import { Route as AppSettingsRouteImport } from './routes/_app/settings'
|
||||||
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
import { Route as AppBillingRouteImport } from './routes/_app/billing'
|
||||||
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
import { Route as AppAiRouteImport } from './routes/_app/ai'
|
||||||
import { Route as DotwellKnownOpenidConfigurationRouteImport } from './routes/[.]well-known/openid-configuration'
|
|
||||||
import { Route as DotwellKnownOauthProtectedResourceRouteImport } from './routes/[.]well-known/oauth-protected-resource'
|
|
||||||
import { Route as DotwellKnownOauthAuthorizationServerRouteImport } from './routes/[.]well-known/oauth-authorization-server'
|
|
||||||
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
import { Route as ApiAutumnSplatRouteImport } from './routes/api/autumn/$'
|
||||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
|
||||||
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
|
||||||
import { Route as DotwellKnownOauthProtectedResourceMcpRouteImport } from './routes/[.]well-known/oauth-protected-resource/mcp'
|
|
||||||
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
|
||||||
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
import { Route as ProjectPProjectIdIndexRouteImport } from './routes/_project/p/$projectId/index'
|
||||||
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
import { Route as ProjectPProjectIdSavedRouteImport } from './routes/_project/p/$projectId/saved'
|
||||||
@ -42,7 +38,6 @@ import { Route as ProjectPProjectIdDomainRouteImport } from './routes/_project/p
|
|||||||
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
import { Route as ProjectPProjectIdBrandLookupRouteImport } from './routes/_project/p/$projectId/brand-lookup'
|
||||||
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
import { Route as ProjectPProjectIdBacklinksRouteImport } from './routes/_project/p/$projectId/backlinks'
|
||||||
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
import { Route as ProjectPProjectIdAuditRouteImport } from './routes/_project/p/$projectId/audit'
|
||||||
import { Route as DotwellKnownOauthAuthorizationServerApiAuthRouteImport } from './routes/[.]well-known/oauth-authorization-server/api/auth'
|
|
||||||
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
import { Route as ProjectPProjectIdRankTrackingIndexRouteImport } from './routes/_project/p/$projectId/rank-tracking/index'
|
||||||
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
import { Route as ProjectPProjectIdAuditIndexRouteImport } from './routes/_project/p/$projectId/audit/index'
|
||||||
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
import { Route as ProjectPProjectIdRankTrackingConfigIdRouteImport } from './routes/_project/p/$projectId/rank-tracking/$configId'
|
||||||
@ -125,24 +120,6 @@ const AppAiRoute = AppAiRouteImport.update({
|
|||||||
path: '/ai',
|
path: '/ai',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DotwellKnownOpenidConfigurationRoute =
|
|
||||||
DotwellKnownOpenidConfigurationRouteImport.update({
|
|
||||||
id: '/.well-known/openid-configuration',
|
|
||||||
path: '/.well-known/openid-configuration',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const DotwellKnownOauthProtectedResourceRoute =
|
|
||||||
DotwellKnownOauthProtectedResourceRouteImport.update({
|
|
||||||
id: '/.well-known/oauth-protected-resource',
|
|
||||||
path: '/.well-known/oauth-protected-resource',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const DotwellKnownOauthAuthorizationServerRoute =
|
|
||||||
DotwellKnownOauthAuthorizationServerRouteImport.update({
|
|
||||||
id: '/.well-known/oauth-authorization-server',
|
|
||||||
path: '/.well-known/oauth-authorization-server',
|
|
||||||
getParentRoute: () => rootRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
const ApiAutumnSplatRoute = ApiAutumnSplatRouteImport.update({
|
||||||
id: '/api/autumn/$',
|
id: '/api/autumn/$',
|
||||||
path: '/api/autumn/$',
|
path: '/api/autumn/$',
|
||||||
@ -158,12 +135,6 @@ const AppHelpDataforseoApiKeyRoute = AppHelpDataforseoApiKeyRouteImport.update({
|
|||||||
path: '/help/dataforseo-api-key',
|
path: '/help/dataforseo-api-key',
|
||||||
getParentRoute: () => AppRouteRoute,
|
getParentRoute: () => AppRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DotwellKnownOauthProtectedResourceMcpRoute =
|
|
||||||
DotwellKnownOauthProtectedResourceMcpRouteImport.update({
|
|
||||||
id: '/mcp',
|
|
||||||
path: '/mcp',
|
|
||||||
getParentRoute: () => DotwellKnownOauthProtectedResourceRoute,
|
|
||||||
} as any)
|
|
||||||
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
|
const ProjectPProjectIdRouteRoute = ProjectPProjectIdRouteRouteImport.update({
|
||||||
id: '/p/$projectId',
|
id: '/p/$projectId',
|
||||||
path: '/p/$projectId',
|
path: '/p/$projectId',
|
||||||
@ -219,12 +190,6 @@ const ProjectPProjectIdAuditRoute = ProjectPProjectIdAuditRouteImport.update({
|
|||||||
path: '/audit',
|
path: '/audit',
|
||||||
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
getParentRoute: () => ProjectPProjectIdRouteRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const DotwellKnownOauthAuthorizationServerApiAuthRoute =
|
|
||||||
DotwellKnownOauthAuthorizationServerApiAuthRouteImport.update({
|
|
||||||
id: '/api/auth',
|
|
||||||
path: '/api/auth',
|
|
||||||
getParentRoute: () => DotwellKnownOauthAuthorizationServerRoute,
|
|
||||||
} as any)
|
|
||||||
const ProjectPProjectIdRankTrackingIndexRoute =
|
const ProjectPProjectIdRankTrackingIndexRoute =
|
||||||
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
ProjectPProjectIdRankTrackingIndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@ -255,9 +220,6 @@ export interface FileRoutesByFullPath {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
|
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
|
||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
@ -267,11 +229,9 @@ export interface FileRoutesByFullPath {
|
|||||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
|
||||||
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -291,9 +251,6 @@ export interface FileRoutesByTo {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
|
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
|
||||||
'/ai': typeof AppAiRoute
|
'/ai': typeof AppAiRoute
|
||||||
'/billing': typeof AppBillingRoute
|
'/billing': typeof AppBillingRoute
|
||||||
'/settings': typeof AppSettingsRoute
|
'/settings': typeof AppSettingsRoute
|
||||||
@ -302,11 +259,9 @@ export interface FileRoutesByTo {
|
|||||||
'/sign-up': typeof AuthSignUpRoute
|
'/sign-up': typeof AuthSignUpRoute
|
||||||
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
'/oauth-consent': typeof AuthenticatedOauthConsentRoute
|
||||||
'/subscribe': typeof AuthenticatedSubscribeRoute
|
'/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
|
||||||
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
|
||||||
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
'/p/$projectId/domain': typeof ProjectPProjectIdDomainRoute
|
||||||
@ -328,9 +283,6 @@ export interface FileRoutesById {
|
|||||||
'/forgot-password': typeof ForgotPasswordRoute
|
'/forgot-password': typeof ForgotPasswordRoute
|
||||||
'/reset-password': typeof ResetPasswordRoute
|
'/reset-password': typeof ResetPasswordRoute
|
||||||
'/verify-email': typeof VerifyEmailRoute
|
'/verify-email': typeof VerifyEmailRoute
|
||||||
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
|
||||||
'/.well-known/oauth-protected-resource': typeof DotwellKnownOauthProtectedResourceRouteWithChildren
|
|
||||||
'/.well-known/openid-configuration': typeof DotwellKnownOpenidConfigurationRoute
|
|
||||||
'/_app/ai': typeof AppAiRoute
|
'/_app/ai': typeof AppAiRoute
|
||||||
'/_app/billing': typeof AppBillingRoute
|
'/_app/billing': typeof AppBillingRoute
|
||||||
'/_app/settings': typeof AppSettingsRoute
|
'/_app/settings': typeof AppSettingsRoute
|
||||||
@ -341,11 +293,9 @@ export interface FileRoutesById {
|
|||||||
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
'/_authenticated/subscribe': typeof AuthenticatedSubscribeRoute
|
||||||
'/_app/': typeof AppIndexRoute
|
'/_app/': typeof AppIndexRoute
|
||||||
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
|
||||||
'/.well-known/oauth-protected-resource/mcp': typeof DotwellKnownOauthProtectedResourceMcpRoute
|
|
||||||
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
'/api/autumn/$': typeof ApiAutumnSplatRoute
|
||||||
'/.well-known/oauth-authorization-server/api/auth': typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
|
||||||
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
'/_project/p/$projectId/audit': typeof ProjectPProjectIdAuditRouteWithChildren
|
||||||
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
'/_project/p/$projectId/backlinks': typeof ProjectPProjectIdBacklinksRoute
|
||||||
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
'/_project/p/$projectId/brand-lookup': typeof ProjectPProjectIdBrandLookupRoute
|
||||||
@ -367,9 +317,6 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/oauth-authorization-server'
|
|
||||||
| '/.well-known/oauth-protected-resource'
|
|
||||||
| '/.well-known/openid-configuration'
|
|
||||||
| '/ai'
|
| '/ai'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
@ -379,11 +326,9 @@ export interface FileRouteTypes {
|
|||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
| '/p/$projectId'
|
| '/p/$projectId'
|
||||||
| '/.well-known/oauth-protected-resource/mcp'
|
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
|
||||||
| '/p/$projectId/audit'
|
| '/p/$projectId/audit'
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
@ -403,9 +348,6 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/oauth-authorization-server'
|
|
||||||
| '/.well-known/oauth-protected-resource'
|
|
||||||
| '/.well-known/openid-configuration'
|
|
||||||
| '/ai'
|
| '/ai'
|
||||||
| '/billing'
|
| '/billing'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
@ -414,11 +356,9 @@ export interface FileRouteTypes {
|
|||||||
| '/sign-up'
|
| '/sign-up'
|
||||||
| '/oauth-consent'
|
| '/oauth-consent'
|
||||||
| '/subscribe'
|
| '/subscribe'
|
||||||
| '/.well-known/oauth-protected-resource/mcp'
|
|
||||||
| '/help/dataforseo-api-key'
|
| '/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
|
||||||
| '/p/$projectId/backlinks'
|
| '/p/$projectId/backlinks'
|
||||||
| '/p/$projectId/brand-lookup'
|
| '/p/$projectId/brand-lookup'
|
||||||
| '/p/$projectId/domain'
|
| '/p/$projectId/domain'
|
||||||
@ -439,9 +379,6 @@ export interface FileRouteTypes {
|
|||||||
| '/forgot-password'
|
| '/forgot-password'
|
||||||
| '/reset-password'
|
| '/reset-password'
|
||||||
| '/verify-email'
|
| '/verify-email'
|
||||||
| '/.well-known/oauth-authorization-server'
|
|
||||||
| '/.well-known/oauth-protected-resource'
|
|
||||||
| '/.well-known/openid-configuration'
|
|
||||||
| '/_app/ai'
|
| '/_app/ai'
|
||||||
| '/_app/billing'
|
| '/_app/billing'
|
||||||
| '/_app/settings'
|
| '/_app/settings'
|
||||||
@ -452,11 +389,9 @@ export interface FileRouteTypes {
|
|||||||
| '/_authenticated/subscribe'
|
| '/_authenticated/subscribe'
|
||||||
| '/_app/'
|
| '/_app/'
|
||||||
| '/_project/p/$projectId'
|
| '/_project/p/$projectId'
|
||||||
| '/.well-known/oauth-protected-resource/mcp'
|
|
||||||
| '/_app/help/dataforseo-api-key'
|
| '/_app/help/dataforseo-api-key'
|
||||||
| '/api/auth/$'
|
| '/api/auth/$'
|
||||||
| '/api/autumn/$'
|
| '/api/autumn/$'
|
||||||
| '/.well-known/oauth-authorization-server/api/auth'
|
|
||||||
| '/_project/p/$projectId/audit'
|
| '/_project/p/$projectId/audit'
|
||||||
| '/_project/p/$projectId/backlinks'
|
| '/_project/p/$projectId/backlinks'
|
||||||
| '/_project/p/$projectId/brand-lookup'
|
| '/_project/p/$projectId/brand-lookup'
|
||||||
@ -480,9 +415,6 @@ export interface RootRouteChildren {
|
|||||||
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
ForgotPasswordRoute: typeof ForgotPasswordRoute
|
||||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||||
VerifyEmailRoute: typeof VerifyEmailRoute
|
VerifyEmailRoute: typeof VerifyEmailRoute
|
||||||
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRouteWithChildren
|
|
||||||
DotwellKnownOauthProtectedResourceRoute: typeof DotwellKnownOauthProtectedResourceRouteWithChildren
|
|
||||||
DotwellKnownOpenidConfigurationRoute: typeof DotwellKnownOpenidConfigurationRoute
|
|
||||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
ApiAutumnSplatRoute: typeof ApiAutumnSplatRoute
|
||||||
}
|
}
|
||||||
@ -601,27 +533,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppAiRouteImport
|
preLoaderRoute: typeof AppAiRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
'/.well-known/openid-configuration': {
|
|
||||||
id: '/.well-known/openid-configuration'
|
|
||||||
path: '/.well-known/openid-configuration'
|
|
||||||
fullPath: '/.well-known/openid-configuration'
|
|
||||||
preLoaderRoute: typeof DotwellKnownOpenidConfigurationRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/.well-known/oauth-protected-resource': {
|
|
||||||
id: '/.well-known/oauth-protected-resource'
|
|
||||||
path: '/.well-known/oauth-protected-resource'
|
|
||||||
fullPath: '/.well-known/oauth-protected-resource'
|
|
||||||
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/.well-known/oauth-authorization-server': {
|
|
||||||
id: '/.well-known/oauth-authorization-server'
|
|
||||||
path: '/.well-known/oauth-authorization-server'
|
|
||||||
fullPath: '/.well-known/oauth-authorization-server'
|
|
||||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerRouteImport
|
|
||||||
parentRoute: typeof rootRouteImport
|
|
||||||
}
|
|
||||||
'/api/autumn/$': {
|
'/api/autumn/$': {
|
||||||
id: '/api/autumn/$'
|
id: '/api/autumn/$'
|
||||||
path: '/api/autumn/$'
|
path: '/api/autumn/$'
|
||||||
@ -643,13 +554,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
|
preLoaderRoute: typeof AppHelpDataforseoApiKeyRouteImport
|
||||||
parentRoute: typeof AppRouteRoute
|
parentRoute: typeof AppRouteRoute
|
||||||
}
|
}
|
||||||
'/.well-known/oauth-protected-resource/mcp': {
|
|
||||||
id: '/.well-known/oauth-protected-resource/mcp'
|
|
||||||
path: '/mcp'
|
|
||||||
fullPath: '/.well-known/oauth-protected-resource/mcp'
|
|
||||||
preLoaderRoute: typeof DotwellKnownOauthProtectedResourceMcpRouteImport
|
|
||||||
parentRoute: typeof DotwellKnownOauthProtectedResourceRoute
|
|
||||||
}
|
|
||||||
'/_project/p/$projectId': {
|
'/_project/p/$projectId': {
|
||||||
id: '/_project/p/$projectId'
|
id: '/_project/p/$projectId'
|
||||||
path: '/p/$projectId'
|
path: '/p/$projectId'
|
||||||
@ -720,13 +624,6 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof ProjectPProjectIdAuditRouteImport
|
preLoaderRoute: typeof ProjectPProjectIdAuditRouteImport
|
||||||
parentRoute: typeof ProjectPProjectIdRouteRoute
|
parentRoute: typeof ProjectPProjectIdRouteRoute
|
||||||
}
|
}
|
||||||
'/.well-known/oauth-authorization-server/api/auth': {
|
|
||||||
id: '/.well-known/oauth-authorization-server/api/auth'
|
|
||||||
path: '/api/auth'
|
|
||||||
fullPath: '/.well-known/oauth-authorization-server/api/auth'
|
|
||||||
preLoaderRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRouteImport
|
|
||||||
parentRoute: typeof DotwellKnownOauthAuthorizationServerRoute
|
|
||||||
}
|
|
||||||
'/_project/p/$projectId/rank-tracking/': {
|
'/_project/p/$projectId/rank-tracking/': {
|
||||||
id: '/_project/p/$projectId/rank-tracking/'
|
id: '/_project/p/$projectId/rank-tracking/'
|
||||||
path: '/'
|
path: '/'
|
||||||
@ -884,36 +781,6 @@ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
|||||||
AuthenticatedRouteChildren,
|
AuthenticatedRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
interface DotwellKnownOauthAuthorizationServerRouteChildren {
|
|
||||||
DotwellKnownOauthAuthorizationServerApiAuthRoute: typeof DotwellKnownOauthAuthorizationServerApiAuthRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const DotwellKnownOauthAuthorizationServerRouteChildren: DotwellKnownOauthAuthorizationServerRouteChildren =
|
|
||||||
{
|
|
||||||
DotwellKnownOauthAuthorizationServerApiAuthRoute:
|
|
||||||
DotwellKnownOauthAuthorizationServerApiAuthRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DotwellKnownOauthAuthorizationServerRouteWithChildren =
|
|
||||||
DotwellKnownOauthAuthorizationServerRoute._addFileChildren(
|
|
||||||
DotwellKnownOauthAuthorizationServerRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
interface DotwellKnownOauthProtectedResourceRouteChildren {
|
|
||||||
DotwellKnownOauthProtectedResourceMcpRoute: typeof DotwellKnownOauthProtectedResourceMcpRoute
|
|
||||||
}
|
|
||||||
|
|
||||||
const DotwellKnownOauthProtectedResourceRouteChildren: DotwellKnownOauthProtectedResourceRouteChildren =
|
|
||||||
{
|
|
||||||
DotwellKnownOauthProtectedResourceMcpRoute:
|
|
||||||
DotwellKnownOauthProtectedResourceMcpRoute,
|
|
||||||
}
|
|
||||||
|
|
||||||
const DotwellKnownOauthProtectedResourceRouteWithChildren =
|
|
||||||
DotwellKnownOauthProtectedResourceRoute._addFileChildren(
|
|
||||||
DotwellKnownOauthProtectedResourceRouteChildren,
|
|
||||||
)
|
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
AppRouteRoute: AppRouteRouteWithChildren,
|
AppRouteRoute: AppRouteRouteWithChildren,
|
||||||
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
ProjectRouteRoute: ProjectRouteRouteWithChildren,
|
||||||
@ -922,11 +789,6 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
ForgotPasswordRoute: ForgotPasswordRoute,
|
ForgotPasswordRoute: ForgotPasswordRoute,
|
||||||
ResetPasswordRoute: ResetPasswordRoute,
|
ResetPasswordRoute: ResetPasswordRoute,
|
||||||
VerifyEmailRoute: VerifyEmailRoute,
|
VerifyEmailRoute: VerifyEmailRoute,
|
||||||
DotwellKnownOauthAuthorizationServerRoute:
|
|
||||||
DotwellKnownOauthAuthorizationServerRouteWithChildren,
|
|
||||||
DotwellKnownOauthProtectedResourceRoute:
|
|
||||||
DotwellKnownOauthProtectedResourceRouteWithChildren,
|
|
||||||
DotwellKnownOpenidConfigurationRoute: DotwellKnownOpenidConfigurationRoute,
|
|
||||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
ApiAutumnSplatRoute: ApiAutumnSplatRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
||||||
|
|
||||||
function unavailableMetadataResponse() {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/.well-known/oauth-authorization-server")(
|
|
||||||
{
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
|
||||||
return unavailableMetadataResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return oauthProviderAuthServerMetadata(getAuth())(request);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
||||||
|
|
||||||
function unavailableMetadataResponse() {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
|
||||||
"/.well-known/oauth-authorization-server/api/auth",
|
|
||||||
)({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
|
||||||
return unavailableMetadataResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return oauthProviderAuthServerMetadata(getAuth())(request);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { mcpProtectedResourceMetadataResponse } from "@/server/mcp/protected-resource-metadata";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/.well-known/oauth-protected-resource")({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async () => mcpProtectedResourceMetadataResponse(env.AUTH_MODE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,13 +0,0 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { mcpProtectedResourceMetadataResponse } from "@/server/mcp/protected-resource-metadata";
|
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
|
||||||
"/.well-known/oauth-protected-resource/mcp",
|
|
||||||
)({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async () => mcpProtectedResourceMetadataResponse(env.AUTH_MODE),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
import { oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
|
||||||
import { env } from "cloudflare:workers";
|
|
||||||
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
||||||
|
|
||||||
function unavailableMetadataResponse() {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/.well-known/openid-configuration")({
|
|
||||||
server: {
|
|
||||||
handlers: {
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE) || !hasHostedAuthConfig()) {
|
|
||||||
return unavailableMetadataResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
return oauthProviderOpenIdConfigMetadata(getAuth())(request);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -29,6 +29,7 @@ function SignInPage() {
|
|||||||
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
|
const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState(
|
||||||
search.redirect,
|
search.redirect,
|
||||||
);
|
);
|
||||||
|
const authCallbackURL = redirectTo;
|
||||||
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
const [verificationEmail, setVerificationEmail] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@ -53,7 +54,7 @@ function SignInPage() {
|
|||||||
const result = await authClient.signIn.email({
|
const result = await authClient.signIn.email({
|
||||||
email,
|
email,
|
||||||
password: value.password,
|
password: value.password,
|
||||||
callbackURL: redirectTo,
|
callbackURL: authCallbackURL,
|
||||||
...(oauthQuery ? { oauth_query: oauthQuery } : {}),
|
...(oauthQuery ? { oauth_query: oauthQuery } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -103,12 +104,16 @@ function SignInPage() {
|
|||||||
setIsSendingVerification(true);
|
setIsSendingVerification(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const callbackURL = new URL("/verify-email", window.location.origin);
|
const verificationCallbackURL = new URL(
|
||||||
if (redirectTo !== "/")
|
"/verify-email",
|
||||||
callbackURL.searchParams.set("redirect", redirectTo);
|
window.location.origin,
|
||||||
|
);
|
||||||
|
if (authCallbackURL !== "/") {
|
||||||
|
verificationCallbackURL.searchParams.set("redirect", authCallbackURL);
|
||||||
|
}
|
||||||
const result = await authClient.sendVerificationEmail({
|
const result = await authClient.sendVerificationEmail({
|
||||||
email: verificationEmail,
|
email: verificationEmail,
|
||||||
callbackURL: callbackURL.toString(),
|
callbackURL: verificationCallbackURL.toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { Check, Database, KeyRound, User } from "lucide-react";
|
import { Check, Database, KeyRound, User } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { authClient, useSession } from "@/lib/auth-client";
|
import { useSession } from "@/lib/auth-client";
|
||||||
import { getOAuthClientInfo } from "@/serverFunctions/oauth";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authenticated/oauth-consent")({
|
export const Route = createFileRoute("/_authenticated/oauth-consent")({
|
||||||
component: OAuthConsentPage,
|
component: OAuthConsentPage,
|
||||||
@ -27,42 +25,35 @@ function OAuthConsentPage() {
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const clientId =
|
|
||||||
typeof window !== "undefined"
|
|
||||||
? new URLSearchParams(window.location.search).get("client_id")
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const clientInfoQuery = useQuery({
|
|
||||||
queryKey: ["oauth-client-info", clientId],
|
|
||||||
queryFn: () =>
|
|
||||||
clientId
|
|
||||||
? getOAuthClientInfo({ data: { clientId } })
|
|
||||||
: Promise.resolve(null),
|
|
||||||
enabled: Boolean(clientId),
|
|
||||||
staleTime: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clientName = clientInfoQuery.data?.name ?? null;
|
|
||||||
const userEmail = session?.user?.email ?? null;
|
const userEmail = session?.user?.email ?? null;
|
||||||
const isLoadingClient = clientInfoQuery.isLoading;
|
|
||||||
const named = Boolean(clientName);
|
|
||||||
|
|
||||||
async function respond(accept: boolean) {
|
async function respond(accept: boolean) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
|
||||||
const { data, error: consentError } = await authClient.oauth2.consent({
|
const response = await fetch("/api/oauth/consent", {
|
||||||
accept,
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
accept,
|
||||||
|
query: window.location.search,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
const data: {
|
||||||
|
redirectTo?: string;
|
||||||
|
error?: string;
|
||||||
|
} = await response.json();
|
||||||
|
|
||||||
if (consentError) {
|
if (!response.ok) {
|
||||||
setError(consentError.message ?? "Unable to complete authorization.");
|
setError(data.error ?? "Unable to complete authorization.");
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data?.redirect && data.url) {
|
if (data.redirectTo) {
|
||||||
window.location.assign(data.url);
|
window.location.assign(data.redirectTo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,33 +69,12 @@ function OAuthConsentPage() {
|
|||||||
alt="OpenSEO"
|
alt="OpenSEO"
|
||||||
className="size-10 rounded-lg"
|
className="size-10 rounded-lg"
|
||||||
/>
|
/>
|
||||||
{isLoadingClient ? (
|
<h1 className="mt-5 text-xl font-semibold">Authorize MCP access</h1>
|
||||||
<div className="mt-5 h-7 w-48 animate-pulse rounded-md bg-base-200" />
|
|
||||||
) : (
|
|
||||||
<h1 className="mt-5 text-xl font-semibold">
|
|
||||||
{named ? (
|
|
||||||
<>
|
|
||||||
Authorize <span className="text-primary">{clientName}</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
"Authorize MCP access"
|
|
||||||
)}
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-sm text-base-content/70">
|
<p className="mt-2 text-sm text-base-content/70">
|
||||||
{named
|
An MCP client is requesting access to your OpenSEO workspace.
|
||||||
? `${clientName} is requesting access to your OpenSEO workspace.`
|
|
||||||
: "An MCP client is requesting access to your OpenSEO workspace."}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!named && !isLoadingClient ? (
|
|
||||||
<div className="mt-5 rounded-lg border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning-content/90">
|
|
||||||
This client did not provide a name during registration. Only continue
|
|
||||||
if you started this connection yourself.
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{userEmail ? (
|
{userEmail ? (
|
||||||
<div className="mt-6 flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/50 px-3 py-2 text-sm">
|
<div className="mt-6 flex items-center gap-3 rounded-lg border border-base-300 bg-base-200/50 px-3 py-2 text-sm">
|
||||||
<div className="flex size-7 items-center justify-center rounded-full bg-base-300">
|
<div className="flex size-7 items-center justify-center rounded-full bg-base-300">
|
||||||
@ -119,7 +89,7 @@ function OAuthConsentPage() {
|
|||||||
|
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<div className="text-xs font-medium uppercase tracking-wide text-base-content/60">
|
<div className="text-xs font-medium uppercase tracking-wide text-base-content/60">
|
||||||
{named ? `This will allow ${clientName} to` : "This will allow it to"}
|
This will allow it to
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-3 space-y-3">
|
<ul className="mt-3 space-y-3">
|
||||||
{SCOPES.map((scope) => (
|
{SCOPES.map((scope) => (
|
||||||
|
|||||||
@ -1,106 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
import { getAuth, hasHostedAuthConfig } from "@/lib/auth";
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
import { getMcpResource } from "@/lib/oauth-resource";
|
|
||||||
|
|
||||||
const TOKEN_PATH = "/api/auth/oauth2/token";
|
|
||||||
const REGISTER_PATH = "/api/auth/oauth2/register";
|
|
||||||
const PUBLIC_CLIENT_AUTH_METHOD = "none";
|
|
||||||
|
|
||||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasRequestAuthContext(request: Request) {
|
|
||||||
return Boolean(
|
|
||||||
request.headers.get("authorization") || request.headers.get("cookie"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestWithReplacedBody(request: Request, body: BodyInit) {
|
|
||||||
const headers = new Headers(request.headers);
|
|
||||||
headers.delete("content-length");
|
|
||||||
|
|
||||||
return new Request(request.url, {
|
|
||||||
method: request.method,
|
|
||||||
headers,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject RFC 8707 `resource` into /oauth2/token requests when the client
|
|
||||||
// omitted it. Some MCP clients (notably codex as of 2026-05) skip the
|
|
||||||
// resource indicator, which makes better-auth issue an opaque access token
|
|
||||||
// (see `checkResource` in @better-auth/oauth-provider — audience comes from
|
|
||||||
// `ctx.body.resource` at token-issuance time, not from the stored authorize
|
|
||||||
// query). Without an audience to bind, no `aud` claim → opaque token → no
|
|
||||||
// local JWT verify on the resource side.
|
|
||||||
//
|
|
||||||
// We only have one valid audience (`validAudiences: [mcpResource]` in
|
|
||||||
// auth-config.ts), so it is safe to default missing resources to it. Remove
|
|
||||||
// this shim once MCP clients reliably pass `resource` per spec.
|
|
||||||
export async function maybeInjectMcpResource(
|
|
||||||
request: Request,
|
|
||||||
): Promise<Request> {
|
|
||||||
if (request.method !== "POST") return request;
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
if (url.pathname !== TOKEN_PATH) return request;
|
|
||||||
|
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.includes("application/x-www-form-urlencoded"))
|
|
||||||
return request;
|
|
||||||
|
|
||||||
const body = await request.clone().text();
|
|
||||||
const params = new URLSearchParams(body);
|
|
||||||
if (params.has("resource")) return request;
|
|
||||||
|
|
||||||
params.set("resource", getMcpResource(getHostedBaseUrl()));
|
|
||||||
|
|
||||||
return requestWithReplacedBody(request, params.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Some hosted MCP clients attempt unauthenticated DCR while sending a
|
|
||||||
// confidential-client auth method. Better Auth only permits unauthenticated DCR
|
|
||||||
// for public clients, so normalize that case to the compatible public shape.
|
|
||||||
// Claude Desktop hit this path during connector setup: it had no session or
|
|
||||||
// registration bearer token, but sent a non-`none` token endpoint auth method.
|
|
||||||
export async function maybeDefaultMcpClientRegistrationAuthMethod(
|
|
||||||
request: Request,
|
|
||||||
): Promise<Request> {
|
|
||||||
if (request.method !== "POST") return request;
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
if (url.pathname !== REGISTER_PATH) return request;
|
|
||||||
|
|
||||||
const contentType = request.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.includes("application/json")) return request;
|
|
||||||
|
|
||||||
let body: unknown;
|
|
||||||
try {
|
|
||||||
body = await request.clone().json();
|
|
||||||
} catch {
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isJsonObject(body)) return request;
|
|
||||||
|
|
||||||
if (
|
|
||||||
hasRequestAuthContext(request) ||
|
|
||||||
body.token_endpoint_auth_method === PUBLIC_CLIENT_AUTH_METHOD
|
|
||||||
) {
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
return requestWithReplacedBody(
|
|
||||||
request,
|
|
||||||
JSON.stringify({
|
|
||||||
...body,
|
|
||||||
token_endpoint_auth_method: PUBLIC_CLIENT_AUTH_METHOD,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAuthRequest(request: Request) {
|
async function handleAuthRequest(request: Request) {
|
||||||
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
if (!isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
@ -116,12 +17,7 @@ async function handleAuthRequest(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auth = getAuth();
|
const auth = getAuth();
|
||||||
const requestWithRegistrationDefaults =
|
return auth.handler(request);
|
||||||
await maybeDefaultMcpClientRegistrationAuthMethod(request);
|
|
||||||
const requestWithResource = await maybeInjectMcpResource(
|
|
||||||
requestWithRegistrationDefaults,
|
|
||||||
);
|
|
||||||
return auth.handler(requestWithResource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/auth/$")({
|
export const Route = createFileRoute("/api/auth/$")({
|
||||||
|
|||||||
@ -1,181 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("cloudflare:workers", () => ({
|
|
||||||
env: {
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@tanstack/react-router", () => ({
|
|
||||||
createFileRoute: () => (routeConfig: unknown) => routeConfig,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/auth", () => ({
|
|
||||||
getAuth: () => ({ handler: vi.fn() }),
|
|
||||||
getHostedBaseUrl: () => "https://open-seo.test",
|
|
||||||
hasHostedAuthConfig: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("maybeInjectMcpResource", () => {
|
|
||||||
it("injects the MCP resource into form token requests when missing", async () => {
|
|
||||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
"Content-Length": "13",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code: "code_123",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await maybeInjectMcpResource(request);
|
|
||||||
const params = new URLSearchParams(await result.text());
|
|
||||||
|
|
||||||
expect(params.get("resource")).toBe("https://open-seo.test/mcp");
|
|
||||||
expect(params.get("grant_type")).toBe("authorization_code");
|
|
||||||
expect(params.get("code")).toBe("code_123");
|
|
||||||
expect(result.headers.has("content-length")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves token requests alone when a resource is already present", async () => {
|
|
||||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request("https://open-seo.test/api/auth/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
resource: "https://other-resource.test/mcp",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips requests that are not matching form POST token requests", async () => {
|
|
||||||
const { maybeInjectMcpResource } = await import("@/routes/api/auth/$");
|
|
||||||
const requests = [
|
|
||||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
|
||||||
method: "GET",
|
|
||||||
}),
|
|
||||||
new Request("https://open-seo.test/api/auth/oauth2/authorize", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({ grant_type: "authorization_code" }),
|
|
||||||
}),
|
|
||||||
new Request("https://open-seo.test/api/auth/oauth2/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Content-Length": "13",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ grant_type: "authorization_code" }),
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const request of requests) {
|
|
||||||
await expect(maybeInjectMcpResource(request)).resolves.toBe(request);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("maybeDefaultMcpClientRegistrationAuthMethod", () => {
|
|
||||||
it("defaults JSON dynamic client registration to a public client when omitted", async () => {
|
|
||||||
const { maybeDefaultMcpClientRegistrationAuthMethod } =
|
|
||||||
await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request(
|
|
||||||
"https://open-seo.test/api/auth/oauth2/register",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
client_name: "Claude",
|
|
||||||
redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await maybeDefaultMcpClientRegistrationAuthMethod(request);
|
|
||||||
const body = await result.json();
|
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
client_name: "Claude",
|
|
||||||
redirect_uris: ["https://claude.ai/api/mcp/auth_callback"],
|
|
||||||
token_endpoint_auth_method: "none",
|
|
||||||
});
|
|
||||||
expect(result.headers.has("content-length")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("forces unauthenticated dynamic client registration to a public client", async () => {
|
|
||||||
const { maybeDefaultMcpClientRegistrationAuthMethod } =
|
|
||||||
await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request(
|
|
||||||
"https://open-seo.test/api/auth/oauth2/register",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
token_endpoint_auth_method: "client_secret_basic",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = await maybeDefaultMcpClientRegistrationAuthMethod(request);
|
|
||||||
const body = await result.json();
|
|
||||||
|
|
||||||
expect(body).toMatchObject({
|
|
||||||
token_endpoint_auth_method: "none",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves explicit registration auth methods alone when auth context exists", async () => {
|
|
||||||
const { maybeDefaultMcpClientRegistrationAuthMethod } =
|
|
||||||
await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request(
|
|
||||||
"https://open-seo.test/api/auth/oauth2/register",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Cookie: "better-auth.session_token=session_123",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
token_endpoint_auth_method: "client_secret_basic",
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
maybeDefaultMcpClientRegistrationAuthMethod(request),
|
|
||||||
).resolves.toBe(request);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves malformed JSON registration requests for the auth handler", async () => {
|
|
||||||
const { maybeDefaultMcpClientRegistrationAuthMethod } =
|
|
||||||
await import("@/routes/api/auth/$");
|
|
||||||
const request = new Request(
|
|
||||||
"https://open-seo.test/api/auth/oauth2/register",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: "{",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
maybeDefaultMcpClientRegistrationAuthMethod(request),
|
|
||||||
).resolves.toBe(request);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -6,21 +6,34 @@ import { RankTrackingRepository } from "@/server/features/rank-tracking/reposito
|
|||||||
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
import { beginRankCheckRun } from "@/server/features/rank-tracking/services/rankCheckRunGuards";
|
||||||
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
import { customerHasPaidPlan } from "@/server/billing/subscription";
|
||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
||||||
|
import {
|
||||||
|
createOpenSeoOAuthProvider,
|
||||||
|
type OpenSeoOAuthEnv,
|
||||||
|
} from "@/server/mcp/oauth-provider";
|
||||||
|
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
import { computeNextCheckAt } from "@/shared/rank-tracking";
|
||||||
import { handleMcpRequest, MCP_ROUTE } from "@/server/mcp/handler";
|
|
||||||
|
|
||||||
const appFetch = createStartHandler(defaultStreamHandler);
|
const appFetch = createStartHandler(defaultStreamHandler);
|
||||||
const fetch = (
|
const handleAppFetch = (request: Request): Response | Promise<Response> =>
|
||||||
|
appFetch(request);
|
||||||
|
const openSeoOAuthProvider = createOpenSeoOAuthProvider(handleAppFetch);
|
||||||
|
|
||||||
|
function fetch(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
ctx: ExecutionContext,
|
ctx: ExecutionContext,
|
||||||
): Response | Promise<Response> => {
|
): Response | Promise<Response> {
|
||||||
if (new URL(request.url).pathname === MCP_ROUTE) {
|
if (isHostedAuthMode(env.AUTH_MODE)) {
|
||||||
return handleMcpRequest(request, env, ctx);
|
return openSeoOAuthProvider.fetch(
|
||||||
|
requestWithPublicOrigin(request),
|
||||||
|
env as OpenSeoOAuthEnv,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return appFetch(request);
|
return handleAppFetch(request);
|
||||||
};
|
}
|
||||||
|
|
||||||
// Export Workflow classes as named exports
|
// Export Workflow classes as named exports
|
||||||
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
|
||||||
|
|||||||
43
src/server/mcp/context.test.ts
Normal file
43
src/server/mcp/context.test.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createWorkersOAuthMcpProps,
|
||||||
|
MCP_AUTH_CONTEXT_PROP,
|
||||||
|
withWorkersOAuthMcpScopes,
|
||||||
|
workersOAuthMcpPropsSchema,
|
||||||
|
} from "@/server/mcp/context";
|
||||||
|
|
||||||
|
const mcpContext = {
|
||||||
|
userId: "user_123",
|
||||||
|
userEmail: "alice@example.com",
|
||||||
|
organizationId: "org_123",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["offline_access", "mcp"],
|
||||||
|
audience: "https://open-seo.test/mcp",
|
||||||
|
subject: "user_123",
|
||||||
|
baseUrl: "https://open-seo.test",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("withWorkersOAuthMcpScopes", () => {
|
||||||
|
it("stores the OpenSEO MCP context in Workers OAuth props", () => {
|
||||||
|
const props = createWorkersOAuthMcpProps(mcpContext);
|
||||||
|
|
||||||
|
expect(workersOAuthMcpPropsSchema.parse(props)).toEqual({
|
||||||
|
[MCP_AUTH_CONTEXT_PROP]: mcpContext,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates access-token props with downscoped token scopes", () => {
|
||||||
|
const props = createWorkersOAuthMcpProps(mcpContext);
|
||||||
|
|
||||||
|
expect(withWorkersOAuthMcpScopes(props, ["mcp"])).toEqual({
|
||||||
|
[MCP_AUTH_CONTEXT_PROP]: {
|
||||||
|
...mcpContext,
|
||||||
|
scopes: ["mcp"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves unrecognized provider props alone", () => {
|
||||||
|
expect(withWorkersOAuthMcpScopes({}, ["mcp"])).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,9 @@
|
|||||||
import { getMcpAuthContext } from "agents/mcp";
|
import type {
|
||||||
|
ServerNotification,
|
||||||
|
ServerRequest,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { buildDashboardUrl } from "@/server/mcp/urls";
|
import { buildDashboardUrl } from "@/server/mcp/urls";
|
||||||
@ -14,6 +19,7 @@ type McpAuth = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
|
export const MCP_AUTH_CONTEXT_PROP = "openSeoAuth";
|
||||||
|
export const MCP_ROUTE = "/mcp";
|
||||||
|
|
||||||
const mcpToolAuthContextSchema = z.object({
|
const mcpToolAuthContextSchema = z.object({
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@ -28,10 +34,48 @@ const mcpToolAuthContextSchema = z.object({
|
|||||||
|
|
||||||
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
|
type McpToolAuthContext = z.infer<typeof mcpToolAuthContextSchema>;
|
||||||
|
|
||||||
export type ToolExtra = unknown;
|
export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
|
||||||
|
|
||||||
export function requireMcpToolAuthContext(): McpToolAuthContext {
|
export const workersOAuthMcpPropsSchema = z.object({
|
||||||
const rawContext = getMcpAuthContext()?.props[MCP_AUTH_CONTEXT_PROP];
|
[MCP_AUTH_CONTEXT_PROP]: mcpToolAuthContextSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mcpToolAuthContextStorage = new AsyncLocalStorage<McpToolAuthContext>();
|
||||||
|
|
||||||
|
export function createWorkersOAuthMcpProps(
|
||||||
|
context: McpToolAuthContext,
|
||||||
|
): Record<string, McpToolAuthContext> {
|
||||||
|
return {
|
||||||
|
[MCP_AUTH_CONTEXT_PROP]: context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withWorkersOAuthMcpScopes(
|
||||||
|
props: unknown,
|
||||||
|
scopes: string[],
|
||||||
|
): Record<string, McpToolAuthContext> | undefined {
|
||||||
|
const result = workersOAuthMcpPropsSchema.safeParse(props);
|
||||||
|
if (!result.success) return undefined;
|
||||||
|
|
||||||
|
return createWorkersOAuthMcpProps({
|
||||||
|
...result.data[MCP_AUTH_CONTEXT_PROP],
|
||||||
|
scopes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWithMcpToolAuthContext<T>(
|
||||||
|
context: McpToolAuthContext,
|
||||||
|
callback: () => T,
|
||||||
|
) {
|
||||||
|
return mcpToolAuthContextStorage.run(context, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireMcpToolAuthContext(
|
||||||
|
extra: ToolExtra,
|
||||||
|
): McpToolAuthContext {
|
||||||
|
const rawContext =
|
||||||
|
mcpToolAuthContextStorage.getStore() ??
|
||||||
|
extra.authInfo?.extra?.[MCP_AUTH_CONTEXT_PROP];
|
||||||
const result = mcpToolAuthContextSchema.safeParse(rawContext);
|
const result = mcpToolAuthContextSchema.safeParse(rawContext);
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@ -41,13 +85,13 @@ export function requireMcpToolAuthContext(): McpToolAuthContext {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuth(_extra?: ToolExtra): McpAuth {
|
export function getAuth(extra: ToolExtra): McpAuth {
|
||||||
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext();
|
const { baseUrl: _baseUrl, ...auth } = requireMcpToolAuthContext(extra);
|
||||||
return auth;
|
return auth;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getBaseUrl(_extra?: ToolExtra): string {
|
export function getBaseUrl(extra: ToolExtra): string {
|
||||||
return requireMcpToolAuthContext().baseUrl;
|
return requireMcpToolAuthContext(extra).baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildBillingCustomer(
|
export function buildBillingCustomer(
|
||||||
|
|||||||
@ -1,332 +0,0 @@
|
|||||||
import type { CreateMcpHandlerOptions } from "agents/mcp";
|
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
|
||||||
|
|
||||||
const verifyMocks = vi.hoisted(() => ({
|
|
||||||
verifyJwsAccessToken: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const userEmailMocks = vi.hoisted(() => ({
|
|
||||||
getMcpUserEmail: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const serverMocks = vi.hoisted(() => ({
|
|
||||||
nextServerId: 0,
|
|
||||||
createdServerIds: [] as number[],
|
|
||||||
serverIds: new WeakMap<McpServer, number>(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/auth", () => ({
|
|
||||||
getAuth: () => ({ api: { getJwks: vi.fn() } }),
|
|
||||||
getHostedBaseUrl: () => "https://open-seo.test",
|
|
||||||
hasHostedAuthConfig: () => true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("better-auth/oauth2", () => ({
|
|
||||||
verifyJwsAccessToken: verifyMocks.verifyJwsAccessToken,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/mcp/server", () => ({
|
|
||||||
createOpenSeoMcpServer: () => {
|
|
||||||
serverMocks.nextServerId += 1;
|
|
||||||
const server = new McpServer({ name: "Test MCP", version: "0.0.0" });
|
|
||||||
serverMocks.createdServerIds.push(serverMocks.nextServerId);
|
|
||||||
serverMocks.serverIds.set(server, serverMocks.nextServerId);
|
|
||||||
return server;
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/mcp/user-email", () => ({
|
|
||||||
getMcpUserEmail: userEmailMocks.getMcpUserEmail,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("agents/mcp", () => ({
|
|
||||||
createMcpHandler: (_server: McpServer, options: CreateMcpHandlerOptions) => {
|
|
||||||
return async () =>
|
|
||||||
new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
serverId: serverMocks.serverIds.get(_server),
|
|
||||||
options,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ctx: ExecutionContext = {
|
|
||||||
waitUntil() {},
|
|
||||||
passThroughOnException() {},
|
|
||||||
props: {},
|
|
||||||
};
|
|
||||||
|
|
||||||
const transportOptionsSchema = z.object({
|
|
||||||
serverId: z.number().optional(),
|
|
||||||
options: z.object({
|
|
||||||
route: z.string().optional(),
|
|
||||||
enableJsonResponse: z.boolean().optional(),
|
|
||||||
authContext: z
|
|
||||||
.object({
|
|
||||||
props: z.record(z.string(), z.unknown()),
|
|
||||||
})
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
function createMcpRequest({
|
|
||||||
method = "tools/call",
|
|
||||||
token,
|
|
||||||
}: {
|
|
||||||
method?: string;
|
|
||||||
token?: string;
|
|
||||||
} = {}) {
|
|
||||||
const headers = new Headers({
|
|
||||||
Accept: "application/json, text/event-stream",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
headers.set("Authorization", `Bearer ${token}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Request("https://open-seo.test/mcp", {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
id: 1,
|
|
||||||
method,
|
|
||||||
params:
|
|
||||||
method === "tools/call"
|
|
||||||
? {
|
|
||||||
name: "whoami",
|
|
||||||
arguments: {},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const jwtShapedToken = "header.payload.signature";
|
|
||||||
const organizationIdClaim = "https://open-seo.test/mcp/claims/organization-id";
|
|
||||||
|
|
||||||
function createAccessTokenPayload(
|
|
||||||
overrides: Record<string, unknown> = {},
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
sub: "user_123",
|
|
||||||
azp: "client_123",
|
|
||||||
scope: "offline_access mcp",
|
|
||||||
aud: "https://open-seo.test/mcp",
|
|
||||||
[organizationIdClaim]: "org_123",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("handleMcpRequest", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
serverMocks.nextServerId = 0;
|
|
||||||
serverMocks.createdServerIds = [];
|
|
||||||
serverMocks.serverIds = new WeakMap<McpServer, number>();
|
|
||||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
|
||||||
createAccessTokenPayload(),
|
|
||||||
);
|
|
||||||
userEmailMocks.getMcpUserEmail.mockResolvedValue("alice@example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts access tokens verified by Better Auth", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const body = transportOptionsSchema.parse(await response.json());
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(
|
|
||||||
body.options.authContext?.props[MCP_AUTH_CONTEXT_PROP],
|
|
||||||
).toMatchObject({
|
|
||||||
userId: "user_123",
|
|
||||||
userEmail: "alice@example.com",
|
|
||||||
organizationId: "org_123",
|
|
||||||
clientId: "client_123",
|
|
||||||
scopes: ["offline_access", "mcp"],
|
|
||||||
audience: "https://open-seo.test/mcp",
|
|
||||||
subject: "user_123",
|
|
||||||
baseUrl: "https://open-seo.test",
|
|
||||||
});
|
|
||||||
expect(body.options.route).toBe("/mcp");
|
|
||||||
expect(body.options.enableJsonResponse).toBe(true);
|
|
||||||
|
|
||||||
const functionMatcher: unknown = expect.any(Function);
|
|
||||||
expect(verifyMocks.verifyJwsAccessToken).toHaveBeenCalledWith(
|
|
||||||
jwtShapedToken,
|
|
||||||
expect.objectContaining({
|
|
||||||
verifyOptions: {
|
|
||||||
audience: "https://open-seo.test/mcp",
|
|
||||||
issuer: "https://open-seo.test/api/auth",
|
|
||||||
},
|
|
||||||
jwksFetch: functionMatcher,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a fresh server for each request without persisted transport state", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
|
|
||||||
const first = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const second = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const firstBody = transportOptionsSchema.parse(await first.json());
|
|
||||||
const secondBody = transportOptionsSchema.parse(await second.json());
|
|
||||||
|
|
||||||
expect(serverMocks.createdServerIds).toEqual([1, 2]);
|
|
||||||
expect(firstBody.serverId).toBe(1);
|
|
||||||
expect(secondBody.serverId).toBe(2);
|
|
||||||
expect(firstBody.options).not.toHaveProperty("sessionIdGenerator");
|
|
||||||
expect(firstBody.options).not.toHaveProperty("storage");
|
|
||||||
expect(firstBody.options).not.toHaveProperty("transport");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets the MCP transport handle OPTIONS without token verification", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
new Request("https://open-seo.test/mcp", { method: "OPTIONS" }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const body = transportOptionsSchema.parse(await response.json());
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled();
|
|
||||||
expect(body.options.authContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets unauthenticated initialize reach the MCP transport", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ method: "initialize" }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const body = transportOptionsSchema.parse(await response.json());
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled();
|
|
||||||
expect(body.options.authContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("lets unauthenticated tools/list reach the MCP transport", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ method: "tools/list" }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
const body = transportOptionsSchema.parse(await response.json());
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(verifyMocks.verifyJwsAccessToken).not.toHaveBeenCalled();
|
|
||||||
expect(body.options.authContext).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when Better Auth rejects the access token", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
verifyMocks.verifyJwsAccessToken.mockRejectedValue(
|
|
||||||
new Error("invalid audience"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
expect(response.headers.get("WWW-Authenticate")).toBe(
|
|
||||||
'Bearer resource_metadata="https://open-seo.test/.well-known/oauth-protected-resource/mcp"',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 403 when the verified token is missing MCP organization context", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
|
||||||
createAccessTokenPayload({
|
|
||||||
[organizationIdClaim]: undefined,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 403 when the verified user is not found", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
userEmailMocks.getMcpUserEmail.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(403);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 401 when the token is missing the required mcp scope", async () => {
|
|
||||||
const { handleMcpRequest } = await import("@/server/mcp/handler");
|
|
||||||
verifyMocks.verifyJwsAccessToken.mockResolvedValue(
|
|
||||||
createAccessTokenPayload({ scope: "offline_access" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await handleMcpRequest(
|
|
||||||
createMcpRequest({ token: jwtShapedToken }),
|
|
||||||
{
|
|
||||||
AUTH_MODE: "hosted",
|
|
||||||
},
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
import { createMcpHandler } from "agents/mcp";
|
|
||||||
import { verifyJwsAccessToken } from "better-auth/oauth2";
|
|
||||||
import type { JWTPayload } from "jose";
|
|
||||||
import { getAuth, getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
||||||
import {
|
|
||||||
getMcpOrganizationIdClaim,
|
|
||||||
getMcpProtectedResourceMetadataUrl,
|
|
||||||
getMcpResource,
|
|
||||||
MCP_SCOPE,
|
|
||||||
} from "@/lib/oauth-resource";
|
|
||||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
|
||||||
import { createOpenSeoMcpServer } from "@/server/mcp/server";
|
|
||||||
import { getMcpUserEmail } from "@/server/mcp/user-email";
|
|
||||||
|
|
||||||
// MCP request flow:
|
|
||||||
// 1. Resource (`resource=<mcp>`) is injected into /oauth2/token requests by
|
|
||||||
// `routes/api/auth/$.ts` so Better Auth always issues audience-bound JWTs
|
|
||||||
// (some MCP clients skip RFC 8707; without it tokens would be opaque).
|
|
||||||
// 2. Here we verify the JWT in-process via `verifyJwsAccessToken`, reading
|
|
||||||
// the JWKS through `auth.api.getJwks()` rather than HTTP self-fetching
|
|
||||||
// `/api/auth/jwks` (which 500s under workerd dev's self-routing and is
|
|
||||||
// pointless in prod since the auth server and resource server are the
|
|
||||||
// same Worker).
|
|
||||||
// 3. We expect `iss = baseURL + basePath` (basePath defaults to `/api/auth`)
|
|
||||||
// and `aud = mcpResource`, both confirmed against the published
|
|
||||||
// /.well-known/oauth-authorization-server metadata.
|
|
||||||
export const MCP_ROUTE = "/mcp";
|
|
||||||
|
|
||||||
type McpAccessTokenPayload = JWTPayload & {
|
|
||||||
azp?: unknown;
|
|
||||||
client_id?: unknown;
|
|
||||||
scope?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type McpJsonRpcRequest = {
|
|
||||||
method?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PUBLIC_MCP_METHODS = new Set([
|
|
||||||
"initialize",
|
|
||||||
"notifications/initialized",
|
|
||||||
"ping",
|
|
||||||
"tools/list",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function getTokenScopes(payload: McpAccessTokenPayload) {
|
|
||||||
return typeof payload.scope === "string"
|
|
||||||
? payload.scope.split(/\s+/).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStringClaim(payload: Record<string, unknown>, claim: string) {
|
|
||||||
const value = payload[claim];
|
|
||||||
return typeof value === "string" && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function unauthorizedResponse(resource: string) {
|
|
||||||
return new Response("Unauthorized", {
|
|
||||||
status: 401,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
||||||
"WWW-Authenticate": `Bearer resource_metadata="${getMcpProtectedResourceMetadataUrl(
|
|
||||||
resource,
|
|
||||||
)}"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMcpJsonRpcMethod(request: Request) {
|
|
||||||
if (request.method !== "POST") return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const body: McpJsonRpcRequest = await request.clone().json();
|
|
||||||
return typeof body.method === "string" ? body.method : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPublicMcpRequest(request: Request, jsonRpcMethod: string | null) {
|
|
||||||
if (request.method === "OPTIONS") return true;
|
|
||||||
return jsonRpcMethod != null && PUBLIC_MCP_METHODS.has(jsonRpcMethod);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function handleMcpRequest(
|
|
||||||
request: Request,
|
|
||||||
env: { AUTH_MODE?: unknown },
|
|
||||||
ctx: ExecutionContext,
|
|
||||||
) {
|
|
||||||
const authMode =
|
|
||||||
typeof env.AUTH_MODE === "string" ? env.AUTH_MODE : undefined;
|
|
||||||
const jsonRpcMethod = await getMcpJsonRpcMethod(request);
|
|
||||||
|
|
||||||
if (!isHostedAuthMode(authMode)) {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasHostedAuthConfig()) {
|
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getHostedBaseUrl();
|
|
||||||
const auth = getAuth();
|
|
||||||
const mcpResource = getMcpResource(baseUrl);
|
|
||||||
const issuer = `${baseUrl}/api/auth`;
|
|
||||||
const organizationIdClaim = getMcpOrganizationIdClaim(baseUrl);
|
|
||||||
const server = createOpenSeoMcpServer();
|
|
||||||
|
|
||||||
if (isPublicMcpRequest(request, jsonRpcMethod)) {
|
|
||||||
return createMcpHandler(server, {
|
|
||||||
route: MCP_ROUTE,
|
|
||||||
enableJsonResponse: true,
|
|
||||||
})(request, env, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken =
|
|
||||||
request.headers
|
|
||||||
.get("Authorization")
|
|
||||||
?.replace(/^Bearer\s+/i, "")
|
|
||||||
.trim() || undefined;
|
|
||||||
|
|
||||||
let payload: McpAccessTokenPayload;
|
|
||||||
try {
|
|
||||||
if (!accessToken) throw new Error("missing access token");
|
|
||||||
payload = await verifyJwsAccessToken(accessToken, {
|
|
||||||
jwksFetch: () => auth.api.getJwks(),
|
|
||||||
verifyOptions: { audience: mcpResource, issuer },
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return unauthorizedResponse(mcpResource);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scopes = getTokenScopes(payload);
|
|
||||||
if (!scopes.includes(MCP_SCOPE)) {
|
|
||||||
return unauthorizedResponse(mcpResource);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = getStringClaim(payload, "sub");
|
|
||||||
const organizationId = getStringClaim(payload, organizationIdClaim);
|
|
||||||
const clientId =
|
|
||||||
getStringClaim(payload, "azp") ?? getStringClaim(payload, "client_id");
|
|
||||||
|
|
||||||
if (!userId || !organizationId) {
|
|
||||||
return new Response(
|
|
||||||
userId
|
|
||||||
? "MCP organization context required"
|
|
||||||
: "MCP user context required",
|
|
||||||
{
|
|
||||||
status: 403,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const userEmail = await getMcpUserEmail(userId);
|
|
||||||
if (!userEmail) {
|
|
||||||
return new Response("MCP user context required", {
|
|
||||||
status: 403,
|
|
||||||
headers: {
|
|
||||||
"Access-Control-Allow-Origin": "*",
|
|
||||||
"Access-Control-Expose-Headers": "WWW-Authenticate",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return createMcpHandler(server, {
|
|
||||||
route: MCP_ROUTE,
|
|
||||||
enableJsonResponse: true,
|
|
||||||
authContext: {
|
|
||||||
props: {
|
|
||||||
[MCP_AUTH_CONTEXT_PROP]: {
|
|
||||||
userId,
|
|
||||||
userEmail,
|
|
||||||
organizationId,
|
|
||||||
clientId,
|
|
||||||
scopes,
|
|
||||||
audience: mcpResource,
|
|
||||||
subject: userId,
|
|
||||||
baseUrl,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})(request, env, ctx);
|
|
||||||
}
|
|
||||||
407
src/server/mcp/oauth-provider.ts
Normal file
407
src/server/mcp/oauth-provider.ts
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
import {
|
||||||
|
OAuthProvider,
|
||||||
|
type AuthRequest,
|
||||||
|
type OAuthHelpers,
|
||||||
|
type OAuthProviderOptions,
|
||||||
|
} from "@cloudflare/workers-oauth-provider";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getHostedBaseUrl } from "@/lib/auth";
|
||||||
|
import {
|
||||||
|
getMcpResource,
|
||||||
|
MCP_OAUTH_SCOPES,
|
||||||
|
MCP_SCOPE,
|
||||||
|
} from "@/lib/oauth-resource";
|
||||||
|
import { asAppError } from "@/server/lib/errors";
|
||||||
|
import {
|
||||||
|
createWorkersOAuthMcpProps,
|
||||||
|
MCP_ROUTE,
|
||||||
|
withWorkersOAuthMcpScopes,
|
||||||
|
} from "@/server/mcp/context";
|
||||||
|
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
||||||
|
import { handleAuthenticatedOpenSeoMcpRequest } from "@/server/mcp/transport";
|
||||||
|
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
||||||
|
|
||||||
|
const OAUTH_AUTHORIZE_PATH = "/api/auth/oauth2/authorize";
|
||||||
|
const OAUTH_TOKEN_PATH = "/api/auth/oauth2/token";
|
||||||
|
const OAUTH_REGISTER_PATH = "/api/auth/oauth2/register";
|
||||||
|
|
||||||
|
const OAUTH_CONSENT_RESPONSE_PATH = "/api/oauth/consent";
|
||||||
|
const WWW_AUTHENTICATE_HEADER = "WWW-Authenticate";
|
||||||
|
const OAUTH_AUTHORIZATION_PARAM_NAMES = [
|
||||||
|
"response_type",
|
||||||
|
"client_id",
|
||||||
|
"redirect_uri",
|
||||||
|
"scope",
|
||||||
|
"state",
|
||||||
|
"code_challenge",
|
||||||
|
"code_challenge_method",
|
||||||
|
"resource",
|
||||||
|
] as const;
|
||||||
|
const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
|
export type OpenSeoOAuthEnv = Env & {
|
||||||
|
OAUTH_KV: KVNamespace;
|
||||||
|
OAUTH_PROVIDER?: OAuthHelpers;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AppFetch = (request: Request) => Response | Promise<Response>;
|
||||||
|
|
||||||
|
type OAuthExecutionContext = ExecutionContext & {
|
||||||
|
props?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExportedHandlerWithFetch<Env> = ExportedHandler<Env> & {
|
||||||
|
fetch: NonNullable<ExportedHandler<Env>["fetch"]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const consentResponseSchema = z.object({
|
||||||
|
accept: z.boolean(),
|
||||||
|
query: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getOAuthHelpers(env: OpenSeoOAuthEnv) {
|
||||||
|
if (!env.OAUTH_PROVIDER) {
|
||||||
|
throw new Error("OAuth provider helpers are unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return env.OAUTH_PROVIDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpResourceForRequest(request: Request) {
|
||||||
|
return getMcpResource(getPublicOrigin(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRelativeRequestTarget(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return `${url.pathname}${url.search}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectToSignIn(request: Request) {
|
||||||
|
const signInUrl = new URL("/sign-in", request.url);
|
||||||
|
signInUrl.searchParams.set("redirect", getRelativeRequestTarget(request));
|
||||||
|
return Response.redirect(signInUrl.toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidOAuthRequestResponse(error: unknown) {
|
||||||
|
return new Response(
|
||||||
|
error instanceof Error ? error.message : "Invalid OAuth request",
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, init?: ResponseInit) {
|
||||||
|
const headers = new Headers(init?.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthErrorResponse(error: {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
status: number;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}) {
|
||||||
|
console.warn(`[oauth] ${error.status} ${error.code}: ${error.description}`);
|
||||||
|
|
||||||
|
const headers = new Headers(error.headers);
|
||||||
|
headers.set("Content-Type", "application/json");
|
||||||
|
if (headers.has(WWW_AUTHENTICATE_HEADER)) {
|
||||||
|
headers.set("Access-Control-Expose-Headers", WWW_AUTHENTICATE_HEADER);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: error.code,
|
||||||
|
error_description: error.description,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: error.status,
|
||||||
|
headers,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function csrfProtected(request: Request) {
|
||||||
|
const origin = request.headers.get("Origin");
|
||||||
|
return origin === getPublicOrigin(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAuthorizeSessionBlocker(request: Request) {
|
||||||
|
try {
|
||||||
|
await resolveHostedContext(request.headers);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
const appError = asAppError(error);
|
||||||
|
if (appError?.code === "UNAUTHENTICATED") {
|
||||||
|
return redirectToSignIn(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appError?.code === "AUTH_CONFIG_MISSING") {
|
||||||
|
return new Response("Missing Better Auth hosted configuration", {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveContextForConsent(request: Request) {
|
||||||
|
try {
|
||||||
|
return await resolveHostedContext(request.headers);
|
||||||
|
} catch (error) {
|
||||||
|
const appError = asAppError(error);
|
||||||
|
if (appError?.code === "UNAUTHENTICATED") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildConsentUrl(request: Request) {
|
||||||
|
const sourceUrl = new URL(request.url);
|
||||||
|
const consentUrl = new URL("/oauth-consent", request.url);
|
||||||
|
|
||||||
|
for (const key of OAUTH_AUTHORIZATION_PARAM_NAMES) {
|
||||||
|
for (const value of sourceUrl.searchParams.getAll(key)) {
|
||||||
|
consentUrl.searchParams.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return consentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorizeRequestFromConsentQuery(
|
||||||
|
request: Request,
|
||||||
|
query: string,
|
||||||
|
) {
|
||||||
|
const authorizeUrl = new URL(OAUTH_AUTHORIZE_PATH, request.url);
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
|
||||||
|
for (const key of OAUTH_AUTHORIZATION_PARAM_NAMES) {
|
||||||
|
for (const value of params.getAll(key)) {
|
||||||
|
authorizeUrl.searchParams.append(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Request(authorizeUrl.toString(), {
|
||||||
|
headers: request.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withDefaultMcpResource(authRequest: AuthRequest, request: Request) {
|
||||||
|
const mcpResource = getMcpResourceForRequest(request);
|
||||||
|
if (!authRequest.resource) {
|
||||||
|
return {
|
||||||
|
...authRequest,
|
||||||
|
resource: mcpResource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedResources = Array.isArray(authRequest.resource)
|
||||||
|
? authRequest.resource
|
||||||
|
: [authRequest.resource];
|
||||||
|
|
||||||
|
if (requestedResources.some((resource) => resource !== mcpResource)) {
|
||||||
|
throw new Error(`OAuth resource must be ${mcpResource}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...authRequest,
|
||||||
|
resource: mcpResource,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGrantedMcpScopes(requestedScopes: string[]) {
|
||||||
|
if (requestedScopes.length === 0) {
|
||||||
|
return [...MCP_OAUTH_SCOPES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = new Set(requestedScopes);
|
||||||
|
const granted = MCP_OAUTH_SCOPES.filter((scope) => requested.has(scope));
|
||||||
|
|
||||||
|
if (!granted.includes(MCP_SCOPE)) {
|
||||||
|
throw new Error("The mcp scope is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deniedRedirect(authRequest: AuthRequest) {
|
||||||
|
const redirectUrl = new URL(authRequest.redirectUri);
|
||||||
|
redirectUrl.searchParams.set("error", "access_denied");
|
||||||
|
redirectUrl.searchParams.set("error_description", "The user denied access");
|
||||||
|
if (authRequest.state) {
|
||||||
|
redirectUrl.searchParams.set("state", authRequest.state);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirectUrl.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOAuthAuthorizeRequest(
|
||||||
|
request: Request,
|
||||||
|
env: OpenSeoOAuthEnv,
|
||||||
|
) {
|
||||||
|
const oauth = getOAuthHelpers(env);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await oauth.parseAuthRequest(request);
|
||||||
|
} catch (error) {
|
||||||
|
return invalidOAuthRequestResponse(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionBlocker = await getAuthorizeSessionBlocker(request);
|
||||||
|
if (sessionBlocker) return sessionBlocker;
|
||||||
|
|
||||||
|
return Response.redirect(buildConsentUrl(request).toString(), 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOAuthConsentResponse(
|
||||||
|
request: Request,
|
||||||
|
env: OpenSeoOAuthEnv,
|
||||||
|
) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("Method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!csrfProtected(request)) {
|
||||||
|
return jsonResponse({ error: "Invalid request origin" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawBody: unknown;
|
||||||
|
try {
|
||||||
|
rawBody = await request.json();
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ error: "Invalid consent response" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = consentResponseSchema.safeParse(rawBody);
|
||||||
|
if (!body.success) {
|
||||||
|
return jsonResponse({ error: "Invalid consent response" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oauth = getOAuthHelpers(env);
|
||||||
|
const authorizeRequest = buildAuthorizeRequestFromConsentQuery(
|
||||||
|
request,
|
||||||
|
body.data.query,
|
||||||
|
);
|
||||||
|
|
||||||
|
let authRequest: AuthRequest;
|
||||||
|
try {
|
||||||
|
authRequest = await oauth.parseAuthRequest(authorizeRequest);
|
||||||
|
authRequest = withDefaultMcpResource(authRequest, request);
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "Invalid OAuth request",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!body.data.accept) {
|
||||||
|
return jsonResponse({ redirectTo: deniedRedirect(authRequest) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = await resolveContextForConsent(request);
|
||||||
|
if (!context) {
|
||||||
|
return jsonResponse({ error: "Sign in required" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let scopes: string[];
|
||||||
|
try {
|
||||||
|
scopes = getGrantedMcpScopes(authRequest.scope);
|
||||||
|
} catch (error) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: error instanceof Error ? error.message : "Invalid OAuth scopes",
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audience = getMcpResourceForRequest(request);
|
||||||
|
const props = createWorkersOAuthMcpProps({
|
||||||
|
userId: context.userId,
|
||||||
|
userEmail: context.userEmail,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
clientId: authRequest.clientId,
|
||||||
|
scopes,
|
||||||
|
audience,
|
||||||
|
subject: context.userId,
|
||||||
|
baseUrl: getHostedBaseUrl(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { redirectTo } = await oauth.completeAuthorization({
|
||||||
|
request: authRequest,
|
||||||
|
userId: context.userId,
|
||||||
|
metadata: {
|
||||||
|
clientId: authRequest.clientId,
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
},
|
||||||
|
scope: scopes,
|
||||||
|
props,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse({ redirectTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultHandler(
|
||||||
|
appFetch: AppFetch,
|
||||||
|
): ExportedHandlerWithFetch<OpenSeoOAuthEnv> {
|
||||||
|
return {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (url.pathname === OAUTH_AUTHORIZE_PATH) {
|
||||||
|
return handleOAuthAuthorizeRequest(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === OAUTH_CONSENT_RESPONSE_PATH) {
|
||||||
|
return handleOAuthConsentResponse(request, env);
|
||||||
|
}
|
||||||
|
|
||||||
|
return appFetch(request);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpApiHandler: ExportedHandlerWithFetch<OpenSeoOAuthEnv> = {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
return handleAuthenticatedOpenSeoMcpRequest(
|
||||||
|
request,
|
||||||
|
(ctx as OAuthExecutionContext).props,
|
||||||
|
env,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createOpenSeoOAuthProvider(appFetch: AppFetch) {
|
||||||
|
const options: OAuthProviderOptions<OpenSeoOAuthEnv> = {
|
||||||
|
apiRoute: MCP_ROUTE,
|
||||||
|
apiHandler: mcpApiHandler,
|
||||||
|
defaultHandler: createDefaultHandler(appFetch),
|
||||||
|
authorizeEndpoint: OAUTH_AUTHORIZE_PATH,
|
||||||
|
tokenEndpoint: OAUTH_TOKEN_PATH,
|
||||||
|
clientRegistrationEndpoint: OAUTH_REGISTER_PATH,
|
||||||
|
scopesSupported: [...MCP_OAUTH_SCOPES],
|
||||||
|
refreshTokenTTL: REFRESH_TOKEN_TTL_SECONDS,
|
||||||
|
resourceMetadata: {
|
||||||
|
scopes_supported: [...MCP_OAUTH_SCOPES],
|
||||||
|
resource_name: "OpenSEO MCP",
|
||||||
|
},
|
||||||
|
tokenExchangeCallback: ({ props, requestedScope }) => {
|
||||||
|
const accessTokenProps = withWorkersOAuthMcpScopes(props, requestedScope);
|
||||||
|
|
||||||
|
return accessTokenProps ? { accessTokenProps } : undefined;
|
||||||
|
},
|
||||||
|
onError: oauthErrorResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new OAuthProvider(options);
|
||||||
|
}
|
||||||
@ -1,15 +1,12 @@
|
|||||||
|
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||||
|
import type { ToolExtra } from "@/server/mcp/context";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
import { MCP_AUTH_CONTEXT_PROP } from "@/server/mcp/context";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
getMcpAuthContext: vi.fn(),
|
|
||||||
getProjectForOrganization: vi.fn(),
|
getProjectForOrganization: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("agents/mcp", () => ({
|
|
||||||
getMcpAuthContext: mocks.getMcpAuthContext,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
vi.mock("@/server/features/projects/services/ProjectService", () => ({
|
||||||
ProjectService: {
|
ProjectService: {
|
||||||
getProjectForOrganization: mocks.getProjectForOrganization,
|
getProjectForOrganization: mocks.getProjectForOrganization,
|
||||||
@ -27,14 +24,24 @@ const authContext = {
|
|||||||
baseUrl: "https://open-seo.test",
|
baseUrl: "https://open-seo.test",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toolExtra: ToolExtra = {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
requestId: 1,
|
||||||
|
sendNotification: vi.fn(),
|
||||||
|
sendRequest: vi.fn(),
|
||||||
|
authInfo: {
|
||||||
|
token: "token",
|
||||||
|
clientId: "client_123",
|
||||||
|
scopes: ["mcp"],
|
||||||
|
resource: new URL("https://open-seo.test/mcp"),
|
||||||
|
extra: { [MCP_AUTH_CONTEXT_PROP]: authContext },
|
||||||
|
} satisfies AuthInfo,
|
||||||
|
};
|
||||||
|
|
||||||
describe("withMcpProjectAuth", () => {
|
describe("withMcpProjectAuth", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
mocks.getMcpAuthContext.mockReset();
|
|
||||||
mocks.getProjectForOrganization.mockReset();
|
mocks.getProjectForOrganization.mockReset();
|
||||||
mocks.getMcpAuthContext.mockReturnValue({
|
|
||||||
props: { [MCP_AUTH_CONTEXT_PROP]: authContext },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("checks project access for the authenticated organization", async () => {
|
it("checks project access for the authenticated organization", async () => {
|
||||||
@ -43,7 +50,7 @@ describe("withMcpProjectAuth", () => {
|
|||||||
|
|
||||||
const wrapped = withMcpProjectAuth(handler);
|
const wrapped = withMcpProjectAuth(handler);
|
||||||
await expect(
|
await expect(
|
||||||
wrapped({ projectId: "project_123" }, undefined),
|
wrapped({ projectId: "project_123" }, toolExtra),
|
||||||
).resolves.toBe("ok");
|
).resolves.toBe("ok");
|
||||||
|
|
||||||
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
|
expect(mocks.getProjectForOrganization).toHaveBeenCalledWith(
|
||||||
@ -57,7 +64,7 @@ describe("withMcpProjectAuth", () => {
|
|||||||
const handler = vi.fn().mockReturnValue("ok");
|
const handler = vi.fn().mockReturnValue("ok");
|
||||||
|
|
||||||
const wrapped = withMcpProjectAuth(handler);
|
const wrapped = withMcpProjectAuth(handler);
|
||||||
await wrapped({ projectId: "project_123" }, undefined);
|
await wrapped({ projectId: "project_123" }, toolExtra);
|
||||||
|
|
||||||
expect(handler).toHaveBeenCalledWith(
|
expect(handler).toHaveBeenCalledWith(
|
||||||
{ projectId: "project_123" },
|
{ projectId: "project_123" },
|
||||||
@ -89,7 +96,7 @@ describe("withMcpProjectAuth", () => {
|
|||||||
const handler = vi.fn();
|
const handler = vi.fn();
|
||||||
|
|
||||||
const wrapped = withMcpProjectAuth(handler);
|
const wrapped = withMcpProjectAuth(handler);
|
||||||
await expect(wrapped({ projectId: "project_123" }, undefined)).rejects.toBe(
|
await expect(wrapped({ projectId: "project_123" }, toolExtra)).rejects.toBe(
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -10,7 +10,7 @@ type ProjectScopedArgs = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function requireProjectAccess(_extra: ToolExtra, projectId: string) {
|
async function requireProjectAccess(_extra: ToolExtra, projectId: string) {
|
||||||
const { baseUrl, ...auth } = requireMcpToolAuthContext();
|
const { baseUrl, ...auth } = requireMcpToolAuthContext(_extra);
|
||||||
|
|
||||||
// This lookup enforces that the project belongs to the authenticated org.
|
// This lookup enforces that the project belongs to the authenticated org.
|
||||||
await ProjectService.getProjectForOrganization(
|
await ProjectService.getProjectForOrganization(
|
||||||
|
|||||||
@ -1,44 +0,0 @@
|
|||||||
import { getHostedBaseUrl, hasHostedAuthConfig } from "@/lib/auth";
|
|
||||||
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
||||||
import { getOAuthProviderResourceActions } from "@/lib/oauth-provider-resource-client";
|
|
||||||
import { getMcpResource, MCP_OAUTH_SCOPES } from "@/lib/oauth-resource";
|
|
||||||
|
|
||||||
function unavailableMcpProtectedResourceMetadataResponse(
|
|
||||||
authMode: string | null | undefined,
|
|
||||||
) {
|
|
||||||
if (!isHostedAuthMode(authMode)) {
|
|
||||||
return new Response("Not found", { status: 404 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response("Missing Better Auth hosted configuration", {
|
|
||||||
status: 500,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function mcpProtectedResourceMetadataResponse(
|
|
||||||
authMode: string | null | undefined,
|
|
||||||
) {
|
|
||||||
if (!isHostedAuthMode(authMode) || !hasHostedAuthConfig()) {
|
|
||||||
return unavailableMcpProtectedResourceMetadataResponse(authMode);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getHostedBaseUrl();
|
|
||||||
const resource = getMcpResource(baseUrl);
|
|
||||||
const authorizationServer = `${baseUrl}/api/auth`;
|
|
||||||
|
|
||||||
const metadata =
|
|
||||||
await getOAuthProviderResourceActions().getProtectedResourceMetadata({
|
|
||||||
resource,
|
|
||||||
authorization_servers: [authorizationServer],
|
|
||||||
scopes_supported: [...MCP_OAUTH_SCOPES],
|
|
||||||
resource_name: "OpenSEO MCP",
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(metadata), {
|
|
||||||
headers: {
|
|
||||||
"Cache-Control":
|
|
||||||
"public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
58
src/server/mcp/public-origin.test.ts
Normal file
58
src/server/mcp/public-origin.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getPublicOrigin, requestWithPublicOrigin } from "./public-origin";
|
||||||
|
|
||||||
|
describe("getPublicOrigin", () => {
|
||||||
|
it("uses the forwarded public protocol and host for tunneled requests", () => {
|
||||||
|
const request = new Request("http://localhost:3102/api/oauth/consent", {
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-forwarded-host": "scenario-tools.trycloudflare.com",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPublicOrigin(request)).toBe(
|
||||||
|
"https://scenario-tools.trycloudflare.com",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the request origin without proxy headers", () => {
|
||||||
|
const request = new Request("http://localhost:3102/api/oauth/consent");
|
||||||
|
|
||||||
|
expect(getPublicOrigin(request)).toBe("http://localhost:3102");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores forwarded hosts when the request is already public https", () => {
|
||||||
|
const request = new Request("https://app.openseo.so/api/oauth/consent", {
|
||||||
|
headers: {
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-forwarded-host": "evil.test",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getPublicOrigin(request)).toBe("https://app.openseo.so");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requestWithPublicOrigin", () => {
|
||||||
|
it("rewrites the request URL origin while preserving path and query", async () => {
|
||||||
|
const request = new Request("http://localhost:3102/mcp?x=1", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"x-forwarded-proto": "https",
|
||||||
|
"x-forwarded-host": "scenario-tools.trycloudflare.com",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ method: "initialize" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicRequest = requestWithPublicOrigin(request);
|
||||||
|
|
||||||
|
expect(publicRequest.url).toBe(
|
||||||
|
"https://scenario-tools.trycloudflare.com/mcp?x=1",
|
||||||
|
);
|
||||||
|
expect(publicRequest.method).toBe("POST");
|
||||||
|
await expect(publicRequest.json()).resolves.toEqual({
|
||||||
|
method: "initialize",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
43
src/server/mcp/public-origin.ts
Normal file
43
src/server/mcp/public-origin.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
function firstHeaderValue(value: string | null) {
|
||||||
|
return value?.split(",")[0]?.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getForwardedProtocol(request: Request) {
|
||||||
|
const protocol = firstHeaderValue(request.headers.get("x-forwarded-proto"));
|
||||||
|
return protocol === "http" || protocol === "https" ? protocol : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPublicOrigin(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.protocol === "https:") {
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = getForwardedProtocol(request);
|
||||||
|
const host = firstHeaderValue(request.headers.get("x-forwarded-host"));
|
||||||
|
|
||||||
|
if (!protocol || !host) {
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(`${protocol}://${host}`).origin;
|
||||||
|
} catch {
|
||||||
|
return url.origin;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestWithPublicOrigin(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const publicOrigin = getPublicOrigin(request);
|
||||||
|
|
||||||
|
if (url.origin === publicOrigin) {
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicUrl = new URL(
|
||||||
|
`${url.pathname}${url.search}${url.hash}`,
|
||||||
|
publicOrigin,
|
||||||
|
);
|
||||||
|
return new Request(publicUrl, request);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
import { getBacklinksOverviewTool } from "@/server/mcp/tools/get-backlinks-overview";
|
||||||
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
import { getDomainKeywordSuggestionsTool } from "@/server/mcp/tools/get-domain-keyword-suggestions";
|
||||||
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
import { getDomainOverviewTool } from "@/server/mcp/tools/get-domain-overview";
|
||||||
@ -10,12 +10,7 @@ import { researchKeywordsTool } from "@/server/mcp/tools/research-keywords";
|
|||||||
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
|
import { saveKeywordsTool } from "@/server/mcp/tools/save-keywords";
|
||||||
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
import { whoamiTool } from "@/server/mcp/tools/whoami";
|
||||||
|
|
||||||
export function createOpenSeoMcpServer() {
|
export function registerOpenSeoMcpTools(server: McpServer) {
|
||||||
const server = new McpServer({
|
|
||||||
name: "OpenSEO MCP",
|
|
||||||
version: "0.0.10",
|
|
||||||
});
|
|
||||||
|
|
||||||
server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler);
|
server.registerTool(whoamiTool.name, whoamiTool.config, whoamiTool.handler);
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
listProjectsTool.name,
|
listProjectsTool.name,
|
||||||
@ -62,6 +57,4 @@ export function createOpenSeoMcpServer() {
|
|||||||
getRankTrackerTool.config,
|
getRankTrackerTool.config,
|
||||||
getRankTrackerTool.handler,
|
getRankTrackerTool.handler,
|
||||||
);
|
);
|
||||||
|
|
||||||
return server;
|
|
||||||
}
|
}
|
||||||
|
|||||||
52
src/server/mcp/transport.ts
Normal file
52
src/server/mcp/transport.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { createMcpHandler } from "agents/mcp";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { MCP_SCOPE } from "@/lib/oauth-resource";
|
||||||
|
import {
|
||||||
|
MCP_AUTH_CONTEXT_PROP,
|
||||||
|
MCP_ROUTE,
|
||||||
|
runWithMcpToolAuthContext,
|
||||||
|
workersOAuthMcpPropsSchema,
|
||||||
|
} from "@/server/mcp/context";
|
||||||
|
import { registerOpenSeoMcpTools } from "@/server/mcp/server";
|
||||||
|
|
||||||
|
function createOpenSeoMcpServer() {
|
||||||
|
const server = new McpServer({
|
||||||
|
name: "OpenSEO MCP",
|
||||||
|
version: "0.0.10",
|
||||||
|
});
|
||||||
|
registerOpenSeoMcpTools(server);
|
||||||
|
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleAuthenticatedOpenSeoMcpRequest(
|
||||||
|
request: Request,
|
||||||
|
props: unknown,
|
||||||
|
env: Env,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): Promise<Response> {
|
||||||
|
const result = workersOAuthMcpPropsSchema.safeParse(props);
|
||||||
|
const scopes = result.success
|
||||||
|
? result.data[MCP_AUTH_CONTEXT_PROP].scopes
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!result.success || !scopes.includes(MCP_SCOPE)) {
|
||||||
|
return new Response("MCP auth context required", { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createOpenSeoMcpServer();
|
||||||
|
const handler = createMcpHandler(server, {
|
||||||
|
route: MCP_ROUTE,
|
||||||
|
enableJsonResponse: true,
|
||||||
|
authContext: { props: result.data },
|
||||||
|
corsOptions: {
|
||||||
|
headers:
|
||||||
|
"Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id",
|
||||||
|
exposeHeaders: "mcp-protocol-version, mcp-session-id",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return runWithMcpToolAuthContext(result.data[MCP_AUTH_CONTEXT_PROP], () =>
|
||||||
|
handler(request, env, ctx),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { user } from "@/db/schema";
|
|
||||||
|
|
||||||
export async function getMcpUserEmail(userId: string) {
|
|
||||||
const authUser = await db.query.user.findFirst({
|
|
||||||
columns: { email: true },
|
|
||||||
where: eq(user.id, userId),
|
|
||||||
});
|
|
||||||
|
|
||||||
return authUser?.email ?? null;
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db } from "@/db";
|
|
||||||
import { oauthClient } from "@/db/better-auth-schema";
|
|
||||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
|
||||||
|
|
||||||
const getOAuthClientInfoSchema = z.object({
|
|
||||||
clientId: z.string().min(1),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getOAuthClientInfo = createServerFn({ method: "POST" })
|
|
||||||
.middleware(requireAuthenticatedContext)
|
|
||||||
.inputValidator((data: unknown) => getOAuthClientInfoSchema.parse(data))
|
|
||||||
.handler(async ({ data }) => {
|
|
||||||
const row = await db
|
|
||||||
.select({
|
|
||||||
name: oauthClient.name,
|
|
||||||
icon: oauthClient.icon,
|
|
||||||
uri: oauthClient.uri,
|
|
||||||
})
|
|
||||||
.from(oauthClient)
|
|
||||||
.where(eq(oauthClient.clientId, data.clientId))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: row.name ?? null,
|
|
||||||
icon: row.icon ?? null,
|
|
||||||
uri: row.uri ?? null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -10,12 +10,16 @@ export default defineConfig(({ mode }) => {
|
|||||||
const env = loadEnv(mode, process.cwd(), "");
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
const port = env.PORT ? Number(env.PORT) : 3001;
|
const port = env.PORT ? Number(env.PORT) : 3001;
|
||||||
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
|
const showDevtools = env.VITE_SHOW_DEVTOOLS !== "false";
|
||||||
const allowedHosts = env.ALLOWED_HOST ? [env.ALLOWED_HOST] : undefined;
|
const allowedHosts = [
|
||||||
|
env.ALLOWED_HOST,
|
||||||
|
env.BETTER_AUTH_URL ? new URL(env.BETTER_AUTH_URL).hostname : undefined,
|
||||||
|
].filter((host): host is string => Boolean(host));
|
||||||
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
|
const emitSourcemaps = env.POSTHOG_SOURCEMAPS === "true";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
envPrefix: ["VITE_", "AUTH_MODE", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST"],
|
envPrefix: ["VITE_", "AUTH_MODE", "POSTHOG_PUBLIC_KEY", "POSTHOG_HOST"],
|
||||||
server: {
|
server: {
|
||||||
|
allowedHosts,
|
||||||
port,
|
port,
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
|
|||||||
@ -34,6 +34,10 @@
|
|||||||
"binding": "KV",
|
"binding": "KV",
|
||||||
"id": "4abd52f3f2c549ac83cc2cb4ceec8620",
|
"id": "4abd52f3f2c549ac83cc2cb4ceec8620",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"binding": "OAUTH_KV",
|
||||||
|
"id": "bd1759494309474a9b423b029967b0db",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
"d1_databases": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user