feat: Add option for propagating id_token to upstream app

Fixes #315

Co-authored-by: tronghn <trong.huu.nguyen@nav.no>
This commit is contained in:
Sindre Rødseth Hansen
2025-01-20 13:07:54 +01:00
parent bc307916be
commit 2feb6a3b77
7 changed files with 132 additions and 66 deletions

View File

@@ -16,52 +16,53 @@ openid.client-id -> WONDERWALL_OPENID_CLIENT_ID
The following flags are available:
| Flag | Type | Default Value | Description |
|:----------------------------------|:---------|:---------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `auto-login` | boolean | `false` | Enforce authentication if the user does not have a valid session for all matching upstream paths. Automatically redirects HTTP navigation requests to login, otherwise responds with 401 with the Location header set. |
| `auto-login-ignore-paths` | strings | | Comma separated list of absolute paths to ignore when `auto-login` is enabled. Supports basic wildcard matching with glob-style asterisks. Invalid patterns are ignored. |
| `bind-address` | string | `127.0.0.1:3000` | Listen address for public connections. |
| `cookie.prefix` | string | `io.nais.wonderwall` | Prefix for cookie names. |
| `cookie.same-site` | string | `Lax` | SameSite attribute for session cookies. One of [Strict, Lax, None]. |
| `cookie.secure` | string | `true` | Set secure flag on session cookies. Can only be disabled when `ingress` only consist of localhost hosts. Generally, disabling this is only necessary when using Safari. |
| `encryption-key` | string | | Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. |
| `ingress` | strings | | Comma separated list of ingresses used to access the main application. |
| `log-format` | string | `json` | Log format, either `json` or `text`. |
| `log-level` | string | `info` | Logging verbosity level. |
| `metrics-bind-address` | string | `127.0.0.1:3001` | Listen address for metrics only. |
| `openid.acr-values` | string | | Space separated string that configures the default security level (`acr_values`) parameter for authorization requests. |
| `openid.audiences` | strings | | List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation. |
| `openid.client-id` | string | | Client ID for the OpenID client. |
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over `openid.client-secret`. |
| `openid.client-secret` | string | | Client secret for the OpenID client. Overridden by `openid.client-jwk`, if configured. |
| `openid.id-token-signing-alg` | string | `RS256` | Expected JWA value (as defined in RFC 7518) of public keys for validating id_token signatures. This only applies where the key's `alg` header is not set. |
| `openid.post-logout-redirect-uri` | string | | URI for redirecting the user after successful logout at the Identity Provider. |
| `openid.provider` | string | `openid` | Provider configuration to load and use, either `openid`, `azure`, `idporten`. |
| `openid.resource-indicator` | string | | OAuth2 resource indicator to include in authorization request for acquiring audience-restricted tokens. |
| `openid.scopes` | strings | | Comma separated list of additional scopes (other than `openid`) that should be used during the login flow. |
| `openid.ui-locales` | string | | Space-separated string that configures the default UI locale (`ui_locales`) parameter for OAuth2 consent screen. |
| `openid.well-known-url` | string | | URI to the well-known OpenID Configuration metadata document. |
| `redis.address` | string | | Deprecated: prefer using `redis.uri`. Address of the Redis instance (host:port). An empty value will use in-memory session storage. Does not override address set by `redis.uri`. |
| `redis.connection-idle-timeout` | int | `0` | Idle timeout for Redis connections, in seconds. If non-zero, the value should be less than the client timeout configured at the Redis server. A value of -1 disables timeout. If zero, the default value from go-redis is used (30 minutes). Overrides options set by `redis.uri`. |
| `redis.password` | string | | Password for Redis. Overrides password set by `redis.uri`. |
| `redis.tls` | boolean | `true` | Whether or not to use TLS for connecting to Redis. Does not override TLS config set by `redis.uri`. |
| `redis.uri` | string | | Redis URI string. An empty value will fall back to `redis-address`. |
| `redis.username` | string | | Username for Redis. Overrides username set by `redis.uri`. |
| `session.forward-auth` | boolean | `false` | Enable endpoint for forward authentication. |
| `session.inactivity` | boolean | `false` | Automatically expire user sessions if they have not refreshed their tokens within a given duration. |
| `session.inactivity-timeout` | duration | `30m` | Inactivity timeout for user sessions. |
| `session.max-lifetime` | duration | `10h` | Max lifetime for user sessions. |
| `shutdown-graceful-period` | duration | `30s` | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. |
| `shutdown-wait-before-period` | duration | `0s` | Wait period when receiving a shutdown signal before actually starting a graceful shutdown. Useful for allowing propagation of Endpoint updates in Kubernetes. |
| `sso.domain` | string | | The domain that the session cookies should be set for, usually the second-level domain name (e.g. `example.com`). |
| `sso.enabled` | boolean | `false` | Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store. |
| `sso.mode` | string | `server` | The SSO mode for this instance. Must be one of `server` or `proxy`. |
| `sso.server-default-redirect-url` | string | | The URL that the SSO server should redirect to by default if a given redirect query parameter is invalid. |
| `sso.server-url` | string | | The URL used by the proxy to point to the SSO server instance. |
| `sso.session-cookie-name` | string | | Session cookie name. Must be the same across all SSO Servers and Proxies that should share sessions. |
| `upstream-host` | string | `127.0.0.1:8080` | Address of upstream host. |
| `upstream-ip` | string | | IP of upstream host. Overrides `upstream-host` if set. |
| `upstream-port` | int | | Port of upstream host. Overrides `upstream-host` if set. |
| Flag | Type | Default Value | Description |
|:-------------------------------------------|:---------|:----------------------|:-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `auto-login` | boolean | `false` | Enforce authentication if the user does not have a valid session for all matching upstream paths. Automatically redirects HTTP navigation requests to login, otherwise responds with 401 with the Location header set. |
| `auto-login-ignore-paths` | strings | | Comma separated list of absolute paths to ignore when `auto-login` is enabled. Supports basic wildcard matching with glob-style asterisks. Invalid patterns are ignored. |
| `bind-address` | string | `127.0.0.1:3000` | Listen address for public connections. |
| `cookie.prefix` | string | `io.nais.wonderwall` | Prefix for cookie names. |
| `cookie.same-site` | string | `Lax` | SameSite attribute for session cookies. One of [Strict, Lax, None]. |
| `cookie.secure` | string | `true` | Set secure flag on session cookies. Can only be disabled when `ingress` only consist of localhost hosts. Generally, disabling this is only necessary when using Safari. |
| `encryption-key` | string | | Base64 encoded 256-bit cookie encryption key; must be identical in instances that share session store. |
| `ingress` | strings | | Comma separated list of ingresses used to access the main application. |
| `log-format` | string | `json` | Log format, either `json` or `text`. |
| `log-level` | string | `info` | Logging verbosity level. |
| `metrics-bind-address` | string | `127.0.0.1:3001` | Listen address for metrics only. |
| `openid.acr-values` | string | | Space separated string that configures the default security level (`acr_values`) parameter for authorization requests. |
| `openid.audiences` | strings | | List of additional trusted audiences (other than the client_id) for OpenID Connect id_token validation. |
| `openid.client-id` | string | | Client ID for the OpenID client. |
| `openid.client-jwk` | string | | JWK containing the private key for the OpenID client in string format. If configured, this takes precedence over `openid.client-secret`. |
| `openid.client-secret` | string | | Client secret for the OpenID client. Overridden by `openid.client-jwk`, if configured. |
| `openid.id-token-signing-alg` | string | `RS256` | Expected JWA value (as defined in RFC 7518) of public keys for validating id_token signatures. This only applies where the key's `alg` header is not set. |
| `openid.post-logout-redirect-uri` | string | | URI for redirecting the user after successful logout at the Identity Provider. |
| `openid.provider` | string | `openid` | Provider configuration to load and use, either `openid`, `azure`, `idporten`. |
| `openid.resource-indicator` | string | | OAuth2 resource indicator to include in authorization request for acquiring audience-restricted tokens. |
| `openid.scopes` | strings | | Comma separated list of additional scopes (other than `openid`) that should be used during the login flow. |
| `openid.ui-locales` | string | | Space-separated string that configures the default UI locale (`ui_locales`) parameter for OAuth2 consent screen. |
| `openid.well-known-url` | string | | URI to the well-known OpenID Configuration metadata document. |
| `redis.address` | string | | Deprecated: prefer using `redis.uri`. Address of the Redis instance (host:port). An empty value will use in-memory session storage. Does not override address set by `redis.uri`. |
| `redis.connection-idle-timeout` | int | `0` | Idle timeout for Redis connections, in seconds. If non-zero, the value should be less than the client timeout configured at the Redis server. A value of -1 disables timeout. If zero, the default value from go-redis is used (30 minutes). Overrides options set by `redis.uri`. |
| `redis.password` | string | | Password for Redis. Overrides password set by `redis.uri`. |
| `redis.tls` | boolean | `true` | Whether or not to use TLS for connecting to Redis. Does not override TLS config set by `redis.uri`. |
| `redis.uri` | string | | Redis URI string. An empty value will fall back to `redis-address`. |
| `redis.username` | string | | Username for Redis. Overrides username set by `redis.uri`. |
| `session.forward-auth` | boolean | `false` | Enable endpoint for forward authentication. |
| `session.inactivity` | boolean | `false` | Automatically expire user sessions if they have not refreshed their tokens within a given duration. |
| `session.inactivity-timeout` | duration | `30m` | Inactivity timeout for user sessions. |
| `session.max-lifetime` | duration | `10h` | Max lifetime for user sessions. |
| `shutdown-graceful-period` | duration | `30s` | Graceful shutdown period when receiving a shutdown signal after which the server is forcibly exited. |
| `shutdown-wait-before-period` | duration | `0s` | Wait period when receiving a shutdown signal before actually starting a graceful shutdown. Useful for allowing propagation of Endpoint updates in Kubernetes. |
| `sso.domain` | string | | The domain that the session cookies should be set for, usually the second-level domain name (e.g. `example.com`). |
| `sso.enabled` | boolean | `false` | Enable single sign-on mode; one server acting as the OIDC Relying Party, and N proxies. The proxies delegate most endpoint operations to the server, and only implements a reverse proxy that reads the user's session data from the shared store. |
| `sso.mode` | string | `server` | The SSO mode for this instance. Must be one of `server` or `proxy`. |
| `sso.server-default-redirect-url` | string | | The URL that the SSO server should redirect to by default if a given redirect query parameter is invalid. |
| `sso.server-url` | string | | The URL used by the proxy to point to the SSO server instance. |
| `sso.session-cookie-name` | string | | Session cookie name. Must be the same across all SSO Servers and Proxies that should share sessions. |
| `upstream-host` | string | `127.0.0.1:8080` | Address of upstream host. |
| `upstream-ip` | string | | IP of upstream host. Overrides `upstream-host` if set. |
| `upstream-port` | int | | Port of upstream host. Overrides `upstream-host` if set. |
| `upstream-include-id-token` | boolean | `false` | Include ID token in upstream requests in 'X-Wonderwall-Id-Token' header. |
Boolean flags are by default set to `false` unless noted otherwise.

View File

@@ -24,16 +24,17 @@ type Config struct {
ShutdownWaitBeforePeriod time.Duration `json:"shutdown-wait-before-period"`
Version string `json:"version"`
AutoLogin bool `json:"auto-login"`
AutoLoginIgnorePaths []string `json:"auto-login-ignore-paths"`
Cookie Cookie `json:"cookie"`
EncryptionKey string `json:"encryption-key"`
Ingresses []string `json:"ingress"`
LegacyCookie bool `json:"legacy-cookie"`
UpstreamAccessLogs bool `json:"upstream-access-logs"`
UpstreamHost string `json:"upstream-host"`
UpstreamIP string `json:"upstream-ip"`
UpstreamPort int `json:"upstream-port"`
AutoLogin bool `json:"auto-login"`
AutoLoginIgnorePaths []string `json:"auto-login-ignore-paths"`
Cookie Cookie `json:"cookie"`
EncryptionKey string `json:"encryption-key"`
Ingresses []string `json:"ingress"`
LegacyCookie bool `json:"legacy-cookie"`
UpstreamAccessLogs bool `json:"upstream-access-logs"`
UpstreamHost string `json:"upstream-host"`
UpstreamIP string `json:"upstream-ip"`
UpstreamPort int `json:"upstream-port"`
UpstreamIncludeIdToken bool `json:"upstream-include-id-token"`
OpenTelemetry OpenTelemetry `json:"otel"`
OpenID OpenID `json:"openid"`
@@ -50,13 +51,14 @@ const (
ShutdownGracefulPeriod = "shutdown-graceful-period"
ShutdownWaitBeforePeriod = "shutdown-wait-before-period"
AutoLogin = "auto-login"
AutoLoginIgnorePaths = "auto-login-ignore-paths"
Ingress = "ingress"
UpstreamAccessLogs = "upstream-access-logs"
UpstreamHost = "upstream-host"
UpstreamIP = "upstream-ip"
UpstreamPort = "upstream-port"
AutoLogin = "auto-login"
AutoLoginIgnorePaths = "auto-login-ignore-paths"
Ingress = "ingress"
UpstreamAccessLogs = "upstream-access-logs"
UpstreamHost = "upstream-host"
UpstreamIP = "upstream-ip"
UpstreamPort = "upstream-port"
UpstreamIncludeIdToken = "upstream-include-id-token"
)
var logger = log.WithField("logger", "wonderwall.config")
@@ -78,6 +80,7 @@ func Initialize() (*Config, error) {
flag.String(UpstreamHost, "127.0.0.1:8080", "Address of upstream host.")
flag.String(UpstreamIP, "", "IP of upstream host. Overrides 'upstream-host' if set.")
flag.Int(UpstreamPort, 0, "Port of upstream host. Overrides 'upstream-host' if set.")
flag.Bool(UpstreamIncludeIdToken, false, "Include ID token in upstream requests in 'X-Wonderwall-Id-Token' header.")
cookieFlags()
openidFlags()

View File

@@ -82,7 +82,7 @@ func NewStandalone(
Ingresses: ingresses,
Redirect: url.NewStandaloneRedirect(),
SessionManager: sessionManager,
UpstreamProxy: NewUpstreamProxy(upstream, cfg.UpstreamAccessLogs),
UpstreamProxy: NewUpstreamProxy(upstream, cfg.UpstreamAccessLogs, cfg.UpstreamIncludeIdToken),
}, nil
}

View File

@@ -69,7 +69,7 @@ func NewSSOProxy(cfg *config.Config, crypter crypto.Crypter) (*SSOProxy, error)
SSOServerURL: serverURL,
SSOServerReverseProxy: NewReverseProxy(serverURL, false),
SessionReader: sessionReader,
UpstreamProxy: NewUpstreamProxy(upstream, cfg.UpstreamAccessLogs),
UpstreamProxy: NewUpstreamProxy(upstream, cfg.UpstreamAccessLogs, cfg.UpstreamIncludeIdToken),
}, nil
}

View File

@@ -29,11 +29,13 @@ type ReverseProxySource interface {
type ReverseProxy struct {
*httputil.ReverseProxy
EnableAccessLogs bool
IncludeIdToken bool
}
func NewUpstreamProxy(upstream *urllib.URL, enableAccessLogs bool) *ReverseProxy {
func NewUpstreamProxy(upstream *urllib.URL, enableAccessLogs bool, includeIdToken bool) *ReverseProxy {
rp := NewReverseProxy(upstream, true)
rp.EnableAccessLogs = enableAccessLogs
rp.IncludeIdToken = includeIdToken
return rp
}
@@ -68,6 +70,11 @@ func NewReverseProxy(upstream *urllib.URL, preserveInboundHostHeader bool) *Reve
if ok {
r.Out.Header.Set("authorization", "Bearer "+accessToken)
}
idToken, ok := mw.IdTokenFrom(r.In.Context())
if ok {
r.Out.Header.Set("X-Wonderwall-Id-Token", idToken)
}
},
Transport: server.DefaultTransport(),
}
@@ -117,6 +124,10 @@ func (rp *ReverseProxy) Handler(src ReverseProxySource, w http.ResponseWriter, r
if isAuthenticated {
ctx = mw.WithAccessToken(ctx, accessToken)
if rp.IncludeIdToken {
idToken := sess.IDToken()
ctx = mw.WithIdToken(ctx, idToken)
}
if rp.EnableAccessLogs && isRelevantAccessLog(r) {
logger.Info("default: authenticated request")

View File

@@ -437,4 +437,45 @@ func TestReverseProxy(t *testing.T) {
}...)
assertUpstreamOKResponse(t, resp)
})
t.Run("request should not include idToken by default", func(t *testing.T) {
cfg := mock.Config()
cfg.UpstreamHost = up.URL.Host
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
up.SetIdentityProvider(idp)
rpClient := idp.RelyingPartyClient()
// acquire session
login(t, rpClient, idp)
up.requestCallback = func(r *http.Request) {
assert.Empty(t, r.Header.Get("x-wonderwall-id-token"))
}
resp := get(t, rpClient, idp.RelyingPartyServer.URL)
assertUpstreamOKResponse(t, resp)
})
t.Run("request should include idToken", func(t *testing.T) {
cfg := mock.Config()
cfg.UpstreamHost = up.URL.Host
cfg.UpstreamIncludeIdToken = true
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
up.SetIdentityProvider(idp)
rpClient := idp.RelyingPartyClient()
// acquire session
login(t, rpClient, idp)
up.requestCallback = func(r *http.Request) {
assert.NotEmpty(t, r.Header.Get("x-wonderwall-id-token"))
}
resp := get(t, rpClient, idp.RelyingPartyServer.URL)
assertUpstreamOKResponse(t, resp)
})
}

View File

@@ -11,6 +11,7 @@ type contextKey string
const (
ctxAccessToken = contextKey("AccessToken")
ctxIdToken = contextKey("IdToken")
ctxIngress = contextKey("Ingress")
ctxPath = contextKey("Path")
)
@@ -24,6 +25,15 @@ func WithAccessToken(ctx context.Context, accessToken string) context.Context {
return context.WithValue(ctx, ctxAccessToken, accessToken)
}
func IdTokenFrom(ctx context.Context) (string, bool) {
idToken, ok := ctx.Value(ctxIdToken).(string)
return idToken, ok
}
func WithIdToken(ctx context.Context, idToken string) context.Context {
return context.WithValue(ctx, ctxIdToken, idToken)
}
func IngressFrom(ctx context.Context) (ingress.Ingress, bool) {
i, ok := ctx.Value(ctxIngress).(ingress.Ingress)
return i, ok