mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-02 11:31:57 +00:00
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.
107 lines
2.8 KiB
Go
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)
|
|
}
|