diff --git a/cmd/laas/docs/docs.go b/cmd/laas/docs/docs.go index 6038f6f..e989260 100644 --- a/cmd/laas/docs/docs.go +++ b/cmd/laas/docs/docs.go @@ -3721,9 +3721,9 @@ const docTemplate = `{ "type": "string", "example": "your_access_token_here" }, - "expires_in": { - "type": "integer", - "example": 3600 + "expires_at": { + "type": "string", + "example": "2026-02-10T15:11:14Z" }, "refresh_token": { "type": "string", diff --git a/cmd/laas/docs/swagger.json b/cmd/laas/docs/swagger.json index 75197ad..de7d126 100644 --- a/cmd/laas/docs/swagger.json +++ b/cmd/laas/docs/swagger.json @@ -3714,9 +3714,9 @@ "type": "string", "example": "your_access_token_here" }, - "expires_in": { - "type": "integer", - "example": 3600 + "expires_at": { + "type": "string", + "example": "2026-02-10T15:11:14Z" }, "refresh_token": { "type": "string", diff --git a/cmd/laas/docs/swagger.yaml b/cmd/laas/docs/swagger.yaml index 357ec7d..b04b0d0 100644 --- a/cmd/laas/docs/swagger.yaml +++ b/cmd/laas/docs/swagger.yaml @@ -861,9 +861,9 @@ definitions: access_token: example: your_access_token_here type: string - expires_in: - example: 3600 - type: integer + expires_at: + example: "2026-02-10T15:11:14Z" + type: string refresh_token: example: your_refresh_token_here type: string diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 68ccf76..389031b 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -1085,12 +1085,14 @@ func generateToken(user models.User) (*models.Tokens, error) { UserEmail: user.UserEmail, UserLevel: user.UserLevel, } + + AccessTokenExpiresAt := now.Add(time.Hour * time.Duration(accessTokenLifespan)) // Build Access Token accessToken, err := jwt.NewBuilder(). Issuer(issuer). IssuedAt(now). NotBefore(now). - Expiration(now.Add(time.Hour*time.Duration(accessTokenLifespan))). + Expiration(AccessTokenExpiresAt). Claim("user", safeClaim). Build() if err != nil { @@ -1127,7 +1129,7 @@ func generateToken(user models.User) (*models.Tokens, error) { tokens := &models.Tokens{ AccessToken: string(signedAccessToken), RefreshToken: string(signedRefreshToken), - AccessTokenExpiresIn: int64(accessTokenLifespan * 3600), // in seconds + AccessTokenExpiresAt: AccessTokenExpiresAt.Format(time.RFC3339), } logger.LogInfo("generateToken completed successfully", zap.String("userID", user.Id.String())) return tokens, nil diff --git a/pkg/models/types.go b/pkg/models/types.go index 4fe810e..91f5b02 100644 --- a/pkg/models/types.go +++ b/pkg/models/types.go @@ -382,7 +382,7 @@ type ApiResponse[T any] struct { type Tokens struct { AccessToken string `json:"access_token" example:"your_access_token_here"` RefreshToken string `json:"refresh_token,omitempty" example:"your_refresh_token_here"` - AccessTokenExpiresIn int64 `json:"expires_in" example:"3600"` + AccessTokenExpiresAt string `json:"expires_at" example:"2026-02-10T15:11:14Z"` } type RefreshToken struct { diff --git a/tests/auth_test.go b/tests/auth_test.go index f8e66c9..da9f836 100644 --- a/tests/auth_test.go +++ b/tests/auth_test.go @@ -7,9 +7,13 @@ package test import ( "encoding/json" "net/http" + "os" + "strconv" "testing" + "time" "github.com/fossology/LicenseDb/pkg/models" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/assert" ) @@ -193,6 +197,130 @@ func TestProfileUpdate(t *testing.T) { }) } +func TestLoginAndRefreshTokenExpiry(t *testing.T) { + var refreshToken string + t.Run("login", func(t *testing.T) { + loginPayload := models.UserLogin{ + Username: "fossy_admin", + Userpassword: "fossy", + } + + w := makeRequest("POST", "/login", loginPayload, false) + assert.Equal(t, http.StatusOK, w.Code) + + var res models.TokenResonse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("failed to unmarshal login response: %v", err) + } + + refreshToken = res.Data.RefreshToken + assert.NotEmpty(t, refreshToken) + + accessToken := res.Data.AccessToken + assert.NotEmpty(t, accessToken) + + expiresAtStr := res.Data.AccessTokenExpiresAt + assert.NotEmpty(t, expiresAtStr) + + expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + t.Fatalf("invalid expires_at format: %v", err) + } + + // Parse the access token to get iat claim + parsedToken, err := jwt.Parse([]byte(accessToken), jwt.WithVerify(false)) + if err != nil { + t.Fatalf("failed to parse access token: %v", err) + } + + iat, ok := parsedToken.IssuedAt() + if !ok { + t.Fatalf("failed to get iat claim from token") + } + + // Get TOKEN_HOUR_LIFESPAN from environment + tokenLifespanStr := os.Getenv("TOKEN_HOUR_LIFESPAN") + if tokenLifespanStr == "" { + tokenLifespanStr = "1" // default + } + tokenLifespan, err := strconv.ParseInt(tokenLifespanStr, 10, 64) + if err != nil { + t.Fatalf("failed to parse TOKEN_HOUR_LIFESPAN: %v", err) + } + + // Calculate expected expiry: iat + lifespan + expectedExpiry := iat.Add(time.Hour * time.Duration(tokenLifespan)) + + // Allow 1 second tolerance for clock skew + assert.True( + t, + expiresAt.Sub(expectedExpiry) < time.Second, + "expires_at %v does not match expected expiry %v (iat + lifespan)", + expiresAt, expectedExpiry, + ) + }) + t.Run("refresh_token", func(t *testing.T) { + if refreshToken == "" { + t.Fatal("refresh token not set from login step") + } + + refreshPayload := models.RefreshToken{ + RefreshToken: refreshToken, + } + + w := makeRequest("POST", "/refresh-token", refreshPayload, false) + assert.Equal(t, http.StatusOK, w.Code) + + var res models.TokenResonse + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("failed to unmarshal refresh response: %v", err) + } + + accessToken := res.Data.AccessToken + assert.NotEmpty(t, accessToken) + + expiresAtStr := res.Data.AccessTokenExpiresAt + assert.NotEmpty(t, expiresAtStr) + + expiresAt, err := time.Parse(time.RFC3339, expiresAtStr) + if err != nil { + t.Fatalf("invalid expires_at format: %v", err) + } + + // Parse the access token to get iat claim + parsedToken, err := jwt.Parse([]byte(accessToken), jwt.WithVerify(false)) + if err != nil { + t.Fatalf("failed to parse access token: %v", err) + } + + iat, ok := parsedToken.IssuedAt() + if !ok { + t.Fatalf("failed to get iat claim from token") + } + + // Get TOKEN_HOUR_LIFESPAN from environment + tokenLifespanStr := os.Getenv("TOKEN_HOUR_LIFESPAN") + if tokenLifespanStr == "" { + tokenLifespanStr = "1" // default + } + tokenLifespan, err := strconv.ParseInt(tokenLifespanStr, 10, 64) + if err != nil { + t.Fatalf("failed to parse TOKEN_HOUR_LIFESPAN: %v", err) + } + + // Calculate expected expiry: iat + lifespan + expectedExpiry := iat.Add(time.Hour * time.Duration(tokenLifespan)) + + // Allow 1 second tolerance for clock skew + assert.True( + t, + expiresAt.Sub(expectedExpiry) < time.Second, + "expires_at %v does not match expected expiry %v (iat + lifespan)", + expiresAt, expectedExpiry, + ) + }) +} + // Auth Utility Functions // loginAs logs in as the given user type ("superadmin" or "admin") and sets AuthToken.