package handler_test import ( "bytes" "encoding/json" "net/http" "net/url" "strings" "testing" ) type ssoWrappedResponse struct { Code int `json:"code"` Message string `json:"message"` Data json.RawMessage `json:"data"` } type ssoTokenPayload struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int64 `json:"expires_in"` Scope string `json:"scope"` } type ssoIntrospectPayload struct { Active bool `json:"active"` UserID int64 `json:"user_id"` Username string `json:"username"` Scope string `json:"scope"` } type ssoUserInfoPayload struct { UserID int64 `json:"user_id"` Username string `json:"username"` } func doSSOAuthorizeRequest(t *testing.T, rawURL, bearer string) *http.Response { t.Helper() req, err := http.NewRequest(http.MethodGet, rawURL, nil) if err != nil { t.Fatalf("build authorize request: %v", err) } if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { t.Fatalf("execute authorize request: %v", err) } return resp } func doSSOFormPost(t *testing.T, rawURL string, form url.Values, bearer string) (*http.Response, []byte) { t.Helper() req, err := http.NewRequest(http.MethodPost, rawURL, strings.NewReader(form.Encode())) if err != nil { t.Fatalf("build form request: %v", err) } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") if bearer != "" { req.Header.Set("Authorization", "Bearer "+bearer) } resp, err := (&http.Client{}).Do(req) if err != nil { t.Fatalf("execute form request: %v", err) } defer resp.Body.Close() body := new(bytes.Buffer) if _, err := body.ReadFrom(resp.Body); err != nil { t.Fatalf("read form response: %v", err) } return resp, body.Bytes() } func decodeSSOWrappedResponse(t *testing.T, body []byte) ssoWrappedResponse { t.Helper() var wrapped ssoWrappedResponse if err := json.Unmarshal(body, &wrapped); err != nil { t.Fatalf("decode wrapped response failed: %v body=%s", err, string(body)) } return wrapped } func extractAuthorizationCode(t *testing.T, location string) string { t.Helper() parsed, err := url.Parse(location) if err != nil { t.Fatalf("parse redirect location failed: %v", err) } code := parsed.Query().Get("code") if code == "" { t.Fatalf("redirect location missing code: %s", location) } return code } func issueSSOAuthCode(t *testing.T, serverURL, bearer string) string { t.Helper() resp := doSSOAuthorizeRequest(t, serverURL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&scope=profile&state=abc", bearer) defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("authorize expected 302, got %d", resp.StatusCode) } location := resp.Header.Get("Location") if location == "" { t.Fatal("authorize redirect missing Location header") } return extractAuthorizationCode(t, location) } func exchangeSSOToken(t *testing.T, serverURL, code, redirectURI string) ssoTokenPayload { t.Helper() resp, body := doSSOFormPost(t, serverURL+"/api/v1/sso/token", url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {"test-client"}, "client_secret": {"test-secret"}, "redirect_uri": {redirectURI}, }, "") if resp.StatusCode != http.StatusOK { t.Fatalf("token exchange expected 200, got %d body=%s", resp.StatusCode, string(body)) } wrapped := decodeSSOWrappedResponse(t, body) if wrapped.Code != 0 { t.Fatalf("token exchange expected code=0, got %d body=%s", wrapped.Code, string(body)) } var payload ssoTokenPayload if err := json.Unmarshal(wrapped.Data, &payload); err != nil { t.Fatalf("decode token payload failed: %v body=%s", err, string(body)) } if payload.AccessToken == "" { t.Fatalf("token exchange returned empty access token: %s", string(body)) } return payload } func TestSSOHandler_Authorize_CodeFlowRedirectsWithCodeAndState(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "ssouser", "sso@test.com", "Pass123!") platformToken := getToken(server.URL, "ssouser", "Pass123!") if platformToken == "" { t.Fatal("expected login token for authorize flow") } resp := doSSOAuthorizeRequest(t, server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&scope=profile&state=xyz", platformToken) defer resp.Body.Close() if resp.StatusCode != http.StatusFound { t.Fatalf("authorize expected 302, got %d", resp.StatusCode) } location := resp.Header.Get("Location") if location == "" { t.Fatal("authorize redirect missing Location header") } if !strings.Contains(location, "code=") { t.Fatalf("authorize redirect missing code: %s", location) } if !strings.Contains(location, "state=xyz") { t.Fatalf("authorize redirect missing state: %s", location) } } func TestSSOHandler_Authorize_ImplicitFlowRejected(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "ssouser2", "sso2@test.com", "Pass123!") platformToken := getToken(server.URL, "ssouser2", "Pass123!") if platformToken == "" { t.Fatal("expected login token for implicit rejection test") } resp := doSSOAuthorizeRequest(t, server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=token", platformToken) defer resp.Body.Close() if resp.StatusCode != http.StatusBadRequest { t.Fatalf("implicit flow expected 400, got %d", resp.StatusCode) } } func TestSSOHandler_Token_ExchangesWithoutPlatformBearerAuth(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "flowuser", "flow@test.com", "Pass123!") platformToken := getToken(server.URL, "flowuser", "Pass123!") if platformToken == "" { t.Fatal("expected login token for authorization") } code := issueSSOAuthCode(t, server.URL, platformToken) payload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback") if payload.TokenType != "Bearer" { t.Fatalf("unexpected token type: %q", payload.TokenType) } if payload.ExpiresIn <= 0 { t.Fatalf("unexpected expires_in: %d", payload.ExpiresIn) } } func TestSSOHandler_Token_RedirectURIMismatchRejected(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "mismatchuser", "mismatch@test.com", "Pass123!") platformToken := getToken(server.URL, "mismatchuser", "Pass123!") if platformToken == "" { t.Fatal("expected login token for authorization") } code := issueSSOAuthCode(t, server.URL, platformToken) resp, body := doSSOFormPost(t, server.URL+"/api/v1/sso/token", url.Values{ "grant_type": {"authorization_code"}, "code": {code}, "client_id": {"test-client"}, "client_secret": {"test-secret"}, "redirect_uri": {"http://localhost/other"}, }, "") if resp.StatusCode != http.StatusBadRequest { t.Fatalf("redirect mismatch expected 400, got %d body=%s", resp.StatusCode, string(body)) } } func TestSSOHandler_IntrospectAndRevokeUseClientCredentialsNotPlatformBearer(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "introspectuser", "introspect@test.com", "Pass123!") platformToken := getToken(server.URL, "introspectuser", "Pass123!") if platformToken == "" { t.Fatal("expected login token for authorization") } code := issueSSOAuthCode(t, server.URL, platformToken) tokenPayload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback") resp1, body1 := doSSOFormPost(t, server.URL+"/api/v1/sso/introspect", url.Values{ "token": {tokenPayload.AccessToken}, "client_id": {"test-client"}, "client_secret": {"test-secret"}, }, "") if resp1.StatusCode != http.StatusOK { t.Fatalf("introspect expected 200, got %d body=%s", resp1.StatusCode, string(body1)) } wrapped1 := decodeSSOWrappedResponse(t, body1) var introspect ssoIntrospectPayload if err := json.Unmarshal(wrapped1.Data, &introspect); err != nil { t.Fatalf("decode introspect payload failed: %v body=%s", err, string(body1)) } if !introspect.Active { t.Fatalf("expected active token in introspect response: %s", string(body1)) } if introspect.Username != "introspectuser" { t.Fatalf("unexpected introspect username: %q", introspect.Username) } resp2, body2 := doSSOFormPost(t, server.URL+"/api/v1/sso/revoke", url.Values{ "token": {tokenPayload.AccessToken}, "client_id": {"test-client"}, "client_secret": {"test-secret"}, }, "") if resp2.StatusCode != http.StatusOK { t.Fatalf("revoke expected 200, got %d body=%s", resp2.StatusCode, string(body2)) } resp3, body3 := doSSOFormPost(t, server.URL+"/api/v1/sso/introspect", url.Values{ "token": {tokenPayload.AccessToken}, "client_id": {"test-client"}, "client_secret": {"test-secret"}, }, "") if resp3.StatusCode != http.StatusOK { t.Fatalf("post-revoke introspect expected 200, got %d body=%s", resp3.StatusCode, string(body3)) } wrapped3 := decodeSSOWrappedResponse(t, body3) var revoked ssoIntrospectPayload if err := json.Unmarshal(wrapped3.Data, &revoked); err != nil { t.Fatalf("decode revoked introspect payload failed: %v body=%s", err, string(body3)) } if revoked.Active { t.Fatalf("expected revoked token to be inactive: %s", string(body3)) } } func TestSSOHandler_UserInfoUsesSSOAccessTokenSubject(t *testing.T) { server, cleanup := setupHandlerTestServer(t) defer cleanup() registerUser(server.URL, "userinfo-user", "userinfo@test.com", "Pass123!") platformToken := getToken(server.URL, "userinfo-user", "Pass123!") if platformToken == "" { t.Fatal("expected login token for authorization") } code := issueSSOAuthCode(t, server.URL, platformToken) tokenPayload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback") resp, body := doGet(server.URL+"/api/v1/sso/userinfo", tokenPayload.AccessToken) if resp.StatusCode != http.StatusOK { t.Fatalf("userinfo expected 200, got %d body=%s", resp.StatusCode, body) } wrapped := decodeSSOWrappedResponse(t, []byte(body)) var payload ssoUserInfoPayload if err := json.Unmarshal(wrapped.Data, &payload); err != nil { t.Fatalf("decode userinfo payload failed: %v body=%s", err, body) } if payload.Username != "userinfo-user" { t.Fatalf("unexpected userinfo username: %q body=%s", payload.Username, body) } if payload.UserID == 0 { t.Fatalf("userinfo user_id should be non-zero: %s", body) } }