From 47ccc93e36eddee476e47ecbac459f72f3d82b6f Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 3 Jul 2026 15:36:11 -0700 Subject: [PATCH 1/3] fix(oauth): capture DPoP JKT in standard authorize flow and harden nil guards The standard (non-PAR) authorize flow had an empty DPoP JKT block, so DPoP-bound clients that didn't use PAR silently lost their DPoP binding. This caused tokens to be issued as Bearer instead of DPoP, and a nil pointer dereference when the token was used against protected endpoints. Changes: - handle_oauth_authorize.go: Validate DPoP proof header, pass it to AuthenticateClient, and capture JKT for DPoP-bound clients (matching the PAR handler). Return an error when dpop_jkt is required but not provided. - handle_oauth_par.go: Guard against nil dpopProof before accessing .JKT to prevent nil dereference when a DPoP-bound client omits both the proof header and the dpop_jkt parameter. - middleware.go: Replace raw dereference of DpopJkt with the nil-safe dpopJktMatches helper to prevent panics on non-DPoP-bound tokens. - handle_oauth_token.go: Fix cnf claim to use RFC 9449 format {"jkt": "..."} instead of a bare string, in both authorization_code and refresh_token grants. Also fixes a *string vs string bug in the refresh grant (the *& was a no-op). - handle_well_known.go: Advertise granular scope resources (repo, rpc, blob, account, identity, include) in scopes_supported metadata. --- server/handle_oauth_authorize.go | 30 +++++++++++++++++++++++++++++- server/handle_oauth_par.go | 10 ++++++++-- server/handle_oauth_token.go | 4 ++-- server/handle_well_known.go | 6 ++++++ server/middleware.go | 4 ++-- 5 files changed, 47 insertions(+), 7 deletions(-) diff --git a/server/handle_oauth_authorize.go b/server/handle_oauth_authorize.go index 5941942..84ec930 100644 --- a/server/handle_oauth_authorize.go +++ b/server/handle_oauth_authorize.go @@ -12,6 +12,7 @@ import ( "github.com/haileyok/cocoon/internal/helpers" "github.com/haileyok/cocoon/oauth" "github.com/haileyok/cocoon/oauth/constants" + "github.com/haileyok/cocoon/oauth/dpop" "github.com/haileyok/cocoon/oauth/provider" "github.com/labstack/echo-contrib/session" "github.com/labstack/echo/v4" @@ -63,7 +64,24 @@ func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { return helpers.InputError(e, to.StringPtr("no request uri and invalid parameters")) } - client, clientAuth, err := s.oauthProvider.AuthenticateClient(ctx, parRequest.AuthenticateClientRequestBase, nil, &provider.AuthenticateClientOptions{ + dpopProof, err := s.oauthProvider.DpopManager.CheckProof(e.Request().Method, "https://"+s.config.Hostname+e.Request().URL.String(), e.Request().Header, nil) + if err != nil { + if errors.Is(err, dpop.ErrUseDpopNonce) { + nonce := s.oauthProvider.NextNonce() + if nonce != "" { + e.Response().Header().Set("DPoP-Nonce", nonce) + e.Response().Header().Add("access-control-expose-headers", "DPoP-Nonce") + } + s.logger.Error("nonce error: use_dpop_nonce", "headers", e.Request().Header) + return e.JSON(400, map[string]string{ + "error": "use_dpop_nonce", + }) + } + s.logger.Error("error getting dpop proof", "error", err) + return helpers.InputError(e, nil) + } + + client, clientAuth, err := s.oauthProvider.AuthenticateClient(ctx, parRequest.AuthenticateClientRequestBase, dpopProof, &provider.AuthenticateClientOptions{ AllowMissingDpopProof: true, }) if err != nil { @@ -86,12 +104,22 @@ func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { if parRequest.DpopJkt == nil { if client.Metadata.DpopBoundAccessTokens { + if dpopProof != nil { + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) + } else { + msg := "dpop_jkt is required for clients with dpop_bound_access_tokens" + return helpers.InputError(e, &msg) + } } } else { if !client.Metadata.DpopBoundAccessTokens { msg := "dpop bound access tokens are not enabled for this client" return helpers.InputError(e, &msg) } + if dpopProof != nil && dpopProof.JKT != *parRequest.DpopJkt { + msg := "supplied dpop jkt does not match header dpop jkt" + return helpers.InputError(e, &msg) + } } eat := time.Now().Add(constants.ParExpiresIn) diff --git a/server/handle_oauth_par.go b/server/handle_oauth_par.go index 0ea74ed..306496f 100644 --- a/server/handle_oauth_par.go +++ b/server/handle_oauth_par.go @@ -76,7 +76,13 @@ func (s *Server) handleOauthPar(e echo.Context) error { if parRequest.DpopJkt == nil { if client.Metadata.DpopBoundAccessTokens { - parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) + if dpopProof != nil { + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) + } else { + msg := "dpop_jkt is required for clients with dpop_bound_access_tokens" + logger.Error(msg) + return helpers.InputError(e, &msg) + } } } else { if !client.Metadata.DpopBoundAccessTokens { @@ -85,7 +91,7 @@ func (s *Server) handleOauthPar(e echo.Context) error { return helpers.InputError(e, &msg) } - if dpopProof.JKT != *parRequest.DpopJkt { + if dpopProof != nil && dpopProof.JKT != *parRequest.DpopJkt { msg := "supplied dpop jkt does not match header dpop jkt" logger.Error(msg) return helpers.InputError(e, &msg) diff --git a/server/handle_oauth_token.go b/server/handle_oauth_token.go index 476ae74..a887e7c 100644 --- a/server/handle_oauth_token.go +++ b/server/handle_oauth_token.go @@ -134,7 +134,7 @@ func (s *Server) handleOauthToken(e echo.Context) error { } if authReq.Parameters.DpopJkt != nil { - accessClaims["cnf"] = *authReq.Parameters.DpopJkt + accessClaims["cnf"] = map[string]string{"jkt": *authReq.Parameters.DpopJkt} } accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) @@ -233,7 +233,7 @@ func (s *Server) handleOauthToken(e echo.Context) error { } if oauthToken.Parameters.DpopJkt != nil { - accessClaims["cnf"] = *&oauthToken.Parameters.DpopJkt + accessClaims["cnf"] = map[string]string{"jkt": *oauthToken.Parameters.DpopJkt} } accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) diff --git a/server/handle_well_known.go b/server/handle_well_known.go index a1c4456..cf8207c 100644 --- a/server/handle_well_known.go +++ b/server/handle_well_known.go @@ -16,6 +16,12 @@ var ( "transition:email", "transition:generic", "transition:chat.bsky", + "repo", + "rpc", + "blob", + "account", + "identity", + "include", } ) diff --git a/server/middleware.go b/server/middleware.go index 187cbb0..44f0585 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -286,8 +286,8 @@ func (s *Server) handleOauthSessionMiddleware(next echo.HandlerFunc) echo.Handle }) } - if *oauthToken.Parameters.DpopJkt != proof.JKT { - logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof.JKT) + if !dpopJktMatches(oauthToken.Parameters.DpopJkt, proof) { + logger.Error("jkt mismatch", "token", oauthToken.Parameters.DpopJkt, "proof", proof) return helpers.InputError(e, to.StringPtr("dpop jkt mismatch")) } From 8fb5e5488768fe79cb010a914202fc66c2be0771 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 3 Jul 2026 15:41:18 -0700 Subject: [PATCH 2/3] fix(oauth): fall back to token-endpoint DPoP proof when dpop_jkt is absent The previous hard-error for missing dpop_jkt broke DPoP-bound clients that use the standard authorize flow without sending dpop_jkt (e.g. pckt.blog). Instead of rejecting the request, the authorize and PAR endpoints now silently proceed without dpop_jkt. The token endpoint falls back to the DPoP proof presented at token exchange time to determine the effective JKT for the cnf claim, token type, and stored token parameters. This keeps the DPoP binding correct while maintaining interoperability with clients that don't send dpop_jkt in the authorize request. --- server/handle_oauth_authorize.go | 9 ++------- server/handle_oauth_par.go | 10 ++-------- server/handle_oauth_token.go | 26 ++++++++++++++++++++++---- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/server/handle_oauth_authorize.go b/server/handle_oauth_authorize.go index 84ec930..034e4e8 100644 --- a/server/handle_oauth_authorize.go +++ b/server/handle_oauth_authorize.go @@ -103,13 +103,8 @@ func (s *Server) handleOauthAuthorizeGet(e echo.Context) error { } if parRequest.DpopJkt == nil { - if client.Metadata.DpopBoundAccessTokens { - if dpopProof != nil { - parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) - } else { - msg := "dpop_jkt is required for clients with dpop_bound_access_tokens" - return helpers.InputError(e, &msg) - } + if client.Metadata.DpopBoundAccessTokens && dpopProof != nil { + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) } } else { if !client.Metadata.DpopBoundAccessTokens { diff --git a/server/handle_oauth_par.go b/server/handle_oauth_par.go index 306496f..f78f6fc 100644 --- a/server/handle_oauth_par.go +++ b/server/handle_oauth_par.go @@ -75,14 +75,8 @@ func (s *Server) handleOauthPar(e echo.Context) error { } if parRequest.DpopJkt == nil { - if client.Metadata.DpopBoundAccessTokens { - if dpopProof != nil { - parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) - } else { - msg := "dpop_jkt is required for clients with dpop_bound_access_tokens" - logger.Error(msg) - return helpers.InputError(e, &msg) - } + if client.Metadata.DpopBoundAccessTokens && dpopProof != nil { + parRequest.DpopJkt = to.StringPtr(dpopProof.JKT) } } else { if !client.Metadata.DpopBoundAccessTokens { diff --git a/server/handle_oauth_token.go b/server/handle_oauth_token.go index a887e7c..7e2eb73 100644 --- a/server/handle_oauth_token.go +++ b/server/handle_oauth_token.go @@ -133,8 +133,19 @@ func (s *Server) handleOauthToken(e echo.Context) error { "client_id": authReq.ClientId, } + // Determine the effective DPoP JKT: prefer the value bound in the auth + // request (from PAR or the dpop_jkt query param), but fall back to the + // proof presented at the token endpoint. This handles clients that use + // the standard (non-PAR) authorize flow without sending dpop_jkt. + effectiveJkt := "" if authReq.Parameters.DpopJkt != nil { - accessClaims["cnf"] = map[string]string{"jkt": *authReq.Parameters.DpopJkt} + effectiveJkt = *authReq.Parameters.DpopJkt + } else if proof != nil { + effectiveJkt = proof.JKT + } + + if effectiveJkt != "" { + accessClaims["cnf"] = map[string]string{"jkt": effectiveJkt} } accessToken := jwt.NewWithClaims(jwt.SigningMethodES256, accessClaims) @@ -144,10 +155,18 @@ func (s *Server) handleOauthToken(e echo.Context) error { return err } + // Ensure the stored token parameters reflect the effective DPoP binding + // so that subsequent requests (middleware JKT check, refresh) are + // consistent with the JWT's cnf claim. + tokenParams := authReq.Parameters + if effectiveJkt != "" && tokenParams.DpopJkt == nil { + tokenParams.DpopJkt = to.StringPtr(effectiveJkt) + } + if err := s.db.Create(ctx, &provider.OauthToken{ ClientId: authReq.ClientId, ClientAuth: *clientAuth, - Parameters: authReq.Parameters, + Parameters: tokenParams, ExpiresAt: eat, DeviceId: "", Sub: repo.Repo.Did, @@ -160,9 +179,8 @@ func (s *Server) handleOauthToken(e echo.Context) error { return helpers.ServerError(e, nil) } - // prob not needed tokenType := "Bearer" - if authReq.Parameters.DpopJkt != nil { + if effectiveJkt != "" { tokenType = "DPoP" } From d80c871d1d256da209e0456f8d58215547bf5ea9 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 3 Jul 2026 15:50:40 -0700 Subject: [PATCH 3/3] fix(cors): echo specific Origin instead of wildcard with credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CORS config used AllowOrigins: ["*"] and AllowHeaders: ["*"] with AllowCredentials: true. Per the Fetch spec, wildcard values for origin and headers are invalid when credentials are allowed — browsers reject the preflight, silently blocking the actual request. This was preventing OAuth token exchanges from browser-based clients (like pckt.blog) that send credentials with their fetch requests. The preflight would be blocked, so POST /oauth/token never reached the server. Fix: use AllowOriginFunc to echo the specific Origin header, and omit AllowHeaders so Echo reflects the client's requested headers (both valid for credentialed requests). --- server/server.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/server/server.go b/server/server.go index 6896eb2..5d65a25 100644 --- a/server/server.go +++ b/server/server.go @@ -293,8 +293,13 @@ func New(args *Args) (*Server, error) { e.Use(echo_session.Middleware(sessions.NewCookieStore([]byte(args.SessionSecret)))) e.Use(echoprometheus.NewMiddleware("cocoon")) e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ - AllowOrigins: []string{"*"}, - AllowHeaders: []string{"*"}, + // Use AllowOriginFunc instead of AllowOrigins: ["*"] so that the + // specific Origin is echoed back. When AllowCredentials is true, + // browsers reject "Access-Control-Allow-Origin: *" and + // "Access-Control-Allow-Headers: *" for credentialed requests. + // This was silently breaking OAuth token exchanges from browser-based + // clients (e.g. pckt.blog) that send credentials with their fetch. + AllowOriginFunc: func(origin string) (bool, error) { return true, nil }, AllowMethods: []string{"*"}, AllowCredentials: true, MaxAge: 100_000_000,