-
Notifications
You must be signed in to change notification settings - Fork 926
(docs) Authorization guide #1326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
} | ||
``` | ||
|
||
If the registration succeeds, the authorization server will return a JSON blob with client registration information, including the original registration metadata as well as `client_id`, `client_secret` (if applicable), `client_id_issued_at` (optional), and `client_secret_expires_at` (if `client_secret` is used). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it worth talking about the token_endpoint_auth_method
param and how it influences this? As is it is unclear why an auth server will or won't issue a secret.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
imo that gets too into the weeds. if we start explaining each optional branch, we'll end up with way too long of a post
|
||
</Step> | ||
|
||
<Step title="User Authorization"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This step combines the /authorize
endpoint, the OAuth auth code callback handling, and the /token
endpoint. If the audience isn't familiar with OAuth, this could be broken out into a few more steps.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1 to include steps.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good point, I can fix this.
throw new Error('No token verification endpoint available in metadata'); | ||
} | ||
|
||
const params = new URLSearchParams({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thoughts on using a library for this? panva/openid-client
is a solid choice if so
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eeehhhh I am on the fence about this. As I mentioned in the doc, I am all for not reinventing the wheel here, but I wonder if for simple enough things like this a library-less approach is better. Open to your thoughts here @max-stytch
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, we do say
Use off-the-shelf, well-tested, and secure libraries for things like token validation or authorization decisions.
and I think it is worth it on that front.
Additionally the internal mechanics of token introspection aren't really what we're here to talk about - it isn't an official part of the MCP protocol. Freeing up some cognitive space would allow us to talk about token introspection vs local jwt validation, for example.
}) | ||
); | ||
|
||
server.registerTool("multiply", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A tool that shows using the caller's identity within the tool handler could work very well here. Even a simple whoami
that echos the sub
and the scope
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe? That would imply a more complex behavior, though, where now I need to pass authorization context into the tool, and that feels like a whole separate tutorial.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Without authorization context in the tool, or the ability to use the caller's information, we're not doing a good job of showing why OAuth is better than API Keys. Since we're using the requireBearerAuth
helper, the auth info should be passed in to the tool automatically. Based off of this test we could do something like:
server.registerTool("whoami", {
title: "Who Am I",
description: "Retrieve authentication information about the current caller",
},
async ({}, { authInfo }, => ({
content: [{ type: "text", text: `You are:\n${JSON.stringify(authInfo, null, 2)}` }]
})
);
which could at least serve as a pointer to a separate future tutorial where we go further in depth on scope management
- **Always validate tokens**. Just because your server received a token does not mean that the token is valid or that it's meant for your server. Always verify that what your MCP server is getting from the client matches the required constraints. | ||
- **Store tokens in secure, encrypted storage**. In certain scenarios, you might need to cache tokens server-side. If that is the case, ensure that the storage has the right access controls and cannot be easily exfiltrated by malicious parties with access to your server. You should also implement robust cache eviction policies to ensure that your MCP server is not re-using expired or otherwise invalid tokens. | ||
- **Enforce HTTPS in production**. Do not accept tokens or redirect callbacks over plain HTTP except for `localhost` during development. | ||
- **Least-privilege scopes**. Don't use catch‑all scopes. Split access per tool or capability where possible and verify required scopes per route/tool on the resource server. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
verify required scopes per route/tool on the resource server.
Can we show this above?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likely a separate tutorial for authorization policies 😄
</Step> | ||
|
||
<Step title="User Authorization"> | ||
The client will now need to open a browser to the `/authorize` endpoint, where the user can log in and grant the required permissions. The authorization server will redirect back to the client with an authorization code that the client exchanges for tokens: |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Are we considering only front-channel authorization? A client implementation might also use back-channel flows such as Device Code or CIBA.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Out of scope for this guide. There could be a multitude of authorization approaches, and we're only documenting what's outlined in the core spec.
|
||
`OAUTH_CLIENT_ID` and `OAUTH_CLIENT_SECRET` are associated with the MCP server client we created earlier. | ||
|
||
In addition to implementing the MCP authorization specification, the server below also does token introspection via Keycloak to make sure that the token it receives from the client is valid. It also implements basic logging to allow you to easily diagnose any issues. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the access tokens are JWTs, we could validate them locally using the JWKS endpoint instead of calling introspection for every request. People who are not familiar with oauth might not know this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good call-out. I will mention that introspection is one of the ways but if the AS does not offer it, there are ways to do that locally.
Co-authored-by: Max Gerber <89937743+max-stytch@users.noreply.github.com>
})); | ||
|
||
app.use(cors({ | ||
origin: '*', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It may be worth pointing to the spec security warning somewhere to clarify why we use CORS here, and e.g. add a comment that this is not production-ready. See also my test using the c# sdk.
For development and guide-purposes, I think using a localhost-uri is a better default than *
(the latter effectively violating the spec), wdyt?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm I don't think CORS *
violates that guideline? Those lines are about preventing DNS rebinding, but having *
should be fine since we have auth here. CORS *
is necessary if you want to allow web based clients.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeh, it should be fine, agreed. Auth + HTTPS mitigate most attack vectors. It's just that the spec clearly states MUST and all incoming connections, which is effectively not true, so one may be able to access e.g. the unprotected endpoints like PRM using dns rebind. But not worth building a repro with tools like lock.cmpxchg8b.com/rebinder.html now, so.. Nvm, maybe I am just too used to follow specs by the (RFC2119 key-) word. 😅
app.get('/', authMiddleware, handleSessionRequest); | ||
app.delete('/', authMiddleware, handleSessionRequest); | ||
|
||
app.listen(CONFIG.port, () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
as with my origin-comment, we have a minor thing here. the spec states:
When implementing Streamable HTTP transport:
- Servers MUST validate the Origin header on all incoming connections to prevent DNS rebinding attacks
- When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)
- Servers SHOULD implement proper authentication for all connections
Now calling app.listen without a host effectively binds to 0.0.0.0 and should as such not be used in (security relevant) guides imo. WDYT?
I know it should be a prescriptive "starter" guide, but these small cracks might get copy/pasted everywhere (coming from the OIDC/OAuth world, this happened a lot and happens till today), so call me a burned child, but that's why I think it'd be better to give people good material from the start.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agree on listening on 127.0.0.1, think CORS is okay though (will comment on that)
let transport: StreamableHTTPServerTransport; | ||
|
||
if (sessionId && transports[sessionId]) { | ||
transport = transports[sessionId]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What do you think about making this into a stateless MCP server? If we close the transport when the request closes, we don't need to manage transports in memory, or deal with sessions at all. We can leave stateful transports up to another guide.
// Look Ma, No State!
try {
const server = getServer(req);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on('close', () => {
console.log('Request closed');
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error('Error handling MCP request:', error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: '2.0',
error: {
code: -32603,
message: 'Internal server error',
},
id: null,
});
}
}
</Step> | ||
|
||
<Step title="Protected Resource Metadata Discovery"> | ||
With the URI pointer to the PRM document, the client will fetch the metadata to learn about the authorization server, supported scopes, and other resource information. The data is typically encapsulated in a JSON blob, similar to the one below. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thinking through whether we want to mention metadata probing... and my answer is no, let's leave it out. that can be an advanced topic. (no change needed here)
} | ||
``` | ||
|
||
If the registration succeeds, the authorization server will return a JSON blob with client registration information, including the original registration metadata as well as `client_id`, `client_secret` (if applicable), `client_id_issued_at` (optional), and `client_secret_expires_at` (if `client_secret` is used). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
imo that gets too into the weeds. if we start explaining each optional branch, we'll end up with way too long of a post
The audience configuration above is meant for testing. For production scenarios, additional set-up and configuration will be required to ensure that audiences are properly constrained for issued tokens. | ||
</Warning> | ||
|
||
Now, navigate to **Clients**, then **Client registration**, and then **Trusted Hosts**. Disable the **Client URIs Must Match** setting and add the hosts from which you're testing. You can get your current host IP by running the `ifconfig` command on Linux or macOS, or `ipconfig` on Windows. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(will test this): can we use 127.0.0.1
instead of needing to fetch the IP? if we're using the host ip, makes me think we'll need to worry about dns rebinding
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Usually not. I've a working example here and the ip that reaches keycloak is my external ip.
<Warning> | ||
**Embedded Audience** | ||
|
||
Notice the `aud` claim embedded in the token - it's currently set to be the URI of the test MCP server and its inferred from the scope that we've previously configured. This will be important in our implementation to validate. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(will test this): does this come from the resource parameter? if it comes from the scope, it might not offer the protection we want
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's from the scope, and a workaround until keycloak implements the rfcs. See here for status on that: keycloak/keycloak#41521
app.get('/', authMiddleware, handleSessionRequest); | ||
app.delete('/', authMiddleware, handleSessionRequest); | ||
|
||
app.listen(CONFIG.port, () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
agree on listening on 127.0.0.1, think CORS is okay though (will comment on that)
})); | ||
|
||
app.use(cors({ | ||
origin: '*', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm I don't think CORS *
violates that guideline? Those lines are about preventing DNS rebinding, but having *
should be fine since we have auth here. CORS *
is necessary if you want to allow web based clients.
Co-authored-by: Paul Carleton <paulc@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Looks good. 👍
Co-authored-by: Mike Kistler <mikekistler@microsoft.com>
Co-authored-by: Stephen Halter <halter73@gmail.com>
Co-authored-by: Stephen Halter <halter73@gmail.com>
Scaffolding for the authorization guide, per #1058 and #1059