Skip to content

Commit

Permalink
feat(api,enterprise): add support for SAML authentication
Browse files Browse the repository at this point in the history
SAML (Security Assertion Markup Language) is an XML-based SSO protocol
that enables secure authentication between organizations. ShellHub can
now acts as a SAML Service Provider (SP), allowing users to authenticate
through their organization's Identity Provider (IdP).

- Implement SAML SP functionality for external authentication
- Support single IdP configuration at instance level
- Extend `/info` endpoint to expose SAML authentication status
- Add a `external_id` attributes to user's collection. Which defines the
  user's ID in the IdP.

Only one IdP can be configured globally per ShellHub instance; this
feature is enterprise-only.
  • Loading branch information
heiytor committed Jan 9, 2025
1 parent 66e55a7 commit 7672097
Show file tree
Hide file tree
Showing 11 changed files with 458 additions and 2 deletions.
1 change: 1 addition & 0 deletions api/pkg/responses/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ type SystemInfo struct {

type SystemAuthenticationInfo struct {
Local bool `json:"local"`
SAML bool `json:"saml"`
}

type SystemEndpointsInfo struct {
Expand Down
1 change: 1 addition & 0 deletions api/services/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func (s *service) GetSystemInfo(ctx context.Context, req *requests.GetSystemInfo
},
Authentication: &responses.SystemAuthenticationInfo{
Local: system.Authentication.Local.Enabled,
SAML: system.Authentication.SAML.Enabled,
},
}

Expand Down
2 changes: 2 additions & 0 deletions api/store/mongo/migrations/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ func GenerateMigrations() []migrate.Migration {
migration85,
migration86,
migration87,
migration88,
migration89,
}
}

Expand Down
73 changes: 73 additions & 0 deletions api/store/mongo/migrations/migration_88.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package migrations

import (
"context"

"github.com/sirupsen/logrus"
migrate "github.com/xakep666/mongo-migrate"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

var migration88 = migrate.Migration{
Version: 88,
Description: "Adding an 'authentication.saml' attributes to system collection",
Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error {
logrus.WithFields(logrus.Fields{
"component": "migration",
"version": 88,
"action": "Up",
}).Info("Applying migration")

filter := bson.M{
"authentication.saml": bson.M{"$exists": false},
}

update := bson.M{
"$set": bson.M{
"authentication.saml": bson.M{
"enabled": false,
"idp": bson.M{
"entity_id": "",
"signon_url": "",
"certificates": []string{},
},
"sp": bson.M{
"sign_auth_requests": false,
"certificate": "",
"private_key": "",
},
},
},
}

_, err := db.
Collection("system").
UpdateMany(ctx, filter, update)

return err
}),
Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error {
logrus.WithFields(logrus.Fields{
"component": "migration",
"version": 88,
"action": "Down",
}).Info("Reverting migration")

filter := bson.M{
"authentication.saml": bson.M{"$exists": true},
}

update := bson.M{
"$unset": bson.M{
"authentication.saml": "",
},
}

_, err := db.
Collection("system").
UpdateMany(ctx, filter, update)

return err
}),
}
136 changes: 136 additions & 0 deletions api/store/mongo/migrations/migration_88_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package migrations

import (
"context"
"testing"

"github.com/shellhub-io/shellhub/pkg/envs"
envmock "github.com/shellhub-io/shellhub/pkg/envs/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
migrate "github.com/xakep666/mongo-migrate"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
)

func TestMigration88Up(t *testing.T) {
ctx := context.Background()

mock := &envmock.Backend{}
envs.DefaultBackend = mock

tests := []struct {
description string
setup func() error
}{
{
description: "Apply up on migration 88",
setup: func() error {
_, err := c.
Database("test").
Collection("system").
InsertOne(ctx, map[string]interface{}{
"authentication": map[string]interface{}{
"local": map[string]interface{}{
"enabled": true,
},
},
})

return err
},
},
}

for _, tc := range tests {
t.Run(tc.description, func(tt *testing.T) {
tt.Cleanup(func() {
assert.NoError(tt, srv.Reset())
})

assert.NoError(tt, tc.setup())

migrates := migrate.NewMigrate(c.Database("test"), GenerateMigrations()[87])
require.NoError(tt, migrates.Up(context.Background(), migrate.AllAvailable))

query := c.
Database("test").
Collection("system").
FindOne(context.TODO(), bson.M{})

system := make(map[string]interface{})
require.NoError(tt, query.Decode(&system))

saml, ok := system["authentication"].(map[string]interface{})["saml"].(map[string]interface{})
require.Equal(tt, true, ok)

enabled, ok := saml["enabled"]
require.Equal(tt, true, ok)
require.Equal(tt, false, enabled)

idp, ok := saml["idp"].(map[string]interface{})
require.Equal(tt, true, ok)
require.Equal(tt, map[string]interface{}{"entity_id": "", "signon_url": "", "certificates": primitive.A{}}, idp)

sp, ok := saml["sp"].(map[string]interface{})
require.Equal(tt, true, ok)
require.Equal(tt, map[string]interface{}{"sign_auth_requests": false, "certificate": "", "private_key": ""}, sp)
})
}
}

func TestMigration88Down(t *testing.T) {
ctx := context.Background()

mock := &envmock.Backend{}
envs.DefaultBackend = mock

mock.On("Get", "SHELLHUB_CLOUD").Return("false")
mock.On("Get", "SHELLHUB_ENTERPRISE").Return("false")

tests := []struct {
description string
setup func() error
}{
{
description: "Apply up on migration 88",
setup: func() error {
_, err := c.
Database("test").
Collection("system").
InsertOne(ctx, map[string]interface{}{
"authentication": map[string]interface{}{
"local": true,
},
})

return err
},
},
}

for _, tc := range tests {
t.Run(tc.description, func(tt *testing.T) {
tt.Cleanup(func() {
assert.NoError(tt, srv.Reset())
})

assert.NoError(tt, tc.setup())

migrates := migrate.NewMigrate(c.Database("test"), GenerateMigrations()[87])
require.NoError(tt, migrates.Up(context.Background(), migrate.AllAvailable))
require.NoError(tt, migrates.Down(context.Background(), migrate.AllAvailable))

query := c.
Database("test").
Collection("system").
FindOne(context.TODO(), bson.M{})

system := make(map[string]interface{})
require.NoError(tt, query.Decode(&system))

_, ok := system["authentication"].(map[string]interface{})["saml"]
require.Equal(tt, false, ok)
})
}
}
61 changes: 61 additions & 0 deletions api/store/mongo/migrations/migration_89.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package migrations

import (
"context"

"github.com/sirupsen/logrus"
migrate "github.com/xakep666/mongo-migrate"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
)

var migration89 = migrate.Migration{
Version: 89,
Description: "Adding an external ID attribute to users collection",
Up: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error {
logrus.WithFields(logrus.Fields{
"component": "migration",
"version": 89,
"action": "Up",
}).Info("Applying migration")

filter := bson.M{
"external_id": bson.M{"$exists": false},
}

update := bson.M{
"$set": bson.M{
"external_id": "",
},
}

_, err := db.
Collection("users").
UpdateMany(ctx, filter, update)

return err
}),
Down: migrate.MigrationFunc(func(ctx context.Context, db *mongo.Database) error {
logrus.WithFields(logrus.Fields{
"component": "migration",
"version": 89,
"action": "Down",
}).Info("Reverting migration")

filter := bson.M{
"external_id": bson.M{"$exists": true},
}

update := bson.M{
"$unset": bson.M{
"external_id": "",
},
}

_, err := db.
Collection("users").
UpdateMany(ctx, filter, update)

return err
}),
}
Loading

0 comments on commit 7672097

Please sign in to comment.