mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-10-05 04:51:58 +00:00
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.
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user