Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add device ownership #249

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions cmd/authd-oidc/daemon/daemon.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ func (a *App) serve(config daemonConfig) error {
}
}

brokerConfigDir := broker.GetDropInDir(config.Paths.BrokerConf)
if err := ensureDirWithPerms(brokerConfigDir, 0700, os.Geteuid()); err != nil {
close(a.ready)
return fmt.Errorf("error initializing broker configuration directory %q: %v", brokerConfigDir, err)
}

b, err := broker.New(broker.Config{
ConfigFile: config.Paths.BrokerConf,
DataDir: config.Paths.DataDir,
Expand Down
28 changes: 28 additions & 0 deletions conf/broker.conf
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,31 @@ client_id = <CLIENT_ID>
## If configured, only users with a suffix in this list are allowed to
## log in via SSH. The suffixes must be separated by comma.
#ssh_allowed_suffixes = @example.com,@anotherexample.com

## 'allowed_users' specifies the users who are permitted to log in after
## successfully authenticating with the Identity Provider.
## Values are separated by commas. Supported values:
## - 'OWNER': Grants access to the user specified in the 'owner' option
## (see below). This is the default.
## - 'ALL': Grants access to all users who successfully authenticate
## with the Identity Provider.
## - <username>: Grants access to specific additional users
## (e.g. user1@example.com).
## Example: allowed_users = OWNER,user1@example.com,admin@example.com
#allowed_users = OWNER

## 'owner' specifies the user assigned the owner role. This user is
## permitted to log in if 'OWNER' is included in the 'allowed_users'
## option.
##
## If this option is left unset, the first user to successfully log in
## via this broker will automatically be assigned the owner role. A
## drop-in configuration file will be created in broker.conf.d/ to set
## the 'owner' option.
##
## To disable automatic assignment, you can either:
## 1. Explicitly set this option to an empty value (e.g. owner = "")
## 2. Remove 'OWNER' from the 'allowed_users' option
##
## Example: owner = user2@example.com
#owner =
43 changes: 32 additions & 11 deletions internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,6 @@ type Config struct {
userConfig
}

type userConfig struct {
clientID string
clientSecret string
issuerURL string
homeBaseDir string
allowedSSHSuffixes []string
}

// Broker is the real implementation of the broker to track sessions and process oidc calls.
type Broker struct {
cfg Config
Expand Down Expand Up @@ -106,15 +98,17 @@ type Option func(*option)
func New(cfg Config, args ...Option) (b *Broker, err error) {
defer decorate.OnError(&err, "could not create broker")

p := providers.CurrentProvider()

if cfg.ConfigFile != "" {
cfg.userConfig, err = parseConfigFile(cfg.ConfigFile)
cfg.userConfig, err = parseConfigFile(cfg.ConfigFile, p)
if err != nil {
return nil, fmt.Errorf("could not parse config: %v", err)
}
}

opts := option{
provider: providers.CurrentProvider(),
provider: p,
}
for _, arg := range args {
arg(&opts)
Expand Down Expand Up @@ -364,7 +358,6 @@ func (b *Broker) generateUILayout(session *session, authModeID string) (map[stri
// Some providers support both of these authentication methods, some implement only one and
// some implement neither.
// This was tested with the following providers:
// - Google: supports client_secret_post
// - Ory Hydra: supports client_secret_post
// TODO @shipperizer: client_authentication methods should be configurable
if secret := session.oauth2Config.ClientSecret; secret != "" {
Expand Down Expand Up @@ -620,6 +613,17 @@ func (b *Broker) handleIsAuthenticated(ctx context.Context, session *session, au
}
}

if err := b.cfg.registerOwner(b.cfg.ConfigFile, authInfo.UserInfo.Name); err != nil {
// The user is not allowed if we fail to create the owner-autoregistration file.
// Otherwise the owner might change if the broker is restarted.
slog.Error(fmt.Sprintf("Failed to assign the owner role: %v", err))
return AuthDenied, errorMessage{Message: "could not register the owner"}
}

if !b.userNameIsAllowed(authInfo.UserInfo.Name) {
return AuthDenied, errorMessage{Message: "permission denied"}
}

if session.isOffline {
return AuthGranted, userInfoMessage{UserInfo: authInfo.UserInfo}
}
Expand All @@ -636,6 +640,23 @@ func (b *Broker) handleIsAuthenticated(ctx context.Context, session *session, au
return AuthGranted, userInfoMessage{UserInfo: authInfo.UserInfo}
}

// userNameIsAllowed checks whether the user's username is allowed to access the machine.
func (b *Broker) userNameIsAllowed(userName string) bool {
normalizedUsername := b.provider.NormalizeUsername(userName)
// The user is allowed to log in if:
// - ALL users are allowed
// - the user's name is in the list of allowed_users
// - the user is the owner of the machine and OWNER is in the allowed_users list
if b.cfg.userConfig.allUsersAllowed {
return true
}
if _, ok := b.cfg.userConfig.allowedUsers[normalizedUsername]; ok {
return true
}

return b.cfg.isOwnerAllowed(normalizedUsername)
}

func (b *Broker) startAuthenticate(sessionID string) (context.Context, error) {
session, err := b.getSession(sessionID)
if err != nil {
Expand Down
169 changes: 159 additions & 10 deletions internal/broker/broker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -536,8 +538,10 @@ func TestIsAuthenticated(t *testing.T) {
require.NoError(t, err, "Setup: Mkdir should not have returned an error")

cfg := &brokerForTestConfig{
Config: broker.Config{DataDir: dataDir},
getUserInfoFails: tc.getUserInfoFails,
Config: broker.Config{DataDir: dataDir},
getUserInfoFails: tc.getUserInfoFails,
ownerAllowed: true,
firstUserBecomesOwner: true,
}
if tc.customHandlers == nil {
// Use the default provider URL if no custom handlers are provided.
Expand Down Expand Up @@ -707,14 +711,36 @@ func TestIsAuthenticated(t *testing.T) {
// Due to ordering restrictions, this test can not be run in parallel, otherwise the routines would not be ordered as expected.
func TestConcurrentIsAuthenticated(t *testing.T) {
tests := map[string]struct {
firstCallDelay int
secondCallDelay int
firstCallDelay int
secondCallDelay int
ownerAllowed bool
allUsersAllowed bool
firstUserBecomesOwner bool

timeBetween time.Duration
}{
"First_auth_starts_and_finishes_before_second": {secondCallDelay: 1, timeBetween: 2 * time.Second},
"First_auth_starts_first_but_second_finishes_first": {firstCallDelay: 3, timeBetween: time.Second},
"First_auth_starts_first_then_second_starts_and_first_finishes": {firstCallDelay: 2, secondCallDelay: 3, timeBetween: time.Second},
"First_auth_starts_and_finishes_before_second": {
secondCallDelay: 1,
timeBetween: 2 * time.Second,
allUsersAllowed: true,
},
"First_auth_starts_first_but_second_finishes_first": {
firstCallDelay: 3,
timeBetween: time.Second,
allUsersAllowed: true,
},
"First_auth_starts_first_then_second_starts_and_first_finishes": {
firstCallDelay: 2,
secondCallDelay: 3,
timeBetween: time.Second,
allUsersAllowed: true,
},
"First_auth_starts_first_but_second_finishes_first_and_is_registered_as_the_owner": {
firstCallDelay: 3,
timeBetween: time.Second,
ownerAllowed: true,
firstUserBecomesOwner: true,
},
}

for name, tc := range tests {
Expand All @@ -728,9 +754,12 @@ func TestConcurrentIsAuthenticated(t *testing.T) {
username2 := "user2@example.com"

b := newBrokerForTests(t, &brokerForTestConfig{
Config: broker.Config{DataDir: dataDir},
firstCallDelay: tc.firstCallDelay,
secondCallDelay: tc.secondCallDelay,
Config: broker.Config{DataDir: dataDir},
allUsersAllowed: tc.allUsersAllowed,
ownerAllowed: tc.ownerAllowed,
firstUserBecomesOwner: tc.firstUserBecomesOwner,
firstCallDelay: tc.firstCallDelay,
secondCallDelay: tc.secondCallDelay,
tokenHandlerOptions: &testutils.TokenHandlerOptions{
IDTokenClaims: []map[string]interface{}{
{"sub": "user1", "name": "user1", "email": username1},
Expand Down Expand Up @@ -827,6 +856,126 @@ func TestConcurrentIsAuthenticated(t *testing.T) {
}
}

func TestIsAuthenticatedAllowedUsersConfig(t *testing.T) {
t.Parallel()

u1 := "u1"
u2 := "u2"
u3 := "U3"
allUsers := []string{u1, u2, u3}

idTokenClaims := []map[string]interface{}{}
for _, uname := range allUsers {
idTokenClaims = append(idTokenClaims, map[string]interface{}{"sub": "user", "name": "user", "email": uname})
}

tests := map[string]struct {
allowedUsers map[string]struct{}
owner string
ownerAllowed bool
allUsersAllowed bool
firstUserBecomesOwner bool

wantAllowedUsers []string
wantUnallowedUsers []string
}{
"No_users_allowed": {
wantUnallowedUsers: allUsers,
},
"No_users_allowed_when_owner_is_allowed_but_not_set": {
ownerAllowed: true,
wantUnallowedUsers: allUsers,
},
"No_users_allowed_when_owner_is_set_but_not_allowed": {
owner: u1,
wantUnallowedUsers: allUsers,
},

"All_users_are_allowed": {
allUsersAllowed: true,
wantAllowedUsers: allUsers,
},
"Only_owner_allowed": {
ownerAllowed: true,
owner: u1,
wantAllowedUsers: []string{u1},
wantUnallowedUsers: []string{u2, u3},
},
"Only_first_user_allowed": {
ownerAllowed: true,
firstUserBecomesOwner: true,
wantAllowedUsers: []string{u1},
wantUnallowedUsers: []string{u2, u3},
},
"Specific_users_allowed": {
allowedUsers: map[string]struct{}{u1: {}, u2: {}},
wantAllowedUsers: []string{u1, u2},
wantUnallowedUsers: []string{u3},
},
"Specific_users_and_owner": {
ownerAllowed: true,
allowedUsers: map[string]struct{}{u1: {}},
owner: u2,
wantAllowedUsers: []string{u1, u2},
wantUnallowedUsers: []string{u3},
},
"Usernames_are_normalized": {
ownerAllowed: true,
allowedUsers: map[string]struct{}{u3: {}},
owner: strings.ToLower(u3),
wantAllowedUsers: []string{u3},
wantUnallowedUsers: []string{u1, u2},
},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
outDir := t.TempDir()
dataDir := filepath.Join(outDir, "data")
err := os.Mkdir(dataDir, 0700)
require.NoError(t, err, "Setup: Mkdir should not have returned an error")

b := newBrokerForTests(t, &brokerForTestConfig{
Config: broker.Config{DataDir: dataDir},
allowedUsers: tc.allowedUsers,
owner: tc.owner,
ownerAllowed: tc.ownerAllowed,
allUsersAllowed: tc.allUsersAllowed,
firstUserBecomesOwner: tc.firstUserBecomesOwner,
tokenHandlerOptions: &testutils.TokenHandlerOptions{
IDTokenClaims: idTokenClaims,
},
})

for _, u := range allUsers {
sessionID, key := newSessionForTests(t, b, u, "")
token := tokenOptions{username: u}
generateAndStoreCachedInfo(t, token, b.TokenPathForSession(sessionID))
err = password.HashAndStorePassword("password", b.PasswordFilepathForSession(sessionID))
require.NoError(t, err, "Setup: HashAndStorePassword should not have returned an error")

updateAuthModes(t, b, sessionID, authmodes.Password)

authData := `{"challenge":"` + encryptChallenge(t, "password", key) + `"}`

access, data, err := b.IsAuthenticated(sessionID, authData)
require.True(t, json.Valid([]byte(data)), "IsAuthenticated returned data must be a valid JSON")
require.NoError(t, err)
if slices.Contains(tc.wantAllowedUsers, u) {
require.Equal(t, access, broker.AuthGranted, "authentication failed")
continue
}
if slices.Contains(tc.wantUnallowedUsers, u) {
require.Equal(t, access, broker.AuthDenied, "authentication failed")
continue
}
t.Fatalf("user %s is not in the allowed or unallowed users list", u)
}
})
}
}

func TestFetchUserInfo(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading