From 71bf844dc1733de9c8d5c5aab67fe24b54635ca1 Mon Sep 17 00:00:00 2001 From: kjagosz <63209438+kjagosz@users.noreply.github.com> Date: Mon, 20 Oct 2025 14:55:17 +0200 Subject: [PATCH 1/6] - Add PKCE support with S256 challenge method for OAuth2 (fixes #852) - Update UI for OAuth2 Code Challenge Method selection (closes #854 ) - Introduce OAuth2 configuration cache to optimize requests (fixes #852) - Fixes using DNS Challange for Custom ACME Server --- src/def.go | 1 + src/go.mod | 1 + src/go.sum | 2 + src/mod/auth/sso/oauth2/oauth2.go | 125 +++++++++++++++++++++++------- src/start.go | 5 +- src/web/components/sso.html | 21 +++++ src/web/snippet/acme.html | 6 +- 7 files changed, 130 insertions(+), 31 deletions(-) diff --git a/src/def.go b/src/def.go index 01a5909..a592568 100644 --- a/src/def.go +++ b/src/def.go @@ -93,6 +93,7 @@ var ( enableHighSpeedGeoIPLookup = flag.Bool("fastgeoip", false, "Enable high speed geoip lookup, require 1GB extra memory (Not recommend for low end devices)") allowWebFileManager = flag.Bool("webfm", true, "Enable web file manager for static web server root folder") enableAutoUpdate = flag.Bool("cfgupgrade", true, "Enable auto config upgrade if breaking change is detected") + oauth2ConfigurationCache = flag.Duration("oauth2cc", 60*time.Second, "Time in seconds to cache OAuth2 configuration, set to 0 to disable cache. Default: 60 seconds") /* Logging Configuration Flags */ enableLog = flag.Bool("enablelog", true, "Enable system wide logging, set to false for writing log to STDOUT only") diff --git a/src/go.mod b/src/go.mod index 6d354b2..6366b38 100644 --- a/src/go.mod +++ b/src/go.mod @@ -14,6 +14,7 @@ require ( github.com/gorilla/sessions v1.2.2 github.com/gorilla/websocket v1.5.1 github.com/grandcat/zeroconf v1.0.0 + github.com/jellydator/ttlcache/v3 v3.4.0 github.com/likexian/whois v1.15.1 github.com/microcosm-cc/bluemonday v1.0.26 github.com/monperrus/crawler-user-agents v1.1.0 diff --git a/src/go.sum b/src/go.sum index dd2019c..2ce1488 100644 --- a/src/go.sum +++ b/src/go.sum @@ -445,6 +445,8 @@ github.com/infobloxopen/infoblox-go-client/v2 v2.10.0/go.mod h1:NeNJpz09efw/edzq github.com/jarcoal/httpmock v1.0.8/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= diff --git a/src/mod/auth/sso/oauth2/oauth2.go b/src/mod/auth/sso/oauth2/oauth2.go index e06aec7..f48f28e 100644 --- a/src/mod/auth/sso/oauth2/oauth2.go +++ b/src/mod/auth/sso/oauth2/oauth2.go @@ -7,7 +7,9 @@ import ( "net/http" "net/url" "strings" + "time" + "github.com/jellydator/ttlcache/v3" "golang.org/x/oauth2" "imuslab.com/zoraxy/mod/database" "imuslab.com/zoraxy/mod/info/logger" @@ -15,15 +17,18 @@ import ( ) type OAuth2RouterOptions struct { - OAuth2ServerURL string //The URL of the OAuth 2.0 server server - OAuth2TokenURL string //The URL of the OAuth 2.0 token server - OAuth2ClientId string //The client id for OAuth 2.0 Application - OAuth2ClientSecret string //The client secret for OAuth 2.0 Application - OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server - OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint - OAuth2Scopes string //The scopes for OAuth 2.0 Application - Logger *logger.Logger - Database *database.Database + OAuth2ServerURL string //The URL of the OAuth 2.0 server server + OAuth2TokenURL string //The URL of the OAuth 2.0 token server + OAuth2ClientId string //The client id for OAuth 2.0 Application + OAuth2ClientSecret string //The client secret for OAuth 2.0 Application + OAuth2WellKnownUrl string //The well-known url for OAuth 2.0 server + OAuth2UserInfoUrl string //The URL of the OAuth 2.0 user info endpoint + OAuth2Scopes string //The scopes for OAuth 2.0 Application + OAuth2CodeChallengeMethod string //The authorization code challange method + Logger *logger.Logger + Database *database.Database + OAuth2ConfigCacheTTL *time.Duration + OAuth2ConfigCache *ttlcache.Cache[string, *oauth2.Config] } type OIDCDiscoveryDocument struct { @@ -57,11 +62,19 @@ func NewOAuth2Router(options *OAuth2RouterOptions) *OAuth2Router { options.Database.Read("oauth2", "oauth2ClientId", &options.OAuth2ClientId) options.Database.Read("oauth2", "oauth2ClientSecret", &options.OAuth2ClientSecret) options.Database.Read("oauth2", "oauth2UserInfoUrl", &options.OAuth2UserInfoUrl) + options.Database.Read("oauth2", "oauth2CodeChallengeMethod", &options.OAuth2CodeChallengeMethod) options.Database.Read("oauth2", "oauth2Scopes", &options.OAuth2Scopes) - return &OAuth2Router{ + ar := &OAuth2Router{ options: options, } + + options.OAuth2ConfigCache = ttlcache.New[string, *oauth2.Config]( + ttlcache.WithTTL[string, *oauth2.Config](*options.OAuth2ConfigCacheTTL), + ) + go options.OAuth2ConfigCache.Start() + + return ar } // HandleSetOAuth2Settings is the internal handler for setting the OAuth URL and HTTPS @@ -81,13 +94,14 @@ func (ar *OAuth2Router) HandleSetOAuth2Settings(w http.ResponseWriter, r *http.R func (ar *OAuth2Router) handleSetOAuthSettingsGET(w http.ResponseWriter, r *http.Request) { //Return the current settings js, _ := json.Marshal(map[string]interface{}{ - "oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl, - "oauth2ServerUrl": ar.options.OAuth2ServerURL, - "oauth2TokenUrl": ar.options.OAuth2TokenURL, - "oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl, - "oauth2Scopes": ar.options.OAuth2Scopes, - "oauth2ClientSecret": ar.options.OAuth2ClientSecret, - "oauth2ClientId": ar.options.OAuth2ClientId, + "oauth2WellKnownUrl": ar.options.OAuth2WellKnownUrl, + "oauth2ServerUrl": ar.options.OAuth2ServerURL, + "oauth2TokenUrl": ar.options.OAuth2TokenURL, + "oauth2UserInfoUrl": ar.options.OAuth2UserInfoUrl, + "oauth2Scopes": ar.options.OAuth2Scopes, + "oauth2ClientSecret": ar.options.OAuth2ClientSecret, + "oauth2ClientId": ar.options.OAuth2ClientId, + "oauth2CodeChallengeMethod": ar.options.OAuth2CodeChallengeMethod, }) utils.SendJSONResponse(w, string(js)) @@ -95,7 +109,7 @@ func (ar *OAuth2Router) handleSetOAuthSettingsGET(w http.ResponseWriter, r *http func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *http.Request) { //Update the settings - var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl string + var oauth2ServerUrl, oauth2TokenURL, oauth2Scopes, oauth2UserInfoUrl, oauth2CodeChallengeMethod string oauth2ClientId, err := utils.PostPara(r, "oauth2ClientId") if err != nil { @@ -109,6 +123,12 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt return } + oauth2CodeChallengeMethod, err = utils.PostPara(r, "oauth2CodeChallengeMethod") + if err != nil { + utils.SendErrorResponse(w, "oauth2CodeChallengeMethod not found") + return + } + oauth2WellKnownUrl, err := utils.PostPara(r, "oauth2WellKnownUrl") if err != nil { oauth2ServerUrl, err = utils.PostPara(r, "oauth2ServerUrl") @@ -146,6 +166,7 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt ar.options.OAuth2ClientId = oauth2ClientId ar.options.OAuth2ClientSecret = oauth2ClientSecret ar.options.OAuth2Scopes = oauth2Scopes + ar.options.OAuth2CodeChallengeMethod = oauth2CodeChallengeMethod //Write changes to database ar.options.Database.Write("oauth2", "oauth2WellKnownUrl", oauth2WellKnownUrl) @@ -155,6 +176,10 @@ func (ar *OAuth2Router) handleSetOAuthSettingsPOST(w http.ResponseWriter, r *htt ar.options.Database.Write("oauth2", "oauth2ClientId", oauth2ClientId) ar.options.Database.Write("oauth2", "oauth2ClientSecret", oauth2ClientSecret) ar.options.Database.Write("oauth2", "oauth2Scopes", oauth2Scopes) + ar.options.Database.Write("oauth2", "oauth2CodeChallengeMethod", oauth2CodeChallengeMethod) + + // Flush caches + ar.options.OAuth2ConfigCache.DeleteAll() utils.SendOK(w) } @@ -167,6 +192,7 @@ func (ar *OAuth2Router) handleSetOAuthSettingsDELETE(w http.ResponseWriter, r *h ar.options.OAuth2ClientId = "" ar.options.OAuth2ClientSecret = "" ar.options.OAuth2Scopes = "" + ar.options.OAuth2CodeChallengeMethod = "" ar.options.Database.Delete("oauth2", "oauth2WellKnownUrl") ar.options.Database.Delete("oauth2", "oauth2ServerUrl") @@ -175,6 +201,7 @@ func (ar *OAuth2Router) handleSetOAuthSettingsDELETE(w http.ResponseWriter, r *h ar.options.Database.Delete("oauth2", "oauth2ClientId") ar.options.Database.Delete("oauth2", "oauth2ClientSecret") ar.options.Database.Delete("oauth2", "oauth2Scopes") + ar.options.Database.Delete("oauth2", "oauth2CodeChallengeMethod") utils.SendOK(w) } @@ -189,12 +216,10 @@ func (ar *OAuth2Router) fetchOAuth2Configuration(config *oauth2.Config) (*oauth2 return nil, err } else { defer resp.Body.Close() - oidcDiscoveryDocument := OIDCDiscoveryDocument{} if err := json.NewDecoder(resp.Body).Decode(&oidcDiscoveryDocument); err != nil { return nil, err } - if len(config.Scopes) == 0 { config.Scopes = oidcDiscoveryDocument.ScopesSupported } @@ -241,14 +266,24 @@ func (ar *OAuth2Router) newOAuth2Conf(redirectUrl string) (*oauth2.Config, error func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) error { const callbackPrefix = "/internal/oauth2" const tokenCookie = "z-token" + const verifierCookie = "z-verifier" scheme := "http" if r.TLS != nil { scheme = "https" } + reqUrl := scheme + "://" + r.Host + r.RequestURI - oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix) - if err != nil { - ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err) + oauthConfigCache, status := ar.options.OAuth2ConfigCache.GetOrSetFunc(r.Host, func() *oauth2.Config { + oauthConfig, err := ar.newOAuth2Conf(scheme + "://" + r.Host + callbackPrefix) + if err != nil { + ar.options.Logger.PrintAndLog("OAuth2Router", "Failed to fetch OIDC configuration:", err) + return nil + } + return oauthConfig + }) + + oauthConfig := oauthConfigCache.Value() + if !status { w.WriteHeader(500) return errors.New("failed to fetch OIDC configuration") } @@ -263,26 +298,44 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) state := r.URL.Query().Get("state") if r.Method == http.MethodGet && strings.HasPrefix(r.RequestURI, callbackPrefix) && code != "" && state != "" { ctx := context.Background() - token, err := oauthConfig.Exchange(ctx, code) + var authCodeOptions []oauth2.AuthCodeOption + if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" { + verifierCookie, err := r.Cookie(verifierCookie) + if err != nil || verifierCookie.Value == "" { + ar.options.Logger.PrintAndLog("OAuth2Router", "Read OAuth2 verifier cookie failed", err) + w.WriteHeader(401) + return errors.New("unauthorized") + } + authCodeOptions = append(authCodeOptions, oauth2.VerifierOption(verifierCookie.Value)) + } + token, err := oauthConfig.Exchange(ctx, code, authCodeOptions...) if err != nil { ar.options.Logger.PrintAndLog("OAuth2", "Token exchange failed", err) w.WriteHeader(401) return errors.New("unauthorized") } - if !token.Valid() { ar.options.Logger.PrintAndLog("OAuth2", "Invalid token", err) w.WriteHeader(401) return errors.New("unauthorized") } - cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/"} + cookie := http.Cookie{Name: tokenCookie, Value: token.AccessToken, Path: "/", Expires: token.Expiry} if scheme == "https" { cookie.Secure = true cookie.SameSite = http.SameSiteLaxMode } w.Header().Add("Set-Cookie", cookie.String()) + if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" { + cookie := http.Cookie{Name: verifierCookie, Value: "", Path: "/", Expires: time.Now().Add(-time.Hour * 1)} + if scheme == "https" { + cookie.Secure = true + cookie.SameSite = http.SameSiteLaxMode + } + w.Header().Add("Set-Cookie", cookie.String()) + } + //Fix for #695 location := strings.TrimPrefix(state, "/internal/") //Check if the location starts with http:// or https://. if yes, this is full URL @@ -321,7 +374,25 @@ func (ar *OAuth2Router) HandleOAuth2Auth(w http.ResponseWriter, r *http.Request) } if unauthorized { state := url.QueryEscape(reqUrl) - url := oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + var url string + if ar.options.OAuth2CodeChallengeMethod == "PKCE" || ar.options.OAuth2CodeChallengeMethod == "PKCE_S256" { + cookie := http.Cookie{Name: verifierCookie, Value: oauth2.GenerateVerifier(), Path: "/", Expires: time.Now().Add(time.Hour * 1)} + if scheme == "https" { + cookie.Secure = true + cookie.SameSite = http.SameSiteLaxMode + } + + w.Header().Add("Set-Cookie", cookie.String()) + + if ar.options.OAuth2CodeChallengeMethod == "PKCE" { + url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("code_challenge", cookie.Value)) + } else { + url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cookie.Value)) + } + } else { + url = oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline) + } + http.Redirect(w, r, url, http.StatusFound) return errors.New("unauthorized") diff --git a/src/start.go b/src/start.go index b2fb0cf..ac1b84a 100644 --- a/src/start.go +++ b/src/start.go @@ -188,8 +188,9 @@ func startupSequence() { }) oauth2Router = oauth2.NewOAuth2Router(&oauth2.OAuth2RouterOptions{ - Logger: SystemWideLogger, - Database: sysdb, + Logger: SystemWideLogger, + Database: sysdb, + OAuth2ConfigCacheTTL: oauth2ConfigurationCache, }) //Create a statistic collector diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 7271844..7cf931d 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -109,6 +109,26 @@ Secret key of the OAuth2 application +
+ + + + Options:
+ Plain: No code challenge is used.
+ PKCE: Uses a code challenge for added security.
+ PKCE (S256): Uses a hashed code challenge (SHA-256) for maximum protection.
+ Note: PKCE (especially S256) is recommended for better security. +
+
@@ -299,6 +319,7 @@ $('#oauth2ClientId').val(data.oauth2ClientId); $('#oauth2ClientSecret').val(data.oauth2ClientSecret); $('#oauth2Scopes').val(data.oauth2Scopes); + $('[data-value="'+data.oauth2CodeChallengeMethod+'"]').click(); }, error: function(jqXHR, textStatus, errorThrown) { console.error('Error fetching SSO settings:', textStatus, errorThrown); diff --git a/src/web/snippet/acme.html b/src/web/snippet/acme.html index f1f8b5b..ec8377e 100644 --- a/src/web/snippet/acme.html +++ b/src/web/snippet/acme.html @@ -408,8 +408,10 @@ $("#kidInput").show(); $("#hmacInput").show(); $("#skipTLS").show(); - $("#dnsChallenge").hide(); - $(".dnsChallengeOnly").hide(); + $("#dnsChallenge").show(); + if ($("#useDnsChallenge")[0].checked){ + $(".dnsChallengeOnly").show(); + } } else if (this.value == "ZeroSSL") { $("#kidInput").show(); $("#hmacInput").show(); From 322e5239a8fb6efca8b4fd4198ac6a387cc683e2 Mon Sep 17 00:00:00 2001 From: Toby Chui Date: Mon, 20 Oct 2025 21:34:31 +0800 Subject: [PATCH 2/6] Update src/web/components/sso.html Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/web/components/sso.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/web/components/sso.html b/src/web/components/sso.html index 7cf931d..85a5ce1 100644 --- a/src/web/components/sso.html +++ b/src/web/components/sso.html @@ -110,7 +110,7 @@ Secret key of the OAuth2 application
- + + +
+ + + Time to cache OAuth2 configuration before refresh. Accepts Go time.Duration format (e.g. 1m, 10m, 1h). Defaults to 60s. +
+ @@ -319,6 +326,7 @@ $('#oauth2ClientId').val(data.oauth2ClientId); $('#oauth2ClientSecret').val(data.oauth2ClientSecret); $('#oauth2Scopes').val(data.oauth2Scopes); + $('#oauth2ConfigurationCacheTime').val(data.oauth2ConfigurationCacheTime); $('[data-value="'+data.oauth2CodeChallengeMethod+'"]').click(); }, error: function(jqXHR, textStatus, errorThrown) {