package identity import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) func TestOnboardingClientKeepsCodeAndExchangeTokenOutOfURLs(t *testing.T) { const code = "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH" const token = "ex1.bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb.abcdefghijklmnopqrstuvwxyzABCDEFGH" server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.String(), code) || strings.Contains(r.URL.String(), token) { t.Fatal("onboarding credential leaked into URL") } switch { case r.Method == http.MethodPost && r.URL.Path == "/api/v1/onboarding-exchanges": var body map[string]string _ = json.NewDecoder(r.Body).Decode(&body) if body["onboarding_code"] != code || r.Header.Get("Authorization") != "" { t.Fatalf("unexpected claim request: body=%#v authorization=%q", body, r.Header.Get("Authorization")) } w.Header().Set("Cache-Control", "no-store") w.WriteHeader(http.StatusCreated) _, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","exchange_token":"` + token + `","expires_at":"2026-07-17T12:30:00Z","version":1}`)) case r.Method == http.MethodPut && r.URL.Path == "/api/v1/onboarding-exchanges/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/metadata": if r.Header.Get("Authorization") != "Bearer "+token || r.Header.Get("If-Match") != `W/"1"` || len(r.Header.Get("Idempotency-Key")) < 16 { t.Fatalf("unexpected exchange headers: %#v", r.Header) } w.Header().Set("ETag", `W/"2"`) w.WriteHeader(http.StatusAccepted) _, _ = w.Write([]byte(`{"exchange_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","application_id":"cccccccc-cccc-cccc-cccc-cccccccccccc","status":"preparing","version":2,"expires_at":"2026-07-17T12:30:00Z"}`)) default: http.NotFound(w, r) } })) defer server.Close() client, err := NewOnboardingClient(server.URL, server.Client(), "production") if err != nil { t.Fatal(err) } claimed, err := client.Claim(context.Background(), code) if err != nil { t.Fatal(err) } metadata := ConsumerMetadata{PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", RedirectURIs: []string{"https://api.example.com/api/v1/auth/oidc/callback"}, LogoutURIs: []string{"https://gateway.example.com/"}} view, err := client.SubmitMetadata(context.Background(), claimed, metadata, "pairing-metadata-123456") if err != nil || view.Status != ExchangePreparing || view.Version != 2 { t.Fatalf("view=%#v err=%v", view, err) } } func TestManifestV1ValidationRequiresStableFieldsAndCapabilityDependencies(t *testing.T) { valid := ManifestV1{ SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/shared", TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Capabilities: []string{"oidc_login", "api_access", "machine_to_machine", "token_introspection", "session_revocation"}, Audience: "urn:easyai:resource:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Scopes: []string{"openid", "gateway.access"}, Clients: ManifestClients{BrowserLogin: &ManifestClient{ClientID: "browser"}, MachineToMachine: &ManifestClient{ClientID: "service"}}, SecurityEvents: &ManifestSecurityEvents{TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"}, } if err := valid.Validate("production"); err != nil { t.Fatal(err) } invalid := valid invalid.Capabilities = []string{"session_revocation"} if err := invalid.Validate("production"); err == nil { t.Fatal("manifest with missing capability dependencies was accepted") } invalid = valid invalid.SchemaVersion = 2 if err := invalid.Validate("production"); err == nil { t.Fatal("unsupported manifest schema was accepted") } } func TestOnboardingClientRejectsRedirects(t *testing.T) { target := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "should not be reached", http.StatusTeapot) })) defer target.Close() redirect := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, target.URL, http.StatusTemporaryRedirect) })) defer redirect.Close() client, err := NewOnboardingClient(redirect.URL, redirect.Client(), "production") if err != nil { t.Fatal(err) } if _, err := client.Claim(context.Background(), "onb1.aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa.abcdefghijklmnopqrstuvwxyzABCDEFGH"); err == nil { t.Fatal("redirecting onboarding endpoint was accepted") } } func TestManifestAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { manifest := ManifestV1{ SchemaVersion: 1, Issuer: "https://auth.example.com/issuer/easyai", TenantID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", ApplicationID: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", Capabilities: []string{"machine_to_machine", "token_introspection", "session_revocation"}, Scopes: []string{"gateway.access"}, Clients: ManifestClients{MachineToMachine: &ManifestClient{ClientID: "service"}}, SecurityEvents: &ManifestSecurityEvents{ TransmitterIssuer: "https://auth.example.com/ssf", ConfigurationEndpoint: "https://auth.example.com/.well-known/ssf-configuration/ssf", Audience: "urn:easyai:ssf:receiver:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", }, } for _, test := range []struct { name string mutate func(*ManifestV1) }{ {name: "OIDC issuer", mutate: func(value *ManifestV1) { value.Issuer = "http://localhost:18003/issuer/easyai" }}, {name: "SSF issuer", mutate: func(value *ManifestV1) { value.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" }}, {name: "SSF configuration", mutate: func(value *ManifestV1) { value.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf" }}, } { t.Run(test.name, func(t *testing.T) { candidate := manifest securityEvents := *manifest.SecurityEvents candidate.SecurityEvents = &securityEvents test.mutate(&candidate) if err := candidate.Validate("production"); err == nil { t.Fatalf("production accepted loopback HTTP %s URL", test.name) } }) } localManifest := manifest localSecurityEvents := *manifest.SecurityEvents localManifest.SecurityEvents = &localSecurityEvents localManifest.Issuer = "http://localhost:18003/issuer/easyai" localManifest.SecurityEvents.TransmitterIssuer = "http://127.0.0.1:18004/ssf" localManifest.SecurityEvents.ConfigurationEndpoint = "http://127.0.0.1:18004/.well-known/ssf-configuration/ssf" if err := localManifest.Validate("test"); err != nil { t.Fatalf("test environment rejected loopback HTTP manifest URLs: %v", err) } localManifest.Capabilities = []string{"machine_to_machine"} localManifest.Issuer = manifest.Issuer if err := localManifest.Validate("production"); err == nil { t.Fatal("production accepted optional loopback HTTP security event metadata") } } func TestOnboardingClientAllowsLoopbackHTTPOnlyInLocalEnvironments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) defer server.Close() if _, err := NewOnboardingClient(server.URL, server.Client(), "production"); err == nil { t.Fatal("production accepted loopback HTTP Auth Center URL") } if _, err := NewOnboardingClient(server.URL, server.Client(), "development"); err != nil { t.Fatalf("development rejected loopback HTTP Auth Center URL: %v", err) } }