Skip to content
Merged
13 changes: 10 additions & 3 deletions internal/handler/auth_verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog/log"

"github.com/highflame-ai/zeroid/internal/middleware"
"github.com/highflame-ai/zeroid/internal/oautherror"
)

Expand Down Expand Up @@ -42,17 +43,23 @@ func (a *API) registerAuthVerifyRoute(router chi.Router) {
// copy_headers X-Forwarded-User X-Zeroid-Identity-Type X-Zeroid-Trust-Level X-Zeroid-Account-ID X-Zeroid-Project-ID
// }
func (a *API) authVerifyHandler(w http.ResponseWriter, r *http.Request) {
prm := a.prmURL()

authHeader := r.Header.Get("Authorization")
if authHeader == "" {
w.Header().Set("WWW-Authenticate", `Bearer error="missing_token"`)
// RFC 6750 §3.1 — "missing_token" is not in the standard enum,
// but pre-PR usage shipped this string; preserved for client
// compatibility. The RFC 9728 §5.1 breadcrumb is the additive
// improvement.
w.Header().Set("WWW-Authenticate", middleware.WWWAuthenticate("missing_token", "", prm))
http.Error(w, `{"error":"missing_token"}`, http.StatusUnauthorized)
return
}

token, ok := strings.CutPrefix(authHeader, "Bearer ")
token = strings.TrimSpace(token)
if !ok || token == "" {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error=%q`, oautherror.InvalidRequest))
w.Header().Set("WWW-Authenticate", middleware.WWWAuthenticate(oautherror.InvalidRequest, "", prm))
http.Error(w, `{"error":"invalid_authorization_header"}`, http.StatusUnauthorized)
return
}
Expand All @@ -66,7 +73,7 @@ func (a *API) authVerifyHandler(w http.ResponseWriter, r *http.Request) {

active, _ := claims["active"].(bool)
if !active {
w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Bearer error=%q`, oautherror.InvalidToken))
w.Header().Set("WWW-Authenticate", middleware.WWWAuthenticate(oautherror.InvalidToken, "", prm))
http.Error(w, fmt.Sprintf(`{"error":%q}`, oautherror.InvalidToken), http.StatusUnauthorized)
return
}
Expand Down
58 changes: 41 additions & 17 deletions internal/handler/dynamic_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"github.com/rs/zerolog/log"

"github.com/highflame-ai/zeroid/domain"
"github.com/highflame-ai/zeroid/internal/middleware"
"github.com/highflame-ai/zeroid/internal/oautherror"
"github.com/highflame-ai/zeroid/internal/service"
)
Expand Down Expand Up @@ -57,9 +58,15 @@ type DCRRegisterInput struct {

// DCROutput is the polymorphic response body. RFC 7591/7592 success bodies are
// dynamic-shape; error bodies are oauthErrorBody.
//
// WWWAuthenticate, when set, is emitted as the WWW-Authenticate response
// header. Populated by `dcrErr` for 401 responses so the RFC 9728 §5.1
// resource_metadata breadcrumb reaches clients that hit a DCR auth failure
// the same way it reaches clients that hit a generic bearer-auth failure.
type DCROutput struct {
Status int
Body any
Status int
WWWAuthenticate string `header:"WWW-Authenticate"`
Body any
}

// DCRGetInput / DCRUpdateInput / DCRDeleteInput share the same auth shape:
Expand Down Expand Up @@ -140,12 +147,12 @@ func (a *API) registerDynamicRegistrationRoutes(api huma.API) {
func (a *API) dcrRegisterOp(ctx context.Context, input *DCRRegisterInput) (*DCROutput, error) {
iatClaims, err := a.validateInitialAccessToken(input.Authorization)
if err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}

v, err := validateDCRClientMetadata(input.Body.ClientName, input.Body.Scope, input.Body.TokenEndpointAuthMethod, input.Body.GrantTypes)
if err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}

client, plainSecret, plainRegToken, regErr := a.oauthClientSvc.DynamicRegisterClient(ctx, service.DynamicRegisterClientRequest{
Expand All @@ -160,10 +167,10 @@ func (a *API) dcrRegisterOp(ctx context.Context, input *DCRRegisterInput) (*DCRO
})
if regErr != nil {
if errors.Is(regErr, service.ErrOAuthClientAlreadyExists) {
return dcrErr(&dcrError{status: http.StatusConflict, code: oautherror.InvalidClientMetadata, desc: "client already exists"}), nil
return a.dcrErr(&dcrError{status: http.StatusConflict, code: oautherror.InvalidClientMetadata, desc: "client already exists"}), nil
}
log.Error().Err(regErr).Msg("dynamic client registration failed")
return dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to register client"}), nil
return a.dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to register client"}), nil
}

// Audit log: who minted what. registered_by_* claims are derived from the
Expand All @@ -187,19 +194,19 @@ func (a *API) dcrRegisterOp(ctx context.Context, input *DCRRegisterInput) (*DCRO
func (a *API) dcrGetOp(ctx context.Context, input *DCRGetInput) (*DCROutput, error) {
cl, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID)
if err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}
return &DCROutput{Status: http.StatusOK, Body: a.dcrClientResponse(cl)}, nil
}

func (a *API) dcrUpdateOp(ctx context.Context, input *DCRUpdateInput) (*DCROutput, error) {
if _, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID); err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}

v, err := validateDCRClientMetadata(input.Body.ClientName, input.Body.Scope, input.Body.TokenEndpointAuthMethod, input.Body.GrantTypes)
if err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}

updated, updateErr := a.oauthClientSvc.UpdateDynamicClient(ctx, input.ClientID, service.DynamicRegisterClientRequest{
Expand All @@ -217,21 +224,21 @@ func (a *API) dcrUpdateOp(ctx context.Context, input *DCRUpdateInput) (*DCROutpu
// have been deleted in between. Map ErrOAuthClientNotFound to the
// same 401 the auth path uses; other errors are infra failures.
if errors.Is(updateErr, service.ErrOAuthClientNotFound) {
return dcrErr(&dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidToken, desc: "client no longer exists"}), nil
return a.dcrErr(&dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidToken, desc: "client no longer exists"}), nil
}
log.Error().Err(updateErr).Str("client_id", input.ClientID).Msg("dynamic client update failed")
return dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to update client registration"}), nil
return a.dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to update client registration"}), nil
}
return &DCROutput{Status: http.StatusOK, Body: a.dcrClientResponse(updated)}, nil
}

func (a *API) dcrDeleteOp(ctx context.Context, input *DCRDeleteInput) (*DCROutput, error) {
if _, err := a.authorizeDCRManagement(ctx, input.Authorization, input.ClientID); err != nil {
return dcrErr(err), nil
return a.dcrErr(err), nil
}
if err := a.oauthClientSvc.DeleteDynamicClient(ctx, input.ClientID); err != nil {
log.Error().Err(err).Str("client_id", input.ClientID).Msg("dynamic client delete failed")
return dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to delete client registration"}), nil
return a.dcrErr(&dcrError{status: http.StatusInternalServerError, code: oautherror.ServerError, desc: "failed to delete client registration"}), nil
}
return &DCROutput{Status: http.StatusNoContent, Body: nil}, nil
}
Expand Down Expand Up @@ -300,8 +307,17 @@ type dcrError struct {

func (e *dcrError) Error() string { return e.code + ": " + e.desc }

func dcrErr(de *dcrError) *DCROutput {
return &DCROutput{Status: de.status, Body: oauthErrorBody{Error: de.code, ErrorDescription: de.desc}}
func (a *API) dcrErr(de *dcrError) *DCROutput {
out := &DCROutput{Status: de.status, Body: oauthErrorBody{Error: de.code, ErrorDescription: de.desc}}
// RFC 9728 §5.1 — on 401 from any Bearer-protected path, the
// resource_metadata breadcrumb points cold-start clients at the PRM
// document so they can discover the AS without prior knowledge. The
// error code and description echo what the body already carries so a
// client that only inspects headers gets the same signal.
if de.status == http.StatusUnauthorized {
out.WWWAuthenticate = middleware.WWWAuthenticate(de.code, de.desc, a.prmURL())
Comment thread
rsharath marked this conversation as resolved.
}
return out
}

// initialAccessTokenClaims captures the tenant-relevant claims of a successfully
Expand All @@ -324,7 +340,12 @@ type initialAccessTokenClaims struct {
// Returns the extracted tenant claims on success or a *dcrError on failure.
func (a *API) validateInitialAccessToken(authHeader string) (*initialAccessTokenClaims, *dcrError) {
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, &dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidToken, desc: "Authorization header with Bearer initial access token is required"}
// RFC 6750 §3.1 — a missing/non-Bearer scheme is a malformed request,
// not a bad token, so the WWW-Authenticate challenge `error` (which
// dcrErr mirrors from this code) must be invalid_request. invalid_token
// is reserved for a syntactically valid Bearer credential that fails
// validation (see the jwt.Parse failure path below).
return nil, &dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidRequest, desc: "Authorization header with Bearer initial access token is required"}
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

Expand Down Expand Up @@ -388,7 +409,10 @@ func (a *API) validateInitialAccessToken(authHeader string) (*initialAccessToken
// Authorization header against the stored bcrypt hash for the path's client_id.
func (a *API) authorizeDCRManagement(ctx context.Context, authHeader, clientID string) (*domain.OAuthClient, *dcrError) {
if !strings.HasPrefix(authHeader, "Bearer ") {
return nil, &dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidToken, desc: "Authorization header with Bearer registration_access_token is required"}
// RFC 6750 §3.1 — missing/non-Bearer scheme is a malformed request
// (invalid_request), not a rejected credential (invalid_token). The
// real-token-but-wrong/unknown case below correctly stays invalid_token.
return nil, &dcrError{status: http.StatusUnauthorized, code: oautherror.InvalidRequest, desc: "Authorization header with Bearer registration_access_token is required"}
}
regToken := strings.TrimPrefix(authHeader, "Bearer ")

Expand Down
21 changes: 17 additions & 4 deletions internal/handler/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,12 @@ func (a *API) bcApproveOp(ctx context.Context, input *BcApproveInput) (*BcApprov
}
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
// Missing X-Account-ID / X-Project-ID is a request-formedness
// failure (admin endpoints have no built-in auth — the deployer's
// edge service is responsible for setting routing headers after
// its own auth check). 400, not 401: there are no credentials in
// play at this layer.
return nil, huma.Error400BadRequest("missing X-Account-ID or X-Project-ID header")
}
Comment thread
rsharath marked this conversation as resolved.
if err := a.backchannelSvc.Approve(ctx, service.ApproveInput{
AuthReqID: input.AuthReqID,
Expand Down Expand Up @@ -450,7 +455,8 @@ func (a *API) bcDenyOp(ctx context.Context, input *BcDenyInput) (*BcDenyOutput,
}
tenant, err := internalMiddleware.GetTenant(ctx)
if err != nil {
return nil, huma.Error401Unauthorized("missing tenant context")
// See bcApproveOp comment — same 400-not-401 rationale.
return nil, huma.Error400BadRequest("missing X-Account-ID or X-Project-ID header")
}
if err := a.backchannelSvc.Deny(ctx, service.DenyInput{
AuthReqID: input.AuthReqID,
Expand All @@ -469,14 +475,21 @@ func (a *API) bcDenyOp(ctx context.Context, input *BcDenyInput) (*BcDenyOutput,
// admin error. The admin endpoints are not OAuth token endpoints, so the
// RFC 6749 §5.2 error_code/error_description envelope would be misleading
// here; we use plain HTTP semantics instead.
//
// The backchannel service produces only 400 and 500 OAuthErrors — there's
// no auth surface inside the service (admin auth happens at the handler /
// edge layer, see bcApproveOp / bcDenyOp). 401 would be returned only if
// the service started producing invalid_client errors directly, which it
// doesn't today; if that ever changes, add a case here AND consider
// whether the failure is really an OAuth client-auth failure (RFC 9728
// §5.1 breadcrumb applies) or just a misuse of OAuthError shape (it
// doesn't apply).
func mapBackchannelAdminError(err error) error {
var oauthErr *service.OAuthError
if errors.As(err, &oauthErr) {
switch oauthErr.HTTPStatus {
case http.StatusBadRequest:
return huma.Error400BadRequest(oauthErr.Description)
case http.StatusUnauthorized:
return huma.Error401Unauthorized(oauthErr.Description)
case http.StatusInternalServerError:
return huma.Error500InternalServerError(oauthErr.Description)
}
Expand Down
8 changes: 8 additions & 0 deletions internal/handler/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ func NewHumaAPI(router chi.Router) huma.API {
return humachi.New(router, config)
}

// prmURL returns the absolute URL of this server's RFC 9728 Protected
// Resource Metadata document. Centralized so the value emitted in
// WWW-Authenticate breadcrumbs (RFC 9728 §5.1) stays in lockstep with the
// PRM endpoint's registered path.
func (a *API) prmURL() string {
return a.issuer + "/.well-known/oauth-protected-resource"
}

// RegisterPublic registers endpoints that require no authentication:
// health, well-known, OAuth2 endpoints (token, revoke), and forward-auth verify.
// The /oauth2/register endpoints (RFC 7591/7592) live here too — they enforce
Expand Down
9 changes: 8 additions & 1 deletion internal/handler/signal.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,14 @@ func (a *API) listSignalsOp(ctx context.Context, input *SignalListInput) (*Signa
func (a *API) streamSignalsHandler(w http.ResponseWriter, r *http.Request) {
tenant, err := internalMiddleware.GetTenant(r.Context())
if err != nil {
respondWithError(w, http.StatusUnauthorized, domain.ErrCodeUnauthorized, "missing tenant context")
// Missing X-Account-ID / X-Project-ID is a request-formedness
// failure — the caller (typically the operator's edge service
// after its own auth check) did not include the required routing
// headers. This is 400 Bad Request, not 401: there are no
// credentials in play at this layer (ZeroID admin endpoints have
// no built-in auth; the deployer wraps them via AdminAuthMiddleware
// or the network), so RFC 6750 §3 / RFC 9728 §5.1 don't apply.
respondWithError(w, http.StatusBadRequest, domain.ErrCodeBadRequest, "missing X-Account-ID or X-Project-ID header")
return
Comment thread
rsharath marked this conversation as resolved.
}

Expand Down
69 changes: 61 additions & 8 deletions internal/middleware/agent_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ type AgentAuthConfig struct {
PublicKey *ecdsa.PublicKey
// Issuer is the expected iss claim value.
Issuer string
// ResourceMetadataURL is the absolute URL of this server's RFC 9728
// Protected Resource Metadata document. Emitted in the WWW-Authenticate
// header on every 401 so cold-start clients can chain resource → PRM →
// AS metadata per RFC 9728 §5.1. Empty disables the breadcrumb (e.g.
// for legacy deployments that haven't migrated to issuer-anchored
// discovery).
ResourceMetadataURL string
}

// AgentAuthMiddleware validates ES256 Bearer tokens issued by ZeroID and injects agent claims into context.
Expand All @@ -43,16 +50,45 @@ func AgentAuthMiddleware(cfg AgentAuthConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
// RFC 6750 §3: "If the request lacks any authentication
// information, the resource server SHOULD NOT include an
// error code or other error information." Emit a bare
// Bearer challenge — the RFC 9728 §5.1 resource_metadata
// breadcrumb still gets attached (the SHOULD-NOT clause
// scopes to error info, not discovery hints), so a
// cold-start client can still find PRM.
//
// The JSON body still carries a human-readable message
// (`bodyMessage`) so a developer reading the response gets
// actionable signal — the SHOULD-NOT-include-error-info
// guidance is about the WWW-Authenticate header, not the
// response body which is a ZeroID-internal convention.
writeAgentAuthError(w, "", "", "Authorization header is required", cfg.ResourceMetadataURL)
return
}
Comment thread
rsharath marked this conversation as resolved.
if !strings.HasPrefix(authHeader, "Bearer ") {
writeAgentAuthError(w, http.StatusUnauthorized, "missing or invalid Authorization header")
// Credentials WERE sent, just not in a recognized scheme —
// RFC 6750 §3.1 error_code applies here.
writeAgentAuthError(w, "invalid_request", "Authorization header must use the Bearer scheme", "Authorization header must use the Bearer scheme", cfg.ResourceMetadataURL)
return
}
Comment thread
rsharath marked this conversation as resolved.
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")

// `Bearer ` with no token after the prefix is a malformed
// request, not a token-validation failure — there is no token
// to validate. RFC 6750 §3.1 invalid_request applies; short-
// circuiting before jwtalg.Validate also avoids an unnecessary
// JWS parse on input that can never succeed.
if tokenStr == "" {
writeAgentAuthError(w, "invalid_request", "Authorization header carries an empty Bearer token", "Authorization header carries an empty Bearer token", cfg.ResourceMetadataURL)
return
}

// Reject alg=none / HS* before any further work — JWT-SVID §3.
if err := jwtalg.Validate(tokenStr); err != nil {
log.Warn().Err(err).Str("path", r.URL.Path).Msg("Agent JWT rejected: bad alg")
writeAgentAuthError(w, http.StatusUnauthorized, "invalid or expired token")
writeAgentAuthError(w, "invalid_token", "invalid or expired token", "invalid or expired token", cfg.ResourceMetadataURL)
return
Comment thread
rsharath marked this conversation as resolved.
}

Expand All @@ -63,14 +99,14 @@ func AgentAuthMiddleware(cfg AgentAuthConfig) func(http.Handler) http.Handler {
)
if err != nil {
log.Warn().Err(err).Str("path", r.URL.Path).Msg("Agent JWT validation failed")
writeAgentAuthError(w, http.StatusUnauthorized, "invalid or expired token")
writeAgentAuthError(w, "invalid_token", "invalid or expired token", "invalid or expired token", cfg.ResourceMetadataURL)
return
}

claims := extractAgentClaims(parsed)

if claims.AccountID == "" || claims.ProjectID == "" {
writeAgentAuthError(w, http.StatusUnauthorized, "token missing required tenant claims")
writeAgentAuthError(w, "invalid_token", "token missing required tenant claims", "token missing required tenant claims", cfg.ResourceMetadataURL)
return
}

Expand Down Expand Up @@ -132,13 +168,30 @@ func extractAgentClaims(token jwt.Token) AgentClaims {
return claims
}

func writeAgentAuthError(w http.ResponseWriter, status int, message string) {
// writeAgentAuthError emits a 401 response with an RFC 6750 §3 challenge in
// the WWW-Authenticate header.
//
// - errorCode — RFC 6750 §3.1 value ("invalid_request", "invalid_token",
// "insufficient_scope"). Empty when the request lacks any auth info, per
// RFC 6750 §3 SHOULD-NOT-emit-error-info.
// - headerMessage — error_description value in the header. Same RFC 6750 §3
// constraint as errorCode: empty when the request lacked auth info.
// - bodyMessage — human-readable message for the JSON response body.
// This is ZeroID's internal convention, NOT subject to the RFC 6750 §3
// SHOULD-NOT clause (which scopes to the header). Always populate this so
// a developer reading the response gets actionable signal even when the
// header is intentionally bare.
//
// When resourceMetadataURL is non-empty, RFC 9728 §5.1's resource_metadata
// parameter is appended so cold-start clients can discover the PRM document.
func writeAgentAuthError(w http.ResponseWriter, errorCode, headerMessage, bodyMessage, resourceMetadataURL string) {
w.Header().Set("WWW-Authenticate", WWWAuthenticate(errorCode, headerMessage, resourceMetadataURL))
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{
"code": status,
"message": message,
"code": http.StatusUnauthorized,
"message": bodyMessage,
},
})
}
Loading
Loading