feat: add support for OAuth 2.0 DPoP (Demonstrating Proof of Possession)

This commit is contained in:
Yang Luo
2026-04-11 10:45:33 +08:00
parent 29eeb03f85
commit d7bc2bf052
8 changed files with 254 additions and 19 deletions

View File

@@ -250,6 +250,9 @@ func (c *ApiController) GetOAuthToken() {
}
}
// Extract DPoP proof header (RFC 9449). Empty string when DPoP is not used.
dpopProof := c.Ctx.Request.Header.Get("DPoP")
host := c.Ctx.Request.Host
if deviceCode != "" {
deviceAuthCache, ok := object.DeviceAuthMap.Load(deviceCode)
@@ -291,7 +294,7 @@ func (c *ApiController) GetOAuthToken() {
username = deviceAuthCacheCast.UserName
}
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource)
token, err := object.GetOAuthToken(grantType, clientId, clientSecret, code, verifier, scope, nonce, username, password, host, refreshToken, tag, avatar, c.GetAcceptLanguage(), subjectToken, subjectTokenType, assertion, clientAssertion, clientAssertionType, audience, resource, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -340,7 +343,8 @@ func (c *ApiController) RefreshToken() {
return
}
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
dpopProof := c.Ctx.Request.Header.Get("DPoP")
refreshToken2, err := object.RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
if err != nil {
c.ResponseError(err.Error())
return
@@ -556,6 +560,11 @@ func (c *ApiController) IntrospectToken() {
introspectionResponse.TokenType = token.TokenType
introspectionResponse.ClientId = application.ClientId
// Expose DPoP key binding in the introspection response (RFC 9449 §8).
if token.DPoPJkt != "" {
introspectionResponse.Cnf = &object.DPoPConfirmation{JKT: token.DPoPJkt}
}
}
c.Data["json"] = introspectionResponse

View File

@@ -43,7 +43,8 @@ type Token struct {
CodeChallenge string `xorm:"varchar(100)" json:"codeChallenge"`
CodeIsUsed bool `json:"codeIsUsed"`
CodeExpireIn int64 `json:"codeExpireIn"`
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
Resource string `xorm:"varchar(255)" json:"resource"` // RFC 8707 Resource Indicator
DPoPJkt string `xorm:"varchar(255) 'dpop_jkt'" json:"dPoPJkt"` // RFC 9449 DPoP JWK thumbprint binding
}
func GetTokenCount(owner, organization, field, value string) (int64, error) {
@@ -235,3 +236,9 @@ func ExpireTokenByUser(owner, username string) (bool, error) {
return affected != 0, nil
}
// updateTokenDPoP updates the token_type and dpop_jkt columns for DPoP binding (RFC 9449).
func updateTokenDPoP(token *Token) error {
_, err := ormer.Engine.ID(core.PK{token.Owner, token.Name}).Cols("token_type", "dpop_jkt").Update(token)
return err
}

157
object/token_dpop.go Normal file
View File

@@ -0,0 +1,157 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"crypto"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/golang-jwt/jwt/v5"
)
const dpopMaxAgeSeconds = 300
// DPoPProofClaims represents the payload claims of a DPoP proof JWT (RFC 9449).
type DPoPProofClaims struct {
Jti string `json:"jti"`
Htm string `json:"htm"`
Htu string `json:"htu"`
Ath string `json:"ath,omitempty"`
jwt.RegisteredClaims
}
// ValidateDPoPProof validates a DPoP proof JWT as specified in RFC 9449.
//
// - proofToken: the compact-serialized DPoP proof JWT from the DPoP HTTP header
// - method: the HTTP request method (e.g., "POST", "GET")
// - htu: the HTTP request URL without query string or fragment
// - accessToken: the access token string; empty at the token endpoint,
// non-empty at protected resource endpoints (enables ath claim validation)
//
// On success it returns the base64url-encoded SHA-256 JWK thumbprint (jkt) of
// the DPoP public key embedded in the proof header.
func ValidateDPoPProof(proofToken, method, htu, accessToken string) (string, error) {
parts := strings.Split(proofToken, ".")
if len(parts) != 3 {
return "", fmt.Errorf("invalid DPoP proof JWT format")
}
// Decode and inspect the JOSE header before signature verification.
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("failed to decode DPoP proof header: %w", err)
}
var header struct {
Typ string `json:"typ"`
Alg string `json:"alg"`
JWK json.RawMessage `json:"jwk"`
}
if err = json.Unmarshal(headerBytes, &header); err != nil {
return "", fmt.Errorf("failed to parse DPoP proof header: %w", err)
}
// typ MUST be exactly "dpop+jwt" (RFC 9449 §4.2).
if header.Typ != "dpop+jwt" {
return "", fmt.Errorf("DPoP proof typ must be \"dpop+jwt\", got %q", header.Typ)
}
// alg MUST identify an asymmetric digital signature algorithm;
// symmetric algorithms (HS*) are explicitly forbidden (RFC 9449 §4.2).
if header.Alg == "" || strings.HasPrefix(header.Alg, "HS") {
return "", fmt.Errorf("DPoP proof must use an asymmetric algorithm, got %q", header.Alg)
}
// jwk MUST be present (RFC 9449 §4.2).
if len(header.JWK) == 0 {
return "", fmt.Errorf("DPoP proof header must contain the jwk claim")
}
var jwkKey jose.JSONWebKey
if err = jwkKey.UnmarshalJSON(header.JWK); err != nil {
return "", fmt.Errorf("failed to parse DPoP JWK: %w", err)
}
// Compute the JWK SHA-256 thumbprint per RFC 7638.
thumbprintBytes, err := jwkKey.Thumbprint(crypto.SHA256)
if err != nil {
return "", fmt.Errorf("failed to compute DPoP JWK thumbprint: %w", err)
}
jkt := base64.RawURLEncoding.EncodeToString(thumbprintBytes)
// Verify the proof's signature using the public key embedded in the header.
// WithoutClaimsValidation is used so that we can perform all claim checks
// ourselves (jwt library exp/nbf validation is not appropriate here).
t, err := jwt.ParseWithClaims(proofToken, &DPoPProofClaims{}, func(token *jwt.Token) (interface{}, error) {
return jwkKey.Key, nil
}, jwt.WithoutClaimsValidation())
if err != nil || !t.Valid {
return "", fmt.Errorf("DPoP proof signature verification failed: %w", err)
}
claims, ok := t.Claims.(*DPoPProofClaims)
if !ok {
return "", fmt.Errorf("failed to parse DPoP proof claims")
}
// htm MUST match the HTTP request method (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htm, method) {
return "", fmt.Errorf("DPoP proof htm %q does not match request method %q", claims.Htm, method)
}
// htu MUST match the request URL without query/fragment (RFC 9449 §4.2).
if !strings.EqualFold(claims.Htu, htu) {
return "", fmt.Errorf("DPoP proof htu %q does not match request URL %q", claims.Htu, htu)
}
// iat MUST be present and within the acceptable time window (RFC 9449 §4.2).
if claims.IssuedAt == nil {
return "", fmt.Errorf("DPoP proof missing iat claim")
}
age := time.Since(claims.IssuedAt.Time).Abs()
if age > time.Duration(dpopMaxAgeSeconds)*time.Second {
return "", fmt.Errorf("DPoP proof iat is outside the acceptable time window (%d seconds)", dpopMaxAgeSeconds)
}
// jti MUST be present to support replay detection (RFC 9449 §4.2).
if claims.Jti == "" {
return "", fmt.Errorf("DPoP proof missing jti claim")
}
// ath MUST be validated at protected resource endpoints (RFC 9449 §4.2).
// It is the base64url-encoded SHA-256 hash of the ASCII access token string.
if accessToken != "" {
hash := sha256.Sum256([]byte(accessToken))
expectedAth := base64.RawURLEncoding.EncodeToString(hash[:])
if claims.Ath != expectedAth {
return "", fmt.Errorf("DPoP proof ath claim does not match access token hash")
}
}
return jkt, nil
}
// GetDPoPHtu constructs the full DPoP htu URL for a given host and path.
// It uses the same origin-detection logic as the rest of the backend.
func GetDPoPHtu(host, path string) string {
_, originBackend := getOriginFromHost(host)
return originBackend + path
}

View File

@@ -23,7 +23,7 @@ import (
"github.com/casdoor/casdoor/util"
)
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string) (interface{}, error) {
func GetOAuthToken(grantType string, clientId string, clientSecret string, code string, verifier string, scope string, nonce string, username string, password string, host string, refreshToken string, tag string, avatar string, lang string, subjectToken string, subjectTokenType string, assertion string, clientAssertion string, clientAssertionType string, audience string, resource string, dpopProof string) (interface{}, error) {
var (
application *Application
err error
@@ -85,7 +85,7 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
case "urn:ietf:params:oauth:grant-type:token-exchange": // Token Exchange Grant (RFC 8693)
token, tokenError, err = GetTokenExchangeToken(application, clientSecret, subjectToken, subjectTokenType, audience, scope, host)
case "refresh_token":
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host)
refreshToken2, err := RefreshToken(application, grantType, refreshToken, scope, clientId, clientSecret, host, dpopProof)
if err != nil {
return nil, err
}
@@ -108,6 +108,23 @@ func GetOAuthToken(grantType string, clientId string, clientSecret string, code
return tokenError, nil
}
// Apply DPoP binding (RFC 9449) if a DPoP proof was supplied by the client.
if dpopProof != "" {
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
jkt, dpopErr := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
if dpopErr != nil {
return &TokenError{
Error: "invalid_dpop_proof",
ErrorDescription: dpopErr.Error(),
}, nil
}
token.TokenType = "DPoP"
token.DPoPJkt = jkt
if err = updateTokenDPoP(token); err != nil {
return nil, err
}
}
token.CodeIsUsed = true
_, err = updateUsedByCode(token)

View File

@@ -62,19 +62,25 @@ type TokenError struct {
ErrorDescription string `json:"error_description,omitempty"`
}
// DPoPConfirmation holds the DPoP key confirmation claim (RFC 9449).
type DPoPConfirmation struct {
JKT string `json:"jkt"`
}
type IntrospectionResponse struct {
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientId string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud []string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
Active bool `json:"active"`
Scope string `json:"scope,omitempty"`
ClientId string `json:"client_id,omitempty"`
Username string `json:"username,omitempty"`
TokenType string `json:"token_type,omitempty"`
Exp int64 `json:"exp,omitempty"`
Iat int64 `json:"iat,omitempty"`
Nbf int64 `json:"nbf,omitempty"`
Sub string `json:"sub,omitempty"`
Aud []string `json:"aud,omitempty"`
Iss string `json:"iss,omitempty"`
Jti string `json:"jti,omitempty"`
Cnf *DPoPConfirmation `json:"cnf,omitempty"` // RFC 9449 DPoP key binding
}
type DeviceAuthCache struct {
@@ -349,7 +355,7 @@ func GetOAuthCode(userId string, clientId string, provider string, signinMethod
}, nil
}
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string) (interface{}, error) {
func RefreshToken(application *Application, grantType string, refreshToken string, scope string, clientId string, clientSecret string, host string, dpopProof string) (interface{}, error) {
if grantType != "refresh_token" {
return &TokenError{
Error: UnsupportedGrantType,
@@ -480,6 +486,23 @@ func RefreshToken(application *Application, grantType string, refreshToken strin
return nil, err
}
// Apply DPoP binding to the refreshed token if a DPoP proof was provided.
if dpopProof != "" {
dpopHtu := GetDPoPHtu(host, "/api/login/oauth/access_token")
jkt, err := ValidateDPoPProof(dpopProof, "POST", dpopHtu, "")
if err != nil {
return &TokenError{
Error: "invalid_dpop_proof",
ErrorDescription: err.Error(),
}, nil
}
newToken.TokenType = "DPoP"
newToken.DPoPJkt = jkt
if err = updateTokenDPoP(newToken); err != nil {
return nil, err
}
}
_, err = DeleteToken(token)
if err != nil {
return nil, err

View File

@@ -46,6 +46,7 @@ type OidcDiscovery struct {
RequestParameterSupported bool `json:"request_parameter_supported"`
RequestObjectSigningAlgValuesSupported []string `json:"request_object_signing_alg_values_supported"`
EndSessionEndpoint string `json:"end_session_endpoint"`
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported,omitempty"` // RFC 9449
}
type WebFinger struct {
@@ -167,6 +168,7 @@ func GetOidcDiscovery(host string, applicationName string) OidcDiscovery {
RequestParameterSupported: true,
RequestObjectSigningAlgValuesSupported: []string{"HS256", "HS384", "HS512"},
EndSessionEndpoint: fmt.Sprintf("%s/api/logout", originBackend),
DPoPSigningAlgValuesSupported: []string{"RS256", "RS512", "ES256", "ES384", "ES512", "PS256", "PS384", "PS512"},
}
return oidcDiscovery

View File

@@ -70,6 +70,25 @@ func AutoSigninFilter(ctx *context.Context) {
return
}
// Validate DPoP proof for DPoP-bound tokens (RFC 9449).
if token.TokenType == "DPoP" {
dpopProof := ctx.Request.Header.Get("DPoP")
if dpopProof == "" {
responseError(ctx, "DPoP proof header required for DPoP-bound access token")
return
}
htu := object.GetDPoPHtu(ctx.Request.Host, ctx.Request.URL.Path)
jkt, dpopErr := object.ValidateDPoPProof(dpopProof, ctx.Request.Method, htu, accessToken)
if dpopErr != nil {
responseError(ctx, fmt.Sprintf("DPoP proof validation failed: %s", dpopErr.Error()))
return
}
if jkt != token.DPoPJkt {
responseError(ctx, "DPoP proof key binding mismatch")
return
}
}
userId := util.GetId(token.Organization, token.User)
application, err := object.GetApplicationByUserId(fmt.Sprintf("app/%s", token.Application))
if err != nil {

View File

@@ -238,8 +238,9 @@ func parseBearerToken(ctx *context.Context) string {
return ""
}
// Accept both "Bearer" (RFC 6750) and "DPoP" (RFC 9449) authorization schemes.
prefix := tokens[0]
if prefix != "Bearer" {
if prefix != "Bearer" && prefix != "DPoP" {
return ""
}