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
+
+
+
+
+
+
+
Plain
+
+
Plain
+
PKCE
+
PKCE (S256)
+
+
+ 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.
+
+