mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-11-03 07:44:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb68df890a |
@@ -17,14 +17,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
never reads a preflight body (`settings.go`, `middleware.go`, FIX-02).
|
||||
See the option table in [CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
|
||||
- **`streamingRefresh` (default `false`).** SSE and WebSocket requests do
|
||||
not check the token expiry, so a session stays usable for streaming until
|
||||
the session cookie expires. Set the option to `true` to refresh an expired
|
||||
token on these requests. If the IdP rejects the refresh with
|
||||
`invalid_grant`, the request gets `401`. If the IdP cannot be reached, the
|
||||
request is forwarded as before. The request waits for the full refresh, so
|
||||
a rotated refresh token is never lost (`middleware.go`).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Opaque bearer tokens when all three bearer flags are set.** A deployment
|
||||
@@ -121,11 +113,6 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
### Fixed
|
||||
|
||||
- SSE and WebSocket requests now get the configured `headers`, as normal
|
||||
requests do. The last release sent only `X-Forwarded-User` on these
|
||||
requests, so a backend that checks a shared-secret header answered `401`
|
||||
on WebSocket upgrades. The same requests now also honor back-channel and
|
||||
front-channel logout (`middleware.go`, #162).
|
||||
- The opaque bearer path rejects a token that `RevokeToken` added to the
|
||||
blacklist, for example at logout. It also rejects an opaque token whose
|
||||
`nbf` is in the future (`bearer_auth.go`).
|
||||
|
||||
@@ -139,7 +139,6 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
| `disableReplayDetection` | `false` | Disable JTI cache. Use Redis instead for multi-replica. |
|
||||
| `allowPrivateIPAddresses` | `false` | Permit private-IP `providerURL` (internal Keycloak, etc.). |
|
||||
| `allowUnauthenticatedPreflight` | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`); the response body is still discarded. Default requires auth for every `OPTIONS` request, preflight or not. |
|
||||
| `streamingRefresh` | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request when the IdP revokes the grant. |
|
||||
| `minimalHeaders` | `false` | Reduce forwarded headers (mitigates HTTP 431). |
|
||||
| `stripAuthCookies` | `false` | Strip OIDC cookies from backend hop (mitigates HTTP 431). |
|
||||
| `caCertPath` / `caCertPEM` | none | Trust an internal CA for the provider's TLS. |
|
||||
@@ -162,14 +161,6 @@ Full reference in [docs/CONFIGURATION.md](docs/CONFIGURATION.md).
|
||||
(previously signed only) and their cryptographic lifetime tracks
|
||||
`sessionMaxAge` (previously a fixed 30 days). Existing cookies become invalid
|
||||
on upgrade, so users re-authenticate one time.
|
||||
- **A rollback to v1.0.25 or earlier also invalidates sessions.** Those
|
||||
releases cannot read the encrypted cookies that v1.0.26 and later issue.
|
||||
They log `securecookie: the value is not valid` and `No state in
|
||||
callback`, and the browser loops through the login. After a rollback, tell
|
||||
users to delete
|
||||
the `_oidc_raczylo_*` cookies (or the cookies with your `cookiePrefix`)
|
||||
for the affected hosts. The cookies are `HttpOnly`, so users must delete
|
||||
them in the browser site-data settings.
|
||||
- **Invalid configuration now fails closed at startup** instead of being
|
||||
silently accepted: a `sessionEncryptionKey` shorter than 32 bytes, a
|
||||
`rateLimit` below 10, a missing `callbackURL`, or a non-HTTPS remote
|
||||
@@ -313,17 +304,9 @@ WebSocket upgrade. The middleware handles this automatically:
|
||||
- They are **not** unauthenticated — a valid encrypted session cookie is
|
||||
required, otherwise the request is rejected. The session must already exist
|
||||
(i.e. the user logged in via a normal HTTP page first).
|
||||
- `X-Forwarded-User` and the configured `headers` are forwarded from the
|
||||
session. Header templates render from the claims in the session cookie.
|
||||
- `X-Forwarded-User` is forwarded from the session.
|
||||
- Validation is cookie-only (no JWK fetch), so streaming keeps working during
|
||||
brief IdP outages. Back-channel and front-channel logout still apply: a
|
||||
session that the IdP logged out is rejected.
|
||||
- By default, the token expiry is not checked, so a session stays usable
|
||||
for streaming until the session cookie expires. Set `streamingRefresh: true`
|
||||
to refresh an expired token on the request. If the IdP rejects the refresh
|
||||
(`invalid_grant`), the request gets `401`. If the IdP cannot be reached,
|
||||
the request is forwarded as before. On a WebSocket upgrade, the refreshed
|
||||
session cookie arrives on the `101 Switching Protocols` response.
|
||||
brief IdP outages.
|
||||
|
||||
No configuration needed — this is implicit behavior.
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -138,8 +137,6 @@ func main() {
|
||||
runCheck("fix06-sse-flush-reaches-next", checkSSEFlushReachesNext)
|
||||
runCheck("middleware-erraborthandler-aborts-under-yaegi", checkErrAbortHandlerAbortsUnderYaegi)
|
||||
runCheck("fix17-setifabsent-claims-once", checkSetIfAbsentUnderYaegi)
|
||||
runCheck("issue162-websocket-forwards-header-templates", checkIssue162WebSocketHeaderTemplates)
|
||||
runCheck("streaming-refresh-revoked-grant-rejected", checkStreamingRefreshRevokedGrant)
|
||||
fmt.Println("OK: all yaegi regression checks passed")
|
||||
}
|
||||
|
||||
@@ -726,168 +723,6 @@ func checkSSEFlushReachesNext() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// checkIssue162WebSocketHeaderTemplates pins #162 under the interpreter: an
|
||||
// authenticated WebSocket upgrade takes the streaming bypass, which must
|
||||
// still render the configured headers (static and claim-derived) for next.
|
||||
func checkIssue162WebSocketHeaderTemplates() (string, error) {
|
||||
discovery := servers.NewOIDCServer(nil)
|
||||
defer discovery.Close()
|
||||
|
||||
cfg := oidc.CreateConfig()
|
||||
cfg.ProviderURL = discovery.URL
|
||||
cfg.ClientID = "yaegi-check-162-client"
|
||||
cfg.ClientSecret = "yaegi-check-162-secret"
|
||||
cfg.CallbackURL = "/oauth2/callback"
|
||||
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
|
||||
cfg.Headers = []oidc.TemplatedHeader{
|
||||
{Name: "X-Proxy-Secret", Value: "s3cret"},
|
||||
{Name: "X-Forwarded-Preferred-Username", Value: "{{.Claims.preferred_username}}"},
|
||||
}
|
||||
|
||||
var seen http.Header
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Clone()
|
||||
})
|
||||
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-162")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("New: %w", err)
|
||||
}
|
||||
if closer, ok := h.(interface{ Close() error }); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("NewSessionManager: %w", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
setupReq := httptest.NewRequest("GET", "/ws", nil)
|
||||
session, err := sm.GetSession(setupReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GetSession: %w", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
return "", fmt.Errorf("SetAuthenticated: %w", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
// Unsigned JWT: the bypass reads cookie-sealed claims without signature
|
||||
// verification. Built at runtime because a JWT literal trips the
|
||||
// pre-commit secret scanner.
|
||||
b64 := base64.RawURLEncoding.EncodeToString
|
||||
sig := make([]byte, 32)
|
||||
for i := range sig {
|
||||
sig[i] = byte(i + 1)
|
||||
}
|
||||
session.SetIDToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(`{"preferred_username":"alice"}`)) + "." + b64(sig))
|
||||
rec := httptest.NewRecorder()
|
||||
if err := session.Save(setupReq, rec); err != nil {
|
||||
return "", fmt.Errorf("session.Save: %w", err)
|
||||
}
|
||||
session.ReturnToPool()
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
rw := httptest.NewRecorder()
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
if seen == nil {
|
||||
return "", fmt.Errorf("next was not reached (status %d)", rw.Code)
|
||||
}
|
||||
if got := seen.Get("X-Proxy-Secret"); got != "s3cret" {
|
||||
return "", fmt.Errorf("X-Proxy-Secret = %q, want %q", got, "s3cret")
|
||||
}
|
||||
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "alice" {
|
||||
return "", fmt.Errorf("X-Forwarded-Preferred-Username = %q, want %q", got, "alice")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// checkStreamingRefreshRevokedGrant drives streamingRefresh under the
|
||||
// interpreter through the real RefreshCoordinator and token exchanger: an
|
||||
// expired session whose refresh the IdP answers with invalid_grant must get
|
||||
// 401 on a WebSocket upgrade instead of reaching next.
|
||||
func checkStreamingRefreshRevokedGrant() (string, error) {
|
||||
idpCfg := servers.DefaultConfig()
|
||||
idpCfg.RefreshError = &servers.OIDCError{Error: "invalid_grant"}
|
||||
idp := servers.NewOIDCServer(idpCfg)
|
||||
defer idp.Close()
|
||||
|
||||
cfg := oidc.CreateConfig()
|
||||
cfg.ProviderURL = idp.URL
|
||||
cfg.ClientID = "yaegi-check-refresh-client"
|
||||
cfg.ClientSecret = "yaegi-check-refresh-secret"
|
||||
cfg.CallbackURL = "/oauth2/callback"
|
||||
cfg.SessionEncryptionKey = "0123456789abcdef0123456789abcdef"
|
||||
cfg.StreamingRefresh = true
|
||||
|
||||
reached := false
|
||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
reached = true
|
||||
})
|
||||
h, err := oidc.New(context.Background(), next, cfg, "yaegi-check-refresh")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("New: %w", err)
|
||||
}
|
||||
if closer, ok := h.(interface{ Close() error }); ok {
|
||||
defer closer.Close()
|
||||
}
|
||||
// The streaming bypass does not wait for provider metadata, so a normal
|
||||
// request (which does) runs first to make the token endpoint known.
|
||||
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||
|
||||
sm, err := oidc.NewSessionManager(cfg.SessionEncryptionKey, cfg.ForceHTTPS, cfg.CookieDomain, cfg.CookiePrefix, time.Duration(cfg.SessionMaxAge)*time.Second, oidc.NewLogger("error"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("NewSessionManager: %w", err)
|
||||
}
|
||||
defer sm.Shutdown()
|
||||
|
||||
setupReq := httptest.NewRequest("GET", "/ws", nil)
|
||||
session, err := sm.GetSession(setupReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GetSession: %w", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
return "", fmt.Errorf("SetAuthenticated: %w", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
// Built at runtime because a JWT literal trips the pre-commit secret scanner.
|
||||
b64 := base64.RawURLEncoding.EncodeToString
|
||||
sig := make([]byte, 32)
|
||||
for i := range sig {
|
||||
sig[i] = byte(i + 1)
|
||||
}
|
||||
payload := fmt.Sprintf(`{"email":"[email protected]","exp":%d}`, time.Now().Add(-time.Minute).Unix())
|
||||
session.SetAccessToken(b64([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." + b64([]byte(payload)) + "." + b64(sig))
|
||||
session.SetRefreshToken("yaegi-check-refresh-token")
|
||||
rec := httptest.NewRecorder()
|
||||
if err := session.Save(setupReq, rec); err != nil {
|
||||
return "", fmt.Errorf("session.Save: %w", err)
|
||||
}
|
||||
session.ReturnToPool()
|
||||
|
||||
req := httptest.NewRequest("GET", "/ws", nil)
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
rw := httptest.NewRecorder()
|
||||
h.ServeHTTP(rw, req)
|
||||
|
||||
if reached {
|
||||
return "", fmt.Errorf("next reached for a revoked grant (status %d)", rw.Code)
|
||||
}
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
return "", fmt.Errorf("status = %d, want %d", rw.Code, http.StatusUnauthorized)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// checkErrAbortHandlerAbortsUnderYaegi guards the re-review finding on
|
||||
// ServeHTTP's ErrAbortHandler branch (middleware.go): under yaegi v0.16.1,
|
||||
// `r == http.ErrAbortHandler` only ever matches a panic raised by COMPILED
|
||||
|
||||
@@ -76,7 +76,6 @@ func (c Config) configToMap() map[string]interface{} {
|
||||
result["enablePKCE"] = c.EnablePKCE
|
||||
result["allowPrivateIPAddresses"] = c.AllowPrivateIPAddresses
|
||||
result["allowUnauthenticatedPreflight"] = c.AllowUnauthenticatedPreflight
|
||||
result["streamingRefresh"] = c.StreamingRefresh
|
||||
result["minimalHeaders"] = c.MinimalHeaders
|
||||
result["stripAuthCookies"] = c.StripAuthCookies
|
||||
result["cookiePath"] = c.CookiePath
|
||||
|
||||
@@ -313,7 +313,6 @@ strictAudienceValidation: true
|
||||
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
||||
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
||||
| `allowUnauthenticatedPreflight` | bool | `false` | Bypass auth for a genuine CORS preflight (`OPTIONS` with `Origin` + `Access-Control-Request-Method`); response body is still discarded. Default requires auth for every `OPTIONS` request. |
|
||||
| `streamingRefresh` | bool | `false` | Refresh an expired session on SSE/WebSocket requests. Reject the request with `401` when the IdP revokes the grant (`invalid_grant`). Forward it as before when the IdP cannot be reached. |
|
||||
|
||||
### Discovered Endpoint Validation
|
||||
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Issue #162: the SSE/WebSocket bypass forwarded only X-Forwarded-User, not
|
||||
// the operator-configured `headers`. Backends that authenticate the proxy
|
||||
// with a shared-secret header (Frigate proxy auth) answered 401 on /ws while
|
||||
// normal requests, which go through forwardAuthorized, kept working.
|
||||
|
||||
func issue162Templates(t *testing.T, headers map[string]string) map[string]*template.Template {
|
||||
t.Helper()
|
||||
funcMap := headerTemplateFuncMap(claimsWhitelist(nil))
|
||||
out := make(map[string]*template.Template, len(headers))
|
||||
for name, value := range headers {
|
||||
tmpl, err := template.New(name).Funcs(funcMap).Option("missingkey=zero").Parse(value)
|
||||
if err != nil {
|
||||
t.Fatalf("parse template %s: %v", name, err)
|
||||
}
|
||||
out[name] = tmpl
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// issue162Request builds a request carrying a saved authenticated session.
|
||||
// idClaims and accessClaims are optional; nil leaves that token unset.
|
||||
func issue162Request(t *testing.T, sm *SessionManager, idClaims, accessClaims map[string]interface{}) *http.Request {
|
||||
t.Helper()
|
||||
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
|
||||
session, err := sm.GetSession(setupReq)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("SetAuthenticated: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
if idClaims != nil {
|
||||
session.SetIDToken(tokenWithGroups(t, idClaims))
|
||||
}
|
||||
if accessClaims != nil {
|
||||
session.SetAccessToken(tokenWithGroups(t, accessClaims))
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
if err := session.Save(setupReq, rr); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
session.returnToPoolSafely()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
func issue162Middleware(t *testing.T, sm *SessionManager, templates map[string]*template.Template, seen *http.Header) *TraefikOidc {
|
||||
t.Helper()
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
return &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
sessionManager: sm,
|
||||
initComplete: initComplete,
|
||||
extractClaimsFunc: extractClaims,
|
||||
groupClaimName: "groups",
|
||||
headerTemplates: templates,
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*seen = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func issue162SessionManager(t *testing.T) *SessionManager {
|
||||
t.Helper()
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sm.Shutdown(); err != nil {
|
||||
t.Errorf("Shutdown: %v", err)
|
||||
}
|
||||
})
|
||||
return sm
|
||||
}
|
||||
|
||||
func TestIssue162_StreamingBypassForwardsHeaderTemplates(t *testing.T) {
|
||||
templates := map[string]string{
|
||||
"X-Proxy-Secret": "s3cret",
|
||||
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
|
||||
"X-Forwarded-Groups": "{{range $i, $e := .Claims.groups}}{{if $i}},{{end}}{{$e}}{{end}}",
|
||||
}
|
||||
idClaims := map[string]interface{}{
|
||||
"preferred_username": "alice",
|
||||
"groups": []string{"admins", "viewers"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
prepare func(*http.Request)
|
||||
}{
|
||||
{"websocket", func(r *http.Request) {
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
}},
|
||||
{"sse", func(r *http.Request) {
|
||||
r.Header.Set("Accept", "text/event-stream")
|
||||
}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sm := issue162SessionManager(t)
|
||||
var seen http.Header
|
||||
m := issue162Middleware(t, sm, issue162Templates(t, templates), &seen)
|
||||
|
||||
req := issue162Request(t, sm, idClaims, nil)
|
||||
tc.prepare(req)
|
||||
rw := httptest.NewRecorder()
|
||||
m.ServeHTTP(rw, req)
|
||||
|
||||
if seen == nil {
|
||||
t.Fatalf("next not invoked (status=%d body=%q)", rw.Code, rw.Body.String())
|
||||
}
|
||||
want := map[string]string{
|
||||
"X-Forwarded-User": "[email protected]",
|
||||
"X-Proxy-Secret": "s3cret",
|
||||
"X-Forwarded-Preferred-Username": "alice",
|
||||
"X-Forwarded-Groups": "admins,viewers",
|
||||
}
|
||||
for h, v := range want {
|
||||
if got := seen.Get(h); got != v {
|
||||
t.Errorf("%s = %q, want %q", h, got, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Opaque-ID-token providers: normal requests render templates from
|
||||
// access-token claims when the session has no ID token, so the bypass must too.
|
||||
func TestIssue162_StreamingBypassTemplatesFallBackToAccessTokenClaims(t *testing.T) {
|
||||
sm := issue162SessionManager(t)
|
||||
var seen http.Header
|
||||
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{
|
||||
"X-Forwarded-Preferred-Username": "{{.Claims.preferred_username}}",
|
||||
}), &seen)
|
||||
|
||||
req := issue162Request(t, sm, nil, map[string]interface{}{"preferred_username": "bob"})
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
m.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if seen == nil {
|
||||
t.Fatal("next not invoked")
|
||||
}
|
||||
if got := seen.Get("X-Forwarded-Preferred-Username"); got != "bob" {
|
||||
t.Fatalf("X-Forwarded-Preferred-Username = %q, want %q", got, "bob")
|
||||
}
|
||||
}
|
||||
|
||||
// An unauthenticated upgrade must still be rejected before any template
|
||||
// (which can carry a shared secret) is rendered onto the request.
|
||||
func TestIssue162_StreamingBypassRejectsUnauthenticatedWithoutTemplates(t *testing.T) {
|
||||
sm := issue162SessionManager(t)
|
||||
var seen http.Header
|
||||
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
rw := httptest.NewRecorder()
|
||||
m.ServeHTTP(rw, req)
|
||||
|
||||
if seen != nil {
|
||||
t.Fatal("next invoked for unauthenticated upgrade")
|
||||
}
|
||||
if rw.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("status = %d, want 401", rw.Code)
|
||||
}
|
||||
if req.Header.Get("X-Proxy-Secret") != "" {
|
||||
t.Fatal("shared-secret template rendered onto a rejected request")
|
||||
}
|
||||
}
|
||||
|
||||
// Now that the bypass forwards proxy credentials (#162), it must honor
|
||||
// IdP-initiated logout like the normal path does; otherwise a logged-out
|
||||
// cookie keeps opening WebSocket/SSE with the shared secret attached.
|
||||
func TestIssue162_StreamingBypassRejectsIdPLoggedOutSession(t *testing.T) {
|
||||
idClaims := map[string]interface{}{
|
||||
"sid": "sess-162",
|
||||
"sub": "alice",
|
||||
"iat": float64(time.Now().Add(-time.Minute).Unix()),
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
invalidate bool
|
||||
wantStatus int
|
||||
}{
|
||||
{"active session forwards", false, http.StatusOK},
|
||||
{"logged-out session rejected", true, http.StatusUnauthorized},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sm := issue162SessionManager(t)
|
||||
cache := NewCache()
|
||||
defer cache.Close()
|
||||
|
||||
var seen http.Header
|
||||
m := issue162Middleware(t, sm, issue162Templates(t, map[string]string{"X-Proxy-Secret": "s3cret"}), &seen)
|
||||
m.enableBackchannelLogout = true
|
||||
m.sessionInvalidationCache = cache
|
||||
if tc.invalidate {
|
||||
if err := m.invalidateSession("sess-162", ""); err != nil {
|
||||
t.Fatalf("invalidateSession: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
req := issue162Request(t, sm, idClaims, nil)
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
rw := httptest.NewRecorder()
|
||||
m.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rw.Code, tc.wantStatus)
|
||||
}
|
||||
if forwarded := seen != nil; forwarded != (tc.wantStatus == http.StatusOK) {
|
||||
t.Fatalf("next invoked = %v, want %v", forwarded, tc.wantStatus == http.StatusOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,21 +80,3 @@ func makeTestJWT(t *testing.T, claims map[string]interface{}) string {
|
||||
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(pl) + ".c2ln"
|
||||
}
|
||||
|
||||
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
|
||||
// the session chunk manager's format validation (>=10 chars) so the access
|
||||
// token round-trips through Set/GetAccessToken.
|
||||
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
|
||||
pl, _ := json.Marshal(claims)
|
||||
// Varied (non-repeating) signature bytes so the session chunk manager's
|
||||
// repeated-character heuristic does not reject it.
|
||||
sigBytes := make([]byte, 32)
|
||||
for i := range sigBytes {
|
||||
sigBytes[i] = byte(i + 1)
|
||||
}
|
||||
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
|
||||
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
|
||||
}
|
||||
|
||||
@@ -336,7 +336,6 @@ func NewWithContext(ctx context.Context, config *Config, next http.Handler, name
|
||||
dcrConfig: config.DynamicClientRegistration,
|
||||
allowPrivateIPAddresses: config.AllowPrivateIPAddresses,
|
||||
allowUnauthenticatedPreflight: config.AllowUnauthenticatedPreflight,
|
||||
streamingRefresh: config.StreamingRefresh,
|
||||
allowLoopbackHosts: isLoopbackProviderURL(config.ProviderURL),
|
||||
minimalHeaders: config.MinimalHeaders,
|
||||
stripAuthCookies: config.StripAuthCookies,
|
||||
|
||||
+73
-155
@@ -273,7 +273,7 @@ func stripIdentityHeaders(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.Request, reason string) (bool, int) {
|
||||
func (t *TraefikOidc) applyBypassUserHeaders(req *http.Request, reason string) (bool, int) {
|
||||
if t.sessionManager == nil {
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
@@ -296,30 +296,6 @@ func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.R
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
|
||||
// Opt-in: refresh an expired session so an IdP-revoked grant stops opening
|
||||
// streams. Only a definitive rejection (refreshToken clears the session on
|
||||
// invalid_grant) denies; IdP outages keep forwarding as before. The full
|
||||
// coordinator wait is kept so a rotated refresh token is never dropped.
|
||||
if t.streamingRefresh && bypassTokenExpired(session) {
|
||||
if !t.refreshToken(rw, req, session) && !session.GetAuthenticated() {
|
||||
t.logger.Infof("User %s refresh rejected by IdP (bypass)", userIdentifier)
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the normal path's IdP-initiated logout check: this path forwards
|
||||
// configured headers (often a shared proxy secret), so a logged-out cookie
|
||||
// must not keep opening streams until the absolute session timeout.
|
||||
if t.enableBackchannelLogout || t.enableFrontchannelLogout {
|
||||
if idToken := session.GetIDToken(); idToken != "" {
|
||||
sid, sub, _ := t.extractSessionInfo(idToken)
|
||||
if t.isSessionInvalidated(sid, sub, t.sessionCreatedAtForInvalidation(idToken, session)) {
|
||||
t.logger.Infof("User %s session invalidated via IdP-initiated logout (bypass)", userIdentifier)
|
||||
return false, http.StatusUnauthorized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enforce the user allowlist, mirroring the normal path's authorization
|
||||
// check (isAllowedUser). Without this, an authenticated user not in
|
||||
// allowedUsers (or whose domain is not in allowedUserDomains) could
|
||||
@@ -338,18 +314,19 @@ func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.R
|
||||
// fallback, opaque-ID-token providers (groups only in the access token)
|
||||
// were granted on normal requests but 403'd on the streaming bypass
|
||||
// (R118).
|
||||
var claims map[string]interface{}
|
||||
var claimsErr error
|
||||
if len(t.allowedRolesAndGroups) > 0 || len(t.headerTemplates) > 0 {
|
||||
claims, claimsErr = t.bypassSessionClaims(session)
|
||||
}
|
||||
|
||||
if len(t.allowedRolesAndGroups) > 0 {
|
||||
if claimsErr != nil || claims == nil {
|
||||
var groupClaims map[string]interface{}
|
||||
var claimsErr error
|
||||
if idToken := session.GetIDToken(); idToken != "" {
|
||||
groupClaims, claimsErr = session.GetIDTokenClaims(t.extractClaimsFunc)
|
||||
} else if accessToken := session.GetAccessToken(); accessToken != "" {
|
||||
groupClaims, claimsErr = t.extractClaimsFunc(accessToken)
|
||||
}
|
||||
if claimsErr != nil || groupClaims == nil {
|
||||
t.logger.Debugf("%s bypass: cannot read claims for role check (err=%v): %s", reason, claimsErr, userIdentifier)
|
||||
return false, http.StatusForbidden
|
||||
}
|
||||
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(claims)
|
||||
groups, roles, extErr := t.extractGroupsAndRolesFromClaims(groupClaims)
|
||||
if extErr != nil {
|
||||
t.logger.Debugf("%s bypass: role extraction failed for %s: %v", reason, userIdentifier, extErr)
|
||||
return false, http.StatusForbidden
|
||||
@@ -374,8 +351,8 @@ func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.R
|
||||
// only sets X-Forwarded-User / X-Auth-Request-User (never groups/roles),
|
||||
// forged group/role headers would otherwise survive to the backend,
|
||||
// which commonly trusts them for downstream authorization. Must precede
|
||||
// the sanitize gate so forged values are cleared even when the
|
||||
// identifier is dropped as unsafe (R100).
|
||||
// the sanitize gate so even the drop-on-unsafe early return has already
|
||||
// cleared forged values (R100).
|
||||
stripIdentityHeaders(req)
|
||||
|
||||
// Sanitize the claim-derived identifier before it is injected as a
|
||||
@@ -384,73 +361,20 @@ func (t *TraefikOidc) applyBypassUserHeaders(rw http.ResponseWriter, req *http.R
|
||||
// or delimiter characters that would otherwise inject or confuse
|
||||
// downstream header parsing. On failure, drop the header but still
|
||||
// honor the bypass (the identity headers are decoration here).
|
||||
if safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen()); ok {
|
||||
req.Header.Set("X-Forwarded-User", safeIdentifier)
|
||||
if !t.minimalHeaders {
|
||||
req.Header.Set("X-Auth-Request-User", safeIdentifier)
|
||||
}
|
||||
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
|
||||
} else {
|
||||
safeIdentifier, ok := sanitizeHeaderClaimValue(userIdentifier, t.headerClaimMaxLen())
|
||||
if !ok {
|
||||
t.logger.Debugf("%s bypass: dropping unsafe user-identifier header: %s", reason, headerClaimValueReason(userIdentifier, t.headerClaimMaxLen()))
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// Backends that authenticate the proxy via configured headers (shared
|
||||
// secret, user/groups) need them on the upgrade too (#162). Claims are
|
||||
// cookie-sealed but not re-verified here; see the doc comment above.
|
||||
if len(t.headerTemplates) > 0 {
|
||||
if claimsErr != nil {
|
||||
t.logger.Debugf("%s bypass: rendering header templates without claims: %v", reason, claimsErr)
|
||||
}
|
||||
t.applyHeaderTemplates(req, &principal{
|
||||
Source: sourceSession,
|
||||
Identifier: userIdentifier,
|
||||
AccessToken: session.GetAccessToken(),
|
||||
IDToken: session.GetIDToken(),
|
||||
RefreshToken: session.GetRefreshToken(),
|
||||
Claims: claims,
|
||||
})
|
||||
req.Header.Set("X-Forwarded-User", safeIdentifier)
|
||||
if !t.minimalHeaders {
|
||||
req.Header.Set("X-Auth-Request-User", safeIdentifier)
|
||||
}
|
||||
t.logger.Debugf("%s bypass: forwarded user %s from session", reason, safeIdentifier)
|
||||
return true, 0
|
||||
}
|
||||
|
||||
// bypassTokenExpired reports whether the session's access token, or its ID
|
||||
// token when the access token carries no readable exp (opaque), has expired.
|
||||
// exp is read unverified: the cookie is sealed and a verified refresh follows.
|
||||
func bypassTokenExpired(session *SessionData) bool {
|
||||
for _, token := range []string{session.GetAccessToken(), session.GetIDToken()} {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
jwt, err := parseJWT(token)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if exp, ok := jwt.Claims["exp"].(float64); ok {
|
||||
return !time.Now().Before(time.Unix(int64(exp), 0))
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bypassSessionClaims returns the session's ID-token claims, falling back to
|
||||
// access-token claims when there is no ID token, matching the source the
|
||||
// normal authorization path uses (R118).
|
||||
func (t *TraefikOidc) bypassSessionClaims(session *SessionData) (map[string]interface{}, error) {
|
||||
if t.extractClaimsFunc == nil {
|
||||
return nil, nil
|
||||
}
|
||||
// Explicit assignments, not tail-call returns: yaegi can zero
|
||||
// multi-value tail-call results (see issue151_regression_test.go).
|
||||
var claims map[string]interface{}
|
||||
var err error
|
||||
if session.GetIDToken() != "" {
|
||||
claims, err = session.GetIDTokenClaims(t.extractClaimsFunc)
|
||||
} else if accessToken := session.GetAccessToken(); accessToken != "" {
|
||||
claims, err = t.extractClaimsFunc(accessToken)
|
||||
}
|
||||
return claims, err
|
||||
}
|
||||
|
||||
// ServeHTTP implements the main middleware logic for processing HTTP requests.
|
||||
// It handles the complete OIDC authentication flow including:
|
||||
// - Excluded URL bypass
|
||||
@@ -670,7 +594,7 @@ func (t *TraefikOidc) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
// Otherwise an unauthenticated client could hit the backend
|
||||
// just by setting Accept: text/event-stream or sending a
|
||||
// WebSocket upgrade.
|
||||
if ok, status := t.applyBypassUserHeaders(rw, req, reason); !ok {
|
||||
if ok, status := t.applyBypassUserHeaders(req, reason); !ok {
|
||||
msg := "Authentication required"
|
||||
if status == http.StatusForbidden {
|
||||
msg = "Access denied"
|
||||
@@ -1324,64 +1248,6 @@ func joinBoundedClaimHeader(values []string) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// applyHeaderTemplates renders the configured header templates for p onto req.
|
||||
func (t *TraefikOidc) applyHeaderTemplates(req *http.Request, p *principal) {
|
||||
if len(t.headerTemplates) == 0 {
|
||||
return
|
||||
}
|
||||
// p.Claims may be nil (e.g. session without an ID token). Templates
|
||||
// referencing .Claims.* will simply produce empty values — matches
|
||||
// the prior behavior. Bearer-source principals always carry access-
|
||||
// token claims (post-verifyToken).
|
||||
templateData := map[string]interface{}{
|
||||
"AccessToken": p.AccessToken,
|
||||
"IDToken": p.IDToken,
|
||||
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
|
||||
"RefreshToken": p.RefreshToken,
|
||||
"Claims": p.Claims,
|
||||
}
|
||||
|
||||
for headerName, tmpl := range t.headerTemplates {
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
|
||||
continue
|
||||
}
|
||||
headerValue := buf.String()
|
||||
// Go's text/template renders a missing key in an interface-valued
|
||||
// map as the literal "<no value>" even with missingkey=zero
|
||||
// (zero only applies to typed keys). Replace that sentinel so an
|
||||
// optional claim the provider did not emit (e.g. email) does not
|
||||
// leak "<no value>" into the downstream header.
|
||||
if strings.Contains(headerValue, noValueSentinel) {
|
||||
headerValue = stripNoValueSentinels(headerValue)
|
||||
}
|
||||
// Skip an empty render: Setting "" would clobber an identity
|
||||
// header (X-Forwarded-User / X-Auth-Request-*) the caller already
|
||||
// injected with the authenticated value, silently
|
||||
// dropping the user identity at the backend.
|
||||
if headerValue == "" {
|
||||
t.logger.Debugf("Skipping templated header %s: rendered value is empty", headerName)
|
||||
continue
|
||||
}
|
||||
// Sanitize the rendered output: template inputs are claim-derived
|
||||
// and attacker-influenceable, so reject control chars (header
|
||||
// injection), bidi-override runes, the , ; = delimiters, and an
|
||||
// over-long value. Fail closed by dropping the header rather than
|
||||
// forwarding a tainted value. Do not log the value (it commonly
|
||||
// carries the access token); log only name + reason.
|
||||
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
|
||||
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
|
||||
continue
|
||||
}
|
||||
req.Header.Set(headerName, headerValue)
|
||||
// Do not log the value: templated headers commonly carry the access
|
||||
// token (e.g. "Authorization: Bearer {{.AccessToken}}"), and logging
|
||||
// it — even at debug — leaks credentials into logs.
|
||||
t.logger.Debugf("Set templated header %s (%d bytes)", headerName, len(headerValue))
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Request, p *principal) {
|
||||
// This middleware fully owns the identity headers it injects upstream.
|
||||
// Unconditionally remove any inbound values with the same names first:
|
||||
@@ -1517,7 +1383,59 @@ func (t *TraefikOidc) forwardAuthorized(rw http.ResponseWriter, req *http.Reques
|
||||
req.Header.Del("Authorization")
|
||||
}
|
||||
|
||||
t.applyHeaderTemplates(req, p)
|
||||
if len(t.headerTemplates) > 0 {
|
||||
// p.Claims may be nil (e.g. session without an ID token). Templates
|
||||
// referencing .Claims.* will simply produce empty values — matches
|
||||
// the prior behavior. Bearer-source principals always carry access-
|
||||
// token claims (post-verifyToken).
|
||||
templateData := map[string]interface{}{
|
||||
"AccessToken": p.AccessToken,
|
||||
"IDToken": p.IDToken,
|
||||
"IdToken": p.IDToken, // documented spelling (README/CONFIGURATION); alias so {{.IdToken}} renders (issue #149 review)
|
||||
"RefreshToken": p.RefreshToken,
|
||||
"Claims": p.Claims,
|
||||
}
|
||||
|
||||
for headerName, tmpl := range t.headerTemplates {
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||
t.logger.Errorf("Failed to execute template for header %s: %v", headerName, err)
|
||||
continue
|
||||
}
|
||||
headerValue := buf.String()
|
||||
// Go's text/template renders a missing key in an interface-valued
|
||||
// map as the literal "<no value>" even with missingkey=zero
|
||||
// (zero only applies to typed keys). Replace that sentinel so an
|
||||
// optional claim the provider did not emit (e.g. email) does not
|
||||
// leak "<no value>" into the downstream header.
|
||||
if strings.Contains(headerValue, noValueSentinel) {
|
||||
headerValue = stripNoValueSentinels(headerValue)
|
||||
}
|
||||
// Skip an empty render: Setting "" would clobber an identity
|
||||
// header (X-Forwarded-User / X-Auth-Request-*) already injected
|
||||
// earlier in this function with the authenticated value, silently
|
||||
// dropping the user identity at the backend.
|
||||
if headerValue == "" {
|
||||
t.logger.Debugf("Skipping templated header %s: rendered value is empty", headerName)
|
||||
continue
|
||||
}
|
||||
// Sanitize the rendered output: template inputs are claim-derived
|
||||
// and attacker-influenceable, so reject control chars (header
|
||||
// injection), bidi-override runes, the , ; = delimiters, and an
|
||||
// over-long value. Fail closed by dropping the header rather than
|
||||
// forwarding a tainted value. Do not log the value (it commonly
|
||||
// carries the access token); log only name + reason.
|
||||
if reason := headerValueReason(headerValue, headerTemplateMaxLen); reason != "" {
|
||||
t.logger.Debugf("Dropping templated header %s: value failed sanitization (%s)", headerName, reason)
|
||||
continue
|
||||
}
|
||||
req.Header.Set(headerName, headerValue)
|
||||
// Do not log the value: templated headers commonly carry the access
|
||||
// token (e.g. "Authorization: Bearer {{.AccessToken}}"), and logging
|
||||
// it — even at debug — leaks credentials into logs.
|
||||
t.logger.Debugf("Set templated header %s (%d bytes)", headerName, len(headerValue))
|
||||
}
|
||||
}
|
||||
|
||||
// Strip OIDC session cookies before forwarding to the backend to prevent
|
||||
// HTTP 431 "Request Header Fields Too Large" errors (GitHub issue #122).
|
||||
|
||||
+2
-2
@@ -53,7 +53,7 @@ func TestApplyBypassUserHeaders_ClearsForgedIdentityHeaders(t *testing.T) {
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestApplyBypassUserHeaders_UnsafeIdentifierClearsForgedValue(t *testing.T)
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
if v := req.Header.Get("X-Forwarded-User"); v != "" {
|
||||
|
||||
+21
-1
@@ -2,6 +2,8 @@ package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -62,6 +64,24 @@ func TestIsRetryableError_HTTPErrorStatus(t *testing.T) {
|
||||
|
||||
// --- R118 fix C: bypass role gate must fall back to access-token claims ---
|
||||
|
||||
// tokenWithGroups builds a JWT whose signature segment is long enough to pass
|
||||
// the session chunk manager's format validation (>=10 chars) so the access
|
||||
// token round-trips through Set/GetAccessToken.
|
||||
func tokenWithGroups(t *testing.T, claims map[string]interface{}) string {
|
||||
t.Helper()
|
||||
hdr, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"})
|
||||
pl, _ := json.Marshal(claims)
|
||||
// Varied (non-repeating) signature bytes so the session chunk manager's
|
||||
// repeated-character heuristic does not reject it.
|
||||
sigBytes := make([]byte, 32)
|
||||
for i := range sigBytes {
|
||||
sigBytes[i] = byte(i + 1)
|
||||
}
|
||||
sig := base64.RawURLEncoding.EncodeToString(sigBytes)
|
||||
return base64.RawURLEncoding.EncodeToString(hdr) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(pl) + "." + sig
|
||||
}
|
||||
|
||||
// TestApplyBypassUserHeaders_AccessTokenGroupFallback regresses R118: the
|
||||
// SSE/WebSocket bypass role gate read only the session's ID token, while the
|
||||
// main authorization path falls back to access-token claims when no ID token
|
||||
@@ -100,7 +120,7 @@ func TestApplyBypassUserHeaders_AccessTokenGroupFallback(t *testing.T) {
|
||||
allowedRolesAndGroups: map[string]struct{}{"team-a": {}},
|
||||
}
|
||||
|
||||
ok, status := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test")
|
||||
ok, status := oidc.applyBypassUserHeaders(req, "test")
|
||||
if !ok {
|
||||
t.Fatalf("bypass role gate denied access-token group; got status %d, want granted", status)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestApplyBypassUserHeaders_SanitizesIdentifier(t *testing.T) {
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
if v := req.Header.Get("X-Forwarded-User"); v != "" {
|
||||
@@ -83,7 +83,7 @@ func TestApplyBypassUserHeaders_KeepsCleanIdentifier(t *testing.T) {
|
||||
logger: NewLogger("debug"),
|
||||
}
|
||||
|
||||
if ok, _ := oidc.applyBypassUserHeaders(httptest.NewRecorder(), req, "test"); !ok {
|
||||
if ok, _ := oidc.applyBypassUserHeaders(req, "test"); !ok {
|
||||
t.Fatalf("applyBypassUserHeaders returned false; bypass should be honored")
|
||||
}
|
||||
if v := req.Header.Get("X-Forwarded-User"); v != "[email protected]" {
|
||||
|
||||
+2
-6
@@ -115,12 +115,8 @@ type Config struct {
|
||||
// or not, like any other method. Enable only if your backend itself
|
||||
// answers CORS preflights and must see them unauthenticated.
|
||||
AllowUnauthenticatedPreflight bool `json:"allowUnauthenticatedPreflight,omitempty"`
|
||||
// StreamingRefresh refreshes an expired session on the SSE/WebSocket
|
||||
// bypass and rejects the request when the IdP revokes the grant.
|
||||
// Default false: the bypass trusts the session cookie until it expires.
|
||||
StreamingRefresh bool `json:"streamingRefresh,omitempty"`
|
||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
||||
MinimalHeaders bool `json:"minimalHeaders,omitempty"`
|
||||
StripAuthCookies bool `json:"stripAuthCookies,omitempty"`
|
||||
// CookiePath restricts session cookies to a specific path prefix instead of "/".
|
||||
// When traefikoidc protects some but not all paths on a domain, set this to the
|
||||
// middleware's path prefix (e.g. "/app-protegido") so the browser does not send
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
// streamingRefreshSession stores a session whose tokens expire at exp and
|
||||
// returns a WebSocket upgrade request carrying its cookies. An empty
|
||||
// refreshToken leaves the refresh token unset; opaqueAccess stores a
|
||||
// non-JWT access token so only the ID token carries an exp.
|
||||
func streamingRefreshSession(t *testing.T, sm *SessionManager, exp time.Time, refreshToken string, opaqueAccess bool) *http.Request {
|
||||
t.Helper()
|
||||
setupReq := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
|
||||
session, err := sm.GetSession(setupReq)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
if err := session.SetAuthenticated(true); err != nil {
|
||||
t.Fatalf("SetAuthenticated: %v", err)
|
||||
}
|
||||
session.SetUserIdentifier("[email protected]")
|
||||
claims := map[string]interface{}{
|
||||
"email": "[email protected]",
|
||||
"preferred_username": "alice-old",
|
||||
"exp": float64(exp.Unix()),
|
||||
}
|
||||
session.SetIDToken(tokenWithGroups(t, claims))
|
||||
if opaqueAccess {
|
||||
session.SetAccessToken("opaque-access-token-without-dots")
|
||||
} else {
|
||||
session.SetAccessToken(tokenWithGroups(t, claims))
|
||||
}
|
||||
if refreshToken != "" {
|
||||
session.SetRefreshToken(refreshToken)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
if err := session.Save(setupReq, rr); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
session.returnToPoolSafely()
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "https://app.example.com/ws", nil)
|
||||
for _, c := range rr.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
req.Header.Set("Upgrade", "websocket")
|
||||
req.Header.Set("Connection", "Upgrade")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestStreamingBypassRefresh(t *testing.T) {
|
||||
past := time.Now().Add(-time.Minute)
|
||||
future := time.Now().Add(time.Hour)
|
||||
errInvalidGrant := errors.New(`token endpoint returned 400: {"error":"invalid_grant"}`)
|
||||
errTransient := errors.New("dial tcp 10.0.0.1:443: connect: connection refused")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
enabled bool
|
||||
exp time.Time
|
||||
refreshToken string
|
||||
opaqueAccess bool
|
||||
refreshErr error
|
||||
wantStatus int
|
||||
wantRefresh int
|
||||
wantUsername string
|
||||
}{
|
||||
{name: "flag off keeps expired session forwarding", enabled: false, exp: past, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
|
||||
{name: "unexpired token skips refresh", enabled: true, exp: future, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
|
||||
{name: "expired token refreshes and forwards fresh claims", enabled: true, exp: past, refreshToken: "rt-old", wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-new"},
|
||||
{name: "opaque access token uses ID token expiry", enabled: true, exp: past, refreshToken: "rt-old", opaqueAccess: true, wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-new"},
|
||||
{name: "revoked grant is rejected", enabled: true, exp: past, refreshToken: "rt-old", refreshErr: errInvalidGrant, wantStatus: http.StatusUnauthorized, wantRefresh: 1},
|
||||
{name: "transient IdP failure still forwards", enabled: true, exp: past, refreshToken: "rt-old", refreshErr: errTransient, wantStatus: http.StatusOK, wantRefresh: 1, wantUsername: "alice-old"},
|
||||
{name: "no refresh token still forwards", enabled: true, exp: past, wantStatus: http.StatusOK, wantRefresh: 0, wantUsername: "alice-old"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
sm, err := NewSessionManager(strings.Repeat("k", 32), false, "", "", time.Hour, NewLogger("error"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewSessionManager: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sm.Shutdown(); err != nil {
|
||||
t.Errorf("Shutdown: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
newClaims := map[string]interface{}{
|
||||
"email": "[email protected]",
|
||||
"preferred_username": "alice-new",
|
||||
"exp": float64(future.Unix()),
|
||||
}
|
||||
exchanger := &EnhancedMockTokenExchanger{
|
||||
RefreshErr: tc.refreshErr,
|
||||
RefreshResponse: &TokenResponse{
|
||||
AccessToken: tokenWithGroups(t, newClaims),
|
||||
IDToken: tokenWithGroups(t, newClaims),
|
||||
RefreshToken: "rt-new",
|
||||
},
|
||||
}
|
||||
tmpl := template.Must(template.New("X-Forwarded-Preferred-Username").
|
||||
Funcs(headerTemplateFuncMap(claimsWhitelist(nil))).
|
||||
Option("missingkey=zero").
|
||||
Parse("{{.Claims.preferred_username}}"))
|
||||
|
||||
initComplete := make(chan struct{})
|
||||
close(initComplete)
|
||||
var seen http.Header
|
||||
m := &TraefikOidc{
|
||||
logger: GetSingletonNoOpLogger(),
|
||||
name: "test",
|
||||
sessionManager: sm,
|
||||
initComplete: initComplete,
|
||||
extractClaimsFunc: extractClaims,
|
||||
userIdentifierClaim: "email",
|
||||
tokenExchanger: exchanger,
|
||||
tokenVerifier: &EnhancedMockTokenVerifier{},
|
||||
streamingRefresh: tc.enabled,
|
||||
headerTemplates: map[string]*template.Template{"X-Forwarded-Preferred-Username": tmpl},
|
||||
next: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
seen = r.Header.Clone()
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}),
|
||||
}
|
||||
|
||||
req := streamingRefreshSession(t, sm, tc.exp, tc.refreshToken, tc.opaqueAccess)
|
||||
rw := httptest.NewRecorder()
|
||||
m.ServeHTTP(rw, req)
|
||||
|
||||
if rw.Code != tc.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", rw.Code, tc.wantStatus)
|
||||
}
|
||||
if got := len(exchanger.RefreshCalls); got != tc.wantRefresh {
|
||||
t.Fatalf("refresh calls = %d, want %d", got, tc.wantRefresh)
|
||||
}
|
||||
if tc.wantStatus != http.StatusOK {
|
||||
if seen != nil {
|
||||
t.Fatal("next invoked for a rejected request")
|
||||
}
|
||||
return
|
||||
}
|
||||
if seen == nil {
|
||||
t.Fatal("next not invoked")
|
||||
}
|
||||
if got := seen.Get("X-Forwarded-Preferred-Username"); got != tc.wantUsername {
|
||||
t.Fatalf("X-Forwarded-Preferred-Username = %q, want %q", got, tc.wantUsername)
|
||||
}
|
||||
if tc.wantRefresh == 1 && tc.refreshErr == nil && len(rw.Result().Cookies()) == 0 {
|
||||
t.Fatal("refreshed session was not written back as a cookie")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamingRefreshConfigWiring(t *testing.T) {
|
||||
if CreateConfig().StreamingRefresh {
|
||||
t.Fatal("streamingRefresh must default to false")
|
||||
}
|
||||
for _, enabled := range []bool{false, true} {
|
||||
config := CreateConfig()
|
||||
config.ProviderURL = "https://provider.example.com"
|
||||
config.ClientID = "test-client-id"
|
||||
config.ClientSecret = "test-secret"
|
||||
config.SessionEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
config.CallbackURL = "/callback"
|
||||
config.StreamingRefresh = enabled
|
||||
|
||||
if got, ok := config.configToMap()["streamingRefresh"].(bool); !ok || got != enabled {
|
||||
t.Fatalf("configToMap streamingRefresh = %v (present=%v), want %v", got, ok, enabled)
|
||||
}
|
||||
oidc, err := NewWithContext(context.Background(), config, http.NotFoundHandler(), "test")
|
||||
if err != nil {
|
||||
t.Fatalf("NewWithContext: %v", err)
|
||||
}
|
||||
if oidc.streamingRefresh != enabled {
|
||||
t.Errorf("streamingRefresh = %v, want %v", oidc.streamingRefresh, enabled)
|
||||
}
|
||||
if err := oidc.Close(); err != nil {
|
||||
t.Errorf("Close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,8 +235,6 @@ type TraefikOidc struct {
|
||||
// (default false): gates whether shouldBypassAuth's genuine-preflight
|
||||
// check (middleware.go) bypasses OIDC auth at all.
|
||||
allowUnauthenticatedPreflight bool
|
||||
// streamingRefresh mirrors Config.StreamingRefresh (default false).
|
||||
streamingRefresh bool
|
||||
// allowLoopbackHosts permits loopback/localhost hosts in outbound URL
|
||||
// validation (validateHost). Derived at construction time from a loopback
|
||||
// providerURL (local development), never operator-set directly. Mirrors
|
||||
|
||||
+1
-1
@@ -14,4 +14,4 @@ const devPluginVersion = "0.0.0-dev"
|
||||
// from build info at runtime (debug.ReadBuildInfo sees Traefik's build graph,
|
||||
// not the interpreted plugin). This build-stamped constant is therefore the
|
||||
// single source of truth for the version reported by anonymous usage telemetry.
|
||||
const traefikoidcPluginVersion = "1.0.41"
|
||||
const traefikoidcPluginVersion = "1.0.37"
|
||||
|
||||
Reference in New Issue
Block a user