feat(identity): 增加统一认证配置版本

新增统一认证 Revision 状态机、单 Active 数据库约束、SecretStore 引用字段、Break-glass 与本地租户门禁,并在关键身份变化或禁用时清理旧 BFF Session。\n\n同时实现标准应用接入 Manifest v1 消费端,接入码只进入请求 Body,Exchange Token 只进入 Authorization Header,禁用重定向并限制响应大小。\n\n验证:go test ./...;go vet ./...
This commit is contained in:
chengcheng 2026-07-17 11:52:01 +08:00
parent 2a73e18123
commit c2ce42fead
7 changed files with 1025 additions and 0 deletions

View File

@ -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)
}
}
}

View File

@ -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()
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -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
}

View File

@ -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)
);