diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4542945f..cfdbbe32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,9 @@
## Release Highlights
+- 💪 DPoP support (RFC 9449 aka "Demonstration of Proof-of-Possession")
+ - see [dpop.md](docs/docs/features/dpop.md)
+
## Important Notes
## Breaking Changes
diff --git a/docs/docs/configuration/overview.md b/docs/docs/configuration/overview.md
index 7bd7bf07..f35c2a5a 100644
--- a/docs/docs/configuration/overview.md
+++ b/docs/docs/configuration/overview.md
@@ -83,7 +83,7 @@ Provider specific options can be found on their respective subpages.
| flag: `--approval-prompt`
toml: `approval_prompt` | string | OAuth approval_prompt | `"force"` |
| flag: `--backend-logout-url`
toml: `backend_logout_url` | string | URL to perform backend logout, if you use `{id_token}` in the url it will be replaced by the actual `id_token` of the user session | |
| flag: `--client-id`
toml: `client_id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | |
-| flag: `--client-secret-file`
toml: `client_secret_file` | string | the file with OAuth Client Secret. The file must contain the secret only, with no trailing newline | |
+| flag: `--client-secret-file`
toml: `client_secret_file` | string | the file with OAuth Client Secret. The file must contain the secret only, with no trailing newline | |
| flag: `--client-secret`
toml: `client_secret` | string | the OAuth Client Secret | |
| flag: `--code-challenge-method`
toml: `code_challenge_method` | string | use PKCE code challenges with the specified method. Either 'plain' or 'S256' (recommended) | |
| flag: `--insecure-oidc-allow-unverified-email`
toml: `insecure_oidc_allow_unverified_email` | bool | don't fail if an email address in an id_token is not verified | false |
@@ -128,7 +128,7 @@ Provider specific options can be found on their respective subpages.
| flag: `--cookie-refresh`
toml: `cookie_refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers [^1] | |
| flag: `--cookie-samesite`
toml: `cookie_samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
| flag: `--cookie-secret`
toml: `cookie_secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
-| flag: `--cookie-secret-file`
toml: `cookie_secret_file` | string | File containing the cookie secret (must be raw binary, exactly 16, 24, or 32 bytes). Use dd if=/dev/urandom bs=32 count=1 > cookie.secret to generate | |
+| flag: `--cookie-secret-file`
toml: `cookie_secret_file` | string | File containing the cookie secret (must be raw binary, exactly 16, 24, or 32 bytes). Use dd if=/dev/urandom bs=32 count=1 > cookie.secret to generate | |
| flag: `--cookie-secure`
toml: `cookie_secure` | bool | set [secure (HTTPS only) cookie flag](https://owasp.org/www-community/controls/SecureFlag) | true |
[^1]: The following providers support `--cookie-refresh`: ADFS, Azure, GitLab, Google, Keycloak and all other Identity Providers which support the full [OIDC specification](https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokens)
@@ -253,6 +253,26 @@ Provider specific options can be found on their respective subpages.
| flag: `--redis-use-sentinel`
toml: `redis_use_sentinel` | bool | Connect to redis via sentinels. Must set `--redis-sentinel-master-name` and `--redis-sentinel-connection-urls` to use this feature | false |
| flag: `--redis-connection-idle-timeout`
toml: `redis_connection_idle_timeout` | int | Redis connection idle timeout seconds. If Redis [timeout](https://redis.io/docs/reference/clients/#client-timeouts) option is set to non-zero, the `--redis-connection-idle-timeout` must be less than Redis timeout option. Example: if either redis.conf includes `timeout 15` or using `CONFIG SET timeout 15` the `--redis-connection-idle-timeout` must be at least `--redis-connection-idle-timeout=14` | 0 |
+### DPoP Options
+
+| Flag / Config Field | Type | Description | Default |
+| --------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
+| flag: `--enable-dpop-support`
toml: `enable_dpop_support` | bool | enable DPoP support for verifying DPoP structured tokens | false |
+| flag: `--dpop-time-window`
toml: `dpop_time_window` | duration | the acceptable time window for DPoP proof's `iat` claim | 30s |
+| flag: `--dpop-jti-store-type`
toml: `dpop_jti_store_type` | string | the type of JTI store to use for DPoP (`memory`, `redis`, `session-redis`). NOTE: defaults to `session-redis` if session type is `redis`, otherwise `memory`. | memory or session-redis |
+| flag: `--dpop-redis-connection-url`
toml: `dpop_redis_connection_url` | string | URL of redis server for DPoP JTI storage (e.g. `redis://HOST[:PORT]`) | |
+| flag: `--dpop-redis-username`
toml: `dpop_redis_username` | string | Redis username for DPoP storage. Applicable for Redis configurations where ACL has been configured. Will override any username set in `--dpop-redis-connection-url` | |
+| flag: `--dpop-redis-password`
toml: `dpop_redis_password` | string | Redis password for DPoP storage. Applicable for all Redis configurations. Will override any password set in `--dpop-redis-connection-url` | |
+| flag: `--dpop-redis-use-sentinel`
toml: `dpop_redis_use_sentinel` | bool | Connect to redis via sentinels for DPoP storage. Must set `--dpop-redis-sentinel-master-name` and `--dpop-redis-sentinel-connection-urls` to use this feature | false |
+| flag: `--dpop-redis-sentinel-password`
toml: `dpop_redis_sentinel_password` | string | Redis sentinel password for DPoP storage. Used only for sentinel connection; any redis node passwords need to use `--dpop-redis-password` | |
+| flag: `--dpop-redis-sentinel-master-name`
toml: `dpop_redis_sentinel_master_name` | string | Redis sentinel master name for DPoP storage. Used in conjunction with `--dpop-redis-use-sentinel` | |
+| flag: `--dpop-redis-sentinel-connection-urls`
toml: `dpop_redis_sentinel_connection_urls` | string \| list | List of Redis sentinel connection URLs (e.g. `redis://HOST[:PORT]`) for DPoP storage. Used in conjunction with `--dpop-redis-use-sentinel` | |
+| flag: `--dpop-redis-use-cluster`
toml: `dpop_redis_use_cluster` | bool | Connect to redis cluster for DPoP storage. Must set `--dpop-redis-cluster-connection-urls` to use this feature | false |
+| flag: `--dpop-redis-cluster-connection-urls`
toml: `dpop_redis_cluster_connection_urls` | string \| list | List of Redis cluster connection URLs (e.g. `redis://HOST[:PORT]`) for DPoP storage. Used in conjunction with `--dpop-redis-use-cluster` | |
+| flag: `--dpop-redis-ca-path`
toml: `dpop_redis_ca_path` | string | Path to CA certificate for Redis connection (DPoP storage) | |
+| flag: `--dpop-redis-insecure-skip-tls-verify`
toml: `dpop_redis_insecure_skip_tls_verify` | bool | skip TLS verification when connecting to Redis (DPoP storage) | false |
+| flag: `--dpop-redis-connection-idle-timeout`
toml: `dpop_redis_connection_idle_timeout` | int | Redis connection idle timeout seconds for DPoP storage. | 0 |
+
### Upstream Options
| Flag / Config Field | Type | Description | Default |
diff --git a/docs/docs/features/dpop.md b/docs/docs/features/dpop.md
new file mode 100644
index 00000000..de4925b3
--- /dev/null
+++ b/docs/docs/features/dpop.md
@@ -0,0 +1,48 @@
+---
+id: dpop
+title: DPoP
+---
+
+OAuth2-Proxy supports **Demonstrating Proof of Possession (DPoP)** at the application level, as defined in [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449).
+
+DPoP is a mechanism that allows a client to prove possession of a private key by signing a JWT (DPoP Proof) and including it in the request. This binds the access token to the client's key, preventing token replay if intercepted.
+
+## Implementation Details
+
+- **Spec Support**: [RFC 9449](https://datatracker.ietf.org/doc/html/rfc9449).
+- **Library**: JWT parsing and signature verification are handled by [go-jose](https://github.com/go-jose/go-jose).
+- **JTI Storage**: To prevent replay attacks, the JTI (JWT ID) from the DPoP proof is stored for the duration of the time window.
+
+## Configuration
+
+Enable DPoP support and configure the JTI store using the following flags:
+
+| Flag | Description | Default |
+| ----------------------- | ------------------------------------------------------------------ | --------- |
+| `--enable-dpop-support` | Enable verification of DPoP structured tokens. | `false` |
+| `--dpop-time-window` | The acceptable time window for DPoP proof's `iat` claim. | `5m` |
+| `--dpop-jti-store-type` | The type of JTI store to use (`memory`, `redis`, `session-redis`). | `memory`* |
+
+> [!NOTE]
+> *The default for `--dpop-jti-store-type` is dynamic. If your session store is set to `redis` and you don't explicitly set a JTI store type, it will automatically use `session-redis`.
+
+### Redis Session Integration (`session-redis`)
+
+The `session-redis` store type allows DPoP to reuse the existing Redis configuration used for sessions. This is the recommended configuration when using Redis sessions as it requires no additional DPoP-specific Redis flags.
+
+```bash
+# Example configuration for Redis sessions with automatic DPoP JTI storage
+oauth2-proxy \
+ --session-store-type=redis \
+ --redis-connection-url=redis://localhost:6379 \
+ --enable-dpop-support=true
+```
+
+### Standalone Redis Storage (`redis`)
+
+If you want to use a separate Redis instance for DPoP JTIs, use the `redis` store type and configure the DPoP-specific Redis flags:
+
+- `--dpop-redis-connection-url`
+- `--dpop-redis-password`
+- `--dpop-redis-use-sentinel`
+- (and other `dpop-redis-*` flags)
diff --git a/oauthproxy.go b/oauthproxy.go
index 508084c8..4eb2d662 100644
--- a/oauthproxy.go
+++ b/oauthproxy.go
@@ -27,6 +27,7 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/app/redirect"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/authentication/basic"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/cookies"
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/dpop"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/encryption"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/proxyhttp"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/util"
@@ -118,6 +119,15 @@ type OAuthProxy struct {
// NewOAuthProxy creates a new instance of OAuthProxy from the options provided
func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthProxy, error) {
+ var dpopValidator dpop.Validator
+ if opts.DPoP.Enable {
+ dpopStore, err := dpop.NewDpopStore(opts)
+ if err != nil {
+ return nil, fmt.Errorf("error initialising DPoP store: %v", err)
+ }
+ dpopValidator = dpop.NewDpopValidator(opts.DPoP.TimeWindow, dpopStore)
+ }
+
sessionStore, err := sessions.NewSessionStore(&opts.Session, &opts.Cookie)
if err != nil {
return nil, fmt.Errorf("error initialising session store: %v", err)
@@ -204,7 +214,7 @@ func NewOAuthProxy(opts *options.Options, validator func(string) bool) (*OAuthPr
if err != nil {
return nil, fmt.Errorf("could not build pre-auth chain: %v", err)
}
- sessionChain := buildSessionChain(opts, provider, sessionStore, basicAuthValidator)
+ sessionChain := buildSessionChain(opts, provider, sessionStore, basicAuthValidator, dpopValidator)
headersChain, err := buildHeadersChain(opts)
if err != nil {
return nil, fmt.Errorf("could not build headers chain: %v", err)
@@ -393,7 +403,7 @@ func buildPreAuthChain(opts *options.Options, sessionStore sessionsapi.SessionSt
return chain, nil
}
-func buildSessionChain(opts *options.Options, provider providers.Provider, sessionStore sessionsapi.SessionStore, validator basic.Validator) alice.Chain {
+func buildSessionChain(opts *options.Options, provider providers.Provider, sessionStore sessionsapi.SessionStore, validator basic.Validator, dpopValidator dpop.Validator) alice.Chain {
chain := alice.New()
if opts.SkipJwtBearerTokens {
@@ -407,7 +417,7 @@ func buildSessionChain(opts *options.Options, provider providers.Provider, sessi
middlewareapi.CreateTokenToSessionFunc(verifier.Verify))
}
- chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders, opts.BearerTokenLoginFallback))
+ chain = chain.Append(middleware.NewJwtSessionLoader(sessionLoaders, opts.BearerTokenLoginFallback, dpopValidator))
}
if validator != nil {
diff --git a/pkg/apis/options/dpop.go b/pkg/apis/options/dpop.go
new file mode 100644
index 00000000..844798c8
--- /dev/null
+++ b/pkg/apis/options/dpop.go
@@ -0,0 +1,58 @@
+package options
+
+import (
+ "time"
+)
+
+// DefaultDpopTimeWindow is the default acceptable time window for DPoP proof's iat claim
+const DefaultDpopTimeWindow = 30 * time.Second
+
+// DpopOptions holds the configuration for Demonstrating Proof-of-Possession
+type DpopOptions struct {
+ Enable bool `flag:"enable-dpop-support" cfg:"enable_dpop_support"`
+ TimeWindow time.Duration `flag:"dpop-time-window" cfg:"dpop_time_window"`
+ JtiStoreType string `flag:"dpop-jti-store-type" cfg:"dpop_jti_store_type"`
+ Redis DpopRedisStoreOptions `cfg:",squash"`
+}
+
+// DpopRedisStoreOptions contains configuration options for the DPoP Redis JTI store.
+// It is a copy of RedisStoreOptions but with dpop-specific flag and cfg tags.
+type DpopRedisStoreOptions struct {
+ ConnectionURL string `flag:"dpop-redis-connection-url" cfg:"dpop_redis_connection_url"`
+ Username string `flag:"dpop-redis-username" cfg:"dpop_redis_username"`
+ Password string `flag:"dpop-redis-password" cfg:"dpop_redis_password"`
+ UseSentinel bool `flag:"dpop-redis-use-sentinel" cfg:"dpop_redis_use_sentinel"`
+ SentinelPassword string `flag:"dpop-redis-sentinel-password" cfg:"dpop_redis_sentinel_password"`
+ SentinelMasterName string `flag:"dpop-redis-sentinel-master-name" cfg:"dpop_redis_sentinel_master_name"`
+ SentinelConnectionURLs []string `flag:"dpop-redis-sentinel-connection-urls" cfg:"dpop_redis_sentinel_connection_urls"`
+ UseCluster bool `flag:"dpop-redis-use-cluster" cfg:"dpop_redis_use_cluster"`
+ ClusterConnectionURLs []string `flag:"dpop-redis-cluster-connection-urls" cfg:"dpop_redis_cluster_connection_urls"`
+ CAPath string `flag:"dpop-redis-ca-path" cfg:"dpop_redis_ca_path"`
+ InsecureSkipTLSVerify bool `flag:"dpop-redis-insecure-skip-tls-verify" cfg:"dpop_redis_insecure_skip_tls_verify"`
+ IdleTimeout int `flag:"dpop-redis-connection-idle-timeout" cfg:"dpop_redis_connection_idle_timeout"`
+}
+
+func (opts DpopRedisStoreOptions) ToRedisStoreOptions() RedisStoreOptions {
+ return RedisStoreOptions{
+ ConnectionURL: opts.ConnectionURL,
+ Username: opts.Username,
+ Password: opts.Password,
+ UseSentinel: opts.UseSentinel,
+ SentinelPassword: opts.SentinelPassword,
+ SentinelMasterName: opts.SentinelMasterName,
+ SentinelConnectionURLs: opts.SentinelConnectionURLs,
+ UseCluster: opts.UseCluster,
+ ClusterConnectionURLs: opts.ClusterConnectionURLs,
+ CAPath: opts.CAPath,
+ InsecureSkipTLSVerify: opts.InsecureSkipTLSVerify,
+ IdleTimeout: opts.IdleTimeout,
+ }
+}
+
+func dpopDefaults() DpopOptions {
+ return DpopOptions{
+ Enable: false,
+ TimeWindow: DefaultDpopTimeWindow,
+ JtiStoreType: "",
+ }
+}
diff --git a/pkg/apis/options/load_test.go b/pkg/apis/options/load_test.go
index 42083f76..e255cc56 100644
--- a/pkg/apis/options/load_test.go
+++ b/pkg/apis/options/load_test.go
@@ -58,6 +58,7 @@ var _ = Describe("Load", func() {
Templates: templatesDefaults(),
SkipAuthPreflight: false,
Logging: loggingDefaults(),
+ DPoP: dpopDefaults(),
},
}
diff --git a/pkg/apis/options/options.go b/pkg/apis/options/options.go
index b57d5aed..e799eb16 100644
--- a/pkg/apis/options/options.go
+++ b/pkg/apis/options/options.go
@@ -65,6 +65,8 @@ type Options struct {
EncodeState bool `flag:"encode-state" cfg:"encode_state"`
AllowQuerySemicolons bool `flag:"allow-query-semicolons" cfg:"allow_query_semicolons"`
+ DPoP DpopOptions `cfg:",squash"`
+
SignatureKey string `flag:"signature-key" cfg:"signature_key"`
GCPHealthChecks bool `flag:"gcp-healthchecks" cfg:"gcp_healthchecks"`
@@ -110,6 +112,7 @@ func NewOptions() *Options {
Templates: templatesDefaults(),
SkipAuthPreflight: false,
Logging: loggingDefaults(),
+ DPoP: dpopDefaults(),
}
}
@@ -135,6 +138,9 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.Bool("encode-state", false, "will encode oauth state with base64")
flagSet.Bool("allow-query-semicolons", false, "allow the use of semicolons in query args")
flagSet.StringSlice("extra-jwt-issuers", []string{}, "if skip-jwt-bearer-tokens is set, a list of extra JWT issuer=audience pairs (where the issuer URL has a .well-known/openid-configuration or a .well-known/jwks.json)")
+ flagSet.Bool("enable-dpop-support", false, "enable DPoP support for verifying DPoP structured tokens")
+ flagSet.Duration("dpop-time-window", DefaultDpopTimeWindow, "the acceptable time window for DPoP proof's iat claim")
+ flagSet.String("dpop-jti-store-type", "", "the type of JTI store to use for DPoP (memory, redis, session-redis). NOTE: defaults to session-redis if session type is redis")
flagSet.StringSlice("email-domain", []string{}, "authenticate emails with the specified domain (may be given multiple times). Use * to authenticate any email")
flagSet.StringSlice("whitelist-domain", []string{}, "allowed domains for redirection after authentication. Prefix domain with a . or a *. to allow subdomains (eg .example.com, *.example.com)")
@@ -159,6 +165,19 @@ func NewFlagSet() *pflag.FlagSet {
flagSet.Bool("redis-use-cluster", false, "Connect to redis cluster. Must set --redis-cluster-connection-urls to use this feature")
flagSet.StringSlice("redis-cluster-connection-urls", []string{}, "List of Redis cluster connection URLs (eg redis://[USER[:PASSWORD]@]HOST[:PORT]). Used in conjunction with --redis-use-cluster")
flagSet.Int("redis-connection-idle-timeout", 0, "Redis connection idle timeout seconds, if Redis timeout option is non-zero, the --redis-connection-idle-timeout must be less then Redis timeout option")
+
+ flagSet.String("dpop-redis-connection-url", "", "URL of redis server for DPoP JTI storage (eg: redis://[USER[:PASSWORD]@]HOST[:PORT])")
+ flagSet.String("dpop-redis-username", "", "Redis username for DPoP JTI storage. Applicable for Redis configurations where ACL has been configured. Will override any username set in `--dpop-redis-connection-url`")
+ flagSet.String("dpop-redis-password", "", "Redis password for DPoP JTI storage. Applicable for all Redis configurations. Will override any password set in `--dpop-redis-connection-url`")
+ flagSet.Bool("dpop-redis-use-sentinel", false, "Connect to redis via sentinels for DPoP JTI storage. Must set --dpop-redis-sentinel-master-name and --dpop-redis-sentinel-connection-urls to use this feature")
+ flagSet.String("dpop-redis-sentinel-password", "", "Redis sentinel password for DPoP JTI storage. Used only for sentinel connection; any redis node passwords need to use `--dpop-redis-password`")
+ flagSet.String("dpop-redis-sentinel-master-name", "", "Redis sentinel master name for DPoP JTI storage. Used in conjunction with --dpop-redis-use-sentinel")
+ flagSet.String("dpop-redis-ca-path", "", "Redis custom CA path for DPoP JTI storage")
+ flagSet.Bool("dpop-redis-insecure-skip-tls-verify", false, "Use insecure TLS connection to redis for DPoP JTI storage")
+ flagSet.StringSlice("dpop-redis-sentinel-connection-urls", []string{}, "List of Redis sentinel connection URLs for DPoP JTI storage (eg redis://[USER[:PASSWORD]@]HOST[:PORT]). Used in conjunction with --dpop-redis-use-sentinel")
+ flagSet.Bool("dpop-redis-use-cluster", false, "Connect to redis cluster for DPoP JTI storage. Must set --dpop-redis-cluster-connection-urls to use this feature")
+ flagSet.StringSlice("dpop-redis-cluster-connection-urls", []string{}, "List of Redis cluster connection URLs for DPoP JTI storage (eg redis://[USER[:PASSWORD]@]HOST[:PORT]). Used in conjunction with --dpop-redis-use-cluster")
+ flagSet.Int("dpop-redis-connection-idle-timeout", 0, "Redis connection idle timeout seconds for DPoP JTI storage, if Redis timeout option is non-zero, the --dpop-redis-connection-idle-timeout must be less then Redis timeout option")
flagSet.String("signature-key", "", "GAP-Signature request signature key (algorithm:secretkey)")
flagSet.Bool("gcp-healthchecks", false, "Enable GCP/GKE healthcheck endpoints")
diff --git a/pkg/dpop/dpop.go b/pkg/dpop/dpop.go
new file mode 100644
index 00000000..a6abf678
--- /dev/null
+++ b/pkg/dpop/dpop.go
@@ -0,0 +1,251 @@
+package dpop
+
+import (
+ "context"
+ "crypto"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-jose/go-jose/v3"
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests/util"
+)
+
+// claims represents the expected payload claims of a DPoP proof.
+type claims struct {
+ JTI string `json:"jti"`
+ HTM string `json:"htm"`
+ HTU string `json:"htu"`
+ IAT int64 `json:"iat"`
+ ATH string `json:"ath,omitempty"`
+}
+
+// CalcATH calculates the base64url encoded SHA-256 hash of an access token
+// to be used as the `ath` claim in a DPoP proof.
+func CalcATH(accessToken string) string {
+ hash := sha256.Sum256([]byte(accessToken))
+ return base64.RawURLEncoding.EncodeToString(hash[:])
+}
+
+// Validator is an interface for validating DPoP proofs.
+type Validator interface {
+ ValidateDPopToken(req *http.Request, accessToken string) (string, error)
+}
+
+type dpopValidator struct {
+ timeWindow time.Duration
+ store DpopStore
+}
+
+// NewDpopValidator creates a new DPoP validator with the given configuration.
+func NewDpopValidator(timeWindow time.Duration, store DpopStore) Validator {
+ return &dpopValidator{
+ timeWindow: timeWindow,
+ store: store,
+ }
+}
+
+// ValidateDPopToken validates the DPoP proof in the HTTP headers based on RFC 9449 (https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs).
+// It implements the DPoP proof validation steps described in Section 4.3.
+// Returns the JWK thumbprint on success, or an error if validation fails.
+//
+// Note: If an opaque access token is used, the caller is responsible for extracting
+// and verifying the JKT (e.g., via introspection) against the returned thumbprint.
+func (v *dpopValidator) ValidateDPopToken(req *http.Request, token string) (string, error) {
+ dpopJws, err := parseDpopHeaderJws(req)
+ if err != nil {
+ return "", err
+ }
+
+ dpopPayload, dpopJwk, err := checkJwsSignature(dpopJws)
+ if err != nil {
+ return "", err
+ }
+
+ claims, err := parseAndCheckDpopClaims(dpopPayload, req, v.timeWindow)
+ if err != nil {
+ return "", err
+ }
+
+ dpopJkt, err := calculateJkt(dpopJwk)
+ if err != nil {
+ return "", err
+ }
+
+ if token != "" {
+ if err := checkTokenAgainstJwtAth(token, claims); err != nil {
+ return "", err
+ }
+ if tokenCnfJkt := extractJwtCnfJktClaim(token); tokenCnfJkt != "" {
+ if tokenCnfJkt != dpopJkt {
+ return "", fmt.Errorf("DPoP thumbprint mismatch: token jkt %q != DPoP proof jkt %q", tokenCnfJkt, dpopJkt)
+ }
+ }
+ }
+
+ if v.store != nil {
+ if err := v.checkJwtReplay(req.Context(), dpopJkt, claims); err != nil {
+ return "", err
+ }
+ }
+
+ return dpopJkt, nil
+}
+
+// parseDpopHeaderJws parses the DPoP header string into a JSON Web Signature.
+// It enforces the requirement of a single DPoP header per RFC 9449 Section 4.1 (https://datatracker.ietf.org/doc/html/rfc9449#name-the-dpop-http-header).
+// Returns an error if multiple DPoP headers are present or the header is empty/invalid.
+func parseDpopHeaderJws(req *http.Request) (*jose.JSONWebSignature, error) {
+ dpopHeaders := req.Header.Values("DPoP")
+ if len(dpopHeaders) == 0 {
+ return nil, errors.New("missing DPoP header")
+ }
+ if len(dpopHeaders) > 1 {
+ return nil, errors.New("multiple DPoP headers present")
+ }
+
+ dpopJws, err := jose.ParseSigned(dpopHeaders[0])
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse DPoP JWS: %v", err)
+ }
+
+ return dpopJws, nil
+}
+
+// checkJwsSignature checks the JWS signature using the embedded JWK.
+// It validates the `typ` and `alg` headers and verifies the signature per RFC 9449 (https://datatracker.ietf.org/doc/html/rfc9449#name-checking-dpop-proofs).
+func checkJwsSignature(dpopJws *jose.JSONWebSignature) ([]byte, *jose.JSONWebKey, error) {
+ if len(dpopJws.Signatures) != 1 {
+ return nil, nil, errors.New("expected exactly one signature in DPoP JWS")
+ }
+
+ sig := dpopJws.Signatures[0]
+ header := sig.Protected
+
+ // RFC 9449 Section 4.2: typ must be "dpop+jwt"
+ typ, ok := header.ExtraHeaders["typ"].(string)
+ if !ok || !strings.EqualFold(typ, "dpop+jwt") {
+ return nil, nil, errors.New("invalid or missing typ header claim, expected dpop+jwt")
+ }
+
+ // alg must not be "none" (go-jose rejects empty alg, but explicitly check for none)
+ if header.Algorithm == "none" || header.Algorithm == "" {
+ return nil, nil, errors.New("invalid alg header claim")
+ }
+
+ // JWK must be present
+ jwk := header.JSONWebKey
+ if jwk == nil {
+ return nil, nil, errors.New("missing jwk header claim")
+ }
+ if !jwk.Valid() {
+ return nil, nil, errors.New("invalid jwk header claim")
+ }
+
+ // Verify the signature
+ payload, err := dpopJws.Verify(jwk)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to verify DPoP signature: %v", err)
+ }
+
+ return payload, jwk, nil
+}
+
+// parseAndCheckDpopClaims parses the payload and validates the required DPoP claims.
+// It verifies htm, htu, jti, and iat per RFC 9449 Section 4.3.
+func parseAndCheckDpopClaims(payloadBytes []byte, req *http.Request, timeWindow time.Duration) (*claims, error) {
+ var claims claims
+ if err := json.Unmarshal(payloadBytes, &claims); err != nil {
+ return nil, fmt.Errorf("failed to unmarshal DPoP payload: %v", err)
+ }
+
+ // jti is required
+ if claims.JTI == "" {
+ return nil, errors.New("missing jti claim")
+ }
+
+ // htm must match the HTTP method
+ if !strings.EqualFold(claims.HTM, req.Method) {
+ return nil, fmt.Errorf("htm claim (%q) does not match HTTP method (%q)", claims.HTM, req.Method)
+ }
+
+ // htu must match the HTTP URI (excluding query and fragment)
+ // Construct absolute URL from request
+ scheme := util.GetRequestProto(req)
+ host := util.GetRequestHost(req)
+ path := util.GetRequestPath(req)
+ expectedHTU := fmt.Sprintf("%s://%s%s", scheme, host, path)
+
+ if !strings.EqualFold(claims.HTU, expectedHTU) {
+ return nil, fmt.Errorf("htu claim (%q) does not match expected URI (%q)", claims.HTU, expectedHTU)
+ }
+
+ if claims.IAT == 0 {
+ return nil, errors.New("missing iat claim")
+ }
+
+ // iat must be within an acceptable time window
+ iatTime := time.Unix(claims.IAT, 0)
+ now := time.Now()
+ if iatTime.Before(now.Add(-timeWindow)) || iatTime.After(now.Add(timeWindow)) {
+ return nil, fmt.Errorf("invalid iat claim: %v is outside acceptable window", iatTime)
+ }
+
+ return &claims, nil
+}
+
+// calculateJkt calculates the JWK Thumbprint (RFC 7638) using SHA-256 (https://datatracker.ietf.org/doc/html/rfc7638).
+func calculateJkt(jwk *jose.JSONWebKey) (string, error) {
+ dpopThumbprintBytes, err := jwk.Thumbprint(crypto.SHA256)
+ if err != nil {
+ return "", fmt.Errorf("failed to calculate JWK thumbprint: %v", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(dpopThumbprintBytes), nil
+}
+
+// checkTokenAgainstJwtAth validates the ath claim against the access token per RFC 9449 Section 4.3.
+func checkTokenAgainstJwtAth(accessToken string, claims *claims) error {
+ // ath must match the base64url encoding of the SHA-256 hash of the access token
+ if claims.ATH == "" {
+ return errors.New("missing ath claim, required when access token is used")
+ }
+ expectedATH := CalcATH(accessToken)
+ if claims.ATH != expectedATH {
+ return fmt.Errorf("ath claim (%q) does not match access token hash (%q)", claims.ATH, expectedATH)
+ }
+ return nil
+}
+
+// dpopTokenClaims defines the expected structure of a DPoP-bound access token.
+type dpopTokenClaims struct {
+ jwt.RegisteredClaims
+ Cnf struct {
+ Jkt string `json:"jkt"`
+ } `json:"cnf"`
+}
+
+func extractJwtCnfJktClaim(tokenString string) string {
+ var claims dpopTokenClaims
+ // If it fails to parse as a JWT, we assume it's an opaque token where
+ // cnf binding must be validated downstream by an introspection endpoint.
+ if _, _, err := jwt.NewParser().ParseUnverified(tokenString, &claims); err != nil {
+ return ""
+ }
+ return claims.Cnf.Jkt
+}
+
+func (v *dpopValidator) checkJwtReplay(ctx context.Context, jkt string, claims *claims) error {
+ iatTime := time.Unix(claims.IAT, 0)
+ if added, err := v.store.MarkJtiSeen(ctx, jkt, claims.JTI, iatTime.Add(v.timeWindow*2)); err != nil {
+ return fmt.Errorf("failed to check JTI replay status: %v", err)
+ } else if !added {
+ return errors.New("invalid DPoP proof: jti has already been used (replay attack)")
+ }
+ return nil
+}
diff --git a/pkg/dpop/dpop_test.go b/pkg/dpop/dpop_test.go
new file mode 100644
index 00000000..5f4990ee
--- /dev/null
+++ b/pkg/dpop/dpop_test.go
@@ -0,0 +1,487 @@
+package dpop
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/alicebob/miniredis/v2"
+ "github.com/go-jose/go-jose/v3"
+ "github.com/google/uuid"
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
+ sessions_redis "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/redis"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestDpop(t *testing.T) {
+ logger.SetOutput(GinkgoWriter)
+ logger.SetErrOutput(GinkgoWriter)
+
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "DPoP Suite")
+}
+
+var (
+ testPrivateKey *ecdsa.PrivateKey
+ testJWK jose.JSONWebKey
+)
+
+var _ = BeforeSuite(func() {
+ var err error
+ testPrivateKey, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ Expect(err).ToNot(HaveOccurred())
+
+ testJWK = jose.JSONWebKey{
+ Key: &testPrivateKey.PublicKey,
+ Algorithm: string(jose.ES256),
+ Use: "sig",
+ }
+})
+
+// testDpopOpts customize DPoP proof generation for testing
+type testDpopOpts struct {
+ Method string
+ URI string
+ AccessToken string
+
+ InitSignerOpts func(opts *jose.SignerOptions, jwk jose.JSONWebKey)
+ MutateClaims func(*claims)
+ InvalidSig bool
+}
+
+func generateTestDpop(opts testDpopOpts) string {
+ signerOpts := &jose.SignerOptions{}
+ if opts.InitSignerOpts != nil {
+ opts.InitSignerOpts(signerOpts, testJWK)
+ } else {
+ signerOpts.WithType("dpop+jwt")
+ signerOpts.WithHeader("jwk", testJWK)
+ }
+
+ signer, err := jose.NewSigner(
+ jose.SigningKey{Algorithm: jose.ES256, Key: testPrivateKey},
+ signerOpts,
+ )
+ Expect(err).ToNot(HaveOccurred())
+
+ ath := ""
+ if opts.AccessToken != "" {
+ ath = CalcATH(opts.AccessToken)
+ }
+
+ c := claims{
+ JTI: uuid.New().String(),
+ HTM: opts.Method,
+ HTU: opts.URI,
+ IAT: time.Now().Unix(),
+ ATH: ath,
+ }
+
+ if opts.MutateClaims != nil {
+ opts.MutateClaims(&c)
+ }
+
+ payload, err := json.Marshal(c)
+ Expect(err).ToNot(HaveOccurred())
+
+ jws, err := signer.Sign(payload)
+ Expect(err).ToNot(HaveOccurred())
+
+ serialized, err := jws.CompactSerialize()
+ Expect(err).ToNot(HaveOccurred())
+
+ if opts.InvalidSig {
+ mid := len(serialized) / 2
+ serialized = serialized[:mid] + "X" + serialized[mid+1:]
+ }
+
+ return serialized
+}
+
+var _ = Describe("DPoP", func() {
+ Describe("Factory", func() {
+ var opts *options.Options
+
+ BeforeEach(func() {
+ opts = options.NewOptions()
+ opts.DPoP.Enable = true
+ })
+
+ It("should return nil if DPoP is disabled", func() {
+ opts.DPoP.Enable = false
+ store, err := NewDpopStore(opts)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store).To(BeNil())
+ })
+
+ It("should return a memory store if specifically configured", func() {
+ opts.DPoP.JtiStoreType = "memory"
+ store, err := NewDpopStore(opts)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store).To(BeAssignableToTypeOf(&MemoryDpopStore{}))
+ })
+
+ It("should return a memory store by default if session type is not redis", func() {
+ opts.Session.Type = "cookie"
+ opts.DPoP.JtiStoreType = ""
+ store, err := NewDpopStore(opts)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store).To(BeAssignableToTypeOf(&MemoryDpopStore{}))
+ })
+
+ It("should return a redis store with session config if session type is redis and no type is specified", func() {
+ opts.Session.Type = "redis"
+ opts.Session.Redis.ConnectionURL = "redis://host:1234"
+ opts.DPoP.JtiStoreType = ""
+ store, err := NewDpopStore(opts)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store).To(BeAssignableToTypeOf(&RedisDpopStore{}))
+ })
+
+ It("should return a redis store with session config if specifically configured as session-redis", func() {
+ opts.Session.Type = "cookie"
+ opts.Session.Redis.ConnectionURL = "redis://host:1234"
+ opts.DPoP.JtiStoreType = "session-redis"
+ store, err := NewDpopStore(opts)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(store).To(BeAssignableToTypeOf(&RedisDpopStore{}))
+ })
+
+ It("should return an error for an unknown store type", func() {
+ opts.DPoP.JtiStoreType = "unknown"
+ store, err := NewDpopStore(opts)
+ Expect(err).To(HaveOccurred())
+ Expect(store).To(BeNil())
+ })
+ })
+
+ Describe("Store", func() {
+ Context("Memory Store", func() {
+ var store *MemoryDpopStore
+
+ BeforeEach(func() {
+ store = NewMemoryDpopStore()
+ })
+
+ RunStoreTests(func() DpopStore {
+ return NewMemoryDpopStore()
+ }, func(d time.Duration) {
+ time.Sleep(d)
+ })
+
+ It("should perform CleanUp", func() {
+ ctx := context.Background()
+ _, _ = store.MarkJtiSeen(ctx, "jkt", "jti-short", time.Now().Add(5*time.Millisecond))
+ _, _ = store.MarkJtiSeen(ctx, "jkt", "jti-long", time.Now().Add(1*time.Minute))
+
+ Expect(store.entries).To(HaveLen(2))
+ time.Sleep(10 * time.Millisecond)
+ store.CleanUp()
+
+ Expect(store.entries).To(HaveLen(1))
+ Expect(store.entries).To(HaveKey("jkt:jti-long"))
+ })
+
+ It("should perform preemptive CleanUp", func() {
+ ctx := context.Background()
+ _, _ = store.MarkJtiSeen(ctx, "jkt", "jti-expire", time.Now().Add(10*time.Millisecond))
+ Expect(store.dirty).To(BeTrue())
+ Expect(store.entries).To(HaveLen(1))
+
+ time.Sleep(20 * time.Millisecond)
+ store.lastCleanup = time.Now().Add(-2 * time.Minute)
+
+ _, _ = store.MarkJtiSeen(ctx, "jkt", "jti-new", time.Now().Add(1*time.Minute))
+
+ Expect(store.entries).To(HaveLen(1))
+ Expect(store.entries).To(HaveKey("jkt:jti-new"))
+ Expect(store.entries).ToNot(HaveKey("jkt:jti-expire"))
+
+ Expect(store.dirty).To(BeTrue())
+ Expect(store.lastCleanup).To(BeTemporally("~", time.Now(), time.Second))
+ })
+ })
+
+ Context("Redis Store", func() {
+ var mr *miniredis.Miniredis
+
+ BeforeEach(func() {
+ var err error
+ mr, err = miniredis.Run()
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ AfterEach(func() {
+ if mr != nil {
+ mr.Close()
+ }
+ })
+
+ getStore := func() DpopStore {
+ client, err := sessions_redis.NewRedisClient(options.RedisStoreOptions{
+ ConnectionURL: "redis://" + mr.Addr(),
+ })
+ Expect(err).ToNot(HaveOccurred())
+ return NewRedisDpopStore(client)
+ }
+
+ RunStoreTests(getStore, func(d time.Duration) {
+ mr.FastForward(d)
+ })
+
+ It("should handle expiration correctly in Redis", func() {
+ store := getStore()
+ ctx := context.Background()
+
+ added, err := store.MarkJtiSeen(ctx, "jkt", "jti-expire", time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeTrue())
+
+ added, err = store.MarkJtiSeen(ctx, "jkt", "jti-expire", time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeFalse())
+
+ mr.FastForward(2 * time.Minute)
+ time.Sleep(10 * time.Millisecond)
+
+ added, err = store.MarkJtiSeen(ctx, "jkt", "jti-expire", time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeTrue())
+ })
+
+ It("should allow same JTI for different JKTs", func() {
+ store := getStore()
+ ctx := context.Background()
+ jti := "common-jti"
+
+ added, err := store.MarkJtiSeen(ctx, "jkt-1", jti, time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeTrue())
+
+ added, err = store.MarkJtiSeen(ctx, "jkt-2", jti, time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeTrue())
+
+ added, err = store.MarkJtiSeen(ctx, "jkt-1", jti, time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(added).To(BeFalse())
+ })
+ })
+ })
+
+ Describe("Validator", func() {
+ validMethod := "POST"
+ validURI := "https://server.example.com/resource"
+ validToken := "my-access-token"
+
+ DescribeTable("Validate proof structured tests",
+ func(reqSetup func() *http.Request, accessToken string, expectErrMsgContains string) {
+ req := reqSetup()
+ validator := NewDpopValidator(options.DefaultDpopTimeWindow, nil)
+ thumbprint, err := validator.ValidateDPopToken(req, accessToken)
+
+ if expectErrMsgContains != "" {
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring(expectErrMsgContains))
+ Expect(thumbprint).To(BeEmpty())
+ } else {
+ Expect(err).ToNot(HaveOccurred())
+ Expect(thumbprint).ToNot(BeEmpty())
+ }
+ },
+ Entry("Valid Proof with Access Token", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{Method: validMethod, URI: validURI, AccessToken: validToken})
+ req.Header.Add("DPoP", proof)
+ return req
+ }, validToken, ""),
+ Entry("Valid Proof without Access Token", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{Method: validMethod, URI: validURI})
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", ""),
+ Entry("Missing DPoP Header", func() *http.Request {
+ return httptest.NewRequest(validMethod, validURI, nil)
+ }, "", "missing DPoP header"),
+ Entry("Invalid JWT Structure", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ req.Header.Add("DPoP", "not-a-jwt")
+ return req
+ }, "", "failed to parse DPoP JWS"),
+ Entry("Invalid Signature", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{Method: validMethod, URI: validURI, InvalidSig: true})
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "invalid or missing typ header claim"),
+ Entry("Missing jwk header", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ InitSignerOpts: func(so *jose.SignerOptions, jwk jose.JSONWebKey) {
+ so.WithType("dpop+jwt")
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "missing jwk header claim"),
+ Entry("jwk with invalid key type", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ InitSignerOpts: func(so *jose.SignerOptions, jwk jose.JSONWebKey) {
+ so.WithType("dpop+jwt")
+ so.WithHeader("jwk", jose.JSONWebKey{Key: []byte("not-a-key"), Use: "sig"})
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "failed to parse DPoP JWS"),
+ Entry("Invalid HTM claim", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ MutateClaims: func(c *claims) {
+ c.HTM = "GET"
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "htm claim"),
+ Entry("Invalid HTU claim", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ MutateClaims: func(c *claims) {
+ c.HTU = "https://wrong.com"
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "htu claim"),
+ Entry("Invalid ATH claim", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ AccessToken: "wrong-token",
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, validToken, "ath claim"),
+ Entry("Missing ATH claim when token present", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{Method: validMethod, URI: validURI})
+ req.Header.Add("DPoP", proof)
+ return req
+ }, validToken, "missing ath claim"),
+ Entry("Expired Proof (iat too old)", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ MutateClaims: func(c *claims) {
+ c.IAT = time.Now().Add(-10 * time.Minute).Unix()
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "invalid iat claim"),
+ Entry("Future Proof (iat in future)", func() *http.Request {
+ req := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{
+ Method: validMethod,
+ URI: validURI,
+ MutateClaims: func(c *claims) {
+ c.IAT = time.Now().Add(10 * time.Minute).Unix()
+ },
+ })
+ req.Header.Add("DPoP", proof)
+ return req
+ }, "", "invalid iat claim"),
+ )
+
+ It("rejection of replayed jti", func() {
+ store := NewMemoryDpopStore()
+ validator := NewDpopValidator(options.DefaultDpopTimeWindow, store)
+
+ token := "token"
+ req1 := httptest.NewRequest(validMethod, validURI, nil)
+ proof := generateTestDpop(testDpopOpts{Method: validMethod, URI: validURI, AccessToken: token})
+ req1.Header.Add("DPoP", proof)
+
+ _, err := validator.ValidateDPopToken(req1, token)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Replay same request
+ req2 := httptest.NewRequest(validMethod, validURI, nil)
+ req2.Header.Add("DPoP", proof)
+
+ _, err = validator.ValidateDPopToken(req2, token)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("jti has already been used (replay attack)"))
+ })
+ })
+})
+
+func RunStoreTests(getStore func() DpopStore, advance func(time.Duration)) {
+ var store DpopStore
+ var ctx context.Context
+
+ BeforeEach(func() {
+ store = getStore()
+ ctx = context.Background()
+ })
+
+ It("returns true for first time seeing a JTI", func() {
+ seen, err := store.MarkJtiSeen(ctx, "jkt", "test-jti-1", time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(seen).To(BeTrue())
+ })
+
+ It("returns false for second time seeing a JTI", func() {
+ jti := "test-jti-2"
+ _, _ = store.MarkJtiSeen(ctx, "jkt", jti, time.Now().Add(1*time.Minute))
+ seen, err := store.MarkJtiSeen(ctx, "jkt", jti, time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(seen).To(BeFalse())
+ })
+
+ It("returns true after JTI has expired", func() {
+ jti := "test-jti-expire"
+ _, err := store.MarkJtiSeen(ctx, "jkt", jti, time.Now().Add(50*time.Millisecond))
+ Expect(err).ToNot(HaveOccurred())
+
+ if advance != nil {
+ advance(100 * time.Millisecond)
+ } else {
+ time.Sleep(100 * time.Millisecond)
+ }
+
+ seen, err := store.MarkJtiSeen(ctx, "jkt", jti, time.Now().Add(1*time.Minute))
+ Expect(err).ToNot(HaveOccurred())
+ Expect(seen).To(BeTrue())
+ })
+
+ It("handles multiple distinct JTIs", func() {
+ seen1, err1 := store.MarkJtiSeen(ctx, "jkt", "jti-a", time.Now().Add(1*time.Minute))
+ seen2, err2 := store.MarkJtiSeen(ctx, "jkt", "jti-b", time.Now().Add(1*time.Minute))
+ Expect(err1).ToNot(HaveOccurred())
+ Expect(err2).ToNot(HaveOccurred())
+ Expect(seen1).To(BeTrue())
+ Expect(seen2).To(BeTrue())
+ })
+}
diff --git a/pkg/dpop/factory.go b/pkg/dpop/factory.go
new file mode 100644
index 00000000..b7c2d107
--- /dev/null
+++ b/pkg/dpop/factory.go
@@ -0,0 +1,43 @@
+package dpop
+
+import (
+ "fmt"
+
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
+ sessions_redis "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/sessions/redis"
+)
+
+// NewDpopStore creates a new DpopStore based on the provided options.
+func NewDpopStore(opts *options.Options) (DpopStore, error) {
+ if !opts.DPoP.Enable {
+ return nil, nil
+ }
+
+ storeType := opts.DPoP.JtiStoreType
+ if storeType == "" {
+ if opts.Session.Type == "redis" {
+ storeType = "session-redis"
+ } else {
+ storeType = "memory"
+ }
+ }
+
+ switch storeType {
+ case "memory":
+ return NewMemoryDpopStore(), nil
+ case "redis":
+ client, err := sessions_redis.NewRedisClient(opts.DPoP.Redis.ToRedisStoreOptions())
+ if err != nil {
+ return nil, fmt.Errorf("error constructing redis client for DPoP: %v", err)
+ }
+ return NewRedisDpopStore(client), nil
+ case "session-redis":
+ client, err := sessions_redis.NewRedisClient(opts.Session.Redis)
+ if err != nil {
+ return nil, fmt.Errorf("error constructing session redis client for DPoP: %v", err)
+ }
+ return NewRedisDpopStore(client), nil
+ default:
+ return nil, fmt.Errorf("unknown DPoP JTI store type: %s", storeType)
+ }
+}
diff --git a/pkg/dpop/memory_store.go b/pkg/dpop/memory_store.go
new file mode 100644
index 00000000..58230b1d
--- /dev/null
+++ b/pkg/dpop/memory_store.go
@@ -0,0 +1,77 @@
+package dpop
+
+import (
+ "context"
+ "sync"
+ "time"
+)
+
+type memoryStoreEntry struct {
+ expiresAt time.Time
+}
+
+// MemoryDpopStore is an in-memory implementation of the DpopStore interface.
+// It is intended for testing and single-instance deployments.
+type MemoryDpopStore struct {
+ mu sync.Mutex
+ entries map[string]memoryStoreEntry
+ lastCleanup time.Time
+ dirty bool
+}
+
+// NewMemoryDpopStore creates a new in-memory DpopStore.
+func NewMemoryDpopStore() *MemoryDpopStore {
+ return &MemoryDpopStore{
+ entries: make(map[string]memoryStoreEntry),
+ lastCleanup: time.Now(),
+ }
+}
+
+// MarkJtiSeen checks if a JTI scoped by JKT has been seen. If not, it stores it with the
+// specified absolute expiration time. Returns true if it was newly added.
+func (c *MemoryDpopStore) MarkJtiSeen(ctx context.Context, jkt string, jti string, expiresAt time.Time) (bool, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ key := jkt + ":" + jti
+ now := time.Now()
+
+ // Preemptive cleanup
+ if c.dirty && now.Sub(c.lastCleanup) > 1*time.Minute {
+ c.cleanUpLocked(now)
+ }
+
+ if entry, exists := c.entries[key]; exists {
+ if now.Before(entry.expiresAt) {
+ return false, nil // Already seen and not expired
+ }
+ // It exists but has expired. We can overwrite it and treat it as unseen.
+ }
+
+ // Not seen or expired, add it
+ c.entries[key] = memoryStoreEntry{
+ expiresAt: expiresAt,
+ }
+ c.dirty = true
+
+ return true, nil
+}
+
+// CleanUp removes expired entries from the store.
+// Call this periodically if running for long periods.
+func (c *MemoryDpopStore) CleanUp() {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ c.cleanUpLocked(time.Now())
+}
+
+func (c *MemoryDpopStore) cleanUpLocked(now time.Time) {
+ for k, v := range c.entries {
+ if now.After(v.expiresAt) {
+ delete(c.entries, k)
+ }
+ }
+ c.lastCleanup = now
+ c.dirty = false
+}
diff --git a/pkg/dpop/redis_store.go b/pkg/dpop/redis_store.go
new file mode 100644
index 00000000..e4799596
--- /dev/null
+++ b/pkg/dpop/redis_store.go
@@ -0,0 +1,52 @@
+package dpop
+
+import (
+ "context"
+ "time"
+)
+
+// RedisClient defines the subset of redis commands used by RedisDpopStore.
+// This allows for easier mocked testing or using different redis implementations.
+type RedisClient interface {
+ SetNX(ctx context.Context, key string, value []byte, expiration time.Duration) (bool, error)
+}
+
+// RedisDpopStore is a Redis-backed implementation of the DpopStore interface.
+// It is intended for scalable, multi-instance deployments.
+type RedisDpopStore struct {
+ client RedisClient
+ jtiPrefix string
+}
+
+// NewRedisDpopStore creates a new Redis DpopStore.
+func NewRedisDpopStore(client RedisClient) *RedisDpopStore {
+ return &RedisDpopStore{
+ client: client,
+ jtiPrefix: "dpop:jti:",
+ }
+}
+
+// MarkJtiSeen checks if a JTI scoped by JKT has been seen by checking if it exists in Redis.
+// It relies on Redis's SetNX (Set if Not eXists) command to atomically check and set.
+// It returns true if the JTI was successfully inserted (it was not seen before).
+// It returns false if the JTI was already present (it has been seen).
+func (c *RedisDpopStore) MarkJtiSeen(ctx context.Context, jkt string, jti string, expiresAt time.Time) (bool, error) {
+ key := c.jtiPrefix + jkt + ":" + jti
+ ttl := time.Until(expiresAt)
+
+ // If the expiration time is already in the past, we shouldn't even store it,
+ // or we can store it with a minimal TTL. However, the validator should
+ // have already rejected it if iat was too old.
+ if ttl <= 0 {
+ return false, nil // Already expired
+ }
+
+ // SetNX will return true if the key didn't exist and was set.
+ // It will return false if the key already existed.
+ res, err := c.client.SetNX(ctx, key, []byte("1"), ttl)
+ if err != nil {
+ return false, err
+ }
+
+ return res, nil
+}
diff --git a/pkg/dpop/store.go b/pkg/dpop/store.go
new file mode 100644
index 00000000..04e2bfa5
--- /dev/null
+++ b/pkg/dpop/store.go
@@ -0,0 +1,16 @@
+package dpop
+
+import (
+ "context"
+ "time"
+)
+
+// DpopStore defines the interface for storing DPoP JTI (JWT ID) claims
+// to prevent replay attacks.
+type DpopStore interface {
+ // MarkJtiSeen attempts to store a JTI in the store.
+ // It returns true if the JTI was successfully inserted (i.e., it was not seen before).
+ // It returns false if the JTI was already present in the store (it has been seen).
+ // Returns an error if the underlying storage encounters an issue.
+ MarkJtiSeen(ctx context.Context, jkt string, jti string, expiresAt time.Time) (bool, error)
+}
diff --git a/pkg/middleware/jwt_session.go b/pkg/middleware/jwt_session.go
index 790eb8b2..26bb223e 100644
--- a/pkg/middleware/jwt_session.go
+++ b/pkg/middleware/jwt_session.go
@@ -9,17 +9,19 @@ import (
"github.com/justinas/alice"
middlewareapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/middleware"
sessionsapi "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions"
+ "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/dpop"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger"
k8serrors "k8s.io/apimachinery/pkg/util/errors"
)
const jwtRegexFormat = `^ey[a-zA-Z0-9_-]*\.ey[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]+$`
-func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc, bearerTokenLoginFallback bool) alice.Constructor {
+func NewJwtSessionLoader(sessionLoaders []middlewareapi.TokenToSessionFunc, bearerTokenLoginFallback bool, dpopValidator dpop.Validator) alice.Constructor {
js := &jwtSessionLoader{
jwtRegex: regexp.MustCompile(jwtRegexFormat),
sessionLoaders: sessionLoaders,
denyInvalidJWTs: !bearerTokenLoginFallback,
+ dpopValidator: dpopValidator,
}
return js.loadSession
}
@@ -30,6 +32,7 @@ type jwtSessionLoader struct {
jwtRegex *regexp.Regexp
sessionLoaders []middlewareapi.TokenToSessionFunc
denyInvalidJWTs bool
+ dpopValidator dpop.Validator
}
// loadSession attempts to load a session from a JWT stored in an Authorization
@@ -73,11 +76,29 @@ func (j *jwtSessionLoader) getJwtSession(req *http.Request) (*sessionsapi.Sessio
return nil, nil
}
+<<<<<<< Updated upstream
token, err := j.findTokenFromHeader(auth)
+=======
+ tokenType, token, err := j.findTokenFromHeader(auth)
+>>>>>>> Stashed changes
if err != nil {
return nil, err
}
+<<<<<<< Updated upstream
+ if err := j.handleDpop(req, auth, token); err != nil {
+ return nil, err
+=======
+ if tokenType == "DPoP" {
+ if j.dpopValidator == nil {
+ return nil, errors.New("DPoP support is not enabled")
+ }
+ if _, err := j.dpopValidator.ValidateDPopToken(req, token); err != nil {
+ return nil, fmt.Errorf("invalid DPoP proof: %v", err)
+ }
+>>>>>>> Stashed changes
+ }
+
// This leading error message only occurs if all session loaders fail
errs := []error{errors.New("unable to verify bearer token")}
for _, loader := range j.sessionLoaders {
@@ -92,24 +113,62 @@ func (j *jwtSessionLoader) getJwtSession(req *http.Request) (*sessionsapi.Sessio
return nil, k8serrors.NewAggregate(errs)
}
+<<<<<<< Updated upstream
+func (j *jwtSessionLoader) handleDpop(req *http.Request, auth, token string) error {
+ tokenType, _, err := splitAuthHeader(auth)
+ if err == nil && tokenType == "DPoP" {
+ if j.dpopValidator == nil {
+ logger.Errorf("DPoP validation failed: DPoP support is not enabled")
+ return errors.New("DPoP support is not enabled")
+ }
+ if _, dpopErr := j.dpopValidator.Validate(req, token); dpopErr != nil {
+ logger.Errorf("DPoP validation failed: %v", dpopErr)
+ return fmt.Errorf("invalid DPoP proof: %v", dpopErr)
+ }
+ }
+ return nil
+}
+
// findTokenFromHeader finds a valid JWT token from the Authorization header of a given request.
func (j *jwtSessionLoader) findTokenFromHeader(header string) (string, error) {
tokenType, token, err := splitAuthHeader(header)
if err != nil {
return "", err
+=======
+// findTokenFromHeader finds a valid JWT token from the Authorization header of a given request.
+func (j *jwtSessionLoader) findTokenFromHeader(header string) (string, string, error) {
+ tokenType, token, err := splitAuthHeader(header)
+ if err != nil {
+ return "", "", err
+>>>>>>> Stashed changes
}
- if tokenType == "Bearer" && j.jwtRegex.MatchString(token) {
- // Found a JWT as a bearer token
+ if (tokenType == "Bearer" || tokenType == "DPoP") && j.jwtRegex.MatchString(token) {
+ // Found a JWT as a bearer or dpop token
+<<<<<<< Updated upstream
return token, nil
+=======
+ return tokenType, token, nil
+>>>>>>> Stashed changes
}
if tokenType == "Basic" {
// Check if we have a Bearer token masquerading in Basic
+<<<<<<< Updated upstream
return j.getBasicToken(token)
}
return "", fmt.Errorf("no valid bearer token found in authorization header")
+=======
+ t, err := j.getBasicToken(token)
+ if err != nil {
+ return "", "", err
+ }
+ return "Bearer", t, nil
+ }
+
+ return "", "", fmt.Errorf("no valid bearer or DPoP token found in authorization header")
+>>>>>>> Stashed changes
}
// getBasicToken tries to extract a token from the basic value provided.
diff --git a/pkg/middleware/jwt_session_test.go b/pkg/middleware/jwt_session_test.go
index 7b724280..751e3baf 100644
--- a/pkg/middleware/jwt_session_test.go
+++ b/pkg/middleware/jwt_session_test.go
@@ -115,7 +115,7 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
// Create the handler with a next handler that will capture the session
// from the scope
var gotSession *sessionsapi.SessionState
- handler := NewJwtSessionLoader(sessionLoaders, true)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := NewJwtSessionLoader(sessionLoaders, true, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSession = middlewareapi.GetRequestScope(r).Session
}))
handler.ServeHTTP(rw, req)
@@ -185,7 +185,7 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
// Create the handler with a next handler that will capture the session
// from the scope
var gotSession *sessionsapi.SessionState
- handler := NewJwtSessionLoader(sessionLoaders, false)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ handler := NewJwtSessionLoader(sessionLoaders, false, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotSession = middlewareapi.GetRequestScope(r).Session
}))
handler.ServeHTTP(rw, req)
@@ -263,6 +263,7 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
sessionLoaders: []middlewareapi.TokenToSessionFunc{
middlewareapi.CreateTokenToSessionFunc(verifier),
},
+ dpopValidator: nil,
}
})
@@ -334,84 +335,153 @@ Nnc3a3lGVWFCNUMxQnNJcnJMTWxka1dFaHluYmI4Ongtb2F1dGgtYmFzaWM=`
BeforeEach(func() {
j = &jwtSessionLoader{
- jwtRegex: regexp.MustCompile(jwtRegexFormat),
+ jwtRegex: regexp.MustCompile(jwtRegexFormat),
+ dpopValidator: nil,
}
})
type findBearerTokenFromHeaderTableInput struct {
header string
expectedErr error
+<<<<<<< Updated upstream
+=======
+ expectedType string
+>>>>>>> Stashed changes
expectedToken string
}
DescribeTable("with a header",
func(in findBearerTokenFromHeaderTableInput) {
+<<<<<<< Updated upstream
token, err := j.findTokenFromHeader(in.header)
+=======
+ tokenType, token, err := j.findTokenFromHeader(in.header)
+>>>>>>> Stashed changes
if in.expectedErr != nil {
Expect(err).To(MatchError(in.expectedErr))
} else {
Expect(err).ToNot(HaveOccurred())
}
+<<<<<<< Updated upstream
+=======
+ Expect(tokenType).To(Equal(in.expectedType))
+>>>>>>> Stashed changes
Expect(token).To(Equal(in.expectedToken))
},
Entry("Bearer", findBearerTokenFromHeaderTableInput{
header: "Bearer",
expectedErr: errors.New("invalid authorization header: \"Bearer\""),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Bearer abc def", findBearerTokenFromHeaderTableInput{
header: "Bearer abc def",
expectedErr: errors.New("invalid authorization header: \"Bearer abc def\""),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Bearer abcdef", findBearerTokenFromHeaderTableInput{
header: "Bearer abcdef",
expectedErr: errors.New("no valid bearer token found in authorization header"),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Bearer ", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("Bearer %s", validToken),
expectedErr: nil,
+<<<<<<< Updated upstream
+=======
+ expectedType: "Bearer",
+ expectedToken: validToken,
+ }),
+ Entry("DPoP ", findBearerTokenFromHeaderTableInput{
+ header: fmt.Sprintf("DPoP %s", validToken),
+ expectedErr: nil,
+ expectedType: "DPoP",
+>>>>>>> Stashed changes
expectedToken: validToken,
}),
Entry("Bearer ", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("Bearer %s", validTokenWithSpace),
expectedErr: nil,
+<<<<<<< Updated upstream
+=======
+ expectedType: "Bearer",
+>>>>>>> Stashed changes
expectedToken: validTokenWithSpace,
}),
Entry("Basic invalid-base64", findBearerTokenFromHeaderTableInput{
header: "Basic invalid-base64",
expectedErr: errors.New("invalid basic auth token: illegal base64 data at input byte 7"),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Basic Base64(:) (No password)", findBearerTokenFromHeaderTableInput{
header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
expectedErr: nil,
+<<<<<<< Updated upstream
expectedToken: validToken,
}),
Entry("Basic Base64(:x-oauth-basic) (Sentinel password)", findBearerTokenFromHeaderTableInput{
header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
expectedErr: nil,
+=======
+ expectedType: "Bearer",
+ expectedToken: validToken,
+ }),
+ Entry("Basic Base64(:x-oauth-basic) (Sentinel password)", findBearerTokenFromHeaderTableInput{
+ header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
+ expectedErr: nil,
+ expectedType: "Bearer",
+>>>>>>> Stashed changes
expectedToken: validToken,
}),
Entry("Basic Base64(any-user:) (Matching password)", findBearerTokenFromHeaderTableInput{
header: "Basic YW55LXVzZXI6ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY=",
expectedErr: nil,
+<<<<<<< Updated upstream
+=======
+ expectedType: "Bearer",
+>>>>>>> Stashed changes
expectedToken: validToken,
}),
Entry("Basic Base64(any-user:any-password) (No matches)", findBearerTokenFromHeaderTableInput{
header: "Basic YW55LXVzZXI6YW55LXBhc3N3b3Jk",
expectedErr: errors.New("invalid basic auth token found in authorization header"),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Basic Base64(any-user any-password) (Invalid format)", findBearerTokenFromHeaderTableInput{
header: "Basic YW55LXVzZXIgYW55LXBhc3N3b3Jk",
expectedErr: errors.New("invalid format: \"any-user any-password\""),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
Entry("Something ", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("Something %s", validToken),
expectedErr: errors.New("no valid bearer token found in authorization header"),
+<<<<<<< Updated upstream
+=======
+ expectedType: "",
+>>>>>>> Stashed changes
expectedToken: "",
}),
)
diff --git a/pkg/sessions/redis/client.go b/pkg/sessions/redis/client.go
index 00cff17c..6374f945 100644
--- a/pkg/sessions/redis/client.go
+++ b/pkg/sessions/redis/client.go
@@ -13,6 +13,7 @@ type Client interface {
Get(ctx context.Context, key string) ([]byte, error)
Lock(key string) sessions.Lock
Set(ctx context.Context, key string, value []byte, expiration time.Duration) error
+ SetNX(ctx context.Context, key string, value []byte, expiration time.Duration) (bool, error)
Del(ctx context.Context, key string) error
Ping(ctx context.Context) error
}
@@ -37,6 +38,10 @@ func (c *client) Set(ctx context.Context, key string, value []byte, expiration t
return c.Client.Set(ctx, key, value, expiration).Err()
}
+func (c *client) SetNX(ctx context.Context, key string, value []byte, expiration time.Duration) (bool, error) {
+ return c.Client.SetNX(ctx, key, value, expiration).Result()
+}
+
func (c *client) Del(ctx context.Context, key string) error {
return c.Client.Del(ctx, key).Err()
}
@@ -69,6 +74,10 @@ func (c *clusterClient) Set(ctx context.Context, key string, value []byte, expir
return c.ClusterClient.Set(ctx, key, value, expiration).Err()
}
+func (c *clusterClient) SetNX(ctx context.Context, key string, value []byte, expiration time.Duration) (bool, error) {
+ return c.ClusterClient.SetNX(ctx, key, value, expiration).Result()
+}
+
func (c *clusterClient) Del(ctx context.Context, key string) error {
return c.ClusterClient.Del(ctx, key).Err()
}