feat: oidc support OIDC_LOGOUT_URL

This commit is contained in:
yusing 2025-01-24 00:13:45 +08:00
parent 7dd00d2424
commit 648fd23a57
10 changed files with 65 additions and 22 deletions

View file

@ -46,7 +46,7 @@ func NewHandler(cfg config.ConfigInstance) http.Handler {
}
})
mux.HandleFunc("GET,POST", "/v1/auth/callback", defaultAuth.LoginCallbackHandler)
mux.HandleFunc("GET,POST", "/v1/auth/logout", auth.LogoutCallbackHandler(defaultAuth))
mux.HandleFunc("GET,POST", "/v1/auth/logout", defaultAuth.LogoutCallbackHandler)
} else {
mux.HandleFunc("GET", "/v1/auth/check", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)

View file

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"time"
@ -23,6 +24,7 @@ type OIDCProvider struct {
oauthConfig *oauth2.Config
oidcProvider *oidc.Provider
oidcVerifier *oidc.IDTokenVerifier
oidcLogoutURL *url.URL
allowedUsers []string
allowedGroups []string
isMiddleware bool
@ -35,11 +37,20 @@ const (
OIDCLogoutPath = "/auth/logout"
)
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL, logoutURL string, allowedUsers, allowedGroups []string) (*OIDCProvider, error) {
if len(allowedUsers)+len(allowedGroups) == 0 {
return nil, errors.New("OIDC users, groups, or both must not be empty")
}
var logout *url.URL
var err error
if logoutURL != "" {
logout, err = url.Parse(logoutURL)
if err != nil {
return nil, fmt.Errorf("failed to parse logout URL: %w", err)
}
}
provider, err := oidc.NewProvider(context.Background(), issuerURL)
if err != nil {
return nil, fmt.Errorf("failed to initialize OIDC provider: %w", err)
@ -57,6 +68,7 @@ func NewOIDCProvider(issuerURL, clientID, clientSecret, redirectURL string, allo
oidcVerifier: provider.Verifier(&oidc.Config{
ClientID: clientID,
}),
oidcLogoutURL: logout,
allowedUsers: allowedUsers,
allowedGroups: allowedGroups,
}, nil
@ -69,6 +81,7 @@ func NewOIDCProviderFromEnv() (*OIDCProvider, error) {
common.OIDCClientID,
common.OIDCClientSecret,
common.OIDCRedirectURL,
common.OIDCLogoutURL,
common.OIDCAllowedUsers,
common.OIDCAllowedGroups,
)
@ -222,6 +235,25 @@ func (auth *OIDCProvider) LoginCallbackHandler(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}
func (auth *OIDCProvider) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
if auth.oidcLogoutURL == nil {
DefaultLogoutCallbackHandler(auth, w, r)
return
}
token, err := r.Cookie(auth.TokenCookieName())
if err != nil {
U.HandleErr(w, r, E.New("missing token cookie"), http.StatusBadRequest)
return
}
clearTokenCookie(w, r, auth.TokenCookieName())
logoutURL := *auth.oidcLogoutURL
logoutURL.Query().Add("id_token_hint", token.Value)
http.Redirect(w, r, logoutURL.String(), http.StatusFound)
}
// handleTestCallback handles OIDC callback in test environment.
func (auth *OIDCProvider) handleTestCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(CookieOauthState)

View file

@ -115,7 +115,7 @@ func setupProvider(t *testing.T) *provider {
}
}
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint
// buildRSAJWK is a helper to construct a minimal JWK for the JWKS endpoint.
func buildRSAJWK(t *testing.T, pub *rsa.PublicKey, kid string) map[string]any {
t.Helper()
@ -257,17 +257,13 @@ func TestInitOIDC(t *testing.T) {
clientID string
clientSecret string
redirectURL string
logoutURL string
allowedUsers []string
allowedGroups []string
wantErr bool
}{
{
name: "Fail - Empty configuration",
issuerURL: "",
clientID: "",
clientSecret: "",
redirectURL: "",
allowedUsers: nil,
wantErr: true,
},
{
@ -288,6 +284,17 @@ func TestInitOIDC(t *testing.T) {
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
{
name: "Success - Valid configuration with users, groups and logout URL",
issuerURL: server.URL,
clientID: "client_id",
clientSecret: "client_secret",
redirectURL: "https://example.com/callback",
logoutURL: "https://example.com/logout",
allowedUsers: []string{"user1", "user2"},
allowedGroups: []string{"group1", "group2"},
wantErr: false,
},
{
name: "Fail - No allowed users or allowed groups",
issuerURL: "https://example.com",
@ -300,7 +307,7 @@ func TestInitOIDC(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.allowedUsers, tt.allowedGroups)
_, err := NewOIDCProvider(tt.issuerURL, tt.clientID, tt.clientSecret, tt.redirectURL, tt.logoutURL, tt.allowedUsers, tt.allowedGroups)
if (err != nil) != tt.wantErr {
t.Errorf("InitOIDC() error = %v, wantErr %v", err, tt.wantErr)
}

View file

@ -9,4 +9,5 @@ type Provider interface {
CheckToken(r *http.Request) error
RedirectLoginPage(w http.ResponseWriter, r *http.Request)
LoginCallbackHandler(w http.ResponseWriter, r *http.Request)
LogoutCallbackHandler(w http.ResponseWriter, r *http.Request)
}

View file

@ -128,6 +128,10 @@ func (auth *UserPassAuth) LoginCallbackHandler(w http.ResponseWriter, r *http.Re
w.WriteHeader(http.StatusOK)
}
func (auth *UserPassAuth) LogoutCallbackHandler(w http.ResponseWriter, r *http.Request) {
DefaultLogoutCallbackHandler(auth, w, r)
}
func (auth *UserPassAuth) validatePassword(user, pass string) error {
if user != auth.username {
return ErrInvalidUsername.Subject(user)

View file

@ -62,9 +62,8 @@ func clearTokenCookie(w http.ResponseWriter, r *http.Request, name string) {
})
}
func LogoutCallbackHandler(auth Provider) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// DefaultLogoutCallbackHandler clears the token cookie and redirects to the login page..
func DefaultLogoutCallbackHandler(auth Provider, w http.ResponseWriter, r *http.Request) {
clearTokenCookie(w, r, auth.TokenCookieName())
auth.RedirectLoginPage(w, r)
}
}

View file

@ -51,6 +51,7 @@ var (
// OIDC Configuration.
OIDCIssuerURL = GetEnvString("OIDC_ISSUER_URL", "")
OIDCLogoutURL = GetEnvString("OIDC_LOGOUT_URL", "")
OIDCClientID = GetEnvString("OIDC_CLIENT_ID", "")
OIDCClientSecret = GetEnvString("OIDC_CLIENT_SECRET", "")
OIDCRedirectURL = GetEnvString("OIDC_REDIRECT_URL", "")

View file

@ -13,7 +13,6 @@ type oidcMiddleware struct {
auth auth.Provider
authMux *http.ServeMux
logoutHandler http.HandlerFunc
}
var OIDC = NewMiddleware[oidcMiddleware]()
@ -41,7 +40,6 @@ func (amw *oidcMiddleware) finalize() error {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
})
amw.authMux.HandleFunc("/", authProvider.RedirectLoginPage)
amw.logoutHandler = auth.LogoutCallbackHandler(authProvider)
amw.auth = authProvider
return nil
}
@ -52,7 +50,7 @@ func (amw *oidcMiddleware) before(w http.ResponseWriter, r *http.Request) (proce
return false
}
if r.URL.Path == auth.OIDCLogoutPath {
amw.logoutHandler(w, r)
amw.auth.LogoutCallbackHandler(w, r)
return false
}
return true

View file

@ -130,7 +130,6 @@ func TestApplyLabel(t *testing.T) {
ExpectEqual(t, b.Container.StopSignal, "SIGTERM")
ExpectEqual(t, a.Homepage.Show, true)
ExpectEqual(t, a.Homepage.Hide, false)
ExpectEqual(t, a.Homepage.Icon.Value, "png/adguard-home.png")
ExpectEqual(t, a.Homepage.Icon.Extra.FileType, "png")
ExpectEqual(t, a.Homepage.Icon.Extra.Name, "adguard-home")

View file

@ -92,6 +92,8 @@ GoDoxy v0.9.0 expected changes
- `GODOXY_OIDC_ISSUER_URL` e.g.:
- Pocket ID: `https://pocker-id.yourdomain.com`
- Authentik: `https://authentik.yourdomain.com/application/o/<application_slug>/` **The ending slash is required**
- `GODOXY_OIDC_LOGOUT_URL` _(if your issuer supports it, e.g.)_
- Authentik: `https://authentik.yourdomain.com/application/o/<application_slug>/end-session`
- `GODOXY_OIDC_CLIENT_ID`
- `GODOXY_OIDC_CLIENT_SECRET`
- `GODOXY_OIDC_REDIRECT_URL`