CVE-2026-33544
Description
Tinyauth is an authentication and authorization server. Prior to version 5.0.5, all three OAuth service implementations (GenericOAuthService, GithubOAuthService, GoogleOAuthService) store PKCE verifiers and access tokens as mutable struct fields on singleton instances shared across all concurrent requests. When two users initiate OAuth login for the same provider concurrently, a race condition between VerifyCode() and Userinfo() causes one user to receive a session with the other user's identity. This issue has been patched in version 5.0.5.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/steveiliop56/tinyauthGo | < 1.0.1-0.20260401140714-fc1d4f2082a5 | 1.0.1-0.20260401140714-fc1d4f2082a5 |
Affected products
1Patches
1f26c2171610drefactor: oauth flow (#726)
15 files changed · +519 −557
internal/bootstrap/app_bootstrap.go+16 −14 modified@@ -22,16 +22,17 @@ import ( type BootstrapApp struct { config config.Config context struct { - appUrl string - uuid string - cookieDomain string - sessionCookieName string - csrfCookieName string - redirectCookieName string - users []config.User - oauthProviders map[string]config.OAuthServiceConfig - configuredProviders []controller.Provider - oidcClients []config.OIDCClientConfig + appUrl string + uuid string + cookieDomain string + sessionCookieName string + csrfCookieName string + redirectCookieName string + oauthSessionCookieName string + users []config.User + oauthProviders map[string]config.OAuthServiceConfig + configuredProviders []controller.Provider + oidcClients []config.OIDCClientConfig } services Services } @@ -113,6 +114,7 @@ func (app *BootstrapApp) Setup() error { app.context.sessionCookieName = fmt.Sprintf("%s-%s", config.SessionCookieName, cookieId) app.context.csrfCookieName = fmt.Sprintf("%s-%s", config.CSRFCookieName, cookieId) app.context.redirectCookieName = fmt.Sprintf("%s-%s", config.RedirectCookieName, cookieId) + app.context.oauthSessionCookieName = fmt.Sprintf("%s-%s", config.OAuthSessionCookieName, cookieId) // Dumps tlog.App.Trace().Interface("config", app.config).Msg("Config dump") @@ -190,12 +192,12 @@ func (app *BootstrapApp) Setup() error { // Start db cleanup routine tlog.App.Debug().Msg("Starting database cleanup routine") - go app.dbCleanup(queries) + go app.dbCleanupRoutine(queries) // If analytics are not disabled, start heartbeat if app.config.Analytics.Enabled { tlog.App.Debug().Msg("Starting heartbeat routine") - go app.heartbeat() + go app.heartbeatRoutine() } // If we have an socket path, bind to it @@ -226,7 +228,7 @@ func (app *BootstrapApp) Setup() error { return nil } -func (app *BootstrapApp) heartbeat() { +func (app *BootstrapApp) heartbeatRoutine() { ticker := time.NewTicker(time.Duration(12) * time.Hour) defer ticker.Stop() @@ -280,7 +282,7 @@ func (app *BootstrapApp) heartbeat() { } } -func (app *BootstrapApp) dbCleanup(queries *repository.Queries) { +func (app *BootstrapApp) dbCleanupRoutine(queries *repository.Queries) { ticker := time.NewTicker(time.Duration(30) * time.Minute) defer ticker.Stop() ctx := context.Background()
internal/bootstrap/router_bootstrap.go+7 −6 modified@@ -77,12 +77,13 @@ func (app *BootstrapApp) setupRouter() (*gin.Engine, error) { contextController.SetupRoutes() oauthController := controller.NewOAuthController(controller.OAuthControllerConfig{ - AppURL: app.config.AppURL, - SecureCookie: app.config.Auth.SecureCookie, - CSRFCookieName: app.context.csrfCookieName, - RedirectCookieName: app.context.redirectCookieName, - CookieDomain: app.context.cookieDomain, - }, apiRouter, app.services.authService, app.services.oauthBrokerService) + AppURL: app.config.AppURL, + SecureCookie: app.config.Auth.SecureCookie, + CSRFCookieName: app.context.csrfCookieName, + RedirectCookieName: app.context.redirectCookieName, + CookieDomain: app.context.cookieDomain, + OAuthSessionCookieName: app.context.oauthSessionCookieName, + }, apiRouter, app.services.authService) oauthController.SetupRoutes()
internal/bootstrap/service_bootstrap.go+11 −11 modified@@ -58,6 +58,16 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er services.accessControlService = accessControlsService + oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders) + + err = oauthBrokerService.Init() + + if err != nil { + return Services{}, err + } + + services.oauthBrokerService = oauthBrokerService + authService := service.NewAuthService(service.AuthServiceConfig{ Users: app.context.users, OauthWhitelist: app.config.OAuth.Whitelist, @@ -70,7 +80,7 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er SessionCookieName: app.context.sessionCookieName, IP: app.config.Auth.IP, LDAPGroupsCacheTTL: app.config.Ldap.GroupCacheTTL, - }, dockerService, services.ldapService, queries) + }, dockerService, services.ldapService, queries, services.oauthBrokerService) err = authService.Init() @@ -80,16 +90,6 @@ func (app *BootstrapApp) initServices(queries *repository.Queries) (Services, er services.authService = authService - oauthBrokerService := service.NewOAuthBrokerService(app.context.oauthProviders) - - err = oauthBrokerService.Init() - - if err != nil { - return Services{}, err - } - - services.oauthBrokerService = oauthBrokerService - oidcService := service.NewOIDCService(service.OIDCServiceConfig{ Clients: app.config.OIDC.Clients, PrivateKeyPath: app.config.OIDC.PrivateKeyPath,
internal/config/config.go+1 −0 modified@@ -73,6 +73,7 @@ var BuildTimestamp = "0000-00-00T00:00:00Z" var SessionCookieName = "tinyauth-session" var CSRFCookieName = "tinyauth-csrf" var RedirectCookieName = "tinyauth-redirect" +var OAuthSessionCookieName = "tinyauth-oauth" // Main app config
internal/controller/oauth_controller.go+50 −36 modified@@ -21,26 +21,25 @@ type OAuthRequest struct { } type OAuthControllerConfig struct { - CSRFCookieName string - RedirectCookieName string - SecureCookie bool - AppURL string - CookieDomain string + CSRFCookieName string + OAuthSessionCookieName string + RedirectCookieName string + SecureCookie bool + AppURL string + CookieDomain string } type OAuthController struct { config OAuthControllerConfig router *gin.RouterGroup auth *service.AuthService - broker *service.OAuthBrokerService } -func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService, broker *service.OAuthBrokerService) *OAuthController { +func NewOAuthController(config OAuthControllerConfig, router *gin.RouterGroup, auth *service.AuthService) *OAuthController { return &OAuthController{ config: config, router: router, auth: auth, - broker: broker, } } @@ -63,21 +62,30 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { return } - service, exists := controller.broker.GetService(req.Provider) + sessionId, session, err := controller.auth.NewOAuthSession(req.Provider) - if !exists { - tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider) - c.JSON(404, gin.H{ - "status": 404, - "message": "Not Found", + if err != nil { + tlog.App.Error().Err(err).Msg("Failed to create OAuth session") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", + }) + return + } + + authUrl, err := controller.auth.GetOAuthURL(sessionId) + + if err != nil { + tlog.App.Error().Err(err).Msg("Failed to get OAuth URL") + c.JSON(500, gin.H{ + "status": 500, + "message": "Internal Server Error", }) return } - service.GenerateVerifier() - state := service.GenerateState() - authURL := service.GetAuthURL(state) - c.SetCookie(controller.config.CSRFCookieName, state, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + c.SetCookie(controller.config.OAuthSessionCookieName, sessionId, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + c.SetCookie(controller.config.CSRFCookieName, session.State, int(time.Hour.Seconds()), "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) redirectURI := c.Query("redirect_uri") isRedirectSafe := utils.IsRedirectSafe(redirectURI, controller.config.CookieDomain) @@ -95,7 +103,7 @@ func (controller *OAuthController) oauthURLHandler(c *gin.Context) { c.JSON(200, gin.H{ "status": 200, "message": "OK", - "url": authURL, + "url": authUrl, }) } @@ -112,6 +120,17 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { return } + sessionIdCookie, err := c.Cookie(controller.config.OAuthSessionCookieName) + + if err != nil { + tlog.App.Warn().Err(err).Msg("OAuth session cookie missing") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + + c.SetCookie(controller.config.OAuthSessionCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) + defer controller.auth.EndOAuthSession(sessionIdCookie) + state := c.Query("state") csrfCookie, err := c.Cookie(controller.config.CSRFCookieName) @@ -125,28 +144,15 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { c.SetCookie(controller.config.CSRFCookieName, "", -1, "/", fmt.Sprintf(".%s", controller.config.CookieDomain), controller.config.SecureCookie, true) code := c.Query("code") - service, exists := controller.broker.GetService(req.Provider) + _, err = controller.auth.GetOAuthToken(sessionIdCookie, code) - if !exists { - tlog.App.Warn().Msgf("OAuth provider not found: %s", req.Provider) - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) - return - } - - err = service.VerifyCode(code) if err != nil { - tlog.App.Error().Err(err).Msg("Failed to verify OAuth code") + tlog.App.Error().Err(err).Msg("Failed to exchange code for token") c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) return } - user, err := controller.broker.GetUser(req.Provider) - - if err != nil { - tlog.App.Error().Err(err).Msg("Failed to get user from OAuth provider") - c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) - return - } + user, err := controller.auth.GetOAuthUserinfo(sessionIdCookie) if user.Email == "" { tlog.App.Error().Msg("OAuth provider did not return an email") @@ -192,13 +198,21 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) { username = strings.Replace(user.Email, "@", "_", 1) } + service, err := controller.auth.GetOAuthService(sessionIdCookie) + + if err != nil { + tlog.App.Error().Err(err).Msg("Failed to get OAuth service for session") + c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/error", controller.config.AppURL)) + return + } + sessionCookie := repository.Session{ Username: username, Name: name, Email: user.Email, Provider: req.Provider, OAuthGroups: utils.CoalesceToString(user.Groups), - OAuthName: service.GetName(), + OAuthName: service.Name(), OAuthSub: user.Sub, }
internal/controller/proxy_controller_test.go+1 −1 modified@@ -85,7 +85,7 @@ func setupProxyController(t *testing.T, middlewares []gin.HandlerFunc) (*gin.Eng LoginTimeout: 300, LoginMaxRetries: 3, SessionCookieName: "tinyauth-session", - }, dockerService, nil, queries) + }, dockerService, nil, queries, &service.OAuthBrokerService{}) // Controller ctrl := controller.NewProxyController(controller.ProxyControllerConfig{
internal/controller/user_controller_test.go+1 −1 modified@@ -71,7 +71,7 @@ func setupUserController(t *testing.T, middlewares *[]gin.HandlerFunc) (*gin.Eng LoginTimeout: 300, LoginMaxRetries: 3, SessionCookieName: "tinyauth-session", - }, nil, nil, queries) + }, nil, nil, queries, &service.OAuthBrokerService{}) // Controller ctrl := controller.NewUserController(controller.UserControllerConfig{
internal/service/auth_service.go+208 −15 modified@@ -17,8 +17,21 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" + "golang.org/x/exp/slices" + "golang.org/x/oauth2" ) +const MaxOAuthPendingSessions = 256 +const OAuthCleanupCount = 16 + +type OAuthPendingSession struct { + State string + Verifier string + Token *oauth2.Token + Service *OAuthServiceImpl + ExpiresAt time.Time +} + type LdapGroupsCache struct { Groups []string Expires time.Time @@ -45,28 +58,34 @@ type AuthServiceConfig struct { } type AuthService struct { - config AuthServiceConfig - docker *DockerService - loginAttempts map[string]*LoginAttempt - ldapGroupsCache map[string]*LdapGroupsCache - loginMutex sync.RWMutex - ldapGroupsMutex sync.RWMutex - ldap *LdapService - queries *repository.Queries + config AuthServiceConfig + docker *DockerService + loginAttempts map[string]*LoginAttempt + ldapGroupsCache map[string]*LdapGroupsCache + oauthPendingSessions map[string]*OAuthPendingSession + oauthMutex sync.RWMutex + loginMutex sync.RWMutex + ldapGroupsMutex sync.RWMutex + ldap *LdapService + queries *repository.Queries + oauthBroker *OAuthBrokerService } -func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries) *AuthService { +func NewAuthService(config AuthServiceConfig, docker *DockerService, ldap *LdapService, queries *repository.Queries, oauthBroker *OAuthBrokerService) *AuthService { return &AuthService{ - config: config, - docker: docker, - loginAttempts: make(map[string]*LoginAttempt), - ldapGroupsCache: make(map[string]*LdapGroupsCache), - ldap: ldap, - queries: queries, + config: config, + docker: docker, + loginAttempts: make(map[string]*LoginAttempt), + ldapGroupsCache: make(map[string]*LdapGroupsCache), + oauthPendingSessions: make(map[string]*OAuthPendingSession), + ldap: ldap, + queries: queries, + oauthBroker: oauthBroker, } } func (auth *AuthService) Init() error { + go auth.CleanupOAuthSessionsRoutine() return nil } @@ -553,3 +572,177 @@ func (auth *AuthService) IsBypassedIP(acls config.AppIP, ip string) bool { tlog.App.Debug().Str("ip", ip).Msg("IP not in bypass list, continuing with authentication") return false } + +func (auth *AuthService) NewOAuthSession(serviceName string) (string, OAuthPendingSession, error) { + auth.ensureOAuthSessionLimit() + + service, ok := auth.oauthBroker.GetService(serviceName) + + if !ok { + return "", OAuthPendingSession{}, fmt.Errorf("oauth service not found: %s", serviceName) + } + + sessionId, err := uuid.NewRandom() + + if err != nil { + return "", OAuthPendingSession{}, fmt.Errorf("failed to generate session ID: %w", err) + } + + state := service.NewRandom() + verifier := service.NewRandom() + + session := OAuthPendingSession{ + State: state, + Verifier: verifier, + Service: &service, + ExpiresAt: time.Now().Add(1 * time.Hour), + } + + auth.oauthMutex.Lock() + auth.oauthPendingSessions[sessionId.String()] = &session + auth.oauthMutex.Unlock() + + return sessionId.String(), session, nil +} + +func (auth *AuthService) GetOAuthURL(sessionId string) (string, error) { + session, err := auth.getOAuthPendingSession(sessionId) + + if err != nil { + return "", err + } + + return (*session.Service).GetAuthURL(session.State, session.Verifier), nil +} + +func (auth *AuthService) GetOAuthToken(sessionId string, code string) (*oauth2.Token, error) { + session, err := auth.getOAuthPendingSession(sessionId) + + if err != nil { + return nil, err + } + + token, err := (*session.Service).GetToken(code, session.Verifier) + + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + + auth.oauthMutex.Lock() + session.Token = token + auth.oauthMutex.Unlock() + + return token, nil +} + +func (auth *AuthService) GetOAuthUserinfo(sessionId string) (config.Claims, error) { + session, err := auth.getOAuthPendingSession(sessionId) + + if err != nil { + return config.Claims{}, err + } + + if session.Token == nil { + return config.Claims{}, fmt.Errorf("oauth token not found for session: %s", sessionId) + } + + userinfo, err := (*session.Service).GetUserinfo(session.Token) + + if err != nil { + return config.Claims{}, fmt.Errorf("failed to get userinfo: %w", err) + } + + return userinfo, nil +} + +func (auth *AuthService) GetOAuthService(sessionId string) (OAuthServiceImpl, error) { + session, err := auth.getOAuthPendingSession(sessionId) + + if err != nil { + return nil, err + } + + return *session.Service, nil +} + +func (auth *AuthService) EndOAuthSession(sessionId string) { + auth.oauthMutex.Lock() + delete(auth.oauthPendingSessions, sessionId) + auth.oauthMutex.Unlock() +} + +func (auth *AuthService) CleanupOAuthSessionsRoutine() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + + for range ticker.C { + auth.oauthMutex.Lock() + + now := time.Now() + + for sessionId, session := range auth.oauthPendingSessions { + if now.After(session.ExpiresAt) { + delete(auth.oauthPendingSessions, sessionId) + } + } + + auth.oauthMutex.Unlock() + } +} + +func (auth *AuthService) getOAuthPendingSession(sessionId string) (*OAuthPendingSession, error) { + auth.ensureOAuthSessionLimit() + + auth.oauthMutex.RLock() + session, exists := auth.oauthPendingSessions[sessionId] + auth.oauthMutex.RUnlock() + + if !exists { + return &OAuthPendingSession{}, fmt.Errorf("oauth session not found: %s", sessionId) + } + + if time.Now().After(session.ExpiresAt) { + auth.oauthMutex.Lock() + delete(auth.oauthPendingSessions, sessionId) + auth.oauthMutex.Unlock() + return &OAuthPendingSession{}, fmt.Errorf("oauth session expired: %s", sessionId) + } + + return session, nil +} + +func (auth *AuthService) ensureOAuthSessionLimit() { + auth.oauthMutex.Lock() + defer auth.oauthMutex.Unlock() + + if len(auth.oauthPendingSessions) >= MaxOAuthPendingSessions { + + cleanupIds := make([]string, 0, OAuthCleanupCount) + + for range OAuthCleanupCount { + oldestId := "" + oldestTime := int64(0) + + 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() + } + } + + cleanupIds = append(cleanupIds, oldestId) + } + + for _, id := range cleanupIds { + delete(auth.oauthPendingSessions, id) + } + } +}
internal/service/generic_oauth_service.go+0 −132 removed@@ -1,132 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "crypto/tls" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/steveiliop56/tinyauth/internal/config" - "github.com/steveiliop56/tinyauth/internal/utils/tlog" - - "golang.org/x/oauth2" -) - -type GenericOAuthService struct { - config oauth2.Config - context context.Context - token *oauth2.Token - verifier string - insecureSkipVerify bool - userinfoUrl string - name string -} - -func NewGenericOAuthService(config config.OAuthServiceConfig) *GenericOAuthService { - return &GenericOAuthService{ - config: oauth2.Config{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - RedirectURL: config.RedirectURL, - Scopes: config.Scopes, - Endpoint: oauth2.Endpoint{ - AuthURL: config.AuthURL, - TokenURL: config.TokenURL, - }, - }, - insecureSkipVerify: config.Insecure, - userinfoUrl: config.UserinfoURL, - name: config.Name, - } -} - -func (generic *GenericOAuthService) Init() error { - transport := &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: generic.insecureSkipVerify, - MinVersion: tls.VersionTLS12, - }, - } - - httpClient := &http.Client{ - Transport: transport, - Timeout: 30 * time.Second, - } - - ctx := context.Background() - - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - - generic.context = ctx - return nil -} - -func (generic *GenericOAuthService) GenerateState() string { - b := make([]byte, 128) - _, err := rand.Read(b) - if err != nil { - return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) - } - state := base64.RawURLEncoding.EncodeToString(b) - return state -} - -func (generic *GenericOAuthService) GenerateVerifier() string { - verifier := oauth2.GenerateVerifier() - generic.verifier = verifier - return verifier -} - -func (generic *GenericOAuthService) GetAuthURL(state string) string { - return generic.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(generic.verifier)) -} - -func (generic *GenericOAuthService) VerifyCode(code string) error { - token, err := generic.config.Exchange(generic.context, code, oauth2.VerifierOption(generic.verifier)) - - if err != nil { - return err - } - - generic.token = token - return nil -} - -func (generic *GenericOAuthService) Userinfo() (config.Claims, error) { - var user config.Claims - - client := generic.config.Client(generic.context, generic.token) - - res, err := client.Get(generic.userinfoUrl) - if err != nil { - return user, err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return user, fmt.Errorf("request failed with status: %s", res.Status) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - tlog.App.Trace().Str("body", string(body)).Msg("Userinfo response body") - - err = json.Unmarshal(body, &user) - if err != nil { - return user, err - } - - return user, nil -} - -func (generic *GenericOAuthService) GetName() string { - return generic.name -}
internal/service/github_oauth_service.go+0 −184 removed@@ -1,184 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "strconv" - "time" - - "github.com/steveiliop56/tinyauth/internal/config" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/endpoints" -) - -var GithubOAuthScopes = []string{"user:email", "read:user"} - -type GithubEmailResponse []struct { - Email string `json:"email"` - Primary bool `json:"primary"` -} - -type GithubUserInfoResponse struct { - Login string `json:"login"` - Name string `json:"name"` - ID int `json:"id"` -} - -type GithubOAuthService struct { - config oauth2.Config - context context.Context - token *oauth2.Token - verifier string - name string -} - -func NewGithubOAuthService(config config.OAuthServiceConfig) *GithubOAuthService { - return &GithubOAuthService{ - config: oauth2.Config{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - RedirectURL: config.RedirectURL, - Scopes: GithubOAuthScopes, - Endpoint: endpoints.GitHub, - }, - name: config.Name, - } -} - -func (github *GithubOAuthService) Init() error { - httpClient := &http.Client{ - Timeout: 30 * time.Second, - } - ctx := context.Background() - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - github.context = ctx - return nil -} - -func (github *GithubOAuthService) GenerateState() string { - b := make([]byte, 128) - _, err := rand.Read(b) - if err != nil { - return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) - } - state := base64.RawURLEncoding.EncodeToString(b) - return state -} - -func (github *GithubOAuthService) GenerateVerifier() string { - verifier := oauth2.GenerateVerifier() - github.verifier = verifier - return verifier -} - -func (github *GithubOAuthService) GetAuthURL(state string) string { - return github.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(github.verifier)) -} - -func (github *GithubOAuthService) VerifyCode(code string) error { - token, err := github.config.Exchange(github.context, code, oauth2.VerifierOption(github.verifier)) - - if err != nil { - return err - } - - github.token = token - return nil -} - -func (github *GithubOAuthService) Userinfo() (config.Claims, error) { - var user config.Claims - - client := github.config.Client(github.context, github.token) - - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) - if err != nil { - return user, err - } - - req.Header.Set("Accept", "application/vnd.github+json") - - res, err := client.Do(req) - if err != nil { - return user, err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return user, fmt.Errorf("request failed with status: %s", res.Status) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return user, err - } - - var userInfo GithubUserInfoResponse - - err = json.Unmarshal(body, &userInfo) - if err != nil { - return user, err - } - - req, err = http.NewRequest("GET", "https://api.github.com/user/emails", nil) - if err != nil { - return user, err - } - - req.Header.Set("Accept", "application/vnd.github+json") - - res, err = client.Do(req) - if err != nil { - return user, err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return user, fmt.Errorf("request failed with status: %s", res.Status) - } - - body, err = io.ReadAll(res.Body) - if err != nil { - return user, err - } - - var emails GithubEmailResponse - - err = json.Unmarshal(body, &emails) - if err != nil { - return user, err - } - - for _, email := range emails { - if email.Primary { - user.Email = email.Email - break - } - } - - if len(emails) == 0 { - return user, errors.New("no emails found") - } - - // Use first available email if no primary email was found - if user.Email == "" { - user.Email = emails[0].Email - } - - user.PreferredUsername = userInfo.Login - user.Name = userInfo.Name - user.Sub = strconv.Itoa(userInfo.ID) - - return user, nil -} - -func (github *GithubOAuthService) GetName() string { - return github.name -}
internal/service/google_oauth_service.go+0 −116 removed@@ -1,116 +0,0 @@ -package service - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - "time" - - "github.com/steveiliop56/tinyauth/internal/config" - - "golang.org/x/oauth2" - "golang.org/x/oauth2/endpoints" -) - -var GoogleOAuthScopes = []string{"openid", "email", "profile"} - -type GoogleOAuthService struct { - config oauth2.Config - context context.Context - token *oauth2.Token - verifier string - name string -} - -func NewGoogleOAuthService(config config.OAuthServiceConfig) *GoogleOAuthService { - return &GoogleOAuthService{ - config: oauth2.Config{ - ClientID: config.ClientID, - ClientSecret: config.ClientSecret, - RedirectURL: config.RedirectURL, - Scopes: GoogleOAuthScopes, - Endpoint: endpoints.Google, - }, - name: config.Name, - } -} - -func (google *GoogleOAuthService) Init() error { - httpClient := &http.Client{ - Timeout: 30 * time.Second, - } - ctx := context.Background() - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) - google.context = ctx - return nil -} - -func (oauth *GoogleOAuthService) GenerateState() string { - b := make([]byte, 128) - _, err := rand.Read(b) - if err != nil { - return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "state-%d", time.Now().UnixNano())) - } - state := base64.RawURLEncoding.EncodeToString(b) - return state -} - -func (google *GoogleOAuthService) GenerateVerifier() string { - verifier := oauth2.GenerateVerifier() - google.verifier = verifier - return verifier -} - -func (google *GoogleOAuthService) GetAuthURL(state string) string { - return google.config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(google.verifier)) -} - -func (google *GoogleOAuthService) VerifyCode(code string) error { - token, err := google.config.Exchange(google.context, code, oauth2.VerifierOption(google.verifier)) - - if err != nil { - return err - } - - google.token = token - return nil -} - -func (google *GoogleOAuthService) Userinfo() (config.Claims, error) { - var user config.Claims - - client := google.config.Client(google.context, google.token) - - res, err := client.Get("https://openidconnect.googleapis.com/v1/userinfo") - if err != nil { - return config.Claims{}, err - } - defer res.Body.Close() - - if res.StatusCode < 200 || res.StatusCode >= 300 { - return user, fmt.Errorf("request failed with status: %s", res.Status) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - return config.Claims{}, err - } - - err = json.Unmarshal(body, &user) - if err != nil { - return config.Claims{}, err - } - - user.PreferredUsername = strings.SplitN(user.Email, "@", 2)[0] - - return user, nil -} - -func (google *GoogleOAuthService) GetName() string { - return google.name -}
internal/service/oauth_broker_service.go+21 −41 modified@@ -1,60 +1,48 @@ package service import ( - "errors" - "github.com/steveiliop56/tinyauth/internal/config" "github.com/steveiliop56/tinyauth/internal/utils/tlog" "golang.org/x/exp/slices" + "golang.org/x/oauth2" ) -type OAuthService interface { - Init() error - GenerateState() string - GenerateVerifier() string - GetAuthURL(state string) string - VerifyCode(code string) error - Userinfo() (config.Claims, error) - GetName() string +type OAuthServiceImpl interface { + Name() string + NewRandom() string + GetAuthURL(state string, verifier string) string + GetToken(code string, verifier string) (*oauth2.Token, error) + GetUserinfo(token *oauth2.Token) (config.Claims, error) } type OAuthBrokerService struct { - services map[string]OAuthService + services map[string]OAuthServiceImpl configs map[string]config.OAuthServiceConfig } +var presets = map[string]func(config config.OAuthServiceConfig) *OAuthService{ + "github": newGitHubOAuthService, + "google": newGoogleOAuthService, +} + func NewOAuthBrokerService(configs map[string]config.OAuthServiceConfig) *OAuthBrokerService { return &OAuthBrokerService{ - services: make(map[string]OAuthService), + services: make(map[string]OAuthServiceImpl), configs: configs, } } func (broker *OAuthBrokerService) Init() error { for name, cfg := range broker.configs { - switch name { - case "github": - service := NewGithubOAuthService(cfg) - broker.services[name] = service - case "google": - service := NewGoogleOAuthService(cfg) - broker.services[name] = service - default: - service := NewGenericOAuthService(cfg) - broker.services[name] = service - } - } - - for name, service := range broker.services { - err := service.Init() - if err != nil { - tlog.App.Error().Err(err).Msgf("Failed to initialize OAuth service: %s", name) - return err + if presetFunc, exists := presets[name]; exists { + broker.services[name] = presetFunc(cfg) + tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from preset") + } else { + broker.services[name] = NewOAuthService(cfg) + tlog.App.Debug().Str("service", name).Msg("Loaded OAuth service from config") } - tlog.App.Info().Str("service", name).Msg("Initialized OAuth service") } - return nil } @@ -67,15 +55,7 @@ func (broker *OAuthBrokerService) GetConfiguredServices() []string { return services } -func (broker *OAuthBrokerService) GetService(name string) (OAuthService, bool) { +func (broker *OAuthBrokerService) GetService(name string) (OAuthServiceImpl, bool) { service, exists := broker.services[name] return service, exists } - -func (broker *OAuthBrokerService) GetUser(service string) (config.Claims, error) { - oauthService, exists := broker.services[service] - if !exists { - return config.Claims{}, errors.New("oauth service not found") - } - return oauthService.Userinfo() -}
internal/service/oauth_extractors.go+102 −0 added@@ -0,0 +1,102 @@ +package service + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + + "github.com/steveiliop56/tinyauth/internal/config" +) + +type GithubEmailResponse []struct { + Email string `json:"email"` + Primary bool `json:"primary"` +} + +type GithubUserInfoResponse struct { + Login string `json:"login"` + Name string `json:"name"` + ID int `json:"id"` +} + +func defaultExtractor(client *http.Client, url string) (config.Claims, error) { + return simpleReq[config.Claims](client, url, nil) +} + +func githubExtractor(client *http.Client, url string) (config.Claims, error) { + var user config.Claims + + userInfo, err := simpleReq[GithubUserInfoResponse](client, "https://api.github.com/user", map[string]string{ + "accept": "application/vnd.github+json", + }) + if err != nil { + return config.Claims{}, err + } + + userEmails, err := simpleReq[GithubEmailResponse](client, "https://api.github.com/user/emails", map[string]string{ + "accept": "application/vnd.github+json", + }) + if err != nil { + return config.Claims{}, err + } + + if len(userEmails) == 0 { + return user, errors.New("no emails found") + } + + for _, email := range userEmails { + if email.Primary { + user.Email = email.Email + break + } + } + + // Use first available email if no primary email was found + if user.Email == "" { + user.Email = userEmails[0].Email + } + + user.PreferredUsername = userInfo.Login + user.Name = userInfo.Name + user.Sub = strconv.Itoa(userInfo.ID) + + return user, nil +} + +func simpleReq[T any](client *http.Client, url string, headers map[string]string) (T, error) { + var decodedRes T + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return decodedRes, err + } + + for key, value := range headers { + req.Header.Add(key, value) + } + + res, err := client.Do(req) + if err != nil { + return decodedRes, err + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return decodedRes, fmt.Errorf("request failed with status: %s", res.Status) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + return decodedRes, err + } + + err = json.Unmarshal(body, &decodedRes) + if err != nil { + return decodedRes, err + } + + return decodedRes, nil +}
internal/service/oauth_presets.go+23 −0 added@@ -0,0 +1,23 @@ +package service + +import ( + "github.com/steveiliop56/tinyauth/internal/config" + "golang.org/x/oauth2/endpoints" +) + +func newGoogleOAuthService(config config.OAuthServiceConfig) *OAuthService { + scopes := []string{"openid", "email", "profile"} + config.Scopes = scopes + config.AuthURL = endpoints.Google.AuthURL + config.TokenURL = endpoints.Google.TokenURL + config.UserinfoURL = "https://openidconnect.googleapis.com/v1/userinfo" + return NewOAuthService(config) +} + +func newGitHubOAuthService(config config.OAuthServiceConfig) *OAuthService { + scopes := []string{"read:user", "user:email"} + config.Scopes = scopes + config.AuthURL = endpoints.GitHub.AuthURL + config.TokenURL = endpoints.GitHub.TokenURL + return NewOAuthService(config).WithUserinfoExtractor(githubExtractor) +}
internal/service/oauth_service.go+78 −0 added@@ -0,0 +1,78 @@ +package service + +import ( + "context" + "crypto/tls" + "net/http" + "time" + + "github.com/steveiliop56/tinyauth/internal/config" + "golang.org/x/oauth2" +) + +type UserinfoExtractor func(client *http.Client, url string) (config.Claims, error) + +type OAuthService struct { + serviceCfg config.OAuthServiceConfig + config *oauth2.Config + ctx context.Context + userinfoExtractor UserinfoExtractor +} + +func NewOAuthService(config config.OAuthServiceConfig) *OAuthService { + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: config.Insecure, + }, + }, + } + ctx := context.Background() + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + + return &OAuthService{ + serviceCfg: config, + config: &oauth2.Config{ + ClientID: config.ClientID, + ClientSecret: config.ClientSecret, + RedirectURL: config.RedirectURL, + Scopes: config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: config.AuthURL, + TokenURL: config.TokenURL, + }, + }, + ctx: ctx, + userinfoExtractor: defaultExtractor, + } +} + +func (s *OAuthService) WithUserinfoExtractor(extractor UserinfoExtractor) *OAuthService { + s.userinfoExtractor = extractor + return s +} + +func (s *OAuthService) Name() string { + return s.serviceCfg.Name +} + +func (s *OAuthService) NewRandom() string { + // The generate verifier function just creates a random string, + // so we can use it to generate a random state as well + random := oauth2.GenerateVerifier() + return random +} + +func (s *OAuthService) GetAuthURL(state string, verifier string) string { + return s.config.AuthCodeURL(state, oauth2.AccessTypeOnline, oauth2.S256ChallengeOption(verifier)) +} + +func (s *OAuthService) GetToken(code string, verifier string) (*oauth2.Token, error) { + return s.config.Exchange(s.ctx, code, oauth2.VerifierOption(verifier)) +} + +func (s *OAuthService) GetUserinfo(token *oauth2.Token) (config.Claims, error) { + client := oauth2.NewClient(s.ctx, oauth2.StaticTokenSource(token)) + return s.userinfoExtractor(client, s.serviceCfg.UserinfoURL) +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/steveiliop56/tinyauth/commit/f26c2171610d5c2dfbba2edb6ccd39490e349803nvdPatchWEB
- github.com/steveiliop56/tinyauth/security/advisories/GHSA-9q5m-jfc4-wc92nvdExploitMitigationVendor AdvisoryWEB
- github.com/advisories/GHSA-9q5m-jfc4-wc92ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-33544ghsaADVISORY
- github.com/steveiliop56/tinyauth/releases/tag/v5.0.5nvdProductRelease NotesWEB
News mentions
0No linked articles in our index yet.