Skip to content
Open
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
53 changes: 53 additions & 0 deletions auth-providers-common/pkg/env/env.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package env

import (
"encoding/json"
"fmt"
"os"
"reflect"
Expand Down Expand Up @@ -64,3 +65,55 @@ func LoadEnvForStruct[T any](s *T) error {

return nil
}

type ValidationError struct {
Err error `json:"error"`
}

func (e ValidationError) Error() string {
return e.Err.Error()
}

type FieldValidationError struct {
EnvVar string `json:"envVar"`
Message string `json:"message"`
Value string `json:"value"`
Sensitive bool `json:"sensitive"`
Comment on lines +80 to +81
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure passing Value and Sensitive fields back is necessary, we don't use them in the front end ATM.

}

func (e FieldValidationError) Error() string {
if e.Sensitive {
return fmt.Sprintf("invalid environment variable %s: %s (value: %s)", e.EnvVar, e.Message, "REDACTED")
}
return fmt.Sprintf("invalid environment variable %s: %s (value: %s)", e.EnvVar, e.Message, e.Value)
}

func (e FieldValidationError) MarshalJSON() ([]byte, error) {
// Create a copy of the struct for JSON marshaling
value := e.Value
if e.Sensitive {
value = "REDACTED"
}

return json.Marshal(struct {
EnvVar string `json:"envVar"`
Message string `json:"message"`
Value string `json:"value"`
Sensitive bool `json:"sensitive"`
}{
EnvVar: e.EnvVar,
Message: e.Message,
Value: value,
Sensitive: e.Sensitive,
})
}

type FieldValidationErrors []FieldValidationError

func (e FieldValidationErrors) Error() string {
msgs := make([]string, len(e))
for i, err := range e {
msgs[i] = err.Error()
}
return strings.Join(msgs, "\n")
}
5 changes: 4 additions & 1 deletion github-auth-provider/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ replace (
require (
github.com/oauth2-proxy/oauth2-proxy/v7 v7.8.1
github.com/obot-platform/tools/auth-providers-common v0.0.0-20241008222508-3c6174b443e7
github.com/sahilm/fuzzy v0.1.1
github.com/stretchr/testify v1.10.0
)

require (
Expand All @@ -26,6 +28,7 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-oidc/v3 v3.13.0 // indirect
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
Expand Down Expand Up @@ -55,14 +58,14 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.21.1 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/redis/go-redis/v9 v9.7.3 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
Expand Down
58 changes: 20 additions & 38 deletions github-auth-provider/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,29 @@ import (
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/validation"
"github.com/obot-platform/tools/auth-providers-common/pkg/env"
"github.com/obot-platform/tools/auth-providers-common/pkg/state"
"github.com/obot-platform/tools/github-auth-provider/pkg/config"
"github.com/obot-platform/tools/github-auth-provider/pkg/profile"
"github.com/sahilm/fuzzy"
)

type Options struct {
ClientID string `env:"OBOT_GITHUB_AUTH_PROVIDER_CLIENT_ID"`
ClientSecret string `env:"OBOT_GITHUB_AUTH_PROVIDER_CLIENT_SECRET"`
ObotServerURL string `env:"OBOT_SERVER_URL"`
PostgresConnectionDSN string `env:"OBOT_AUTH_PROVIDER_POSTGRES_CONNECTION_DSN" optional:"true"`
AuthCookieSecret string `usage:"Secret used to encrypt cookie" env:"OBOT_AUTH_PROVIDER_COOKIE_SECRET"`
AuthEmailDomains string `usage:"Email domains allowed for authentication" default:"*" env:"OBOT_AUTH_PROVIDER_EMAIL_DOMAINS"`
AuthTokenRefreshDuration string `usage:"Duration to refresh auth token after" optional:"true" default:"1h" env:"OBOT_AUTH_PROVIDER_TOKEN_REFRESH_DURATION"`
GitHubOrg *string `usage:"restrict logins to members of this GitHub organization" optional:"true" env:"OBOT_GITHUB_AUTH_PROVIDER_ORG"`
GitHubAllowUsers *string `usage:"users allowed to log in, even if they do not belong to the specified org and team or collaborators" optional:"true" env:"OBOT_GITHUB_AUTH_PROVIDER_ALLOW_USERS"`
}

func main() {
var opts Options
if err := env.LoadEnvForStruct(&opts); err != nil {
fmt.Printf("ERROR: github-auth-provider: failed to load options: %v\n", err)
os.Exit(1)
}
opts, err := config.LoadEnv()
if len(os.Args) > 1 && os.Args[1] == "validate" {
if err != nil {
var validationErr env.ValidationError
if errors.As(err, &validationErr) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, when validation fails, you should do this and return os.exit(0) https://github.com/obot-platform/enterprise-tools/pull/64/files#diff-01e49a110328ad7a46eea37ea6af13367c4de003c836988ef175f66f9750839aR27

This is so that the backend will not interpret it as an error and just read from output(json object) to decide if it is an error.

if err := json.NewEncoder(os.Stdout).Encode(validationErr); err != nil {
fmt.Printf("ERROR: github-auth-provider: failed to encode validation errors: %v\n", err)
os.Exit(1)
}
}
}

refreshDuration, err := time.ParseDuration(opts.AuthTokenRefreshDuration)
if err != nil {
fmt.Printf("ERROR: github-auth-provider: failed to parse token refresh duration: %v\n", err)
os.Exit(1)
return
}

if refreshDuration < 0 {
fmt.Printf("ERROR: github-auth-provider: token refresh duration must be greater than 0\n")
if err != nil {
fmt.Printf("ERROR: github-auth-provider: failed to load and validate options: %v\n", err)
os.Exit(1)
}

Expand All @@ -64,12 +56,8 @@ func main() {
legacyOpts.LegacyProvider.ClientSecret = opts.ClientSecret

// GitHub-specific options
if opts.GitHubOrg != nil {
legacyOpts.LegacyProvider.GitHubOrg = *opts.GitHubOrg
}
if opts.GitHubAllowUsers != nil {
legacyOpts.LegacyProvider.GitHubUsers = strings.Split(*opts.GitHubAllowUsers, ",")
}
legacyOpts.LegacyProvider.GitHubOrg = opts.GitHubOrg
legacyOpts.LegacyProvider.GitHubUsers = opts.GitHubAllowUsers

oauthProxyOpts, err := legacyOpts.ToOptions()
if err != nil {
Expand All @@ -84,20 +72,14 @@ func main() {
oauthProxyOpts.Session.Postgres.ConnectionDSN = opts.PostgresConnectionDSN
oauthProxyOpts.Session.Postgres.TableNamePrefix = "github_"
}
oauthProxyOpts.Cookie.Refresh = refreshDuration
oauthProxyOpts.Cookie.Refresh = opts.AuthTokenRefreshDuration
oauthProxyOpts.Cookie.Name = "obot_access_token"
oauthProxyOpts.Cookie.Secret = string(cookieSecret)
oauthProxyOpts.Cookie.Secure = strings.HasPrefix(opts.ObotServerURL, "https://")
oauthProxyOpts.Cookie.CSRFExpire = 30 * time.Minute
oauthProxyOpts.Templates.Path = os.Getenv("GPTSCRIPT_TOOL_DIR") + "/../auth-providers-common/templates"
oauthProxyOpts.RawRedirectURL = opts.ObotServerURL + "/"
if opts.AuthEmailDomains != "" {
emailDomains := strings.Split(opts.AuthEmailDomains, ",")
for i := range emailDomains {
emailDomains[i] = strings.TrimSpace(emailDomains[i])
}
oauthProxyOpts.EmailDomains = emailDomains
}
oauthProxyOpts.EmailDomains = opts.AuthEmailDomains
oauthProxyOpts.Logging.RequestEnabled = false
oauthProxyOpts.Logging.AuthEnabled = false
oauthProxyOpts.Logging.StandardEnabled = false
Expand Down Expand Up @@ -131,7 +113,7 @@ func main() {
}
json.NewEncoder(w).Encode(userInfo)
})
mux.HandleFunc("/obot-list-auth-groups", listGroups(*opts.GitHubOrg))
mux.HandleFunc("/obot-list-auth-groups", listGroups(opts.GitHubOrg))
mux.HandleFunc("/obot-list-user-auth-groups", listUserGroups)
mux.HandleFunc("/", oauthProxy.ServeHTTP)

Expand Down
176 changes: 176 additions & 0 deletions github-auth-provider/pkg/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package config

import (
"fmt"
"regexp"
"strings"
"time"

"github.com/obot-platform/tools/auth-providers-common/pkg/env"
)

// options is the private struct that holds raw environment variable values
type options struct {
ClientID string `env:"OBOT_GITHUB_AUTH_PROVIDER_CLIENT_ID"`
ClientSecret string `env:"OBOT_GITHUB_AUTH_PROVIDER_CLIENT_SECRET"`
ObotServerURL string `env:"OBOT_SERVER_URL"`
PostgresConnectionDSN string `env:"OBOT_AUTH_PROVIDER_POSTGRES_CONNECTION_DSN" optional:"true"`
AuthCookieSecret string `usage:"Secret used to encrypt cookie" env:"OBOT_AUTH_PROVIDER_COOKIE_SECRET"`
AuthEmailDomains string `usage:"Email domains allowed for authentication" default:"*" env:"OBOT_AUTH_PROVIDER_EMAIL_DOMAINS"`
AuthTokenRefreshDuration string `usage:"Duration to refresh auth token after" optional:"true" default:"1h" env:"OBOT_AUTH_PROVIDER_TOKEN_REFRESH_DURATION"`
GitHubOrg *string `usage:"restrict logins to members of this GitHub organization" optional:"true" env:"OBOT_GITHUB_AUTH_PROVIDER_ORG"`
GitHubAllowUsers *string `usage:"users allowed to log in, even if they do not belong to the specified org and team or collaborators" optional:"true" env:"OBOT_GITHUB_AUTH_PROVIDER_ALLOW_USERS"`
}

// Options is the public struct that holds validated and processed configuration values
type Options struct {
options
AuthEmailDomains []string
AuthTokenRefreshDuration time.Duration
GitHubOrg string
GitHubAllowUsers []string
}

var (
gitHubLoginRegex = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
emailDomainRegex = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`)
)

// LoadEnv loads environment variables, validates them, and returns the completed configuration
func LoadEnv() (*Options, error) {
completed, err := loadEnv()
if err != nil {
return nil, env.ValidationError{Err: err}
}

return completed, nil
}

func loadEnv() (*Options, error) {
var opts options
if err := env.LoadEnvForStruct(&opts); err != nil {
return nil, fmt.Errorf("failed to load environment variables: %w", err)
}

return complete(opts)
}

func complete(o options) (*Options, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func complete(o options) (*Options, error) {
func completeAndValidate(o options) (*Options, error) {

var (
validationErrors env.FieldValidationErrors
completedOptions = Options{
options: o,
}
)

if o.AuthEmailDomains != "" {
// TODO(njhale): Add validation for email domains
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Will remove this TODO; it's no longer valid.

var (
emailDomains = strings.Split(o.AuthEmailDomains, ",")
errorMsg string
)
for i := range emailDomains {
switch domain := strings.TrimSpace(emailDomains[i]); {
case domain == "":
errorMsg = "cannot contain empty email domains"
case domain == "*" && len(emailDomains) > 1:
errorMsg = "cannot specify multiple email domains when * is provided"
case domain != "*" && !emailDomainRegex.MatchString(domain):
errorMsg = fmt.Sprintf("'%s' is not a valid email domain", domain)
default:
emailDomains[i] = domain
continue
}

// Stop after the first email domain validation error
break
}
Comment on lines +66 to +87
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pretty sure this configuration is common to all auth providers. It has me wondering if part of this validation /completion should live in env.LoadEnv; e.g. you build a validator/completor to pass into env.LoadEnv.


if errorMsg != "" {
validationErrors = append(validationErrors, env.FieldValidationError{
EnvVar: "OBOT_AUTH_PROVIDER_EMAIL_DOMAINS",
Message: errorMsg,
Value: o.AuthEmailDomains,
Sensitive: false,
})
} else {
completedOptions.AuthEmailDomains = emailDomains
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change

}

refreshDuration, err := time.ParseDuration(o.AuthTokenRefreshDuration)
if err != nil || refreshDuration <= 0 {
validationErrors = append(validationErrors, env.FieldValidationError{
EnvVar: "OBOT_AUTH_PROVIDER_TOKEN_REFRESH_DURATION",
Message: "must be a valid duration string and greater than 0",
Value: o.AuthTokenRefreshDuration,
Sensitive: false,
})
} else {
completedOptions.AuthTokenRefreshDuration = refreshDuration
}
Comment on lines +102 to +112
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment about generic/composable validation here.


if o.GitHubOrg != nil && *o.GitHubOrg != "" {
var (
org = *o.GitHubOrg
errorMsg string
)
if len(org) > 39 {
errorMsg = fmt.Sprintf("must be 39 characters or less, got %d characters", len(org))
}
if !gitHubLoginRegex.MatchString(org) {
errorMsg = "is not a valid GitHub organization login (must contain only alphanumeric characters and single hyphens, cannot start or end with hyphen)"
}
if errorMsg != "" {
validationErrors = append(validationErrors, env.FieldValidationError{
EnvVar: "OBOT_GITHUB_AUTH_PROVIDER_ORG",
Message: errorMsg,
Value: org,
Sensitive: false,
})
} else {
completedOptions.GitHubOrg = org
}
}

if o.GitHubAllowUsers != nil && *o.GitHubAllowUsers != "" {
var (
users = strings.Split(*o.GitHubAllowUsers, ",")
errorMsg string
)
for i := range users {
switch user := strings.TrimSpace(users[i]); {
case user == "":
errorMsg = "cannot contain empty users"
case len(user) > 39:
errorMsg = fmt.Sprintf("user '%s' must be 39 characters or less, got %d characters", user, len(user))
case !gitHubLoginRegex.MatchString(user):
errorMsg = fmt.Sprintf("user '%s' is not a valid GitHub username (must contain only alphanumeric characters and single hyphens, cannot start or end with hyphen)", user)
default:
users[i] = user
continue
}

// Stop after the first user validation error
break
}

if errorMsg != "" {
validationErrors = append(validationErrors, env.FieldValidationError{
EnvVar: "OBOT_GITHUB_AUTH_PROVIDER_ALLOW_USERS",
Message: errorMsg,
Value: *o.GitHubAllowUsers,
Sensitive: false,
})
} else {
completedOptions.GitHubAllowUsers = users
}
}

if len(validationErrors) > 0 {
return nil, validationErrors
}

return &completedOptions, nil
}
Loading
Loading