Skip to content
1 change: 1 addition & 0 deletions internal/config/redis.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

func InitRedis(cfg *Config) *redis.Client {
ctx := context.Background() // Local context variable


opt, err := redis.ParseURL(cfg.Redis.URL)
if err != nil {
Expand Down
13 changes: 8 additions & 5 deletions internal/handler/auth_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,12 +281,12 @@

// Get device information
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")

Check failure on line 284 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "User-Agent" 5 times.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYd&open=AZ6T_U0JdEQVRFx1HZYd&pullRequest=50

// Authenticate user
loginResp, err := h.authService.Login(&req, ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusUnauthorized, utils.ErrorResponse("Login failed", err))

Check failure on line 289 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Login failed" 3 times.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYf&open=AZ6T_U0JdEQVRFx1HZYf&pullRequest=50
return
}

Expand All @@ -294,7 +294,7 @@
// MaxAge is 7 days (matching refresh token)
c.SetCookie("auth_token", loginResp.AccessToken, 7*24*3600, "/", "", false, true)

c.JSON(http.StatusOK, utils.SuccessResponse("Login successful", loginResp))

Check failure on line 297 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "Login successful" 4 times.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYe&open=AZ6T_U0JdEQVRFx1HZYe&pullRequest=50
}

// RefreshToken handles refresh token requests with token rotation
Expand Down Expand Up @@ -443,6 +443,9 @@
return
}

currentSessionID, _ := c.Get("sessionID")
currentID, _ := currentSessionID.(string)

// Convert to response format
sessionResponses := make([]dto.SessionResponse, len(sessions))
for i, session := range sessions {
Expand All @@ -452,7 +455,7 @@
UserAgent: session.UserAgent,
CreatedAt: session.CreatedAt.Format("2006-01-02 15:04:05"),
ExpiresAt: session.ExpiresAt.Format("2006-01-02 15:04:05"),
IsCurrent: false, // TODO: Determine if this is the current session
IsCurrent: session.ID == currentID, // TODO: Determine if this is the current session

Check warning on line 458 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYk&open=AZ6T_U0JdEQVRFx1HZYk&pullRequest=50
}
}

Expand Down Expand Up @@ -503,7 +506,7 @@
}

// Store state in cookie for verification
c.SetCookie("oauth_state", state, 3600, "/", "", false, true) // Secure should be true in prod

Check warning on line 509 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that setting the "secure" flag to "false" is safe here.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYg&open=AZ6T_U0JdEQVRFx1HZYg&pullRequest=50

url := h.oauthService.GetGoogleAuthURL(state)
c.Redirect(http.StatusTemporaryRedirect, url)
Expand All @@ -525,7 +528,7 @@
}

// Clear cookie
c.SetCookie("oauth_state", "", -1, "/", "", false, true)

Check warning on line 531 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that setting the "secure" flag to "false" is safe here.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYh&open=AZ6T_U0JdEQVRFx1HZYh&pullRequest=50

// Exchange code
token, err := h.oauthService.ExchangeGoogleCode(c.Request.Context(), code)
Expand All @@ -552,7 +555,7 @@
// Login or Register
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")

loginResp, err := h.authService.LoginWithOAuth(email, oauthID, firstName, lastName, "google", ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, utils.ErrorResponse("Login failed", err))
Expand All @@ -577,7 +580,7 @@
return
}

c.SetCookie("oauth_state", state, 3600, "/", "", false, true)

Check warning on line 583 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that setting the "secure" flag to "false" is safe here.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYi&open=AZ6T_U0JdEQVRFx1HZYi&pullRequest=50

url := h.oauthService.GetGitHubAuthURL(state)
c.Redirect(http.StatusTemporaryRedirect, url)
Expand All @@ -597,7 +600,7 @@
return
}

c.SetCookie("oauth_state", "", -1, "/", "", false, true)

Check warning on line 603 in internal/handler/auth_handler.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Make sure that setting the "secure" flag to "false" is safe here.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U0JdEQVRFx1HZYj&open=AZ6T_U0JdEQVRFx1HZYj&pullRequest=50

token, err := h.oauthService.ExchangeGitHubCode(c.Request.Context(), code)
if err != nil {
Expand All @@ -612,7 +615,7 @@
}

email := userInfo["email"].(string)

// GitHub names are often one string "Name" or just login
firstName := ""
lastName := ""
Expand All @@ -625,12 +628,12 @@
} else {
firstName = userInfo["login"].(string)
}

oauthID := fmt.Sprintf("%.0f", userInfo["id"].(float64)) // GitHub ID is number

ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")

loginResp, err := h.authService.LoginWithOAuth(email, oauthID, firstName, lastName, "github", ipAddress, userAgent)
if err != nil {
c.JSON(http.StatusInternalServerError, utils.ErrorResponse("Login failed", err))
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/auth_handler_protected_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func TestAuthHandler_GetMe(t *testing.T) {
}

// Generate Token
token, _ := tokenService.GenerateAccessToken(user)
token, _ := tokenService.GenerateAccessToken(user, "test-session-id")

req, _ := http.NewRequest(http.MethodGet, "/api/auth/me", nil)
req.Header.Set("Authorization", "Bearer " + token)
Expand Down
164 changes: 161 additions & 3 deletions internal/handler/auth_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"testing"

"github.com/gin-gonic/gin"
"github.com/roshankumar0036singh/auth-server/internal/config"
"github.com/roshankumar0036singh/auth-server/internal/dto"
"github.com/roshankumar0036singh/auth-server/internal/handler"
"github.com/roshankumar0036singh/auth-server/internal/middleware"
"github.com/roshankumar0036singh/auth-server/internal/service"
"github.com/roshankumar0036singh/auth-server/internal/testutils"
"github.com/stretchr/testify/assert"
)
Expand All @@ -18,9 +21,9 @@ func SetupRouter(t *testing.T) (*gin.Engine, *handler.AuthHandler) {
authService, _, mr := testutils.SetupIntegrationTest(t)
// mock OAuth service or pass nil if not needed for these tests
authHandler := handler.NewAuthHandler(authService, nil)

t.Cleanup(func() { mr.Close() }) // Ensure mr is closed after tests in this Setup config

gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(gin.Recovery())
Expand All @@ -30,7 +33,6 @@ func SetupRouter(t *testing.T) (*gin.Engine, *handler.AuthHandler) {
return r, authHandler
}


func TestAuthHandler_Register(t *testing.T) {
r, h := SetupRouter(t)
r.POST("/api/auth/register", h.Register)
Expand Down Expand Up @@ -95,3 +97,159 @@ func TestAuthHandler_Login(t *testing.T) {
}

// TODO: Add tests for Protected Routes using middleware

func TestAuthHandler_GetSessions_CurrentSessionFlag(t *testing.T) {
authService, _, mr := testutils.SetupIntegrationTest(t)
defer mr.Close()

authHandler := handler.NewAuthHandler(authService, nil)

cfg := &config.Config{
JWT: config.JWTConfig{
AccessSecret: "secret",
RefreshSecret: "refresh-secret",
},
}
tokenService := service.NewTokenService(cfg)

gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.AuthMiddleware(tokenService))

r.GET("/api/auth/sessions", authHandler.GetSessions)

// Create user
regReq := &dto.RegisterRequest{
Email: "sessions@example.com",
Password: "Password123!",
FirstName: "Session",
LastName: "Test",
}

_, err := authService.Register(regReq)
assert.NoError(t, err)

// Create a session via login
loginResp, err := authService.Login(
&dto.LoginRequest{
Email: "sessions@example.com",
Password: "Password123!",
},
"127.0.0.1",
"test-agent",
)

assert.NoError(t, err)

claims, err := tokenService.ValidateAccessToken(loginResp.AccessToken)
assert.NoError(t, err)

expectedSessionID := claims.SessionID

// Call sessions endpoint using the access token
req, _ := http.NewRequest(
http.MethodGet,
"/api/auth/sessions",
nil,
)

req.Header.Set(
"Authorization",
"Bearer "+loginResp.AccessToken,
)

w := httptest.NewRecorder()
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var resp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)

data := resp["data"].([]interface{})

foundExpectedSession := false

for _, item := range data {
session := item.(map[string]interface{})

sessionID := session["id"].(string)
isCurrent := session["isCurrent"].(bool)

if sessionID == expectedSessionID {
assert.True(t, isCurrent, "expected session used by request token to be current")
foundExpectedSession = true
}
}

assert.True(t, foundExpectedSession, "expected session ID not found in response")

}

func TestAuthHandler_GetSessions_NoSessionIDInContext(t *testing.T) {
authService, _, mr := testutils.SetupIntegrationTest(t)
defer mr.Close()

authHandler := handler.NewAuthHandler(authService, nil)

gin.SetMode(gin.TestMode)
r := gin.New()

// Register user
regReq := &dto.RegisterRequest{
Email: "nosession@example.com",
Password: "Password123!",
FirstName: "No",
LastName: "Session",
}

user, err := authService.Register(regReq)
assert.NoError(t, err)

userID := user.ID

// Intentionally set only userID, not sessionID
r.GET("/api/auth/sessions", func(c *gin.Context) {
c.Set("userID", userID)
authHandler.GetSessions(c)
})

// Create a session
_, err = authService.Login(
&dto.LoginRequest{
Email: regReq.Email,
Password: regReq.Password,
},
"127.0.0.1",
"test-agent",
)
assert.NoError(t, err)

req, _ := http.NewRequest(
http.MethodGet,
"/api/auth/sessions",
nil,
)

w := httptest.NewRecorder()
r.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)

var resp map[string]interface{}
err = json.Unmarshal(w.Body.Bytes(), &resp)
assert.NoError(t, err)

data := resp["data"].([]interface{})

for _, item := range data {
session := item.(map[string]interface{})

assert.False(
t,
session["isCurrent"].(bool),
"expected no session to be marked current when sessionID is missing",
)
}
}
1 change: 1 addition & 0 deletions internal/middleware/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,5 @@ func setContextUser(c *gin.Context, claims *service.JWTClaims) {
c.Set("userID", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("sessionID", claims.SessionID)
}
32 changes: 28 additions & 4 deletions internal/repository/token_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@
// FindRefreshToken finds a refresh token by token string
func (r *TokenRepository) FindRefreshToken(tokenString string) (*models.RefreshToken, error) {
var token models.RefreshToken
if err := r.db.Where("token = ?", tokenString).First(&token).Error; err != nil {

Check failure on line 27 in internal/repository/token_repository.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "token = ?" 3 times.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U02dEQVRFx1HZYm&open=AZ6T_U02dEQVRFx1HZYm&pullRequest=50
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("refresh token not found")

Check failure on line 29 in internal/repository/token_repository.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "refresh token not found" 5 times.

See more on https://sonarcloud.io/project/issues?id=roshankumar0036singh_auth-server&issues=AZ6T_U02dEQVRFx1HZYl&open=AZ6T_U02dEQVRFx1HZYl&pullRequest=50
}
return nil, err
}
Expand Down Expand Up @@ -61,7 +61,7 @@
result := r.db.Model(&models.RefreshToken{}).
Where("token = ?", tokenString).
Update("is_revoked", true)

if result.Error != nil {
return result.Error
}
Expand All @@ -76,7 +76,7 @@
result := r.db.Model(&models.RefreshToken{}).
Where("id = ?", id).
Update("is_revoked", true)

if result.Error != nil {
return result.Error
}
Expand All @@ -97,7 +97,7 @@
func (r *TokenRepository) DeleteExpiredTokens() (int64, error) {
result := r.db.Where("expires_at < ?", time.Now()).
Delete(&models.RefreshToken{})

if result.Error != nil {
return 0, result.Error
}
Expand All @@ -109,7 +109,7 @@
cutoffTime := time.Now().Add(-olderThan)
result := r.db.Where("is_revoked = ? AND updated_at < ?", true, cutoffTime).
Delete(&models.RefreshToken{})

if result.Error != nil {
return 0, result.Error
}
Expand All @@ -124,3 +124,27 @@
Count(&count).Error
return count, err
}

func (r *TokenRepository) RotateRefreshToken(oldToken string, newToken *models.RefreshToken) error {

return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(newToken).Error; err != nil {
return err
}

result := tx.Model(&models.RefreshToken{}).
Where("token = ?", oldToken).
Update("is_revoked", true)

if result.Error != nil {
return result.Error
}

if result.RowsAffected == 0 {
return errors.New("refresh token not found")
}

return nil

})
}
Loading
Loading