diff --git a/apps/api/cmd/migrate/main_test.go b/apps/api/cmd/migrate/main_test.go index e55c714..4c17357 100644 --- a/apps/api/cmd/migrate/main_test.go +++ b/apps/api/cmd/migrate/main_test.go @@ -39,3 +39,27 @@ func TestSecurityEventVerificationStateRepairMigrationExists(t *testing.T) { } } } + +func TestIdentityConfigurationRevisionMigrationDefinesSecretReferencesAndSingleActiveRevision(t *testing.T) { + payload, err := os.ReadFile("../../migrations/0067_identity_configuration_revisions.sql") + if err != nil { + t.Fatal(err) + } + content := string(payload) + for _, required := range []string{ + "CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions", + "machine_credential_ref text", + "session_encryption_key_ref text", + "WHERE state = 'active'", + "CHECK (state IN ('draft','validated','active','superseded','failed'))", + } { + if !strings.Contains(content, required) { + t.Fatalf("identity configuration migration is missing %q", required) + } + } + for _, forbidden := range []string{"machine_secret", "client_secret", "exchange_token text", "onboarding_code text"} { + if strings.Contains(strings.ToLower(content), forbidden) { + t.Fatalf("identity configuration migration stores forbidden secret field %q", forbidden) + } + } +} diff --git a/apps/api/internal/identity/configuration.go b/apps/api/internal/identity/configuration.go new file mode 100644 index 0000000..2faf81c --- /dev/null +++ b/apps/api/internal/identity/configuration.go @@ -0,0 +1,222 @@ +package identity + +import ( + "errors" + "net" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +var ( + ErrRevisionNotFound = errors.New("identity configuration revision not found") + ErrRevisionConflict = errors.New("identity configuration revision conflicts with current state") + ErrBreakGlassRequired = errors.New("a local break-glass manager credential is required") + ErrLocalTenantInvalid = errors.New("local tenant mapping is invalid") +) + +type RevisionState string + +const ( + RevisionDraft RevisionState = "draft" + RevisionValidated RevisionState = "validated" + RevisionActive RevisionState = "active" + RevisionSuperseded RevisionState = "superseded" + RevisionFailed RevisionState = "failed" +) + +type Revision struct { + ID string `json:"id"` + State RevisionState `json:"state"` + SchemaVersion int `json:"schemaVersion"` + AuthCenterURL string `json:"authCenterUrl"` + Issuer string `json:"issuer,omitempty"` + TenantID string `json:"tenantId,omitempty"` + ApplicationID string `json:"applicationId,omitempty"` + Audience string `json:"audience,omitempty"` + BrowserClientID string `json:"browserClientId,omitempty"` + MachineClientID string `json:"machineClientId,omitempty"` + Scopes []string `json:"scopes"` + Capabilities []string `json:"capabilities"` + RolePrefix string `json:"rolePrefix"` + LocalTenantKey string `json:"localTenantKey"` + PublicBaseURL string `json:"publicBaseUrl"` + WebBaseURL string `json:"webBaseUrl"` + JITEnabled bool `json:"jitEnabled"` + LegacyJWTEnabled bool `json:"legacyJwtEnabled"` + TokenIntrospection bool `json:"tokenIntrospection"` + SessionRevocation bool `json:"sessionRevocation"` + MachineCredentialRef string `json:"-"` + SessionEncryptionKeyRef string `json:"-"` + SessionIdleSeconds int `json:"sessionIdleSeconds"` + SessionAbsoluteSeconds int `json:"sessionAbsoluteSeconds"` + SessionRefreshSeconds int `json:"sessionRefreshSeconds"` + Version int64 `json:"version"` + LastErrorCategory string `json:"lastErrorCategory,omitempty"` + LastTraceID string `json:"lastTraceId,omitempty"` + LastAuditID string `json:"lastAuditId,omitempty"` + ValidatedAt *time.Time `json:"validatedAt,omitempty"` + ActivatedAt *time.Time `json:"activatedAt,omitempty"` + SupersededAt *time.Time `json:"supersededAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type PairingInput struct { + AuthCenterURL string `json:"authCenterUrl"` + OnboardingCode string `json:"onboardingCode"` + PublicBaseURL string `json:"publicBaseUrl"` + WebBaseURL string `json:"webBaseUrl"` + LocalTenantKey string `json:"localTenantKey"` + LegacyJWTEnabled bool `json:"legacyJwtEnabled"` +} + +type ConsumerMetadata struct { + PublicBaseURL string `json:"public_base_url"` + WebBaseURL string `json:"web_base_url"` + RedirectURIs []string `json:"redirect_uris"` + LogoutURIs []string `json:"logout_uris"` + ReceiverEndpoint string `json:"receiver_endpoint,omitempty"` +} + +type ManifestApplication struct { + Manifest ManifestV1 + MachineCredentialRef string + SessionEncryptionKeyRef string + TraceID string + AuditID string +} + +func NewDraft(input PairingInput) (Revision, error) { + if _, err := input.ConsumerMetadata(false); err != nil { + return Revision{}, err + } + authCenter, _ := exactBaseURL(input.AuthCenterURL) + publicBase, _ := exactBaseURL(input.PublicBaseURL) + webBase, _ := exactBaseURL(input.WebBaseURL) + return Revision{ + ID: uuid.NewString(), State: RevisionDraft, SchemaVersion: 1, + AuthCenterURL: authCenter, RolePrefix: "gateway.", LocalTenantKey: strings.TrimSpace(input.LocalTenantKey), + PublicBaseURL: publicBase, WebBaseURL: webBase, JITEnabled: true, LegacyJWTEnabled: input.LegacyJWTEnabled, + Scopes: []string{}, Capabilities: []string{}, SessionIdleSeconds: 1800, SessionAbsoluteSeconds: 28800, + SessionRefreshSeconds: 60, Version: 1, + }, nil +} + +func ApplyManifest(revision Revision, input ManifestApplication) (Revision, error) { + if revision.State != RevisionDraft { + return Revision{}, ErrRevisionConflict + } + if err := input.Manifest.Validate(); err != nil { + return Revision{}, err + } + capabilities := make(map[string]bool, len(input.Manifest.Capabilities)) + for _, capability := range input.Manifest.Capabilities { + capabilities[capability] = true + } + if capabilities["machine_to_machine"] && strings.TrimSpace(input.MachineCredentialRef) == "" { + return Revision{}, errors.New("machine credential reference is required") + } + if capabilities["oidc_login"] && strings.TrimSpace(input.SessionEncryptionKeyRef) == "" { + return Revision{}, errors.New("session encryption key reference is required") + } + revision.Issuer = strings.TrimRight(input.Manifest.Issuer, "/") + revision.TenantID = input.Manifest.TenantID + revision.ApplicationID = input.Manifest.ApplicationID + revision.Audience = input.Manifest.Audience + revision.Scopes = append([]string(nil), input.Manifest.Scopes...) + revision.Capabilities = append([]string(nil), input.Manifest.Capabilities...) + if input.Manifest.Clients.BrowserLogin != nil { + revision.BrowserClientID = input.Manifest.Clients.BrowserLogin.ClientID + } + if input.Manifest.Clients.MachineToMachine != nil { + revision.MachineClientID = input.Manifest.Clients.MachineToMachine.ClientID + } + revision.TokenIntrospection = capabilities["token_introspection"] + revision.SessionRevocation = capabilities["session_revocation"] + revision.MachineCredentialRef = strings.TrimSpace(input.MachineCredentialRef) + revision.SessionEncryptionKeyRef = strings.TrimSpace(input.SessionEncryptionKeyRef) + revision.LastTraceID = strings.TrimSpace(input.TraceID) + revision.LastAuditID = strings.TrimSpace(input.AuditID) + return revision, nil +} + +func CanTransition(from, to RevisionState) bool { + switch from { + case RevisionDraft: + return to == RevisionValidated || to == RevisionFailed + case RevisionValidated: + return to == RevisionActive || to == RevisionFailed + case RevisionActive: + return to == RevisionSuperseded + case RevisionSuperseded: + return to == RevisionValidated + default: + return false + } +} + +func (input PairingInput) ConsumerMetadata(sessionRevocation bool) (ConsumerMetadata, error) { + authCenter, err := exactBaseURL(input.AuthCenterURL) + if err != nil { + return ConsumerMetadata{}, errors.New("auth center URL is invalid") + } + _ = authCenter + publicBase, err := exactBaseURL(input.PublicBaseURL) + if err != nil { + return ConsumerMetadata{}, errors.New("public base URL is invalid") + } + webBase, err := exactBaseURL(input.WebBaseURL) + if err != nil { + return ConsumerMetadata{}, errors.New("web base URL is invalid") + } + if strings.TrimSpace(input.LocalTenantKey) == "" { + return ConsumerMetadata{}, errors.New("local tenant mapping is required") + } + metadata := ConsumerMetadata{ + PublicBaseURL: publicBase, WebBaseURL: webBase, + RedirectURIs: []string{publicBase + "/api/v1/auth/oidc/callback"}, + LogoutURIs: []string{webBase + "/"}, + } + if sessionRevocation { + metadata.ReceiverEndpoint = publicBase + "/api/v1/security-events/ssf" + } + return metadata, nil +} + +func exactBaseURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Opaque != "" || parsed.User != nil || parsed.Host == "" || parsed.RawQuery != "" || parsed.Fragment != "" { + return "", errors.New("invalid public URL") + } + hostname := strings.ToLower(parsed.Hostname()) + if parsed.Path != "" && parsed.Path != "/" { + return "", errors.New("base URL must not contain a path") + } + scheme := strings.ToLower(parsed.Scheme) + if scheme != "https" && !(scheme == "http" && isLoopbackHost(hostname)) { + return "", errors.New("public URL must use HTTPS") + } + port := parsed.Port() + if scheme == "https" && port == "443" || scheme == "http" && port == "80" { + port = "" + } + host := hostname + if strings.Contains(hostname, ":") { + host = "[" + hostname + "]" + } + if port != "" { + host = net.JoinHostPort(hostname, port) + } + return scheme + "://" + host, nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/apps/api/internal/identity/configuration_test.go b/apps/api/internal/identity/configuration_test.go new file mode 100644 index 0000000..1b7b745 --- /dev/null +++ b/apps/api/internal/identity/configuration_test.go @@ -0,0 +1,89 @@ +package identity + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRevisionStateTransitions(t *testing.T) { + tests := []struct { + from, to RevisionState + allowed bool + }{ + {RevisionDraft, RevisionValidated, true}, + {RevisionDraft, RevisionFailed, true}, + {RevisionValidated, RevisionActive, true}, + {RevisionValidated, RevisionFailed, true}, + {RevisionActive, RevisionSuperseded, true}, + {RevisionSuperseded, RevisionValidated, true}, + {RevisionSuperseded, RevisionActive, false}, + {RevisionDraft, RevisionActive, false}, + {RevisionFailed, RevisionActive, false}, + {RevisionActive, RevisionValidated, false}, + } + for _, test := range tests { + if got := CanTransition(test.from, test.to); got != test.allowed { + t.Errorf("CanTransition(%q, %q)=%v, want %v", test.from, test.to, got, test.allowed) + } + } +} + +func TestRevisionNeverSerializesSecretValues(t *testing.T) { + type secretFields interface { + MachineSecret() string + } + var _ = any((*Revision)(nil)) + if _, exposesSecret := any((*Revision)(nil)).(secretFields); exposesSecret { + t.Fatal("Revision unexpectedly exposes a machine secret value") + } + revision := Revision{MachineCredentialRef: "identity-machine-example", SessionEncryptionKeyRef: "identity-session-example"} + if revision.MachineCredentialRef == "" || revision.SessionEncryptionKeyRef == "" { + t.Fatal("Revision must retain SecretStore references") + } +} + +func TestValidatePairingInputDerivesExactGatewayURIs(t *testing.T) { + input := PairingInput{ + AuthCenterURL: "https://auth.example.com", PublicBaseURL: "https://api.example.com", + WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default", + } + metadata, err := input.ConsumerMetadata(true) + if err != nil { + t.Fatal(err) + } + if metadata.RedirectURIs[0] != "https://api.example.com/api/v1/auth/oidc/callback" || + metadata.LogoutURIs[0] != "https://gateway.example.com/" || + metadata.ReceiverEndpoint != "https://api.example.com/api/v1/security-events/ssf" { + t.Fatalf("unexpected derived metadata: %#v", metadata) + } +} + +func TestValidatePairingInputRejectsRemoteHTTPAndURLCredentials(t *testing.T) { + for _, input := range []PairingInput{ + {AuthCenterURL: "http://auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, + {AuthCenterURL: "https://user:password@auth.example.com", PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", LocalTenantKey: "default"}, + } { + if _, err := input.ConsumerMetadata(false); err == nil { + t.Fatalf("unsafe pairing input accepted: %#v", input) + } + } +} + +func TestNewDraftAppliesSessionDefaultsWithoutPersistingOnboardingCode(t *testing.T) { + draft, err := NewDraft(PairingInput{ + AuthCenterURL: "https://auth.example.com", OnboardingCode: "onb1.must-never-be-persisted", + PublicBaseURL: "https://api.example.com", WebBaseURL: "https://gateway.example.com", + LocalTenantKey: "default", LegacyJWTEnabled: true, + }) + if err != nil { + t.Fatal(err) + } + if draft.State != RevisionDraft || draft.SessionIdleSeconds != 1800 || draft.SessionAbsoluteSeconds != 28800 || draft.SessionRefreshSeconds != 60 { + t.Fatalf("unexpected draft defaults: %#v", draft) + } + payload, _ := json.Marshal(draft) + if strings.Contains(string(payload), "must-never-be-persisted") || strings.Contains(string(payload), "onboardingCode") { + t.Fatalf("draft exposed onboarding code: %s", payload) + } +} diff --git a/apps/api/internal/identity/onboarding_client.go b/apps/api/internal/identity/onboarding_client.go new file mode 100644 index 0000000..1b4030b --- /dev/null +++ b/apps/api/internal/identity/onboarding_client.go @@ -0,0 +1,300 @@ +package identity + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" +) + +const maxOnboardingResponseBytes = 1 << 20 + +type ExchangeStatus string + +const ( + ExchangeMetadataPending ExchangeStatus = "metadata_pending" + ExchangePreparing ExchangeStatus = "preparing" + ExchangeReady ExchangeStatus = "ready" + ExchangeCredentialDelivered ExchangeStatus = "credential_delivered" + ExchangeCompleted ExchangeStatus = "completed" + ExchangeFailed ExchangeStatus = "failed" + ExchangeExpired ExchangeStatus = "expired" +) + +type ClaimedExchange struct { + ExchangeID string `json:"exchange_id"` + ExchangeToken string `json:"exchange_token"` + ExpiresAt time.Time `json:"expires_at"` + Version int64 `json:"version"` +} + +type Exchange struct { + ExchangeID string `json:"exchange_id"` + ApplicationID string `json:"application_id"` + Status ExchangeStatus `json:"status"` + Version int64 `json:"version"` + ExpiresAt time.Time `json:"expires_at"` + LastErrorCategory string `json:"last_error_category,omitempty"` + AuditID string `json:"audit_id,omitempty"` +} + +type ManifestClient struct { + ClientID string `json:"client_id"` +} + +type ManifestClients struct { + BrowserLogin *ManifestClient `json:"browser_login,omitempty"` + MachineToMachine *ManifestClient `json:"machine_to_machine,omitempty"` +} + +type ManifestSecurityEvents struct { + TransmitterIssuer string `json:"transmitter_issuer"` + ConfigurationEndpoint string `json:"configuration_endpoint"` + Audience string `json:"audience"` +} + +type ManifestV1 struct { + SchemaVersion int `json:"schema_version"` + Issuer string `json:"issuer"` + TenantID string `json:"tenant_id"` + ApplicationID string `json:"application_id"` + Capabilities []string `json:"capabilities"` + Audience string `json:"audience,omitempty"` + Scopes []string `json:"scopes"` + Clients ManifestClients `json:"clients"` + SecurityEvents *ManifestSecurityEvents `json:"security_events,omitempty"` +} + +type MachineCredential struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + IssuedAt time.Time `json:"issued_at"` +} + +type CredentialDelivery struct { + Manifest ManifestV1 `json:"manifest"` + MachineCredential *MachineCredential `json:"machine_credential,omitempty"` + Version int64 `json:"-"` +} + +func (manifest ManifestV1) Validate() error { + if manifest.SchemaVersion != 1 || validatePublicIdentityURL(manifest.Issuer) != nil { + return errors.New("application manifest identity metadata is invalid") + } + if _, err := uuid.Parse(manifest.TenantID); err != nil { + return errors.New("application manifest tenant is invalid") + } + if _, err := uuid.Parse(manifest.ApplicationID); err != nil { + return errors.New("application manifest application is invalid") + } + allowed := map[string]bool{"oidc_login": true, "api_access": true, "machine_to_machine": true, "token_introspection": true, "session_revocation": true} + capabilities := make(map[string]bool, len(manifest.Capabilities)) + for _, capability := range manifest.Capabilities { + if !allowed[capability] || capabilities[capability] { + return errors.New("application manifest capabilities are invalid") + } + capabilities[capability] = true + } + if capabilities["session_revocation"] && !capabilities["token_introspection"] || + capabilities["token_introspection"] && !capabilities["machine_to_machine"] { + return errors.New("application manifest capability dependencies are invalid") + } + if capabilities["oidc_login"] && (manifest.Clients.BrowserLogin == nil || strings.TrimSpace(manifest.Clients.BrowserLogin.ClientID) == "") { + return errors.New("application manifest browser client is missing") + } + if capabilities["machine_to_machine"] && (manifest.Clients.MachineToMachine == nil || strings.TrimSpace(manifest.Clients.MachineToMachine.ClientID) == "") { + return errors.New("application manifest machine client is missing") + } + if capabilities["api_access"] && strings.TrimSpace(manifest.Audience) == "" { + return errors.New("application manifest audience is missing") + } + if capabilities["session_revocation"] { + if manifest.SecurityEvents == nil || validatePublicIdentityURL(manifest.SecurityEvents.TransmitterIssuer) != nil || + validatePublicIdentityURL(manifest.SecurityEvents.ConfigurationEndpoint) != nil || strings.TrimSpace(manifest.SecurityEvents.Audience) == "" { + return errors.New("application manifest security event metadata is invalid") + } + } + seenScopes := make(map[string]bool, len(manifest.Scopes)) + for _, scope := range manifest.Scopes { + scope = strings.TrimSpace(scope) + if scope == "" || seenScopes[scope] { + return errors.New("application manifest scopes are invalid") + } + seenScopes[scope] = true + } + return nil +} + +func validatePublicIdentityURL(raw string) error { + _, err := exactBaseURL(raw) + if err == nil { + return nil + } + // Discovery endpoints may contain a path, but must retain the same URL + // safety constraints as a base URL. + parsed, parseErr := http.NewRequest(http.MethodGet, strings.TrimSpace(raw), nil) + if parseErr != nil || parsed.URL.User != nil || parsed.URL.Host == "" || parsed.URL.RawQuery != "" || parsed.URL.Fragment != "" { + return errors.New("identity URL is invalid") + } + scheme := strings.ToLower(parsed.URL.Scheme) + if scheme == "https" || scheme == "http" && isLoopbackHost(strings.ToLower(parsed.URL.Hostname())) { + return nil + } + return errors.New("identity URL must use HTTPS") +} + +type OnboardingClient struct { + baseURL string + client *http.Client +} + +func NewOnboardingClient(baseURL string, base *http.Client) (*OnboardingClient, error) { + normalized, err := exactBaseURL(baseURL) + if err != nil { + return nil, errors.New("auth center URL is invalid") + } + if base == nil { + base = &http.Client{Timeout: 10 * time.Second} + } + client := *base + if client.Timeout <= 0 { + client.Timeout = 10 * time.Second + } + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse } + return &OnboardingClient{baseURL: normalized, client: &client}, nil +} + +func (client *OnboardingClient) Claim(ctx context.Context, code string) (ClaimedExchange, error) { + var output ClaimedExchange + status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges", "", "", 0, + map[string]string{"onboarding_code": strings.TrimSpace(code)}, &output) + if err != nil || status != http.StatusCreated || output.Version < 1 || output.ExchangeID == "" || output.ExchangeToken == "" { + return ClaimedExchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) SubmitMetadata(ctx context.Context, claimed ClaimedExchange, metadata ConsumerMetadata, idempotencyKey string) (Exchange, error) { + var output Exchange + status, _, err := client.request(ctx, http.MethodPut, "/api/v1/onboarding-exchanges/"+claimed.ExchangeID+"/metadata", + claimed.ExchangeToken, idempotencyKey, claimed.Version, metadata, &output) + if err != nil || status != http.StatusAccepted || output.Version < 1 { + return Exchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) Get(ctx context.Context, exchangeID, exchangeToken string) (Exchange, error) { + var output Exchange + status, _, err := client.request(ctx, http.MethodGet, "/api/v1/onboarding-exchanges/"+exchangeID, exchangeToken, "", 0, nil, &output) + if err != nil || status != http.StatusOK || output.Version < 1 { + return Exchange{}, onboardingProtocolError(err) + } + return output, nil +} + +func (client *OnboardingClient) DeliverCredential(ctx context.Context, exchange Exchange, exchangeToken, idempotencyKey string) (CredentialDelivery, error) { + var output CredentialDelivery + status, etag, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchange.ExchangeID+"/credential-deliveries", + exchangeToken, idempotencyKey, exchange.Version, nil, &output) + if err != nil || status != http.StatusOK { + return CredentialDelivery{}, onboardingProtocolError(err) + } + if err := output.Manifest.Validate(); err != nil { + return CredentialDelivery{}, err + } + output.Version, err = parseWeakETag(etag) + if err != nil { + return CredentialDelivery{}, errors.New("onboarding response ETag is invalid") + } + return output, nil +} + +func (client *OnboardingClient) Complete(ctx context.Context, exchangeID, exchangeToken, idempotencyKey string, version int64) error { + status, _, err := client.request(ctx, http.MethodPost, "/api/v1/onboarding-exchanges/"+exchangeID+"/completion", + exchangeToken, idempotencyKey, version, nil, nil) + if err != nil || status != http.StatusNoContent { + return onboardingProtocolError(err) + } + return nil +} + +func (client *OnboardingClient) request(ctx context.Context, method, path, token, idempotencyKey string, version int64, body, output any) (int, string, error) { + var reader io.Reader + if body != nil { + payload, err := json.Marshal(body) + if err != nil { + return 0, "", err + } + reader = bytes.NewReader(payload) + } + request, err := http.NewRequestWithContext(ctx, method, client.baseURL+path, reader) + if err != nil { + return 0, "", err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + if token != "" { + request.Header.Set("Authorization", "Bearer "+token) + } + if idempotencyKey != "" { + request.Header.Set("Idempotency-Key", idempotencyKey) + } + if version > 0 { + request.Header.Set("If-Match", fmt.Sprintf(`W/"%d"`, version)) + } + response, err := client.client.Do(request) + if err != nil { + return 0, "", errors.New("onboarding endpoint request failed") + } + defer response.Body.Close() + if response.StatusCode >= 300 && response.StatusCode < 400 { + return response.StatusCode, "", errors.New("onboarding endpoint redirect is forbidden") + } + if output == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, maxOnboardingResponseBytes)) + return response.StatusCode, response.Header.Get("ETag"), nil + } + payload, err := io.ReadAll(io.LimitReader(response.Body, maxOnboardingResponseBytes+1)) + if err != nil || len(payload) > maxOnboardingResponseBytes { + return response.StatusCode, "", errors.New("onboarding endpoint response is invalid") + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response.StatusCode, "", errors.New("onboarding endpoint rejected the request") + } + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(output); err != nil { + return response.StatusCode, "", errors.New("onboarding endpoint response is invalid") + } + return response.StatusCode, response.Header.Get("ETag"), nil +} + +func onboardingProtocolError(cause error) error { + if cause == nil { + return errors.New("onboarding endpoint returned an unexpected status") + } + return cause +} + +func parseWeakETag(value string) (int64, error) { + value = strings.TrimSpace(value) + if !strings.HasPrefix(value, `W/"`) || !strings.HasSuffix(value, `"`) { + return 0, errors.New("invalid ETag") + } + version, err := strconv.ParseInt(strings.TrimSuffix(strings.TrimPrefix(value, `W/"`), `"`), 10, 64) + if err != nil || version < 1 { + return 0, errors.New("invalid ETag") + } + return version, nil +} diff --git a/apps/api/internal/identity/onboarding_client_test.go b/apps/api/internal/identity/onboarding_client_test.go new file mode 100644 index 0000000..aadce38 --- /dev/null +++ b/apps/api/internal/identity/onboarding_client_test.go @@ -0,0 +1,97 @@ +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()) + 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(); err != nil { + t.Fatal(err) + } + invalid := valid + invalid.Capabilities = []string{"session_revocation"} + if err := invalid.Validate(); err == nil { + t.Fatal("manifest with missing capability dependencies was accepted") + } + invalid = valid + invalid.SchemaVersion = 2 + if err := invalid.Validate(); 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()) + 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") + } +} diff --git a/apps/api/internal/store/identity_configurations.go b/apps/api/internal/store/identity_configurations.go new file mode 100644 index 0000000..2c3aa73 --- /dev/null +++ b/apps/api/internal/store/identity_configurations.go @@ -0,0 +1,240 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + + "github.com/easyai/easyai-ai-gateway/apps/api/internal/identity" + "github.com/jackc/pgx/v5" +) + +const identityRevisionColumns = ` +id::text,state,schema_version,auth_center_url,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(application_id,''), +COALESCE(audience,''),COALESCE(browser_client_id,''),COALESCE(machine_client_id,''),scopes,capabilities,role_prefix, +local_tenant_key,public_base_url,web_base_url,jit_enabled,legacy_jwt_enabled,token_introspection,session_revocation, +COALESCE(machine_credential_ref,''),COALESCE(session_encryption_key_ref,''),session_idle_seconds,session_absolute_seconds, +session_refresh_seconds,version,COALESCE(last_error_category,''),COALESCE(last_trace_id,''),COALESCE(last_audit_id,''), +validated_at,activated_at,superseded_at,created_at,updated_at` + +func (s *Store) CreateIdentityConfigurationRevision(ctx context.Context, revision identity.Revision) (identity.Revision, error) { + scopes, _ := json.Marshal(revision.Scopes) + capabilities, _ := json.Marshal(revision.Capabilities) + return scanIdentityRevision(s.pool.QueryRow(ctx, ` +INSERT INTO gateway_identity_configuration_revisions ( + id,state,schema_version,auth_center_url,role_prefix,local_tenant_key,public_base_url,web_base_url, + jit_enabled,legacy_jwt_enabled,scopes,capabilities,session_idle_seconds,session_absolute_seconds,session_refresh_seconds +) VALUES ($1::uuid,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb,$12::jsonb,$13,$14,$15) +RETURNING `+identityRevisionColumns, + revision.ID, revision.State, revision.SchemaVersion, revision.AuthCenterURL, revision.RolePrefix, + revision.LocalTenantKey, revision.PublicBaseURL, revision.WebBaseURL, revision.JITEnabled, revision.LegacyJWTEnabled, + string(scopes), string(capabilities), revision.SessionIdleSeconds, revision.SessionAbsoluteSeconds, revision.SessionRefreshSeconds, + )) +} + +func (s *Store) IdentityConfigurationRevision(ctx context.Context, id string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE id=$1::uuid`, id)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) ActiveIdentityConfigurationRevision(ctx context.Context) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, `SELECT `+identityRevisionColumns+` +FROM gateway_identity_configuration_revisions WHERE state='active'`)) + return revision, normalizeIdentityRevisionError(err) +} + +func (s *Store) ApplyIdentityManifest(ctx context.Context, id string, expectedVersion int64, applied identity.ManifestApplication) (identity.Revision, error) { + current, err := s.IdentityConfigurationRevision(ctx, id) + if err != nil { + return identity.Revision{}, err + } + if current.Version != expectedVersion { + return identity.Revision{}, identity.ErrRevisionConflict + } + updated, err := identity.ApplyManifest(current, applied) + if err != nil { + return identity.Revision{}, err + } + scopes, _ := json.Marshal(updated.Scopes) + capabilities, _ := json.Marshal(updated.Capabilities) + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET + issuer=$3,tenant_id=$4,application_id=$5,audience=NULLIF($6,''),browser_client_id=NULLIF($7,''),machine_client_id=NULLIF($8,''), + scopes=$9::jsonb,capabilities=$10::jsonb,token_introspection=$11,session_revocation=$12, + machine_credential_ref=NULLIF($13,''),session_encryption_key_ref=NULLIF($14,''),last_trace_id=NULLIF($15,''), + last_audit_id=NULLIF($16,''),last_error_category=NULL,version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state='draft' +RETURNING `+identityRevisionColumns, + id, expectedVersion, updated.Issuer, updated.TenantID, updated.ApplicationID, updated.Audience, + updated.BrowserClientID, updated.MachineClientID, string(scopes), string(capabilities), updated.TokenIntrospection, + updated.SessionRevocation, updated.MachineCredentialRef, updated.SessionEncryptionKeyRef, updated.LastTraceID, updated.LastAuditID, + )) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) MarkIdentityRevisionValidated(ctx context.Context, id string, expectedVersion int64, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET state='validated',validated_at=now(),last_error_category=NULL, + last_trace_id=NULLIF($3,''),last_audit_id=NULLIF($4,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state IN ('draft','superseded') +RETURNING `+identityRevisionColumns, id, expectedVersion, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) MarkIdentityRevisionFailed(ctx context.Context, id string, expectedVersion int64, category, traceID, auditID string) (identity.Revision, error) { + revision, err := scanIdentityRevision(s.pool.QueryRow(ctx, ` +UPDATE gateway_identity_configuration_revisions SET state='failed',last_error_category=NULLIF($3,''), + last_trace_id=NULLIF($4,''),last_audit_id=NULLIF($5,''),version=version+1,updated_at=now() +WHERE id=$1::uuid AND version=$2 AND state IN ('draft','validated') +RETURNING `+identityRevisionColumns, id, expectedVersion, category, traceID, auditID)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + return revision, err +} + +func (s *Store) ActivateIdentityRevision(ctx context.Context, id string, expectedVersion int64) (identity.Revision, bool, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.Revision{}, false, err + } + defer tx.Rollback(ctx) + if ok, err := hasBreakGlassManager(ctx, tx); err != nil { + return identity.Revision{}, false, err + } else if !ok { + return identity.Revision{}, false, identity.ErrBreakGlassRequired + } + var tenantExists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=( +SELECT local_tenant_key FROM gateway_identity_configuration_revisions WHERE id=$1::uuid) AND status='active')`, id).Scan(&tenantExists); err != nil { + return identity.Revision{}, false, err + } + if !tenantExists { + return identity.Revision{}, false, identity.ErrLocalTenantInvalid + } + var previousID, previousIssuer, previousTenant, previousAudience, previousClient, previousSessionRef string + _ = tx.QueryRow(ctx, `SELECT id::text,COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), +COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions +WHERE state='active' FOR UPDATE`).Scan(&previousID, &previousIssuer, &previousTenant, &previousAudience, &previousClient, &previousSessionRef) + var nextIssuer, nextTenant, nextAudience, nextClient, nextSessionRef string + if err := tx.QueryRow(ctx, `SELECT COALESCE(issuer,''),COALESCE(tenant_id,''),COALESCE(audience,''), +COALESCE(browser_client_id,''),COALESCE(session_encryption_key_ref,'') FROM gateway_identity_configuration_revisions +WHERE id=$1::uuid AND version=$2 AND state='validated' FOR UPDATE`, id, expectedVersion).Scan( + &nextIssuer, &nextTenant, &nextAudience, &nextClient, &nextSessionRef, + ); errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, false, identity.ErrRevisionConflict + } else if err != nil { + return identity.Revision{}, false, err + } + if previousID != "" { + if _, err := tx.Exec(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded',superseded_at=now(), +version=version+1,updated_at=now() WHERE id=$1::uuid AND state='active'`, previousID); err != nil { + return identity.Revision{}, false, err + } + } + revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='active', +activated_at=now(),superseded_at=NULL,version=version+1,updated_at=now() WHERE id=$1::uuid AND version=$2 AND state='validated' +RETURNING `+identityRevisionColumns, id, expectedVersion)) + if err != nil { + return identity.Revision{}, false, err + } + identityChanged := previousID != "" && (previousIssuer != nextIssuer || previousTenant != nextTenant || previousAudience != nextAudience || previousClient != nextClient || previousSessionRef != nextSessionRef) + if identityChanged { + if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { + return identity.Revision{}, false, err + } + } + if err := tx.Commit(ctx); err != nil { + return identity.Revision{}, false, err + } + return revision, identityChanged, nil +} + +func (s *Store) DisableActiveIdentityRevision(ctx context.Context, expectedVersion int64) (identity.Revision, error) { + tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable}) + if err != nil { + return identity.Revision{}, err + } + defer tx.Rollback(ctx) + if ok, err := hasBreakGlassManager(ctx, tx); err != nil { + return identity.Revision{}, err + } else if !ok { + return identity.Revision{}, identity.ErrBreakGlassRequired + } + revision, err := scanIdentityRevision(tx.QueryRow(ctx, `UPDATE gateway_identity_configuration_revisions SET state='superseded', +superseded_at=now(),version=version+1,updated_at=now() WHERE state='active' AND version=$1 RETURNING `+identityRevisionColumns, expectedVersion)) + if errors.Is(err, pgx.ErrNoRows) { + return identity.Revision{}, identity.ErrRevisionConflict + } + if err != nil { + return identity.Revision{}, err + } + if _, err := tx.Exec(ctx, `DELETE FROM gateway_oidc_sessions`); err != nil { + return identity.Revision{}, err + } + if err := tx.Commit(ctx); err != nil { + return identity.Revision{}, err + } + return revision, nil +} + +func (s *Store) HasBreakGlassManager(ctx context.Context) (bool, error) { + return hasBreakGlassManager(ctx, s.pool) +} + +func (s *Store) HasActiveTenantKey(ctx context.Context, tenantKey string) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_tenants WHERE tenant_key=$1 AND status='active')`, tenantKey).Scan(&exists) + return exists, err +} + +func hasBreakGlassManager(ctx context.Context, query interface { + QueryRow(context.Context, string, ...any) pgx.Row +}) (bool, error) { + var exists bool + err := query.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway_users WHERE source='gateway' AND status='active' +AND deleted_at IS NULL AND password_hash IS NOT NULL AND password_hash <> '' AND (roles ? 'manager' OR roles ? 'admin'))`).Scan(&exists) + return exists, err +} + +func scanIdentityRevision(row scanner) (identity.Revision, error) { + var revision identity.Revision + var state string + var scopes, capabilities []byte + if err := row.Scan( + &revision.ID, &state, &revision.SchemaVersion, &revision.AuthCenterURL, &revision.Issuer, &revision.TenantID, + &revision.ApplicationID, &revision.Audience, &revision.BrowserClientID, &revision.MachineClientID, &scopes, + &capabilities, &revision.RolePrefix, &revision.LocalTenantKey, &revision.PublicBaseURL, &revision.WebBaseURL, + &revision.JITEnabled, &revision.LegacyJWTEnabled, &revision.TokenIntrospection, &revision.SessionRevocation, + &revision.MachineCredentialRef, &revision.SessionEncryptionKeyRef, &revision.SessionIdleSeconds, + &revision.SessionAbsoluteSeconds, &revision.SessionRefreshSeconds, &revision.Version, &revision.LastErrorCategory, + &revision.LastTraceID, &revision.LastAuditID, &revision.ValidatedAt, &revision.ActivatedAt, &revision.SupersededAt, + &revision.CreatedAt, &revision.UpdatedAt, + ); err != nil { + return identity.Revision{}, err + } + revision.State = identity.RevisionState(state) + _ = json.Unmarshal(scopes, &revision.Scopes) + _ = json.Unmarshal(capabilities, &revision.Capabilities) + if revision.Scopes == nil { + revision.Scopes = []string{} + } + if revision.Capabilities == nil { + revision.Capabilities = []string{} + } + return revision, nil +} + +func normalizeIdentityRevisionError(err error) error { + if errors.Is(err, pgx.ErrNoRows) { + return identity.ErrRevisionNotFound + } + return err +} diff --git a/apps/api/migrations/0067_identity_configuration_revisions.sql b/apps/api/migrations/0067_identity_configuration_revisions.sql new file mode 100644 index 0000000..12edebc --- /dev/null +++ b/apps/api/migrations/0067_identity_configuration_revisions.sql @@ -0,0 +1,53 @@ +CREATE TABLE IF NOT EXISTS gateway_identity_configuration_revisions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state text NOT NULL DEFAULT 'draft' CHECK (state IN ('draft','validated','active','superseded','failed')), + schema_version integer NOT NULL DEFAULT 1 CHECK (schema_version = 1), + auth_center_url text NOT NULL, + issuer text, + tenant_id text, + application_id text, + audience text, + browser_client_id text, + machine_client_id text, + scopes jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(scopes) = 'array'), + capabilities jsonb NOT NULL DEFAULT '[]'::jsonb CHECK (jsonb_typeof(capabilities) = 'array'), + role_prefix text NOT NULL DEFAULT 'gateway.', + local_tenant_key text NOT NULL, + public_base_url text NOT NULL, + web_base_url text NOT NULL, + jit_enabled boolean NOT NULL DEFAULT true, + legacy_jwt_enabled boolean NOT NULL DEFAULT false, + token_introspection boolean NOT NULL DEFAULT false, + session_revocation boolean NOT NULL DEFAULT false, + machine_credential_ref text, + session_encryption_key_ref text, + session_idle_seconds integer NOT NULL DEFAULT 1800 CHECK (session_idle_seconds > 0), + session_absolute_seconds integer NOT NULL DEFAULT 28800 CHECK (session_absolute_seconds > session_idle_seconds), + session_refresh_seconds integer NOT NULL DEFAULT 60 CHECK (session_refresh_seconds > 0 AND session_refresh_seconds < session_idle_seconds), + version bigint NOT NULL DEFAULT 1 CHECK (version > 0), + last_error_category text, + last_trace_id text, + last_audit_id text, + validated_at timestamptz, + activated_at timestamptz, + superseded_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (machine_credential_ref IS NULL OR machine_credential_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$'), + CHECK (session_encryption_key_ref IS NULL OR session_encryption_key_ref ~ '^[a-z0-9][a-z0-9-]{0,127}$') +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_gateway_identity_configuration_single_active + ON gateway_identity_configuration_revisions ((true)) WHERE state = 'active'; + +CREATE INDEX IF NOT EXISTS idx_gateway_identity_configuration_revisions_created + ON gateway_identity_configuration_revisions(created_at DESC); + +CREATE TABLE IF NOT EXISTS gateway_identity_management_requests ( + operation text NOT NULL, + idempotency_key text NOT NULL, + request_hash text NOT NULL, + response jsonb NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (operation, idempotency_key) +);