Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2222,7 +2222,9 @@ class Connection {
final Map<String, dynamic>? 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;
Expand All @@ -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,
Expand All @@ -2270,7 +2274,9 @@ class Connection {
attributeMappings: json['attribute_mappings'] == null ? null : Map<String, dynamic>.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,
Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
89 changes: 89 additions & 0 deletions plugins/sso/enforce_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
78 changes: 78 additions & 0 deletions plugins/sso/link_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
17 changes: 10 additions & 7 deletions plugins/sso/matching_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ 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(),
AppID: appID,
EnvID: envID,
Email: email,
EmailVerified: verified,
PasswordHash: passwordHash,
}
row := &user.UserEmail{
ID: id.NewUserEmailID(),
Expand All @@ -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)
}

Expand All @@ -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)
Expand All @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions plugins/sso/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
},
},
)
}
Loading
Loading