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 |
com_authorize |
RFC 8414 / OIDC discovery for issuer |
HubZero core (com_developer + bshaffer oauth2-server-php 1.11.1) |
the real |
The discovery chain (why each piece exists)
Client hits
/api/mcp/{tool}/mcpwithout a token → 401 (must be 401, not 403 — MCP clients only trigger discovery on 401) withWWW-Authenticate: Bearer resource_metadata="…".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.com_authorize serves the AS metadata at
/authorize/.well-known/openid-configurationand…/oauth-authorization-server. Theissuerstring must byte-match theauthorization_serversentry (no trailing slash) — clients validate this.Metadata advertises
token_endpoint_auth_methods_supported: ["client_secret_post", "none"]andcode_challenge_methods_supported: ["S256"], plusregistration_endpoint.
DCR: hybrid admission (supported, but gated)
POST /authorize/register (RFC 7591), two tiers:
Anonymous — allowed only when every
redirect_urishost 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: Bearerfrom a member of com_mcp’sallowed_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_tokenonly (neverpassword/client_credentials).
Decoding token-endpoint errors (saves hours)
Error you see |
What it actually means |
|---|---|
|
client_id not found / not published. NOT “public clients rejected”. |
|
public clients genuinely disabled ( |
|
a secret was sent and it’s wrong |
|
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.