package xguard import ( "context" "crypto/hmac" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "net/http/httptest" "net/url" "strconv" "strings" "testing" "time" jose "github.com/go-jose/go-jose/v4" "github.com/go-jose/go-jose/v4/jwt" ) // fakeSSO: X-Guard palsu (discovery, JWKS, token endpoint). type fakeSSO struct { srv *httptest.Server key *rsa.PrivateKey nonce string // nonce dari authorize terakhir, dipakai di id_token extra map[string]any // klaim tambahan id_token } func newFakeSSO(t *testing.T) *fakeSSO { t.Helper() key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatal(err) } f := &fakeSSO{key: key, extra: map[string]any{}} mux := http.NewServeMux() mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(map[string]any{ "issuer": f.srv.URL, "authorization_endpoint": f.srv.URL + "/authorize", "token_endpoint": f.srv.URL + "/oauth/token", "jwks_uri": f.srv.URL + "/keys", "end_session_endpoint": f.srv.URL + "/end_session", "id_token_signing_alg_values_supported": []string{"RS256"}, }) }) mux.HandleFunc("/keys", func(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{Key: &key.PublicKey, KeyID: "k1", Algorithm: "RS256", Use: "sig"}}}) }) mux.HandleFunc("/oauth/token", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") id, pw, _ := r.BasicAuth() if id != "app" || pw != "rahasia-client-yang-panjang-sekali-123" || r.FormValue("code") != "kode-benar" || r.FormValue("code_verifier") == "" { w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant"}) return } claims := map[string]any{"nonce": f.nonce, "email": "Nama@API-Informatics.com", "email_verified": true, "name": "Nama Uji", "amr": []string{"pwd"}, "sid": "sesi-1", "auth_time": time.Now().Unix()} for k, v := range f.extra { claims[k] = v } _ = json.NewEncoder(w).Encode(map[string]any{ "access_token": f.sign(t, "user-1", []string{"app"}, time.Now().Add(5*time.Minute), map[string]any{ "client_id": "app", "org": "acme", "scp": []string{"openid", "profile", "email"}}), "token_type": "bearer", "expires_in": 300, "id_token": f.sign(t, "user-1", []string{"app"}, time.Now().Add(15*time.Minute), claims), }) }) f.srv = httptest.NewServer(mux) t.Cleanup(f.srv.Close) return f } func (f *fakeSSO) sign(t *testing.T, sub string, aud []string, exp time.Time, extra map[string]any) string { t.Helper() sig, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: f.key}, (&jose.SignerOptions{}).WithHeader("kid", "k1")) if err != nil { t.Fatal(err) } std := jwt.Claims{Issuer: f.srv.URL, Subject: sub, Audience: aud, IssuedAt: jwt.NewNumericDate(time.Now()), Expiry: jwt.NewNumericDate(exp)} raw, err := jwt.Signed(sig).Claims(std).Claims(extra).Serialize() if err != nil { t.Fatal(err) } return raw } func newTestClient(t *testing.T, f *fakeSSO) *Client { t.Helper() c, err := New(context.Background(), Config{ Issuer: f.srv.URL, ClientID: "app", ClientSecret: "rahasia-client-yang-panjang-sekali-123", RedirectURL: "https://app.test/auth/sso/callback", PostLogoutRedirectURL: "https://app.test/", }) if err != nil { t.Fatal(err) } return c } func TestLoginFlow(t *testing.T) { f := newFakeSSO(t) c := newTestClient(t, f) // 1. StartLogin → redirect ke authorize dengan PKCE S256, nonce, state + cookie. rec := httptest.NewRecorder() c.StartLogin(rec, httptest.NewRequest("GET", "/login", nil), "/dokumen?id=7") loc, _ := url.Parse(rec.Header().Get("Location")) q := loc.Query() if loc.Path != "/authorize" || q.Get("code_challenge_method") != "S256" || q.Get("code_challenge") == "" || q.Get("nonce") == "" { t.Fatalf("authorize URL tidak lengkap: %s", loc) } if got := q.Get("scope"); got != "openid profile email" { t.Fatalf("scope = %q", got) } cookies := rec.Result().Cookies() if len(cookies) != 1 || !cookies[0].HttpOnly || !cookies[0].Secure || cookies[0].SameSite != http.SameSiteLaxMode { t.Fatalf("cookie login tidak aman: %+v", cookies) } f.nonce = q.Get("nonce") callback := func(state, code string, withCookie bool) (*Identity, string, error) { r := httptest.NewRequest("GET", "/auth/sso/callback?state="+url.QueryEscape(state)+"&code="+code, nil) if withCookie { r.AddCookie(cookies[0]) } return c.HandleCallback(httptest.NewRecorder(), r) } // 2. Callback benar → Identity. id, ret, err := callback(q.Get("state"), "kode-benar", true) if err != nil { t.Fatal(err) } if id.Subject != "user-1" || id.Email != "nama@api-informatics.com" || id.Name != "Nama Uji" || id.SessionID != "sesi-1" || ret != "/dokumen?id=7" { t.Fatalf("identity salah: %+v ret=%s", id, ret) } if id.Org != "acme" || !id.EmailVerified || len(id.AMR) != 1 { t.Fatalf("organisasi dari access token, email terverifikasi, amr: %+v", id) } if id.Token == nil || id.Token.AccessToken == "" { t.Fatalf("Identity.Token kosong: %+v", id.Token) } if q.Get("audience") != "" { t.Fatalf("audience tanpa Config.Audience: %q", q.Get("audience")) } if !strings.Contains(c.LogoutURL(id.IDToken), "post_logout_redirect_uri=https%3A%2F%2Fapp.test%2F") { t.Fatalf("LogoutURL tanpa post_logout_redirect_uri: %s", c.LogoutURL(id.IDToken)) } // 3. Kasus gagal. if _, _, err := callback(q.Get("state"), "kode-benar", false); err == nil { t.Fatal("tanpa cookie harus gagal") } if _, _, err := callback(strings.Repeat("x", 43), "kode-benar", true); err == nil { t.Fatal("state lain harus gagal") } f.nonce = "nonce-lain" if _, _, err := callback(q.Get("state"), "kode-benar", true); err == nil { t.Fatal("nonce tidak cocok harus gagal") } // Error dari X-Guard tanpa state yang cocok tidak dipercaya. r := httptest.NewRequest("GET", "/cb?error=access_denied&error_description=Akun+tidak+aktif", nil) if _, _, err := c.HandleCallback(httptest.NewRecorder(), r); err == nil || err.(*AuthError).Code != "invalid_state" { t.Fatalf("error tanpa state harus invalid_state, dapat %v", err) } } func TestSilentLogin(t *testing.T) { f := newFakeSSO(t) c := newTestClient(t, f) start := func(opts ...LoginOption) (url.Values, *http.Cookie) { rec := httptest.NewRecorder() c.StartLogin(rec, httptest.NewRequest("GET", "/login", nil), "/proyek/9", opts...) loc, _ := url.Parse(rec.Header().Get("Location")) return loc.Query(), rec.Result().Cookies()[0] } if q, _ := start(); q.Get("prompt") != "" { t.Fatalf("login biasa tanpa prompt, dapat %q", q.Get("prompt")) } q, ck := start(Silent()) if q.Get("prompt") != "none" { t.Fatalf("Silent: prompt = %q", q.Get("prompt")) } errCallback := func(state, code string, ck *http.Cookie) (string, error) { r := httptest.NewRequest("GET", "/cb?error="+code+"&state="+url.QueryEscape(state), nil) if ck != nil { r.AddCookie(ck) } _, ret, err := c.HandleCallback(httptest.NewRecorder(), r) return ret, err } ret, err := errCallback(q.Get("state"), "login_required", ck) if !errors.Is(err, ErrLoginRequired) || errors.Is(err, ErrAccessDenied) || ret != "/proyek/9" { t.Fatalf("login_required: err=%v ret=%q", err, ret) } for _, code := range []string{"interaction_required", "consent_required", "account_selection_required"} { q, ck := start(Silent()) if _, err := errCallback(q.Get("state"), code, ck); !errors.Is(err, ErrLoginRequired) { t.Fatalf("%s harus ErrLoginRequired: %v", code, err) } } q, ck = start(Silent()) ret, err = errCallback(q.Get("state"), "access_denied", ck) if !errors.Is(err, ErrAccessDenied) || errors.Is(err, ErrLoginRequired) || ret != "/proyek/9" { t.Fatalf("access_denied: err=%v ret=%q", err, ret) } // State palsu / tanpa cookie: bukan ErrLoginRequired (mencegah loop paksa dari luar). if _, err := errCallback(q.Get("state"), "login_required", nil); errors.Is(err, ErrLoginRequired) { t.Fatal("error tanpa cookie state tidak boleh dipercaya") } } func TestAudienceParam(t *testing.T) { f := newFakeSSO(t) c, err := New(context.Background(), Config{ Issuer: f.srv.URL, ClientID: "app", ClientSecret: "rahasia-client-yang-panjang-sekali-123", RedirectURL: "https://app.test/auth/sso/callback", Audience: []string{"api-dokumen", "api-gaji"}, }) if err != nil { t.Fatal(err) } rec := httptest.NewRecorder() c.StartLogin(rec, httptest.NewRequest("GET", "/login", nil), "/") loc, _ := url.Parse(rec.Header().Get("Location")) if got := loc.Query().Get("audience"); got != "api-dokumen api-gaji" { t.Fatalf("audience = %q", got) } } func TestSafePath(t *testing.T) { for in, want := range map[string]string{"/a?b=1": "/a?b=1", "": "/", "https://jahat.test": "/", "//jahat.test": "/", "/\\jahat": "/"} { if got := safePath(in); got != want { t.Errorf("safePath(%q) = %q, mau %q", in, got, want) } } } func TestVerifyLogoutToken(t *testing.T) { f := newFakeSSO(t) c := newTestClient(t, f) ev := map[string]any{backchannelEvent: map[string]any{}} ok := func(extra map[string]any) map[string]any { m := map[string]any{"events": ev, "sid": "sesi-1", "jti": "j1"} for k, v := range extra { m[k] = v } return m } exp := time.Now().Add(time.Minute) lt, err := c.VerifyLogoutToken(context.Background(), f.sign(t, "user-1", []string{"app"}, exp, ok(nil))) if err != nil || lt.Subject != "user-1" || lt.SessionID != "sesi-1" { t.Fatalf("token valid ditolak: %v %+v", err, lt) } cases := map[string]string{ "aud lain": f.sign(t, "user-1", []string{"app-lain"}, exp, ok(nil)), "ada nonce": f.sign(t, "user-1", []string{"app"}, exp, ok(map[string]any{"nonce": "n"})), "tanpa events": f.sign(t, "user-1", []string{"app"}, exp, map[string]any{"sid": "sesi-1"}), "bukan jwt": "abc", } for name, raw := range cases { if _, err := c.VerifyLogoutToken(context.Background(), raw); err == nil { t.Errorf("%s: harus ditolak", name) } } // Handler: 200 untuk token valid, 400 untuk token salah. var revoked string h := c.BackchannelLogoutHandler(func(_ context.Context, lt *LogoutToken) error { revoked = lt.SessionID; return nil }) post := func(tok string) int { r := httptest.NewRequest("POST", "/bcl", strings.NewReader(url.Values{"logout_token": {tok}}.Encode())) r.Header.Set("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() h.ServeHTTP(w, r) return w.Code } if code := post(f.sign(t, "user-1", []string{"app"}, exp, ok(nil))); code != 200 || revoked != "sesi-1" { t.Fatalf("handler: code=%d revoked=%q", code, revoked) } if code := post("abc"); code != 400 { t.Fatalf("token salah harus 400, dapat %d", code) } } func TestVerifyAccessToken(t *testing.T) { f := newFakeSSO(t) c := newTestClient(t, f) tok := func(client string, exp time.Time) string { return f.sign(t, "user-1", nil, exp, map[string]any{"client_id": client, "scp": []string{"openid"}, "org": "acme"}) } if at, err := c.VerifyAccessToken(context.Background(), tok("app", time.Now().Add(time.Minute))); err != nil || at.Subject != "user-1" || at.Org != "acme" || !at.HasScope("openid") { t.Fatalf("token valid ditolak: %v", err) } if _, err := c.VerifyAccessToken(context.Background(), tok("app-lain", time.Now().Add(time.Minute))); err == nil { t.Fatal("client lain harus ditolak") } if _, err := c.VerifyAccessToken(context.Background(), tok("app-lain", time.Now().Add(time.Minute)), "app-lain"); err != nil { t.Fatalf("client yang diizinkan eksplisit ditolak: %v", err) } if _, err := c.VerifyAccessToken(context.Background(), tok("app", time.Now().Add(-time.Minute))); err == nil { t.Fatal("token kedaluwarsa harus ditolak") } h := c.RequireAccessToken(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { at, _ := AccessTokenFrom(r.Context()) _, _ = w.Write([]byte(at.Subject)) })) w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/api", nil) r.Header.Set("Authorization", "Bearer "+tok("app", time.Now().Add(time.Minute))) h.ServeHTTP(w, r) if w.Code != 200 || w.Body.String() != "user-1" { t.Fatalf("middleware: %d %s", w.Code, w.Body) } w = httptest.NewRecorder() h.ServeHTTP(w, httptest.NewRequest("GET", "/api", nil)) if w.Code != 401 { t.Fatalf("tanpa token harus 401, dapat %d", w.Code) } } func sigFor(secret, ts string, body []byte) string { m := hmac.New(sha256.New, []byte(secret)) m.Write([]byte(ts + "." + string(body))) return "sha256=" + hex.EncodeToString(m.Sum(nil)) } func TestWebhook(t *testing.T) { const secret = "xgwh_rahasia-webhook-yang-panjang-sekali-1234" body := []byte(`{"email":"a@b.co","id":"e1","occurred_at":"2026-11-09T00:23:55.123Z","sub":"user-1","type":"user.disabled"}`) now := time.Now() ts := strconv.FormatInt(now.Unix(), 10) ev, err := VerifyWebhook(secret, ts, sigFor(secret, ts, body), body, now) if err != nil || ev.Type != EventUserDisabled || ev.Sub != "user-1" || ev.OccurredAt.IsZero() { t.Fatalf("webhook valid ditolak: %v %+v", err, ev) } old := strconv.FormatInt(now.Add(-6*time.Minute).Unix(), 10) if _, err := VerifyWebhook(secret, old, sigFor(secret, old, body), body, now); err != ErrWebhookTimestamp { t.Fatalf("timestamp lama: %v", err) } if _, err := VerifyWebhook(secret, ts, sigFor("secret-lain", ts, body), body, now); err != ErrWebhookSignature { t.Fatalf("secret lain: %v", err) } tampered := []byte(strings.Replace(string(body), "disabled", "enabled", 1)) if _, err := VerifyWebhook(secret, ts, sigFor(secret, ts, body), tampered, now); err != ErrWebhookSignature { t.Fatalf("body diubah: %v", err) } var got *Event h := WebhookHandler(secret, func(_ context.Context, ev *Event) error { got = ev; return nil }) send := func(sig string) int { r := httptest.NewRequest("POST", "/events", strings.NewReader(string(body))) r.Header.Set(HeaderTimestamp, ts) r.Header.Set(HeaderSignature, sig) w := httptest.NewRecorder() h.ServeHTTP(w, r) return w.Code } if code := send(sigFor(secret, ts, body)); code != 204 || got.ID != "e1" { t.Fatalf("handler valid: %d %+v", code, got) } if code := send("sha256=salah"); code != 401 { t.Fatalf("handler tanda tangan salah harus 401, dapat %d", code) } } func TestWebhookPingDanAkses(t *testing.T) { const secret = "xgwh_rahasia-webhook-yang-panjang-sekali-1234" now := time.Now() ts := strconv.FormatInt(now.Unix(), 10) ping := []byte(`{"id":"e2","type":"webhook.ping","occurred_at":"2026-11-09T00:00:00Z"}`) if ev, err := VerifyWebhook(secret, ts, sigFor(secret, ts, ping), ping, now); err != nil || ev.Type != EventWebhookPing || ev.Sub != "" { t.Fatalf("uji kirim: %v %+v", err, ev) } akses := []byte(`{"access":"revoked","email":"b@c.id","id":"e4","occurred_at":"2026-11-09T00:00:00Z","sub":"USR_A","type":"user.apps_changed"}`) if ev, err := VerifyWebhook(secret, ts, sigFor(secret, ts, akses), akses, now); err != nil || ev.Access != AccessRevoked { t.Fatalf("akses dicabut: %v %+v", err, ev) } user := []byte(`{"id":"e3","type":"user.disabled","occurred_at":"2026-10-29T00:00:00Z"}`) if _, err := VerifyWebhook(secret, ts, sigFor(secret, ts, user), user, now); err == nil { t.Fatal("event pengguna tanpa sub harus ditolak") } }