diff --git a/server/app/servers.go b/server/app/servers.go index 81d1e4a9cb..07bb62521d 100644 --- a/server/app/servers.go +++ b/server/app/servers.go @@ -155,6 +155,12 @@ func (app *CortezaApp) mountHttpRoutes(r chi.Router) { }() func() { + // Mount OIDC Discovery document at root level for backward compatibility r.Handle("/.well-known/openid-configuration", app.AuthService.WellKnownOpenIDConfiguration()) + + // Also mount at /auth/.well-known/openid-configuration for OIDC compliance + // Per OIDC Discovery 1.0, the well-known endpoint must be at {issuer}/.well-known/openid-configuration + // Since our issuer URL is {baseURL}/auth, clients need to discover at /auth/.well-known/... + r.Handle("/auth/.well-known/openid-configuration", app.AuthService.WellKnownOpenIDConfiguration()) }() } diff --git a/server/auth/auth.go b/server/auth/auth.go index fa629cc794..081b8e1b6e 100644 --- a/server/auth/auth.go +++ b/server/auth/auth.go @@ -436,19 +436,94 @@ func dirCheck(path string) (err error) { return } +// WellKnownOpenIDConfiguration returns the OIDC Discovery document +// per OpenID Connect Discovery 1.0 specification func (svc service) WellKnownOpenIDConfiguration() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]interface{}{ - "issuer": svc.opt.BaseURL, - "authorization_endpoint": svc.opt.BaseURL + "/oauth2/authorize", - "token_endpoint": svc.opt.BaseURL + "/oauth2/token", - "jwks_uri": svc.opt.BaseURL + "/oauth2/public-keys", - "scope_supported": []string{"profile", "api"}, - "id_token_signing_alg_values_supported": []string{"RS256", "HS512"}, - "response_types_supported": []string{"code", "token"}, - }) + // AUTH_BASE_URL defaults to FullURL("/auth"), which includes the /auth suffix + // We need to strip this suffix because the endpoint paths already include /auth prefix + baseURL := strings.TrimSuffix(svc.opt.BaseURL, "/") + baseURL = strings.TrimSuffix(baseURL, "/auth") + + // OIDC Discovery spec requires issuer URL to match the path where discovery was accessed + // minus the /.well-known/openid-configuration suffix. + // If accessed at /auth/.well-known/openid-configuration, issuer should include /auth + issuer := baseURL + if strings.HasPrefix(r.URL.Path, "/auth/") { + issuer = baseURL + "/auth" + } + + config := map[string]interface{}{ + // Required fields (OIDC Discovery 1.0 Section 3) + "issuer": issuer, + "authorization_endpoint": baseURL + "/auth/oauth2/authorize", + "token_endpoint": baseURL + "/auth/oauth2/token", + "jwks_uri": baseURL + "/auth/oauth2/public-keys", + + // UserInfo endpoint (critical for OIDC clients) + "userinfo_endpoint": baseURL + "/auth/oauth2/userinfo", + + // Response types supported + "response_types_supported": []string{"code"}, + + // Subject types + "subject_types_supported": []string{"public"}, + + // ID Token signing algorithms + "id_token_signing_alg_values_supported": []string{"HS512"}, + + // Token endpoint authentication methods + "token_endpoint_auth_methods_supported": []string{ + "client_secret_basic", + "client_secret_post", + }, + + // Scopes supported (note: correct field name is scopes_supported, not scope_supported) + "scopes_supported": []string{ + "openid", + "profile", + "email", + "api", + }, + + // Claims supported + "claims_supported": []string{ + "sub", + "iss", + "aud", + "exp", + "iat", + "name", + "given_name", + "family_name", + "preferred_username", + "email", + "email_verified", + "picture", + "locale", + "updated_at", + }, + + // Code challenge methods (PKCE) + "code_challenge_methods_supported": []string{"plain", "S256"}, + + // Grant types supported + "grant_types_supported": []string{ + "authorization_code", + "refresh_token", + "client_credentials", + }, + + // Prompt values supported (OIDC Core 1.0 Section 3.1.2.1) + // "none" enables silent authentication checks without UI + // "login" forces re-authentication + // "consent" forces consent screen display + "prompt_values_supported": []string{"none", "login", "consent"}, + } w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "public, max-age=3600") + _ = json.NewEncoder(w).Encode(config) } } diff --git a/server/auth/handlers/handle_oauth2.go b/server/auth/handlers/handle_oauth2.go index 6f33c44389..82f646e653 100644 --- a/server/auth/handlers/handle_oauth2.go +++ b/server/auth/handlers/handle_oauth2.go @@ -539,35 +539,224 @@ func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) { } } -// Generates ID token that is part of OIDC flow for doing corteza-to-corteza auth +// Generates ID token that is part of OIDC flow (OIDC Core 1.0 Section 2) +// Returns a JWT with standard OIDC claims func generateIdToken(user *types.User, client *types.AuthClient, ti oauth2def.TokenInfo, baseURL string) (_ []byte, err error) { token := jwt.New() + + // Required claims (OIDC Core 1.0 Section 2) if err = token.Set(jwt.IssuerKey, baseURL); err != nil { return } - // we do not know what the admin used for client key value - // on the receiving end, so we'll encode both, - // client's ID, and it's handle + // 'sub' - Subject Identifier (REQUIRED) + // Using user ID as the subject - this is stable and unique + if err = token.Set(jwt.SubjectKey, strconv.FormatUint(user.ID, 10)); err != nil { + return + } + + // 'aud' - Audience (REQUIRED) + // Include both client ID and handle so receiving end can match either aud := []string{strconv.FormatUint(client.ID, 10)} if len(client.Handle) > 0 { aud = append(aud, client.Handle) } + if err = token.Set(jwt.AudienceKey, aud); err != nil { + return + } - if err = token.Set("aud", aud); err != nil { + // 'exp' - Expiration time (REQUIRED) + if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil { return } - if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil { + + // 'iat' - Issued at (REQUIRED) + if err = token.Set(jwt.IssuedAtKey, now().Unix()); err != nil { return } - if err = token.Set("email", user.Email); err != nil { + + // Standard claims based on scope + scope := ti.GetScope() + + // Profile claims + if strings.Contains(scope, "profile") || strings.Contains(scope, "openid") { + if user.Name != "" { + if err = token.Set("name", user.Name); err != nil { + return + } + // Try to extract given_name and family_name + nameParts := strings.SplitN(user.Name, " ", 2) + if len(nameParts) >= 1 { + _ = token.Set("given_name", nameParts[0]) + } + if len(nameParts) >= 2 { + _ = token.Set("family_name", nameParts[1]) + } + } + + if user.Handle != "" { + if err = token.Set("preferred_username", user.Handle); err != nil { + return + } + } + + // Picture claim + if user.Meta != nil && user.Meta.AvatarID != 0 { + pictureURL := fmt.Sprintf("%s/api/system/attachment/avatar/%d/original/avatar.png", + strings.TrimSuffix(baseURL, "/"), + user.Meta.AvatarID) + _ = token.Set("picture", pictureURL) + } + + // updated_at claim + if user.UpdatedAt != nil { + _ = token.Set("updated_at", user.UpdatedAt.Unix()) + } + + // locale claim (Corteza's preferred language) + if user.Meta != nil && user.Meta.PreferredLanguage != "" { + _ = token.Set("locale", user.Meta.PreferredLanguage) + } + } + + // Email claims + if strings.Contains(scope, "email") || strings.Contains(scope, "openid") { + if user.Email != "" { + if err = token.Set("email", user.Email); err != nil { + return + } + if err = token.Set("email_verified", user.EmailConfirmed); err != nil { + return + } + } + } + + return jwt.Sign(token, jwa.HS512, []byte(client.Secret)) +} + +// oauth2Userinfo implements the OIDC UserInfo endpoint (OIDC Core 1.0 Section 5.3) +// Returns claims about the authenticated user based on the granted scopes +func (h AuthHandlers) oauth2Userinfo(w http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + jt jwt.Token + ) + + err := func() (err error) { + // Extract token from context (set by HttpTokenVerifier middleware) + if jt, _, err = jwtauth.FromContext(ctx); err != nil { + return + } + + if jt == nil { + return fmt.Errorf("no token found") + } + + // Validate the token + if err = auth.TokenIssuer.Validate(ctx, jt); err != nil { + return + } + + return nil + }() + + if err != nil { + h.userinfoError(w, err) return } - if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil { + + // Extract user ID from the subject claim + subClaim, ok := jt.Get("sub") + if !ok { + h.userinfoError(w, fmt.Errorf("missing sub claim")) return } - return jwt.Sign(token, jwa.HS512, []byte(client.Secret)) + userID, _ := auth.ExtractFromSubClaim(cast.ToString(subClaim)) + if userID == 0 { + h.userinfoError(w, fmt.Errorf("invalid user ID in sub claim")) + return + } + + // Get scope from token + scopeClaim, _ := jt.Get("scope") + scope := cast.ToString(scopeClaim) + + // Load user + suCtx := auth.SetIdentityToContext(ctx, auth.ServiceUser()) + user, err := h.UserService.FindByAny(suCtx, userID) + if err != nil { + h.userinfoError(w, fmt.Errorf("user not found: %w", err)) + return + } + + // Build response with OIDC standard claims + response := make(map[string]interface{}) + + // 'sub' claim is always required (OIDC Core 1.0 Section 5.1) + response["sub"] = strconv.FormatUint(user.ID, 10) + + // 'profile' scope claims (OIDC Core 1.0 Section 5.4) + if auth.CheckScope(scope, "profile", "openid") { + if user.Name != "" { + response["name"] = user.Name + // Try to split name into given_name and family_name + nameParts := strings.SplitN(user.Name, " ", 2) + if len(nameParts) >= 1 { + response["given_name"] = nameParts[0] + } + if len(nameParts) >= 2 { + response["family_name"] = nameParts[1] + } + } + + if user.Handle != "" { + response["preferred_username"] = user.Handle + } + + // Picture claim - construct URL to avatar if available + if user.Meta != nil && user.Meta.AvatarID != 0 { + response["picture"] = fmt.Sprintf("%s/api/system/attachment/avatar/%d/original/avatar.png", + strings.TrimSuffix(h.Opt.BaseURL, "/"), + user.Meta.AvatarID) + } + + // updated_at claim (seconds since Unix epoch) + if user.UpdatedAt != nil { + response["updated_at"] = user.UpdatedAt.Unix() + } else { + response["updated_at"] = user.CreatedAt.Unix() + } + + // Corteza-specific: preferred language + if user.Meta != nil && user.Meta.PreferredLanguage != "" { + response["locale"] = user.Meta.PreferredLanguage + } + } + + // 'email' scope claims (OIDC Core 1.0 Section 5.4) + if auth.CheckScope(scope, "email", "openid") { + if user.Email != "" { + response["email"] = user.Email + response["email_verified"] = user.EmailConfirmed + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + _ = json.NewEncoder(w).Encode(response) +} + +// userinfoError returns an error response for the userinfo endpoint +func (h AuthHandlers) userinfoError(w http.ResponseWriter, err error) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`) + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": "invalid_token", + "error_description": err.Error(), + }) } func writeResponse(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error { diff --git a/server/auth/handlers/handle_oauth2_test.go b/server/auth/handlers/handle_oauth2_test.go index 2569568533..0bd4ff95fc 100644 --- a/server/auth/handlers/handle_oauth2_test.go +++ b/server/auth/handlers/handle_oauth2_test.go @@ -1,16 +1,24 @@ package handlers import ( + "context" + "encoding/json" "errors" "fmt" "net/http" + "net/http/httptest" "net/url" "testing" + "time" "github.com/cortezaproject/corteza/server/auth/request" "github.com/cortezaproject/corteza/server/auth/settings" + "github.com/cortezaproject/corteza/server/pkg/auth" + "github.com/cortezaproject/corteza/server/pkg/options" "github.com/cortezaproject/corteza/server/system/types" + "github.com/go-chi/jwtauth" oauth2models "github.com/go-oauth2/oauth2/v4/models" + "github.com/lestrrat-go/jwx/jwt" "github.com/stretchr/testify/require" "go.uber.org/zap" ) @@ -178,3 +186,231 @@ func Test_SubSplitRoles(t *testing.T) { } } } + +func Test_oauth2Userinfo(t *testing.T) { + // Initialize TokenIssuer for tests with a mock lookup that always succeeds + var err error + auth.TokenIssuer, err = auth.NewTokenIssuer( + auth.WithSecretSigner("test-secret"), + auth.WithLookup(func(ctx context.Context, tokenID string) error { + // Always return nil to indicate token is valid + return nil + }), + ) + if err != nil { + t.Fatalf("failed to initialize token issuer: %v", err) + } + + var ( + now = time.Now() + updatedAt = now.Add(-time.Hour) + ) + + testCases := []struct { + name string + user *types.User + scope string + expectedStatus int + expectedClaims map[string]interface{} + userNotFound bool + noToken bool + }{ + { + name: "full profile and email claims", + user: &types.User{ + ID: 12345, + Name: "John Doe", + Handle: "johndoe", + Email: "john@example.com", + EmailConfirmed: true, + CreatedAt: now, + UpdatedAt: &updatedAt, + Meta: &types.UserMeta{ + PreferredLanguage: "en", + }, + }, + scope: "openid profile email", + expectedStatus: http.StatusOK, + expectedClaims: map[string]interface{}{ + "sub": "12345", + "name": "John Doe", + "given_name": "John", + "family_name": "Doe", + "preferred_username": "johndoe", + "email": "john@example.com", + "email_verified": true, + "locale": "en", + }, + }, + { + name: "profile scope only", + user: &types.User{ + ID: 12345, + Name: "Jane Smith", + Handle: "janesmith", + Email: "jane@example.com", + CreatedAt: now, + Meta: &types.UserMeta{}, + }, + scope: "openid profile", + expectedStatus: http.StatusOK, + expectedClaims: map[string]interface{}{ + "sub": "12345", + "name": "Jane Smith", + "preferred_username": "janesmith", + }, + }, + { + name: "email scope only", + user: &types.User{ + ID: 12345, + Email: "test@example.com", + EmailConfirmed: false, + CreatedAt: now, + Meta: &types.UserMeta{}, + }, + scope: "openid email", + expectedStatus: http.StatusOK, + expectedClaims: map[string]interface{}{ + "sub": "12345", + "email": "test@example.com", + "email_verified": false, + }, + }, + { + name: "minimal claims - openid only", + user: &types.User{ + ID: 12345, + CreatedAt: now, + Meta: &types.UserMeta{}, + }, + scope: "openid", + expectedStatus: http.StatusOK, + expectedClaims: map[string]interface{}{ + "sub": "12345", + }, + }, + { + name: "single name without family name", + user: &types.User{ + ID: 12345, + Name: "Madonna", + CreatedAt: now, + Meta: &types.UserMeta{}, + }, + scope: "openid profile", + expectedStatus: http.StatusOK, + expectedClaims: map[string]interface{}{ + "sub": "12345", + "name": "Madonna", + "given_name": "Madonna", + }, + }, + { + name: "no token", + noToken: true, + expectedStatus: http.StatusUnauthorized, + }, + { + name: "user not found", + user: &types.User{ + ID: 99999, + CreatedAt: now, + Meta: &types.UserMeta{}, + }, + scope: "openid profile", + userNotFound: true, + expectedStatus: http.StatusUnauthorized, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rq := require.New(t) + + // Create request + req := httptest.NewRequest(http.MethodGet, "/oauth2/userinfo", nil) + rec := httptest.NewRecorder() + + // Set up context with JWT token + ctx := req.Context() + if !tc.noToken && tc.user != nil { + // Create a properly signed token using the TokenIssuer + signed, err := auth.TokenIssuer.Sign( + auth.WithIdentity(auth.Authenticated(tc.user.ID)), + auth.WithScope(tc.scope), + ) + rq.NoError(err) + + // Parse the signed token to get jwt.Token + token, err := jwt.Parse(signed) + rq.NoError(err) + + ctx = jwtauth.NewContext(ctx, token, nil) + req = req.WithContext(ctx) + } + + // Set up mock user service + userService := userServiceMocked{ + findByAny: func(ctx context.Context, any interface{}) (*types.User, error) { + if tc.userNotFound { + return nil, fmt.Errorf("user not found") + } + return tc.user, nil + }, + } + + // Create handler + h := AuthHandlers{ + Log: zap.NewNop(), + UserService: userService, + Opt: options.AuthOpt{BaseURL: "https://example.com"}, + } + + // Call handler + h.oauth2Userinfo(rec, req) + + // Check status + rq.Equal(tc.expectedStatus, rec.Code) + + if tc.expectedStatus == http.StatusOK { + // Parse response + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + rq.NoError(err) + + // Check expected claims are present + for key, expected := range tc.expectedClaims { + rq.Contains(response, key, "missing claim: %s", key) + rq.Equal(expected, response[key], "claim %s mismatch", key) + } + + // Verify sub is always present + rq.Contains(response, "sub") + } else { + // Check error response + rq.Contains(rec.Header().Get("WWW-Authenticate"), "Bearer") + rq.Equal("application/json", rec.Header().Get("Content-Type")) + } + }) + } +} + +func Test_userinfoError(t *testing.T) { + rq := require.New(t) + + rec := httptest.NewRecorder() + h := AuthHandlers{Log: zap.NewNop()} + + h.userinfoError(rec, fmt.Errorf("test error")) + + rq.Equal(http.StatusUnauthorized, rec.Code) + rq.Equal("application/json", rec.Header().Get("Content-Type")) + rq.Contains(rec.Header().Get("WWW-Authenticate"), `Bearer error="invalid_token"`) + + var response map[string]interface{} + err := json.Unmarshal(rec.Body.Bytes(), &response) + rq.NoError(err) + rq.Equal("invalid_token", response["error"]) + rq.Equal("test error", response["error_description"]) +} diff --git a/server/auth/handlers/routes.go b/server/auth/handlers/routes.go index 29c5c23a93..01d84234f9 100644 --- a/server/auth/handlers/routes.go +++ b/server/auth/handlers/routes.go @@ -102,9 +102,14 @@ func (h *AuthHandlers) MountHttpRoutes(r chi.Router) { r.Post(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClientProc))) r.Get(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClient)) r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc)) - r.Get(tbp(l.OAuth2UserInfo), h.handle(h.oauth2authorizeDefaultClientProc)) }) + // OIDC UserInfo endpoint (OIDC Core 1.0 Section 5.3) + // Mounted outside CSRF group as it uses Bearer token authentication + // Supports both GET and POST per OIDC spec + r.Get(tbp(l.OAuth2UserInfo), h.oauth2Userinfo) + r.Post(tbp(l.OAuth2UserInfo), h.oauth2Userinfo) + // Wrapping SAML structs so we assure that fresh ones are always used in case // of settings changes. // diff --git a/server/auth/oauth2/user_authorizer.go b/server/auth/oauth2/user_authorizer.go index 4ed0f1a370..bb119bd18c 100644 --- a/server/auth/oauth2/user_authorizer.go +++ b/server/auth/oauth2/user_authorizer.go @@ -3,6 +3,7 @@ package oauth2 import ( "fmt" "net/http" + "net/url" "github.com/cortezaproject/corteza/server/auth/request" internalAuth "github.com/cortezaproject/corteza/server/pkg/auth" @@ -16,8 +17,41 @@ func NewUserAuthorizer(sm *request.SessionManager, loginURL, clientAuthURL strin ses = sm.Get(r) au = request.GetAuthUser(ses) client = request.GetOauth2Client(ses) + prompt = r.Form.Get("prompt") ) + // Handle prompt=none (OIDC silent authentication) + // Per OIDC Core 1.0 Section 3.1.2.1, when prompt=none the Authorization Server + // MUST NOT display any authentication or consent UI. If the user is not already + // authenticated or consent is required, an error must be returned. + if prompt == "none" { + redirectURI := r.Form.Get("redirect_uri") + state := r.Form.Get("state") + + if au == nil { + // User not logged in - return login_required error + redirectWithOIDCError(w, r, redirectURI, state, "login_required", "User is not authenticated") + return "", nil + } + + // Check if client authorization is needed + // Trusted clients don't require explicit consent + if client != nil && !client.Trusted && !request.IsOauth2ClientAuthorized(ses) { + // User logged in but consent is required for non-trusted client + redirectWithOIDCError(w, r, redirectURI, state, "interaction_required", "User consent is required") + return "", nil + } + + // User is authenticated and either: + // - Client is trusted (no consent needed) + // - Client was previously authorized in this session + // Mark client as authorized and continue with the flow + if client != nil { + request.SetOauth2ClientAuthorized(ses, true) + sm.Save(w, r) + } + } + // temporary break oauth2 flow by redirecting to // login form and ask user to authenticate request.SetOauth2AuthParams(ses, r.Form) @@ -76,3 +110,24 @@ func UserIDSerializer(userID uint64, rr ...uint64) string { return identity } + +// redirectWithOIDCError redirects to the client's redirect_uri with an OIDC error response +// Per OIDC Core 1.0 Section 3.1.2.6, errors are returned as query parameters +func redirectWithOIDCError(w http.ResponseWriter, r *http.Request, redirectURI, state, errorCode, errorDesc string) { + u, err := url.Parse(redirectURI) + if err != nil { + // If redirect_uri is invalid, we can't redirect - return a simple error page + http.Error(w, "Invalid redirect_uri", http.StatusBadRequest) + return + } + + q := u.Query() + q.Set("error", errorCode) + q.Set("error_description", errorDesc) + if state != "" { + q.Set("state", state) + } + u.RawQuery = q.Encode() + + http.Redirect(w, r, u.String(), http.StatusFound) +} diff --git a/server/auth/oauth2/user_authorizer_test.go b/server/auth/oauth2/user_authorizer_test.go new file mode 100644 index 0000000000..8567ab8684 --- /dev/null +++ b/server/auth/oauth2/user_authorizer_test.go @@ -0,0 +1,143 @@ +package oauth2 + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_redirectWithOIDCError(t *testing.T) { + testCases := []struct { + name string + redirectURI string + state string + errorCode string + errorDesc string + expectStatus int + expectParams map[string]string + }{ + { + name: "login_required error with state", + redirectURI: "https://client.example.com/callback", + state: "abc123", + errorCode: "login_required", + errorDesc: "User is not authenticated", + expectStatus: http.StatusFound, + expectParams: map[string]string{ + "error": "login_required", + "error_description": "User is not authenticated", + "state": "abc123", + }, + }, + { + name: "interaction_required error without state", + redirectURI: "https://client.example.com/callback", + state: "", + errorCode: "interaction_required", + errorDesc: "User consent is required", + expectStatus: http.StatusFound, + expectParams: map[string]string{ + "error": "interaction_required", + "error_description": "User consent is required", + }, + }, + { + name: "preserves existing query params", + redirectURI: "https://client.example.com/callback?existing=param", + state: "xyz", + errorCode: "access_denied", + errorDesc: "Access denied", + expectStatus: http.StatusFound, + expectParams: map[string]string{ + "error": "access_denied", + "error_description": "Access denied", + "state": "xyz", + "existing": "param", + }, + }, + { + name: "invalid redirect_uri returns bad request", + redirectURI: "://invalid-uri", + state: "", + errorCode: "server_error", + errorDesc: "Something went wrong", + expectStatus: http.StatusBadRequest, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rq := require.New(t) + + req := httptest.NewRequest(http.MethodGet, "/authorize", nil) + rec := httptest.NewRecorder() + + redirectWithOIDCError(rec, req, tc.redirectURI, tc.state, tc.errorCode, tc.errorDesc) + + rq.Equal(tc.expectStatus, rec.Code) + + if tc.expectStatus == http.StatusFound { + location := rec.Header().Get("Location") + rq.NotEmpty(location) + + parsedURL, err := url.Parse(location) + rq.NoError(err) + + query := parsedURL.Query() + for key, expectedValue := range tc.expectParams { + rq.Equal(expectedValue, query.Get(key), "query param %s mismatch", key) + } + + // State should not be present if it was empty + if tc.state == "" { + rq.Empty(query.Get("state")) + } + } + }) + } +} + +func Test_UserIDSerializer(t *testing.T) { + testCases := []struct { + name string + userID uint64 + roles []uint64 + expected string + }{ + { + name: "user ID only", + userID: 12345, + roles: nil, + expected: "12345", + }, + { + name: "user ID with one role", + userID: 12345, + roles: []uint64{100}, + expected: "12345 100", + }, + { + name: "user ID with multiple roles", + userID: 12345, + roles: []uint64{100, 200, 300}, + expected: "12345 100 200 300", + }, + { + name: "empty roles slice", + userID: 99999, + roles: []uint64{}, + expected: "99999", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rq := require.New(t) + result := UserIDSerializer(tc.userID, tc.roles...) + rq.Equal(tc.expected, result) + }) + } +}