Files
ResolveSpec/pkg/security/oauth_dpop.go
T
Hein 640faeeeaf feat(security): full OAuth 2.1 / OpenID Connect server and OIDC relying-party client
Authorization server: consent and scopes, OIDC (nonce, auth_time, acr, sid,
at_hash, signed userinfo, RP-initiated and back-channel logout), managed
refresh tokens with rotation and reuse detection, RFC 9068 JWT access tokens,
DPoP, PAR, device grant, token exchange, private_key_jwt, RFC 7591/7592
registration, RFC 9207 iss, signing keyring with rotation.

State is DB-backed through a new lookup.OAuthGrantStore (procedure and direct
backends, four dialect DDLs, conformance cases).

Client side: WithOIDC discovery, PKCE, nonce, id_token validation, OAuth2LogoutURL.

PeekRefresh now returns already rotated tokens so RotateRefresh can detect reuse.

Docs: OAUTH2_SERVER.md, oauth2_full_example.go, breaking_changes.md step 8.
2026-10-01 14:42:12 +02:00

107 lines
2.8 KiB
Go

package security
import (
"crypto/sha256"
"crypto/subtle"
"net/http"
"net/url"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// dpopProof is a validated RFC 9449 proof.
type dpopProof struct {
JKT string // base64url SHA-256 thumbprint of the proof key
}
// verifyDPoP validates the DPoP header of r. It returns (nil, nil) when the request has none.
// accessToken, when non-empty, is the token the proof must be bound to (the "ath" claim).
func (s *OAuthServer) verifyDPoP(r *http.Request, accessToken string) (*dpopProof, *oauthError) {
vals := r.Header.Values("DPoP")
if len(vals) == 0 {
return nil, nil
}
bad := func(desc string) (*dpopProof, *oauthError) {
return nil, oerr("invalid_dpop_proof", desc, http.StatusBadRequest)
}
if !s.cfg.EnableDPoP {
return bad("DPoP is not enabled")
}
if len(vals) != 1 {
return bad("exactly one DPoP header is required")
}
var jkt string
claims := &struct {
jwt.RegisteredClaims
HTM string `json:"htm"`
HTU string `json:"htu"`
ATH string `json:"ath"`
}{}
tok, err := jwt.NewParser(jwt.WithValidMethods([]string{"ES256", "ES384", "RS256", "PS256"})).
ParseWithClaims(vals[0], claims, func(t *jwt.Token) (any, error) {
if t.Header["typ"] != "dpop+jwt" {
return nil, jwt.ErrTokenUnverifiable
}
m, ok := t.Header["jwk"].(map[string]any)
if !ok {
return nil, jwt.ErrTokenUnverifiable
}
if _, private := m["d"]; private {
return nil, jwt.ErrTokenUnverifiable
}
pub, err := publicFromJWK(m)
if err != nil {
return nil, err
}
if jkt, err = jwkThumbprint(pub); err != nil {
return nil, err
}
return pub, nil
})
if err != nil || !tok.Valid {
return bad("proof signature or header invalid")
}
if claims.HTM != r.Method {
return bad("htm does not match the request method")
}
if !sameURLNoQuery(claims.HTU, s.requestURL(r)) {
return bad("htu does not match the request URL")
}
if claims.IssuedAt == nil || claims.ID == "" {
return bad("iat and jti are required")
}
iat := claims.IssuedAt.Time
if d := time.Since(iat); d > 2*time.Minute || d < -time.Minute {
return bad("proof is not fresh")
}
if accessToken != "" {
sum := sha256.Sum256([]byte(accessToken))
if subtle.ConstantTimeCompare([]byte(b64u(sum[:])), []byte(claims.ATH)) != 1 {
return bad("ath does not match the access token")
}
}
seen, err := s.replayed(r.Context(), "dpop:"+jkt+":"+claims.ID, iat.Add(3*time.Minute))
if err != nil {
return nil, serverErr()
}
if seen {
return bad("proof replayed")
}
return &dpopProof{JKT: jkt}, nil
}
func sameURLNoQuery(a, b string) bool {
ua, err1 := url.Parse(a)
ub, err2 := url.Parse(b)
if err1 != nil || err2 != nil {
return false
}
norm := func(u *url.URL) string {
return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + u.EscapedPath()
}
return norm(ua) == norm(ub)
}