328 lines
10 KiB
Go
328 lines
10 KiB
Go
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)
|
|
}
|
|
}
|