diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c3c9ed39..057723fe 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -69,6 +69,8 @@ jobs: -e APP_ENV=test \ pocket-id/pocket-id:test + docker logs -f pocket-id-sqlite &> /tmp/backend.log & + - name: Run Playwright tests working-directory: ./frontend run: npx playwright test @@ -81,6 +83,14 @@ jobs: include-hidden-files: true retention-days: 15 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-sqlite + path: /tmp/backend.log + include-hidden-files: true + retention-days: 15 + test-postgres: if: github.event.pull_request.head.ref != 'i18n_crowdin' runs-on: ubuntu-latest @@ -143,6 +153,8 @@ jobs: -e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \ pocket-id/pocket-id:test + docker logs -f pocket-id-postgres &> /tmp/backend.log & + - name: Run Playwright tests working-directory: ./frontend run: npx playwright test @@ -154,3 +166,11 @@ jobs: path: frontend/tests/.report include-hidden-files: true retention-days: 15 + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: backend-postgres + path: /tmp/backend.log + include-hidden-files: true + retention-days: 15 diff --git a/backend/go.mod b/backend/go.mod index b0e67443..dff063d5 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -14,11 +14,10 @@ require ( github.com/go-ldap/ldap/v3 v3.4.10 github.com/go-playground/validator/v10 v10.24.0 github.com/go-webauthn/webauthn v0.11.2 - github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 - github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 + github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 github.com/mileusna/useragent v1.3.5 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/stretchr/testify v1.10.0 @@ -45,6 +44,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-webauthn/x v0.1.16 // indirect github.com/goccy/go-json v0.10.4 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/go-tpm v0.9.3 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7b240c84..ec18bbd4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -145,8 +145,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA= github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms= -github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0= -github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc= +github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I= +github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 3994177f..deb3a1e1 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -3,6 +3,7 @@ package controller import ( "fmt" "net/http" + "strconv" "github.com/gin-gonic/gin" "github.com/pocket-id/pocket-id/backend/internal/common" @@ -143,7 +144,7 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { // @Success 200 {file} binary "Logo image" // @Router /api/application-configuration/logo [get] func (acc *AppConfigController) getLogoHandler(c *gin.Context) { - lightLogo := c.DefaultQuery("light", "true") == "true" + lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) var imageName string var imageType string @@ -196,7 +197,7 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) { // @Security BearerAuth // @Router /api/application-configuration/logo [put] func (acc *AppConfigController) updateLogoHandler(c *gin.Context) { - lightLogo := c.DefaultQuery("light", "true") == "true" + lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true")) var imageName string var imageType string diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 43ccf2e2..f17f5245 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -195,22 +195,28 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) { // @Security OAuth2AccessToken // @Router /api/oidc/userinfo [get] func (oc *OidcController) userInfoHandler(c *gin.Context) { - authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") - if len(authHeaderSplit) != 2 { + _, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ") + if !ok || authToken == "" { _ = c.Error(&common.MissingAccessToken{}) return } - token := authHeaderSplit[1] - - jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token) + token, err := oc.jwtService.VerifyOauthAccessToken(authToken) if err != nil { _ = c.Error(err) return } - userID := jwtClaims.Subject - clientId := jwtClaims.Audience[0] - claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId) + userID, ok := token.Subject() + if !ok { + _ = c.Error(&common.TokenInvalidError{}) + return + } + clientID, ok := token.Audience() + if !ok || len(clientID) != 1 { + _ = c.Error(&common.TokenInvalidError{}) + return + } + claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientID[0]) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index b3f4303d..95b7f19b 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -2,7 +2,6 @@ package controller import ( "net/http" - "strconv" "time" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" @@ -228,7 +227,7 @@ func (uc *UserController) updateUserHandler(c *gin.Context) { // @Success 200 {object} dto.UserDto // @Router /api/users/me [put] func (uc *UserController) updateCurrentUserHandler(c *gin.Context) { - if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" { + if !uc.appConfigService.DbConfig.AllowOwnAccountEdit.IsTrue() { _ = c.Error(&common.AccountEditNotAllowedError{}) return } @@ -391,8 +390,7 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) { return } - sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value) - maxAge := sessionDurationInMinutesParsed * 60 + maxAge := int(uc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) @@ -417,8 +415,7 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) { return } - sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value) - maxAge := sessionDurationInMinutesParsed * 60 + maxAge := int(uc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go index e39dd695..010dd994 100644 --- a/backend/internal/controller/webauthn_controller.go +++ b/backend/internal/controller/webauthn_controller.go @@ -2,7 +2,6 @@ package controller import ( "net/http" - "strconv" "time" "github.com/go-webauthn/webauthn/protocol" @@ -107,8 +106,7 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) { return } - sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value) - maxAge := sessionDurationInMinutesParsed * 60 + maxAge := int(wc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds()) cookie.AddAccessTokenCookie(c, maxAge, token) c.JSON(http.StatusOK, userDto) diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go index dc9d368b..0c9abd90 100644 --- a/backend/internal/controller/well_known_controller.go +++ b/backend/internal/controller/well_known_controller.go @@ -1,9 +1,13 @@ package controller import ( + "encoding/json" + "fmt" + "log" "net/http" "github.com/gin-gonic/gin" + "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" ) @@ -14,12 +18,21 @@ import ( // @Tags Well Known func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) { wkc := &WellKnownController{jwtService: jwtService} + + // Pre-compute the OIDC configuration document, which is static + var err error + wkc.oidcConfig, err = wkc.computeOIDCConfiguration() + if err != nil { + log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err) + } + group.GET("/.well-known/jwks.json", wkc.jwksHandler) group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler) } type WellKnownController struct { jwtService *service.JwtService + oidcConfig []byte } // jwksHandler godoc @@ -46,8 +59,16 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) { // @Success 200 {object} object "OpenID Connect configuration" // @Router /.well-known/openid-configuration [get] func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { + c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig) +} + +func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { appUrl := common.EnvConfig.AppURL - config := map[string]interface{}{ + alg, err := wkc.jwtService.GetKeyAlg() + if err != nil { + return nil, fmt.Errorf("failed to get key algorithm: %w", err) + } + config := map[string]any{ "issuer": appUrl, "authorization_endpoint": appUrl + "/authorize", "token_endpoint": appUrl + "/api/oidc/token", @@ -59,7 +80,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, "response_types_supported": []string{"code", "id_token"}, "subject_types_supported": []string{"public"}, - "id_token_signing_alg_values_supported": []string{"RS256"}, + "id_token_signing_alg_values_supported": []string{alg.String()}, } - c.JSON(http.StatusOK, config) + return json.Marshal(config) } diff --git a/backend/internal/job/ldap_job.go b/backend/internal/job/ldap_job.go index 9e69c1f0..2b9dc8f4 100644 --- a/backend/internal/job/ldap_job.go +++ b/backend/internal/job/ldap_job.go @@ -32,7 +32,7 @@ func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *servic } func (j *LdapJobs) syncLdap() error { - if j.appConfigService.DbConfig.LdapEnabled.Value == "true" { + if j.appConfigService.DbConfig.LdapEnabled.IsTrue() { return j.ldapService.SyncAll() } return nil diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index b67cd1eb..a3149008 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -19,7 +19,6 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware { func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { return func(c *gin.Context) { - userID, isAdmin, err := m.Verify(c, adminRequired) if err != nil { c.Abort() @@ -33,27 +32,37 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { } } -func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { +func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) { // Extract the token from the cookie - token, err := c.Cookie(cookie.AccessTokenCookieName) + accessToken, err := c.Cookie(cookie.AccessTokenCookieName) if err != nil { // Try to extract the token from the Authorization header if it's not in the cookie - authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") - if len(authorizationHeaderSplit) != 2 { + var ok bool + _, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ") + if !ok || accessToken == "" { return "", false, &common.NotSignedInError{} } - token = authorizationHeaderSplit[1] } - claims, err := m.jwtService.VerifyAccessToken(token) + token, err := m.jwtService.VerifyAccessToken(accessToken) if err != nil { return "", false, &common.NotSignedInError{} } + subject, ok := token.Subject() + if !ok { + _ = c.Error(&common.TokenInvalidError{}) + return + } + // Check if the user is an admin - if adminRequired && !claims.IsAdmin { + isAdmin, err = service.GetIsAdmin(token) + if err != nil { + return "", false, &common.TokenInvalidError{} + } + if adminRequired && !isAdmin { return "", false, &common.MissingPermissionError{} } - return claims.Subject, claims.IsAdmin, nil + return subject, isAdmin, nil } diff --git a/backend/internal/model/app_config.go b/backend/internal/model/app_config.go index 678b7df0..d2acac05 100644 --- a/backend/internal/model/app_config.go +++ b/backend/internal/model/app_config.go @@ -2,6 +2,7 @@ package model import ( "strconv" + "time" ) type AppConfigVariable struct { @@ -13,11 +14,21 @@ type AppConfigVariable struct { DefaultValue string } +// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc. func (a *AppConfigVariable) IsTrue() bool { ok, _ := strconv.ParseBool(a.Value) return ok } +// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes. +func (a *AppConfigVariable) AsDurationMinutes() time.Duration { + val, err := strconv.Atoi(a.Value) + if err != nil { + return 0 + } + return time.Duration(val) * time.Minute +} + type AppConfig struct { // General AppName AppConfigVariable diff --git a/backend/internal/model/app_config_test.go b/backend/internal/model/app_config_test.go new file mode 100644 index 00000000..ef43b41a --- /dev/null +++ b/backend/internal/model/app_config_test.go @@ -0,0 +1,60 @@ +package model + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAppConfigVariable_AsMinutesDuration(t *testing.T) { + tests := []struct { + name string + value string + expected time.Duration + expectedSeconds int + }{ + { + name: "valid positive integer", + value: "60", + expected: 60 * time.Minute, + expectedSeconds: 3600, + }, + { + name: "valid zero integer", + value: "0", + expected: 0, + expectedSeconds: 0, + }, + { + name: "negative integer", + value: "-30", + expected: -30 * time.Minute, + expectedSeconds: -1800, + }, + { + name: "invalid non-integer", + value: "not-a-number", + expected: 0, + expectedSeconds: 0, + }, + { + name: "empty string", + value: "", + expected: 0, + expectedSeconds: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configVar := AppConfigVariable{ + Value: tt.value, + } + + result := configVar.AsDurationMinutes() + assert.Equal(t, tt.expected, result) + assert.Equal(t, tt.expectedSeconds, int(result.Seconds())) + }) + } +} diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index ded77d1a..b5918e8b 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -60,7 +60,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID } // If the user hasn't logged in from the same device before and email notifications are enabled, send an email - if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 { + if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.IsTrue() && count <= 1 { go func() { var user model.User s.db.Where("id = ?", userID).First(&user) diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go index 52831d12..dfb38777 100644 --- a/backend/internal/service/email_service.go +++ b/backend/internal/service/email_service.go @@ -5,13 +5,6 @@ import ( "crypto/tls" "errors" "fmt" - "github.com/emersion/go-sasl" - "github.com/emersion/go-smtp" - "github.com/google/uuid" - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" - "gorm.io/gorm" htemplate "html/template" "mime/multipart" "mime/quotedprintable" @@ -20,6 +13,14 @@ import ( "strings" ttemplate "text/template" "time" + + "github.com/emersion/go-sasl" + "github.com/emersion/go-smtp" + "github.com/google/uuid" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils/email" + "gorm.io/gorm" ) type EmailService struct { diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 88fb2b8b..cc11e0ea 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -11,13 +11,11 @@ import ( "log" "os" "path/filepath" - "slices" - "strconv" "time" - "github.com/golang-jwt/jwt/v5" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" @@ -34,6 +32,13 @@ const ( // KeyUsageSigning is the usage for the private keys, for the "use" property KeyUsageSigning = "sig" + + // IsAdminClaim is a boolean claim used in access tokens for admin users + // This may be omitted on non-admin tokens + IsAdminClaim = "isAdmin" + + // Acceptable clock skew for verifying tokens + clockSkew = time.Minute ) type JwtService struct { @@ -61,11 +66,6 @@ func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) e return s.loadOrGenerateKey(keysPath) } -type AccessTokenJWTClaims struct { - jwt.RegisteredClaims - IsAdmin bool `json:"isAdmin,omitempty"` -} - // loadOrGenerateKey loads the private key from the given path or generates it if not existing. func (s *JwtService) loadOrGenerateKey(keysPath string) error { var key jwk.Key @@ -170,133 +170,164 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error { } func (s *JwtService) GenerateAccessToken(user model.User) (string, error) { - sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value) - claim := AccessTokenJWTClaims{ - RegisteredClaims: jwt.RegisteredClaims{ - Subject: user.ID, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Audience: jwt.ClaimStrings{common.EnvConfig.AppURL}, - }, - IsAdmin: user.IsAdmin, - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) - token.Header["kid"] = s.keyId - - var privateKeyRaw any - err := jwk.Export(s.privateKey, &privateKeyRaw) + now := time.Now() + token, err := jwt.NewBuilder(). + Subject(user.ID). + Expiration(now.Add(s.appConfigService.DbConfig.SessionDuration.AsDurationMinutes())). + IssuedAt(now). + Issuer(common.EnvConfig.AppURL). + Build() if err != nil { - return "", fmt.Errorf("failed to export private key object: %w", err) + return "", fmt.Errorf("failed to build token: %w", err) } - signed, err := token.SignedString(privateKeyRaw) + err = SetAudienceString(token, common.EnvConfig.AppURL) + if err != nil { + return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) + } + + err = SetIsAdmin(token, user.IsAdmin) + if err != nil { + return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err) + } + + alg, _ := s.privateKey.Algorithm() + signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) if err != nil { return "", fmt.Errorf("failed to sign token: %w", err) } - return signed, nil + return string(signed), nil } -func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) { - return s.getPublicKeyRaw() - }) - if err != nil || !token.Valid { - return nil, errors.New("couldn't handle this token") +func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) { + alg, _ := s.privateKey.Algorithm() + token, err := jwt.ParseString( + tokenString, + jwt.WithValidate(true), + jwt.WithKey(alg, s.privateKey), + jwt.WithAcceptableSkew(clockSkew), + jwt.WithAudience(common.EnvConfig.AppURL), + jwt.WithIssuer(common.EnvConfig.AppURL), + ) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) } - claims, isValid := token.Claims.(*AccessTokenJWTClaims) - if !isValid { - return nil, errors.New("can't parse claims") - } - - if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) { - return nil, errors.New("audience doesn't match") - } - return claims, nil + return token, nil } -func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) { - // Initialize with capacity for userClaims, + 4 fixed claims, + 2 claims which may be set in some cases, to avoid re-allocations - claims := make(jwt.MapClaims, len(userClaims)+6) - claims["aud"] = clientID - claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour)) - claims["iat"] = jwt.NewNumericDate(time.Now()) - claims["iss"] = common.EnvConfig.AppURL +func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) { + now := time.Now() + token, err := jwt.NewBuilder(). + Expiration(now.Add(1 * time.Hour)). + IssuedAt(now). + Issuer(common.EnvConfig.AppURL). + Build() + if err != nil { + return "", fmt.Errorf("failed to build token: %w", err) + } + + err = SetAudienceString(token, clientID) + if err != nil { + return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) + } for k, v := range userClaims { - claims[k] = v + err = token.Set(k, v) + if err != nil { + return "", fmt.Errorf("failed to set claim '%s': %w", k, err) + } } if nonce != "" { - claims["nonce"] = nonce + err = token.Set("nonce", nonce) + if err != nil { + return "", fmt.Errorf("failed to set claim 'nonce': %w", err) + } } - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) - token.Header["kid"] = s.keyId - - var privateKeyRaw any - err := jwk.Export(s.privateKey, &privateKeyRaw) + alg, _ := s.privateKey.Algorithm() + signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) if err != nil { - return "", fmt.Errorf("failed to export private key object: %w", err) + return "", fmt.Errorf("failed to sign token: %w", err) } - return token.SignedString(privateKeyRaw) + return string(signed), nil } -func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { - return s.getPublicKeyRaw() - }, jwt.WithIssuer(common.EnvConfig.AppURL)) +func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) { + alg, _ := s.privateKey.Algorithm() - if err != nil && !errors.Is(err, jwt.ErrTokenExpired) { - return nil, errors.New("couldn't handle this token") + opts := make([]jwt.ParseOption, 0) + + // These options are always present + opts = append(opts, + jwt.WithValidate(true), + jwt.WithKey(alg, s.privateKey), + jwt.WithAcceptableSkew(clockSkew), + jwt.WithIssuer(common.EnvConfig.AppURL), + ) + + // By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp" + // In case we want to accept expired tokens (during logout), we need to set the validators explicitly without validating "exp" + if acceptExpiredTokens { + // This is equivalent to the default validators except it doesn't validate "exp" + opts = append(opts, + jwt.WithResetValidators(true), + jwt.WithValidator(jwt.IsIssuedAtValid()), + jwt.WithValidator(jwt.IsNbfValid()), + ) } - claims, isValid := token.Claims.(*jwt.RegisteredClaims) - if !isValid { - return nil, errors.New("can't parse claims") + token, err := jwt.ParseString(tokenString, opts...) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) } - return claims, nil + return token, nil } func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { - claim := jwt.RegisteredClaims{ - Subject: user.ID, - ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), - IssuedAt: jwt.NewNumericDate(time.Now()), - Audience: jwt.ClaimStrings{clientID}, - Issuer: common.EnvConfig.AppURL, - } - - token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) - token.Header["kid"] = s.keyId - - var privateKeyRaw any - err := jwk.Export(s.privateKey, &privateKeyRaw) + now := time.Now() + token, err := jwt.NewBuilder(). + Subject(user.ID). + Expiration(now.Add(1 * time.Hour)). + IssuedAt(now). + Issuer(common.EnvConfig.AppURL). + Build() if err != nil { - return "", fmt.Errorf("failed to export private key object: %w", err) + return "", fmt.Errorf("failed to build token: %w", err) } - return token.SignedString(privateKeyRaw) + err = SetAudienceString(token, clientID) + if err != nil { + return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) + } + + alg, _ := s.privateKey.Algorithm() + signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) + if err != nil { + return "", fmt.Errorf("failed to sign token: %w", err) + } + + return string(signed), nil } -func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) { - token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { - return s.getPublicKeyRaw() - }) - if err != nil || !token.Valid { - return nil, errors.New("couldn't handle this token") +func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) { + alg, _ := s.privateKey.Algorithm() + token, err := jwt.ParseString( + tokenString, + jwt.WithValidate(true), + jwt.WithKey(alg, s.privateKey), + jwt.WithAcceptableSkew(clockSkew), + jwt.WithIssuer(common.EnvConfig.AppURL), + ) + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) } - claims, isValid := token.Claims.(*jwt.RegisteredClaims) - if !isValid { - return nil, errors.New("can't parse claims") - } - - return claims, nil + return token, nil } // GetPublicJWK returns the JSON Web Key (JWK) for the public key. @@ -325,17 +356,18 @@ func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) { return s.jwksEncoded, nil } -func (s *JwtService) getPublicKeyRaw() (any, error) { - pubKey, err := s.privateKey.PublicKey() - if err != nil { - return nil, fmt.Errorf("failed to get public key: %w", err) +// GetKeyAlg returns the algorithm of the key +func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) { + if len(s.jwksEncoded) == 0 { + return nil, errors.New("key is not initialized") } - var pubKeyRaw any - err = jwk.Export(pubKey, &pubKeyRaw) - if err != nil { - return nil, fmt.Errorf("failed to export raw public key: %w", err) + + alg, ok := s.privateKey.Algorithm() + if !ok || alg == nil { + return nil, errors.New("failed to retrieve algorithm for key") } - return pubKeyRaw, nil + + return alg, nil } func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) { @@ -438,3 +470,28 @@ func generateRandomKeyID() (string, error) { } return base64.RawURLEncoding.EncodeToString(buf), nil } + +// GetIsAdmin returns the value of the "isAdmin" claim in the token +func GetIsAdmin(token jwt.Token) (bool, error) { + if !token.Has(IsAdminClaim) { + return false, nil + } + var isAdmin bool + err := token.Get(IsAdminClaim, &isAdmin) + return isAdmin, err +} + +// SetIsAdmin sets the "isAdmin" claim in the token +func SetIsAdmin(token jwt.Token, isAdmin bool) error { + // Only set if true + if !isAdmin { + return nil + } + return token.Set(IsAdminClaim, isAdmin) +} + +// SetAudienceString sets the "aud" claim with a value that is a string, and not an array +// This is permitted by RFC 7519, and it's done here for backwards-compatibility +func SetAudienceString(token jwt.Token, audience string) error { + return token.Set(jwt.AudienceKey, audience) +} diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index 43ac9ea2..fdf7fffc 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -2,10 +2,13 @@ package service import ( "crypto/ecdsa" + "crypto/ed25519" "crypto/elliptic" "crypto/rand" + "crypto/rsa" "os" "path/filepath" + "sync" "testing" "time" @@ -20,16 +23,19 @@ import ( ) func TestJwtService_Init(t *testing.T) { + mockConfig := &AppConfigService{ + DbConfig: &model.AppConfig{ + SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes + }, + } + t.Run("should generate new key when none exists", func(t *testing.T) { // Create a temporary directory for the test tempDir := t.TempDir() - // Create a mock AppConfigService - appConfigService := &AppConfigService{} - // Initialize the JWT service service := &JwtService{} - err := service.init(appConfigService, tempDir) + err := service.init(mockConfig, tempDir) require.NoError(t, err, "Failed to initialize JWT service") // Verify the private key was set @@ -62,7 +68,7 @@ func TestJwtService_Init(t *testing.T) { // First create a service to generate a key firstService := &JwtService{} - err := firstService.init(&AppConfigService{}, tempDir) + err := firstService.init(mockConfig, tempDir) require.NoError(t, err) // Get the key ID of the first service @@ -71,7 +77,7 @@ func TestJwtService_Init(t *testing.T) { // Now create a new service that should load the existing key secondService := &JwtService{} - err = secondService.init(&AppConfigService{}, tempDir) + err = secondService.init(mockConfig, tempDir) require.NoError(t, err) // Verify the loaded key has the same ID as the original @@ -80,33 +86,72 @@ func TestJwtService_Init(t *testing.T) { assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") }) - t.Run("should load existing JWK for EC keys", func(t *testing.T) { + t.Run("should load existing JWK for ECDSA keys", func(t *testing.T) { // Create a temporary directory for the test tempDir := t.TempDir() // Create a new JWK and save it to disk - origKeyID := createECKeyJWK(t, tempDir) + origKeyID := createECDSAKeyJWK(t, tempDir) // Now create a new service that should load the existing key svc := &JwtService{} - err := svc.init(&AppConfigService{}, tempDir) + err := svc.init(mockConfig, tempDir) require.NoError(t, err) + // Ensure loaded key has the right algorithm + alg, ok := svc.privateKey.Algorithm() + _ = assert.True(t, ok) && + assert.Equal(t, jwa.ES256().String(), alg.String(), "Loaded key has the incorrect algorithm") + // Verify the loaded key has the same ID as the original loadedKeyID, ok := svc.privateKey.KeyID() - require.True(t, ok) - assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + _ = assert.True(t, ok) && + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + }) + + t.Run("should load existing JWK for EdDSA keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create a new JWK and save it to disk + origKeyID := createEdDSAKeyJWK(t, tempDir) + + // Now create a new service that should load the existing key + svc := &JwtService{} + err := svc.init(mockConfig, tempDir) + require.NoError(t, err) + + // Ensure loaded key has the right algorithm and curve + alg, ok := svc.privateKey.Algorithm() + _ = assert.True(t, ok) && + assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Loaded key has the incorrect algorithm") + + var curve jwa.EllipticCurveAlgorithm + err = svc.privateKey.Get("crv", &curve) + _ = assert.NoError(t, err, "Failed to get 'crv' claim") && + assert.Equal(t, jwa.Ed25519().String(), curve.String(), "Curve does not match expected value") + + // Verify the loaded key has the same ID as the original + loadedKeyID, ok := svc.privateKey.KeyID() + _ = assert.True(t, ok) && + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") }) } func TestJwtService_GetPublicJWK(t *testing.T) { + mockConfig := &AppConfigService{ + DbConfig: &model.AppConfig{ + SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes + }, + } + t.Run("returns public key when private key is initialized", func(t *testing.T) { // Create a temporary directory for the test tempDir := t.TempDir() // Create a JWT service with initialized key service := &JwtService{} - err := service.init(&AppConfigService{}, tempDir) + err := service.init(mockConfig, tempDir) require.NoError(t, err, "Failed to initialize JWT service") // Get the JWK (public key) @@ -136,11 +181,11 @@ func TestJwtService_GetPublicJWK(t *testing.T) { tempDir := t.TempDir() // Create an ECDSA key and save it as JWK - originalKeyID := createECKeyJWK(t, tempDir) + originalKeyID := createECDSAKeyJWK(t, tempDir) // Create a JWT service that loads the ECDSA key service := &JwtService{} - err := service.init(&AppConfigService{}, tempDir) + err := service.init(mockConfig, tempDir) require.NoError(t, err, "Failed to initialize JWT service") // Get the JWK (public key) @@ -169,6 +214,44 @@ func TestJwtService_GetPublicJWK(t *testing.T) { assert.Equal(t, "ES256", alg.String(), "Algorithm should be ES256") }) + t.Run("returns public key when EdDSA private key is initialized", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an EdDSA key and save it as JWK + originalKeyID := createEdDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the EdDSA key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Get the JWK (public key) + publicKey, err := service.GetPublicJWK() + require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized") + + // Verify the returned key is valid + require.NotNil(t, publicKey, "Public key should not be nil") + + // Validate it's actually a public key + isPrivate, err := jwk.IsPrivateKey(publicKey) + require.NoError(t, err) + assert.False(t, isPrivate, "Returned key should be a public key") + + // Check that key has required properties + keyID, ok := publicKey.KeyID() + require.True(t, ok, "Public key should have a key ID") + assert.Equal(t, originalKeyID, keyID, "Key ID should match the original key ID") + + // Check that the key type is OKP + assert.Equal(t, "OKP", publicKey.KeyType().String(), "Key type should be OKP") + + // Check that the algorithm is EdDSA + alg, ok := publicKey.Algorithm() + require.True(t, ok, "Public key should have an algorithm") + assert.Equal(t, "EdDSA", alg.String(), "Algorithm should be EdDSA") + }) + t.Run("returns error when private key is not initialized", func(t *testing.T) { // Create a service with nil private key service := &JwtService{ @@ -228,15 +311,22 @@ func TestGenerateVerifyAccessToken(t *testing.T) { require.NoError(t, err, "Failed to verify generated token") // Check the claims - assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID") - assert.False(t, claims.IsAdmin, "IsAdmin should be false") - assert.Contains(t, claims.Audience, "https://test.example.com", "Audience should contain the app URL") + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + isAdmin, err := GetIsAdmin(claims) + _ = assert.NoError(t, err, "Failed to get isAdmin claim") && + assert.False(t, isAdmin, "isAdmin should be false") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{"https://test.example.com"}, audience, "Audience should contain the app URL") - // Check token expiration time is approximately 60 minutes from now - expectedExp := time.Now().Add(60 * time.Minute) - tokenExp := claims.ExpiresAt.Time - timeDiff := expectedExp.Sub(tokenExp).Minutes() - assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 60 minutes") + // Check token expiration time is approximately 1 hour from now + expectedExp := time.Now().Add(1 * time.Hour) + expiration, ok := claims.Expiration() + assert.True(t, ok, "Expiration not found in token") + timeDiff := expectedExp.Sub(expiration).Minutes() + assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour") }) t.Run("generates token for admin user", func(t *testing.T) { @@ -263,8 +353,12 @@ func TestGenerateVerifyAccessToken(t *testing.T) { require.NoError(t, err, "Failed to verify generated token") // Check the IsAdmin claim is true - assert.True(t, claims.IsAdmin, "IsAdmin should be true for admin users") - assert.Equal(t, adminUser.ID, claims.Subject, "Token subject should match admin ID") + isAdmin, err := GetIsAdmin(claims) + _ = assert.NoError(t, err, "Failed to get isAdmin claim") && + assert.True(t, isAdmin, "isAdmin should be true") + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, adminUser.ID, subject, "Token subject should match user ID") }) t.Run("uses session duration from config", func(t *testing.T) { @@ -296,10 +390,173 @@ func TestGenerateVerifyAccessToken(t *testing.T) { // Check token expiration time is approximately 30 minutes from now expectedExp := time.Now().Add(30 * time.Minute) - tokenExp := claims.ExpiresAt.Time - timeDiff := expectedExp.Sub(tokenExp).Minutes() + expiration, ok := claims.Expiration() + assert.True(t, ok, "Expiration not found in token") + timeDiff := expectedExp.Sub(expiration).Minutes() assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes") }) + + t.Run("works with Ed25519 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an Ed25519 key and save it as JWK + origKeyID := createEdDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "eddsauser123", + }, + Email: "eddsauser@example.com", + IsAdmin: true, + } + + // Generate a token + tokenString, err := service.GenerateAccessToken(user) + require.NoError(t, err, "Failed to generate access token with Ed25519 key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated token with Ed25519 key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + isAdmin, err := GetIsAdmin(claims) + _ = assert.NoError(t, err, "Failed to get isAdmin claim") && + assert.True(t, isAdmin, "isAdmin should be true") + + // Verify the key type is OKP + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, "OKP", publicKey.KeyType().String(), "Key type should be OKP") + + // Verify the algorithm is EdDSA + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, "EdDSA", alg.String(), "Algorithm should be EdDSA") + }) + + t.Run("works with P-256 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an ECDSA key and save it as JWK + origKeyID := createECDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "ecdsauser123", + }, + Email: "ecdsauser@example.com", + IsAdmin: true, + } + + // Generate a token + tokenString, err := service.GenerateAccessToken(user) + require.NoError(t, err, "Failed to generate access token with ECDSA key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated token with ECDSA key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + isAdmin, err := GetIsAdmin(claims) + _ = assert.NoError(t, err, "Failed to get isAdmin claim") && + assert.True(t, isAdmin, "isAdmin should be true") + + // Verify the key type is EC + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC") + + // Verify the algorithm is ES256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256") + }) + + t.Run("works with RSA-4096 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an RSA-4096 key and save it as JWK + origKeyID := createRSA4096KeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "rsauser123", + }, + Email: "rsauser@example.com", + IsAdmin: true, + } + + // Generate a token + tokenString, err := service.GenerateAccessToken(user) + require.NoError(t, err, "Failed to generate access token with RSA key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated token with RSA key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + isAdmin, err := GetIsAdmin(claims) + _ = assert.NoError(t, err, "Failed to get isAdmin claim") && + assert.True(t, isAdmin, "isAdmin should be true") + + // Verify the key type is RSA + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA") + + // Verify the algorithm is RS256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256") + }) } func TestGenerateVerifyIdToken(t *testing.T) { @@ -340,21 +597,83 @@ func TestGenerateVerifyIdToken(t *testing.T) { assert.NotEmpty(t, tokenString, "Token should not be empty") // Verify the token - claims, err := service.VerifyIdToken(tokenString) + claims, err := service.VerifyIdToken(tokenString, false) require.NoError(t, err, "Failed to verify generated ID token") // Check the claims - assert.Equal(t, "user123", claims.Subject, "Token subject should match user ID") - assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID") - assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL") + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, "user123", subject, "Token subject should match user ID") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{clientID}, audience, "Audience should contain the client ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") // Check token expiration time is approximately 1 hour from now expectedExp := time.Now().Add(1 * time.Hour) - tokenExp := claims.ExpiresAt.Time - timeDiff := expectedExp.Sub(tokenExp).Minutes() + expiration, ok := claims.Expiration() + assert.True(t, ok, "Expiration not found in token") + timeDiff := expectedExp.Sub(expiration).Minutes() assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour") }) + t.Run("can accept expired tokens if told so", func(t *testing.T) { + // Create a JWT service + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Create test claims + userClaims := map[string]interface{}{ + "sub": "user123", + "name": "Test User", + "email": "user@example.com", + } + const clientID = "test-client-123" + + // Create a token that's already expired + token, err := jwt.NewBuilder(). + Subject(userClaims["sub"].(string)). + Issuer(common.EnvConfig.AppURL). + Audience([]string{clientID}). + IssuedAt(time.Now().Add(-2 * time.Hour)). + Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago + Build() + require.NoError(t, err, "Failed to build token") + + // Add custom claims + for k, v := range userClaims { + if k != "sub" { // Already set above + err = token.Set(k, v) + require.NoError(t, err, "Failed to set claim") + } + } + + // Sign the token + signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey)) + require.NoError(t, err, "Failed to sign token") + tokenString := string(signed) + + // Verify the token without allowExpired flag - should fail + _, err = service.VerifyIdToken(tokenString, false) + require.Error(t, err, "Verification should fail with expired token when not allowing expired tokens") + assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure") + + // Verify the token with allowExpired flag - should succeed + claims, err := service.VerifyIdToken(tokenString, true) + require.NoError(t, err, "Verification should succeed with expired token when allowing expired tokens") + + // Validate the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") + }) + t.Run("generates and verifies ID token with nonce", func(t *testing.T) { // Create a JWT service service := &JwtService{} @@ -403,9 +722,168 @@ func TestGenerateVerifyIdToken(t *testing.T) { common.EnvConfig.AppURL = "https://wrong-issuer.com" // Verify should fail due to issuer mismatch - _, err = service.VerifyIdToken(tokenString) + _, err = service.VerifyIdToken(tokenString, false) require.Error(t, err, "Verification should fail with incorrect issuer") - assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure") + assert.Contains(t, err.Error(), `"iss" not satisfied`, "Error message should indicate token verification failure") + }) + + t.Run("works with Ed25519 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an Ed25519 key and save it as JWK + origKeyID := createEdDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create test claims + userClaims := map[string]interface{}{ + "sub": "eddsauser456", + "name": "EdDSA User", + "email": "eddsauser@example.com", + } + const clientID = "eddsa-client-123" + + // Generate a token + tokenString, err := service.GenerateIDToken(userClaims, clientID, "") + require.NoError(t, err, "Failed to generate ID token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyIdToken(tokenString, false) + require.NoError(t, err, "Failed to verify generated ID token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") + + // Verify the key type is OKP + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP") + + // Verify the algorithm is EdDSA + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA") + }) + + t.Run("works with P-256 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an ECDSA key and save it as JWK + origKeyID := createECDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create test claims + userClaims := map[string]interface{}{ + "sub": "ecdsauser456", + "name": "ECDSA User", + "email": "ecdsauser@example.com", + } + const clientID = "ecdsa-client-123" + + // Generate a token + tokenString, err := service.GenerateIDToken(userClaims, clientID, "") + require.NoError(t, err, "Failed to generate ID token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyIdToken(tokenString, false) + require.NoError(t, err, "Failed to verify generated ID token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") + + // Verify the key type is EC + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC") + + // Verify the algorithm is ES256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256") + }) + + t.Run("works with RSA-4096 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an RSA-4096 key and save it as JWK + origKeyID := createRSA4096KeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create test claims + userClaims := map[string]interface{}{ + "sub": "rsauser456", + "name": "RSA User", + "email": "rsauser@example.com", + } + const clientID = "rsa-client-123" + + // Generate a token + tokenString, err := service.GenerateIDToken(userClaims, clientID, "") + require.NoError(t, err, "Failed to generate ID token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyIdToken(tokenString, false) + require.NoError(t, err, "Failed to verify generated ID token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, "rsauser456", subject, "Token subject should match user ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") + + // Verify the key type is RSA + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA") + + // Verify the algorithm is RS256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256") }) } @@ -452,14 +930,21 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) { require.NoError(t, err, "Failed to verify generated OAuth access token") // Check the claims - assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID") - assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID") - assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL") + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{clientID}, audience, "Audience should contain the client ID") + issuer, ok := claims.Issuer() + _ = assert.True(t, ok, "Issuer not found in token") && + assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL") // Check token expiration time is approximately 1 hour from now expectedExp := time.Now().Add(1 * time.Hour) - tokenExp := claims.ExpiresAt.Time - timeDiff := expectedExp.Sub(tokenExp).Minutes() + expiration, ok := claims.Expiration() + assert.True(t, ok, "Expiration not found in token") + timeDiff := expectedExp.Sub(expiration).Minutes() assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour") }) @@ -493,7 +978,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) { // Verify should fail due to expiration _, err = service.VerifyOauthAccessToken(string(signed)) require.Error(t, err, "Verification should fail with expired token") - assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure") + assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure") }) t.Run("fails verification with invalid signature", func(t *testing.T) { @@ -521,18 +1006,175 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) { // Verify with the second service should fail due to different keys _, err = service2.VerifyOauthAccessToken(tokenString) require.Error(t, err, "Verification should fail with invalid signature") - assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure") + assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure") + }) + + t.Run("works with Ed25519 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an Ed25519 key and save it as JWK + origKeyID := createEdDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "eddsauser789", + }, + Email: "eddsaoauth@example.com", + } + const clientID = "eddsa-oauth-client" + + // Generate a token + tokenString, err := service.GenerateOauthAccessToken(user, clientID) + require.NoError(t, err, "Failed to generate OAuth access token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyOauthAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated OAuth access token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{clientID}, audience, "Audience should contain the client ID") + + // Verify the key type is OKP + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP") + + // Verify the algorithm is EdDSA + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA") + }) + + t.Run("works with ECDSA keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an ECDSA key and save it as JWK + origKeyID := createECDSAKeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "ecdsauser789", + }, + Email: "ecdsaoauth@example.com", + } + const clientID = "ecdsa-oauth-client" + + // Generate a token + tokenString, err := service.GenerateOauthAccessToken(user, clientID) + require.NoError(t, err, "Failed to generate OAuth access token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyOauthAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated OAuth access token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{clientID}, audience, "Audience should contain the client ID") + + // Verify the key type is EC + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC") + + // Verify the algorithm is ES256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256") + }) + + t.Run("works with RSA-4096 keys", func(t *testing.T) { + // Create a temporary directory for the test + tempDir := t.TempDir() + + // Create an RSA-4096 key and save it as JWK + origKeyID := createRSA4096KeyJWK(t, tempDir) + + // Create a JWT service that loads the key + service := &JwtService{} + err := service.init(mockConfig, tempDir) + require.NoError(t, err, "Failed to initialize JWT service") + + // Verify it loaded the right key + loadedKeyID, ok := service.privateKey.KeyID() + require.True(t, ok) + assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") + + // Create a test user + user := model.User{ + Base: model.Base{ + ID: "rsauser789", + }, + Email: "rsaoauth@example.com", + } + const clientID = "rsa-oauth-client" + + // Generate a token + tokenString, err := service.GenerateOauthAccessToken(user, clientID) + require.NoError(t, err, "Failed to generate OAuth access token with key") + assert.NotEmpty(t, tokenString, "Token should not be empty") + + // Verify the token + claims, err := service.VerifyOauthAccessToken(tokenString) + require.NoError(t, err, "Failed to verify generated OAuth access token with key") + + // Check the claims + subject, ok := claims.Subject() + _ = assert.True(t, ok, "User ID not found in token") && + assert.Equal(t, user.ID, subject, "Token subject should match user ID") + audience, ok := claims.Audience() + _ = assert.True(t, ok, "Audience not found in token") && + assert.EqualValues(t, []string{clientID}, audience, "Audience should contain the client ID") + + // Verify the key type is RSA + publicKey, err := service.GetPublicJWK() + require.NoError(t, err) + assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA") + + // Verify the algorithm is RS256 + alg, ok := publicKey.Algorithm() + require.True(t, ok) + assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256") }) } -func createECKeyJWK(t *testing.T, path string) string { +func importKey(t *testing.T, privateKeyRaw any, path string) string { t.Helper() - // Generate a new P-256 ECDSA key - privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err, "Failed to generate ECDSA key") - - // Import as JWK and save to disk privateKey, err := importRawKey(privateKeyRaw) require.NoError(t, err, "Failed to import private key") @@ -544,3 +1186,47 @@ func createECKeyJWK(t *testing.T, path string) string { return kid } + +// Because generating a RSA-406 key isn't immediate, we pre-compute one +var ( + rsaKeyPrecomputed *rsa.PrivateKey + rsaKeyPrecomputeOnce sync.Once +) + +func createRSA4096KeyJWK(t *testing.T, path string) string { + t.Helper() + + rsaKeyPrecomputeOnce.Do(func() { + var err error + rsaKeyPrecomputed, err = rsa.GenerateKey(rand.Reader, 4096) + if err != nil { + panic("failed to precompute RSA key: " + err.Error()) + } + }) + + // Import as JWK and save to disk + return importKey(t, rsaKeyPrecomputed, path) +} + +func createECDSAKeyJWK(t *testing.T, path string) string { + t.Helper() + + // Generate a new P-256 ECDSA key + privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err, "Failed to generate ECDSA key") + + // Import as JWK and save to disk + return importKey(t, privateKeyRaw, path) +} + +// Helper function to create an Ed25519 key and save it as JWK +func createEdDSAKeyJWK(t *testing.T, path string) string { + t.Helper() + + // Generate a new Ed25519 key pair + _, privateKeyRaw, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err, "Failed to generate Ed25519 key") + + // Import as JWK and save to disk + return importKey(t, privateKeyRaw, path) +} diff --git a/backend/internal/service/ldap_service.go b/backend/internal/service/ldap_service.go index c9810cd2..dfd5e1c5 100644 --- a/backend/internal/service/ldap_service.go +++ b/backend/internal/service/ldap_service.go @@ -32,12 +32,12 @@ func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService } func (s *LdapService) createClient() (*ldap.Conn, error) { - if s.appConfigService.DbConfig.LdapEnabled.Value != "true" { + if !s.appConfigService.DbConfig.LdapEnabled.IsTrue() { return nil, fmt.Errorf("LDAP is not enabled") } // Setup LDAP connection ldapURL := s.appConfigService.DbConfig.LdapUrl.Value - skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true" + skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.IsTrue() client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify})) //nolint:gosec if err != nil { return nil, fmt.Errorf("failed to connect to LDAP: %w", err) diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 5a3caef5..abc3e1d6 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -461,7 +461,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma if strings.Contains(scope, "email") { claims["email"] = user.Email - claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true" + claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.IsTrue() } if strings.Contains(scope, "groups") { @@ -547,21 +547,24 @@ func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) } // If the ID token hint is provided, verify the ID token - claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint) + // Here we also accept expired ID tokens, which are fine per spec + token, err := s.jwtService.VerifyIdToken(input.IdTokenHint, true) if err != nil { return "", &common.TokenInvalidError{} } // If the client ID is provided check if the client ID in the ID token matches the client ID in the request - if input.ClientId != "" && claims.Audience[0] != input.ClientId { + clientID, ok := token.Audience() + if !ok || len(clientID) == 0 { + return "", &common.TokenInvalidError{} + } + if input.ClientId != "" && clientID[0] != input.ClientId { return "", &common.OidcClientIdNotMatchingError{} } - clientId := claims.Audience[0] - // Check if the user has authorized the client before var userAuthorizedOIDCClient model.UserAuthorizedOidcClient - if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil { + if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).Error; err != nil { return "", &common.OidcMissingAuthorizationError{} } diff --git a/backend/internal/service/user_group_service.go b/backend/internal/service/user_group_service.go index 2bfe8e32..e9835274 100644 --- a/backend/internal/service/user_group_service.go +++ b/backend/internal/service/user_group_service.go @@ -54,7 +54,7 @@ func (s *UserGroupService) Delete(id string) error { } // Disallow deleting the group if it is an LDAP group and LDAP is enabled - if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" { + if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() { return &common.LdapUserGroupUpdateError{} } @@ -87,7 +87,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow } // Disallow updating the group if it is an LDAP group and LDAP is enabled - if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" { + if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() { return model.UserGroup{}, &common.LdapUserGroupUpdateError{} } diff --git a/backend/internal/utils/paging_util.go b/backend/internal/utils/paging_util.go index 38cf703b..7be8d72f 100644 --- a/backend/internal/utils/paging_util.go +++ b/backend/internal/utils/paging_util.go @@ -1,8 +1,10 @@ package utils import ( - "gorm.io/gorm" "reflect" + "strconv" + + "gorm.io/gorm" ) type PaginationResponse struct { @@ -30,7 +32,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor capitalizedSortColumn := CapitalizeFirstLetter(sort.Column) sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) - isSortable := sortField.Tag.Get("sortable") == "true" + isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable")) isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc" if sortFieldFound && isSortable && isValidSortOrder { diff --git a/frontend/tests/oidc.spec.ts b/frontend/tests/oidc.spec.ts index 31c89a65..8afcd76a 100644 --- a/frontend/tests/oidc.spec.ts +++ b/frontend/tests/oidc.spec.ts @@ -116,6 +116,7 @@ test('End session without id token hint shows confirmation page', async ({ page test('End session with id token hint redirects to callback URL', async ({ page }) => { const client = oidcClients.nextcloud; + // Note: this token has expired, but it should be accepted by the logout endpoint anyways, per spec const idToken = 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';