# OAuth + Dynamic Client Registration on nanoHUB **As a tool author you configure none of this** — the hub provides it, and Claude/ChatGPT/Claude Code connect on their own. Read this to understand what happens, and especially to decode auth errors (table below) before concluding "OAuth is broken". Three components cooperate. Only the first two are hub-side code; HubZero core is a fixed external dependency. | Component | Role | |---|---| | **com_mcp** (API gateway) | 401 challenge with `WWW-Authenticate: Bearer resource_metadata="…"`, serves RFC 9728 protected-resource metadata at `/api/mcp/{tool}/mcp/.well-known/oauth-protected-resource` | | **com_authorize** | RFC 8414 / OIDC discovery for issuer `https://{host}/authorize`, RFC 7591 DCR at `/authorize/register` | | **HubZero core** (com_developer + bshaffer oauth2-server-php 1.11.1) | the real `/developer/oauth/authorize` and `/developer/oauth/token` endpoints | ## The discovery chain (why each piece exists) 1. Client hits `/api/mcp/{tool}/mcp` without a token → **401** (must be 401, not 403 — MCP clients only trigger discovery on 401) with `WWW-Authenticate: Bearer resource_metadata="…"`. 2. Protected-resource metadata lists `authorization_servers: ["https://{host}/authorize"]`. HubZero can't route root `/.well-known/*` to a component, so the issuer carries a **path**; spec-compliant clients fall through to path-appended well-known URLs. 3. com_authorize serves the AS metadata at `/authorize/.well-known/openid-configuration` and `…/oauth-authorization-server`. The `issuer` string must **byte-match** the `authorization_servers` entry (no trailing slash) — clients validate this. 4. Metadata advertises `token_endpoint_auth_methods_supported: ["client_secret_post", "none"]` and `code_challenge_methods_supported: ["S256"]`, plus `registration_endpoint`. ## DCR: hybrid admission (supported, but gated) `POST /authorize/register` (RFC 7591), two tiers: - **Anonymous** — allowed only when *every* `redirect_uris` host is on the vendor allowlist (component param; claude.ai, claude.com, chatgpt.com, openai.com) or is a loopback host (`localhost`, `127.0.0.1`, `::1` — how Claude Code registers). Rate-limited per IP/hour and globally/day. - **Member** — any other redirect URI requires `Authorization: Bearer` from a member of com_mcp's `allowed_groups` (RFC 7591 "initial access token" pattern). 401 without a token, 403 for non-members, capped apps per member. Registration is **not** the security boundary: a client row is inert until a group member authenticates at the consent screen, and the gateway 403s non-members on every call. Redirect URIs must be absolute https, or http only on loopback, no fragments. Client names are sanitized (consent screen prints them) and suffixed "(auto-registered)". For `token_endpoint_auth_method: "none"` the response simply omits `client_secret`; a random secret is still stored (never derive it from the client_id — HubZero's automatic hook would use `sha1(client_id)`, i.e. public knowledge). ## HubZero AS facts you must accept (not fix) - Every published, non-deleted client is a **public client** (`isPublicClient()` == published && state != 2), so secret-less exchange completes. That's why advertising `"none"` is truthful. - **PKCE is advertised but not enforced** — the token endpoint ignores `code_verifier`. Clients require S256 in metadata to proceed; flows complete. Server-side enforcement is a HubZero-core follow-up. Don't claim PKCE security you don't have. - Grant types for DCR clients: `authorization_code`, `refresh_token` only (never `password`/`client_credentials`). ## Decoding token-endpoint errors (saves hours) | Error you see | What it actually means | |---|---| | `invalid_client` — "This client is invalid or must authenticate using a client secret" | **client_id not found / not published.** NOT "public clients rejected". | | `invalid_client` — "client credentials are required" | public clients genuinely disabled (`allow_public_clients` false) — not nanoHUB's config | | `invalid_client` — "The client credentials are invalid" | a secret was sent and it's wrong | | `invalid_grant` — "Authorization code doesn't exist or is invalid for the client" | **client auth PASSED**; the code is bad/expired/foreign. This is the success signal in smoke tests. | Definitive smoke test: register a throwaway client with `token_endpoint_auth_method: "none"` and a loopback redirect, then exchange a bogus code with no secret. `invalid_grant` ⇒ the whole public-client path works. See verification.md for the exact curl commands. ## 401 vs 403 discipline - **401** = "authenticate" → clients run OAuth discovery. Use for missing or invalid tokens, always with `WWW-Authenticate`. - **403** = "you're authenticated but not allowed" → use for group-gate denials, with a JSON body naming `required_groups`. Returning 401 here sends clients into an infinite re-auth loop.