Tinyauth's OIDC authorization codes are not bound to client on token exchange
Description
Tinyauth is an authentication and authorization server. Prior to 5.0.3, the OIDC token endpoint does not verify that the client exchanging an authorization code is the same client the code was issued to. A malicious OIDC client operator can exchange another client's authorization code using their own client credentials, obtaining tokens for users who never authorized their application. This violates RFC 6749 Section 4.1.3. This vulnerability is fixed in 5.0.3.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
CVE-2026-32245 is an OIDC authorization code substitution vulnerability in Tinyauth before 5.0.3, allowing a malicious client to exchange another client's authorization code.
Vulnerability
Overview
In Tinyauth versions prior to 5.0.3, the OIDC token endpoint fails to verify that the client exchanging an authorization code is the same client the code was issued to [1][3]. When an authorization code is created, the system correctly stores the ClientID alongside the code hash in the database. However, during token exchange, the handler retrieves the code entry and validates the redirect_uri, but never compares entry.ClientID against the requesting client's ID (creds.ClientID) [3]. The code proceeds directly to token generation, bypassing the intended client binding check.
Attack
Vector and Exploitation
An attacker must control an OIDC client registered with the same Tinyauth instance and have at least one overlapping redirect URI with the victim client, or be able to intercept the authorization code from the victim client's redirect (e.g., via referrer leak, browser history, or log access) [3]. The attacker initiates a normal user login, then authorizes with the victim client A to obtain an authorization code. Instead of using client A's credentials, the attacker submits the stolen authorization code to the token endpoint using their own client B's credentials, successfully exchanging the code for tokens belonging to the victim user [3].
Impact
This violates RFC 6749 Section 4.1.3, which requires that the authorization code be bound to the client to which it was issued [1][3]. A successful exploit allows a malicious OIDC client operator to obtain access and ID tokens for users who never authorized their application, potentially gaining unauthorized access to resources protected by the victim client.
Mitigation
The vulnerability is fixed in Tinyauth version 5.0.3 [1][2]. The fix adds the missing client ID validation during authorization code exchange, ensuring that the authorization code can only be exchanged by the client it was originally issued to [2]. Users should upgrade to 5.0.3 immediately; no workaround is available.
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/steveiliop56/tinyauthGo | < 1.0.1-20260311144920-9eb2d33064b7 | 1.0.1-20260311144920-9eb2d33064b7 |
Affected products
2- steveiliop56/tinyauthv5Range: < 5.0.3
Patches
1b2a1bfb1f532fix: validate client id on oidc token endpoint
5 files changed · +27 −54
internal/controller/oidc_controller.go+8 −1 modified@@ -270,7 +270,7 @@ func (controller *OIDCController) Token(c *gin.Context) { switch req.GrantType { case "authorization_code": - entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code)) + entry, err := controller.oidc.GetCodeEntry(c, controller.oidc.Hash(req.Code), client.ClientID) if err != nil { if errors.Is(err, service.ErrCodeNotFound) { tlog.App.Warn().Msg("Code not found") @@ -286,6 +286,13 @@ func (controller *OIDCController) Token(c *gin.Context) { }) return } + if errors.Is(err, service.ErrInvalidClient) { + tlog.App.Warn().Msg("Invalid client ID") + c.JSON(400, gin.H{ + "error": "invalid_client", + }) + return + } tlog.App.Warn().Err(err).Msg("Failed to get OIDC code entry") c.JSON(400, gin.H{ "error": "server_error",
internal/controller/proxy_controller.go+0 −5 modified@@ -185,11 +185,6 @@ func (controller *ProxyController) proxyHandler(c *gin.Context) { tlog.App.Trace().Interface("context", userContext).Msg("User context from request") - if userContext.IsBasicAuth && userContext.TotpEnabled { - tlog.App.Debug().Msg("User has TOTP enabled, denying basic auth access") - userContext.IsLoggedIn = false - } - if userContext.IsLoggedIn { userAllowed := controller.auth.IsUserAllowed(c, userContext, acls)
internal/controller/proxy_controller_test.go+9 −46 modified@@ -59,6 +59,11 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En Username: "testuser", Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", // test }, + { + Username: "totpuser", + Password: "$2a$10$ne6z693sTgzT3ePoQ05PgOecUHnBjM7sSNj6M.l5CLUP.f6NyCnt.", + TotpSecret: "foo", + }, }, OauthWhitelist: []string{}, SessionExpiry: 3600, @@ -79,9 +84,11 @@ func setupProxyController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.En return router, recorder, authService } +// TODO: Needs tests for context middleware + func TestProxyHandler(t *testing.T) { // Setup - router, recorder, authService := setupProxyController(t, nil) + router, recorder, _ := setupProxyController(t, nil) // Test invalid proxy req := httptest.NewRequest("GET", "/api/auth/invalidproxy", nil) @@ -144,21 +151,6 @@ func TestProxyHandler(t *testing.T) { assert.Equal(t, 401, recorder.Code) // Test logged in user - c := gin.CreateTestContextOnly(recorder, router) - - err := authService.CreateSessionCookie(c, &repository.Session{ - Username: "testuser", - Name: "testuser", - Email: "testuser@example.com", - Provider: "local", - TotpPending: false, - OAuthGroups: "", - }) - - assert.NilError(t, err) - - cookie := c.Writer.Header().Get("Set-Cookie") - router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ func(c *gin.Context) { c.Set("context", &config.UserContext{ @@ -177,44 +169,15 @@ func TestProxyHandler(t *testing.T) { }) req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("Cookie", cookie) req.Header.Set("X-Forwarded-Proto", "https") req.Header.Set("X-Forwarded-Host", "example.com") req.Header.Set("X-Forwarded-Uri", "/somepath") req.Header.Set("Accept", "text/html") - router.ServeHTTP(recorder, req) + router.ServeHTTP(recorder, req) assert.Equal(t, 200, recorder.Code) assert.Equal(t, "testuser", recorder.Header().Get("Remote-User")) assert.Equal(t, "testuser", recorder.Header().Get("Remote-Name")) assert.Equal(t, "testuser@example.com", recorder.Header().Get("Remote-Email")) - - // Ensure basic auth is disabled for TOTP enabled users - router, recorder, _ = setupProxyController(t, &[]gin.HandlerFunc{ - func(c *gin.Context) { - c.Set("context", &config.UserContext{ - Username: "testuser", - Name: "testuser", - Email: "testuser@example.com", - IsLoggedIn: true, - IsBasicAuth: true, - OAuth: false, - Provider: "local", - TotpPending: false, - OAuthGroups: "", - TotpEnabled: true, - }) - c.Next() - }, - }) - - req = httptest.NewRequest("GET", "/api/auth/traefik", nil) - req.Header.Set("X-Forwarded-Proto", "https") - req.Header.Set("X-Forwarded-Host", "example.com") - req.Header.Set("X-Forwarded-Uri", "/somepath") - req.SetBasicAuth("testuser", "test") - router.ServeHTTP(recorder, req) - - assert.Equal(t, 401, recorder.Code) }
internal/middleware/context_middleware.go+5 −1 modified@@ -182,13 +182,17 @@ func (m *ContextMiddleware) Middleware() gin.HandlerFunc { user := m.auth.GetLocalUser(basic.Username) + if user.TotpSecret != "" { + tlog.App.Debug().Msg("User with TOTP not allowed to login via basic auth") + return + } + c.Set("context", &config.UserContext{ Username: user.Username, Name: utils.Capitalize(user.Username), Email: utils.CompileUserEmail(user.Username, m.config.CookieDomain), Provider: "local", IsLoggedIn: true, - TotpEnabled: user.TotpSecret != "", IsBasicAuth: true, }) c.Next()
internal/service/oidc_service.go+5 −1 modified@@ -352,7 +352,7 @@ func (service *OIDCService) ValidateGrantType(grantType string) error { return nil } -func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string) (repository.OidcCode, error) { +func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string, clientId string) (repository.OidcCode, error) { oidcCode, err := service.queries.GetOidcCode(c, codeHash) if err != nil { @@ -374,6 +374,10 @@ func (service *OIDCService) GetCodeEntry(c *gin.Context, codeHash string) (repos return repository.OidcCode{}, ErrCodeExpired } + if oidcCode.ClientID != clientId { + return repository.OidcCode{}, ErrInvalidClient + } + return oidcCode, nil }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-xg2q-62g2-cvcmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-32245ghsaADVISORY
- github.com/steveiliop56/tinyauth/commit/b2a1bfb1f532e87f205fa3afa3fc9f148c53ab89ghsax_refsource_MISCWEB
- github.com/steveiliop56/tinyauth/releases/tag/v5.0.3ghsax_refsource_MISCWEB
- github.com/steveiliop56/tinyauth/security/advisories/GHSA-xg2q-62g2-cvcmghsax_refsource_CONFIRMWEB
- pkg.go.dev/vuln/GO-2026-4689ghsaWEB
News mentions
0No linked articles in our index yet.