This commit is contained in:
jakubskopal 2026-03-06 15:36:58 +01:00 committed by GitHub
commit e38f5c05dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1234 additions and 11 deletions

View File

@ -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

View File

@ -83,7 +83,7 @@ Provider specific options can be found on their respective subpages.
| flag: `--approval-prompt`<br/>toml: `approval_prompt` | string | OAuth approval_prompt | `"force"` |
| flag: `--backend-logout-url`<br/>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`<br/>toml: `client_id` | string | the OAuth Client ID, e.g. `"123456.apps.googleusercontent.com"` | |
| flag: `--client-secret-file`<br/>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`<br/>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`<br/>toml: `client_secret` | string | the OAuth Client Secret | |
| flag: `--code-challenge-method`<br/>toml: `code_challenge_method` | string | use PKCE code challenges with the specified method. Either 'plain' or 'S256' (recommended) | |
| flag: `--insecure-oidc-allow-unverified-email`<br/>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`<br/>toml: `cookie_refresh` | duration | refresh the cookie after this duration; `0` to disable; not supported by all providers&nbsp;[^1] | |
| flag: `--cookie-samesite`<br/>toml: `cookie_samesite` | string | set SameSite cookie attribute (`"lax"`, `"strict"`, `"none"`, or `""`). | `""` |
| flag: `--cookie-secret`<br/>toml: `cookie_secret` | string | the seed string for secure cookies (optionally base64 encoded) | |
| flag: `--cookie-secret-file`<br/>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`<br/>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`<br/>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`<br/>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`<br/>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`<br/>toml: `enable_dpop_support` | bool | enable DPoP support for verifying DPoP structured tokens | false |
| flag: `--dpop-time-window`<br/>toml: `dpop_time_window` | duration | the acceptable time window for DPoP proof's `iat` claim | 30s |
| flag: `--dpop-jti-store-type`<br/>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`<br/>toml: `dpop_redis_connection_url` | string | URL of redis server for DPoP JTI storage (e.g. `redis://HOST[:PORT]`) | |
| flag: `--dpop-redis-username`<br/>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`<br/>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`<br/>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`<br/>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`<br/>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`<br/>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`<br/>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`<br/>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`<br/>toml: `dpop_redis_ca_path` | string | Path to CA certificate for Redis connection (DPoP storage) | |
| flag: `--dpop-redis-insecure-skip-tls-verify`<br/>toml: `dpop_redis_insecure_skip_tls_verify` | bool | skip TLS verification when connecting to Redis (DPoP storage) | false |
| flag: `--dpop-redis-connection-idle-timeout`<br/>toml: `dpop_redis_connection_idle_timeout` | int | Redis connection idle timeout seconds for DPoP storage. | 0 |
### Upstream Options
| Flag / Config Field | Type | Description | Default |

View File

@ -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)

View File

@ -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 {

58
pkg/apis/options/dpop.go Normal file
View File

@ -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: "",
}
}

View File

@ -58,6 +58,7 @@ var _ = Describe("Load", func() {
Templates: templatesDefaults(),
SkipAuthPreflight: false,
Logging: loggingDefaults(),
DPoP: dpopDefaults(),
},
}

View File

@ -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")

251
pkg/dpop/dpop.go Normal file
View File

@ -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
}

487
pkg/dpop/dpop_test.go Normal file
View File

@ -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())
})
}

43
pkg/dpop/factory.go Normal file
View File

@ -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)
}
}

77
pkg/dpop/memory_store.go Normal file
View File

@ -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
}

52
pkg/dpop/redis_store.go Normal file
View File

@ -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
}

16
pkg/dpop/store.go Normal file
View File

@ -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)
}

View File

@ -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.

View File

@ -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 <valid-token>", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("Bearer %s", validToken),
expectedErr: nil,
<<<<<<< Updated upstream
=======
expectedType: "Bearer",
expectedToken: validToken,
}),
Entry("DPoP <valid-token>", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("DPoP %s", validToken),
expectedErr: nil,
expectedType: "DPoP",
>>>>>>> Stashed changes
expectedToken: validToken,
}),
Entry("Bearer <valid-token-with-whitespace>", 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(<validToken>:) (No password)", findBearerTokenFromHeaderTableInput{
header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6",
expectedErr: nil,
<<<<<<< Updated upstream
expectedToken: validToken,
}),
Entry("Basic Base64(<validToken>:x-oauth-basic) (Sentinel password)", findBearerTokenFromHeaderTableInput{
header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
expectedErr: nil,
=======
expectedType: "Bearer",
expectedToken: validToken,
}),
Entry("Basic Base64(<verifiedToken>:x-oauth-basic) (Sentinel password)", findBearerTokenFromHeaderTableInput{
header: "Basic ZXlKZm9vYmFyLmV5SmZvb2Jhci4xMjM0NWFzZGY6eC1vYXV0aC1iYXNpYw==",
expectedErr: nil,
expectedType: "Bearer",
>>>>>>> Stashed changes
expectedToken: validToken,
}),
Entry("Basic Base64(any-user:<validToken>) (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 <valid-token>", findBearerTokenFromHeaderTableInput{
header: fmt.Sprintf("Something %s", validToken),
expectedErr: errors.New("no valid bearer token found in authorization header"),
<<<<<<< Updated upstream
=======
expectedType: "",
>>>>>>> Stashed changes
expectedToken: "",
}),
)

View File

@ -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()
}