feat: add handler for forward-auth

This commit is contained in:
Trong Huu Nguyen
2025-01-14 13:44:49 +01:00
parent c96e457675
commit 0258ce7cfd
9 changed files with 116 additions and 17 deletions
+1
View File
@@ -47,6 +47,7 @@ The following flags are available:
| `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. |
+33 -7
View File
@@ -6,13 +6,14 @@ Wonderwall exposes and owns these endpoints (which means they will never be prox
Endpoints that are available for use by applications:
| Path | Description | Notes |
|--------------------------------|----------------------------------------------------------------------|---------------------------------------------------|
| `GET /oauth2/login` | Initiates the OpenID Connect Authorization Code flow | |
| `GET /oauth2/logout` | Performs local logout and redirects the user to global/single-logout | |
| `GET /oauth2/logout/local` | Performs local logout only | Disabled when `openid.provider` is `idporten`. |
| `GET /oauth2/session` | Returns the current user's session metadata | |
| `POST /oauth2/session/refresh` | Refreshes the tokens for the user's session. | Requires the `session.refresh` flag to be enabled |
| Path | Description | Notes |
|-----------------------------------|----------------------------------------------------------------------|---------------------------------------------------|
| `GET /oauth2/login` | Initiates the OpenID Connect Authorization Code flow | |
| `GET /oauth2/logout` | Performs local logout and redirects the user to global/single-logout | |
| `GET /oauth2/logout/local` | Performs local logout only | Disabled when `openid.provider` is `idporten`. |
| `GET /oauth2/session` | Returns the current user's session metadata | |
| `POST /oauth2/session/refresh` | Refreshes the tokens for the user's session. | Requires the `session.refresh` flag to be enabled |
| `GET /oauth2/session/forwardauth` | Checks the user's session and refreshes it, if necessary. | |
## Endpoints for Identity Providers
@@ -232,3 +233,28 @@ of the tokens returned by the identity provider.
The cooldown period exists to limit the amount of refresh token requests that we send to the identity provider.
A refresh is only triggered if `tokens.refresh_cooldown` is `false`. Requests to the endpoint are idempotent while the cooldown is active.
---
### `/oauth2/session/forwardauth`
This endpoint only exists if the `session.forward-auth` flag is enabled.
The endpoint is intended for use in forward authentication scenarios, where a reverse proxy delegates authentication checks to Wonderwall.
The user's session is checked and refreshed, if necessary.
#### Request:
```
GET /oauth2/session/forwardauth
```
#### Response:
```
HTTP/2 204 No Content
```
The endpoint responds with a `HTTP 204 No Content` if the session is valid.
If the session is invalid (i.e. expired, inactive, or not found), the response is an `HTTP 401 Unauthorized`.
+3
View File
@@ -8,6 +8,7 @@ import (
)
type Session struct {
ForwardAuth bool `json:"forward-auth"`
Inactivity bool `json:"inactivity"`
InactivityTimeout time.Duration `json:"inactivity-timeout"`
MaxLifetime time.Duration `json:"max-lifetime"`
@@ -28,6 +29,7 @@ func (s Session) Validate() error {
}
const (
SessionForwardAuth = "session.forward-auth"
SessionInactivity = "session.inactivity"
SessionInactivityTimeout = "session.inactivity-timeout"
SessionMaxLifetime = "session.max-lifetime"
@@ -36,6 +38,7 @@ const (
)
func sessionFlags() {
flag.Bool(SessionForwardAuth, false, "Enable endpoint for forward authentication.")
flag.Bool(SessionInactivity, false, "Automatically expire user sessions if they have not refreshed their tokens within a given duration.")
flag.Duration(SessionInactivityTimeout, 30*time.Minute, "Inactivity timeout for user sessions.")
flag.Duration(SessionMaxLifetime, 10*time.Hour, "Max lifetime for user sessions.")
+23
View File
@@ -440,6 +440,29 @@ func (s *Standalone) sessionWriteMetadataResponse(w http.ResponseWriter, r *http
return json.NewEncoder(w).Encode(metadata)
}
func (s *Standalone) SessionForwardAuth(w http.ResponseWriter, r *http.Request) {
if !s.Config.Session.ForwardAuth {
http.NotFound(w, r)
return
}
_, err := s.SessionManager.GetOrRefresh(r)
if err != nil {
logger := mw.LogEntryFrom(r)
if errors.Is(err, session.ErrInvalidExternal) || errors.Is(err, session.ErrInvalid) || errors.Is(err, session.ErrNotFound) {
logger.Infof("session/forwardauth: %+v", err)
w.WriteHeader(http.StatusUnauthorized)
return
}
logger.Warnf("session/forwardauth: %+v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// Wildcard proxies all requests to an upstream server.
func (s *Standalone) Wildcard(w http.ResponseWriter, r *http.Request) {
s.UpstreamProxy.Handler(s, w, r)
+6
View File
@@ -190,6 +190,12 @@ func (s *SSOProxy) SessionRefresh(w http.ResponseWriter, r *http.Request) {
s.SSOServerReverseProxy.ServeHTTP(w, r)
}
func (s *SSOProxy) SessionForwardAuth(w http.ResponseWriter, r *http.Request) {
r.URL.Path = paths.OAuth2 + paths.Session + paths.ForwardAuth
removeMiddlewareHeaders(w)
s.SSOServerReverseProxy.ServeHTTP(w, r)
}
// Wildcard proxies all requests to an upstream server.
func (s *SSOProxy) Wildcard(w http.ResponseWriter, r *http.Request) {
s.UpstreamProxy.Handler(s, w, r)
+34
View File
@@ -521,6 +521,33 @@ func TestSession_WithRefreshAuto(t *testing.T) {
assert.Greater(t, data.Tokens.NextAutoRefreshInSeconds, int64(1))
}
func TestSessionForwardAuth(t *testing.T) {
cfg := mock.Config()
cfg.Session.ForwardAuth = true
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionForwardAuth(t, idp, rpClient)
assert.Equal(t, http.StatusUnauthorized, noSessionResp.StatusCode)
login(t, rpClient, idp)
resp := sessionForwardAuth(t, idp, rpClient)
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
}
func TestSessionForwardAuth_Disabled(t *testing.T) {
cfg := mock.Config()
cfg.Session.ForwardAuth = false
idp := mock.NewIdentityProvider(cfg)
defer idp.Close()
rpClient := idp.RelyingPartyClient()
noSessionResp := sessionForwardAuth(t, idp, rpClient)
assert.Equal(t, http.StatusNotFound, noSessionResp.StatusCode)
}
func TestPing(t *testing.T) {
cfg := mock.Config()
idp := mock.NewIdentityProvider(cfg)
@@ -693,6 +720,13 @@ func sessionRefresh(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Cli
return post(t, rpClient, sessionRefreshURL.String())
}
func sessionForwardAuth(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Client) response {
sessionForwardAuthURL, err := url.Parse(idp.RelyingPartyServer.URL + "/oauth2/session/forwardauth")
assert.NoError(t, err)
return get(t, rpClient, sessionForwardAuthURL.String())
}
func waitForRefreshCooldownTimer(t *testing.T, idp *mock.IdentityProvider, rpClient *http.Client) {
timeout := time.After(5 * time.Second)
ticker := time.Tick(500 * time.Millisecond)
+10 -9
View File
@@ -71,15 +71,16 @@ func (m *PrometheusMiddleware) Initialize(path, method string, code int) {
func (m *PrometheusMiddleware) Handler(next http.Handler) http.Handler {
relevantPaths := map[string]bool{
paths.OAuth2 + paths.Login: true,
paths.OAuth2 + paths.LoginCallback: true,
paths.OAuth2 + paths.Logout: true,
paths.OAuth2 + paths.LogoutCallback: true,
paths.OAuth2 + paths.LogoutFrontChannel: true,
paths.OAuth2 + paths.LogoutLocal: true,
paths.OAuth2 + paths.Ping: false,
paths.OAuth2 + paths.Session: true,
paths.OAuth2 + paths.Session + paths.Refresh: true,
paths.OAuth2 + paths.Login: true,
paths.OAuth2 + paths.LoginCallback: true,
paths.OAuth2 + paths.Logout: true,
paths.OAuth2 + paths.LogoutCallback: true,
paths.OAuth2 + paths.LogoutFrontChannel: true,
paths.OAuth2 + paths.LogoutLocal: true,
paths.OAuth2 + paths.Ping: false,
paths.OAuth2 + paths.Session: true,
paths.OAuth2 + paths.Session + paths.Refresh: true,
paths.OAuth2 + paths.Session + paths.ForwardAuth: true,
}
fn := func(w http.ResponseWriter, r *http.Request) {
+1
View File
@@ -11,4 +11,5 @@ const (
Ping = "/ping"
Refresh = "/refresh"
Session = "/session"
ForwardAuth = "/forwardauth"
)
+5 -1
View File
@@ -36,8 +36,11 @@ type Handlers interface {
LogoutLocal(http.ResponseWriter, *http.Request)
// Session returns metadata for the current user's session.
Session(http.ResponseWriter, *http.Request)
// SessionRefresh refreshes current user's session and returns the associated updated metadata.
// SessionRefresh forces a refresh of the current user's session and returns the associated updated metadata.
SessionRefresh(http.ResponseWriter, *http.Request)
// SessionForwardAuth checks the current user's session and refreshes it, if necessary.
// For use in forward authentication scenarios.
SessionForwardAuth(w http.ResponseWriter, r *http.Request)
// Wildcard handles all requests not matching the other handlers.
Wildcard(http.ResponseWriter, *http.Request)
}
@@ -107,6 +110,7 @@ func New(src Source, cfg *config.Config) chi.Router {
r.Get("/", src.Session)
r.Get(paths.Refresh, src.SessionRefresh)
r.Post(paths.Refresh, src.SessionRefresh)
r.Get(paths.ForwardAuth, src.SessionForwardAuth)
})
})
}