diff --git a/flutter/packages/authsome_core/lib/src/generated/api_types.dart b/flutter/packages/authsome_core/lib/src/generated/api_types.dart index 068a01b5..a7059f17 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_types.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_types.dart @@ -2222,7 +2222,9 @@ class Connection { final Map? attributeMappings; final String? clientId; final String createdAt; + final String? displayName; final String domain; + final bool enforced; final String? entityId; final String? envId; final String id; @@ -2245,7 +2247,9 @@ class Connection { this.attributeMappings, this.clientId, required this.createdAt, + this.displayName, required this.domain, + required this.enforced, this.entityId, this.envId, required this.id, @@ -2270,7 +2274,9 @@ class Connection { attributeMappings: json['attribute_mappings'] == null ? null : Map.from(json['attribute_mappings'] as Map), clientId: json['client_id'] as String?, createdAt: json['created_at'] as String, + displayName: json['display_name'] as String?, domain: json['domain'] as String, + enforced: json['enforced'] as bool, entityId: json['entity_id'] as String?, envId: json['env_id'] as String?, id: json['id'] as String, @@ -2296,7 +2302,9 @@ class Connection { if (attributeMappings != null) 'attribute_mappings': attributeMappings, if (clientId != null) 'client_id': clientId, 'created_at': createdAt, + if (displayName != null) 'display_name': displayName, 'domain': domain, + 'enforced': enforced, if (entityId != null) 'entity_id': entityId, if (envId != null) 'env_id': envId, 'id': id, diff --git a/go.mod b/go.mod index aa1160f8..a18a1b4f 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,8 @@ require ( github.com/stretchr/testify v1.12.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/xraph/chronicle v1.6.2 - github.com/xraph/forge v1.9.14 - github.com/xraph/forge/extensions/auth v1.9.14 + github.com/xraph/forge v1.9.16 + github.com/xraph/forge/extensions/auth v1.9.16 github.com/xraph/forgeui v1.4.1 github.com/xraph/grove v1.6.2 github.com/xraph/grove/drivers/mongodriver v1.6.2 @@ -182,7 +182,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xraph/confy v1.0.2 // indirect github.com/xraph/dispatch v1.6.2 - github.com/xraph/go-utils v1.1.8 + github.com/xraph/go-utils v1.2.0 github.com/xraph/ledger v1.6.1 github.com/xraph/vault v1.6.1 github.com/yusufpapurcu/wmi v1.2.4 // indirect diff --git a/go.sum b/go.sum index 1298ecf9..edae9069 100644 --- a/go.sum +++ b/go.sum @@ -430,14 +430,14 @@ github.com/xraph/confy v1.0.2 h1:90jmVLdw9J0uqJOnfDxqatOVJHeZ/IM1R89zd3df1FA= github.com/xraph/confy v1.0.2/go.mod h1:/jKqCF8cMpCatuNO2uFQ/7VClDBP2+V74fM5KFM42o8= github.com/xraph/dispatch v1.6.2 h1:pfyKiPuS1tIlhao+FyBlg36p6J+a5rTSMBTCE6gZlvM= github.com/xraph/dispatch v1.6.2/go.mod h1:K2lGkHo2U4EdVWVSdm22NtaCARSVc2y1OG5WYJsqp3E= -github.com/xraph/forge v1.9.14 h1:mYRpq1efGncNiWxa+hN6ri+7KZtQzsIdUn/UrBuPFN8= -github.com/xraph/forge v1.9.14/go.mod h1:5K24g2dtEObi2PKvarLEGgQhsMc1+HZsjEdP+qwK+zI= -github.com/xraph/forge/extensions/auth v1.9.14 h1:OistNxD+L7PBR/rfmPNzCv8H/05fI+I3OwsP/nn87zg= -github.com/xraph/forge/extensions/auth v1.9.14/go.mod h1:+2o0Js+gIrCfFTQZucn98u+Ut++DZGzzxYS+/QjH5OI= +github.com/xraph/forge v1.9.16 h1:lEqqpLuWHQZNzxi9Ore+Y0pg6vBoBXyR0VpknAS/SvU= +github.com/xraph/forge v1.9.16/go.mod h1:KxvxIAGpRRf3sBBDBJ150mqTkd/X/07/D74IEcWa+7I= +github.com/xraph/forge/extensions/auth v1.9.16 h1:vbhSqmpBHXFkj9dwBKHSe5QEylUyW/QyqZOlzeucgjk= +github.com/xraph/forge/extensions/auth v1.9.16/go.mod h1:Cbu2x3F43HPWcDfHpno/4hXmXwc9/hS8QiSTirNOxbg= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= -github.com/xraph/go-utils v1.1.8 h1:O8+Vie/u/ntn2cEbvh47jJLzQ6S7qwxhYwgRm2SL1sw= -github.com/xraph/go-utils v1.1.8/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= +github.com/xraph/go-utils v1.2.0 h1:ROVTKgBE3S7e9eVyXxGO7iccrJzvw6zXOrcrT8UWzko= +github.com/xraph/go-utils v1.2.0/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= github.com/xraph/grove v1.6.2 h1:O/3UyHTKQQ57CyZiLkDQi5T7xyzMSBz320VlK3C04Vo= github.com/xraph/grove v1.6.2/go.mod h1:bgjHNhnmyfEyzbdpcppRt+Zf24nNcbGKlo450Mi4giI= github.com/xraph/grove/drivers/mongodriver v1.6.2 h1:vyuSb2Fu6pRM7xb2DhtQattDCXyOm//TKUcMnwlkD9M= diff --git a/plugins/sso/enforce_test.go b/plugins/sso/enforce_test.go new file mode 100644 index 00000000..bcec4c91 --- /dev/null +++ b/plugins/sso/enforce_test.go @@ -0,0 +1,89 @@ +package sso + +import ( + "context" + "testing" + + "github.com/xraph/authsome/account" + "github.com/xraph/authsome/id" + "github.com/xraph/authsome/organization" + memstore "github.com/xraph/authsome/store/memory" + "github.com/xraph/authsome/user" +) + +// TestOnBeforeSignIn_Enforcement covers the SSO-required veto and its owner +// break-glass. +func TestOnBeforeSignIn_Enforcement(t *testing.T) { + ctx := context.Background() + appID := id.NewAppID() + orgID := id.NewOrgID() + + setup := func(t *testing.T, enforced, asOwner bool) (*Plugin, string) { + t.Helper() + ssoMem := NewMemoryStore() + coreMem := memstore.New() + p := &Plugin{ssoStore: ssoMem, store: coreMem} + + if err := ssoMem.CreateConnection(ctx, &Connection{ + ID: id.NewSSOConnectionID(), + AppID: appID, + OrgID: orgID, + Provider: "example.com", + Protocol: "saml", + Domain: "example.com", + Active: true, + Enforced: enforced, + }); err != nil { + t.Fatalf("seed connection: %v", err) + } + + email := "user@example.com" + u := &user.User{ID: id.NewUserID(), AppID: appID, Email: email} + if err := coreMem.CreateUserWithPrimaryEmail(ctx, u, user.NewPrimaryEmail(u, "test")); err != nil { + t.Fatalf("seed user: %v", err) + } + if asOwner { + if err := coreMem.CreateMember(ctx, &organization.Member{ + ID: id.NewMemberID(), + OrgID: orgID, + UserID: u.ID, + Role: organization.RoleOwner, + }); err != nil { + t.Fatalf("seed member: %v", err) + } + } + return p, email + } + + req := func(email string) *account.SignInRequest { + return &account.SignInRequest{AppID: appID, Email: email, Password: "x"} + } + + t.Run("enforced domain vetoes password login", func(t *testing.T) { + p, email := setup(t, true, false) + if err := p.OnBeforeSignIn(ctx, req(email)); err == nil { + t.Fatal("expected password login to be vetoed for an enforced domain") + } + }) + + t.Run("owner bypasses enforcement (break-glass)", func(t *testing.T) { + p, email := setup(t, true, true) + if err := p.OnBeforeSignIn(ctx, req(email)); err != nil { + t.Fatalf("owner should bypass, got %v", err) + } + }) + + t.Run("non-enforced domain passes through", func(t *testing.T) { + p, email := setup(t, false, false) + if err := p.OnBeforeSignIn(ctx, req(email)); err != nil { + t.Fatalf("non-enforced domain must pass, got %v", err) + } + }) + + t.Run("unrelated domain passes through", func(t *testing.T) { + p, _ := setup(t, true, false) + if err := p.OnBeforeSignIn(ctx, req("someone@example.net")); err != nil { + t.Fatalf("unrelated domain must pass, got %v", err) + } + }) +} diff --git a/plugins/sso/link_test.go b/plugins/sso/link_test.go new file mode 100644 index 00000000..4533f408 --- /dev/null +++ b/plugins/sso/link_test.go @@ -0,0 +1,78 @@ +package sso + +import ( + "context" + "errors" + "testing" + + "github.com/xraph/authsome/id" + memstore "github.com/xraph/authsome/store/memory" + "github.com/xraph/authsome/user" +) + +// TestLinkableExistingUser covers the SSO account-linking guard, including the +// password-credential rule: an invited-but-never-activated account (no password) +// is safe to link on SSO, while a self-registered account (has a password) is not. +func TestLinkableExistingUser(t *testing.T) { + ctx := context.Background() + appID := id.NewAppID() + var envID id.EnvironmentID // nil env → memory store matches app-wide + + newPlugin := func() (*Plugin, *memstore.Store) { + st := memstore.New() + return &Plugin{store: st}, st + } + seed := func(t *testing.T, st *memstore.Store, email, passwordHash string, verified bool) { + t.Helper() + u := &user.User{ + ID: id.NewUserID(), + AppID: appID, + Email: email, + PasswordHash: passwordHash, + } + pe := user.NewPrimaryEmail(u, "test") + pe.Verified = verified + if err := st.CreateUserWithPrimaryEmail(ctx, u, pe); err != nil { + t.Fatalf("seed %q: %v", email, err) + } + } + + t.Run("no account returns nil,nil (caller creates fresh)", func(t *testing.T) { + p, _ := newPlugin() + u, err := p.linkableExistingUser(ctx, appID, envID, "nobody@example.com") + if u != nil || err != nil { + t.Fatalf("got (%v, %v), want (nil, nil)", u, err) + } + }) + + t.Run("verified email links", func(t *testing.T) { + p, st := newPlugin() + seed(t, st, "verified@example.com", "pwhash", true) + u, err := p.linkableExistingUser(ctx, appID, envID, "verified@example.com") + if u == nil || err != nil { + t.Fatalf("got (%v, %v), want linked", u, err) + } + }) + + t.Run("unverified invited (no password) links and gets verified", func(t *testing.T) { + p, st := newPlugin() + seed(t, st, "invited@example.com", "", false) + u, err := p.linkableExistingUser(ctx, appID, envID, "invited@example.com") + if u == nil || err != nil { + t.Fatalf("got (%v, %v), want linked", u, err) + } + rec, _ := st.GetUserEmailRecord(ctx, appID, envID, "invited@example.com") + if rec == nil || !rec.Verified { + t.Fatal("email should be marked verified after linking an invited account") + } + }) + + t.Run("unverified self-signup (has password) is refused", func(t *testing.T) { + p, st := newPlugin() + seed(t, st, "attacker@example.com", "pwhash", false) + _, err := p.linkableExistingUser(ctx, appID, envID, "attacker@example.com") + if !errors.Is(err, errUnverifiedSSOLink) { + t.Fatalf("got %v, want errUnverifiedSSOLink", err) + } + }) +} diff --git a/plugins/sso/matching_test.go b/plugins/sso/matching_test.go index fd6e132e..3fb2fc55 100644 --- a/plugins/sso/matching_test.go +++ b/plugins/sso/matching_test.go @@ -12,7 +12,7 @@ import ( "github.com/xraph/authsome/user" ) -func seedUserWithEmail(t *testing.T, s *memory.Store, appID id.AppID, envID id.EnvironmentID, email string, verified bool) *user.User { +func seedUserWithEmail(t *testing.T, s *memory.Store, appID id.AppID, envID id.EnvironmentID, email string, verified bool, passwordHash string) *user.User { t.Helper() u := &user.User{ ID: id.NewUserID(), @@ -20,6 +20,7 @@ func seedUserWithEmail(t *testing.T, s *memory.Store, appID id.AppID, envID id.E EnvID: envID, Email: email, EmailVerified: verified, + PasswordHash: passwordHash, } row := &user.UserEmail{ ID: id.NewUserEmailID(), @@ -45,11 +46,13 @@ func TestLinkableExistingUser_RefusesUnverified(t *testing.T) { p.SetStore(s) appID, envID := id.NewAppID(), id.NewEnvironmentID() - seedUserWithEmail(t, s, appID, envID, "victim@corp.com", false) + // A self-registered account: unverified email AND a password credential an + // attacker could have set. Linking SSO to it must still be refused. + seedUserWithEmail(t, s, appID, envID, "victim@example.org", false, "attacker-set-password-hash") - got, err := p.linkableExistingUser(context.Background(), appID, envID, "victim@corp.com") + got, err := p.linkableExistingUser(context.Background(), appID, envID, "victim@example.org") - require.Error(t, err, "linking to an unverified pre-existing account must be refused") + require.Error(t, err, "linking to an unverified password-bearing account must be refused") assert.Nil(t, got) } @@ -61,9 +64,9 @@ func TestLinkableExistingUser_LinksVerified(t *testing.T) { p.SetStore(s) appID, envID := id.NewAppID(), id.NewEnvironmentID() - u := seedUserWithEmail(t, s, appID, envID, "member@corp.com", true) + u := seedUserWithEmail(t, s, appID, envID, "member@example.org", true, "") - got, err := p.linkableExistingUser(context.Background(), appID, envID, "member@corp.com") + got, err := p.linkableExistingUser(context.Background(), appID, envID, "member@example.org") require.NoError(t, err) require.NotNil(t, got) @@ -79,7 +82,7 @@ func TestLinkableExistingUser_NoMatchCreatesFresh(t *testing.T) { p.SetStore(s) appID, envID := id.NewAppID(), id.NewEnvironmentID() - got, err := p.linkableExistingUser(context.Background(), appID, envID, "nobody@corp.com") + got, err := p.linkableExistingUser(context.Background(), appID, envID, "nobody@example.org") require.NoError(t, err) assert.Nil(t, got) diff --git a/plugins/sso/migrations.go b/plugins/sso/migrations.go index 43daa0ea..a7df01d3 100644 --- a/plugins/sso/migrations.go +++ b/plugins/sso/migrations.go @@ -249,4 +249,73 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_authsome_sso_connections_domain }, }, ) + + // ────────────────────────────────────────────────── + // SSO enforcement: require SSO for a connection's domain + // ────────────────────────────────────────────────── + // When enforced, the plugin's BeforeSignIn vetoes password login for users on + // the connection's domain (owners/admins excepted). Defaults false. + + PostgresMigrations.MustRegister( + &migrate.Migration{ + Name: "add_enforced", + Version: "20240201000005", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS enforced BOOLEAN NOT NULL DEFAULT FALSE;`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS enforced;`) + return err + }, + }, + ) + + SqliteMigrations.MustRegister( + &migrate.Migration{ + Name: "add_enforced", + Version: "20240201000005", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections ADD COLUMN enforced INTEGER NOT NULL DEFAULT 0;`) + return err + }, + Down: func(_ context.Context, _ migrate.Executor) error { + return nil // SQLite lacks DROP COLUMN on older versions; best-effort. + }, + }, + ) + + // ────────────────────────────────────────────────── + // Optional admin-set connection label (e.g. "Okta", "Google Workspace") so + // multiple connections for one domain can be told apart. Cosmetic; defaults "". + // ────────────────────────────────────────────────── + + PostgresMigrations.MustRegister( + &migrate.Migration{ + Name: "add_display_name", + Version: "20240201000006", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections ADD COLUMN IF NOT EXISTS display_name TEXT NOT NULL DEFAULT '';`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections DROP COLUMN IF EXISTS display_name;`) + return err + }, + }, + ) + + SqliteMigrations.MustRegister( + &migrate.Migration{ + Name: "add_display_name", + Version: "20240201000006", + Up: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, `ALTER TABLE authsome_sso_connections ADD COLUMN display_name TEXT NOT NULL DEFAULT '';`) + return err + }, + Down: func(_ context.Context, _ migrate.Executor) error { + return nil // SQLite lacks DROP COLUMN on older versions; best-effort. + }, + }, + ) } diff --git a/plugins/sso/oidc_redirect_test.go b/plugins/sso/oidc_redirect_test.go new file mode 100644 index 00000000..2f3e431e --- /dev/null +++ b/plugins/sso/oidc_redirect_test.go @@ -0,0 +1,65 @@ +package sso + +import ( + "context" + "strings" + "testing" + + "github.com/xraph/authsome/ceremony" + "github.com/xraph/authsome/id" +) + +// stubProvider is a minimal Provider for exercising startLogin without a real +// IdP: LoginURL just echoes the state so we can recover it. +type stubProvider struct{} + +func (stubProvider) Name() string { return "stub" } +func (stubProvider) Protocol() string { return "oidc" } +func (stubProvider) LoginURL(state string) (string, error) { + return "https://idp.example.com/authorize?state=" + state, nil +} +func (stubProvider) HandleCallback(context.Context, map[string]string) (*User, error) { + return nil, nil +} + +// The OIDC redirect_uri must be query-free: Google (and Entra) reject or strip a +// `?connection=` param, causing redirect_uri_mismatch. The connection is +// recovered from the login state instead (see TestStartLogin_CarriesConnID). +func TestOIDCRedirectURLFor_IsQueryFree(t *testing.T) { + p := &Plugin{config: Config{PublicBaseURL: "https://api.example.com/api/identity"}} + conn := &Connection{ID: id.NewSSOConnectionID(), Provider: "example.com"} + + got := p.oidcRedirectURLFor(conn) + want := "https://api.example.com/api/identity/v1/sso/example.com/callback" + if got != want { + t.Fatalf("oidcRedirectURLFor = %q, want %q", got, want) + } + if strings.ContainsAny(got, "?") || strings.Contains(got, "connection=") { + t.Fatalf("redirect_uri must be query-free for IdP compatibility, got %q", got) + } +} + +// startLogin must persist the exact connection id in the state ceremony, so the +// query-free callback can recover it (multi-tenant safe — each login's state +// carries its own connection). +func TestStartLogin_CarriesConnID(t *testing.T) { + p := &Plugin{ + config: Config{PublicBaseURL: "https://api.example.com/api/identity"}, + ceremonies: ceremony.NewMemory(), + } + conn := &Connection{ID: id.NewSSOConnectionID(), Provider: "example.com"} + + resp, err := p.startLogin(context.Background(), id.NewAppID(), stubProvider{}, conn.Provider, conn.ID.String(), "") + if err != nil { + t.Fatalf("startLogin: %v", err) + } + + // The callback recovers the connection purely from the state token. + st, err := p.loadState(context.Background(), resp.State, conn.Provider) + if err != nil { + t.Fatalf("loadState: %v", err) + } + if st.ConnID != conn.ID.String() { + t.Fatalf("state ConnID = %q, want %q", st.ConnID, conn.ID.String()) + } +} diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 47a28206..63432352 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -373,7 +373,11 @@ func (p *Plugin) acsURLFor(conn *Connection) string { // the GET handler can recover the connection and its app without a publishable // key. This must be registered as an allowed redirect URI with the IdP. func (p *Plugin) oidcRedirectURLFor(conn *Connection) string { - return p.publicBaseURL() + "/v1/sso/" + conn.Provider + "/callback?connection=" + conn.ID.String() + // Domain-only (no `?connection=`): some IdPs reject or strip query params on + // the OAuth redirect_uri (Google → redirect_uri_mismatch; Entra likewise). + // The callback recovers the exact connection from the OAuth `state` ceremony + // (ssoState.ConnID) instead — see handleOIDCRedirect. + return p.publicBaseURL() + "/v1/sso/" + conn.Provider + "/callback" } // entityIDFor returns the SP EntityID for a connection: the stored override, or @@ -548,6 +552,12 @@ type ssoState struct { // RequestID is the SAML AuthnRequest ID minted at login, matched against the // assertion's InResponseTo at the ACS. Empty for OIDC. RequestID string `json:"request_id,omitempty"` + // ConnID pins the exact connection resolved at login, so the callback can + // recover it without a `?connection=` query param on the redirect_uri (which + // some IdPs — Google, Entra — reject or strip). Multi-tenant safe: the same + // domain in several orgs resolves to distinct connections at login, and each + // login's state carries its own id. + ConnID string `json:"conn_id,omitempty"` } // requestIDProvider is implemented by SAML providers that expose the AuthnRequest @@ -608,18 +618,22 @@ func (p *Plugin) handleLogin(ctx forge.Context, req *LoginRequest) (*LoginRespon if err != nil { return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) } - provider, _, err := p.resolveProvider(ctx.Context(), appID, req.Provider) + provider, conn, err := p.resolveProvider(ctx.Context(), appID, req.Provider) if err != nil { return nil, providerResolveError(req.Provider, err) } - return p.startLogin(ctx.Context(), appID, provider, req.Provider, req.ReturnURL) + var connID string + if conn != nil { + connID = conn.ID.String() + } + return p.startLogin(ctx.Context(), appID, provider, req.Provider, connID, req.ReturnURL) } // startLogin generates a CSRF state (carrying the app + return URL), caches it, // and returns the IdP login URL. Shared by provider-name and email-domain entry // points. The return URL is validated here (login is publishable-key-authed), so // the opaque state token that round-trips through the IdP can't be tampered with. -func (p *Plugin) startLogin(ctx context.Context, appID id.AppID, provider Provider, providerName, returnURL string) (*LoginResponse, error) { +func (p *Plugin) startLogin(ctx context.Context, appID id.AppID, provider Provider, providerName, connID, returnURL string) (*LoginResponse, error) { if returnURL != "" && !p.isAllowedReturnURL(returnURL) { return nil, forge.BadRequest("return_url is not allowed") } @@ -642,8 +656,8 @@ func (p *Plugin) startLogin(ctx context.Context, appID id.AppID, provider Provid return nil, forge.InternalError(fmt.Errorf("failed to get login URL: %w", err)) } - stateData, _ := json.Marshal(ssoState{Provider: providerName, AppID: appID.String(), ReturnURL: returnURL, RequestID: requestID}) //nolint:errcheck // best-effort cache - _ = p.ceremonies.Set(ctx, "sso:state:"+state, stateData, 10*time.Minute) //nolint:errcheck // best-effort cache + stateData, _ := json.Marshal(ssoState{Provider: providerName, AppID: appID.String(), ReturnURL: returnURL, RequestID: requestID, ConnID: connID}) //nolint:errcheck // best-effort cache + _ = p.ceremonies.Set(ctx, "sso:state:"+state, stateData, 10*time.Minute) //nolint:errcheck // best-effort cache return &LoginResponse{ LoginURL: loginURL, @@ -707,7 +721,7 @@ func (p *Plugin) handleLoginByDomain(ctx forge.Context, req *LoginByDomainReques if err != nil { return nil, forge.InternalError(fmt.Errorf("sso: build provider: %w", err)) } - return p.startLogin(ctx.Context(), appID, provider, conn.Provider, req.ReturnURL) + return p.startLogin(ctx.Context(), appID, provider, conn.Provider, conn.ID.String(), req.ReturnURL) } // handleSPMetadata serves the SAML SP metadata XML for an IdP to consume. Raw @@ -766,7 +780,17 @@ func (p *Plugin) handleCallback(ctx forge.Context, req *CallbackRequest) (*Callb return nil, forge.InternalError(fmt.Errorf("invalid app_id configuration: %w", err)) } - provider, conn, err := p.resolveProvider(ctx.Context(), appID, req.Provider) + // Prefer the exact connection pinned in the login state (multi-tenant safe); + // fall back to resolving by provider-name (domain) under the app. + var provider Provider + var conn *Connection + if st.ConnID != "" { + if conn, err = p.connectionByID(ctx.Context(), st.ConnID); err == nil { + provider, err = p.connectionToProvider(conn) + } + } else { + provider, conn, err = p.resolveProvider(ctx.Context(), appID, req.Provider) + } if err != nil { return nil, providerResolveError(req.Provider, err) } @@ -822,8 +846,14 @@ func (p *Plugin) handleOIDCRedirect(ctx forge.Context) error { return fail(providerErr) } - // Resolve the connection (and its app) from `?connection=`; fall back to - // provider-name under the request app for legacy/platform links. + // Resolve the connection. Preference order: + // 1. `?connection=` query param — legacy redirect URIs that still carry it. + // 2. the id carried in the login `state` (ssoState.ConnID) — the current + // path, since the redirect_uri is now domain-only. Multi-tenant safe. + // 3. provider-name (domain) under the request app — last-resort fallback. + if connID == "" && st != nil { + connID = st.ConnID + } var conn *Connection var err error if connID != "" { @@ -960,10 +990,97 @@ func (p *Plugin) linkableExistingUser(ctx context.Context, appID id.AppID, envID return nil, err } rec, recErr := p.store.GetUserEmailRecord(ctx, appID, envID, email) - if recErr != nil || rec == nil || !rec.Verified { - return nil, errUnverifiedSSOLink + if recErr == nil && rec != nil && rec.Verified { + return u, nil // email already verified — safe to link + } + // The email is unverified. Linking SSO to an unverified pre-existing account + // is an account-takeover risk ONLY when that account carries a password + // credential an attacker could have set (a self-registration): the attacker + // pre-registers the victim's email + password, and an auto-link would drop the + // victim into the attacker-controlled account. + // + // An invited-but-never-activated account has NO password — there is nothing to + // hijack — and the SSO assertion (from the domain's admin-configured IdP) is + // itself proof the user controls the email. So linking is safe there. This is + // the common "admin invites user, user then signs in via SSO" flow, which the + // blanket refusal used to break. Verify the email on link so the account is + // clean (and the denormalized users.email_verified is mirrored) going forward. + if strings.TrimSpace(u.PasswordHash) == "" { + if verr := p.store.MarkUserEmailVerified(ctx, u.ID, email); verr != nil && p.logger != nil { + p.logger.Warn("sso: verify invited email on SSO link failed", log.String("error", verr.Error())) + } + return u, nil + } + return nil, errUnverifiedSSOLink +} + +// OnBeforeSignIn enforces SSO: when a user's email domain has an active + +// enforced SSO connection, password sign-in is vetoed — they must use SSO. +// Workspace owners/admins are exempt (break-glass, so a misconfigured IdP can't +// lock out the people who administer it). Non-email sign-ins and domains without +// an enforced connection pass through untouched. +func (p *Plugin) OnBeforeSignIn(ctx context.Context, req *account.SignInRequest) error { + if p.ssoStore == nil || p.store == nil || req == nil { + return nil + } + email := strings.ToLower(strings.TrimSpace(req.Email)) + at := strings.LastIndexByte(email, '@') + if at <= 0 || at == len(email)-1 { + return nil + } + domain := email[at+1:] + + enforced := p.enforcedConnectionsForDomain(ctx, req.AppID, domain) + if len(enforced) == 0 { + return nil // domain isn't SSO-enforced + } + + // Break-glass: an owner/admin of an enforcing org may still use a password. + if u, err := p.store.GetUserByAnyEmail(ctx, req.AppID, req.EnvID, email); err == nil && u != nil { + for _, c := range enforced { + if p.isOrgOwnerOrAdmin(ctx, c.OrgID, u.ID) { + return nil + } + } + } + + return forge.NewHTTPError(http.StatusForbidden, + "single sign-on is required for this domain; sign in with SSO") +} + +// enforcedConnectionsForDomain returns the app's active + enforced connections +// whose domain matches (case-insensitive). SSO connections per app are few, so +// listing + filtering is acceptable at sign-in time. +func (p *Plugin) enforcedConnectionsForDomain(ctx context.Context, appID id.AppID, domain string) []*Connection { + all, err := p.ssoStore.ListConnections(ctx, appID) + if err != nil { + return nil + } + var out []*Connection + for _, c := range all { + if c != nil && c.Active && c.Enforced && strings.EqualFold(c.Domain, domain) { + out = append(out, c) + } + } + return out +} + +// isOrgOwnerOrAdmin reports whether userID is an owner or admin of orgID. +func (p *Plugin) isOrgOwnerOrAdmin(ctx context.Context, orgID id.OrgID, userID id.UserID) bool { + if orgID.Prefix() == "" { + return false } - return u, nil + members, err := p.store.ListMembers(ctx, orgID) + if err != nil { + return false + } + for _, m := range members { + if m != nil && m.UserID == userID && + (m.Role == organization.RoleOwner || m.Role == organization.RoleAdmin) { + return true + } + } + return false } func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Provider, conn *Connection, params map[string]string) (*CallbackResponse, error) { @@ -1093,6 +1210,14 @@ func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Pr return nil, issueErr } sess = result.Session + + // Fire the after-sign-in plugins for SSO logins too. IssueSession emits + // the session-create hooks, but leaves the sign-in-as-auth-event hook to + // the caller (password SignIn and agentauth both emit it themselves). + // Without this, SSO sign-ins skip the audit / anomaly / geo / device + // plugins entirely — e.g. no auth.signin audit record for SSO. Notification + // hooks are fire-and-forget, so there is nothing to fail the login on. + eng.Plugins().EmitAfterSignIn(goCtx, u, sess) } else { sessCfg := account.SessionConfig{ TokenTTL: p.config.SessionTokenTTL, @@ -1421,6 +1546,12 @@ type CreateConnectionInput struct { ACSURL string SignRequests bool AttributeMappings map[string]string + + // Enforced requires users on this domain to sign in via SSO (password login + // blocked). Usually toggled on later via UpdateConnection, not at create. + Enforced bool + // DisplayName is an optional admin-set label for the connection (cosmetic). + DisplayName string } // CreateConnection provisions an SSO connection: it resolves the app's default @@ -1453,16 +1584,18 @@ func (p *Plugin) CreateConnection(ctx context.Context, in CreateConnectionInput) now := time.Now() conn := &Connection{ - ID: id.NewSSOConnectionID(), - AppID: in.AppID, - EnvID: env.ID.String(), - OrgID: in.OrgID, - Provider: in.Provider, - Protocol: in.Protocol, - Domain: in.Domain, - Active: true, - CreatedAt: now, - UpdatedAt: now, + ID: id.NewSSOConnectionID(), + AppID: in.AppID, + EnvID: env.ID.String(), + OrgID: in.OrgID, + Provider: in.Provider, + Protocol: in.Protocol, + Domain: in.Domain, + Active: true, + Enforced: in.Enforced, + DisplayName: strings.TrimSpace(in.DisplayName), + CreatedAt: now, + UpdatedAt: now, } switch in.Protocol { case "oidc": diff --git a/plugins/sso/provider.go b/plugins/sso/provider.go index 25e1f2c5..d59a12ad 100644 --- a/plugins/sso/provider.go +++ b/plugins/sso/provider.go @@ -51,18 +51,26 @@ type User struct { // Connection represents a stored SSO connection for a tenant. type Connection struct { - ID id.SSOConnectionID `json:"id"` - AppID id.AppID `json:"app_id"` - EnvID string `json:"env_id,omitempty"` - OrgID id.OrgID `json:"org_id,omitempty"` - Provider string `json:"provider"` - Protocol string `json:"protocol"` - Domain string `json:"domain"` - MetadataURL string `json:"metadata_url,omitempty"` - ClientID string `json:"client_id,omitempty"` - ClientSecret string `json:"-"` - Issuer string `json:"issuer,omitempty"` - Active bool `json:"active"` + ID id.SSOConnectionID `json:"id"` + AppID id.AppID `json:"app_id"` + EnvID string `json:"env_id,omitempty"` + OrgID id.OrgID `json:"org_id,omitempty"` + Provider string `json:"provider"` + Protocol string `json:"protocol"` + Domain string `json:"domain"` + // DisplayName is an optional admin-set label for the connection (e.g. "Okta", + // "Google Workspace"), so multiple connections for the same domain can be told + // apart. Cosmetic — never used for routing. + DisplayName string `json:"display_name,omitempty"` + MetadataURL string `json:"metadata_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + ClientSecret string `json:"-"` + Issuer string `json:"issuer,omitempty"` + Active bool `json:"active"` + // Enforced requires users on this connection's domain to sign in via SSO — + // password login is blocked for them (workspace owners/admins excepted). See + // the plugin's OnBeforeSignIn. + Enforced bool `json:"enforced"` // SAML-specific configuration. Populated only for SAML connections. IDPMetadataXML string `json:"idp_metadata_xml,omitempty"` diff --git a/plugins/sso/store_models.go b/plugins/sso/store_models.go index fded2323..13ee29aa 100644 --- a/plugins/sso/store_models.go +++ b/plugins/sso/store_models.go @@ -23,11 +23,13 @@ type ssoConnectionModel struct { Provider string `grove:"provider,notnull"` Protocol string `grove:"protocol,notnull"` Domain string `grove:"domain,notnull"` + DisplayName string `grove:"display_name,notnull"` MetadataURL string `grove:"metadata_url,notnull"` ClientID string `grove:"client_id,notnull"` ClientSecret string `grove:"client_secret,notnull"` Issuer string `grove:"issuer,notnull"` Active bool `grove:"active,notnull"` + Enforced bool `grove:"enforced,notnull"` // SAML fields. attribute_mappings is stored as a JSON object. IDPMetadataXML string `grove:"idp_metadata_xml,notnull"` @@ -65,11 +67,13 @@ func toConnection(m *ssoConnectionModel) (*Connection, error) { Provider: m.Provider, Protocol: m.Protocol, Domain: m.Domain, + DisplayName: m.DisplayName, MetadataURL: m.MetadataURL, ClientID: m.ClientID, ClientSecret: m.ClientSecret, Issuer: m.Issuer, Active: m.Active, + Enforced: m.Enforced, IDPMetadataXML: m.IDPMetadataXML, IDPSSOURL: m.IDPSSOURL, @@ -109,11 +113,13 @@ func fromConnection(c *Connection) *ssoConnectionModel { Provider: c.Provider, Protocol: c.Protocol, Domain: c.Domain, + DisplayName: c.DisplayName, MetadataURL: c.MetadataURL, ClientID: c.ClientID, ClientSecret: c.ClientSecret, Issuer: c.Issuer, Active: c.Active, + Enforced: c.Enforced, IDPMetadataXML: c.IDPMetadataXML, IDPSSOURL: c.IDPSSOURL, diff --git a/plugins/sso/store_mongo.go b/plugins/sso/store_mongo.go index 699c27bd..4f3317c5 100644 --- a/plugins/sso/store_mongo.go +++ b/plugins/sso/store_mongo.go @@ -45,6 +45,7 @@ type ssoConnectionDoc struct { Provider string `bson:"provider"` Protocol string `bson:"protocol"` Domain string `bson:"domain"` + DisplayName string `bson:"display_name"` MetadataURL string `bson:"metadata_url"` ClientID string `bson:"client_id"` ClientSecret string `bson:"client_secret"` @@ -60,6 +61,7 @@ type ssoConnectionDoc struct { SignRequests bool `bson:"sign_requests"` AttributeMappings string `bson:"attribute_mappings"` Active bool `bson:"active"` + Enforced bool `bson:"enforced"` CreatedAt time.Time `bson:"created_at"` UpdatedAt time.Time `bson:"updated_at"` } @@ -85,6 +87,7 @@ func ssoDocToConnection(d *ssoConnectionDoc) (*Connection, error) { Provider: d.Provider, Protocol: d.Protocol, Domain: d.Domain, + DisplayName: d.DisplayName, MetadataURL: d.MetadataURL, ClientID: d.ClientID, ClientSecret: d.ClientSecret, @@ -98,6 +101,7 @@ func ssoDocToConnection(d *ssoConnectionDoc) (*Connection, error) { SPPrivateKey: d.SPPrivateKey, SignRequests: d.SignRequests, Active: d.Active, + Enforced: d.Enforced, CreatedAt: d.CreatedAt, UpdatedAt: d.UpdatedAt, } @@ -127,6 +131,7 @@ func ssoConnectionToDoc(c *Connection) *ssoConnectionDoc { Provider: c.Provider, Protocol: c.Protocol, Domain: c.Domain, + DisplayName: c.DisplayName, MetadataURL: c.MetadataURL, ClientID: c.ClientID, ClientSecret: c.ClientSecret, @@ -140,6 +145,7 @@ func ssoConnectionToDoc(c *Connection) *ssoConnectionDoc { SPPrivateKey: c.SPPrivateKey, SignRequests: c.SignRequests, Active: c.Active, + Enforced: c.Enforced, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, } @@ -264,6 +270,7 @@ func (s *MongoStore) UpdateConnection(ctx context.Context, c *Connection) error "provider": doc.Provider, "protocol": doc.Protocol, "domain": doc.Domain, + "display_name": doc.DisplayName, "metadata_url": doc.MetadataURL, "client_id": doc.ClientID, "client_secret": doc.ClientSecret, @@ -278,6 +285,7 @@ func (s *MongoStore) UpdateConnection(ctx context.Context, c *Connection) error "sign_requests": doc.SignRequests, "attribute_mappings": doc.AttributeMappings, "active": doc.Active, + "enforced": doc.Enforced, "updated_at": doc.UpdatedAt, }}, ) diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart index 068a01b5..a7059f17 100644 --- a/sdk/dart/lib/src/types.dart +++ b/sdk/dart/lib/src/types.dart @@ -2222,7 +2222,9 @@ class Connection { final Map? attributeMappings; final String? clientId; final String createdAt; + final String? displayName; final String domain; + final bool enforced; final String? entityId; final String? envId; final String id; @@ -2245,7 +2247,9 @@ class Connection { this.attributeMappings, this.clientId, required this.createdAt, + this.displayName, required this.domain, + required this.enforced, this.entityId, this.envId, required this.id, @@ -2270,7 +2274,9 @@ class Connection { attributeMappings: json['attribute_mappings'] == null ? null : Map.from(json['attribute_mappings'] as Map), clientId: json['client_id'] as String?, createdAt: json['created_at'] as String, + displayName: json['display_name'] as String?, domain: json['domain'] as String, + enforced: json['enforced'] as bool, entityId: json['entity_id'] as String?, envId: json['env_id'] as String?, id: json['id'] as String, @@ -2296,7 +2302,9 @@ class Connection { if (attributeMappings != null) 'attribute_mappings': attributeMappings, if (clientId != null) 'client_id': clientId, 'created_at': createdAt, + if (displayName != null) 'display_name': displayName, 'domain': domain, + 'enforced': enforced, if (entityId != null) 'entity_id': entityId, if (envId != null) 'env_id': envId, 'id': id, diff --git a/sdk/go/types.go b/sdk/go/types.go index 1db38a4b..48c7eb63 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -516,7 +516,9 @@ type Connection struct { AttributeMappings map[string]any `json:"attribute_mappings,omitempty"` ClientID string `json:"client_id,omitempty"` CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name,omitempty"` Domain string `json:"domain"` + Enforced bool `json:"enforced"` EntityID string `json:"entity_id,omitempty"` EnvID string `json:"env_id,omitempty"` ID string `json:"id"` diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index a7a4816b..565488e7 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -450,7 +450,9 @@ export interface Connection { attribute_mappings?: Record; client_id?: string; created_at: string; + display_name?: string; domain: string; + enforced: boolean; entity_id?: string; env_id?: string; id: string; diff --git a/sdkgen/spec.json b/sdkgen/spec.json index a4acdb85..2bee46b3 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -1597,9 +1597,15 @@ "format": "date-time", "type": "string" }, + "display_name": { + "type": "string" + }, "domain": { "type": "string" }, + "enforced": { + "type": "boolean" + }, "entity_id": { "type": "string" }, @@ -1651,6 +1657,7 @@ "protocol", "domain", "active", + "enforced", "created_at", "updated_at" ], diff --git a/ui/packages/components/src/components/sign-in-form.sso.test.tsx b/ui/packages/components/src/components/sign-in-form.sso.test.tsx new file mode 100644 index 00000000..191ee043 --- /dev/null +++ b/ui/packages/components/src/components/sign-in-form.sso.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { SignInForm, type SSOResolution } from "./sign-in-form"; +import { routedFetch, stubAuth, withAuth } from "../test-support"; + +/** + * Home-realm discovery on the email step. `resolveSSO` lets the host app route + * an email's domain to its IdP before a password is ever requested — the + * Okta/Microsoft/Google "identifier-first" flow. These pin the branching so the + * SSO step cannot regress into always asking for a password. + */ +describe("SignInForm SSO discovery", () => { + const mount = ( + resolveSSO?: (email: string) => Promise, + ) => { + const { fetchFn } = routedFetch({}); + return render( + withAuth( + , + stubAuth({ fetch: fetchFn, session: null }), + ), + ); + }; + + const submitEmail = (value: string) => { + fireEvent.change(screen.getByLabelText("Email address"), { + target: { value }, + }); + fireEvent.click(screen.getByRole("button", { name: "Continue" })); + }; + + it("routes an enforced domain straight to SSO, never showing a password field", async () => { + const start = vi.fn(); + const resolveSSO = vi.fn(async () => ({ continue: start, enforced: true })); + + mount(resolveSSO); + submitEmail("user@enforced.example.com"); + + // The SSO step renders and password entry is not offered. + await screen.findByRole("button", { name: /continue with sso/i }); + expect(resolveSSO).toHaveBeenCalledWith("user@enforced.example.com"); + expect(screen.queryByLabelText("Password")).toBeNull(); + expect(screen.queryByText(/password instead/i)).toBeNull(); + // Enforced auto-starts the IdP handoff. + expect(start).toHaveBeenCalledTimes(1); + }); + + it("offers SSO alongside password when the domain is not enforced", async () => { + const start = vi.fn(); + const resolveSSO = vi.fn(async () => ({ + continue: start, + enforced: false, + provider: "Okta", + })); + + mount(resolveSSO); + submitEmail("user@optional.example.com"); + + // Provider name brands the SSO button; password remains reachable. + const ssoButton = await screen.findByRole("button", { + name: /continue with okta/i, + }); + expect(start).not.toHaveBeenCalled(); // not auto-started when optional + + // The SSO button, when clicked, starts the IdP handoff (no-op stub keeps + // the SSO step mounted so the password option is still reachable). + fireEvent.click(ssoButton); + expect(start).toHaveBeenCalledTimes(1); + + // Choosing "password instead" reveals the password field. + fireEvent.click(screen.getByText(/password instead/i)); + expect(screen.getByLabelText("Password")).toBeTruthy(); + }); + + it("falls through to the password step when the domain has no SSO", async () => { + const resolveSSO = vi.fn(async () => null); + + mount(resolveSSO); + submitEmail("user@nosso.example.com"); + + await screen.findByLabelText("Password"); + expect(resolveSSO).toHaveBeenCalledWith("user@nosso.example.com"); + }); + + it("fails open to password when the resolver throws", async () => { + const resolveSSO = vi.fn(async () => { + throw new Error("discovery unavailable"); + }); + + mount(resolveSSO); + submitEmail("user@flaky.example.com"); + + // Discovery failure must never lock a user out of password login. + await screen.findByLabelText("Password"); + }); + + it("keeps the plain email→password flow when no resolver is supplied", async () => { + mount(undefined); + submitEmail("user@plain.example.com"); + + await waitFor(() => + expect(screen.getByLabelText("Password")).toBeTruthy(), + ); + }); +}); diff --git a/ui/packages/components/src/components/sign-in-form.tsx b/ui/packages/components/src/components/sign-in-form.tsx index fb609236..c276112f 100644 --- a/ui/packages/components/src/components/sign-in-form.tsx +++ b/ui/packages/components/src/components/sign-in-form.tsx @@ -23,9 +23,44 @@ import { ArrowLeft, MailCheck } from "lucide-react"; import { TurnstileWidget } from "./turnstile-widget"; import { AuthClientError } from "@authsome/ui-core"; +/** + * The outcome of home-realm discovery for a sign-in email. Returned by + * {@link SignInFormComponentProps.resolveSSO} to tell the form that the email's + * domain is served by a single-sign-on IdP. + */ +export interface SSOResolution { + /** + * Begin the SSO login (typically a redirect to the IdP, or an API call that + * ends in one). Invoked when the user chooses SSO, and immediately when + * `enforced` is true. + */ + continue: () => void | Promise; + /** + * When true the domain requires SSO: the password field is never shown and + * the form routes straight to the IdP. When false/omitted, SSO is offered + * alongside password so the user can pick. + */ + enforced?: boolean; + /** + * Display name for the SSO button, e.g. "Okta" renders "Continue with Okta". + * Falls back to a generic "Continue with SSO". + */ + provider?: string; +} + export interface SignInFormComponentProps { /** Callback invoked after a successful sign-in. */ onSuccess?: () => void; + /** + * Home-realm discovery hook. Called with the entered email when the user + * submits the email step. Return an {@link SSOResolution} to route the domain + * to its IdP (identifier-first, Okta/Microsoft/Google style), or `null` to + * fall through to password entry. Rejections fail open to password so a + * discovery outage never locks users out. + */ + resolveSSO?: ( + email: string, + ) => Promise; /** URL to the sign-up page. Renders a "Don't have an account?" footer link. */ signUpUrl?: string; /** URL to the forgot-password page. Renders a "Forgot password?" link. */ @@ -79,6 +114,7 @@ export interface SignInFormComponentProps { */ export function SignInForm({ onSuccess, + resolveSSO, signUpUrl, forgotPasswordUrl, verifyEmailUrl, @@ -127,11 +163,17 @@ export function SignInForm({ const hasSocial = socialProviders && socialProviders.length > 0 && onSocialLogin; - const [step, setStep] = useState<"email" | "password" | "verify">("email"); + const [step, setStep] = useState<"email" | "password" | "sso" | "verify">( + "email", + ); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + // Set while resolveSSO is in flight (email step → discovery round trip). + const [isResolvingSSO, setIsResolvingSSO] = useState(false); + // The resolved IdP handoff once discovery finds SSO for the domain. + const [sso, setSso] = useState(null); const [captchaToken, setCaptchaToken] = useState(null); const [resendStatus, setResendStatus] = useState<"idle" | "sent" | "error">( "idle", @@ -143,7 +185,7 @@ export function SignInForm({ captchaCfg.provider === "turnstile" && !!captchaCfg.site_key; - const handleEmailContinue = (e: React.FormEvent) => { + const handleEmailContinue = async (e: React.FormEvent) => { e.preventDefault(); setError(null); @@ -152,7 +194,39 @@ export function SignInForm({ return; } - setStep("password"); + // No discovery hook: keep the plain email → password flow. + if (!resolveSSO) { + setStep("password"); + return; + } + + // Home-realm discovery. A rejection (or any non-resolution) fails open to + // password entry — a discovery outage must never lock a user out. + setIsResolvingSSO(true); + let resolution: SSOResolution | null | undefined; + try { + resolution = await resolveSSO(email.trim()); + } catch { + resolution = null; + } finally { + setIsResolvingSSO(false); + } + + if (!resolution) { + setStep("password"); + return; + } + + setSso(resolution); + setStep("sso"); + // An enforced domain routes straight to the IdP — no password offered. + if (resolution.enforced) { + void resolution.continue(); + } + }; + + const startSSO = () => { + if (sso) void sso.continue(); }; const handleSignIn = async (e: React.FormEvent) => { @@ -213,6 +287,7 @@ export function SignInForm({ setStep("email"); setPassword(""); setError(null); + setSso(null); }; const footer = signUpUrl ? ( @@ -368,7 +443,7 @@ export function SignInForm({ placeholder="name@example.com" autoComplete="username" required - disabled={isSubmitting} + disabled={isSubmitting || isResolvingSSO} value={email} onChange={(e) => setEmail(e.target.value)} /> @@ -377,8 +452,9 @@ export function SignInForm({ @@ -398,6 +474,64 @@ export function SignInForm({ ); } + /* ── Step: SSO (home-realm discovery matched an IdP) ── */ + + if (step === "sso") { + const providerLabel = sso?.provider + ? `Continue with ${sso.provider}` + : "Continue with SSO"; + return ( + +
+

+ {sso?.enforced + ? "Your organization requires single sign-on for this email." + : "Your organization supports single sign-on for this email."} +

+ + + + {/* Non-enforced domains may still use a password. */} + {!sso?.enforced && showPassword && ( + <> + + + + )} + + +
+
+ ); + } + /* ── Step 2: Password ───────────────────────────────── */ return ( diff --git a/ui/packages/components/src/components/sign-in.tsx b/ui/packages/components/src/components/sign-in.tsx index 5c91cd18..557bec48 100644 --- a/ui/packages/components/src/components/sign-in.tsx +++ b/ui/packages/components/src/components/sign-in.tsx @@ -4,7 +4,7 @@ import * as React from "react"; import { useAuth } from "@authsome/ui-react"; import { safeRedirectTarget } from "@authsome/ui-core"; import { useSubPath } from "../lib/use-sub-path"; -import { SignInForm } from "./sign-in-form"; +import { SignInForm, type SSOResolution } from "./sign-in-form"; import { ForgotPasswordForm } from "./forgot-password-form"; import { ResetPasswordForm } from "./reset-password-form"; import { EmailVerificationForm } from "./email-verification-form"; @@ -18,6 +18,14 @@ export interface SignInProps { signUpUrl?: string; /** Callback invoked after a successful sign-in (any method). */ onSuccess?: () => void; + /** + * Home-realm discovery hook forwarded to the sign-in form. Return an + * {@link SSOResolution} to route an email's domain to its IdP + * (identifier-first), or `null` to fall through to password. + */ + resolveSSO?: ( + email: string, + ) => Promise; /** Social/OAuth providers to display. Auto-derived from config when omitted. */ socialProviders?: SocialProvider[]; /** Override social login click handler. */ @@ -57,6 +65,7 @@ export function SignIn({ path = "/sign-in", signUpUrl = "/sign-up", onSuccess, + resolveSSO, socialProviders, onSocialLogin, socialLayout, @@ -127,6 +136,7 @@ export function SignIn({ return ( ; client_id?: string; created_at: string; + display_name?: string; domain: string; + enforced: boolean; entity_id?: string; env_id?: string; id: string;