From 00aa922bf122972c62188a1f508a04ea931bd36e Mon Sep 17 00:00:00 2001 From: Igor Souza Date: Thu, 12 Nov 2026 16:31:21 -0300 Subject: [PATCH 1/2] docs: add MCP authorization and SSO/OIDC articles New web-development articles on how remote MCP servers authenticate callers (discovery RFCs, DCR, OAuth2 + PKCE, grants) and on SSO with OIDC (per-app clients, sub as identity, sign-out). Adds an SSO section to the Cognito article and cross-links. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019TGHqUwxSMP5yYKbFpCbQT --- docs/knowledge/devops-tools/aws/cognito.md | 26 +++ .../fastmcp-knowledge-base-server.md | 2 + docs/knowledge/web-development/index.md | 6 + .../web-development/mcp-authorization.md | 212 ++++++++++++++++++ .../oauth2-jwt-authentication.md | 4 + .../web-development/single-sign-on-oidc.md | 131 +++++++++++ 6 files changed, 381 insertions(+) create mode 100644 docs/knowledge/web-development/mcp-authorization.md create mode 100644 docs/knowledge/web-development/single-sign-on-oidc.md diff --git a/docs/knowledge/devops-tools/aws/cognito.md b/docs/knowledge/devops-tools/aws/cognito.md index 3e47346..0af4efa 100644 --- a/docs/knowledge/devops-tools/aws/cognito.md +++ b/docs/knowledge/devops-tools/aws/cognito.md @@ -56,6 +56,28 @@ an [S3](s3.md) bucket without routing the upload through a backend server at all — the client gets temporary, scoped-down S3 credentials from an Identity Pool and uploads directly. +## One Pool, Many Apps (SSO) + +A single User Pool can serve any number of apps: each gets its own **app client** +(own `client_id`, callback/logout URLs and scopes) and all of them share the pool's +**hosted login domain**. Signing in to one app sets a session cookie on that domain, +so the next app's redirect skips the login form — that is +[SSO](../../web-development/single-sign-on-oidc.md). Things worth remembering: + +- **`sub` is per pool.** The same person has a different `sub` in a different pool, + so moving an app between pools means migrating the data keyed by it. +- **`name` and `picture` come in the ID token**, from the user's attributes; `picture` + is only a URL, so the image must be hosted elsewhere. +- **Self-signup can be off** for a private pool; users are created by an admin. +- **No Dynamic Client Registration.** Cognito cannot register clients on the fly, so it + cannot be the authorization server for a "paste a link" remote + [MCP connector](../../web-development/mcp-authorization.md) without a shim or + pre-registered client. +- **Pools created with a retain removal policy** are not deleted when the stack is; + delete them by hand once nothing uses them. +- **Global sign-out** revokes the user's refresh tokens; access tokens already issued + stay valid until they expire. + ## When to Use Which - Need "is this user logged in, and who are they" for your own API? → @@ -85,3 +107,7 @@ for the specific AWS resources the client needs to touch directly. - [S3](s3.md) — the most common target for Identity Pool credentials, e.g. direct client-side uploads. +- [Single Sign-On with OIDC](../../web-development/single-sign-on-oidc.md) — several + apps as clients of one pool. +- [MCP Authorization](../../web-development/mcp-authorization.md) — why a provider + without Dynamic Client Registration complicates remote MCP connectors. diff --git a/docs/knowledge/machine-learning/fastmcp-knowledge-base-server.md b/docs/knowledge/machine-learning/fastmcp-knowledge-base-server.md index 186f6c6..a669ea4 100644 --- a/docs/knowledge/machine-learning/fastmcp-knowledge-base-server.md +++ b/docs/knowledge/machine-learning/fastmcp-knowledge-base-server.md @@ -260,3 +260,5 @@ async with Client("server.py") as client: - [Amazon Bedrock AgentCore](aws-bedrock-agentcore.md) — AgentCore Gateway solves a related problem to a hand-written MCP server: turning existing APIs into governed MCP-compatible tools without writing the server yourself. +- [MCP Authorization](../web-development/mcp-authorization.md) — how a remote MCP + server authenticates callers: discovery, dynamic registration, OAuth2 + PKCE. diff --git a/docs/knowledge/web-development/index.md b/docs/knowledge/web-development/index.md index bf7d849..d862ef0 100644 --- a/docs/knowledge/web-development/index.md +++ b/docs/knowledge/web-development/index.md @@ -33,6 +33,12 @@ Frameworks, patterns, and practical notes for building and shipping web applicat - [OAuth2 & JWT Authentication](oauth2-jwt-authentication.md) — grant types, OIDC, JWT structure and signature verification, and access/refresh token handling. +- [Single Sign-On with OIDC](single-sign-on-oidc.md) — one identity provider + session shared by several apps, per-app clients, `sub` as identity, and sign-out + semantics. +- [MCP Authorization](mcp-authorization.md) — how a remote MCP server + authenticates callers: discovery RFCs, dynamic client registration, OAuth2 + PKCE, + grants and revocation. - [Pydantic Advanced: Validators, Aliases & Settings](pydantic-advanced-validators-aliases.md) — custom validators, field aliasing, computed fields, and settings management with `pydantic-settings`. diff --git a/docs/knowledge/web-development/mcp-authorization.md b/docs/knowledge/web-development/mcp-authorization.md new file mode 100644 index 0000000..31b84c4 --- /dev/null +++ b/docs/knowledge/web-development/mcp-authorization.md @@ -0,0 +1,212 @@ +--- +tags: + +- mcp +- oauth2 +- authentication +- security +- web-development +- advanced + +--- + +# MCP Authorization: How a Remote MCP Server Authenticates Its Callers + +A remote [MCP](../machine-learning/fastmcp-knowledge-base-server.md) server is an +HTTP endpoint that an LLM client (Claude, an IDE agent) calls on a user's behalf. +Anyone who can reach the URL can send requests, so the server needs to know **who is +calling** and **what they approved**. MCP does not invent a new scheme for this: it +reuses [OAuth2](oauth2-jwt-authentication.md) and a handful of small RFCs whose only +job is to let a client that has *never heard of this server* work out how to log in +to it, with nothing but the URL. + +That last part is the whole point. The user pastes one link; everything else (where +to log in, how to register, how to get a token) is discovered by the client. + +## The Roles + +- **MCP server** = the OAuth **resource server**. It holds the tools and data, and + accepts requests carrying `Authorization: Bearer `. +- **Authorization server (AS)** = issues those tokens after the user approves. It can + be the MCP server itself or a separate identity provider. +- **MCP client** = the OAuth **client**. For a hosted assistant it is a *public* + client: it cannot keep a secret, so it relies on PKCE instead. +- **User** = the **resource owner**, the one whose data the tools touch. + +Every token the server accepts must resolve to exactly one user, and every tool must +act as that user only. The token is the identity; tools never take a "user id" +argument. + +## The Handshake, Step by Step + +```text +Client MCP server / AS User (browser) + | POST /mcp (no token) ---------> | + | <-- 401 + WWW-Authenticate: | + | resource_metadata= | + | GET ------> | who is the AS for this resource? + | GET /.well-known/oauth-authorization-server -> endpoints + capabilities + | POST /register ---------------> | Dynamic Client Registration -> client_id + | open /authorize?...&code_challenge=... ------------------------> login + consent + | <-- redirect to client callback with ?code=... ----------------- approve + | POST /token (code + code_verifier) -> access token + refresh token + | POST /mcp Authorization: Bearer ... -> tools work +``` + +Each arrow is a named standard: + +1. **The 401 challenge** (RFC 9110 + RFC 9728). An unauthenticated request gets + `401` with `WWW-Authenticate: Bearer resource_metadata="https://host/.well-known/oauth-protected-resource"`. + This header is the client's only clue, so it has to be right. +2. **Protected Resource Metadata** (RFC 9728). That document says which + authorization server(s) protect this resource: + `{"resource": ".../mcp", "authorization_servers": ["https://host"]}`. +3. **Authorization Server Metadata** (RFC 8414). The AS publishes its + `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, supported + PKCE methods, and so on, at a well-known path. +4. **Dynamic Client Registration, DCR** (RFC 7591). The client POSTs its name and + redirect URIs and gets back a fresh `client_id`. This is what removes the + "create an app and paste a client id" step. Without DCR, the user has to register + the client by hand and type the id into the assistant's settings. +5. **Authorization code + PKCE** (OAuth 2.1). The client sends the user to + `/authorize` with a `code_challenge`; the AS authenticates the user, asks for + consent, and redirects back with a one-time `code`. The client redeems it at + `/token` together with the `code_verifier`; the AS checks + `SHA256(verifier) == challenge`. A stolen code is useless without the verifier. +6. **Bearer token on every call.** Access tokens are short-lived; the refresh token + gets a new one without bothering the user again. + +!!! note + The spec also asks clients to send the target in a `resource` parameter + (RFC 8707 resource indicators) so a token is minted for *this* MCP server only + and cannot be replayed against another. A server that issues its own tokens only + for itself gets that property for free; one that delegates to a shared identity + provider has to check the audience carefully. + +## Two Ways to Provide the Authorization Server + +### 1. The MCP server is its own authorization server + +The server implements `/register`, `/authorize`, `/token` and the two metadata +documents itself, and keeps clients, codes and tokens in its own store. + +- **Pros:** works with any client (DCR is available), tokens are bound to this server + only, and the consent screen can say exactly what is being granted. +- **Cons:** a small security-critical component to own and test: PKCE checking, + single-use codes, redirect-URI validation, token hashing, rotation. + +The "login" step does not need passwords: `/authorize` can just send the user to the +app's own page, where they are already signed in (through whatever SSO the app uses), +and show an **Approve / Deny** screen. The approval records a grant tied to that +user's stable id. + +### 2. Delegate to an external identity provider + +The MCP server only validates tokens issued by a provider such as +[Cognito](../devops-tools/aws/cognito.md), and points `authorization_servers` at it. + +- **Pros:** no token issuance code to maintain; one place for users and MFA. +- **Cons:** the provider must support what the client expects. Cognito, notably, has + **no Dynamic Client Registration**, so a connector can only work if someone + pre-registers a client and gives the assistant that client id by hand (and a shim in + front of the provider usually has to paper over the missing discovery documents). + Tokens are also issued for the whole pool, so the server must check the client and + audience to know a token was meant for it. + +!!! tip "Rule of thumb" + "Paste a link and it works, for several different assistant accounts" means the + server must speak DCR, which in practice means it runs its own small authorization + server. Delegating to a provider is simpler when the clients are known in advance. + +## Design Decisions That Matter + +**Public clients only.** `token_endpoint_auth_method: none`. There is no client +secret to leak; PKCE binds each code to the client instance that started the flow. + +**Exact redirect-URI matching, and validate it before anything else.** An error in +`client_id` or `redirect_uri` must be shown to the user, never redirected to the +supplied URI, otherwise `/authorize` becomes an open redirector. Only after those two +pass is it safe to bounce other errors back to the client with `?error=...&state=...`. + +**Restrict who may register.** DCR is unauthenticated by design, so anyone can +register. Limit it: accept only the assistant vendor's callback hosts, rate-limit +registrations, and keep client records small and expiring. A registered client alone +grants nothing; the user's consent is what creates a grant. + +**Consent is bound to a user, not a client.** A grant records *which user approved +which client*. The access token resolves to that user's id, so a token approved by +user A can never read user B's data, even though many assistant accounts share the +same registered client or callback URL. + +**Hash what you store.** Authorization codes, access tokens and refresh tokens are +stored as digests (SHA-256 is enough for high-entropy random values). A database +leak then yields nothing usable. + +**Short access tokens, rotating refresh tokens.** Access tokens live about an hour. +Each refresh returns a *new* refresh token and invalidates the old one. If an old +refresh token is ever presented again, treat it as theft and **revoke the whole +grant**, forcing a fresh consent. + +**Make grants visible and revocable.** A "Connections" page listing each connected +assistant, with a Disconnect button, is the user's kill switch. Deleting the grant +invalidates its refresh and access tokens at once. + +**Single-use, short-lived codes and pending requests.** An authorization code lives +for about a minute and can be redeemed once; a pending consent request expires in +minutes. Table TTLs make cleanup automatic. + +## Multiple Assistant Accounts, One User + +Several people, or several assistant accounts of the same person, can connect to the +same MCP server. Each connection does its own DCR + consent and ends up with its own +grant row, all pointing at the approving user's id. That gives: + +- per-connection revocation (disconnect one without touching the others); +- no cross-talk, because the user id comes from the grant, not from anything the + assistant sends; +- an audit trail of "which client was approved, when". + +## Gotchas Seen in Practice + +- **The 401 must carry the right `WWW-Authenticate`.** Frameworks and proxies often + emit their own; the client then never finds the metadata and reports a generic + "couldn't connect". +- **Discovery is path-sensitive.** Clients try both + `/.well-known/oauth-protected-resource` and the path-suffixed + `/.well-known/oauth-protected-resource/mcp`. Serve both. +- **A CDN in front can break it quietly.** The well-known documents, `/mcp`, + `/oauth/*` and the consent API must all be routed to the backend and must not be + cached (`Cache-Control: no-store` on token responses). +- **Stateless transport.** A stateless Streamable-HTTP server only routes `POST`; an + unauthenticated `GET` then returns `405` instead of the `401` that starts the OAuth + flow, so the auth check has to run before routing. +- **Tool limits follow from where the server runs.** A hosted server has no local + files or browser, so tools that need them (render a PNG, upload a local image) stay + in a local stdio server. +- **Client-side state is sticky.** After the server's auth model changes, the + assistant must remove and re-add the connector; stale registrations point at + endpoints that no longer exist. + +## Summary + +- MCP auth is plain OAuth2 with **discovery** glued on: 401 → resource metadata (RFC + 9728) → AS metadata (RFC 8414) → dynamic registration (RFC 7591) → code + PKCE → + bearer token. +- The MCP server is a resource server; whether it also acts as the authorization + server decides whether "just a link" onboarding is possible. +- No DCR (as in Cognito) means manual client registration; running a small + authorization server with DCR removes it. +- Bind every grant to the approving user's id, hash all secrets, rotate refresh + tokens and revoke the grant on reuse, restrict DCR, and give users a revoke page. +- Validate `client_id` and `redirect_uri` before ever redirecting. + +## Related Articles + +- [OAuth2 & JWT Authentication](oauth2-jwt-authentication.md) — the grant types, + PKCE, token validation and refresh-rotation basics this builds on. +- [Single Sign-On with OIDC](single-sign-on-oidc.md) — how the user's login behind + the consent screen works when several apps share one identity provider. +- [FastMCP: Building and Deploying an MCP Server](../machine-learning/fastmcp-knowledge-base-server.md) + — the server side: tools, transports, deployment. +- [AWS Cognito](../devops-tools/aws/cognito.md) — the identity provider used in the + delegated variant, and its missing DCR. diff --git a/docs/knowledge/web-development/oauth2-jwt-authentication.md b/docs/knowledge/web-development/oauth2-jwt-authentication.md index a5ca9b9..a9df7fc 100644 --- a/docs/knowledge/web-development/oauth2-jwt-authentication.md +++ b/docs/knowledge/web-development/oauth2-jwt-authentication.md @@ -226,3 +226,7 @@ and calling it solved. the broader contract a consumer relies on. - [API Optimization & Resilience](api-optimization-resilience.md) — retry and idempotency patterns that apply to authenticated API calls too. +- [Single Sign-On with OIDC](single-sign-on-oidc.md) — the same flows with several + apps sharing one identity provider. +- [MCP Authorization](mcp-authorization.md) — OAuth2 + PKCE + discovery applied to + remote MCP servers. diff --git a/docs/knowledge/web-development/single-sign-on-oidc.md b/docs/knowledge/web-development/single-sign-on-oidc.md new file mode 100644 index 0000000..d45c5d5 --- /dev/null +++ b/docs/knowledge/web-development/single-sign-on-oidc.md @@ -0,0 +1,131 @@ +--- +tags: + +- sso +- oidc +- oauth2 +- authentication +- cognito +- web-development +- advanced + +--- + +# Single Sign-On with OIDC: One Login, Many Apps + +Single Sign-On (SSO) means a user signs in **once**, at one place, and every app that +trusts that place lets them in without asking again. The mechanism is not new +technology: it is [OIDC](oauth2-jwt-authentication.md#oidc-authentication-on-top-of-oauth2) +(authorization code + PKCE) where *several apps are clients of the same identity +provider (IdP)*. What makes it "single sign-on" is a **session at the IdP**, separate +from each app's own session. + +## The Moving Parts + +- **Identity provider (IdP)** — owns the users and the login page. Example: a + [Cognito User Pool](../devops-tools/aws/cognito.md) with its hosted login domain. +- **App client** — one registration per app at the IdP, with its own `client_id`, + allowed callback URLs, allowed logout URLs and scopes. +- **Two sessions** — the IdP's session (a cookie on the IdP's domain) and each app's + session (tokens kept by the app). They are independent and expire separately. +- **Launcher / portal** — optional page that lists the apps and links to them. It is + just another client; it does not "hold" the others' logins. + +## What Happens on the Second App + +```text +Open app A -> no app session -> redirect to IdP /authorize -> no IdP session + -> user types credentials once -> IdP sets its cookie + -> redirect back to A with ?code=... -> A exchanges it for tokens +Open app B -> no app session -> redirect to IdP /authorize -> IdP cookie present + -> IdP skips the login form -> redirect back to B with ?code=... instantly +``` + +The user sees B load already signed in, but under the hood B ran its own full code +exchange. Nothing is shared between A and B except the IdP cookie. + +## Per-App Clients, Not One Shared Client + +Give every app its **own** client at the IdP rather than sharing one: + +- the **audience (`aud`) / `client_id`** in the tokens tells you which app a token + was issued to, so one app cannot replay a token as another; +- callback and logout URLs are allow-listed per client, so a leaked client id from a + low-stakes app cannot redirect codes to a different app; +- scopes, token lifetimes and secrets can differ (a public SPA client with PKCE and + no secret, a server-side app as a confidential client with one); +- an app can be retired by deleting its client without touching the others. + +## Identity Is the `sub`, Not the Username or Email + +Each user has an immutable id (`sub`) per pool. Apps should key their data on it. +Two consequences: + +- **Different pools mean different `sub`s for the same human.** Moving an app from its + own user pool to a shared one changes every user's id, so stored data keyed by the + old `sub` appears to vanish. Migrating means **copying/rewriting records from the old + `sub` to the new one before switching** the app over, and verifying keys before + deleting the old rows. +- Email and username are attributes, not identity; they can change. + +## Where Claims Live + +- The **ID token** carries profile claims (`name`, `picture`, `email`) for the app to + display; the **access token** carries scopes and is meant for APIs. An app that + wants a name or avatar reads it from the ID token. +- Profile attributes are only as good as what the IdP stores: a `picture` claim holds + a URL the IdP never fetches, so the image has to be hosted somewhere public. +- Claims are a snapshot from the moment of login. Changing a user's name at the IdP + shows up after the next sign-in or token refresh, not instantly. + +## Sessions, Refresh and Sign-Out + +- **App session** = the tokens the app holds. Access tokens are short; a refresh + token (days to weeks) lets the app renew without involving the user, as long as the + IdP session or refresh token has not been revoked. +- **Signing out of one app** clears only that app's tokens (and optionally calls the + IdP's logout endpoint, which also ends the IdP session, so the *next* app visit + asks for credentials again). +- **"Sign out everywhere"** means revoking the user's refresh tokens at the IdP + (Cognito: global sign-out). Apps then fail their next refresh and fall back to + login. Already-issued access tokens keep working until they expire, which is why + they are short-lived. +- A portal's "active sessions" list reflects what the IdP knows (refresh tokens), not + what a browser tab currently shows. + +## Operational Lessons + +- **Cache-bust the app shell.** A static frontend that hard-codes the IdP's client id + and domain must be served with `Cache-Control: no-cache` for its HTML, otherwise + users keep running the old build pointed at the old pool after a cutover. +- **Cut over one app at a time**, with data migrated first, and delete the old pool + only after the user confirms sign-in works. Pools with a retain policy in IaC need + manual deletion. +- **Keep deploy pipelines from re-bootstrapping** shared infrastructure; one app's CI + run should not be able to alter the IdP or another app's resources. +- **Login domain is shared, the app is not.** The hosted login page lives on the IdP's + domain for every app. The user tells apps apart by the client they are redirected + back to, and an API (such as an MCP server) tells them apart by validating issuer, + audience and client id of the token. +- **One-person identity hubs are fine.** For a personal set of apps, one pool with + self-signup disabled and a single user is a complete SSO, with MFA optional. + +## Summary + +- SSO = one IdP session + each app doing its own OIDC code (+ PKCE) exchange; the IdP + cookie is what skips the second login. +- One client per app: separate audiences, callback allow-lists and lifetimes. +- Key data on `sub`; moving between pools changes it, so migrate data before the + cutover. +- Profile claims come in the ID token; sign-out semantics differ between "this app", + "this app + IdP session" and "everywhere". +- Treat cutover as a deploy: migrate, cache-bust, verify, then delete the old pool. + +## Related Articles + +- [OAuth2 & JWT Authentication](oauth2-jwt-authentication.md) — grant types, PKCE, + token validation and refresh rotation. +- [AWS Cognito](../devops-tools/aws/cognito.md) — User Pools as the IdP, app clients + and hosted login. +- [MCP Authorization](mcp-authorization.md) — how an API with its own OAuth surface + sits next to an SSO login and uses it for the consent step. From b9871d5a1bc742ce350bfee1b00e71a85b11173c Mon Sep 17 00:00:00 2001 From: Igor Souza Date: Thu, 12 Nov 2026 16:32:10 -0300 Subject: [PATCH 2/2] docs: use repo list numbering style (MD029) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019TGHqUwxSMP5yYKbFpCbQT --- docs/knowledge/web-development/mcp-authorization.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/knowledge/web-development/mcp-authorization.md b/docs/knowledge/web-development/mcp-authorization.md index 31b84c4..db65637 100644 --- a/docs/knowledge/web-development/mcp-authorization.md +++ b/docs/knowledge/web-development/mcp-authorization.md @@ -58,22 +58,22 @@ Each arrow is a named standard: 1. **The 401 challenge** (RFC 9110 + RFC 9728). An unauthenticated request gets `401` with `WWW-Authenticate: Bearer resource_metadata="https://host/.well-known/oauth-protected-resource"`. This header is the client's only clue, so it has to be right. -2. **Protected Resource Metadata** (RFC 9728). That document says which +1. **Protected Resource Metadata** (RFC 9728). That document says which authorization server(s) protect this resource: `{"resource": ".../mcp", "authorization_servers": ["https://host"]}`. -3. **Authorization Server Metadata** (RFC 8414). The AS publishes its +1. **Authorization Server Metadata** (RFC 8414). The AS publishes its `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, supported PKCE methods, and so on, at a well-known path. -4. **Dynamic Client Registration, DCR** (RFC 7591). The client POSTs its name and +1. **Dynamic Client Registration, DCR** (RFC 7591). The client POSTs its name and redirect URIs and gets back a fresh `client_id`. This is what removes the "create an app and paste a client id" step. Without DCR, the user has to register the client by hand and type the id into the assistant's settings. -5. **Authorization code + PKCE** (OAuth 2.1). The client sends the user to +1. **Authorization code + PKCE** (OAuth 2.1). The client sends the user to `/authorize` with a `code_challenge`; the AS authenticates the user, asks for consent, and redirects back with a one-time `code`. The client redeems it at `/token` together with the `code_verifier`; the AS checks `SHA256(verifier) == challenge`. A stolen code is useless without the verifier. -6. **Bearer token on every call.** Access tokens are short-lived; the refresh token +1. **Bearer token on every call.** Access tokens are short-lived; the refresh token gets a new one without bothering the user again. !!! note