From 5fc0549fd8d6da94d7fa84f8385877e1cd38bac2 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sat, 29 Aug 2026 19:14:45 +0100 Subject: [PATCH 01/11] feat(sso): add tests for linkable existing user and enhance linking logic for invited accounts --- plugins/sso/link_test.go | 78 ++++++++++++++++++++++++++++++++++++ plugins/sso/matching_test.go | 11 +++-- plugins/sso/plugin.go | 24 +++++++++-- 3 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 plugins/sso/link_test.go diff --git a/plugins/sso/link_test.go b/plugins/sso/link_test.go new file mode 100644 index 00000000..5fd776ee --- /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@acme.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@acme.com", "pwhash", true) + u, err := p.linkableExistingUser(ctx, appID, envID, "verified@acme.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@acme.com", "", false) + u, err := p.linkableExistingUser(ctx, appID, envID, "invited@acme.com") + if u == nil || err != nil { + t.Fatalf("got (%v, %v), want linked", u, err) + } + rec, _ := st.GetUserEmailRecord(ctx, appID, envID, "invited@acme.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@acme.com", "pwhash", false) + _, err := p.linkableExistingUser(ctx, appID, envID, "attacker@acme.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..bc1c92d2 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@corp.com", false, "attacker-set-password-hash") got, err := p.linkableExistingUser(context.Background(), appID, envID, "victim@corp.com") - 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,7 +64,7 @@ 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@corp.com", true, "") got, err := p.linkableExistingUser(context.Background(), appID, envID, "member@corp.com") diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 47a28206..43e37806 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -960,10 +960,28 @@ 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 u, nil + return nil, errUnverifiedSSOLink } func (p *Plugin) authenticateUser(ctx forge.Context, appID id.AppID, provider Provider, conn *Connection, params map[string]string) (*CallbackResponse, error) { From 9695e02ceb16495a5620262758dac58dceb943d6 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 30 Aug 2026 12:36:09 +0100 Subject: [PATCH 02/11] feat(sso): add after-sign-in plugin emission for SSO logins to ensure audit records --- plugins/sso/plugin.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 43e37806..08d96c36 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -1111,6 +1111,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, From 238ceeb561ce52ecdb7222a75de8c297acf7d794 Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 30 Aug 2026 13:55:53 +0100 Subject: [PATCH 03/11] feat(sso): implement SSO enforcement for domain connections, requiring SSO for password logins --- plugins/sso/enforce_test.go | 89 +++++++++++++++++++++++++++++++++++++ plugins/sso/migrations.go | 35 +++++++++++++++ plugins/sso/plugin.go | 74 ++++++++++++++++++++++++++++++ plugins/sso/provider.go | 4 ++ plugins/sso/store_models.go | 3 ++ plugins/sso/store_mongo.go | 4 ++ 6 files changed, 209 insertions(+) create mode 100644 plugins/sso/enforce_test.go diff --git a/plugins/sso/enforce_test.go b/plugins/sso/enforce_test.go new file mode 100644 index 00000000..832f8383 --- /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: "acme.com", + Protocol: "saml", + Domain: "acme.com", + Active: true, + Enforced: enforced, + }); err != nil { + t.Fatalf("seed connection: %v", err) + } + + email := "user@acme.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@other.com")); err != nil { + t.Fatalf("unrelated domain must pass, got %v", err) + } + }) +} diff --git a/plugins/sso/migrations.go b/plugins/sso/migrations.go index 43daa0ea..fde73872 100644 --- a/plugins/sso/migrations.go +++ b/plugins/sso/migrations.go @@ -249,4 +249,39 @@ 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. + }, + }, + ) } diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 08d96c36..b19b67c1 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -984,6 +984,75 @@ func (p *Plugin) linkableExistingUser(ctx context.Context, appID id.AppID, envID 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 + } + 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) { // Every SSO entry point funnels through here: the JSON callback, the OIDC // browser landing and the SAML ACS. All three are the identity @@ -1447,6 +1516,10 @@ 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 } // CreateConnection provisions an SSO connection: it resolves the app's default @@ -1487,6 +1560,7 @@ func (p *Plugin) CreateConnection(ctx context.Context, in CreateConnectionInput) Protocol: in.Protocol, Domain: in.Domain, Active: true, + Enforced: in.Enforced, CreatedAt: now, UpdatedAt: now, } diff --git a/plugins/sso/provider.go b/plugins/sso/provider.go index 25e1f2c5..b1b86639 100644 --- a/plugins/sso/provider.go +++ b/plugins/sso/provider.go @@ -63,6 +63,10 @@ type Connection struct { 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..1c8435ec 100644 --- a/plugins/sso/store_models.go +++ b/plugins/sso/store_models.go @@ -28,6 +28,7 @@ type ssoConnectionModel struct { 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"` @@ -70,6 +71,7 @@ func toConnection(m *ssoConnectionModel) (*Connection, error) { ClientSecret: m.ClientSecret, Issuer: m.Issuer, Active: m.Active, + Enforced: m.Enforced, IDPMetadataXML: m.IDPMetadataXML, IDPSSOURL: m.IDPSSOURL, @@ -114,6 +116,7 @@ func fromConnection(c *Connection) *ssoConnectionModel { 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..2cdfdca9 100644 --- a/plugins/sso/store_mongo.go +++ b/plugins/sso/store_mongo.go @@ -60,6 +60,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"` } @@ -98,6 +99,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, } @@ -140,6 +142,7 @@ func ssoConnectionToDoc(c *Connection) *ssoConnectionDoc { SPPrivateKey: c.SPPrivateKey, SignRequests: c.SignRequests, Active: c.Active, + Enforced: c.Enforced, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, } @@ -278,6 +281,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, }}, ) From 95e486a82decb8d40f1dfd6e637ed80b9af7456e Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 30 Aug 2026 17:22:22 +0100 Subject: [PATCH 04/11] feat(sso): implement SSO discovery and routing in SignInForm component --- .../src/components/sign-in-form.sso.test.tsx | 106 +++++++++++++ .../src/components/sign-in-form.tsx | 144 +++++++++++++++++- .../components/src/components/sign-in.tsx | 12 +- ui/packages/components/src/index.ts | 6 +- 4 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 ui/packages/components/src/components/sign-in-form.sso.test.tsx 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..84cc28c4 --- /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.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.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.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.com"); + + await screen.findByLabelText("Password"); + expect(resolveSSO).toHaveBeenCalledWith("user@nosso.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.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.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..dbaa1cdc 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 = null; + 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 ( Date: Sun, 30 Aug 2026 17:33:58 +0100 Subject: [PATCH 05/11] fix(sign-in): remove unnecessary initialization of resolution variable --- ui/packages/components/src/components/sign-in-form.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/components/src/components/sign-in-form.tsx b/ui/packages/components/src/components/sign-in-form.tsx index dbaa1cdc..c276112f 100644 --- a/ui/packages/components/src/components/sign-in-form.tsx +++ b/ui/packages/components/src/components/sign-in-form.tsx @@ -203,7 +203,7 @@ export function SignInForm({ // 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 = null; + let resolution: SSOResolution | null | undefined; try { resolution = await resolveSSO(email.trim()); } catch { From 17b2be6e793bd9ddcf4db4f9b98d3d354b25467d Mon Sep 17 00:00:00 2001 From: Clement James Date: Sun, 30 Aug 2026 18:43:31 +0100 Subject: [PATCH 06/11] feat(connection): add enforced field to Connection type across multiple languages --- .../packages/authsome_core/lib/src/generated/api_types.dart | 4 ++++ sdk/dart/lib/src/types.dart | 4 ++++ sdk/go/types.go | 1 + sdk/typescript/src/types.ts | 1 + sdkgen/spec.json | 4 ++++ ui/packages/core/src/generated/api-types.ts | 1 + 6 files changed, 15 insertions(+) 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..e5c7aacd 100644 --- a/flutter/packages/authsome_core/lib/src/generated/api_types.dart +++ b/flutter/packages/authsome_core/lib/src/generated/api_types.dart @@ -2223,6 +2223,7 @@ class Connection { final String? clientId; final String createdAt; final String domain; + final bool enforced; final String? entityId; final String? envId; final String id; @@ -2246,6 +2247,7 @@ class Connection { this.clientId, required this.createdAt, required this.domain, + required this.enforced, this.entityId, this.envId, required this.id, @@ -2271,6 +2273,7 @@ class Connection { clientId: json['client_id'] as String?, createdAt: json['created_at'] 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, @@ -2297,6 +2300,7 @@ class Connection { if (clientId != null) 'client_id': clientId, 'created_at': createdAt, 'domain': domain, + 'enforced': enforced, if (entityId != null) 'entity_id': entityId, if (envId != null) 'env_id': envId, 'id': id, diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart index 068a01b5..e5c7aacd 100644 --- a/sdk/dart/lib/src/types.dart +++ b/sdk/dart/lib/src/types.dart @@ -2223,6 +2223,7 @@ class Connection { final String? clientId; final String createdAt; final String domain; + final bool enforced; final String? entityId; final String? envId; final String id; @@ -2246,6 +2247,7 @@ class Connection { this.clientId, required this.createdAt, required this.domain, + required this.enforced, this.entityId, this.envId, required this.id, @@ -2271,6 +2273,7 @@ class Connection { clientId: json['client_id'] as String?, createdAt: json['created_at'] 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, @@ -2297,6 +2300,7 @@ class Connection { if (clientId != null) 'client_id': clientId, 'created_at': createdAt, '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..f740ffb4 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -517,6 +517,7 @@ type Connection struct { ClientID string `json:"client_id,omitempty"` CreatedAt string `json:"created_at"` 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..862c14e0 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -451,6 +451,7 @@ export interface Connection { client_id?: string; created_at: 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..64a8aaab 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -1600,6 +1600,9 @@ "domain": { "type": "string" }, + "enforced": { + "type": "boolean" + }, "entity_id": { "type": "string" }, @@ -1651,6 +1654,7 @@ "protocol", "domain", "active", + "enforced", "created_at", "updated_at" ], diff --git a/ui/packages/core/src/generated/api-types.ts b/ui/packages/core/src/generated/api-types.ts index 205e26d9..144dd287 100644 --- a/ui/packages/core/src/generated/api-types.ts +++ b/ui/packages/core/src/generated/api-types.ts @@ -451,6 +451,7 @@ export interface Connection { client_id?: string; created_at: string; domain: string; + enforced: boolean; entity_id?: string; env_id?: string; id: string; From 1a14fc778b1401d922bd27e5fb67499821d547f1 Mon Sep 17 00:00:00 2001 From: Clement James Date: Mon, 31 Aug 2026 15:37:54 +0100 Subject: [PATCH 07/11] fix(tests): update email domains in SSO tests to use example.com --- plugins/sso/enforce_test.go | 8 ++++---- plugins/sso/link_test.go | 16 ++++++++-------- plugins/sso/matching_test.go | 10 +++++----- .../src/components/sign-in-form.sso.test.tsx | 14 +++++++------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/plugins/sso/enforce_test.go b/plugins/sso/enforce_test.go index 832f8383..bcec4c91 100644 --- a/plugins/sso/enforce_test.go +++ b/plugins/sso/enforce_test.go @@ -28,16 +28,16 @@ func TestOnBeforeSignIn_Enforcement(t *testing.T) { ID: id.NewSSOConnectionID(), AppID: appID, OrgID: orgID, - Provider: "acme.com", + Provider: "example.com", Protocol: "saml", - Domain: "acme.com", + Domain: "example.com", Active: true, Enforced: enforced, }); err != nil { t.Fatalf("seed connection: %v", err) } - email := "user@acme.com" + 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) @@ -82,7 +82,7 @@ func TestOnBeforeSignIn_Enforcement(t *testing.T) { t.Run("unrelated domain passes through", func(t *testing.T) { p, _ := setup(t, true, false) - if err := p.OnBeforeSignIn(ctx, req("someone@other.com")); err != nil { + 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 index 5fd776ee..4533f408 100644 --- a/plugins/sso/link_test.go +++ b/plugins/sso/link_test.go @@ -39,7 +39,7 @@ func TestLinkableExistingUser(t *testing.T) { t.Run("no account returns nil,nil (caller creates fresh)", func(t *testing.T) { p, _ := newPlugin() - u, err := p.linkableExistingUser(ctx, appID, envID, "nobody@acme.com") + 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) } @@ -47,8 +47,8 @@ func TestLinkableExistingUser(t *testing.T) { t.Run("verified email links", func(t *testing.T) { p, st := newPlugin() - seed(t, st, "verified@acme.com", "pwhash", true) - u, err := p.linkableExistingUser(ctx, appID, envID, "verified@acme.com") + 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) } @@ -56,12 +56,12 @@ func TestLinkableExistingUser(t *testing.T) { t.Run("unverified invited (no password) links and gets verified", func(t *testing.T) { p, st := newPlugin() - seed(t, st, "invited@acme.com", "", false) - u, err := p.linkableExistingUser(ctx, appID, envID, "invited@acme.com") + 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@acme.com") + 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") } @@ -69,8 +69,8 @@ func TestLinkableExistingUser(t *testing.T) { t.Run("unverified self-signup (has password) is refused", func(t *testing.T) { p, st := newPlugin() - seed(t, st, "attacker@acme.com", "pwhash", false) - _, err := p.linkableExistingUser(ctx, appID, envID, "attacker@acme.com") + 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 bc1c92d2..3fb2fc55 100644 --- a/plugins/sso/matching_test.go +++ b/plugins/sso/matching_test.go @@ -48,9 +48,9 @@ func TestLinkableExistingUser_RefusesUnverified(t *testing.T) { // 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@corp.com", false, "attacker-set-password-hash") + 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 password-bearing account must be refused") assert.Nil(t, got) @@ -64,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) @@ -82,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/ui/packages/components/src/components/sign-in-form.sso.test.tsx b/ui/packages/components/src/components/sign-in-form.sso.test.tsx index 84cc28c4..191ee043 100644 --- a/ui/packages/components/src/components/sign-in-form.sso.test.tsx +++ b/ui/packages/components/src/components/sign-in-form.sso.test.tsx @@ -35,11 +35,11 @@ describe("SignInForm SSO discovery", () => { const resolveSSO = vi.fn(async () => ({ continue: start, enforced: true })); mount(resolveSSO); - submitEmail("user@enforced.com"); + 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.com"); + 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. @@ -55,7 +55,7 @@ describe("SignInForm SSO discovery", () => { })); mount(resolveSSO); - submitEmail("user@optional.com"); + submitEmail("user@optional.example.com"); // Provider name brands the SSO button; password remains reachable. const ssoButton = await screen.findByRole("button", { @@ -77,10 +77,10 @@ describe("SignInForm SSO discovery", () => { const resolveSSO = vi.fn(async () => null); mount(resolveSSO); - submitEmail("user@nosso.com"); + submitEmail("user@nosso.example.com"); await screen.findByLabelText("Password"); - expect(resolveSSO).toHaveBeenCalledWith("user@nosso.com"); + expect(resolveSSO).toHaveBeenCalledWith("user@nosso.example.com"); }); it("fails open to password when the resolver throws", async () => { @@ -89,7 +89,7 @@ describe("SignInForm SSO discovery", () => { }); mount(resolveSSO); - submitEmail("user@flaky.com"); + submitEmail("user@flaky.example.com"); // Discovery failure must never lock a user out of password login. await screen.findByLabelText("Password"); @@ -97,7 +97,7 @@ describe("SignInForm SSO discovery", () => { it("keeps the plain email→password flow when no resolver is supplied", async () => { mount(undefined); - submitEmail("user@plain.com"); + submitEmail("user@plain.example.com"); await waitFor(() => expect(screen.getByLabelText("Password")).toBeTruthy(), From cc4a76a1e63d5837ebe10669535cbaa0eeab3d3b Mon Sep 17 00:00:00 2001 From: Clement James Date: Mon, 31 Aug 2026 18:50:00 +0100 Subject: [PATCH 08/11] feat(sso): implement query-free OIDC redirect URI and state management for connection IDs --- plugins/sso/oidc_redirect_test.go | 65 +++++++++++++++++++++++++++++++ plugins/sso/plugin.go | 48 ++++++++++++++++++----- 2 files changed, 104 insertions(+), 9 deletions(-) create mode 100644 plugins/sso/oidc_redirect_test.go 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 b19b67c1..512534a0 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,7 +656,7 @@ 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 + 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{ @@ -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 != "" { From bc81f75971e0a90f51315d77ea899ddc3a751947 Mon Sep 17 00:00:00 2001 From: Clement James Date: Mon, 31 Aug 2026 19:17:10 +0100 Subject: [PATCH 09/11] fix(plugin): standardize formatting in startLogin function for clarity --- plugins/sso/plugin.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/sso/plugin.go b/plugins/sso/plugin.go index 512534a0..8a8c6833 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -657,7 +657,7 @@ func (p *Plugin) startLogin(ctx context.Context, appID id.AppID, provider Provid } 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 + _ = p.ceremonies.Set(ctx, "sso:state:"+state, stateData, 10*time.Minute) //nolint:errcheck // best-effort cache return &LoginResponse{ LoginURL: loginURL, From 8e0977d72d9a1d778e00cc260fcded47a4269569 Mon Sep 17 00:00:00 2001 From: Clement James Date: Tue, 1 Sep 2026 17:06:23 +0100 Subject: [PATCH 10/11] feat(connection): add optional display name for SSO connections across multiple languages --- .../lib/src/generated/api_types.dart | 4 +++ plugins/sso/migrations.go | 34 +++++++++++++++++++ plugins/sso/plugin.go | 25 ++++++++------ plugins/sso/provider.go | 28 ++++++++------- plugins/sso/store_models.go | 3 ++ plugins/sso/store_mongo.go | 4 +++ sdk/dart/lib/src/types.dart | 4 +++ sdk/go/types.go | 1 + sdk/typescript/src/types.ts | 1 + sdkgen/spec.json | 3 ++ ui/packages/core/src/generated/api-types.ts | 1 + 11 files changed, 85 insertions(+), 23 deletions(-) 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 e5c7aacd..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,6 +2222,7 @@ class Connection { final Map? attributeMappings; final String? clientId; final String createdAt; + final String? displayName; final String domain; final bool enforced; final String? entityId; @@ -2246,6 +2247,7 @@ class Connection { this.attributeMappings, this.clientId, required this.createdAt, + this.displayName, required this.domain, required this.enforced, this.entityId, @@ -2272,6 +2274,7 @@ 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?, @@ -2299,6 +2302,7 @@ 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, diff --git a/plugins/sso/migrations.go b/plugins/sso/migrations.go index fde73872..a7df01d3 100644 --- a/plugins/sso/migrations.go +++ b/plugins/sso/migrations.go @@ -284,4 +284,38 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_authsome_sso_connections_domain }, }, ) + + // ────────────────────────────────────────────────── + // 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/plugin.go b/plugins/sso/plugin.go index 8a8c6833..63432352 100644 --- a/plugins/sso/plugin.go +++ b/plugins/sso/plugin.go @@ -1550,6 +1550,8 @@ type CreateConnectionInput struct { // 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 @@ -1582,17 +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, - Enforced: in.Enforced, - 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 b1b86639..d59a12ad 100644 --- a/plugins/sso/provider.go +++ b/plugins/sso/provider.go @@ -51,18 +51,22 @@ 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. diff --git a/plugins/sso/store_models.go b/plugins/sso/store_models.go index 1c8435ec..13ee29aa 100644 --- a/plugins/sso/store_models.go +++ b/plugins/sso/store_models.go @@ -23,6 +23,7 @@ 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"` @@ -66,6 +67,7 @@ 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, @@ -111,6 +113,7 @@ 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, diff --git a/plugins/sso/store_mongo.go b/plugins/sso/store_mongo.go index 2cdfdca9..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"` @@ -86,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, @@ -129,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, @@ -267,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, diff --git a/sdk/dart/lib/src/types.dart b/sdk/dart/lib/src/types.dart index e5c7aacd..a7059f17 100644 --- a/sdk/dart/lib/src/types.dart +++ b/sdk/dart/lib/src/types.dart @@ -2222,6 +2222,7 @@ class Connection { final Map? attributeMappings; final String? clientId; final String createdAt; + final String? displayName; final String domain; final bool enforced; final String? entityId; @@ -2246,6 +2247,7 @@ class Connection { this.attributeMappings, this.clientId, required this.createdAt, + this.displayName, required this.domain, required this.enforced, this.entityId, @@ -2272,6 +2274,7 @@ 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?, @@ -2299,6 +2302,7 @@ 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, diff --git a/sdk/go/types.go b/sdk/go/types.go index f740ffb4..48c7eb63 100644 --- a/sdk/go/types.go +++ b/sdk/go/types.go @@ -516,6 +516,7 @@ 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"` diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index 862c14e0..565488e7 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -450,6 +450,7 @@ export interface Connection { attribute_mappings?: Record; client_id?: string; created_at: string; + display_name?: string; domain: string; enforced: boolean; entity_id?: string; diff --git a/sdkgen/spec.json b/sdkgen/spec.json index 64a8aaab..2bee46b3 100644 --- a/sdkgen/spec.json +++ b/sdkgen/spec.json @@ -1597,6 +1597,9 @@ "format": "date-time", "type": "string" }, + "display_name": { + "type": "string" + }, "domain": { "type": "string" }, diff --git a/ui/packages/core/src/generated/api-types.ts b/ui/packages/core/src/generated/api-types.ts index 144dd287..8aceed2a 100644 --- a/ui/packages/core/src/generated/api-types.ts +++ b/ui/packages/core/src/generated/api-types.ts @@ -450,6 +450,7 @@ export interface Connection { attribute_mappings?: Record; client_id?: string; created_at: string; + display_name?: string; domain: string; enforced: boolean; entity_id?: string; From ad91ad4530a10cf20080802c45c0aaed118dfcac Mon Sep 17 00:00:00 2001 From: Clement James Date: Tue, 1 Sep 2026 17:29:34 +0100 Subject: [PATCH 11/11] fix(dependencies): update forge and go-utils to latest versions --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) 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=