856 lines
27 KiB
Go
856 lines
27 KiB
Go
|
|
package handler_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/json"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"net/url"
|
||
|
|
"strings"
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/gin-gonic/gin"
|
||
|
|
"github.com/user-management-system/internal/api/handler"
|
||
|
|
"github.com/user-management-system/internal/auth"
|
||
|
|
)
|
||
|
|
|
||
|
|
func doPostForm(targetURL, token string, data url.Values) (*http.Response, string) {
|
||
|
|
var bodyReader io.Reader
|
||
|
|
if data != nil {
|
||
|
|
bodyReader = strings.NewReader(data.Encode())
|
||
|
|
}
|
||
|
|
req, _ := http.NewRequest("POST", targetURL, bodyReader)
|
||
|
|
if token != "" {
|
||
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
client := &http.Client{}
|
||
|
|
resp, _ := client.Do(req)
|
||
|
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||
|
|
resp.Body.Close()
|
||
|
|
return resp, string(bodyBytes)
|
||
|
|
}
|
||
|
|
|
||
|
|
func setupSSOTestServer(t *testing.T) (*httptest.Server, func()) {
|
||
|
|
t.Helper()
|
||
|
|
gin.SetMode(gin.TestMode)
|
||
|
|
|
||
|
|
engine := gin.New()
|
||
|
|
engine.Use(gin.Recovery())
|
||
|
|
|
||
|
|
ssoManager := auth.NewSSOManager()
|
||
|
|
clientsStore := auth.NewDefaultSSOClientsStore()
|
||
|
|
clientsStore.RegisterClient(&auth.SSOClient{
|
||
|
|
ClientID: "test-client",
|
||
|
|
ClientSecret: "test-secret",
|
||
|
|
Name: "Test Client",
|
||
|
|
RedirectURIs: []string{"http://localhost:8080/callback"},
|
||
|
|
})
|
||
|
|
|
||
|
|
ssoHandler := handler.NewSSOHandler(ssoManager, clientsStore)
|
||
|
|
|
||
|
|
// Simple auth middleware for testing
|
||
|
|
authMiddleware := func() gin.HandlerFunc {
|
||
|
|
return func(c *gin.Context) {
|
||
|
|
token := c.GetHeader("Authorization")
|
||
|
|
if token == "" || token == "Bearer " {
|
||
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
c.Set("user_id", int64(1))
|
||
|
|
c.Set("username", "testuser")
|
||
|
|
c.Next()
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
ssoGroup := engine.Group("/api/v1/sso")
|
||
|
|
ssoGroup.Use(authMiddleware)
|
||
|
|
{
|
||
|
|
ssoGroup.GET("/authorize", ssoHandler.Authorize)
|
||
|
|
ssoGroup.POST("/token", ssoHandler.Token)
|
||
|
|
ssoGroup.POST("/introspect", ssoHandler.Introspect)
|
||
|
|
ssoGroup.POST("/revoke", ssoHandler.Revoke)
|
||
|
|
ssoGroup.GET("/userinfo", ssoHandler.UserInfo)
|
||
|
|
}
|
||
|
|
|
||
|
|
server := httptest.NewServer(engine)
|
||
|
|
return server, func() {
|
||
|
|
server.Close()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_MissingParams(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_UnsupportedResponseType(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=unsupported", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_Unauthorized(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_CodeFlow(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code&state=xyz", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("expected status %d (redirect), got %d", http.StatusFound, resp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
location := resp.Header.Get("Location")
|
||
|
|
if location == "" {
|
||
|
|
t.Fatal("expected redirect location")
|
||
|
|
}
|
||
|
|
if !strings.Contains(location, "code=") {
|
||
|
|
t.Errorf("expected redirect with code, got %s", location)
|
||
|
|
}
|
||
|
|
if !strings.Contains(location, "state=xyz") {
|
||
|
|
t.Errorf("expected redirect with state, got %s", location)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_InvalidRedirectURI(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://evil.com/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_TokenFlow(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=token&state=abc", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("expected status %d (redirect), got %d", http.StatusFound, resp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
location := resp.Header.Get("Location")
|
||
|
|
if location == "" {
|
||
|
|
t.Fatal("expected redirect location")
|
||
|
|
}
|
||
|
|
if !strings.Contains(location, "access_token=") {
|
||
|
|
t.Errorf("expected redirect with access_token, got %s", location)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_MissingParams(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", nil)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_InvalidGrantType(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "password")
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_InvalidClient(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", "some-code")
|
||
|
|
formData.Set("client_id", "invalid-client")
|
||
|
|
formData.Set("client_secret", "wrong-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_InvalidCode(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", "invalid-code")
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_Success(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// First authorize to get a code
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
if authResp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("expected authorize redirect, got %d", authResp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, err := url.Parse(location)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("failed to parse redirect URL: %v", err)
|
||
|
|
}
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
if code == "" {
|
||
|
|
t.Fatal("expected authorization code in redirect")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Exchange code for token
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", code)
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
var tokenResp handler.TokenResponse
|
||
|
|
if err := json.Unmarshal([]byte(body), &tokenResp); err != nil {
|
||
|
|
t.Fatalf("failed to parse token response: %v", err)
|
||
|
|
}
|
||
|
|
if tokenResp.AccessToken == "" {
|
||
|
|
t.Errorf("expected access_token in response")
|
||
|
|
}
|
||
|
|
if tokenResp.TokenType != "Bearer" {
|
||
|
|
t.Errorf("expected token_type Bearer, got %s", tokenResp.TokenType)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Introspect_MissingToken(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Introspect_InvalidToken(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": "invalid-token",
|
||
|
|
})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
var result handler.IntrospectResponse
|
||
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||
|
|
t.Fatalf("failed to parse introspect response: %v", err)
|
||
|
|
}
|
||
|
|
if result.Active != false {
|
||
|
|
t.Errorf("expected active=false for invalid token, got %v", result.Active)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Introspect_ValidToken(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Authorize and get token
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, _ := url.Parse(location)
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
|
||
|
|
tokenForm := url.Values{}
|
||
|
|
tokenForm.Set("grant_type", "authorization_code")
|
||
|
|
tokenForm.Set("code", code)
|
||
|
|
tokenForm.Set("client_id", "test-client")
|
||
|
|
tokenForm.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
|
||
|
|
defer tokenResp.Body.Close()
|
||
|
|
|
||
|
|
if tokenResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var tokenResult handler.TokenResponse
|
||
|
|
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse token response: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Introspect the token
|
||
|
|
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
var result handler.IntrospectResponse
|
||
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||
|
|
t.Fatalf("failed to parse introspect response: %v", err)
|
||
|
|
}
|
||
|
|
if result.Active != true {
|
||
|
|
t.Errorf("expected active=true for valid token, got %v", result.Active)
|
||
|
|
}
|
||
|
|
if result.UserID != 1 {
|
||
|
|
t.Errorf("expected user_id=1, got %d", result.UserID)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Revoke_MissingToken(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Revoke_Success(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Authorize and get token
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, _ := url.Parse(location)
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
|
||
|
|
tokenForm := url.Values{}
|
||
|
|
tokenForm.Set("grant_type", "authorization_code")
|
||
|
|
tokenForm.Set("code", code)
|
||
|
|
tokenForm.Set("client_id", "test-client")
|
||
|
|
tokenForm.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
|
||
|
|
defer tokenResp.Body.Close()
|
||
|
|
|
||
|
|
if tokenResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var tokenResult handler.TokenResponse
|
||
|
|
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse token response: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Revoke the token
|
||
|
|
resp, body := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Verify token is revoked
|
||
|
|
introspectResp, introspectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer introspectResp.Body.Close()
|
||
|
|
|
||
|
|
if introspectResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("introspect failed: status=%d body=%s", introspectResp.StatusCode, introspectBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var introspectResult handler.IntrospectResponse
|
||
|
|
if err := json.Unmarshal([]byte(introspectBody), &introspectResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse introspect response: %v", err)
|
||
|
|
}
|
||
|
|
if introspectResult.Active != false {
|
||
|
|
t.Errorf("expected active=false after revoke, got %v", introspectResult.Active)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_UserInfo_Unauthorized(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/userinfo", "")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_UserInfo_Success(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/userinfo", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
|
||
|
|
var result map[string]interface{}
|
||
|
|
if err := json.Unmarshal([]byte(body), &result); err != nil {
|
||
|
|
t.Fatalf("failed to parse response: %v", err)
|
||
|
|
}
|
||
|
|
if result["code"] != float64(0) {
|
||
|
|
t.Errorf("expected code 0, got %v", result["code"])
|
||
|
|
}
|
||
|
|
|
||
|
|
data, ok := result["data"].(map[string]interface{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("expected data in response, got %s", body)
|
||
|
|
}
|
||
|
|
if data["user_id"] != float64(1) {
|
||
|
|
t.Errorf("expected user_id=1, got %v", data["user_id"])
|
||
|
|
}
|
||
|
|
if data["username"] != "testuser" {
|
||
|
|
t.Errorf("expected username=testuser, got %v", data["username"])
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_InvalidClientSecret(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Authorize to get a code
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, _ := url.Parse(location)
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", code)
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "wrong-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_MissingClientID(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize?redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Introspect_FormData(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Test that introspect accepts form-encoded data
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("token", "some-token")
|
||
|
|
|
||
|
|
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/introspect", strings.NewReader(formData.Encode()))
|
||
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
client := &http.Client{}
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("request failed: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
bodyBytes, _ := json.Marshal(resp.Body)
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_FormData(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Authorize to get a code
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, _ := url.Parse(location)
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
|
||
|
|
// Test that token accepts form-encoded data
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", code)
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/token", strings.NewReader(formData.Encode()))
|
||
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
client := &http.Client{}
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("request failed: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
bodyBytes, _ := json.Marshal(resp.Body)
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Revoke_FormData(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("token", "some-token")
|
||
|
|
|
||
|
|
req, _ := http.NewRequest("POST", server.URL+"/api/v1/sso/revoke", strings.NewReader(formData.Encode()))
|
||
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
client := &http.Client{}
|
||
|
|
resp, err := client.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("request failed: %v", err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
bodyBytes, _ := json.Marshal(resp.Body)
|
||
|
|
t.Fatalf("expected status %d, got %d, body: %s", http.StatusOK, resp.StatusCode, string(bodyBytes))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_UnknownClientID(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=unknown-client&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
// When client is unknown, redirect_uri validation fails
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_WithoutAuth(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("code", "some-code")
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
resp, _ := doPostForm(server.URL+"/api/v1/sso/token", "", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_UserInfo_WithoutAuth(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/userinfo", "")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Introspect_WithoutAuth(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doPost(server.URL+"/api/v1/sso/introspect", "", map[string]interface{}{
|
||
|
|
"token": "some-token",
|
||
|
|
})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Revoke_WithoutAuth(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
resp, _ := doPost(server.URL+"/api/v1/sso/revoke", "", map[string]interface{}{
|
||
|
|
"token": "some-token",
|
||
|
|
})
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d", http.StatusUnauthorized, resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_InvalidClientID(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Test with valid redirect URI but unknown client
|
||
|
|
resp, body := doGet(server.URL+"/api/v1/sso/authorize?client_id=unknown&redirect_uri=http://localhost:8080/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusBadRequest {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusBadRequest, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Token_MissingCode(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
formData := url.Values{}
|
||
|
|
formData.Set("grant_type", "authorization_code")
|
||
|
|
formData.Set("client_id", "test-client")
|
||
|
|
formData.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
resp, body := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", formData)
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
// Code is empty, so validate should fail
|
||
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
||
|
|
t.Errorf("expected status %d, got %d, body: %s", http.StatusUnauthorized, resp.StatusCode, body)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_FullFlow(t *testing.T) {
|
||
|
|
server, cleanup := setupSSOTestServer(t)
|
||
|
|
defer cleanup()
|
||
|
|
|
||
|
|
// Step 1: Authorize
|
||
|
|
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost:8080/callback&response_type=code&state=my-state", "Bearer test-token")
|
||
|
|
defer authResp.Body.Close()
|
||
|
|
|
||
|
|
if authResp.StatusCode != http.StatusFound {
|
||
|
|
t.Fatalf("authorize failed: status=%d", authResp.StatusCode)
|
||
|
|
}
|
||
|
|
|
||
|
|
location := authResp.Header.Get("Location")
|
||
|
|
parsedURL, _ := url.Parse(location)
|
||
|
|
code := parsedURL.Query().Get("code")
|
||
|
|
state := parsedURL.Query().Get("state")
|
||
|
|
if code == "" {
|
||
|
|
t.Fatal("expected authorization code")
|
||
|
|
}
|
||
|
|
if state != "my-state" {
|
||
|
|
t.Errorf("expected state=my-state, got %s", state)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 2: Exchange code for token
|
||
|
|
tokenForm := url.Values{}
|
||
|
|
tokenForm.Set("grant_type", "authorization_code")
|
||
|
|
tokenForm.Set("code", code)
|
||
|
|
tokenForm.Set("client_id", "test-client")
|
||
|
|
tokenForm.Set("client_secret", "test-secret")
|
||
|
|
|
||
|
|
tokenResp, tokenBody := doPostForm(server.URL+"/api/v1/sso/token", "Bearer test-token", tokenForm)
|
||
|
|
defer tokenResp.Body.Close()
|
||
|
|
|
||
|
|
if tokenResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("token exchange failed: status=%d body=%s", tokenResp.StatusCode, tokenBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var tokenResult handler.TokenResponse
|
||
|
|
if err := json.Unmarshal([]byte(tokenBody), &tokenResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse token response: %v", err)
|
||
|
|
}
|
||
|
|
if tokenResult.AccessToken == "" {
|
||
|
|
t.Fatal("expected access_token")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 3: Introspect token
|
||
|
|
introspectResp, introspectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer introspectResp.Body.Close()
|
||
|
|
|
||
|
|
if introspectResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("introspect failed: status=%d body=%s", introspectResp.StatusCode, introspectBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var introspectResult handler.IntrospectResponse
|
||
|
|
if err := json.Unmarshal([]byte(introspectBody), &introspectResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse introspect response: %v", err)
|
||
|
|
}
|
||
|
|
if !introspectResult.Active {
|
||
|
|
t.Error("expected token to be active")
|
||
|
|
}
|
||
|
|
if introspectResult.UserID != 1 {
|
||
|
|
t.Errorf("expected user_id=1, got %d", introspectResult.UserID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 4: Get userinfo
|
||
|
|
userinfoResp, userinfoBody := doGet(server.URL+"/api/v1/sso/userinfo", "Bearer test-token")
|
||
|
|
defer userinfoResp.Body.Close()
|
||
|
|
|
||
|
|
if userinfoResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("userinfo failed: status=%d body=%s", userinfoResp.StatusCode, userinfoBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var userinfoResult map[string]interface{}
|
||
|
|
if err := json.Unmarshal([]byte(userinfoBody), &userinfoResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse userinfo response: %v", err)
|
||
|
|
}
|
||
|
|
userinfoData, ok := userinfoResult["data"].(map[string]interface{})
|
||
|
|
if !ok {
|
||
|
|
t.Fatalf("expected userinfo data, got %s", userinfoBody)
|
||
|
|
}
|
||
|
|
if userinfoData["username"] != "testuser" {
|
||
|
|
t.Errorf("expected username=testuser, got %v", userinfoData["username"])
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 5: Revoke token
|
||
|
|
revokeResp, revokeBody := doPost(server.URL+"/api/v1/sso/revoke", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer revokeResp.Body.Close()
|
||
|
|
|
||
|
|
if revokeResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("revoke failed: status=%d body=%s", revokeResp.StatusCode, revokeBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Step 6: Verify token is revoked
|
||
|
|
finalIntrospectResp, finalIntrospectBody := doPost(server.URL+"/api/v1/sso/introspect", "Bearer test-token", map[string]interface{}{
|
||
|
|
"token": tokenResult.AccessToken,
|
||
|
|
})
|
||
|
|
defer finalIntrospectResp.Body.Close()
|
||
|
|
|
||
|
|
if finalIntrospectResp.StatusCode != http.StatusOK {
|
||
|
|
t.Fatalf("final introspect failed: status=%d body=%s", finalIntrospectResp.StatusCode, finalIntrospectBody)
|
||
|
|
}
|
||
|
|
|
||
|
|
var finalResult handler.IntrospectResponse
|
||
|
|
if err := json.Unmarshal([]byte(finalIntrospectBody), &finalResult); err != nil {
|
||
|
|
t.Fatalf("failed to parse final introspect response: %v", err)
|
||
|
|
}
|
||
|
|
if finalResult.Active {
|
||
|
|
t.Error("expected token to be inactive after revoke")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestSSOHandler_Authorize_NoClientStore(t *testing.T) {
|
||
|
|
gin.SetMode(gin.TestMode)
|
||
|
|
|
||
|
|
engine := gin.New()
|
||
|
|
ssoManager := auth.NewSSOManager()
|
||
|
|
// Pass nil clientsStore
|
||
|
|
ssoHandler := handler.NewSSOHandler(ssoManager, nil)
|
||
|
|
|
||
|
|
authMiddleware := func() gin.HandlerFunc {
|
||
|
|
return func(c *gin.Context) {
|
||
|
|
c.Set("user_id", int64(1))
|
||
|
|
c.Set("username", "testuser")
|
||
|
|
c.Next()
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
ssoGroup := engine.Group("/api/v1/sso")
|
||
|
|
ssoGroup.Use(authMiddleware)
|
||
|
|
{
|
||
|
|
ssoGroup.GET("/authorize", ssoHandler.Authorize)
|
||
|
|
}
|
||
|
|
|
||
|
|
server := httptest.NewServer(engine)
|
||
|
|
defer server.Close()
|
||
|
|
|
||
|
|
// Without clients store, any redirect_uri should be accepted (or validation skipped)
|
||
|
|
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=any&redirect_uri=http://any.com/callback&response_type=code", "Bearer test-token")
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusFound {
|
||
|
|
t.Errorf("expected redirect when clientsStore is nil, got %d", resp.StatusCode)
|
||
|
|
}
|
||
|
|
}
|