forked from koshikraj/ottopus
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmetadata.ts
More file actions
129 lines (120 loc) · 5.09 KB
/
Copy pathmetadata.ts
File metadata and controls
129 lines (120 loc) · 5.09 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import { config } from '../config.js'
import { SCOPES } from './scopes.js'
/**
* Discovery documents.
*
* Ottopus is both the resource server and the authorization server, which is
* the simple case the MCP spec allows and the one the endpoints were placed for
* — the issuer origin is the MCP endpoint's origin, so a client that has our
* URL can find everything else without special casing.
*
* Both documents are derived from one config value. Two hand-written copies of
* the same origin is how discovery starts pointing somewhere the server is not.
*/
/** RFC 8707's canonical resource identifier, and our OAuth issuer. */
export const resourceUrl = (): string => config.mcpUrl
/**
* Where RFC 9728 says our metadata lives: the origin's well-known path with the
* resource's own path inserted after it. For https://mcp.ottopus.xyz that is
* /.well-known/oauth-protected-resource; for http://localhost:8787/mcp it is
* /.well-known/oauth-protected-resource/mcp.
*
* Computed rather than written down, because the two deployments spell the
* resource differently and a hardcoded path would be right in one of them.
*/
export function protectedResourceMetadataUrl(): string {
const url = new URL(resourceUrl())
const path = url.pathname.replace(/\/$/, '')
return `${url.origin}/.well-known/oauth-protected-resource${path}`
}
/** Same insertion rule, for the authorization server document. */
export function authorizationServerMetadataUrl(): string {
const url = new URL(resourceUrl())
const path = url.pathname.replace(/\/$/, '')
return `${url.origin}/.well-known/oauth-authorization-server${path}`
}
/**
* Every path the two discovery documents must answer on, derived from the
* resource identifier rather than written down.
*
* A resource with a path gets the path inserted after the well-known segment,
* and the bare form is served too because the same document is mounted on the
* MCP surface, where that path is already the mount point. Hardcoding "/mcp"
* here would be right for one deployment and wrong for any MCP_URL with a
* different path.
*/
export function wellKnownPaths(): { protectedResource: string[]; authorizationServer: string[] } {
const path = new URL(resourceUrl()).pathname.replace(/\/$/, '')
const both = (base: string) => (path ? [base, `${base}${path}`] : [base])
return {
protectedResource: both('/.well-known/oauth-protected-resource'),
authorizationServer: both('/.well-known/oauth-authorization-server'),
}
}
export const endpoints = () => ({
authorization: `${resourceUrl()}/oauth/authorize`,
token: `${resourceUrl()}/oauth/token`,
registration: `${resourceUrl()}/oauth/register`,
revocation: `${resourceUrl()}/oauth/revoke`,
})
/**
* RFC 9728. The MCP spec makes this mandatory for the resource server, and it
* is the first thing a client fetches after a 401.
*/
export function protectedResourceMetadata() {
return {
resource: resourceUrl(),
authorization_servers: [resourceUrl()],
scopes_supported: [...SCOPES],
bearer_methods_supported: ['header'],
resource_name: 'Ottopus',
resource_documentation: 'https://ottopus.xyz',
}
}
/**
* RFC 8414.
*
* `token_endpoint_auth_methods_supported: ['none']` because every client here
* is public — a desktop agent or a browser extension, neither of which can keep
* a secret. PKCE is what replaces the secret, and S256 is the only method
* offered: OAuth 2.1 forbids `plain` for public clients, and the auth-code
* table has a check constraint saying so independently.
*
* `authorization_response_iss_parameter_supported` is true because we do send
* `iss` on authorization responses. RFC 9207 requires the client to reject a
* response with no `iss` once we claim this, so the claim and the behaviour
* have to move together.
*/
export function authorizationServerMetadata() {
const e = endpoints()
return {
issuer: resourceUrl(),
authorization_endpoint: e.authorization,
token_endpoint: e.token,
registration_endpoint: e.registration,
revocation_endpoint: e.revocation,
scopes_supported: [...SCOPES],
response_types_supported: ['code'],
response_modes_supported: ['query'],
grant_types_supported: ['authorization_code', 'refresh_token'],
token_endpoint_auth_methods_supported: ['none'],
code_challenge_methods_supported: ['S256'],
revocation_endpoint_auth_methods_supported: ['none'],
authorization_response_iss_parameter_supported: true,
service_documentation: 'https://ottopus.xyz',
}
}
/**
* The challenge on an unauthenticated request.
*
* `resource_metadata` is what turns a 401 into a discoverable flow — without it
* a client has a rejection and nowhere to go. `scope` follows the spec's advice
* to state what the operation needs, so a client asks for the right grant the
* first time instead of guessing and coming back.
*/
export function challenge(scope: readonly string[] = SCOPES, error?: string): string {
const parts = [`Bearer resource_metadata="${protectedResourceMetadataUrl()}"`]
if (error) parts.push(`error="${error}"`)
if (scope.length) parts.push(`scope="${scope.join(' ')}"`)
return parts.join(', ')
}