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:
2026-07-17 11:52:01 +08:00
parent 2a73e18123
commit c2ce42fead
7 changed files with 1025 additions and 0 deletions
+222
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()
}
@@ -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)
}
}
@@ -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
}
@@ -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")
}
}