From e67429726719061de25c5b406491552720c44e1a Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:08:37 -0400 Subject: [PATCH 01/12] Fix https://github.com/tinyauthapp/tinyauth/issues/859 --- internal/service/oauth_service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/service/oauth_service.go b/internal/service/oauth_service.go index 0def31431..dc0b7c080 100644 --- a/internal/service/oauth_service.go +++ b/internal/service/oauth_service.go @@ -26,6 +26,7 @@ func NewOAuthService(config model.OAuthServiceConfig, id string, ctx context.Con Transport: &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: config.Insecure, + MinVersion: tls.VersionTLS12, }, }, } From 59c7e1f9e746877b1c7ae4dae60317adeda1075e Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:17:16 -0400 Subject: [PATCH 02/12] Remove OIDC public key loading and storage logic Simplify OIDCService by removing the publicKey field and all logic for loading or decoding the public key from file. Now, the service only checks for the existence of the public key file and generates it from the private key if missing. This streamlines initialization and reduces redundant state. Fix and assisted with usage of Copilot AI. --- internal/service/oidc_service.go | 37 ++++---------------------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index b263cc66a..fda618732 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -2,7 +2,6 @@ package service import ( "context" - "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -121,7 +120,6 @@ type OIDCService struct { clients map[string]model.OIDCClientConfig privateKey *rsa.PrivateKey - publicKey crypto.PublicKey issuer string } @@ -194,17 +192,9 @@ func NewOIDCService( } } - var publicKey crypto.PublicKey - - fpublicKey, err := os.ReadFile(config.OIDC.PublicKeyPath) - - if err != nil && !errors.Is(err, os.ErrNotExist) { - return nil, fmt.Errorf("failed to read public key: %w", err) - } - + _, err = os.Stat(config.OIDC.PublicKeyPath) if errors.Is(err, os.ErrNotExist) { - publicKey = privateKey.Public() - der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey)) + der := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) if der == nil { return nil, errors.New("failed to marshal public key") } @@ -217,26 +207,8 @@ func NewOIDCService( if err != nil { return nil, err } - } else { - block, _ := pem.Decode(fpublicKey) - if block == nil { - return nil, errors.New("failed to decode public key") - } - log.App.Trace().Str("type", block.Type).Msg("Loaded public key") - switch block.Type { - case "RSA PUBLIC KEY": - publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse public key: %w", err) - } - case "PUBLIC KEY": - publicKey, err = x509.ParsePKIXPublicKey(block.Bytes) - if err != nil { - return nil, fmt.Errorf("failed to parse public key: %w", err) - } - default: - return nil, fmt.Errorf("unsupported public key type: %s", block.Type) - } + } else if err != nil { + return nil, fmt.Errorf("failed to check public key: %w", err) } // We will reorganize the client into a map with the client ID as the key @@ -271,7 +243,6 @@ func NewOIDCService( clients: clients, privateKey: privateKey, - publicKey: publicKey, issuer: issuer, } From 514fee1c2ce82472ecd642718700510014cc90c4 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:24:15 -0400 Subject: [PATCH 03/12] Always write public key to file in OIDC service Removed the check for existing public key file and now always marshal and write the public key to the specified path. Error handling for file writing is retained. Assisted with usage of Copilot AI. --- internal/service/oidc_service.go | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index fda618732..f94e128f9 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -192,23 +192,17 @@ func NewOIDCService( } } - _, err = os.Stat(config.OIDC.PublicKeyPath) - if errors.Is(err, os.ErrNotExist) { - der := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) - if der == nil { - return nil, errors.New("failed to marshal public key") - } - encoded := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: der, - }) - log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key") - err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644) - if err != nil { - return nil, err - } - } else if err != nil { - return nil, fmt.Errorf("failed to check public key: %w", err) + der := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) + if der == nil { + return nil, errors.New("failed to marshal public key") + } + encoded := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: der, + }) + err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644) + if err != nil { + return nil, err } // We will reorganize the client into a map with the client ID as the key From 6fe533a09c5d79c349ae27737f2a0dba9b5dcec8 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:31:15 -0400 Subject: [PATCH 04/12] Improve error message for missing OIDC client in Authorize Updated the Authorize function to use fmt.Errorf for constructing a more descriptive error message when a client is not found, including the missing client ID in the error details passed to authorizeError. --- internal/controller/oidc_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 142f0b401..32ea95c19 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -146,7 +146,7 @@ func (controller *OIDCController) Authorize(c *gin.Context) { client, ok := controller.oidc.GetClient(req.ClientID) if !ok { - controller.authorizeError(c, err, "Client not found", "The client ID is invalid", "", "", "") + controller.authorizeError(c, fmt.Errorf("client not found: %s", req.ClientID), "Client not found", "The client ID is invalid", "", "", "") return } From 0945e479181ebda95037dd3a38cc31ce140c4fe6 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:34:51 -0400 Subject: [PATCH 05/12] Update log message for token revocation failure Clarified the log message when failing to delete tokens by code hash, changing it from "Failed to delete code" to "Failed to revoke tokens for replayed code" for better error context. --- internal/controller/oidc_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 32ea95c19..e46e7e827 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -288,7 +288,7 @@ func (controller *OIDCController) Token(c *gin.Context) { entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID) if err != nil { if err := controller.oidc.DeleteTokenByCodeHash(c, controller.oidc.Hash(req.Code)); err != nil { - controller.log.App.Error().Err(err).Msg("Failed to delete code") + controller.log.App.Error().Err(err).Msg("Failed to revoke tokens for replayed code") } if errors.Is(err, service.ErrCodeNotFound) { controller.log.App.Warn().Msg("Code not found") From 032577702651e3e7a5fbb62ce32f9f9f624263c1 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:37:31 -0400 Subject: [PATCH 06/12] Refactor OAuth session cleanup for efficiency Refactored ensureOAuthSessionLimit to improve clarity and performance by sorting sessions by expiration and removing the oldest ones in bulk, replacing the previous iterative search and delete approach. --- internal/service/auth_service.go | 45 +++++++++++++++----------------- 1 file changed, 21 insertions(+), 24 deletions(-) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a721aa2bf..2b2dc0215 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -773,35 +773,32 @@ func (auth *AuthService) ensureOAuthSessionLimit() { auth.oauthMutex.Lock() defer auth.oauthMutex.Unlock() - if len(auth.oauthPendingSessions) >= MaxOAuthPendingSessions { - - cleanupIds := make([]string, 0, OAuthCleanupCount) + if len(auth.oauthPendingSessions) < MaxOAuthPendingSessions { + return + } - for range OAuthCleanupCount { - oldestId := "" - oldestTime := int64(0) + type entry struct { + id string + expiresAt int64 + } - for id, session := range auth.oauthPendingSessions { - if oldestTime == 0 { - oldestId = id - oldestTime = session.ExpiresAt.Unix() - continue - } - if slices.Contains(cleanupIds, id) { - continue - } - if session.ExpiresAt.Unix() < oldestTime { - oldestId = id - oldestTime = session.ExpiresAt.Unix() - } - } + entries := make([]entry, 0, len(auth.oauthPendingSessions)) + for id, session := range auth.oauthPendingSessions { + entries = append(entries, entry{id, session.ExpiresAt.Unix()}) + } - cleanupIds = append(cleanupIds, oldestId) + slices.SortFunc(entries, func(a, b entry) int { + if a.expiresAt < b.expiresAt { + return -1 } - - for _, id := range cleanupIds { - delete(auth.oauthPendingSessions, id) + if a.expiresAt > b.expiresAt { + return 1 } + return 0 + }) + + for _, e := range entries[:OAuthCleanupCount] { + delete(auth.oauthPendingSessions, e.id) } } From e2507e5d7582dd5e50a485195da1798311633c78 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:39:27 -0400 Subject: [PATCH 07/12] Update OIDC config: disable request param, remove algs Set RequestParameterSupported to false and remove RequestObjectSigningAlgValuesSupported from the OpenID Connect configuration to improve security and compliance. --- internal/controller/well_known_controller.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/controller/well_known_controller.go b/internal/controller/well_known_controller.go index 8c71d8904..8409db246 100644 --- a/internal/controller/well_known_controller.go +++ b/internal/controller/well_known_controller.go @@ -65,8 +65,7 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", - RequestParameterSupported: true, - RequestObjectSigningAlgValuesSupported: []string{"none"}, + RequestParameterSupported: false, }) } From 728c7de102927d59ef410d070b7ecd7359268c1b Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:40:46 -0400 Subject: [PATCH 08/12] Improve email name parsing in OAuth callback handler Refactor name generation to split email at the first '@' only, using strings.SplitN, and handle emails without '@' gracefully. This prevents index errors and makes the logic more robust. --- internal/controller/oauth_controller.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/controller/oauth_controller.go b/internal/controller/oauth_controller.go index 1aec73ae5..e72c09fdf 100644 --- a/internal/controller/oauth_controller.go +++ b/internal/controller/oauth_controller.go @@ -208,7 +208,12 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { name = user.Name } else { controller.log.App.Debug().Msg("No name from OAuth provider, generating from email") - name = fmt.Sprintf("%s (%s)", utils.Capitalize(strings.Split(user.Email, "@")[0]), strings.Split(user.Email, "@")[1]) + parts := strings.SplitN(user.Email, "@", 2) + if len(parts) == 2 { + name = fmt.Sprintf("%s (%s)", utils.Capitalize(parts[0]), parts[1]) + } else { + name = utils.Capitalize(user.Email) + } } var username string From 6988987e1339c0f4d8a1bb1e03dc875acea2d57c Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:42:20 -0400 Subject: [PATCH 09/12] Prevent redundant lockdown activation in AuthService Add checks to avoid re-entering lockdown if already active. Ensure proper cleanup by adjusting defer order and always canceling context. --- internal/service/auth_service.go | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 2b2dc0215..a808e82d9 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -804,12 +804,18 @@ func (auth *AuthService) ensureOAuthSessionLimit() { func (auth *AuthService) lockdownMode() { ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - auth.lockdownCtx = ctx - auth.lockdownCancelFunc = cancel auth.loginMutex.Lock() + if auth.lockdown != nil && auth.lockdown.Active { + auth.loginMutex.Unlock() + cancel() + return + } + + auth.lockdownCtx = ctx + auth.lockdownCancelFunc = cancel + auth.log.App.Warn().Msg("Too many failed login attempts, entering lockdown mode") auth.lockdown = &Lockdown{ @@ -822,10 +828,12 @@ func (auth *AuthService) lockdownMode() { auth.loginAttempts = make(map[string]*LoginAttempt) timer := time.NewTimer(time.Until(auth.lockdown.ActiveUntil)) - defer timer.Stop() auth.loginMutex.Unlock() + defer cancel() + defer timer.Stop() + select { case <-timer.C: // Timer expired, end lockdown From 06063e4cfefe9d6f32720efa38479ffc88174a2b Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:52:13 -0400 Subject: [PATCH 10/12] Update well-known controller test expectations Changed RequestParameterSupported to false and removed RequestObjectSigningAlgValuesSupported from expected values in TestWellKnownController. --- internal/controller/well_known_controller_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/controller/well_known_controller_test.go b/internal/controller/well_known_controller_test.go index e2323da27..63c6a5ed5 100644 --- a/internal/controller/well_known_controller_test.go +++ b/internal/controller/well_known_controller_test.go @@ -57,8 +57,7 @@ func TestWellKnownController(t *testing.T) { TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", - RequestParameterSupported: true, - RequestObjectSigningAlgValuesSupported: []string{"none"}, + RequestParameterSupported: false, } assert.Equal(t, expected, res) From dd427432a1f9e8580724876877a9b19e22b4c998 Mon Sep 17 00:00:00 2001 From: Dreddy <24421368+Dredsen@users.noreply.github.com> Date: Wed, 13 May 2026 15:59:35 -0400 Subject: [PATCH 11/12] Update internal/service/auth_service.go Avoid evicting sessions at the exact cap (off-by-one). Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- internal/service/auth_service.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index a808e82d9..925c2951f 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -773,7 +773,7 @@ func (auth *AuthService) ensureOAuthSessionLimit() { auth.oauthMutex.Lock() defer auth.oauthMutex.Unlock() - if len(auth.oauthPendingSessions) < MaxOAuthPendingSessions { + if len(auth.oauthPendingSessions) <= MaxOAuthPendingSessions { return } From ed4ccff802e1c402b700424edbbb40de3bed8173 Mon Sep 17 00:00:00 2001 From: Dredsen <24421368+Dredsen@users.noreply.github.com> Date: Fri, 15 May 2026 21:13:25 -0400 Subject: [PATCH 12/12] Address review: restore OIDC pubkey file loading and well-known config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - oidc_service.go: revert to loading public key from PublicKeyPath (parse PEM/PKIX) instead of deriving from privateKey.PublicKey. Lets operators supply their own public key; more predictable behavior. - well_known_controller.go: restore RequestParameterSupported=true and RequestObjectSigningAlgValuesSupported=["none"]. #864 is a false positive — frontend parses the JWT. - well_known_controller_test.go: restore matching expected values. --- internal/controller/well_known_controller.go | 3 +- .../controller/well_known_controller_test.go | 3 +- internal/service/oidc_service.go | 55 +++++++++++++++---- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/internal/controller/well_known_controller.go b/internal/controller/well_known_controller.go index 8409db246..8c71d8904 100644 --- a/internal/controller/well_known_controller.go +++ b/internal/controller/well_known_controller.go @@ -65,7 +65,8 @@ func (controller *WellKnownController) OpenIDConnectConfiguration(c *gin.Context TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", - RequestParameterSupported: false, + RequestParameterSupported: true, + RequestObjectSigningAlgValuesSupported: []string{"none"}, }) } diff --git a/internal/controller/well_known_controller_test.go b/internal/controller/well_known_controller_test.go index 63c6a5ed5..e2323da27 100644 --- a/internal/controller/well_known_controller_test.go +++ b/internal/controller/well_known_controller_test.go @@ -57,7 +57,8 @@ func TestWellKnownController(t *testing.T) { TokenEndpointAuthMethodsSupported: []string{"client_secret_basic", "client_secret_post"}, ClaimsSupported: []string{"sub", "updated_at", "name", "preferred_username", "email", "email_verified", "groups", "phone_number", "phone_number_verified", "address", "given_name", "family_name", "middle_name", "nickname", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale"}, ServiceDocumentation: "https://tinyauth.app/docs/guides/oidc", - RequestParameterSupported: false, + RequestParameterSupported: true, + RequestObjectSigningAlgValuesSupported: []string{"none"}, } assert.Equal(t, expected, res) diff --git a/internal/service/oidc_service.go b/internal/service/oidc_service.go index f94e128f9..b263cc66a 100644 --- a/internal/service/oidc_service.go +++ b/internal/service/oidc_service.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" @@ -120,6 +121,7 @@ type OIDCService struct { clients map[string]model.OIDCClientConfig privateKey *rsa.PrivateKey + publicKey crypto.PublicKey issuer string } @@ -192,17 +194,49 @@ func NewOIDCService( } } - der := x509.MarshalPKCS1PublicKey(&privateKey.PublicKey) - if der == nil { - return nil, errors.New("failed to marshal public key") + var publicKey crypto.PublicKey + + fpublicKey, err := os.ReadFile(config.OIDC.PublicKeyPath) + + if err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("failed to read public key: %w", err) } - encoded := pem.EncodeToMemory(&pem.Block{ - Type: "RSA PUBLIC KEY", - Bytes: der, - }) - err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644) - if err != nil { - return nil, err + + if errors.Is(err, os.ErrNotExist) { + publicKey = privateKey.Public() + der := x509.MarshalPKCS1PublicKey(publicKey.(*rsa.PublicKey)) + if der == nil { + return nil, errors.New("failed to marshal public key") + } + encoded := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PUBLIC KEY", + Bytes: der, + }) + log.App.Trace().Str("type", "RSA PUBLIC KEY").Msg("Generated public RSA key") + err = os.WriteFile(config.OIDC.PublicKeyPath, encoded, 0644) + if err != nil { + return nil, err + } + } else { + block, _ := pem.Decode(fpublicKey) + if block == nil { + return nil, errors.New("failed to decode public key") + } + log.App.Trace().Str("type", block.Type).Msg("Loaded public key") + switch block.Type { + case "RSA PUBLIC KEY": + publicKey, err = x509.ParsePKCS1PublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + case "PUBLIC KEY": + publicKey, err = x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse public key: %w", err) + } + default: + return nil, fmt.Errorf("unsupported public key type: %s", block.Type) + } } // We will reorganize the client into a map with the client ID as the key @@ -237,6 +271,7 @@ func NewOIDCService( clients: clients, privateKey: privateKey, + publicKey: publicKey, issuer: issuer, }