443 lines
14 KiB
Go
443 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"wucher/internal/config"
|
|
"wucher/internal/resilience"
|
|
)
|
|
|
|
func TestNewMicrosoftSSO(t *testing.T) {
|
|
cfg := config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: "https://login.microsoftonline.com/",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
}
|
|
|
|
client := NewMicrosoftSSO(cfg)
|
|
if client == nil {
|
|
t.Fatalf("expected client")
|
|
}
|
|
if client.cfg.ClientID != cfg.ClientID {
|
|
t.Fatalf("client config not set")
|
|
}
|
|
if client.client == nil {
|
|
t.Fatalf("http client should not be nil")
|
|
}
|
|
if client.client.Timeout != 10*time.Second {
|
|
t.Fatalf("unexpected timeout: %v", client.client.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestMicrosoftSSO_AuthCodeURL(t *testing.T) {
|
|
cfg := config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: "https://login.microsoftonline.com/",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
}
|
|
client := NewMicrosoftSSO(cfg)
|
|
|
|
raw := client.AuthCodeURL("state-123")
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse url: %v", err)
|
|
}
|
|
if parsed.Path != "/tenant-id/oauth2/v2.0/authorize" {
|
|
t.Fatalf("unexpected authorize path: %s", parsed.Path)
|
|
}
|
|
q := parsed.Query()
|
|
if q.Get("client_id") != "client-id" ||
|
|
q.Get("response_type") != "code" ||
|
|
q.Get("redirect_uri") != "http://localhost/callback" ||
|
|
q.Get("response_mode") != "query" ||
|
|
q.Get("scope") != "openid profile email offline_access User.Read" ||
|
|
q.Get("prompt") != "select_account" ||
|
|
q.Get("state") != "state-123" {
|
|
t.Fatalf("unexpected query values: %v", q)
|
|
}
|
|
if got := q.Get("max_age"); got != "" {
|
|
t.Fatalf("max_age should not be set, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMicrosoftSSO_LogoutURL(t *testing.T) {
|
|
cfg := config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: "https://login.microsoftonline.com/",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
}
|
|
client := NewMicrosoftSSO(cfg)
|
|
|
|
raw := client.LogoutURL("http://localhost/logout-done")
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil {
|
|
t.Fatalf("parse url: %v", err)
|
|
}
|
|
if parsed.Path != "/tenant-id/oauth2/v2.0/logout" {
|
|
t.Fatalf("unexpected logout path: %s", parsed.Path)
|
|
}
|
|
if got := parsed.Query().Get("post_logout_redirect_uri"); got != "http://localhost/logout-done" {
|
|
t.Fatalf("unexpected post logout redirect: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestMicrosoftSSO_ExchangeCode(t *testing.T) {
|
|
t.Run("missing code", func(t *testing.T) {
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{})
|
|
if _, err := client.ExchangeCode(context.Background(), ""); err == nil {
|
|
t.Fatalf("expected error for missing code")
|
|
}
|
|
})
|
|
|
|
t.Run("token endpoint non 2xx", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
|
|
t.Fatalf("expected token exchange failed error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid json response", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{invalid`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil {
|
|
t.Fatalf("expected unmarshal error")
|
|
}
|
|
})
|
|
|
|
t.Run("missing id token", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"abc"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "missing id_token") {
|
|
t.Fatalf("expected missing id_token error, got %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid id token claims", func(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id_token":"invalid-token"}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil {
|
|
t.Fatalf("expected parse jwt error")
|
|
}
|
|
})
|
|
|
|
t.Run("success with email claim", func(t *testing.T) {
|
|
idToken := makeUnsignedJWT(map[string]any{
|
|
"sub": "subject-1",
|
|
"email": "user@example.com",
|
|
"name": "John Doe",
|
|
})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
t.Fatalf("expected POST")
|
|
}
|
|
if r.URL.Path != "/tenant-id/oauth2/v2.0/token" {
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
t.Fatalf("parse form: %v", err)
|
|
}
|
|
if r.PostForm.Get("client_id") != "client-id" ||
|
|
r.PostForm.Get("client_secret") != "secret" ||
|
|
r.PostForm.Get("grant_type") != "authorization_code" ||
|
|
r.PostForm.Get("code") != "code-1" ||
|
|
r.PostForm.Get("redirect_uri") != "http://localhost/callback" ||
|
|
r.PostForm.Get("scope") != "openid profile email offline_access User.Read" {
|
|
t.Fatalf("unexpected form values: %v", r.PostForm)
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-1"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid", "profile", "email"},
|
|
})
|
|
|
|
claims, err := client.ExchangeCode(context.Background(), "code-1")
|
|
if err != nil {
|
|
t.Fatalf("exchange code failed: %v", err)
|
|
}
|
|
if claims.Subject != "subject-1" || claims.Email != "user@example.com" || claims.Name != "John Doe" {
|
|
t.Fatalf("unexpected claims: %+v", claims)
|
|
}
|
|
})
|
|
|
|
t.Run("success fallback preferred username", func(t *testing.T) {
|
|
idToken := makeUnsignedJWT(map[string]any{
|
|
"sub": "subject-2",
|
|
"preferred_username": "fallback@example.com",
|
|
"name": "Jane Doe",
|
|
})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"id_token": idToken, "access_token": "at-2"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
|
|
claims, err := client.ExchangeCode(context.Background(), "code-1")
|
|
if err != nil {
|
|
t.Fatalf("exchange code failed: %v", err)
|
|
}
|
|
if claims.Email != "fallback@example.com" {
|
|
t.Fatalf("expected preferred_username fallback, got %q", claims.Email)
|
|
}
|
|
})
|
|
|
|
t.Run("http client do error", func(t *testing.T) {
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: "https://example.com/",
|
|
Scopes: []string{"openid"},
|
|
})
|
|
client.client = &http.Client{
|
|
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("network down")
|
|
}),
|
|
}
|
|
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "network down") {
|
|
t.Fatalf("expected http client error, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestMicrosoftSSO_ExchangeCode_CircuitBreaker(t *testing.T) {
|
|
newExecutor := func() *resilience.Executor {
|
|
return resilience.NewExecutor("microsoft_sso", config.CircuitBreakerPolicy{
|
|
Enabled: true,
|
|
Timeout: time.Second,
|
|
MinRequests: 2,
|
|
FailureRatio: 0.5,
|
|
ConsecutiveFailures: 2,
|
|
}, slog.New(slog.NewJSONHandler(io.Discard, nil)), resilience.ClassifyMicrosoftSSOError)
|
|
}
|
|
|
|
t.Run("client errors are excluded", func(t *testing.T) {
|
|
hits := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
http.Error(w, "bad request", http.StatusBadRequest)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
}).WithExecutor(newExecutor())
|
|
|
|
for range 3 {
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
|
|
t.Fatalf("expected token exchange failed error, got %v", err)
|
|
}
|
|
}
|
|
if hits != 3 {
|
|
t.Fatalf("expected breaker to stay closed for excluded client errors, got %d hits", hits)
|
|
}
|
|
})
|
|
|
|
t.Run("server errors open the breaker", func(t *testing.T) {
|
|
hits := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hits++
|
|
http.Error(w, "unavailable", http.StatusServiceUnavailable)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewMicrosoftSSO(config.MicrosoftSSOConfig{
|
|
TenantID: "tenant-id",
|
|
ClientID: "client-id",
|
|
ClientSecret: "secret",
|
|
RedirectURL: "http://localhost/callback",
|
|
Authority: server.URL + "/",
|
|
Scopes: []string{"openid"},
|
|
}).WithExecutor(newExecutor())
|
|
|
|
for range 2 {
|
|
if _, err := client.ExchangeCode(context.Background(), "code-1"); err == nil || !strings.Contains(err.Error(), "token exchange failed") {
|
|
t.Fatalf("expected token exchange failed error, got %v", err)
|
|
}
|
|
}
|
|
_, err := client.ExchangeCode(context.Background(), "code-1")
|
|
var openErr *resilience.OpenError
|
|
if !errors.As(err, &openErr) {
|
|
t.Fatalf("expected circuit open error, got %v", err)
|
|
}
|
|
if hits != 2 {
|
|
t.Fatalf("expected breaker to fail fast after opening, got %d hits", hits)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestParseJWTClaims(t *testing.T) {
|
|
t.Run("invalid jwt format", func(t *testing.T) {
|
|
if _, err := parseJWTClaims("invalid"); err == nil {
|
|
t.Fatalf("expected invalid jwt error")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid payload encoding", func(t *testing.T) {
|
|
if _, err := parseJWTClaims("header.%%%.sig"); err == nil {
|
|
t.Fatalf("expected decode error")
|
|
}
|
|
})
|
|
|
|
t.Run("invalid payload json", func(t *testing.T) {
|
|
payload := base64.RawURLEncoding.EncodeToString([]byte("not-json"))
|
|
if _, err := parseJWTClaims("header." + payload + ".sig"); err == nil {
|
|
t.Fatalf("expected json unmarshal error")
|
|
}
|
|
})
|
|
|
|
t.Run("success", func(t *testing.T) {
|
|
token := makeUnsignedJWT(map[string]any{
|
|
"sub": "subject-1",
|
|
"email": "user@example.com",
|
|
"name": "John Doe",
|
|
})
|
|
claims, err := parseJWTClaims(token)
|
|
if err != nil {
|
|
t.Fatalf("parse claims failed: %v", err)
|
|
}
|
|
if claims["sub"] != "subject-1" || claims["email"] != "user@example.com" {
|
|
t.Fatalf("unexpected claims: %+v", claims)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestDecodeSegment(t *testing.T) {
|
|
t.Run("success", func(t *testing.T) {
|
|
raw := base64.RawURLEncoding.EncodeToString([]byte("hello"))
|
|
got, err := decodeSegment(raw)
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
if string(got) != "hello" {
|
|
t.Fatalf("unexpected value: %q", string(got))
|
|
}
|
|
})
|
|
|
|
t.Run("success url safe alphabet", func(t *testing.T) {
|
|
got, err := decodeSegment("--__")
|
|
if err != nil {
|
|
t.Fatalf("decode failed: %v", err)
|
|
}
|
|
want := []byte{0xfb, 0xef, 0xff}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("unexpected length: %d", len(got))
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("unexpected byte at %d: got %x want %x", i, got[i], want[i])
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("invalid base64", func(t *testing.T) {
|
|
if _, err := decodeSegment("%"); err == nil {
|
|
t.Fatalf("expected decode error")
|
|
}
|
|
})
|
|
}
|
|
|
|
type roundTripFunc func(req *http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) }
|
|
|
|
func makeUnsignedJWT(claims map[string]any) string {
|
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
|
payloadBytes, _ := json.Marshal(claims)
|
|
payload := base64.RawURLEncoding.EncodeToString(payloadBytes)
|
|
return header + "." + payload + ".sig"
|
|
}
|