From 96800cf4d7dff2f8c7c5ae9b9350df46c41031fb Mon Sep 17 00:00:00 2001 From: Jakub Skopal Date: Fri, 6 Mar 2026 15:36:52 +0100 Subject: [PATCH] feature: DPoP validation Signed-off-by: Jakub Skopal --- CHANGELOG.md | 3 + docs/docs/configuration/overview.md | 24 +- docs/docs/features/dpop.md | 48 +++ oauthproxy.go | 16 +- pkg/apis/options/dpop.go | 58 ++++ pkg/apis/options/load_test.go | 1 + pkg/apis/options/options.go | 19 ++ pkg/dpop/dpop.go | 251 ++++++++++++++ pkg/dpop/dpop_test.go | 487 ++++++++++++++++++++++++++++ pkg/dpop/factory.go | 43 +++ pkg/dpop/memory_store.go | 77 +++++ pkg/dpop/redis_store.go | 52 +++ pkg/dpop/store.go | 16 + pkg/middleware/jwt_session.go | 65 +++- pkg/middleware/jwt_session_test.go | 76 ++++- pkg/sessions/redis/client.go | 9 + 16 files changed, 1234 insertions(+), 11 deletions(-) create mode 100644 docs/docs/features/dpop.md create mode 100644 pkg/apis/options/dpop.go create mode 100644 pkg/dpop/dpop.go create mode 100644 pkg/dpop/dpop_test.go create mode 100644 pkg/dpop/factory.go create mode 100644 pkg/dpop/memory_store.go create mode 100644 pkg/dpop/redis_store.go create mode 100644 pkg/dpop/store.go 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() }