package identity import ( "context" "crypto/rand" "errors" "strings" "time" "github.com/google/uuid" ) type PairingStatus string const ( PairingMetadataPending PairingStatus = "metadata_pending" PairingPreparing PairingStatus = "preparing" PairingReady PairingStatus = "ready" PairingCredentialsSaved PairingStatus = "credentials_saved" PairingCompleted PairingStatus = "completed" PairingFailed PairingStatus = "failed" PairingExpired PairingStatus = "expired" ) type PairingExchange struct { ID string `json:"id"` RevisionID string `json:"revisionId"` RemoteExchangeID string `json:"remoteExchangeId"` ExchangeTokenRef string `json:"-"` Status PairingStatus `json:"status"` RemoteVersion int64 `json:"remoteVersion"` ExpiresAt time.Time `json:"expiresAt"` Version int64 `json:"version"` LastErrorCategory string `json:"lastErrorCategory,omitempty"` AuthCenterAuditID string `json:"authCenterAuditId,omitempty"` LastTraceID string `json:"lastTraceId,omitempty"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type PairingExchangeUpdate struct { Status PairingStatus RemoteVersion int64 LastErrorCategory string AuthCenterAuditID string } type PairingRepository interface { CreateIdentityConfigurationRevision(context.Context, Revision) (Revision, error) IdentityConfigurationRevision(context.Context, string) (Revision, error) ApplyIdentityManifest(context.Context, string, int64, ManifestApplication) (Revision, error) CreateIdentityPairingExchange(context.Context, PairingExchange) (PairingExchange, error) IdentityPairingExchange(context.Context, string) (PairingExchange, error) UpdateIdentityPairingExchange(context.Context, string, int64, PairingExchangeUpdate) (PairingExchange, error) } type PairingSecretStore interface { Put(context.Context, string, []byte) error Get(context.Context, string) ([]byte, error) Delete(context.Context, string) error } type OnboardingRemote interface { Claim(context.Context, string) (ClaimedExchange, error) SubmitMetadata(context.Context, ClaimedExchange, ConsumerMetadata, string) (Exchange, error) Get(context.Context, string, string) (Exchange, error) DeliverCredential(context.Context, Exchange, string, string) (CredentialDelivery, error) Complete(context.Context, string, string, string, int64) error } type SecurityEventPreparer interface { PrepareSecurityEvents(context.Context, Revision, []byte) error } type PairingService struct { repository PairingRepository secrets PairingSecretStore remote func(string) (OnboardingRemote, error) security SecurityEventPreparer } func NewPairingService(repository PairingRepository, secrets PairingSecretStore, remote func(string) (OnboardingRemote, error), security SecurityEventPreparer) *PairingService { return &PairingService{repository: repository, secrets: secrets, remote: remote, security: security} } func (service *PairingService) Start(ctx context.Context, input PairingInput, traceID string) (PairingExchange, error) { draft, err := NewDraft(input) if err != nil { return PairingExchange{}, err } draft.LastTraceID = strings.TrimSpace(traceID) draft, err = service.repository.CreateIdentityConfigurationRevision(ctx, draft) if err != nil { return PairingExchange{}, err } remote, err := service.remote(draft.AuthCenterURL) if err != nil { return PairingExchange{}, err } claimed, err := remote.Claim(ctx, strings.TrimSpace(input.OnboardingCode)) if err != nil { return PairingExchange{}, err } pairingID := uuid.NewString() tokenReference := "identity-exchange-" + pairingID if err := service.secrets.Put(ctx, tokenReference, []byte(claimed.ExchangeToken)); err != nil { return PairingExchange{}, err } claimed.ExchangeToken = "" pairing := PairingExchange{ ID: pairingID, RevisionID: draft.ID, RemoteExchangeID: claimed.ExchangeID, ExchangeTokenRef: tokenReference, Status: PairingMetadataPending, RemoteVersion: claimed.Version, ExpiresAt: claimed.ExpiresAt, Version: 1, LastTraceID: strings.TrimSpace(traceID), } pairing, err = service.repository.CreateIdentityPairingExchange(ctx, pairing) if err != nil { _ = service.secrets.Delete(ctx, tokenReference) return PairingExchange{}, err } return pairing, nil } func (service *PairingService) Continue(ctx context.Context, pairingID string) (PairingExchange, error) { for step := 0; step < 6; step++ { pairing, err := service.repository.IdentityPairingExchange(ctx, pairingID) if err != nil { return PairingExchange{}, err } if pairing.Status == PairingCompleted || pairing.Status == PairingFailed || pairing.Status == PairingExpired { return pairing, nil } revision, err := service.repository.IdentityConfigurationRevision(ctx, pairing.RevisionID) if err != nil { return PairingExchange{}, err } token, err := service.secrets.Get(ctx, pairing.ExchangeTokenRef) if err != nil { return PairingExchange{}, err } remote, err := service.remote(revision.AuthCenterURL) if err != nil { clear(token) return PairingExchange{}, err } switch pairing.Status { case PairingMetadataPending: metadata, metadataErr := PairingInput{ AuthCenterURL: revision.AuthCenterURL, PublicBaseURL: revision.PublicBaseURL, WebBaseURL: revision.WebBaseURL, LocalTenantKey: revision.LocalTenantKey, }.ConsumerMetadata(true) if metadataErr != nil { clear(token) return PairingExchange{}, metadataErr } view, submitErr := remote.SubmitMetadata(ctx, ClaimedExchange{ ExchangeID: pairing.RemoteExchangeID, ExchangeToken: string(token), ExpiresAt: pairing.ExpiresAt, Version: pairing.RemoteVersion, }, metadata, "gateway-pairing-metadata-"+pairing.ID) clear(token) if submitErr != nil { return PairingExchange{}, submitErr } if _, err := service.updateFromRemote(ctx, pairing, view); err != nil { return PairingExchange{}, err } case PairingPreparing: view, getErr := remote.Get(ctx, pairing.RemoteExchangeID, string(token)) clear(token) if getErr != nil { return PairingExchange{}, getErr } updated, err := service.updateFromRemote(ctx, pairing, view) if err != nil { return PairingExchange{}, err } if updated.Status == PairingPreparing { return updated, nil } case PairingReady: delivery, deliveryErr := remote.DeliverCredential(ctx, Exchange{ ExchangeID: pairing.RemoteExchangeID, ApplicationID: revision.ApplicationID, Status: ExchangeReady, Version: pairing.RemoteVersion, ExpiresAt: pairing.ExpiresAt, }, string(token), "gateway-pairing-credential-"+uuid.NewString()) clear(token) if deliveryErr != nil { return PairingExchange{}, deliveryErr } updatedRevision, saveErr := service.saveDelivery(ctx, revision, pairing, delivery) if saveErr != nil { return PairingExchange{}, saveErr } _ = updatedRevision if _, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ Status: PairingCredentialsSaved, RemoteVersion: delivery.Version, }); err != nil { return PairingExchange{}, err } case PairingCredentialsSaved: if revision.SessionRevocation { if service.security == nil { clear(token) return PairingExchange{}, errors.New("security event preparation is unavailable") } secret, secretErr := service.secrets.Get(ctx, revision.MachineCredentialRef) if secretErr != nil { clear(token) return PairingExchange{}, secretErr } prepareErr := service.security.PrepareSecurityEvents(ctx, revision, secret) clear(secret) if prepareErr != nil { clear(token) return PairingExchange{}, prepareErr } } completeErr := remote.Complete(ctx, pairing.RemoteExchangeID, string(token), "gateway-pairing-complete-"+pairing.ID, pairing.RemoteVersion) clear(token) if completeErr != nil { return PairingExchange{}, completeErr } completed, err := service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ Status: PairingCompleted, RemoteVersion: pairing.RemoteVersion, }) if err != nil { return PairingExchange{}, err } _ = service.secrets.Delete(ctx, pairing.ExchangeTokenRef) return completed, nil default: clear(token) return PairingExchange{}, ErrRevisionConflict } } return service.repository.IdentityPairingExchange(ctx, pairingID) } func (service *PairingService) saveDelivery(ctx context.Context, revision Revision, pairing PairingExchange, delivery CredentialDelivery) (Revision, error) { if err := delivery.Manifest.Validate(); err != nil { return Revision{}, err } machineReference := "" if delivery.Manifest.Clients.MachineToMachine != nil { if delivery.MachineCredential == nil || delivery.MachineCredential.ClientID != delivery.Manifest.Clients.MachineToMachine.ClientID { return Revision{}, errors.New("onboarding machine credential is invalid") } machineReference = "identity-machine-" + revision.ID secret := []byte(delivery.MachineCredential.ClientSecret) delivery.MachineCredential.ClientSecret = "" if err := service.secrets.Put(ctx, machineReference, secret); err != nil { clear(secret) return Revision{}, err } clear(secret) } sessionReference := "" if delivery.Manifest.Clients.BrowserLogin != nil { sessionReference = "identity-session-" + revision.ID key := make([]byte, 32) if _, err := rand.Read(key); err != nil { _ = service.secrets.Delete(ctx, machineReference) return Revision{}, err } if err := service.secrets.Put(ctx, sessionReference, key); err != nil { clear(key) _ = service.secrets.Delete(ctx, machineReference) return Revision{}, err } clear(key) } updated, err := service.repository.ApplyIdentityManifest(ctx, revision.ID, revision.Version, ManifestApplication{ Manifest: delivery.Manifest, MachineCredentialRef: machineReference, SessionEncryptionKeyRef: sessionReference, TraceID: pairing.LastTraceID, AuditID: pairing.AuthCenterAuditID, }) if err != nil { if machineReference != "" { _ = service.secrets.Delete(ctx, machineReference) } if sessionReference != "" { _ = service.secrets.Delete(ctx, sessionReference) } return Revision{}, err } return updated, nil } func (service *PairingService) updateFromRemote(ctx context.Context, pairing PairingExchange, remote Exchange) (PairingExchange, error) { status := PairingPreparing switch remote.Status { case ExchangeReady, ExchangeCredentialDelivered: status = PairingReady case ExchangeCompleted: status = PairingFailed remote.LastErrorCategory = "remote_state_invalid" case ExchangeFailed: status = PairingFailed case ExchangeExpired: status = PairingExpired } return service.repository.UpdateIdentityPairingExchange(ctx, pairing.ID, pairing.Version, PairingExchangeUpdate{ Status: status, RemoteVersion: remote.Version, LastErrorCategory: remote.LastErrorCategory, AuthCenterAuditID: remote.AuditID, }) }