Auth

HTTP primitives live in core. JWT verification lives in heddle-oauth. Missing credentials from Auth.* are 401 with WWW-Authenticate.

On the example hub, writes and MCP HTTP share one bearer. Swagger Authorize and POST /mcp are not two security stories.

Basic, Bearer, API key

Middleware.basicAuth is the lock. Auth.bearer is the extractor you provided onto routes that need a subject. API keys are Auth.apiKey / Middleware.apiKey.

{
  val locked =
    Routes(Method.GET / "secret" -> Handler.text("ok")) @@ Middleware.basicAuth("ada", "pw")
  val authed =
    Request.get("/secret").copy(headers = Headers.empty.set(Authorization.basic("ada", "pw")))
  for
    denied <- locked(Request.get("/secret"))
    ok     <- locked(authed)
  yield (denied.status, ok.status, ok.body.asString)
}
(Status(401,Unauthorized),Status(200,OK),ok)

OAuth / OIDC

heddle-oauth signs and verifies compact JWTs (RS256, our Jose), fetches JWKS, and speaks authorization-code+PKCE, client credentials, refresh, device, and userinfo.

A loopback OpenID provider is sbt oauth/run (http://127.0.0.1:8080/.well-known/openid-configuration). sbt example/run embeds that OP next to the box office API so Swagger Authorize works against the same process. Seed user ada / ada. Machine client machine / secret.

{
  val key    = SigningKey.generateRsa("k1")
  val issuer = "http://iss"
  val aud    = "api"
  val token  = Jose.sign(key, "ada", issuer, aud, Set("openid"), 5.minutes)
  JwtVerifier.static(key.publicJwksJson, issuer, aud).flatMap(_.verify(token)).map { claim =>
    (claim.subject, claim.scopes.contains("openid"))
  }
}
(ada,true)

Endpoint.auth(SecurityScheme.OAuth2(...)) or HttpBearer is what Swagger uses for Authorize. Do not put a second authorization server inside heddle-mcp. Resource-server JWT verify is the adapter MCP HTTP already expects.