Merge 96800cf4d7 into 88075737a6
This commit is contained in:
commit
e38f5c05dc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 [^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 |
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ var _ = Describe("Load", func() {
|
|||
Templates: templatesDefaults(),
|
||||
SkipAuthPreflight: false,
|
||||
Logging: loggingDefaults(),
|
||||
DPoP: dpopDefaults(),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: "",
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue