-
Notifications
You must be signed in to change notification settings - Fork 939
(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
|
||
`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.
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.
For my laptop at least (using macbook + orbstack), I needed to add:
- localhost
- 192.168.215.1
The 192 address is from a bridge network docker sets up. Here's an edit option to cover that.
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. | |
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. You can see the IP address you need to add by looking at the keycloak logs for a line that looks like `Failed to verify remote host : 192.168.215.1`. Check that the IP address is associated with your host. This may be for a bridge network depending on your docker setup. |
<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>
@localden Hello, I am a Keycloak maintainer working for supporting Authorization part of the MCP specification in Keycloak OAuth SIG. |
<Warning> | ||
**Not for Production** | ||
|
||
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. |
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.
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. | |
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. Specifically, the audience needs to be based on the resource parameter passed from the client, not a fixed value. |
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.
I left a few tweaks:
- clarifying what a production aud parameter should do
- helping find the correct ip address
but those can be optional.
I ran through the example code in typescript, and the instructions were super clear and it all worked. I did the typescript example oauth client.
Scaffolding for the authorization guide, per #1058 and #1059