diff --git a/.github/workflows/reusable-release-helm.yaml b/.github/workflows/reusable-release-helm.yaml index 3c5ac49f7..a761ca721 100644 --- a/.github/workflows/reusable-release-helm.yaml +++ b/.github/workflows/reusable-release-helm.yaml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - chart: [dir, dirctl] + chart: [dir, dirctl, envoy-authz] permissions: packages: write steps: @@ -56,6 +56,11 @@ jobs: - name: Setup Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1 + - name: Helm update nested dependencies + if: matrix.chart == 'dir' + shell: bash + run: helm dependency update install/charts/dir/apiserver + - name: Helm update shell: bash run: helm dependency update install/charts/${{ matrix.chart }} diff --git a/.gitignore b/.gitignore index a98c20b73..361bb9dab 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,9 @@ node_modules # Helm install/charts/dir/charts/ +install/charts/dir/apiserver/charts/ install/charts/dirctl/charts/ +install/charts/envoy-authz/charts/ # MacOS .DS_Store diff --git a/auth/authprovider/constants.go b/auth/authprovider/constants.go new file mode 100644 index 000000000..307a19c25 --- /dev/null +++ b/auth/authprovider/constants.go @@ -0,0 +1,12 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package authprovider + +// Provider name constants. +// These are the canonical identifiers for each supported authentication provider. +// Use these constants instead of string literals to avoid typos and enable refactoring. +const ( + // ProviderGithub is the identifier for GitHub OAuth2 authentication. + ProviderGithub = "github" +) diff --git a/auth/authprovider/github/provider.go b/auth/authprovider/github/provider.go new file mode 100644 index 000000000..bba928417 --- /dev/null +++ b/auth/authprovider/github/provider.go @@ -0,0 +1,278 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + "time" + + "github.com/agntcy/dir/auth/authprovider" + "github.com/google/go-github/v50/github" + "golang.org/x/oauth2" +) + +const ( + // Default configuration values. + defaultCacheTTL = 5 * time.Minute + defaultAPITimeout = 10 * time.Second + + // GitHub API pagination settings. + githubPerPage = 100 +) + +// Provider implements authprovider.Provider for GitHub. +// It validates GitHub OAuth2 tokens, fetches user information, +// and retrieves organization memberships. +type Provider struct { + // cache stores validated user identities and org constructs + cache map[string]*cacheEntry + cacheMu sync.RWMutex + cacheTTL time.Duration + apiTimeout time.Duration +} + +// cacheEntry holds cached provider data. +type cacheEntry struct { + identity *authprovider.UserIdentity + orgConstructs []authprovider.OrgConstruct + expiresAt time.Time +} + +// Config holds GitHub provider configuration. +type Config struct { + // CacheTTL is how long to cache GitHub API responses + // Default: 5 minutes + CacheTTL time.Duration + + // APITimeout is the timeout for GitHub API calls + // Default: 10 seconds + APITimeout time.Duration +} + +// DefaultConfig returns default configuration. +func DefaultConfig() *Config { + return &Config{ + CacheTTL: defaultCacheTTL, + APITimeout: defaultAPITimeout, + } +} + +// NewProvider creates a new GitHub authentication provider. +func NewProvider(cfg *Config) *Provider { + if cfg == nil { + cfg = DefaultConfig() + } + + return &Provider{ + cache: make(map[string]*cacheEntry), + cacheTTL: cfg.CacheTTL, + apiTimeout: cfg.APITimeout, + } +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { + return authprovider.ProviderGithub +} + +// ValidateToken validates a GitHub OAuth2 token and returns user identity. +// This method checks the cache first to minimize GitHub API calls. +func (p *Provider) ValidateToken(ctx context.Context, token string) (*authprovider.UserIdentity, error) { + // Check cache first + p.cacheMu.RLock() + + if entry, ok := p.cache[token]; ok && time.Now().Before(entry.expiresAt) { + p.cacheMu.RUnlock() + + return entry.identity, nil + } + + p.cacheMu.RUnlock() + + // Create GitHub client with official SDK + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + // Set timeout + clientCtx, cancel := context.WithTimeout(ctx, p.apiTimeout) + defer cancel() + + // Call GitHub API to get authenticated user + user, resp, err := client.Users.Get(clientCtx, "") + if err != nil { + // Provide helpful error messages + if resp != nil { + switch resp.StatusCode { + case http.StatusUnauthorized: + return nil, errors.New("invalid or expired GitHub token") + case http.StatusForbidden: + return nil, errors.New("GitHub token lacks required permissions (need: read:user, read:org)") + case http.StatusTooManyRequests: + return nil, errors.New("GitHub API rate limit exceeded") + } + } + + return nil, fmt.Errorf("GitHub API error: %w", err) + } + + // Convert to generic identity + identity := &authprovider.UserIdentity{ + Provider: authprovider.ProviderGithub, + UserID: strconv.FormatInt(user.GetID(), 10), + Username: user.GetLogin(), + Email: user.GetEmail(), + Attributes: map[string]string{ + "github_id": strconv.FormatInt(user.GetID(), 10), + "avatar_url": user.GetAvatarURL(), + "profile_url": user.GetHTMLURL(), + "name": user.GetName(), + }, + } + + // Cache the identity + p.cacheMu.Lock() + + if p.cache[token] == nil { + p.cache[token] = &cacheEntry{} + } + + p.cache[token].identity = identity + p.cache[token].expiresAt = time.Now().Add(p.cacheTTL) + p.cacheMu.Unlock() + + return identity, nil +} + +// GetOrgConstructs fetches the user's GitHub organizations. +// This method also uses caching to minimize GitHub API calls. +func (p *Provider) GetOrgConstructs(ctx context.Context, token string) ([]authprovider.OrgConstruct, error) { + // Check cache first + p.cacheMu.RLock() + + if entry, ok := p.cache[token]; ok && time.Now().Before(entry.expiresAt) && entry.orgConstructs != nil { + p.cacheMu.RUnlock() + + return entry.orgConstructs, nil + } + + p.cacheMu.RUnlock() + + // Create GitHub client + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + + // Set timeout + clientCtx, cancel := context.WithTimeout(ctx, p.apiTimeout) + defer cancel() + + // Fetch all organizations (with pagination) + opts := &github.ListOptions{PerPage: githubPerPage} + + var allOrgs []*github.Organization + + for { + orgs, resp, err := client.Organizations.List(clientCtx, "", opts) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusForbidden { + return nil, errors.New("token lacks read:org permission") + } + + return nil, fmt.Errorf("failed to fetch organizations: %w", err) + } + + allOrgs = append(allOrgs, orgs...) + + if resp.NextPage == 0 { + break + } + + opts.Page = resp.NextPage + } + + // Convert to generic OrgConstruct structure + result := make([]authprovider.OrgConstruct, len(allOrgs)) + for i, org := range allOrgs { + result[i] = authprovider.OrgConstruct{ + ID: strconv.FormatInt(org.GetID(), 10), + Name: org.GetLogin(), // e.g., "agntcy" + Type: "github-org", + } + } + + // Cache the org constructs + p.cacheMu.Lock() + + if p.cache[token] == nil { + p.cache[token] = &cacheEntry{} + } + + p.cache[token].orgConstructs = result + p.cache[token].expiresAt = time.Now().Add(p.cacheTTL) + p.cacheMu.Unlock() + + return result, nil +} + +// IsMemberOfOrgConstruct checks if the user is a member of a specific org construct. +// This is a convenience method for authorization checks. +func (p *Provider) IsMemberOfOrgConstruct(ctx context.Context, token string, orgName string) (bool, error) { + orgConstructs, err := p.GetOrgConstructs(ctx, token) + if err != nil { + return false, err + } + + for _, oc := range orgConstructs { + if oc.Name == orgName { + return true, nil + } + } + + return false, nil +} + +// IsMemberOfAnyOrgConstruct checks if the user is a member of any of the specified org constructs. +// Returns true and the matched org name if found. +func (p *Provider) IsMemberOfAnyOrgConstruct(ctx context.Context, token string, allowedOrgs []string) (bool, string, error) { + orgConstructs, err := p.GetOrgConstructs(ctx, token) + if err != nil { + return false, "", err + } + + // Create lookup map + orgMap := make(map[string]bool) + for _, oc := range orgConstructs { + orgMap[oc.Name] = true + } + + // Check against allowed list + for _, allowed := range allowedOrgs { + if orgMap[allowed] { + return true, allowed, nil + } + } + + return false, "", nil +} + +// ClearCache removes all cached entries. +// Useful for testing or when you want to force fresh GitHub API calls. +func (p *Provider) ClearCache() { + p.cacheMu.Lock() + p.cache = make(map[string]*cacheEntry) + p.cacheMu.Unlock() +} + +// ClearTokenCache removes the cache entry for a specific token. +func (p *Provider) ClearTokenCache(token string) { + p.cacheMu.Lock() + delete(p.cache, token) + p.cacheMu.Unlock() +} diff --git a/auth/authprovider/github/provider_test.go b/auth/authprovider/github/provider_test.go new file mode 100644 index 000000000..374b56e3a --- /dev/null +++ b/auth/authprovider/github/provider_test.go @@ -0,0 +1,190 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package github + +import ( + "context" + "testing" + "time" + + "github.com/agntcy/dir/auth/authprovider" +) + +func TestProvider_Name(t *testing.T) { + provider := NewProvider(nil) + + if got := provider.Name(); got != authprovider.ProviderGithub { + t.Errorf("Name() = %v, want %v", got, authprovider.ProviderGithub) + } +} + +func TestProvider_ValidateToken(t *testing.T) { + // Note: These are integration-style tests that would need actual GitHub tokens + // For unit tests, we'd mock the GitHub API client + tests := []struct { + name string + token string + wantErr bool + }{ + { + name: "empty token", + token: "", + wantErr: true, + }, + { + name: "invalid format", + token: "not-a-token", + wantErr: true, + }, + // Add more test cases as needed + } + + provider := NewProvider(&Config{ + CacheTTL: 1 * time.Minute, + APITimeout: 5 * time.Second, + }) + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + identity, err := provider.ValidateToken(ctx, tt.token) + + if (err != nil) != tt.wantErr { + t.Errorf("ValidateToken() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !tt.wantErr && identity == nil { + t.Error("ValidateToken() returned nil identity without error") + } + + if !tt.wantErr && identity.Provider != authprovider.ProviderGithub { + t.Errorf("ValidateToken() identity.Provider = %v, want %v", identity.Provider, authprovider.ProviderGithub) + } + }) + } +} + +func TestProvider_GetOrgConstructs(t *testing.T) { + provider := NewProvider(nil) + ctx := context.Background() + + // Test with invalid token + _, err := provider.GetOrgConstructs(ctx, "invalid-token") + if err == nil { + t.Error("Expected error for invalid token, got nil") + } + + // Additional tests would require mocking or actual GitHub tokens +} + +func TestProvider_CacheExpiration(t *testing.T) { + provider := NewProvider(&Config{ + CacheTTL: 100 * time.Millisecond, // Short TTL for testing + APITimeout: 5 * time.Second, + }) + + // Manually populate cache + token := "test-token" + provider.cache[token] = &cacheEntry{ + identity: &authprovider.UserIdentity{ + Provider: authprovider.ProviderGithub, + UserID: "123", + Username: "testuser", + }, + expiresAt: time.Now().Add(50 * time.Millisecond), + } + + // Should hit cache + provider.cacheMu.RLock() + _, ok := provider.cache[token] + provider.cacheMu.RUnlock() + + if !ok { + t.Error("Cache entry should exist") + } + + // Wait for expiration + time.Sleep(60 * time.Millisecond) + + // Verify cache logic (entry exists but expired) + provider.cacheMu.RLock() + entry, ok := provider.cache[token] + provider.cacheMu.RUnlock() + + if !ok { + t.Error("Cache entry should still exist (not yet cleaned up)") + } + + if entry != nil && time.Now().Before(entry.expiresAt) { + t.Error("Cache entry should be expired") + } +} + +func TestProvider_IsMemberOfOrgConstruct(t *testing.T) { + provider := NewProvider(nil) + + // Manually set cache for testing + token := "test-token" + provider.cache[token] = &cacheEntry{ + orgConstructs: []authprovider.OrgConstruct{ + {Name: "agntcy", Type: "github-org"}, + {Name: "spiffe", Type: "github-org"}, + }, + expiresAt: time.Now().Add(5 * time.Minute), + } + + tests := []struct { + name string + orgName string + want bool + }{ + {"member of agntcy", "agntcy", true}, + {"member of spiffe", "spiffe", true}, + {"not member of kubernetes", "kubernetes", false}, + } + + ctx := context.Background() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := provider.IsMemberOfOrgConstruct(ctx, token, tt.orgName) + if err != nil { + t.Errorf("IsMemberOfOrgConstruct() error = %v", err) + + return + } + + if got != tt.want { + t.Errorf("IsMemberOfOrgConstruct() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestProvider_ClearCache(t *testing.T) { + provider := NewProvider(nil) + + // Add cache entry + provider.cache["token1"] = &cacheEntry{ + identity: &authprovider.UserIdentity{Username: "user1"}, + expiresAt: time.Now().Add(5 * time.Minute), + } + provider.cache["token2"] = &cacheEntry{ + identity: &authprovider.UserIdentity{Username: "user2"}, + expiresAt: time.Now().Add(5 * time.Minute), + } + + if len(provider.cache) != 2 { + t.Errorf("Expected 2 cache entries, got %d", len(provider.cache)) + } + + // Clear cache + provider.ClearCache() + + if len(provider.cache) != 0 { + t.Errorf("Expected 0 cache entries after clear, got %d", len(provider.cache)) + } +} diff --git a/auth/authprovider/go.mod b/auth/authprovider/go.mod new file mode 100644 index 000000000..ce129f4b7 --- /dev/null +++ b/auth/authprovider/go.mod @@ -0,0 +1,16 @@ +module github.com/agntcy/dir/auth/authprovider + +go 1.25.6 + +require ( + github.com/google/go-github/v50 v50.2.0 + golang.org/x/oauth2 v0.24.0 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.1.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + golang.org/x/crypto v0.30.0 // indirect + golang.org/x/sys v0.28.0 // indirect +) diff --git a/auth/authprovider/go.sum b/auth/authprovider/go.sum new file mode 100644 index 000000000..f0ffc6c00 --- /dev/null +++ b/auth/authprovider/go.sum @@ -0,0 +1,27 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/auth/authprovider/provider.go b/auth/authprovider/provider.go new file mode 100644 index 000000000..ab97f6016 --- /dev/null +++ b/auth/authprovider/provider.go @@ -0,0 +1,124 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package authprovider + +import ( + "context" +) + +// UserIdentity represents a validated user identity from any authentication provider. +// This is a provider-agnostic representation that can be used by authorization logic +// without knowing the specific provider details. +type UserIdentity struct { + // Provider is the authentication provider name (github, google, azure, etc.) + Provider string + + // UserID is the provider-specific unique identifier for the user. + // Examples: GitHub user ID (numeric), Azure object ID (GUID), Google sub claim + UserID string + + // Username is the display name or login name. + // Examples: GitHub login, Azure UPN, Google email + Username string + + // Email is the user's email address (optional, not all providers guarantee this). + Email string + + // Attributes contains provider-specific additional information. + // This allows storing provider-specific data without polluting the common fields. + // Examples: avatar URL, profile URL, provider-specific roles, etc. + Attributes map[string]string +} + +// OrgConstruct represents a provider-specific organizational unit. +// The term "OrgConstruct" (organizational construct) is deliberately generic to +// accommodate the varying terminology used by different providers: +// +// • GitHub: "organization" - User-created organizations for collaboration +// • Azure: "tenant" - Azure Active Directory tenant +// • Google: "domain" - Google Workspace domain +// • AWS: "account" - AWS account ID +// • Okta: "organization" - Okta org domain +// +// This abstraction allows unified authorization rules across providers. +type OrgConstruct struct { + // ID is the provider-specific unique identifier. + // Examples: GitHub org ID (numeric), Azure tenant ID (GUID), Google domain + ID string + + // Name is the human-readable identifier. + // Examples: + // • GitHub: org login ("agntcy") + // • Azure: tenant domain ("contoso.onmicrosoft.com") + // • Google: workspace domain ("agntcy.com") + // • AWS: account ID ("123456789012") + Name string + + // Type indicates the provider-specific construct type. + // This helps authorization logic understand what kind of organizational + // unit this represents. + // + // Standard values: + // • "github-org" - GitHub organization + // • "azure-tenant" - Azure AD tenant + // • "google-domain" - Google Workspace domain + // • "aws-account" - AWS account + // • "okta-org" - Okta organization + Type string +} + +// Provider defines the interface for external authentication providers. +// This abstraction allows supporting multiple identity providers (GitHub, Google, Azure, etc.) +// with a unified interface for credential validation and organizational construct extraction. +type Provider interface { + // Name returns the provider identifier (e.g., "github", "google", "azure"). + Name() string + + // ValidateToken validates the provider-specific credential and returns user identity. + // The token format varies by provider (OAuth token, JWT, etc.). + ValidateToken(ctx context.Context, token string) (*UserIdentity, error) + + // GetOrgConstructs returns the user's organizational affiliations. + // The meaning of "org construct" varies by provider: + // • GitHub: organizations + // • Azure: tenants + // • Google: domains + // • AWS: accounts + GetOrgConstructs(ctx context.Context, token string) ([]OrgConstruct, error) +} + +// ProviderRegistry holds registered authentication providers. +// This allows runtime provider selection based on configuration. +type ProviderRegistry struct { + providers map[string]Provider +} + +// NewProviderRegistry creates a new provider registry. +func NewProviderRegistry() *ProviderRegistry { + return &ProviderRegistry{ + providers: make(map[string]Provider), + } +} + +// Register adds a provider to the registry. +func (r *ProviderRegistry) Register(provider Provider) { + r.providers[provider.Name()] = provider +} + +// Get retrieves a provider by name. +func (r *ProviderRegistry) Get(name string) (Provider, bool) { + provider, ok := r.providers[name] + + return provider, ok +} + +// List returns all registered provider names. +func (r *ProviderRegistry) List() []string { + names := make([]string, 0, len(r.providers)) + for name := range r.providers { + names = append(names, name) + } + + return names +} diff --git a/auth/authzserver/go.mod b/auth/authzserver/go.mod new file mode 100644 index 000000000..8f40779ef --- /dev/null +++ b/auth/authzserver/go.mod @@ -0,0 +1,22 @@ +module github.com/agntcy/dir/auth/authzserver + +go 1.25.6 + +require ( + github.com/agntcy/dir/auth/authprovider v0.0.0 + github.com/envoyproxy/go-control-plane/envoy v1.32.2 + google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def + google.golang.org/grpc v1.69.2 +) + +require ( + github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect + github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect +) + +replace github.com/agntcy/dir/auth/authprovider => ../authprovider diff --git a/auth/authzserver/go.sum b/auth/authzserver/go.sum new file mode 100644 index 000000000..f3c9169ff --- /dev/null +++ b/auth/authzserver/go.sum @@ -0,0 +1,40 @@ +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/envoyproxy/go-control-plane/envoy v1.32.2 h1:zidqwmijfcbyKqVxjQDFx042PgX+p9U+/fu/f9VtSk8= +github.com/envoyproxy/go-control-plane/envoy v1.32.2/go.mod h1:eR2SOX2IedqlPvmiKjUH7Wu//S602JKI7HPC/L3SRq8= +github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/auth/authzserver/server.go b/auth/authzserver/server.go new file mode 100644 index 000000000..c7be1633d --- /dev/null +++ b/auth/authzserver/server.go @@ -0,0 +1,352 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +// Package authzserver implements Envoy's External Authorization gRPC API +// for validating external authentication tokens (GitHub, Google, Azure, etc.) +// and enforcing authorization rules. +package authzserver + +import ( + "context" + "errors" + "fmt" + "log/slog" + "strings" + + "github.com/agntcy/dir/auth/authprovider" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + typev3 "github.com/envoyproxy/go-control-plane/envoy/type/v3" + "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc/codes" +) + +const ( + // authHeaderParts is the expected number of parts in "Bearer " header. + authHeaderParts = 2 +) + +// AuthorizationServer implements the Envoy ext_authz gRPC API. +// It supports multiple authentication providers through a generic interface. +type AuthorizationServer struct { + authv3.UnimplementedAuthorizationServer + + providers map[string]authprovider.Provider + config *Config + logger *slog.Logger + defaultProvider string +} + +// Config holds the authorization server configuration. +type Config struct { + // DefaultProvider is used when provider cannot be auto-detected + DefaultProvider string + + // AllowedOrgConstructs restricts access to users in these org constructs. + // Works across all providers (GitHub orgs, Azure tenants, Google domains, etc.) + // Empty list means no org restriction. + AllowedOrgConstructs []string + + // UserAllowList explicitly allows specific users regardless of org membership. + // Format: "provider:username" (e.g., "github:tkircsi", "google:alice@agntcy.com") + UserAllowList []string + + // UserDenyList explicitly denies specific users (takes precedence over allow lists). + // Format: "provider:username" + UserDenyList []string +} + +// NewAuthorizationServer creates a new authorization server with multiple providers. +func NewAuthorizationServer( + providers map[string]authprovider.Provider, + config *Config, + logger *slog.Logger, +) *AuthorizationServer { + if config == nil { + config = &Config{} + } + + if logger == nil { + logger = slog.Default() + } + + if config.DefaultProvider == "" { + config.DefaultProvider = authprovider.ProviderGithub + } + + return &AuthorizationServer{ + providers: providers, + config: config, + logger: logger, + defaultProvider: config.DefaultProvider, + } +} + +// Check implements the ext_authz Check RPC. +func (s *AuthorizationServer) Check(ctx context.Context, req *authv3.CheckRequest) (*authv3.CheckResponse, error) { + httpReq := req.GetAttributes().GetRequest().GetHttp() + + s.logger.Debug("received authorization request", + "path", httpReq.GetPath(), + "method", httpReq.GetMethod(), + ) + + // Extract Authorization header + authHeader := httpReq.GetHeaders()["authorization"] + if authHeader == "" { + return s.denyResponse(codes.Unauthenticated, "missing Authorization header"), nil + } + + // Parse Bearer token + token, err := extractBearerToken(authHeader) + if err != nil { + return s.denyResponse(codes.Unauthenticated, err.Error()), nil + } + + // Detect provider (combination approach) + providerName := s.detectProvider(httpReq, token) + + s.logger.Debug("detected provider", "provider", providerName) + + // Get provider + provider, ok := s.providers[providerName] + if !ok { + return s.denyResponse( + codes.Unavailable, + fmt.Sprintf("provider %s is not configured", providerName), + ), nil + } + + // Validate token using provider + identity, err := provider.ValidateToken(ctx, token) + if err != nil { + s.logger.Warn("token validation failed", + "provider", providerName, + "error", err, + ) + + return s.denyResponse(codes.Unauthenticated, "invalid token: "+err.Error()), nil + } + + // Get org constructs + orgConstructs, err := provider.GetOrgConstructs(ctx, token) + if err != nil { + s.logger.Warn("failed to fetch org constructs", + "provider", providerName, + "user", identity.Username, + "error", err, + ) + // Continue without org info (might be allowed anyway) + orgConstructs = []authprovider.OrgConstruct{} + } + + // Check authorization rules + if err := s.checkAuthorization(identity, orgConstructs); err != nil { + s.logger.Info("authorization denied", + "provider", identity.Provider, + "user", identity.Username, + "org_constructs", extractOrgNames(orgConstructs), + "reason", err.Error(), + ) + + return s.denyResponse(codes.PermissionDenied, err.Error()), nil + } + + s.logger.Info("authorization granted", + "provider", identity.Provider, + "user", identity.Username, + "org_constructs", extractOrgNames(orgConstructs), + ) + + return s.allowResponse(identity, orgConstructs), nil +} + +// detectProvider determines which provider to use based on request context. +// Priority: 1. Header, 2. Token format, 3. Default. +func (s *AuthorizationServer) detectProvider(httpReq *authv3.AttributeContext_HttpRequest, token string) string { + // Priority 1: Explicit header (user override) + if provider := httpReq.GetHeaders()["x-auth-provider"]; provider != "" { + return provider + } + + // Priority 2: Token format detection (automatic) + // GitHub OAuth2 tokens + if strings.HasPrefix(token, "gho_") || // OAuth token + strings.HasPrefix(token, "ghu_") || // User token + strings.HasPrefix(token, "ghs_") || // Server token + strings.HasPrefix(token, "ghr_") { // Refresh token + return authprovider.ProviderGithub + } + + // Google tokens (future) + // if strings.HasPrefix(token, "ya29.") { + // return authprovider.ProviderGoogle + // } + + // Azure tokens are JWTs (future) + // Note: Azure JWTs start with "eyJ" and are typically > 100 chars + // Could add: if strings.HasPrefix(token, "eyJ") && len(token) > minJWTLength { return authprovider.ProviderAzure } + // For now, we fall through to default provider + + // Priority 3: Configuration default + return s.defaultProvider +} + +// extractBearerToken extracts the token from a "Bearer " header value. +func extractBearerToken(authHeader string) (string, error) { + parts := strings.SplitN(authHeader, " ", authHeaderParts) + if len(parts) != authHeaderParts { + return "", errors.New("invalid Authorization header format") + } + + if !strings.EqualFold(parts[0], "bearer") { + return "", fmt.Errorf("expected Bearer token, got %s", parts[0]) + } + + token := strings.TrimSpace(parts[1]) + if token == "" { + return "", errors.New("empty token") + } + + return token, nil +} + +// checkAuthorization checks if the user is authorized based on configured rules. +func (s *AuthorizationServer) checkAuthorization( + identity *authprovider.UserIdentity, + orgConstructs []authprovider.OrgConstruct, +) error { + userKey := fmt.Sprintf("%s:%s", identity.Provider, identity.Username) + + // Priority 1: Check deny list (highest priority) + for _, denied := range s.config.UserDenyList { + if strings.EqualFold(userKey, denied) || strings.EqualFold(identity.Username, denied) { + return fmt.Errorf("user %q is in the deny list", identity.Username) + } + } + + // Priority 2: Check user allow list (explicit allow) + for _, allowed := range s.config.UserAllowList { + if strings.EqualFold(userKey, allowed) || strings.EqualFold(identity.Username, allowed) { + return nil // Explicitly allowed, skip other checks + } + } + + // Priority 3: Check org construct membership + if len(s.config.AllowedOrgConstructs) == 0 { + return nil // No org restrictions, allow all authenticated users + } + + // Build map of user's org constructs + userOrgSet := make(map[string]bool) + for _, oc := range orgConstructs { + userOrgSet[strings.ToLower(oc.Name)] = true + } + + // Check against allowed list + for _, allowed := range s.config.AllowedOrgConstructs { + if userOrgSet[strings.ToLower(allowed)] { + return nil // User in allowed org construct + } + } + + return fmt.Errorf("user %q is not a member of any allowed organization/tenant/domain", identity.Username) +} + +// allowResponse creates an OK response with user information headers. +func (s *AuthorizationServer) allowResponse( + identity *authprovider.UserIdentity, + orgConstructs []authprovider.OrgConstruct, +) *authv3.CheckResponse { + headers := []*corev3.HeaderValueOption{ + { + Header: &corev3.HeaderValue{ + Key: "x-auth-provider", + Value: identity.Provider, + }, + }, + { + Header: &corev3.HeaderValue{ + Key: "x-user-id", + Value: identity.UserID, + }, + }, + { + Header: &corev3.HeaderValue{ + Key: "x-username", + Value: identity.Username, + }, + }, + } + + // Add email if available + if identity.Email != "" { + headers = append(headers, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: "x-user-email", + Value: identity.Email, + }, + }) + } + + // Add org constructs + if len(orgConstructs) > 0 { + headers = append(headers, &corev3.HeaderValueOption{ + Header: &corev3.HeaderValue{ + Key: "x-org-constructs", + Value: strings.Join(extractOrgNames(orgConstructs), ","), + }, + }) + } + + return &authv3.CheckResponse{ + Status: &status.Status{Code: int32(codes.OK)}, + HttpResponse: &authv3.CheckResponse_OkResponse{ + OkResponse: &authv3.OkHttpResponse{ + Headers: headers, + }, + }, + } +} + +// denyResponse creates a denial response with the given code and message. +func (s *AuthorizationServer) denyResponse(code codes.Code, message string) *authv3.CheckResponse { + httpStatus := typev3.StatusCode_Forbidden + if code == codes.Unauthenticated { + httpStatus = typev3.StatusCode_Unauthorized + } + + return &authv3.CheckResponse{ + Status: &status.Status{ + //nolint:gosec // G115: codes.Code is uint32, status.Status.Code is int32. This is safe as gRPC codes are < MaxInt32 + Code: int32(code), + Message: message, + }, + HttpResponse: &authv3.CheckResponse_DeniedResponse{ + DeniedResponse: &authv3.DeniedHttpResponse{ + Status: &typev3.HttpStatus{ + Code: httpStatus, + }, + Body: fmt.Sprintf(`{"error": "%s", "message": "%s"}`, code.String(), message), + Headers: []*corev3.HeaderValueOption{ + { + Header: &corev3.HeaderValue{ + Key: "content-type", + Value: "application/json", + }, + }, + }, + }, + }, + } +} + +// extractOrgNames extracts just the names from org constructs for logging/headers. +func extractOrgNames(orgConstructs []authprovider.OrgConstruct) []string { + names := make([]string, len(orgConstructs)) + for i, oc := range orgConstructs { + names[i] = oc.Name + } + + return names +} diff --git a/auth/cmd/envoy-authz/Dockerfile b/auth/cmd/envoy-authz/Dockerfile new file mode 100644 index 000000000..3d0978427 --- /dev/null +++ b/auth/cmd/envoy-authz/Dockerfile @@ -0,0 +1,47 @@ +# syntax=docker/dockerfile:1@sha256:fe40cf4e92cd0c467be2cfc30657a680ae2398318afd50b0c80585784c604f28 + +# xx is a helper for cross-compilation +FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.4.0@sha256:0cd3f05c72d6c9b038eb135f91376ee1169ef3a330d34e418e65e2a5c2e9c0d4 AS xx + +FROM --platform=$BUILDPLATFORM golang:1.25.6-bookworm@sha256:2f768d462dbffbb0f0b3a5171009f162945b086f326e0b2a8fd5d29c3219ff14 AS builder + +COPY --link --from=xx / / + +ARG TARGETPLATFORM + +RUN --mount=type=cache,id=${TARGETPLATFORM}-apt,target=/var/cache/apt,sharing=locked \ + apt-get update \ + && xx-apt-get install -y --no-install-recommends \ + gcc \ + libc6-dev + +WORKDIR /build/auth/cmd/envoy-authz + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=bind,source=.,target=/build,ro \ + xx-go mod download -x + +ARG BUILD_OPTS +ARG EXTRA_LDFLAGS + +ENV CGO_ENABLED=0 + +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + --mount=type=bind,source=.,target=/build,ro \ + xx-go build ${BUILD_OPTS} -ldflags="-s -w -extldflags -static ${EXTRA_LDFLAGS}" \ + -o /bin/envoy-authz ./main.go + +RUN xx-verify /bin/envoy-authz + +# Production image - minimal distroless +FROM gcr.io/distroless/static:nonroot@sha256:c0f429e16b13e583da7e5a6ec20dd656d325d88e6819cafe0adb0828976529dc AS production + +WORKDIR / + +COPY --from=builder /bin/envoy-authz ./envoy-authz + +USER 65532:65532 + +ENTRYPOINT ["./envoy-authz"] diff --git a/auth/cmd/envoy-authz/TESTING.md b/auth/cmd/envoy-authz/TESTING.md new file mode 100644 index 000000000..2820cdc04 --- /dev/null +++ b/auth/cmd/envoy-authz/TESTING.md @@ -0,0 +1,72 @@ +# Testing Guide + +## Quick Start + +```bash +cd auth/cmd/envoy-authz + +# Start all services +docker-compose up --build + +# In another terminal, run tests +export GITHUB_TOKEN=gho_your_oauth_token_here +./test/test.sh +``` + +## What Gets Tested + +1. ✅ Health check (no auth) +2. ✅ Request without auth → 401 +3. ✅ Request with invalid token → 401 +4. ✅ Request with valid GitHub OAuth2 token → 200 +5. ✅ User info headers forwarded + +## Services + +- **envoy-authz** (port 9002) - Our ExtAuthz service +- **envoy** (port 8080) - Envoy gateway with ext_authz filter +- **mock-directory** (port 8888) - Mock backend (echoes headers) + +## Testing Manually + +```bash +# Valid request with OAuth2 token +curl -H "Authorization: Bearer $GITHUB_TOKEN" \ + http://localhost:8080/api/test | jq . + +# Check logs +docker-compose logs envoy-authz +docker-compose logs envoy +docker-compose logs mock-directory + +# Envoy admin +curl http://localhost:9901/stats | grep ext_authz +``` + +## Getting a GitHub OAuth2 Token + +To obtain a GitHub OAuth2 token for testing: + +```bash +# Use dirctl to authenticate and get a token +dirctl auth login +dirctl auth status # Shows your current token + +# Or use GitHub CLI +gh auth token +``` + +## Configuration + +Edit `docker-compose.yml` environment variables: + +```yaml +ALLOWED_ORG_CONSTRUCTS: "agntcy,spiffe" # Restrict to these orgs +USER_DENY_LIST: "blocked-user" # Block specific users +``` + +## Cleanup + +```bash +docker-compose down +``` diff --git a/auth/cmd/envoy-authz/docker-compose.yml b/auth/cmd/envoy-authz/docker-compose.yml new file mode 100644 index 000000000..80348cfc5 --- /dev/null +++ b/auth/cmd/envoy-authz/docker-compose.yml @@ -0,0 +1,61 @@ +version: '3.8' + +services: + # ExtAuthz service (our implementation) + envoy-authz: + build: + context: ../.. + dockerfile: auth/cmd/envoy-authz/Dockerfile + container_name: envoy-authz + ports: + - "9002:9002" # gRPC ext_authz + environment: + # Server settings + LISTEN_ADDRESS: ":9002" + LOG_LEVEL: "debug" + DEFAULT_PROVIDER: "github" + + # Authorization rules + ALLOWED_ORG_CONSTRUCTS: "agntcy,spiffe" + USER_ALLOW_LIST: "" + USER_DENY_LIST: "" + + # GitHub provider + GITHUB_ENABLED: "true" + GITHUB_CACHE_TTL: "5m" + GITHUB_API_TIMEOUT: "10s" + networks: + - authz-test + + # Envoy proxy with ext_authz filter + envoy: + image: envoyproxy/envoy:v1.31-latest + container_name: envoy-gateway + ports: + - "8080:8080" # HTTP/gRPC gateway + - "9901:9901" # Admin interface + volumes: + - ./test/envoy.yaml:/etc/envoy/envoy.yaml:ro + command: ["/usr/local/bin/envoy", "-c", "/etc/envoy/envoy.yaml", "--service-cluster", "test-cluster"] + depends_on: + - envoy-authz + - mock-directory + networks: + - authz-test + + # Mock Directory API server (simple echo server) + mock-directory: + build: + context: . + dockerfile: test/Dockerfile.mock + container_name: mock-directory + ports: + - "8888:8888" + environment: + LISTEN_ADDRESS: ":8888" + networks: + - authz-test + +networks: + authz-test: + driver: bridge diff --git a/auth/cmd/envoy-authz/go.mod b/auth/cmd/envoy-authz/go.mod new file mode 100644 index 000000000..055fd3152 --- /dev/null +++ b/auth/cmd/envoy-authz/go.mod @@ -0,0 +1,32 @@ +module github.com/agntcy/dir/auth/cmd/envoy-authz + +go 1.25.6 + +require ( + github.com/agntcy/dir/auth/authprovider v0.0.0 + github.com/agntcy/dir/auth/authzserver v0.0.0 + github.com/envoyproxy/go-control-plane/envoy v1.32.2 + google.golang.org/grpc v1.69.2 +) + +require ( + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect + github.com/cloudflare/circl v1.1.0 // indirect + github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect + github.com/envoyproxy/protoc-gen-validate v1.1.0 // indirect + github.com/google/go-github/v50 v50.2.0 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect + golang.org/x/crypto v0.30.0 // indirect + golang.org/x/net v0.30.0 // indirect + golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def // indirect + google.golang.org/protobuf v1.36.1 // indirect +) + +replace ( + github.com/agntcy/dir/auth/authprovider => ../../authprovider + github.com/agntcy/dir/auth/authzserver => ../../authzserver +) diff --git a/auth/cmd/envoy-authz/go.sum b/auth/cmd/envoy-authz/go.sum new file mode 100644 index 000000000..bfc60645c --- /dev/null +++ b/auth/cmd/envoy-authz/go.sum @@ -0,0 +1,63 @@ +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 h1:QVw89YDxXxEe+l8gU8ETbOasdwEV+avkR75ZzsVV9WI= +github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= +github.com/envoyproxy/go-control-plane/envoy v1.32.2 h1:zidqwmijfcbyKqVxjQDFx042PgX+p9U+/fu/f9VtSk8= +github.com/envoyproxy/go-control-plane/envoy v1.32.2/go.mod h1:eR2SOX2IedqlPvmiKjUH7Wu//S602JKI7HPC/L3SRq8= +github.com/envoyproxy/protoc-gen-validate v1.1.0 h1:tntQDh69XqOCOZsDz0lVJQez/2L6Uu2PdjCQwWCJ3bM= +github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def h1:4P81qv5JXI/sDNae2ClVx88cgDDA6DPilADkG9tYKz8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241230172942-26aa7a208def/go.mod h1:bdAgzvd4kFrpykc5/AC2eLUiegK9T/qxZHD4hXYf/ho= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/auth/cmd/envoy-authz/main.go b/auth/cmd/envoy-authz/main.go new file mode 100644 index 000000000..bef295eda --- /dev/null +++ b/auth/cmd/envoy-authz/main.go @@ -0,0 +1,226 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "log/slog" + "net" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/agntcy/dir/auth/authprovider" + "github.com/agntcy/dir/auth/authprovider/github" + "github.com/agntcy/dir/auth/authzserver" + authv3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + healthpb "google.golang.org/grpc/health/grpc_health_v1" +) + +const ( + // Default cache TTL for authentication tokens. + defaultCacheTTL = 5 * time.Minute + + // Default API timeout for external API calls. + defaultAPITimeout = 10 * time.Second +) + +func main() { + // Setup logging + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: getLogLevel(), + })) + slog.SetDefault(logger) + + // Load configuration + config := loadConfig() + + // Initialize providers + providers := initializeProviders(config) + + if len(providers) == 0 { + logger.Error("no authentication providers configured") + os.Exit(1) + } + + logger.Info("initialized authentication providers", + "providers", getProviderNames(providers), + "default", config.DefaultProvider, + ) + + // Create authorization server + authzConfig := &authzserver.Config{ + DefaultProvider: config.DefaultProvider, + AllowedOrgConstructs: parseList(config.AllowedOrgConstructs), + UserAllowList: parseList(config.UserAllowList), + UserDenyList: parseList(config.UserDenyList), + } + + authzServer := authzserver.NewAuthorizationServer(providers, authzConfig, logger) + + // Create gRPC server + grpcServer := grpc.NewServer() + + // Register ext_authz service + authv3.RegisterAuthorizationServer(grpcServer, authzServer) + + // Register health service + healthServer := health.NewServer() + healthpb.RegisterHealthServer(grpcServer, healthServer) + healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING) + + // Start server + listenAddr := config.ListenAddress + + // Use context for listener (noctx) + listenConfig := &net.ListenConfig{} + + listener, err := listenConfig.Listen(context.Background(), "tcp", listenAddr) + if err != nil { + logger.Error("failed to listen", "address", listenAddr, "error", err) + os.Exit(1) + } + + logger.Info("starting GitHub authorization server", "address", listenAddr) + + // Handle graceful shutdown + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + <-sigCh + + logger.Info("shutting down gracefully...") + grpcServer.GracefulStop() + }() + + // Serve + if err := grpcServer.Serve(listener); err != nil { + logger.Error("server error", "error", err) + os.Exit(1) + } +} + +// Config holds service configuration. +type Config struct { + ListenAddress string + DefaultProvider string + AllowedOrgConstructs string + UserAllowList string + UserDenyList string + CacheTTL time.Duration + + // Provider-specific configs + GitHub struct { + Enabled bool + CacheTTL time.Duration + APITimeout time.Duration + } + + // Future: Google, Azure, etc. +} + +// loadConfig loads configuration from environment variables. +func loadConfig() *Config { + config := &Config{ + ListenAddress: getEnv("LISTEN_ADDRESS", ":9002"), + DefaultProvider: getEnv("DEFAULT_PROVIDER", authprovider.ProviderGithub), + AllowedOrgConstructs: getEnv("ALLOWED_ORG_CONSTRUCTS", ""), + UserAllowList: getEnv("USER_ALLOW_LIST", ""), + UserDenyList: getEnv("USER_DENY_LIST", ""), + CacheTTL: parseDuration(getEnv("CACHE_TTL", "5m"), defaultCacheTTL), + } + + // GitHub provider config + config.GitHub.Enabled = getEnv("GITHUB_ENABLED", "true") == "true" + config.GitHub.CacheTTL = parseDuration(getEnv("GITHUB_CACHE_TTL", "5m"), defaultCacheTTL) + config.GitHub.APITimeout = parseDuration(getEnv("GITHUB_API_TIMEOUT", "10s"), defaultAPITimeout) + + return config +} + +// initializeProviders creates and registers authentication providers. +func initializeProviders(config *Config) map[string]authprovider.Provider { + providers := make(map[string]authprovider.Provider) + + // GitHub provider + if config.GitHub.Enabled { + githubProvider := github.NewProvider(&github.Config{ + CacheTTL: config.GitHub.CacheTTL, + APITimeout: config.GitHub.APITimeout, + }) + providers[authprovider.ProviderGithub] = githubProvider + + slog.Info("registered provider", "name", authprovider.ProviderGithub) + } + + // Future providers + // if config.Google.Enabled { + // providers["google"] = google.NewProvider(&google.Config{...}) + // } + + return providers +} + +// Helper functions + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + + return defaultValue +} + +func getLogLevel() slog.Level { + switch strings.ToLower(getEnv("LOG_LEVEL", "info")) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func parseDuration(s string, defaultValue time.Duration) time.Duration { + if d, err := time.ParseDuration(s); err == nil { + return d + } + + return defaultValue +} + +func parseList(s string) []string { + if s == "" { + return []string{} + } + + parts := strings.Split(s, ",") + + result := make([]string, 0, len(parts)) + for _, part := range parts { + if trimmed := strings.TrimSpace(part); trimmed != "" { + result = append(result, trimmed) + } + } + + return result +} + +func getProviderNames(providers map[string]authprovider.Provider) []string { + names := make([]string, 0, len(providers)) + for name := range providers { + names = append(names, name) + } + + return names +} diff --git a/auth/cmd/envoy-authz/test/Dockerfile.mock b/auth/cmd/envoy-authz/test/Dockerfile.mock new file mode 100644 index 000000000..941288428 --- /dev/null +++ b/auth/cmd/envoy-authz/test/Dockerfile.mock @@ -0,0 +1,11 @@ +FROM golang:1.25.6-alpine + +WORKDIR /app + +COPY test/mock-directory.go . + +RUN go build -o /bin/mock-directory mock-directory.go + +EXPOSE 8888 + +ENTRYPOINT ["/bin/mock-directory"] diff --git a/auth/cmd/envoy-authz/test/envoy.yaml b/auth/cmd/envoy-authz/test/envoy.yaml new file mode 100644 index 000000000..a329ca8ab --- /dev/null +++ b/auth/cmd/envoy-authz/test/envoy.yaml @@ -0,0 +1,110 @@ +# Envoy configuration for testing ext_authz integration +# Based on SPIRE tutorials: https://github.com/spiffe/spire-tutorials/tree/main/k8s/envoy-jwt + +node: + id: "test-envoy" + cluster: "test-cluster" + +admin: + address: + socket_address: + address: 0.0.0.0 + port_value: 9901 + +static_resources: + listeners: + - name: main_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: AUTO + http2_protocol_options: {} + + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: ["*"] + routes: + # Health check - no auth required + - match: + path: "/healthz" + direct_response: + status: 200 + body: + inline_string: '{"status":"healthy","service":"envoy-gateway"}' + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + + # All other requests - require authentication + - match: + prefix: "/" + route: + cluster: mock_directory + timeout: 30s + + http_filters: + # External authorization filter + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + grpc_service: + envoy_grpc: + cluster_name: ext_authz + timeout: 5s + failure_mode_allow: false + + # Router filter (must be last) + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + # ExtAuthz service (our service) + - name: ext_authz + type: STRICT_DNS + connect_timeout: 1s + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: ext_authz + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: envoy-authz + port_value: 9002 + + # Mock Directory backend + - name: mock_directory + type: STRICT_DNS + connect_timeout: 1s + load_assignment: + cluster_name: mock_directory + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: mock-directory + port_value: 8888 diff --git a/auth/cmd/envoy-authz/test/mock-directory.go b/auth/cmd/envoy-authz/test/mock-directory.go new file mode 100644 index 000000000..3abb247b8 --- /dev/null +++ b/auth/cmd/envoy-authz/test/mock-directory.go @@ -0,0 +1,132 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +// Package main implements a mock Directory server for local testing of Envoy ext_authz. +// +// SECURITY NOTE: This is a TEST/DEVELOPMENT server only. +// It intentionally logs all HTTP headers and user input for debugging purposes. +// DO NOT use this server in production environments. +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" +) + +const ( + // Default listen address for the mock server. + defaultListenAddr = ":8888" + + // HTTP server timeouts. + serverReadHeaderTimeout = 10 * time.Second + serverReadTimeout = 30 * time.Second + serverWriteTimeout = 30 * time.Second + serverIdleTimeout = 60 * time.Second + + // Graceful shutdown timeout. + shutdownTimeout = 5 * time.Second +) + +func main() { + addr := os.Getenv("LISTEN_ADDRESS") + if addr == "" { + addr = defaultListenAddr + } + + http.HandleFunc("/", handleRequest) + http.HandleFunc("/healthz", handleHealth) + + // Create server with timeouts (gosec G114) + server := &http.Server{ + Addr: addr, + ReadHeaderTimeout: serverReadHeaderTimeout, + ReadTimeout: serverReadTimeout, + WriteTimeout: serverWriteTimeout, + IdleTimeout: serverIdleTimeout, + } + + // Graceful shutdown + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Printf("Server shutdown error: %v", err) + } + }() + + log.Printf("Mock Directory server listening on %s", addr) + + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } +} + +func handleRequest(w http.ResponseWriter, r *http.Request) { + //nolint:gosec // G110: Mock server for testing - verbose logging intentional + log.Printf("📨 Request: %s %s", r.Method, r.URL.Path) + + // Log all headers (shows what Envoy adds) + log.Println("📋 Headers:") + + for name, values := range r.Header { + for _, value := range values { + //nolint:gosec // G110,G401: Mock server - logs headers for debugging ext_authz + log.Printf(" %s: %s", name, value) + } + } + + // Extract user info from headers (added by ext_authz) + authProvider := r.Header.Get("X-Auth-Provider") + username := r.Header.Get("X-Username") + userID := r.Header.Get("X-User-Id") + email := r.Header.Get("X-User-Email") + orgConstructs := r.Header.Get("X-Org-Constructs") + + // Echo back the request info + response := map[string]any{ + "message": "Mock Directory API", + "path": r.URL.Path, + "method": r.Method, + "authenticated": map[string]string{ + "provider": authProvider, + "username": username, + "user_id": userID, + "email": email, + "org_constructs": orgConstructs, + }, + "note": "This is a mock server for testing ext_authz integration", + } + + // Pretty print for logs + if authProvider != "" { + //nolint:gosec // G110: Mock server - logs authenticated user info for debugging + log.Printf("✅ Authenticated user: %s (provider: %s, orgs: %s)", + username, authProvider, orgConstructs) + } + + w.Header().Set("Content-Type", "application/json") + + if err := json.NewEncoder(w).Encode(response); err != nil { + log.Printf("Error encoding response: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"status":"healthy","service":"mock-directory"}`) +} diff --git a/auth/cmd/envoy-authz/test/test.sh b/auth/cmd/envoy-authz/test/test.sh new file mode 100755 index 000000000..22cd8bb3a --- /dev/null +++ b/auth/cmd/envoy-authz/test/test.sh @@ -0,0 +1,116 @@ +#!/bin/bash +# Test script for ext_authz integration + +set -e + +ENVOY_URL="http://localhost:8080" +GITHUB_TOKEN="${GITHUB_TOKEN:-}" + +echo "🧪 Testing ext_authz Integration" +echo "=================================" +echo "" + +# Colors +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test 1: Health check (no auth) +echo "Test 1: Health check (no auth required)" +echo "----------------------------------------" +curl -s "$ENVOY_URL/healthz" | jq . +echo -e "${GREEN}✓ Health check passed${NC}\n" + +# Test 2: Request without auth (should fail) +echo "Test 2: Request without Authorization header" +echo "---------------------------------------------" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$ENVOY_URL/api/test") +if [ "$HTTP_CODE" = "401" ]; then + echo -e "${GREEN}✓ Correctly rejected (401 Unauthorized)${NC}\n" +else + echo -e "${RED}✗ Expected 401, got $HTTP_CODE${NC}\n" +fi + +# Test 3: Request with invalid token (should fail) +echo "Test 3: Request with invalid GitHub token" +echo "------------------------------------------" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer invalid_token_123" \ + "$ENVOY_URL/api/test") +if [ "$HTTP_CODE" = "401" ]; then + echo -e "${GREEN}✓ Correctly rejected invalid token (401)${NC}\n" +else + echo -e "${RED}✗ Expected 401, got $HTTP_CODE${NC}\n" +fi + +# Test 4: Request with valid GitHub OAuth2 token (should succeed) +if [ -z "$GITHUB_TOKEN" ]; then + echo -e "${YELLOW}⚠ Test 4: Skipped (set GITHUB_TOKEN environment variable to test)${NC}" + echo " Example: export GITHUB_TOKEN=gho_your_oauth_token_here" + echo " Get token: dirctl auth login && dirctl auth status" + echo "" +else + echo "Test 4: Request with valid GitHub OAuth2 token" + echo "-----------------------------------------------" + RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + "$ENVOY_URL/api/test") + + HTTP_CODE=$(echo "$RESPONSE" | grep "HTTP_CODE:" | cut -d: -f2) + BODY=$(echo "$RESPONSE" | grep -v "HTTP_CODE:") + + if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Request successful (200 OK)${NC}" + echo "Response:" + echo "$BODY" | jq . + echo "" + + # Check if user info headers were added + echo "Checking forwarded user info..." + PROVIDER=$(echo "$BODY" | jq -r '.authenticated.provider') + USERNAME=$(echo "$BODY" | jq -r '.authenticated.username') + ORGS=$(echo "$BODY" | jq -r '.authenticated.org_constructs') + + if [ "$PROVIDER" != "null" ] && [ "$PROVIDER" != "" ]; then + echo -e "${GREEN}✓ Provider: $PROVIDER${NC}" + fi + if [ "$USERNAME" != "null" ] && [ "$USERNAME" != "" ]; then + echo -e "${GREEN}✓ Username: $USERNAME${NC}" + fi + if [ "$ORGS" != "null" ] && [ "$ORGS" != "" ]; then + echo -e "${GREEN}✓ Org Constructs: $ORGS${NC}" + fi + echo "" + else + echo -e "${RED}✗ Request failed (HTTP $HTTP_CODE)${NC}" + echo "Response:" + echo "$BODY" | jq . + echo "" + fi +fi + +# Test 5: Request with explicit provider header +if [ -n "$GITHUB_TOKEN" ]; then + echo "Test 5: Request with explicit x-auth-provider header" + echo "-----------------------------------------------------" + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "x-auth-provider: github" \ + "$ENVOY_URL/api/test") + + if [ "$HTTP_CODE" = "200" ]; then + echo -e "${GREEN}✓ Request with explicit provider succeeded${NC}\n" + else + echo -e "${RED}✗ Request failed (HTTP $HTTP_CODE)${NC}\n" + fi +fi + +echo "🎉 Testing Complete!" +echo "" +echo "💡 Tips:" +echo " - Check envoy-authz logs: docker-compose logs envoy-authz" +echo " - Check Envoy logs: docker-compose logs envoy" +echo " - Check mock-directory logs: docker-compose logs mock-directory" +echo " - Envoy admin: curl localhost:9901/stats | grep ext_authz" +echo "" diff --git a/cli/README.md b/cli/README.md index d8e7c2fce..5db2a0ad7 100644 --- a/cli/README.md +++ b/cli/README.md @@ -34,17 +34,20 @@ docker run --rm ghcr.io/agntcy/dir-ctl:latest --help ## Quick Start ```bash -# 1. Store a record +# 1. Authenticate with GitHub (required for federation nodes) +dirctl auth login + +# 2. Store a record dirctl push my-agent.json # Returns: baeareihdr6t7s6sr2q4zo456sza66eewqc7huzatyfgvoupaqyjw23ilvi -# 2. Publish for network discovery +# 3. Publish for network discovery dirctl routing publish baeareihdr6t7s6sr2q4zo456sza66eewqc7huzatyfgvoupaqyjw23ilvi -# 3. Search for records +# 4. Search for records dirctl routing search --skill "AI" --limit 10 -# 4. Retrieve a record +# 5. Retrieve a record dirctl pull baeareihdr6t7s6sr2q4zo456sza66eewqc7huzatyfgvoupaqyjw23ilvi ``` @@ -98,6 +101,16 @@ done ## Command Reference +### 🔐 **Authentication** + +See the [Authentication](#authentication) section above for detailed usage. + +| Command | Description | +|---------|-------------| +| `dirctl auth login` | Authenticate with GitHub (interactive browser flow) | +| `dirctl auth logout` | Clear cached authentication credentials | +| `dirctl auth status` | Show current authentication status | + ### 📦 **Storage Operations** #### `dirctl push ` @@ -599,6 +612,135 @@ Remove synchronization. dirctl sync delete abc123-def456-ghi789 ``` +## Authentication + +Authentication is required when accessing Directory federation nodes. The CLI supports multiple authentication modes, with GitHub OAuth being the recommended approach for interactive use. + +### 🔐 **GitHub OAuth Authentication** + +GitHub OAuth enables secure, interactive authentication for accessing federation nodes. This is the recommended authentication method for CLI users. See [GitHub OIDC Authentication](https://github.com/agntcy/dir-staging/issues/14) for more details on the federation authentication architecture. + +#### Prerequisites + +Before using GitHub authentication, you need to set up a GitHub OAuth App: + +1. Go to [GitHub Developer Settings](https://github.com/settings/developers) +2. Click "New OAuth App" +3. Set the callback URL to: `http://localhost:8484/callback` +4. Note your Client ID (and optionally Client Secret) + +#### Configuration + +Set your GitHub OAuth App credentials: + +```bash +# Required: Set your OAuth App client ID +export DIRECTORY_CLIENT_GITHUB_CLIENT_ID="your-client-id" + +# Optional: Set client secret (if your OAuth App requires it) +export DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET="your-client-secret" +``` + +#### `dirctl auth login` + +Authenticate with GitHub using an interactive browser-based OAuth flow. + +```bash +# Login with GitHub (opens browser) +dirctl auth login + +# Login with explicit client ID +dirctl auth login --client-id=Ov23li... + +# Force re-login even if already authenticated +dirctl auth login --force + +# Login without automatically opening browser +dirctl auth login --no-browser +``` + +**What happens:** +1. Opens your default browser to GitHub's authorization page +2. You authorize the dirctl application +3. Token is cached locally at `~/.config/dirctl/auth-token.json` +4. Token is automatically detected and used for subsequent commands (no `--auth-mode` flag needed) + +#### `dirctl auth status` + +Check your current authentication status. + +```bash +# Show authentication status +dirctl auth status + +# Validate token with GitHub API +dirctl auth status --validate +``` + +**Example output:** +``` +Status: Authenticated + User: your-username + Organizations: agntcy, your-org + Cached at: 2025-12-22T10:30:00Z + Token: Valid ✓ + Estimated expiry: 2025-12-22T18:30:00Z + Cache file: /Users/you/.config/dirctl/auth-token.json +``` + +#### `dirctl auth logout` + +Clear cached authentication credentials. + +```bash +# Logout (clear cached token) +dirctl auth logout +``` + +#### Using Authenticated Commands + +Once authenticated via `dirctl auth login`, your cached credentials are automatically detected and used: + +```bash +# Push to federation (auto-detects and uses cached GitHub credentials) +dirctl push my-agent.json + +# Search federation nodes (auto-detects authentication) +dirctl --server-addr=federation.agntcy.org:443 search --skill "AI" + +# Pull from federation (auto-detects authentication) +dirctl pull baeareihdr6t7s6sr2q4zo456sza66eewqc7huzatyfgvoupaqyjw23ilvi +``` + +**Authentication Mode Behavior:** + +- **No `--auth-mode` flag (default)**: Auto-detects authentication in this order: + 1. SPIFFE (if available in Kubernetes/SPIRE environment) + 2. Cached GitHub credentials (if `dirctl auth login` was run) + 3. Insecure (for local development) + +- **Explicit `--auth-mode=github`**: Forces GitHub authentication (required if you want to bypass SPIFFE in a SPIRE environment) + +- **Other modes**: Use `--auth-mode=x509`, `--auth-mode=jwt`, or `--auth-mode=tls` for specific authentication methods + +**Example with explicit mode:** +```bash +# Force GitHub auth even if SPIFFE is available +dirctl --auth-mode=github push my-agent.json +``` + +### Other Authentication Modes + +| Mode | Description | Use Case | +|------|-------------|----------| +| `github` | GitHub OAuth (explicit) | Force GitHub auth, bypass SPIFFE auto-detect | +| `x509` | SPIFFE X.509 certificates | Kubernetes workloads with SPIRE | +| `jwt` | SPIFFE JWT tokens | Service-to-service authentication | +| `token` | SPIFFE token file | Pre-provisioned credentials | +| `tls` | mTLS with certificates | Custom PKI environments | +| `insecure` / `none` | Insecure (no auth, skip auto-detect) | Testing, local development | +| (empty) | Auto-detect: SPIFFE → cached GitHub → insecure | Default behavior (recommended) | + ## Configuration ### Server Connection @@ -732,6 +874,7 @@ dirctl events listen --output raw | tee event-cids.txt The CLI follows a clear service-based organization: +- **Auth**: GitHub OAuth authentication (`auth login`, `auth logout`, `auth status`) - **Storage**: Direct record management (`push`, `pull`, `delete`, `info`) - **Routing**: Network announcement and discovery (`routing publish`, `routing list`, `routing search`) - **Search**: General content search (`search`) diff --git a/cli/cmd/auth/auth.go b/cli/cmd/auth/auth.go new file mode 100644 index 000000000..881d96e85 --- /dev/null +++ b/cli/cmd/auth/auth.go @@ -0,0 +1,41 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "github.com/spf13/cobra" +) + +// Command is the parent command for authentication-related subcommands. +var Command = &cobra.Command{ + Use: "auth", + Short: "Manage authentication", + Long: `Manage authentication for dirctl. + +This command group provides OAuth2-based authentication for the Directory server +using external providers (currently GitHub). + +Examples: + # Login with OAuth (opens browser) + dirctl auth login + + # Check authentication status + dirctl auth status + + # Logout (clear cached token) + dirctl auth logout`, + // Override root's PersistentPreRunE - auth commands don't need a client + // since they manage authentication themselves + PersistentPreRunE: func(_ *cobra.Command, _ []string) error { + return nil + }, +} + +func init() { + Command.AddCommand( + loginCmd, + logoutCmd, + statusCmd, + ) +} diff --git a/cli/cmd/auth/login.go b/cli/cmd/auth/login.go new file mode 100644 index 000000000..78bece116 --- /dev/null +++ b/cli/cmd/auth/login.go @@ -0,0 +1,321 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/agntcy/dir/auth/authprovider/github" + "github.com/agntcy/dir/client" + "github.com/spf13/cobra" +) + +var ( + // OAuth configuration flags. + callbackPort int + timeout time.Duration + skipBrowserOpen bool + forceLogin bool + useWebFlow bool +) + +// getClientID returns the client ID from config (includes --github-client-id flag value) or env var. +func getClientID() string { + // Try loading from config (includes persistent flag values) + if cfg, err := client.LoadConfig(); err == nil && cfg.GitHubClientID != "" { + return cfg.GitHubClientID + } + // Check environment variable + if envID := os.Getenv("DIRECTORY_CLIENT_GITHUB_CLIENT_ID"); envID != "" { + return envID + } + + return "" +} + +// getClientSecret returns the client secret from config (includes --github-client-secret flag value) or env var. +func getClientSecret() string { + // Try loading from config (includes persistent flag values) + if cfg, err := client.LoadConfig(); err == nil && cfg.GitHubClientSecret != "" { + return cfg.GitHubClientSecret + } + // Check environment variable + if envSecret := os.Getenv("DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET"); envSecret != "" { + return envSecret + } + + return "" +} + +var loginCmd = &cobra.Command{ + Use: "login", + Short: "Authenticate with GitHub", + Long: `Authenticate with GitHub using OAuth2. + +By default, uses device authorization flow which works everywhere +(SSH sessions, servers, headless environments). You'll be shown a +code to enter at github.com/login/device. + +Use --web to open a browser on this machine instead (requires OAuth App setup). + +OAuth Scopes Requested: + • user:email - Access user profile and email + • read:org - Read organization membership + +Device Flow (default): + • Works in any environment (SSH, servers, containers) + • No OAuth App configuration needed + • Complete authorization on any device (phone, laptop, etc.) + • Uses GitHub's public OAuth App + +Web Flow (--web): + • Opens browser on the same machine + • Requires GitHub OAuth App setup: + 1. Go to https://github.com/settings/developers + 2. Click "New OAuth App" + 3. Set callback URL to: http://localhost:8484/callback + 4. Set DIRECTORY_CLIENT_GITHUB_CLIENT_ID environment variable + +Environment Variables: + DIRECTORY_CLIENT_GITHUB_CLIENT_ID GitHub OAuth App client ID (for --web) + DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET GitHub OAuth App client secret (for --web) + +Examples: + # Device flow (default, works everywhere) + dirctl auth login + + # Web flow (opens browser on this machine) + dirctl auth login --web + + # Web flow with explicit client ID + dirctl auth login --web --github-client-id=Ov23li... + + # Force re-login even if already authenticated + dirctl auth login --force`, + RunE: runLogin, +} + +func init() { + flags := loginCmd.Flags() + flags.BoolVar(&useWebFlow, "web", false, "Use web browser flow instead of device flow") + // Note: --github-client-id and --github-client-secret are registered as persistent flags in cli/cmd/options.go + // OAuth scopes are fixed: user:email (for profile) and read:org (for organization membership) + flags.IntVar(&callbackPort, "callback-port", client.DefaultCallbackPort, "Port for OAuth callback server (for --web)") + flags.DurationVar(&timeout, "timeout", client.DefaultOAuthTimeout, "Timeout for OAuth flow") + flags.BoolVar(&skipBrowserOpen, "no-browser", false, "Don't automatically open the browser (for --web)") + flags.BoolVar(&forceLogin, "force", false, "Force re-login even if already authenticated") +} + +func runLogin(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + + flowType := "Device" + if useWebFlow { + flowType = "Web" + } + + cmd.Println("╔════════════════════════════════════════════════════════════╗") + cmd.Printf("║ GitHub OAuth Authentication (%s Flow) ║\n", flowType) + cmd.Println("╚════════════════════════════════════════════════════════════╝") + + // Check for existing valid token unless force login + cache := client.NewTokenCache() + if !forceLogin { + existingToken, _ := cache.GetValidToken() + if existingToken != nil { + cmd.Println() + cmd.Printf("✓ Already authenticated as: %s\n", existingToken.User) + + if len(existingToken.Orgs) > 0 { + cmd.Printf(" Organizations: %s\n", strings.Join(existingToken.Orgs, ", ")) + } + + cmd.Println() + cmd.Println("Use 'dirctl auth logout' to clear credentials and login again,") + cmd.Println("or use 'dirctl auth login --force' to re-authenticate.") + + return nil + } + } + + // Route to appropriate flow + if useWebFlow { + return runWebFlow(cmd, ctx, cache) + } + + return runDeviceFlow(cmd, ctx, cache) +} + +// TokenMetadata contains token information from OAuth flow. +type TokenMetadata struct { + AccessToken string + TokenType string + ExpiresAt time.Time +} + +// fetchUserInfoAndCache fetches user information, organizations, and caches the token. +// This is shared logic between web and device flows. +func fetchUserInfoAndCache( + cmd *cobra.Command, + ctx context.Context, + token TokenMetadata, + cache *client.TokenCache, +) error { + // Fetch user info using auth/authprovider + cmd.Println("Fetching user information...") + + provider := github.NewProvider(nil) + + identity, err := provider.ValidateToken(ctx, token.AccessToken) + if err != nil { + return fmt.Errorf("failed to fetch user info: %w", err) + } + + cmd.Printf("✓ Authenticated as: %s", identity.Username) + + if name := identity.Attributes["name"]; name != "" { + cmd.Printf(" (%s)", name) + } + + cmd.Println() + + // Fetch organizations + cmd.Println("Fetching organization memberships...") + + var orgNames []string + + orgConstructs, err := provider.GetOrgConstructs(ctx, token.AccessToken) + if err != nil { + cmd.Printf("⚠ Could not fetch organizations: %v\n", err) + + orgNames = []string{} // Empty orgs list + } else { + orgNames = make([]string, len(orgConstructs)) + for i, oc := range orgConstructs { + orgNames[i] = oc.Name + } + + if len(orgNames) > 0 { + cmd.Printf("✓ Organizations: %s\n", strings.Join(orgNames, ", ")) + } else { + cmd.Println(" No organizations found") + } + } + + // Cache the token + cachedToken := &client.CachedToken{ + AccessToken: token.AccessToken, + TokenType: token.TokenType, + Provider: "github", + ExpiresAt: token.ExpiresAt, + User: identity.Username, + UserID: identity.UserID, + Email: identity.Email, + Orgs: orgNames, + CreatedAt: time.Now(), + } + + if err := cache.Save(cachedToken); err != nil { + cmd.Printf("⚠ Could not cache token: %v\n", err) + } else { + cmd.Println("✓ Token cached for future use") + cmd.Printf(" Cache location: %s\n", cache.GetCachePath()) + } + + cmd.Println() + cmd.Println("╔════════════════════════════════════════════════════════════╗") + cmd.Println("║ Authentication Complete! ✓ ║") + cmd.Println("╚════════════════════════════════════════════════════════════╝") + cmd.Println() + cmd.Println("You can now use 'dirctl' commands with --auth-mode=github") + + return nil +} + +// runWebFlow handles the web browser OAuth flow. +func runWebFlow(cmd *cobra.Command, ctx context.Context, cache *client.TokenCache) error { + // Get client ID and secret (from flags, env, or config) + resolvedClientID := getClientID() + resolvedClientSecret := getClientSecret() + + // Validate client ID + if resolvedClientID == "" { + return errors.New("GitHub OAuth App client ID is required for web flow.\n\n" + + "Set via flag: --github-client-id=\n" + + "Or environment: export DIRECTORY_CLIENT_GITHUB_CLIENT_ID=\n\n" + + "To create a GitHub OAuth App:\n" + + " 1. Go to https://github.com/settings/developers\n" + + " 2. Click 'New OAuth App'\n" + + " 3. Set callback URL to: http://localhost:8484/callback\n\n" + + "Or use device flow (no OAuth App needed): dirctl auth login") + } + + // Use default OAuth scopes (user:email and read:org) + scopeList := strings.Split(client.DefaultOAuthScopes, ",") + for i := range scopeList { + scopeList[i] = strings.TrimSpace(scopeList[i]) + } + + // Perform interactive login + result, err := client.InteractiveLogin(ctx, client.OAuthConfig{ + ClientID: resolvedClientID, + ClientSecret: resolvedClientSecret, + Scopes: scopeList, + CallbackPort: callbackPort, + Timeout: timeout, + Output: cmd.OutOrStdout(), + SkipBrowserOpen: skipBrowserOpen, + }) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + cmd.Println("✓ Token obtained successfully") + cmd.Println() + + // Fetch user info and cache token + return fetchUserInfoAndCache(cmd, ctx, TokenMetadata{ + AccessToken: result.AccessToken, + TokenType: result.TokenType, + ExpiresAt: result.ExpiresAt, + }, cache) +} + +// runDeviceFlow handles the device authorization OAuth flow. +func runDeviceFlow(cmd *cobra.Command, ctx context.Context, cache *client.TokenCache) error { + // GitHub's public OAuth App client ID for device flow + // This is GitHub's official CLI client ID (publicly documented) + const githubCLIClientID = "178c6fc778ccc68e1d6a" + + // Use default OAuth scopes (user:email and read:org) + scopeList := strings.Split(client.DefaultOAuthScopes, ",") + for i := range scopeList { + scopeList[i] = strings.TrimSpace(scopeList[i]) + } + + // Start device flow + result, err := client.StartDeviceFlow(ctx, &client.DeviceFlowConfig{ + ClientID: githubCLIClientID, + Scopes: scopeList, + Output: cmd.OutOrStdout(), + }) + if err != nil { + return fmt.Errorf("device authorization failed: %w", err) + } + + cmd.Println("✓ Authorization successful!") + cmd.Println() + + // Fetch user info and cache token + return fetchUserInfoAndCache(cmd, ctx, TokenMetadata{ + AccessToken: result.AccessToken, + TokenType: result.TokenType, + ExpiresAt: result.ExpiresAt, + }, cache) +} diff --git a/cli/cmd/auth/logout.go b/cli/cmd/auth/logout.go new file mode 100644 index 000000000..070292b38 --- /dev/null +++ b/cli/cmd/auth/logout.go @@ -0,0 +1,44 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "fmt" + + "github.com/agntcy/dir/client" + "github.com/spf13/cobra" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Clear cached GitHub authentication", + Long: `Clear cached GitHub authentication credentials. + +This command removes the locally cached OAuth token, effectively logging +you out of the Directory server when using GitHub authentication. + +Examples: + # Logout (clear cached token) + dirctl auth logout`, + RunE: runLogout, +} + +func runLogout(cmd *cobra.Command, _ []string) error { + cache := client.NewTokenCache() + + // Load existing token to show who we're logging out + token, _ := cache.Load() + if token != nil && token.User != "" { + cmd.Printf("Logging out user: %s\n", token.User) + } + + if err := cache.Clear(); err != nil { + return fmt.Errorf("failed to clear cached token: %w", err) + } + + cmd.Println("✓ Logged out successfully") + cmd.Printf(" Removed: %s\n", cache.GetCachePath()) + + return nil +} diff --git a/cli/cmd/auth/status.go b/cli/cmd/auth/status.go new file mode 100644 index 000000000..a57d1b807 --- /dev/null +++ b/cli/cmd/auth/status.go @@ -0,0 +1,116 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/agntcy/dir/auth/authprovider/github" + "github.com/agntcy/dir/client" + "github.com/spf13/cobra" +) + +var validateToken bool + +var statusCmd = &cobra.Command{ + Use: "status", + Short: "Show authentication status", + Long: `Show the current GitHub authentication status. + +This command displays information about the cached OAuth token, +including the authenticated user, organizations, and token validity. + +Examples: + # Show authentication status + dirctl auth status + + # Validate token with GitHub API + dirctl auth status --validate`, + RunE: runStatus, +} + +func init() { + statusCmd.Flags().BoolVar(&validateToken, "validate", false, "Validate token with GitHub API") +} + +func runStatus(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + cache := client.NewTokenCache() + + token, err := cache.Load() + if err != nil { + return fmt.Errorf("failed to read token cache: %w", err) + } + + if token == nil { + cmd.Println("Status: Not authenticated") + cmd.Println() + cmd.Println("Run 'dirctl auth login' to authenticate with GitHub.") + + return nil + } + + cmd.Println("Status: Authenticated") + cmd.Printf(" User: %s\n", token.User) + + if len(token.Orgs) > 0 { + cmd.Printf(" Organizations: %s\n", strings.Join(token.Orgs, ", ")) + } + + cmd.Printf(" Cached at: %s\n", token.CreatedAt.Format(time.RFC3339)) + + // Check token validity and display status + if cache.IsValid(token) { + displayValidToken(cmd, ctx, token) + } else { + displayExpiredToken(cmd) + } + + cmd.Printf(" Cache file: %s\n", cache.GetCachePath()) + + return nil +} + +// displayValidToken shows details for a valid token. +func displayValidToken(cmd *cobra.Command, ctx context.Context, token *client.CachedToken) { + cmd.Println(" Token: Valid ✓") + + if !token.ExpiresAt.IsZero() { + cmd.Printf(" Expires: %s\n", token.ExpiresAt.Format(time.RFC3339)) + } else { + // Show estimated expiry based on default validity duration + estimatedExpiry := token.CreatedAt.Add(client.DefaultTokenValidityDuration) + cmd.Printf(" Estimated expiry: %s\n", estimatedExpiry.Format(time.RFC3339)) + } + + // Validate with GitHub API if requested + if validateToken { + if err := validateWithGitHub(ctx, token.AccessToken); err != nil { + cmd.Printf(" API Validation: Failed ✗ (%v)\n", err) + } else { + cmd.Println(" API Validation: Passed ✓") + } + } +} + +// displayExpiredToken shows message for expired token. +func displayExpiredToken(cmd *cobra.Command) { + cmd.Println(" Token: Expired ✗") + cmd.Println() + cmd.Println("Run 'dirctl auth login' to re-authenticate.") +} + +func validateWithGitHub(ctx context.Context, accessToken string) error { + provider := github.NewProvider(nil) + + _, err := provider.ValidateToken(ctx, accessToken) + if err != nil { + return fmt.Errorf("GitHub API validation failed: %w", err) + } + + return nil +} diff --git a/cli/cmd/options.go b/cli/cmd/options.go index 54e088618..181072dd0 100644 --- a/cli/cmd/options.go +++ b/cli/cmd/options.go @@ -18,14 +18,17 @@ func init() { // set flags flags := RootCmd.PersistentFlags() flags.StringVar(&clientConfig.ServerAddress, "server-addr", clientConfig.ServerAddress, "Directory Server API address") - flags.StringVar(&clientConfig.AuthMode, "auth-mode", clientConfig.AuthMode, "Authentication mode: none, x509, jwt, token, tls") + flags.StringVar(&clientConfig.AuthMode, "auth-mode", clientConfig.AuthMode, "Authentication mode: x509, jwt, token (SPIFFE), tls, github, insecure, none, or empty for auto-detect") + flags.StringVar(&clientConfig.GitHubToken, "github-token", clientConfig.GitHubToken, "GitHub token (PAT or OAuth) for authentication - useful for CI/CD (can also use DIRECTORY_CLIENT_GITHUB_TOKEN env var)") flags.StringVar(&clientConfig.SpiffeSocketPath, "spiffe-socket-path", clientConfig.SpiffeSocketPath, "Path to SPIFFE Workload API socket (for x509 or JWT authentication)") - flags.StringVar(&clientConfig.SpiffeToken, "spiffe-token", clientConfig.SpiffeToken, "Path to file containing SPIFFE X509 SVID token (for token authentication)") + flags.StringVar(&clientConfig.SpiffeToken, "spiffe-token", clientConfig.SpiffeToken, "Path to JSON file containing SPIFFE X509 SVID token (for --auth-mode=token)") flags.StringVar(&clientConfig.JWTAudience, "jwt-audience", clientConfig.JWTAudience, "JWT audience (for JWT authentication mode)") flags.BoolVar(&clientConfig.TlsSkipVerify, "tls-skip-verify", clientConfig.TlsSkipVerify, "Skip TLS verification (for TLS authentication mode)") flags.StringVar(&clientConfig.TlsCAFile, "tls-ca-file", clientConfig.TlsCAFile, "Path to TLS CA file (for TLS authentication mode)") flags.StringVar(&clientConfig.TlsCertFile, "tls-cert-file", clientConfig.TlsCertFile, "Path to TLS certificate file (for TLS authentication mode)") flags.StringVar(&clientConfig.TlsKeyFile, "tls-key-file", clientConfig.TlsKeyFile, "Path to TLS key file (for TLS authentication mode)") + flags.StringVar(&clientConfig.GitHubClientID, "github-client-id", clientConfig.GitHubClientID, "GitHub OAuth App client ID (for github authentication mode)") + flags.StringVar(&clientConfig.GitHubClientSecret, "github-client-secret", clientConfig.GitHubClientSecret, "GitHub OAuth App client secret (for github authentication mode)") // mark required flags RootCmd.MarkFlagRequired("server-addr") //nolint:errcheck diff --git a/cli/cmd/root.go b/cli/cmd/root.go index c85976ab3..2b9ce65ec 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/agntcy/dir/cli/cmd/auth" "github.com/agntcy/dir/cli/cmd/delete" "github.com/agntcy/dir/cli/cmd/events" importcmd "github.com/agntcy/dir/cli/cmd/import" @@ -58,6 +59,8 @@ func init() { network.Command.Hidden = true RootCmd.AddCommand( + // auth commands + auth.Command, // Contains: login, logout, status // local commands version.Command, // initialize.Command, // REMOVED: Initialize functionality diff --git a/cli/go.mod b/cli/go.mod index 765afa3db..4f23046b4 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -6,6 +6,7 @@ replace ( // Cosign does not updated the crypto11 owner github.com/ThalesIgnite/crypto11 => github.com/ThalesGroup/crypto11 v1.6.0 github.com/agntcy/dir/api => ../api + github.com/agntcy/dir/auth/authprovider => ../auth/authprovider github.com/agntcy/dir/client => ../client github.com/agntcy/dir/importer => ../importer github.com/agntcy/dir/mcp => ../mcp @@ -24,6 +25,7 @@ replace ( require ( github.com/agntcy/dir/api v0.6.1 + github.com/agntcy/dir/auth/authprovider v0.0.0 github.com/agntcy/dir/client v0.6.1 github.com/agntcy/dir/importer v0.6.1 github.com/agntcy/dir/mcp v0.6.1 @@ -47,6 +49,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/ThalesIgnite/crypto11 v1.2.5 // indirect github.com/agntcy/oasf-sdk/pkg v0.0.14 // indirect @@ -93,6 +96,7 @@ require ( github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.1.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/eino v0.7.20 // indirect github.com/cloudwego/eino-ext/components/model/claude v0.1.13 // indirect @@ -153,6 +157,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.7 // indirect + github.com/google/go-github/v50 v50.2.0 // indirect github.com/google/go-github/v73 v73.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/cli/go.sum b/cli/go.sum index 17c5b1f04..bbe5173ab 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -45,6 +45,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= @@ -120,6 +122,7 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= @@ -166,6 +169,8 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/eino v0.5.15 h1:mcgtsV7ER2TilDCRr1HSfLKM9g5JZXIonMtz8aUccpo= @@ -354,6 +359,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= @@ -878,6 +885,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/client/client_test.go b/client/client_test.go index 458eae890..e7657d67e 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -30,12 +30,10 @@ const ( testServerInsecureMode = "" // Empty string means insecure // Timeout constants. - testContextTimeout = 5 * time.Second - testContextShortTimeout = 1 * time.Second - testContextVeryShort = 10 * time.Millisecond - testConnectionCloseWait = 50 * time.Millisecond - testCleanupWait = 10 * time.Millisecond - testConnectionStateCheck = 100 * time.Millisecond + testContextTimeout = 5 * time.Second + testContextVeryShort = 10 * time.Millisecond + testConnectionCloseWait = 50 * time.Millisecond + testCleanupWait = 10 * time.Millisecond ) // createTestServer creates a test gRPC server with all required services. diff --git a/client/config.go b/client/config.go index db185e045..36983c73c 100644 --- a/client/config.go +++ b/client/config.go @@ -32,6 +32,15 @@ type Config struct { SpiffeToken string `json:"spiffe_token,omitempty" mapstructure:"spiffe_token"` AuthMode string `json:"auth_mode,omitempty" mapstructure:"auth_mode"` JWTAudience string `json:"jwt_audience,omitempty" mapstructure:"jwt_audience"` + + // OAuth configuration (for browser-based login) + GitHubClientID string `json:"github_client_id,omitempty" mapstructure:"github_client_id"` + GitHubClientSecret string `json:"github_client_secret,omitempty" mapstructure:"github_client_secret"` + + // GitHub token (PAT or OAuth) - can be set via flag/env for CI/CD use + // Developers: use 'dirctl auth login' instead + // CI/CD: set via DIRECTORY_CLIENT_GITHUB_TOKEN env var or --github-token flag + GitHubToken string `json:"github_token,omitempty" mapstructure:"github_token"` } func LoadConfig() (*Config, error) { @@ -71,6 +80,15 @@ func LoadConfig() (*Config, error) { _ = v.BindEnv("tls_ca_file") v.SetDefault("tls_ca_file", "") + _ = v.BindEnv("github_client_id") + v.SetDefault("github_client_id", "") + + _ = v.BindEnv("github_client_secret") + v.SetDefault("github_client_secret", "") + + _ = v.BindEnv("github_token") + v.SetDefault("github_token", "") + // Load configuration into struct decodeHooks := mapstructure.ComposeDecodeHookFunc( mapstructure.TextUnmarshallerHookFunc(), diff --git a/client/github.go b/client/github.go new file mode 100644 index 000000000..c4d8e916c --- /dev/null +++ b/client/github.go @@ -0,0 +1,35 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + + "google.golang.org/grpc/credentials" +) + +// githubPerRPCCredentials implements credentials.PerRPCCredentials for GitHub OAuth2 token authentication. +type githubPerRPCCredentials struct { + token string +} + +// GetRequestMetadata attaches the GitHub OAuth2 token to the request metadata as a Bearer token. +func (c *githubPerRPCCredentials) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{ + "authorization": "Bearer " + c.token, + }, nil +} + +// RequireTransportSecurity returns false because GitHub auth can work over insecure connections +// (the Envoy gateway handles TLS termination externally). +func (c *githubPerRPCCredentials) RequireTransportSecurity() bool { + return false +} + +// newGitHubCredentials creates a new PerRPCCredentials that injects a GitHub OAuth2 token. +func newGitHubCredentials(token string) credentials.PerRPCCredentials { + return &githubPerRPCCredentials{ + token: token, + } +} diff --git a/client/github_test.go b/client/github_test.go new file mode 100644 index 000000000..15df232a5 --- /dev/null +++ b/client/github_test.go @@ -0,0 +1,268 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test constants. +const ( + testGitHubToken = "gho_testtoken123" +) + +func TestNewGitHubCredentials(t *testing.T) { + t.Run("should create credentials with token", func(t *testing.T) { + token := "gho_testtoken123456789" + creds := newGitHubCredentials(token) + + require.NotNil(t, creds) + + // Verify it's the correct type + githubCreds, ok := creds.(*githubPerRPCCredentials) + require.True(t, ok, "credentials should be of type *githubPerRPCCredentials") + assert.Equal(t, token, githubCreds.token) + }) + + t.Run("should create credentials with empty token", func(t *testing.T) { + // Should be allowed - validation happens elsewhere + creds := newGitHubCredentials("") + + require.NotNil(t, creds) + + githubCreds, ok := creds.(*githubPerRPCCredentials) + require.True(t, ok) + assert.Empty(t, githubCreds.token) + }) + + t.Run("should create credentials with PAT token", func(t *testing.T) { + token := "ghp_pattoken123456789" + creds := newGitHubCredentials(token) + + require.NotNil(t, creds) + + githubCreds, ok := creds.(*githubPerRPCCredentials) + require.True(t, ok) + assert.Equal(t, token, githubCreds.token) + }) +} + +func TestGitHubPerRPCCredentials_GetRequestMetadata(t *testing.T) { + t.Run("should add Bearer token to authorization header", func(t *testing.T) { + token := testGitHubToken + creds := &githubPerRPCCredentials{token: token} + + ctx := context.Background() + metadata, err := creds.GetRequestMetadata(ctx) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Contains(t, metadata, "authorization") + assert.Equal(t, "Bearer "+token, metadata["authorization"]) + }) + + t.Run("should work with empty token", func(t *testing.T) { + creds := &githubPerRPCCredentials{token: ""} + + ctx := context.Background() + metadata, err := creds.GetRequestMetadata(ctx) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "Bearer ", metadata["authorization"]) + }) + + t.Run("should work with cancelled context", func(t *testing.T) { + token := testGitHubToken + creds := &githubPerRPCCredentials{token: token} + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Should still work because GetRequestMetadata doesn't use the context + metadata, err := creds.GetRequestMetadata(ctx) + + require.NoError(t, err) + require.NotNil(t, metadata) + assert.Equal(t, "Bearer "+token, metadata["authorization"]) + }) + + t.Run("should work regardless of URI parameter", func(t *testing.T) { + token := testGitHubToken + creds := &githubPerRPCCredentials{token: token} + + ctx := context.Background() + + // Test with no URI + metadata1, err := creds.GetRequestMetadata(ctx) + require.NoError(t, err) + assert.Equal(t, "Bearer "+token, metadata1["authorization"]) + + // Test with one URI + metadata2, err := creds.GetRequestMetadata(ctx, "grpc://example.com/service/method") + require.NoError(t, err) + assert.Equal(t, "Bearer "+token, metadata2["authorization"]) + + // Test with multiple URIs + metadata3, err := creds.GetRequestMetadata(ctx, "uri1", "uri2", "uri3") + require.NoError(t, err) + assert.Equal(t, "Bearer "+token, metadata3["authorization"]) + + // All should return the same metadata + assert.Equal(t, metadata1, metadata2) + assert.Equal(t, metadata1, metadata3) + }) + + t.Run("should create new map for each call", func(t *testing.T) { + token := testGitHubToken + creds := &githubPerRPCCredentials{token: token} + + ctx := context.Background() + metadata1, err1 := creds.GetRequestMetadata(ctx) + metadata2, err2 := creds.GetRequestMetadata(ctx) + + require.NoError(t, err1) + require.NoError(t, err2) + + // Should be equal but different map instances + assert.Equal(t, metadata1, metadata2) + + // Modify one shouldn't affect the other + metadata1["test"] = "value" + + assert.NotContains(t, metadata2, "test") + }) + + t.Run("should handle long tokens", func(t *testing.T) { + // Create a very long token (simulating a JWT or similar) + longToken := "gho_" + string(make([]byte, 10000)) + creds := &githubPerRPCCredentials{token: longToken} + + ctx := context.Background() + metadata, err := creds.GetRequestMetadata(ctx) + + require.NoError(t, err) + assert.Equal(t, "Bearer "+longToken, metadata["authorization"]) + }) +} + +func TestGitHubPerRPCCredentials_RequireTransportSecurity(t *testing.T) { + t.Run("should return false for insecure transport", func(t *testing.T) { + creds := &githubPerRPCCredentials{token: "gho_test"} + + secure := creds.RequireTransportSecurity() + + assert.False(t, secure, "GitHub credentials should work over insecure connections (Envoy handles TLS)") + }) + + t.Run("should return false regardless of token", func(t *testing.T) { + testCases := []struct { + name string + token string + }{ + { + name: "with OAuth token", + token: "gho_oauth123", + }, + { + name: "with PAT token", + token: "ghp_pat123", + }, + { + name: "with empty token", + token: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + creds := &githubPerRPCCredentials{token: tc.token} + assert.False(t, creds.RequireTransportSecurity()) + }) + } + }) +} + +func TestGitHubPerRPCCredentials_Integration(t *testing.T) { + t.Run("should work as PerRPCCredentials interface", func(t *testing.T) { + token := "gho_integration_test" + + // Create via the public constructor + var creds any = newGitHubCredentials(token) + + // Verify it implements the interface + _, ok := creds.(interface { + GetRequestMetadata(ctx context.Context, uri ...string) (map[string]string, error) + RequireTransportSecurity() bool + }) + + assert.True(t, ok, "should implement PerRPCCredentials interface methods") + }) + + t.Run("should be usable in gRPC dial options", func(t *testing.T) { + token := "gho_grpc_test" //nolint:gosec // G101: This is a test token, not a real credential + + creds := newGitHubCredentials(token) + + // Verify we can call the interface methods + ctx := context.Background() + metadata, err := creds.GetRequestMetadata(ctx, "grpc://test") + + require.NoError(t, err) + assert.Equal(t, "Bearer "+token, metadata["authorization"]) + assert.False(t, creds.RequireTransportSecurity()) + }) +} + +func TestGitHubPerRPCCredentials_TokenFormats(t *testing.T) { + t.Run("should handle various GitHub token formats", func(t *testing.T) { + testCases := []struct { + name string + token string + expected string + }{ + { + name: "OAuth token (gho_)", + token: "gho_16C7e42F292c6912E7710c838347Ae178B4a", + expected: "Bearer gho_16C7e42F292c6912E7710c838347Ae178B4a", + }, + { + name: "PAT classic (ghp_)", + token: "ghp_1234567890abcdefghijklmnopqrstuvwxyz", + expected: "Bearer ghp_1234567890abcdefghijklmnopqrstuvwxyz", + }, + { + name: "App installation token (ghs_)", + token: "ghs_16C7e42F292c6912E7710c838347Ae178B4a", + expected: "Bearer ghs_16C7e42F292c6912E7710c838347Ae178B4a", + }, + { + name: "User-to-server token (ghu_)", + token: "ghu_16C7e42F292c6912E7710c838347Ae178B4a", + expected: "Bearer ghu_16C7e42F292c6912E7710c838347Ae178B4a", + }, + { + name: "Server-to-server token (ghs_)", + token: "ghs_16C7e42F292c6912E7710c838347Ae178B4a", + expected: "Bearer ghs_16C7e42F292c6912E7710c838347Ae178B4a", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + creds := &githubPerRPCCredentials{token: tc.token} + + ctx := context.Background() + metadata, err := creds.GetRequestMetadata(ctx) + + require.NoError(t, err) + assert.Equal(t, tc.expected, metadata["authorization"]) + }) + } + }) +} diff --git a/client/go.mod b/client/go.mod index c5feeaf8f..ccc43cddc 100644 --- a/client/go.mod +++ b/client/go.mod @@ -16,6 +16,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/spiffe/go-spiffe/v2 v2.6.0 github.com/stretchr/testify v1.11.1 + golang.org/x/oauth2 v0.34.0 google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.11 ) @@ -156,7 +157,6 @@ require ( golang.org/x/crypto v0.47.0 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.39.0 // indirect diff --git a/client/oauth_device.go b/client/oauth_device.go new file mode 100644 index 000000000..6be7f9d5c --- /dev/null +++ b/client/oauth_device.go @@ -0,0 +1,341 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + // GitHub OAuth2 Device Flow endpoints. + githubDeviceCodeURL = "https://github.com/login/device/code" //nolint:gosec // G101: URL endpoint, not a credential + githubDeviceTokenURL = "https://github.com/login/oauth/access_token" //nolint:gosec // G101: URL endpoint, not a credential + + // Device flow polling configuration. + defaultDeviceInterval = 5 * time.Second + devicePollTimeout = 15 * time.Minute + + // HTTP client timeout for API requests. + httpTimeout = 30 * time.Second + + // HTTP client connection pool settings. + maxIdleConns = 10 + maxIdleConnsPerHost = 2 + idleConnTimeout = 90 * time.Second + + // Time conversion constants. + secondsPerMinute = 60 +) + +// defaultHTTPClient is a shared HTTP client with connection pooling for efficiency. +var defaultHTTPClient = &http.Client{ + Timeout: httpTimeout, + Transport: &http.Transport{ + MaxIdleConns: maxIdleConns, + MaxIdleConnsPerHost: maxIdleConnsPerHost, + IdleConnTimeout: idleConnTimeout, + }, +} + +// DeviceFlowConfig configures the device authorization flow. +type DeviceFlowConfig struct { + ClientID string + Scopes []string + Output io.Writer // Where to write user instructions (default: os.Stdout) +} + +// DeviceCodeResponse is the response from GitHub's device code endpoint. +type DeviceCodeResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` // Minimum seconds between polls +} + +// DeviceTokenResponse is the response from GitHub's token endpoint during polling. +type DeviceTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + Scope string `json:"scope"` + Error string `json:"error,omitempty"` + ErrorDescription string `json:"error_description,omitempty"` + Interval int `json:"interval,omitempty"` // New interval when slow_down is returned +} + +// DeviceFlowResult contains the successful device flow result. +type DeviceFlowResult struct { + AccessToken string + TokenType string + Scope string + ExpiresAt time.Time // Calculated expiry (GitHub doesn't provide expires_in for device flow) +} + +// StartDeviceFlow initiates GitHub OAuth2 device authorization flow. +// This flow is ideal for CLI applications, SSH sessions, and headless environments. +// +// The flow: +// 1. Request device and user codes from GitHub +// 2. Display verification URL and user code to the user +// 3. Poll GitHub until user completes authorization +// 4. Return access token +// +// The user can complete authorization on any device (phone, laptop, etc.). +func StartDeviceFlow(ctx context.Context, config *DeviceFlowConfig) (*DeviceFlowResult, error) { + if config == nil { + return nil, errors.New("config is required") + } + + if config.ClientID == "" { + return nil, errors.New("ClientID is required") + } + + if config.Output == nil { + config.Output = io.Discard + } + + // Step 1: Request device and user codes + deviceCode, err := requestDeviceCode(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to request device code: %w", err) + } + + // Step 2: Display instructions to user + displayDeviceInstructions(config.Output, deviceCode) + + // Step 3: Poll for access token + token, err := pollForDeviceToken(ctx, config, deviceCode) + if err != nil { + return nil, fmt.Errorf("failed to complete device authorization: %w", err) + } + + // GitHub device flow tokens don't include expires_in, but GitHub OAuth tokens + // typically expire after 8 hours. We set a conservative 8-hour expiry. + const githubTokenExpiry = 8 * time.Hour + + return &DeviceFlowResult{ + AccessToken: token.AccessToken, + TokenType: token.TokenType, + Scope: token.Scope, + ExpiresAt: time.Now().Add(githubTokenExpiry), + }, nil +} + +// requestDeviceCode requests device and user codes from GitHub. +func requestDeviceCode(ctx context.Context, config *DeviceFlowConfig) (*DeviceCodeResponse, error) { + data := url.Values{} + data.Set("client_id", config.ClientID) + + if len(config.Scopes) > 0 { + data.Set("scope", strings.Join(config.Scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubDeviceCodeURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + + return nil, fmt.Errorf("GitHub API error (HTTP %d): %s", resp.StatusCode, string(body)) + } + + var deviceResp DeviceCodeResponse + if err := json.NewDecoder(resp.Body).Decode(&deviceResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &deviceResp, nil +} + +// displayDeviceInstructions shows the user how to complete authorization. +func displayDeviceInstructions(w io.Writer, deviceCode *DeviceCodeResponse) { + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, "🔐 To authenticate, please follow these steps:\n") + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, " 1. Visit: %s\n", deviceCode.VerificationURI) + fmt.Fprintf(w, " 2. Enter code: %s\n", deviceCode.UserCode) + fmt.Fprintf(w, "\n") + fmt.Fprintf(w, "💡 You can complete this on any device (phone, laptop, etc.)\n") + fmt.Fprintf(w, "⏱️ Code expires in %d minutes\n", deviceCode.ExpiresIn/secondsPerMinute) + fmt.Fprintf(w, "\n") +} + +// pollForDeviceToken polls GitHub until user completes authorization. +func pollForDeviceToken(ctx context.Context, config *DeviceFlowConfig, deviceCode *DeviceCodeResponse) (*DeviceTokenResponse, error) { + interval := time.Duration(deviceCode.Interval) * time.Second + if interval == 0 { + interval = defaultDeviceInterval + } + + // Create timeout context + pollCtx, cancel := context.WithTimeout(ctx, devicePollTimeout) + defer cancel() + + // Show waiting message + fmt.Fprintf(config.Output, "Waiting for authorization...\n") + + // Poll for token + token, err := pollForToken(pollCtx, config.ClientID, deviceCode.DeviceCode, interval) + if err != nil { + return nil, err + } + + fmt.Fprintf(config.Output, "✓ Authorization complete!\n\n") + + return token, nil +} + +// pollForToken polls GitHub's token endpoint until authorization completes or fails. +func pollForToken(ctx context.Context, clientID, deviceCode string, initialInterval time.Duration) (*DeviceTokenResponse, error) { + ticker := time.NewTicker(initialInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("authorization timed out after %v", devicePollTimeout) + + case <-ticker.C: + tokenResp, err := checkDeviceToken(ctx, clientID, deviceCode) + if err != nil { + // Check if it's a retryable error + if isRetryableDeviceError(err) { + // Adjust polling interval if GitHub tells us to slow down + if adjustedInterval := getAdjustedInterval(err); adjustedInterval > 0 { + ticker.Reset(adjustedInterval) + } + + continue // Keep polling + } + + // Non-retryable error + return nil, err + } + + if tokenResp != nil { + return tokenResp, nil + } + + // This should never happen - no error and no token + return nil, errors.New("unexpected response: no error and no token returned") + } + } +} + +// getAdjustedInterval extracts the new polling interval from a slow_down error. +// Returns 0 if no adjustment is needed. +func getAdjustedInterval(err error) time.Duration { + var deviceErr *DeviceFlowError + if errors.As(err, &deviceErr) && deviceErr.Code == "slow_down" { + if deviceErr.NewInterval > 0 { + return time.Duration(deviceErr.NewInterval) * time.Second + } + } + + return 0 +} + +// checkDeviceToken attempts to exchange device code for access token. +func checkDeviceToken(ctx context.Context, clientID, deviceCode string) (*DeviceTokenResponse, error) { + data := url.Values{} + data.Set("client_id", clientID) + data.Set("device_code", deviceCode) + data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubDeviceTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := defaultHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + var tokenResp DeviceTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Handle OAuth2 error responses + if tokenResp.Error != "" { + return nil, &DeviceFlowError{ + Code: tokenResp.Error, + Description: tokenResp.ErrorDescription, + NewInterval: tokenResp.Interval, + } + } + + // Access token received, authorization complete + if tokenResp.AccessToken != "" { + return &tokenResp, nil + } + + // No error, no token - should not happen + return nil, errors.New("unexpected empty response from GitHub") +} + +// DeviceFlowError represents an OAuth2 device flow error. +type DeviceFlowError struct { + Code string + Description string + NewInterval int // New polling interval (for slow_down errors) +} + +// Error implements the error interface. +func (e *DeviceFlowError) Error() string { + if e.Description != "" { + return fmt.Sprintf("%s: %s", e.Code, e.Description) + } + + return e.Code +} + +// isRetryableDeviceError checks if the error is expected during polling. +func isRetryableDeviceError(err error) bool { + var deviceErr *DeviceFlowError + if !errors.As(err, &deviceErr) { + return false + } + + switch deviceErr.Code { + case "authorization_pending": + // User hasn't completed authorization yet - keep polling + return true + case "slow_down": + // We're polling too fast - keep polling but will use longer interval + return true + case "expired_token": + // Device code expired - stop polling + return false + case "access_denied": + // User declined authorization - stop polling + return false + default: + return false + } +} diff --git a/client/oauth_device_test.go b/client/oauth_device_test.go new file mode 100644 index 000000000..bc3f43b43 --- /dev/null +++ b/client/oauth_device_test.go @@ -0,0 +1,395 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDeviceFlowError_Error(t *testing.T) { + t.Run("should format error with description", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "authorization_pending", + Description: "The authorization request is pending", + } + + result := err.Error() + + assert.Equal(t, "authorization_pending: The authorization request is pending", result) + }) + + t.Run("should format error without description", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "expired_token", + } + + result := err.Error() + + assert.Equal(t, "expired_token", result) + }) + + t.Run("should handle empty description", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "slow_down", + Description: "", + } + + result := err.Error() + + assert.Equal(t, "slow_down", result) + }) +} + +func TestIsRetryableDeviceError(t *testing.T) { + t.Run("should return true for authorization_pending", func(t *testing.T) { + err := &DeviceFlowError{Code: "authorization_pending"} + + result := isRetryableDeviceError(err) + + assert.True(t, result) + }) + + t.Run("should return true for slow_down", func(t *testing.T) { + err := &DeviceFlowError{Code: "slow_down"} + + result := isRetryableDeviceError(err) + + assert.True(t, result) + }) + + t.Run("should return false for expired_token", func(t *testing.T) { + err := &DeviceFlowError{Code: "expired_token"} + + result := isRetryableDeviceError(err) + + assert.False(t, result) + }) + + t.Run("should return false for access_denied", func(t *testing.T) { + err := &DeviceFlowError{Code: "access_denied"} + + result := isRetryableDeviceError(err) + + assert.False(t, result) + }) + + t.Run("should return false for unknown error code", func(t *testing.T) { + err := &DeviceFlowError{Code: "unknown_error"} + + result := isRetryableDeviceError(err) + + assert.False(t, result) + }) + + t.Run("should return false for non-DeviceFlowError", func(t *testing.T) { + err := errors.New("some other error") + + result := isRetryableDeviceError(err) + + assert.False(t, result) + }) + + t.Run("should return false for nil error", func(t *testing.T) { + result := isRetryableDeviceError(nil) + + assert.False(t, result) + }) +} + +func TestGetAdjustedInterval(t *testing.T) { + t.Run("should return adjusted interval for slow_down error", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "slow_down", + NewInterval: 10, + } + + interval := getAdjustedInterval(err) + + assert.Equal(t, 10*time.Second, interval) + }) + + t.Run("should return 0 for slow_down without NewInterval", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "slow_down", + NewInterval: 0, + } + + interval := getAdjustedInterval(err) + + assert.Equal(t, time.Duration(0), interval) + }) + + t.Run("should return 0 for non-slow_down error", func(t *testing.T) { + err := &DeviceFlowError{ + Code: "authorization_pending", + NewInterval: 10, + } + + interval := getAdjustedInterval(err) + + assert.Equal(t, time.Duration(0), interval) + }) + + t.Run("should return 0 for non-DeviceFlowError", func(t *testing.T) { + err := errors.New("some other error") + + interval := getAdjustedInterval(err) + + assert.Equal(t, time.Duration(0), interval) + }) + + t.Run("should return 0 for nil error", func(t *testing.T) { + interval := getAdjustedInterval(nil) + + assert.Equal(t, time.Duration(0), interval) + }) +} + +func TestDisplayDeviceInstructions(t *testing.T) { + t.Run("should display complete instructions", func(t *testing.T) { + var buf bytes.Buffer + + deviceCode := &DeviceCodeResponse{ + VerificationURI: "https://github.com/login/device", + UserCode: "ABCD-1234", + ExpiresIn: 900, // 15 minutes + } + + displayDeviceInstructions(&buf, deviceCode) + + output := buf.String() + + assert.Contains(t, output, "🔐 To authenticate, please follow these steps:") + assert.Contains(t, output, "https://github.com/login/device") + assert.Contains(t, output, "ABCD-1234") + assert.Contains(t, output, "Code expires in 15 minutes") + }) + + t.Run("should handle zero expiry time", func(t *testing.T) { + var buf bytes.Buffer + + deviceCode := &DeviceCodeResponse{ + VerificationURI: "https://github.com/login/device", + UserCode: "TEST-CODE", + ExpiresIn: 0, + } + + displayDeviceInstructions(&buf, deviceCode) + + output := buf.String() + + assert.Contains(t, output, "Code expires in 0 minutes") + }) + + t.Run("should write to any io.Writer", func(t *testing.T) { + // Test with different writer implementations + var buf bytes.Buffer + + deviceCode := &DeviceCodeResponse{ + VerificationURI: "https://example.com", + UserCode: "CODE", + ExpiresIn: 300, + } + + displayDeviceInstructions(&buf, deviceCode) + + assert.NotEmpty(t, buf.String()) + }) + + t.Run("should handle io.Discard", func(t *testing.T) { + deviceCode := &DeviceCodeResponse{ + VerificationURI: "https://github.com/login/device", + UserCode: "CODE", + ExpiresIn: 300, + } + + // Should not panic + displayDeviceInstructions(io.Discard, deviceCode) + }) +} + +func TestStartDeviceFlow_Validation(t *testing.T) { + t.Run("should error when config is nil", func(t *testing.T) { + ctx := context.Background() + + result, err := StartDeviceFlow(ctx, nil) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "config is required") + }) + + t.Run("should error when ClientID is empty", func(t *testing.T) { + ctx := context.Background() + + config := &DeviceFlowConfig{ + ClientID: "", + } + + result, err := StartDeviceFlow(ctx, config) + + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "ClientID is required") + }) + + t.Run("should set Output to Discard if nil", func(t *testing.T) { + // We can't easily test the full flow without HTTP, but we can + // verify the validation passes and fails at the HTTP stage + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately to fail fast + + config := &DeviceFlowConfig{ + ClientID: "test-client-id", + Output: nil, // Should be set to Discard + } + + // This will fail due to cancelled context, but that's expected + _, err := StartDeviceFlow(ctx, config) + + // Should have progressed past validation + require.Error(t, err) + // Error should be from HTTP request, not from validation + assert.NotContains(t, err.Error(), "config is required") + assert.NotContains(t, err.Error(), "ClientID is required") + }) +} + +func TestDeviceCodeResponse_Structure(t *testing.T) { + t.Run("should have correct JSON tags", func(t *testing.T) { + resp := DeviceCodeResponse{ + DeviceCode: "device123", + UserCode: "ABCD-1234", + VerificationURI: "https://github.com/login/device", + ExpiresIn: 900, + Interval: 5, + } + + // Verify all fields are set + assert.NotEmpty(t, resp.DeviceCode) + assert.NotEmpty(t, resp.UserCode) + assert.NotEmpty(t, resp.VerificationURI) + assert.NotZero(t, resp.ExpiresIn) + assert.NotZero(t, resp.Interval) + }) +} + +func TestDeviceTokenResponse_Structure(t *testing.T) { + t.Run("should handle success response", func(t *testing.T) { + resp := DeviceTokenResponse{ + AccessToken: "gho_token123", + TokenType: "bearer", + Scope: "read:user read:org", + } + + assert.NotEmpty(t, resp.AccessToken) + assert.Equal(t, "bearer", resp.TokenType) + assert.NotEmpty(t, resp.Scope) + assert.Empty(t, resp.Error) + }) + + t.Run("should handle error response", func(t *testing.T) { + resp := DeviceTokenResponse{ + Error: "authorization_pending", + ErrorDescription: "The authorization request is still pending", + } + + assert.Empty(t, resp.AccessToken) + assert.NotEmpty(t, resp.Error) + assert.NotEmpty(t, resp.ErrorDescription) + }) + + t.Run("should handle slow_down response with new interval", func(t *testing.T) { + resp := DeviceTokenResponse{ + Error: "slow_down", + Interval: 10, + } + + assert.Equal(t, "slow_down", resp.Error) + assert.Equal(t, 10, resp.Interval) + }) +} + +func TestDeviceFlowResult_Structure(t *testing.T) { + t.Run("should contain all required fields", func(t *testing.T) { + now := time.Now() + + result := DeviceFlowResult{ + AccessToken: "gho_token123", + TokenType: "bearer", + Scope: "read:user read:org", + ExpiresAt: now.Add(8 * time.Hour), + } + + assert.NotEmpty(t, result.AccessToken) + assert.Equal(t, "bearer", result.TokenType) + assert.NotEmpty(t, result.Scope) + assert.True(t, result.ExpiresAt.After(now)) + }) +} + +func TestDeviceFlowConfig_Structure(t *testing.T) { + t.Run("should accept valid configuration", func(t *testing.T) { + var buf bytes.Buffer + + config := DeviceFlowConfig{ + ClientID: "test-client-id", + Scopes: []string{"read:user", "read:org"}, + Output: &buf, + } + + assert.Equal(t, "test-client-id", config.ClientID) + assert.Len(t, config.Scopes, 2) + assert.NotNil(t, config.Output) + }) + + t.Run("should handle empty scopes", func(t *testing.T) { + config := DeviceFlowConfig{ + Scopes: []string{}, + } + + assert.Empty(t, config.Scopes) + }) + + t.Run("should handle nil output", func(t *testing.T) { + config := DeviceFlowConfig{ + Output: nil, + } + + assert.Nil(t, config.Output) + }) +} + +func TestDeviceFlowConstants(t *testing.T) { + t.Run("should have reasonable default values", func(t *testing.T) { + assert.Equal(t, 5*time.Second, defaultDeviceInterval) + assert.Equal(t, 15*time.Minute, devicePollTimeout) + assert.Equal(t, 30*time.Second, httpTimeout) + assert.Equal(t, 10, maxIdleConns) + assert.Equal(t, 2, maxIdleConnsPerHost) + assert.Equal(t, 90*time.Second, idleConnTimeout) + assert.Equal(t, 60, secondsPerMinute) + }) + + t.Run("should have valid GitHub endpoints", func(t *testing.T) { + assert.Contains(t, githubDeviceCodeURL, "github.com") + assert.Contains(t, githubDeviceTokenURL, "github.com") + }) +} + +func TestDefaultHTTPClient(t *testing.T) { + t.Run("should be configured properly", func(t *testing.T) { + assert.NotNil(t, defaultHTTPClient) + assert.Equal(t, httpTimeout, defaultHTTPClient.Timeout) + assert.NotNil(t, defaultHTTPClient.Transport) + }) +} diff --git a/client/oauth_web.go b/client/oauth_web.go new file mode 100644 index 000000000..03f5ab3ce --- /dev/null +++ b/client/oauth_web.go @@ -0,0 +1,442 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +const ( + // DefaultCallbackPort is the default port for the OAuth callback server. + DefaultCallbackPort = 8484 + + // DefaultOAuthTimeout is the default timeout for the OAuth flow. + DefaultOAuthTimeout = 5 * time.Minute + + // DefaultOAuthScopes are the default OAuth scopes to request. + DefaultOAuthScopes = "user:email,read:org" + + // oauthStateBytes is the number of random bytes for OAuth state. + oauthStateBytes = 32 + + // serverShutdownTimeout is the timeout for graceful server shutdown. + serverShutdownTimeout = 5 * time.Second + + // Server timeouts for security. + serverReadHeaderTimeout = 10 * time.Second + serverReadTimeout = 30 * time.Second + serverWriteTimeout = 30 * time.Second + serverIdleTimeout = 60 * time.Second + + // browserStartCheckDelay is how long to wait to verify browser command started successfully. + browserStartCheckDelay = 500 * time.Millisecond + + // githubWebFlowTokenExpiry is the default expiry for GitHub web flow tokens. + // GitHub doesn't provide expires_in for web flow tokens (they're long-lived), + // but we set a conservative 8-hour expiry for consistency with device flow. + githubWebFlowTokenExpiry = 8 * time.Hour +) + +// OAuthConfig holds the configuration for GitHub OAuth. +type OAuthConfig struct { + // ClientID is the GitHub OAuth App client ID. + ClientID string + + // ClientSecret is the GitHub OAuth App client secret. + // This can be empty for public clients using PKCE. + ClientSecret string + + // Scopes are the OAuth scopes to request. + // Default: ["user:email", "read:org"] + Scopes []string + + // CallbackPort is the port for the local callback server. + // Default: 8484 + CallbackPort int + + // Timeout is the maximum time to wait for the OAuth flow. + // Default: 5 minutes + Timeout time.Duration + + // SkipBrowserOpen skips automatically opening the browser. + // Useful for headless environments or testing. + SkipBrowserOpen bool + + // Output is where to write status messages. + // Default: os.Stdout + Output io.Writer +} + +// OAuthTokenResult holds the result of a successful OAuth flow. +type OAuthTokenResult struct { + // AccessToken is the GitHub access token. + AccessToken string + + // TokenType is the token type (usually "bearer"). + TokenType string + + // ExpiresAt is when the token expires (if available). + ExpiresAt time.Time + + // Scopes are the granted scopes. + Scopes []string +} + +// InteractiveLogin performs an interactive browser-based GitHub OAuth login. +// It opens the user's browser to GitHub's authorization page, starts a local +// HTTP server to receive the callback, and exchanges the code for an access token. +func InteractiveLogin(ctx context.Context, cfg OAuthConfig) (*OAuthTokenResult, error) { + // Apply defaults + cfg = applyOAuthDefaults(cfg) + + // Create OAuth2 config + oauthCfg := &oauth2.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + Scopes: cfg.Scopes, + Endpoint: github.Endpoint, + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", cfg.CallbackPort), + } + + // Generate random state for CSRF protection + state, err := generateOAuthState() + if err != nil { + return nil, fmt.Errorf("failed to generate state: %w", err) + } + + // Create context with timeout + ctx, cancel := context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + + // Start callback server + server, listener, codeChan, errChan, err := startCallbackServer(ctx, cfg.CallbackPort, state) + if err != nil { + return nil, err + } + + defer shutdownServer(ctx, server) + + // Get actual port and build auth URL + actualPort, err := getActualPort(listener) + if err != nil { + return nil, fmt.Errorf("failed to get listener port: %w", err) + } + + if actualPort != cfg.CallbackPort { + oauthCfg.RedirectURL = fmt.Sprintf("http://localhost:%d/callback", actualPort) + } + + authURL := oauthCfg.AuthCodeURL(state, oauth2.AccessTypeOnline) + + // Prompt user to authenticate + promptUserAuthentication(ctx, cfg, authURL) + + // Wait for callback and exchange code for token + return waitForAuthenticationAndExchange(ctx, cfg, oauthCfg, codeChan, errChan) +} + +// applyOAuthDefaults applies default values to the OAuth config. +func applyOAuthDefaults(cfg OAuthConfig) OAuthConfig { + if cfg.CallbackPort == 0 { + cfg.CallbackPort = DefaultCallbackPort + } + + if cfg.Timeout == 0 { + cfg.Timeout = DefaultOAuthTimeout + } + + if len(cfg.Scopes) == 0 { + cfg.Scopes = strings.Split(DefaultOAuthScopes, ",") + } + + if cfg.Output == nil { + cfg.Output = os.Stdout + } + + return cfg +} + +// getActualPort extracts the port from the listener address. +func getActualPort(listener net.Listener) (int, error) { + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + return 0, fmt.Errorf("unexpected listener address type: %T", listener.Addr()) + } + + return addr.Port, nil +} + +// startCallbackServer starts the OAuth callback HTTP server. +func startCallbackServer( + ctx context.Context, + port int, + state string, +) (*http.Server, net.Listener, chan string, chan error, error) { + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Use context-aware listener + listenConfig := net.ListenConfig{} + + listener, err := listenConfig.Listen(ctx, "tcp", fmt.Sprintf(":%d", port)) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to start callback server on port %d: %w", port, err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/callback", createCallbackHandler(state, codeChan, errChan)) + + server := &http.Server{ + Handler: mux, + ReadHeaderTimeout: serverReadHeaderTimeout, + ReadTimeout: serverReadTimeout, + WriteTimeout: serverWriteTimeout, + IdleTimeout: serverIdleTimeout, + } + + go func() { + if err := server.Serve(listener); err != http.ErrServerClosed { + errChan <- fmt.Errorf("callback server error: %w", err) + } + }() + + return server, listener, codeChan, errChan, nil +} + +// createCallbackHandler creates the HTTP handler for the OAuth callback. +func createCallbackHandler(state string, codeChan chan string, errChan chan error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Verify state + if r.URL.Query().Get("state") != state { + errChan <- errors.New("invalid state parameter (possible CSRF attack)") + + http.Error(w, "Invalid state parameter", http.StatusBadRequest) + + return + } + + // Check for error from GitHub + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + errDesc := r.URL.Query().Get("error_description") + errChan <- fmt.Errorf("GitHub OAuth error: %s - %s", errMsg, errDesc) + + http.Error(w, "OAuth error: "+errDesc, http.StatusBadRequest) + + return + } + + // Get authorization code + code := r.URL.Query().Get("code") + if code == "" { + errChan <- errors.New("no authorization code received") + + http.Error(w, "No authorization code", http.StatusBadRequest) + + return + } + + // Send code to channel + codeChan <- code + + // Show success page + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, oauthSuccessPage) + } +} + +// shutdownServer gracefully shuts down the HTTP server. +func shutdownServer(parentCtx context.Context, server *http.Server) { + shutdownCtx, shutdownCancel := context.WithTimeout(parentCtx, serverShutdownTimeout) + defer shutdownCancel() + + _ = server.Shutdown(shutdownCtx) +} + +// promptUserAuthentication displays instructions and opens the browser. +func promptUserAuthentication(ctx context.Context, cfg OAuthConfig, authURL string) { + out := cfg.Output + + if !cfg.SkipBrowserOpen { + fmt.Fprintln(out) + fmt.Fprintln(out, "🔐 Opening browser for GitHub authentication...") + fmt.Fprintln(out) + + if err := openBrowser(ctx, authURL); err != nil { + fmt.Fprintf(out, "⚠️ Could not open browser automatically.\n") + } + } + + fmt.Fprintf(out, "If the browser doesn't open, visit this URL:\n") + fmt.Fprintf(out, "\n %s\n\n", authURL) + fmt.Fprintf(out, "Waiting for authentication (timeout: %s)...\n", cfg.Timeout) +} + +// waitForAuthenticationAndExchange waits for the OAuth callback and exchanges the code for a token. +func waitForAuthenticationAndExchange( + ctx context.Context, + cfg OAuthConfig, + oauthCfg *oauth2.Config, + codeChan chan string, + errChan chan error, +) (*OAuthTokenResult, error) { + out := cfg.Output + + select { + case code := <-codeChan: + fmt.Fprintln(out, "✓ Received authorization code") + fmt.Fprintln(out, "Exchanging code for access token...") + + token, err := oauthCfg.Exchange(ctx, code) + if err != nil { + return nil, formatTokenExchangeError(err) + } + + result := &OAuthTokenResult{ + AccessToken: token.AccessToken, + TokenType: token.TokenType, + } + + // Set expiry: use GitHub's provided expiry if available, + // otherwise use our conservative 8-hour default + if !token.Expiry.IsZero() { + result.ExpiresAt = token.Expiry + } else { + result.ExpiresAt = time.Now().Add(githubWebFlowTokenExpiry) + } + + return result, nil + + case err := <-errChan: + return nil, err + + case <-ctx.Done(): + return nil, fmt.Errorf("authentication timed out after %s", cfg.Timeout) + } +} + +// formatTokenExchangeError formats the error from token exchange with helpful context. +func formatTokenExchangeError(err error) error { + errStr := err.Error() + if strings.Contains(errStr, "incorrect_client_credentials") { + return fmt.Errorf("GitHub rejected the credentials.\n\n"+ + "This usually means the client secret is missing or incorrect.\n"+ + "Make sure you've set DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET:\n\n"+ + " export DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET=\"your-client-secret\"\n\n"+ + "To get your client secret:\n"+ + " 1. Go to https://github.com/settings/developers\n"+ + " 2. Click on your OAuth App\n"+ + " 3. Generate a new client secret\n\n"+ + "Original error: %w", err) + } + + return fmt.Errorf("failed to exchange code for token: %w", err) +} + +// generateOAuthState generates a cryptographically random state string. +func generateOAuthState() (string, error) { + b := make([]byte, oauthStateBytes) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + return base64.URLEncoding.EncodeToString(b), nil +} + +// openBrowser opens the specified URL in the default browser. +func openBrowser(ctx context.Context, url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "darwin": + cmd = exec.CommandContext(ctx, "open", url) + case "linux": + cmd = exec.CommandContext(ctx, "xdg-open", url) + case "windows": + cmd = exec.CommandContext(ctx, "rundll32", "url.dll,FileProtocolHandler", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + // Start the command asynchronously (don't wait for browser to close) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start browser command: %w", err) + } + + // Wait briefly to catch immediate failures (e.g., command not found) + // Use a channel to avoid blocking indefinitely + done := make(chan error, 1) + + go func() { + done <- cmd.Wait() + }() + + select { + case <-time.After(browserStartCheckDelay): + // Command is still running, assume success + return nil + case err := <-done: + // Command exited quickly - check if it was an error + if err != nil { + return fmt.Errorf("browser command failed: %w", err) + } + // Quick exit with no error might be normal for some systems + return nil + } +} + +// oauthSuccessPage is the HTML page shown after successful OAuth authentication. +const oauthSuccessPage = ` + + + Authentication Successful + + + +
+
+

Authentication Successful!

+

You can close this window and return to the terminal.

+

dirctl is now authenticated with GitHub.

+
+ +` diff --git a/client/oauth_web_test.go b/client/oauth_web_test.go new file mode 100644 index 000000000..328364a55 --- /dev/null +++ b/client/oauth_web_test.go @@ -0,0 +1,332 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "context" + "encoding/base64" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyOAuthDefaults(t *testing.T) { + t.Run("should apply all defaults when config is empty", func(t *testing.T) { + cfg := OAuthConfig{} + + result := applyOAuthDefaults(cfg) + + assert.Equal(t, DefaultCallbackPort, result.CallbackPort) + assert.Equal(t, DefaultOAuthTimeout, result.Timeout) + assert.Len(t, result.Scopes, 2) + assert.Contains(t, result.Scopes, "user:email") + assert.Contains(t, result.Scopes, "read:org") + assert.NotNil(t, result.Output) + }) + + t.Run("should preserve non-zero CallbackPort", func(t *testing.T) { + cfg := OAuthConfig{ + CallbackPort: 9090, + } + + result := applyOAuthDefaults(cfg) + + assert.Equal(t, 9090, result.CallbackPort) + }) + + t.Run("should preserve non-zero Timeout", func(t *testing.T) { + cfg := OAuthConfig{ + Timeout: 10 * time.Minute, + } + + result := applyOAuthDefaults(cfg) + + assert.Equal(t, 10*time.Minute, result.Timeout) + }) + + t.Run("should preserve existing Scopes", func(t *testing.T) { + customScopes := []string{"repo", "admin:org"} + + cfg := OAuthConfig{ + Scopes: customScopes, + } + + result := applyOAuthDefaults(cfg) + + assert.Equal(t, customScopes, result.Scopes) + }) + + t.Run("should not override existing Output", func(t *testing.T) { + // Can't easily test the exact writer, but can verify it doesn't change + cfg := OAuthConfig{ + Output: nil, + } + + result := applyOAuthDefaults(cfg) + + // Should be set to os.Stdout (not nil) + assert.NotNil(t, result.Output) + }) + + t.Run("should handle all custom values", func(t *testing.T) { + cfg := OAuthConfig{ + ClientID: "custom-id", + ClientSecret: "custom-secret", + CallbackPort: 7777, + Timeout: 3 * time.Minute, + Scopes: []string{"custom:scope"}, + } + + result := applyOAuthDefaults(cfg) + + assert.Equal(t, "custom-id", result.ClientID) + assert.Equal(t, "custom-secret", result.ClientSecret) + assert.Equal(t, 7777, result.CallbackPort) + assert.Equal(t, 3*time.Minute, result.Timeout) + assert.Equal(t, []string{"custom:scope"}, result.Scopes) + }) +} + +func TestGetActualPort(t *testing.T) { + t.Run("should extract port from TCP listener", func(t *testing.T) { + // Create a real TCP listener using ListenConfig + ctx := context.Background() + + lc := net.ListenConfig{} + + listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + require.NoError(t, err) + + defer listener.Close() + + port, extractErr := getActualPort(listener) + + require.NoError(t, extractErr) + assert.Positive(t, port) + assert.Less(t, port, 65536) + }) + + t.Run("should handle specific port", func(t *testing.T) { + // Try to bind to a specific port (may fail if port is in use) + ctx := context.Background() + + lc := net.ListenConfig{} + + listener, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + require.NoError(t, err) + + defer listener.Close() + + port, extractErr := getActualPort(listener) + + require.NoError(t, extractErr) + assert.Positive(t, port) + }) +} + +func TestGenerateOAuthState(t *testing.T) { + t.Run("should generate non-empty state", func(t *testing.T) { + state, err := generateOAuthState() + + require.NoError(t, err) + assert.NotEmpty(t, state) + }) + + t.Run("should generate URL-safe base64", func(t *testing.T) { + state, err := generateOAuthState() + + require.NoError(t, err) + + // Should be valid base64 URL encoding + _, decodeErr := base64.URLEncoding.DecodeString(state) + assert.NoError(t, decodeErr) + }) + + t.Run("should generate unique states", func(t *testing.T) { + state1, err1 := generateOAuthState() + state2, err2 := generateOAuthState() + + require.NoError(t, err1) + require.NoError(t, err2) + + // Should be different + assert.NotEqual(t, state1, state2) + }) + + t.Run("should generate sufficient length", func(t *testing.T) { + state, err := generateOAuthState() + + require.NoError(t, err) + + // Base64 encoding of 32 bytes should be ~43 characters + assert.Greater(t, len(state), 40) + }) + + t.Run("should generate consistent length", func(t *testing.T) { + states := make([]string, 10) + + for i := range 10 { + state, err := generateOAuthState() + require.NoError(t, err) + + states[i] = state + } + + // All should have the same length + firstLen := len(states[0]) + for _, state := range states { + assert.Len(t, state, firstLen) + } + }) +} + +func TestFormatTokenExchangeError(t *testing.T) { + t.Run("should format incorrect_client_credentials error", func(t *testing.T) { + originalErr := errors.New("oauth2: cannot fetch token: 400 Bad Request\nResponse: {\"error\":\"incorrect_client_credentials\"}") + + formattedErr := formatTokenExchangeError(originalErr) + + errMsg := formattedErr.Error() + + assert.Contains(t, errMsg, "GitHub rejected the credentials") + assert.Contains(t, errMsg, "client secret is missing or incorrect") + assert.Contains(t, errMsg, "DIRECTORY_CLIENT_GITHUB_CLIENT_SECRET") + assert.Contains(t, errMsg, "https://github.com/settings/developers") + }) + + t.Run("should format generic error", func(t *testing.T) { + originalErr := errors.New("some network error") + + formattedErr := formatTokenExchangeError(originalErr) + + errMsg := formattedErr.Error() + + assert.Contains(t, errMsg, "failed to exchange code for token") + assert.Contains(t, errMsg, "some network error") + }) + + t.Run("should preserve wrapped error", func(t *testing.T) { + originalErr := errors.New("connection timeout") + + formattedErr := formatTokenExchangeError(originalErr) + + // Should be able to unwrap + assert.ErrorContains(t, formattedErr, "connection timeout") + }) +} + +func TestOAuthConfig_Structure(t *testing.T) { + t.Run("should accept valid configuration", func(t *testing.T) { + cfg := OAuthConfig{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + Scopes: []string{"user:email", "read:org"}, + CallbackPort: 8484, + Timeout: 5 * time.Minute, + SkipBrowserOpen: false, + } + + assert.Equal(t, "test-client-id", cfg.ClientID) + assert.Equal(t, "test-secret", cfg.ClientSecret) + assert.Len(t, cfg.Scopes, 2) + assert.Equal(t, 8484, cfg.CallbackPort) + assert.Equal(t, 5*time.Minute, cfg.Timeout) + assert.False(t, cfg.SkipBrowserOpen) + }) + + t.Run("should handle empty ClientSecret for PKCE", func(t *testing.T) { + cfg := OAuthConfig{ + ClientID: "public-client-id", + ClientSecret: "", // Empty for public clients + } + + assert.NotEmpty(t, cfg.ClientID) + assert.Empty(t, cfg.ClientSecret) + }) + + t.Run("should handle SkipBrowserOpen flag", func(t *testing.T) { + cfg := OAuthConfig{ + SkipBrowserOpen: true, + } + + assert.True(t, cfg.SkipBrowserOpen) + }) +} + +func TestOAuthTokenResult_Structure(t *testing.T) { + t.Run("should contain all fields", func(t *testing.T) { + now := time.Now() + + result := OAuthTokenResult{ + AccessToken: "gho_token123", + TokenType: "bearer", + ExpiresAt: now.Add(time.Hour), + Scopes: []string{"user:email", "read:org"}, + } + + assert.NotEmpty(t, result.AccessToken) + assert.Equal(t, "bearer", result.TokenType) + assert.True(t, result.ExpiresAt.After(now)) + assert.Len(t, result.Scopes, 2) + }) + + t.Run("should handle zero expiry", func(t *testing.T) { + result := OAuthTokenResult{ + ExpiresAt: time.Time{}, // Zero time + } + + assert.True(t, result.ExpiresAt.IsZero()) + }) + + t.Run("should handle empty scopes", func(t *testing.T) { + result := OAuthTokenResult{ + Scopes: []string{}, + } + + assert.Empty(t, result.Scopes) + }) +} + +func TestOAuthConstants(t *testing.T) { + t.Run("should have reasonable default values", func(t *testing.T) { + assert.Equal(t, 8484, DefaultCallbackPort) + assert.Equal(t, 5*time.Minute, DefaultOAuthTimeout) + assert.Equal(t, "user:email,read:org", DefaultOAuthScopes) + assert.Equal(t, 32, oauthStateBytes) + assert.Equal(t, 5*time.Second, serverShutdownTimeout) + assert.Equal(t, 10*time.Second, serverReadHeaderTimeout) + assert.Equal(t, 30*time.Second, serverReadTimeout) + assert.Equal(t, 30*time.Second, serverWriteTimeout) + assert.Equal(t, 60*time.Second, serverIdleTimeout) + assert.Equal(t, 500*time.Millisecond, browserStartCheckDelay) + assert.Equal(t, 8*time.Hour, githubWebFlowTokenExpiry) + }) +} + +func TestOAuthSuccessPage(t *testing.T) { + t.Run("should contain success message", func(t *testing.T) { + assert.Contains(t, oauthSuccessPage, "Authentication Successful") + assert.Contains(t, oauthSuccessPage, "close this window") + assert.Contains(t, oauthSuccessPage, "dirctl") + }) + + t.Run("should be valid HTML", func(t *testing.T) { + assert.Contains(t, oauthSuccessPage, "") + assert.Contains(t, oauthSuccessPage, "") + assert.Contains(t, oauthSuccessPage, "") + }) + + t.Run("should have styling", func(t *testing.T) { + assert.Contains(t, oauthSuccessPage, "") + }) + + t.Run("should have checkmark", func(t *testing.T) { + assert.Contains(t, oauthSuccessPage, "✓") + }) +} diff --git a/client/options.go b/client/options.go index ef72a55a5..168135f68 100644 --- a/client/options.go +++ b/client/options.go @@ -78,18 +78,78 @@ func withAuth(ctx context.Context) Option { return o.setupSpiffeAuth(ctx) case "tls": return o.setupTlsAuth(ctx) - case "": - // Empty auth mode - use insecure connection (for development/testing only) - o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) - - return nil + case "github": + // Explicit GitHub auth mode - use cached credentials or fail + return o.setupGitHubAuth(ctx) + case "insecure", "none", "": + // Insecure/none/empty auth mode - try auto-detection first, fallback to insecure + return o.setupAutoDetectAuth(ctx) default: // Invalid auth mode specified - return error to prevent silent security issues - return fmt.Errorf("unsupported auth mode: %s (supported: 'jwt', 'x509', 'token', or empty for insecure)", o.config.AuthMode) + return fmt.Errorf("unsupported auth mode: %s (supported: 'jwt', 'x509', 'token', 'tls', 'github', 'insecure', 'none', or empty for auto-detect)", o.config.AuthMode) } } } +// setupAutoDetectAuth attempts to auto-detect available credentials and falls back to insecure if none found. +// This is used when auth mode is empty, "insecure", or "none". +func (o *options) setupAutoDetectAuth(_ context.Context) error { + // For explicit "insecure" or "none" mode, skip auto-detection + if o.config.AuthMode == "insecure" || o.config.AuthMode == "none" { + authLogger.Debug("Using insecure connection (explicit mode)", "auth_mode", o.config.AuthMode) + o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + + return nil + } + + // Empty auth mode - auto-detect based on available credentials + var token string + + // 1. Check if token is provided via config/flag/env + if o.config.GitHubToken != "" { + authLogger.Debug("Auto-detected token from config/environment") + + token = o.config.GitHubToken + } else { + // 2. Check for cached GitHub OAuth token + cache := NewTokenCache() + + cachedToken, err := cache.GetValidToken() + if err != nil { + authLogger.Debug("Error loading cached GitHub token, falling back to insecure", "error", err) + } + + if cachedToken != nil { + authLogger.Debug("Auto-detected cached GitHub OAuth token", "user", cachedToken.User) + token = cachedToken.AccessToken + } + } + + // If token found (either from config or cache), use it + if token != "" { + // Use TLS for external Envoy gateway (ingress expects HTTPS on port 443) + // System CA pool for validating ingress TLS certificates + tlsConfig := &tls.Config{ + InsecureSkipVerify: o.config.TlsSkipVerify, //nolint:gosec + } + o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + + // Add GitHub token as Bearer token in Authorization header + o.authOpts = append(o.authOpts, grpc.WithPerRPCCredentials(newGitHubCredentials(token))) + + authLogger.Debug("GitHub authentication configured via auto-detect") + + return nil + } + + // No cached credentials - use insecure connection (for local development only) + authLogger.Debug("No cached credentials found, using insecure connection") + + o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + + return nil +} + func (o *options) setupJWTAuth(ctx context.Context) error { // Validate SPIFFE socket path is set if o.config.SpiffeSocketPath == "" { @@ -345,6 +405,49 @@ func (o *options) setupSpiffeAuth(_ context.Context) error { return nil } +func (o *options) setupGitHubAuth(_ context.Context) error { + authLogger.Debug("Setting up GitHub authentication") + + var token string + + // 1. First, check if token is provided via config/flag/env + // This allows CI/CD to use PATs: export DIRECTORY_CLIENT_GITHUB_TOKEN=ghp_xxx + if o.config.GitHubToken != "" { + authLogger.Debug("Using token from config/environment") + + token = o.config.GitHubToken + } else { + // 2. Fall back to cached OAuth token from interactive login + cache := NewTokenCache() + + cachedToken, err := cache.GetValidToken() + if err != nil { + authLogger.Debug("Error loading cached token", "error", err) + } + + if cachedToken == nil { + return errors.New("not authenticated with GitHub. Run 'dirctl auth login' or set DIRECTORY_CLIENT_GITHUB_TOKEN environment variable") + } + + authLogger.Debug("Using cached GitHub OAuth token", "user", cachedToken.User) + token = cachedToken.AccessToken + } + + // Use TLS for external Envoy gateway (ingress expects HTTPS on port 443) + // System CA pool for validating ingress TLS certificates + tlsConfig := &tls.Config{ + InsecureSkipVerify: o.config.TlsSkipVerify, //nolint:gosec + } + o.authOpts = append(o.authOpts, grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) + + // Add GitHub token as Bearer token in Authorization header + o.authOpts = append(o.authOpts, grpc.WithPerRPCCredentials(newGitHubCredentials(token))) + + authLogger.Debug("GitHub authentication configured") + + return nil +} + func (o *options) setupTlsAuth(_ context.Context) error { // Validate TLS config is set if o.config.TlsCAFile == "" || o.config.TlsCertFile == "" || o.config.TlsKeyFile == "" { diff --git a/client/options_test.go b/client/options_test.go index 4da60f7c2..04097cb57 100644 --- a/client/options_test.go +++ b/client/options_test.go @@ -436,3 +436,241 @@ func TestWithAuth_AllAuthModes(t *testing.T) { }) } } + +func TestSetupGitHubAuth(t *testing.T) { + t.Run("should error when no token provided and no cache", func(t *testing.T) { + // Use non-existent cache directory to ensure no cached token + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "", // No token provided + }, + } + + ctx := context.Background() + err := opts.setupGitHubAuth(ctx) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not authenticated with GitHub") + }) + + t.Run("should succeed with token from config", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "gho_testtoken123456789", // Token provided + }, + } + + ctx := context.Background() + err := opts.setupGitHubAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + + t.Run("should succeed with token from environment", func(t *testing.T) { + t.Setenv("DIRECTORY_CLIENT_GITHUB_TOKEN", "ghp_envtoken123456789") + + cfg, err := LoadConfig() + require.NoError(t, err) + + opts := &options{ + config: cfg, + } + + ctx := context.Background() + err = opts.setupGitHubAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + + t.Run("should use insecure transport", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "gho_testtoken", + }, + } + + ctx := context.Background() + err := opts.setupGitHubAuth(ctx) + + require.NoError(t, err) + // Should have transport credentials (insecure) and per-RPC credentials (bearer token) + assert.Len(t, opts.authOpts, 2) + }) +} + +func TestSetupAutoDetectAuth(t *testing.T) { + t.Run("should use insecure for explicit 'insecure' mode", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "insecure", + }, + } + + ctx := context.Background() + err := opts.setupAutoDetectAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + + t.Run("should use insecure for explicit 'none' mode", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "none", + }, + } + + ctx := context.Background() + err := opts.setupAutoDetectAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + + t.Run("should auto-detect token from config", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "", // Empty - auto-detect + GitHubToken: "gho_autodetect123", + }, + } + + ctx := context.Background() + err := opts.setupAutoDetectAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + // Should have both transport and per-RPC credentials + assert.Len(t, opts.authOpts, 2) + }) + + t.Run("should fallback to insecure when no credentials found", func(t *testing.T) { + // Use non-existent cache directory + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "", // Empty - auto-detect + GitHubToken: "", // No token + }, + } + + ctx := context.Background() + err := opts.setupAutoDetectAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + // Should only have transport credentials (insecure), no per-RPC credentials + assert.Len(t, opts.authOpts, 1) + }) + + t.Run("should auto-detect token from environment", func(t *testing.T) { + t.Setenv("DIRECTORY_CLIENT_GITHUB_TOKEN", "ghp_fromenv123") + + cfg, err := LoadConfig() + require.NoError(t, err) + + opts := &options{ + config: cfg, + } + opts.config.AuthMode = "" // Empty for auto-detect + + ctx := context.Background() + err = opts.setupAutoDetectAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + // Should have both transport and per-RPC credentials + assert.Len(t, opts.authOpts, 2) + }) +} + +func TestWithAuth_GitHubMode(t *testing.T) { + t.Run("should setup GitHub auth when mode is 'github'", func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "gho_testtoken", + }, + } + + ctx := context.Background() + opt := withAuth(ctx) + err := opt(opts) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + + t.Run("should error when GitHub mode but no token", func(t *testing.T) { + // Use non-existent cache directory + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "", // No token + }, + } + + ctx := context.Background() + opt := withAuth(ctx) + err := opt(opts) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not authenticated with GitHub") + }) +} + +func TestSetupGitHubAuth_TLSSkipVerify(t *testing.T) { + t.Run("should respect TlsSkipVerify flag", func(t *testing.T) { + testCases := []struct { + name string + tlsSkipVerify bool + }{ + { + name: "with TLS skip verify enabled", + tlsSkipVerify: true, + }, + { + name: "with TLS skip verify disabled", + tlsSkipVerify: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + opts := &options{ + config: &Config{ + ServerAddress: testServerAddr, + AuthMode: "github", + GitHubToken: "gho_testtoken", + TlsSkipVerify: tc.tlsSkipVerify, + }, + } + + ctx := context.Background() + err := opts.setupGitHubAuth(ctx) + + require.NoError(t, err) + assert.NotEmpty(t, opts.authOpts) + }) + } + }) +} diff --git a/client/token_cache.go b/client/token_cache.go new file mode 100644 index 000000000..76cbd3f0a --- /dev/null +++ b/client/token_cache.go @@ -0,0 +1,190 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +const ( + // DefaultTokenCacheDir is the default directory for storing cached tokens (relative to home directory). + // When XDG_CONFIG_HOME is set, tokens are stored at $XDG_CONFIG_HOME/dirctl instead. + //nolint:gosec // G101: This is a directory path, not a credential + DefaultTokenCacheDir = ".config/dirctl" + + // TokenCacheFile is the filename for the cached token. + //nolint:gosec // G101: This is a filename, not a credential + TokenCacheFile = "auth-token.json" + + // DefaultTokenValidityDuration is how long a token is considered valid if no expiry is set. + DefaultTokenValidityDuration = 8 * time.Hour + + // TokenExpiryBuffer is how much time before actual expiry we consider a token expired. + TokenExpiryBuffer = 5 * time.Minute + + // File and directory permissions for secure token storage. + cacheDirPerms = 0o700 // Owner read/write/execute only + cacheFilePerms = 0o600 // Owner read/write only +) + +// CachedToken represents a cached authentication token from any provider. +type CachedToken struct { + // AccessToken is the authentication token. + AccessToken string `json:"access_token"` + + // TokenType is the token type (usually "bearer"). + TokenType string `json:"token_type,omitempty"` + + // Provider is the authentication provider (github, google, azure, etc.) + Provider string `json:"provider,omitempty"` + + // ExpiresAt is when the token expires. + ExpiresAt time.Time `json:"expires_at,omitzero"` + + // User is the authenticated username. + User string `json:"user,omitempty"` + + // UserID is the provider-specific user ID. + UserID string `json:"user_id,omitempty"` + + // Email is the user's email address. + Email string `json:"email,omitempty"` + + // Orgs are the user's organizations/tenants/domains. + Orgs []string `json:"orgs,omitempty"` + + // CreatedAt is when the token was cached. + CreatedAt time.Time `json:"created_at"` +} + +// TokenCacheEntry is an alias for CachedToken (for compatibility). +type TokenCacheEntry = CachedToken + +// TokenCache manages cached authentication tokens from any provider. +type TokenCache struct { + // CacheDir is the directory where tokens are stored. + CacheDir string +} + +// NewTokenCache creates a new token cache with the default directory. +// Respects XDG_CONFIG_HOME environment variable for config directory location. +func NewTokenCache() *TokenCache { + configHome := os.Getenv("XDG_CONFIG_HOME") + if configHome == "" { + home, _ := os.UserHomeDir() + configHome = filepath.Join(home, ".config") + } + + return &TokenCache{ + CacheDir: filepath.Join(configHome, "dirctl"), + } +} + +// NewTokenCacheWithDir creates a new token cache with a custom directory. +func NewTokenCacheWithDir(dir string) *TokenCache { + return &TokenCache{CacheDir: dir} +} + +// GetCachePath returns the full path to the token cache file. +func (c *TokenCache) GetCachePath() string { + return filepath.Join(c.CacheDir, TokenCacheFile) +} + +// Load loads the cached token from disk. +// Returns nil if no valid token is found. +func (c *TokenCache) Load() (*CachedToken, error) { + path := c.GetCachePath() + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + //nolint:nilnil // Returning (nil, nil) is idiomatic for "not found" - not an error condition + return nil, nil // No cached token + } + + return nil, fmt.Errorf("failed to read token cache: %w", err) + } + + var token CachedToken + if err := json.Unmarshal(data, &token); err != nil { + return nil, fmt.Errorf("failed to parse token cache: %w", err) + } + + return &token, nil +} + +// Save saves a token to the cache. +func (c *TokenCache) Save(token *CachedToken) error { + // Ensure directory exists with secure permissions + if err := os.MkdirAll(c.CacheDir, cacheDirPerms); err != nil { + return fmt.Errorf("failed to create cache directory: %w", err) + } + + // Set creation time if not set + if token.CreatedAt.IsZero() { + token.CreatedAt = time.Now() + } + + data, err := json.MarshalIndent(token, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal token: %w", err) + } + + path := c.GetCachePath() + // Write with secure permissions (owner read/write only) + if err := os.WriteFile(path, data, cacheFilePerms); err != nil { + return fmt.Errorf("failed to write token cache: %w", err) + } + + return nil +} + +// Clear removes the cached token. +func (c *TokenCache) Clear() error { + path := c.GetCachePath() + + err := os.Remove(path) + if err != nil && !os.IsNotExist(err) { + return fmt.Errorf("failed to remove token cache: %w", err) + } + + return nil +} + +// IsValid checks if a cached token is still valid. +// A token is considered valid if it exists and hasn't expired. +func (c *TokenCache) IsValid(token *CachedToken) bool { + if token == nil || token.AccessToken == "" { + return false + } + + // If no expiry set, assume valid for DefaultTokenValidityDuration from creation + if token.ExpiresAt.IsZero() { + defaultExpiry := token.CreatedAt.Add(DefaultTokenValidityDuration) + + return time.Now().Before(defaultExpiry) + } + + // Check if token has expired (with buffer) + return time.Now().Add(TokenExpiryBuffer).Before(token.ExpiresAt) +} + +// GetValidToken returns a valid cached token or nil if none exists. +func (c *TokenCache) GetValidToken() (*CachedToken, error) { + token, err := c.Load() + if err != nil { + return nil, err + } + + if !c.IsValid(token) { + //nolint:nilnil // Returning (nil, nil) is idiomatic for "no valid token" - not an error condition + return nil, nil + } + + return token, nil +} diff --git a/client/token_cache_test.go b/client/token_cache_test.go new file mode 100644 index 000000000..3384c1749 --- /dev/null +++ b/client/token_cache_test.go @@ -0,0 +1,591 @@ +// Copyright AGNTCY Contributors (https://github.com/agntcy) +// SPDX-License-Identifier: Apache-2.0 + +package client + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewTokenCache(t *testing.T) { + t.Run("should create cache with default directory", func(t *testing.T) { + cache := NewTokenCache() + + require.NotNil(t, cache) + assert.NotEmpty(t, cache.CacheDir) + assert.Contains(t, cache.CacheDir, DefaultTokenCacheDir) + }) + + t.Run("should include home directory in path", func(t *testing.T) { + cache := NewTokenCache() + + home, err := os.UserHomeDir() + require.NoError(t, err) + + expectedPath := filepath.Join(home, DefaultTokenCacheDir) + assert.Equal(t, expectedPath, cache.CacheDir) + }) +} + +func TestNewTokenCacheWithDir(t *testing.T) { + t.Run("should create cache with custom directory", func(t *testing.T) { + customDir := "/tmp/test-cache" + cache := NewTokenCacheWithDir(customDir) + + require.NotNil(t, cache) + assert.Equal(t, customDir, cache.CacheDir) + }) + + t.Run("should accept empty directory", func(t *testing.T) { + cache := NewTokenCacheWithDir("") + + require.NotNil(t, cache) + assert.Empty(t, cache.CacheDir) + }) +} + +func TestTokenCache_GetCachePath(t *testing.T) { + t.Run("should return full path to cache file", func(t *testing.T) { + customDir := "/tmp/test-cache" + cache := NewTokenCacheWithDir(customDir) + + path := cache.GetCachePath() + + expectedPath := filepath.Join(customDir, TokenCacheFile) + assert.Equal(t, expectedPath, path) + }) + + t.Run("should handle trailing slash in directory", func(t *testing.T) { + cache := NewTokenCacheWithDir("/tmp/test-cache/") + + path := cache.GetCachePath() + + assert.Contains(t, path, TokenCacheFile) + }) +} + +func TestTokenCache_Save(t *testing.T) { + t.Run("should save token to cache", func(t *testing.T) { + // Create temporary directory for test + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + token := &CachedToken{ + AccessToken: "test_token_123", + TokenType: "bearer", + Provider: "github", + User: "testuser", + ExpiresAt: time.Now().Add(time.Hour), + } + + err := cache.Save(token) + + require.NoError(t, err) + + // Verify file exists + path := cache.GetCachePath() + _, statErr := os.Stat(path) + assert.NoError(t, statErr) + }) + + t.Run("should create directory if it doesn't exist", func(t *testing.T) { + tmpDir := filepath.Join(t.TempDir(), "nested", "dir") + cache := NewTokenCacheWithDir(tmpDir) + + token := &CachedToken{ + AccessToken: "test_token_123", + } + + err := cache.Save(token) + + require.NoError(t, err) + + // Verify directory was created + _, statErr := os.Stat(tmpDir) + assert.NoError(t, statErr) + }) + + t.Run("should set CreatedAt if not set", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + token := &CachedToken{ + AccessToken: "test_token_123", + // CreatedAt is zero + } + + before := time.Now() + err := cache.Save(token) + after := time.Now() + + require.NoError(t, err) + assert.False(t, token.CreatedAt.IsZero()) + assert.True(t, token.CreatedAt.After(before.Add(-time.Second))) + assert.True(t, token.CreatedAt.Before(after.Add(time.Second))) + }) + + t.Run("should preserve existing CreatedAt", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + createdAt := time.Now().Add(-24 * time.Hour) + token := &CachedToken{ + AccessToken: "test_token_123", + CreatedAt: createdAt, + } + + err := cache.Save(token) + + require.NoError(t, err) + assert.Equal(t, createdAt, token.CreatedAt) + }) + + t.Run("should write JSON with proper formatting", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + token := &CachedToken{ + AccessToken: "test_token_123", + User: "testuser", + } + + err := cache.Save(token) + require.NoError(t, err) + + // Read and verify JSON formatting + data, readErr := os.ReadFile(cache.GetCachePath()) + require.NoError(t, readErr) + + // Should be indented JSON + assert.Contains(t, string(data), "\n") + assert.Contains(t, string(data), " ") + }) + + t.Run("should set secure file permissions", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + token := &CachedToken{ + AccessToken: "test_token_123", + } + + err := cache.Save(token) + require.NoError(t, err) + + // Check file permissions + info, statErr := os.Stat(cache.GetCachePath()) + require.NoError(t, statErr) + + // Should be 0600 (owner read/write only) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) + }) +} + +func TestTokenCache_Load(t *testing.T) { + t.Run("should load token from cache", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Save token first + originalToken := &CachedToken{ + AccessToken: "test_token_123", + TokenType: "bearer", + Provider: "github", + User: "testuser", + Email: "test@example.com", + Orgs: []string{"org1", "org2"}, + ExpiresAt: time.Now().Add(time.Hour), + } + + err := cache.Save(originalToken) + require.NoError(t, err) + + // Load token + loadedToken, loadErr := cache.Load() + + require.NoError(t, loadErr) + require.NotNil(t, loadedToken) + assert.Equal(t, originalToken.AccessToken, loadedToken.AccessToken) + assert.Equal(t, originalToken.TokenType, loadedToken.TokenType) + assert.Equal(t, originalToken.Provider, loadedToken.Provider) + assert.Equal(t, originalToken.User, loadedToken.User) + assert.Equal(t, originalToken.Email, loadedToken.Email) + assert.Equal(t, originalToken.Orgs, loadedToken.Orgs) + }) + + t.Run("should return nil when cache doesn't exist", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + token, err := cache.Load() + + require.NoError(t, err) + assert.Nil(t, token) + }) + + t.Run("should error on malformed JSON", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Write invalid JSON + err := os.MkdirAll(tmpDir, cacheDirPerms) + require.NoError(t, err) + + invalidJSON := []byte("{invalid json") + writeErr := os.WriteFile(cache.GetCachePath(), invalidJSON, cacheFilePerms) + require.NoError(t, writeErr) + + // Try to load + token, loadErr := cache.Load() + + require.Error(t, loadErr) + assert.Nil(t, token) + assert.Contains(t, loadErr.Error(), "failed to parse token cache") + }) + + t.Run("should handle empty file", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Write empty file + err := os.MkdirAll(tmpDir, cacheDirPerms) + require.NoError(t, err) + + writeErr := os.WriteFile(cache.GetCachePath(), []byte(""), cacheFilePerms) + require.NoError(t, writeErr) + + // Try to load + token, loadErr := cache.Load() + + require.Error(t, loadErr) + assert.Nil(t, token) + }) +} + +func TestTokenCache_Clear(t *testing.T) { + t.Run("should remove cached token", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Save token first + token := &CachedToken{AccessToken: "test_token"} + err := cache.Save(token) + require.NoError(t, err) + + // Clear cache + clearErr := cache.Clear() + + require.NoError(t, clearErr) + + // Verify file is gone + _, statErr := os.Stat(cache.GetCachePath()) + assert.True(t, os.IsNotExist(statErr)) + }) + + t.Run("should not error if cache doesn't exist", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Clear non-existent cache + err := cache.Clear() + + assert.NoError(t, err) + }) + + t.Run("should error if file cannot be removed", func(t *testing.T) { + // This test is platform-dependent, skip on Windows + if os.PathSeparator == '\\' { + t.Skip("Skipping on Windows") + } + + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Save token + token := &CachedToken{AccessToken: "test_token"} + err := cache.Save(token) + require.NoError(t, err) + + // Make directory read-only to prevent file deletion + chmodErr := os.Chmod(tmpDir, 0o500) + require.NoError(t, chmodErr) + + defer func() { + // Restore permissions for cleanup + _ = os.Chmod(tmpDir, cacheDirPerms) + }() + + // Try to clear (should fail) + clearErr := cache.Clear() + + assert.Error(t, clearErr) + }) +} + +func TestTokenCache_IsValid(t *testing.T) { + t.Run("should return false for nil token", func(t *testing.T) { + cache := NewTokenCache() + + valid := cache.IsValid(nil) + + assert.False(t, valid) + }) + + t.Run("should return false for empty access token", func(t *testing.T) { + cache := NewTokenCache() + + token := &CachedToken{ + AccessToken: "", + } + + valid := cache.IsValid(token) + + assert.False(t, valid) + }) + + t.Run("should return true for valid token with future expiry", func(t *testing.T) { + cache := NewTokenCache() + + token := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(time.Hour), + } + + valid := cache.IsValid(token) + + assert.True(t, valid) + }) + + t.Run("should return false for expired token", func(t *testing.T) { + cache := NewTokenCache() + + token := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(-time.Hour), + } + + valid := cache.IsValid(token) + + assert.False(t, valid) + }) + + t.Run("should return false for token expiring within buffer", func(t *testing.T) { + cache := NewTokenCache() + + // Token expires in 4 minutes (less than 5-minute buffer) + token := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(4 * time.Minute), + } + + valid := cache.IsValid(token) + + assert.False(t, valid) + }) + + t.Run("should use CreatedAt for tokens without expiry", func(t *testing.T) { + cache := NewTokenCache() + + // Token created 1 hour ago, should be valid (default 8 hours) + token := &CachedToken{ + AccessToken: "test_token", + CreatedAt: time.Now().Add(-time.Hour), + // ExpiresAt is zero + } + + valid := cache.IsValid(token) + + assert.True(t, valid) + }) + + t.Run("should return false for old token without expiry", func(t *testing.T) { + cache := NewTokenCache() + + // Token created 9 hours ago, should be expired (default 8 hours) + token := &CachedToken{ + AccessToken: "test_token", + CreatedAt: time.Now().Add(-9 * time.Hour), + // ExpiresAt is zero + } + + valid := cache.IsValid(token) + + assert.False(t, valid) + }) + + t.Run("should handle token at exact expiry boundary", func(t *testing.T) { + cache := NewTokenCache() + + // Token expires exactly at buffer time (5 minutes from now) + token := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(TokenExpiryBuffer), + } + + valid := cache.IsValid(token) + + // Should be considered expired (not valid) + assert.False(t, valid) + }) +} + +func TestTokenCache_GetValidToken(t *testing.T) { + t.Run("should return valid token", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Save valid token + originalToken := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(time.Hour), + } + + err := cache.Save(originalToken) + require.NoError(t, err) + + // Get valid token + token, getErr := cache.GetValidToken() + + require.NoError(t, getErr) + require.NotNil(t, token) + assert.Equal(t, originalToken.AccessToken, token.AccessToken) + }) + + t.Run("should return nil for expired token", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Save expired token + expiredToken := &CachedToken{ + AccessToken: "test_token", + ExpiresAt: time.Now().Add(-time.Hour), + CreatedAt: time.Now().Add(-time.Hour), + } + + err := cache.Save(expiredToken) + require.NoError(t, err) + + // Get valid token + token, getErr := cache.GetValidToken() + + require.NoError(t, getErr) + assert.Nil(t, token) + }) + + t.Run("should return nil when no cache exists", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Get valid token without saving first + token, err := cache.GetValidToken() + + require.NoError(t, err) + assert.Nil(t, token) + }) + + t.Run("should return error for malformed cache", func(t *testing.T) { + tmpDir := t.TempDir() + cache := NewTokenCacheWithDir(tmpDir) + + // Write invalid JSON + err := os.MkdirAll(tmpDir, cacheDirPerms) + require.NoError(t, err) + + writeErr := os.WriteFile(cache.GetCachePath(), []byte("{invalid}"), cacheFilePerms) + require.NoError(t, writeErr) + + // Try to get valid token + token, getErr := cache.GetValidToken() + + require.Error(t, getErr) + assert.Nil(t, token) + }) +} + +func TestCachedToken_JSONSerialization(t *testing.T) { + t.Run("should serialize and deserialize correctly", func(t *testing.T) { + now := time.Now() + original := &CachedToken{ + AccessToken: "test_token_123", + TokenType: "bearer", + Provider: "github", + ExpiresAt: now.Add(time.Hour), + User: "testuser", + UserID: "12345", + Email: "test@example.com", + Orgs: []string{"org1", "org2"}, + CreatedAt: now, + } + + // Marshal to JSON + data, err := json.Marshal(original) + require.NoError(t, err) + + // Unmarshal back + var decoded CachedToken + + unmarshalErr := json.Unmarshal(data, &decoded) + + require.NoError(t, unmarshalErr) + assert.Equal(t, original.AccessToken, decoded.AccessToken) + assert.Equal(t, original.TokenType, decoded.TokenType) + assert.Equal(t, original.Provider, decoded.Provider) + assert.Equal(t, original.User, decoded.User) + assert.Equal(t, original.UserID, decoded.UserID) + assert.Equal(t, original.Email, decoded.Email) + assert.Equal(t, original.Orgs, decoded.Orgs) + // Time comparison with truncation for JSON precision + assert.True(t, original.ExpiresAt.Truncate(time.Second).Equal(decoded.ExpiresAt.Truncate(time.Second))) + assert.True(t, original.CreatedAt.Truncate(time.Second).Equal(decoded.CreatedAt.Truncate(time.Second))) + }) + + t.Run("should omit empty optional fields", func(t *testing.T) { + token := &CachedToken{ + AccessToken: "test_token", + CreatedAt: time.Now(), + } + + data, err := json.Marshal(token) + require.NoError(t, err) + + // Should not contain omitted fields + jsonStr := string(data) + assert.NotContains(t, jsonStr, "token_type") + assert.NotContains(t, jsonStr, "provider") + assert.NotContains(t, jsonStr, "user") + assert.NotContains(t, jsonStr, "user_id") + assert.NotContains(t, jsonStr, "email") + assert.NotContains(t, jsonStr, "orgs") + }) + + t.Run("should handle zero time with omitzero", func(t *testing.T) { + token := &CachedToken{ + AccessToken: "test_token", + CreatedAt: time.Now(), + // ExpiresAt is zero + } + + data, err := json.Marshal(token) + require.NoError(t, err) + + // Should not contain expires_at + jsonStr := string(data) + assert.NotContains(t, jsonStr, "expires_at") + }) +} + +func TestTokenCacheConstants(t *testing.T) { + t.Run("should have reasonable default values", func(t *testing.T) { + assert.Equal(t, ".config/dirctl", DefaultTokenCacheDir) + assert.Equal(t, "auth-token.json", TokenCacheFile) + assert.Equal(t, 8*time.Hour, DefaultTokenValidityDuration) + assert.Equal(t, 5*time.Minute, TokenExpiryBuffer) + }) +} diff --git a/docker-bake.hcl b/docker-bake.hcl index 9e7822799..8949289cd 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -18,6 +18,7 @@ group "default" { targets = [ "dir-apiserver", "dir-ctl", + "envoy-authz", ] } @@ -77,6 +78,16 @@ target "dir-ctl" { tags = get_tag(target.docker-metadata-action.tags, "${target.dir-ctl.name}") } +target "envoy-authz" { + context = "." + dockerfile = "./auth/cmd/envoy-authz/Dockerfile" + inherits = [ + "_common", + "docker-metadata-action", + ] + tags = get_tag(target.docker-metadata-action.tags, "${target.envoy-authz.name}") +} + target "sdks-test" { context = "." dockerfile = "./e2e/sdk/Dockerfile" diff --git a/e2e/go.mod b/e2e/go.mod index 59b2230ef..a1bf4a1ab 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -6,6 +6,7 @@ replace ( // Cosign does not updated the crypto11 owner github.com/ThalesIgnite/crypto11 => github.com/ThalesGroup/crypto11 v1.6.0 github.com/agntcy/dir/api => ../api + github.com/agntcy/dir/auth/authprovider => ../auth/authprovider github.com/agntcy/dir/cli => ../cli github.com/agntcy/dir/client => ../client github.com/agntcy/dir/importer => ../importer @@ -48,8 +49,10 @@ require ( github.com/JohannesKaufmann/html-to-markdown v1.6.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/PuerkitoBio/goquery v1.11.0 // indirect github.com/ThalesIgnite/crypto11 v1.2.5 // indirect + github.com/agntcy/dir/auth/authprovider v0.0.0 // indirect github.com/agntcy/dir/importer v0.6.1 // indirect github.com/agntcy/dir/mcp v0.6.1 // indirect github.com/agntcy/oasf-sdk/pkg v0.0.14 // indirect @@ -96,6 +99,7 @@ require ( github.com/clipperhouse/displaywidth v0.7.0 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.1.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/eino v0.7.20 // indirect github.com/cloudwego/eino-ext/components/model/claude v0.1.13 // indirect @@ -157,6 +161,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-containerregistry v0.20.7 // indirect + github.com/google/go-github/v50 v50.2.0 // indirect github.com/google/go-github/v73 v73.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/e2e/go.sum b/e2e/go.sum index 8a9539539..878e39a1a 100644 --- a/e2e/go.sum +++ b/e2e/go.sum @@ -45,6 +45,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA= +github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g= github.com/PuerkitoBio/goquery v1.9.2/go.mod h1:GHPCaP0ODyyxqcNoFGYlAprUFH81NuRPd0GX3Zu2Mvk= github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= @@ -120,6 +122,7 @@ github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMU github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= github.com/bugsnag/panicwrap v1.2.0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= +github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/mockey v1.3.0 h1:ONLRdvhqmCfr9rTasUB8ZKCfvbdD2tohOg4u+4Q/ed0= @@ -160,6 +163,8 @@ github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5 github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudflare/circl v1.1.0 h1:bZgT/A+cikZnKIwn7xL2OBj012Bmvho/o6RpRvv3GKY= +github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I= github.com/clipperhouse/displaywidth v0.7.0 h1:QNv1GYsnLX9QBrcWUtMlogpTXuM5FVnBwKWp1O5NwmE= github.com/clipperhouse/displaywidth v0.7.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -361,6 +366,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.7 h1:24VGNpS0IwrOZ2ms2P1QE3Xa5X9p4phx0aUgzYzHW6I= github.com/google/go-containerregistry v0.20.7/go.mod h1:Lx5LCZQjLH1QBaMPeGwsME9biPeo1lPx6lbGj/UmzgM= +github.com/google/go-github/v50 v50.2.0 h1:j2FyongEHlO9nxXLc+LP3wuBSVU9mVxfpdYUexMpIfk= +github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q= github.com/google/go-github/v73 v73.0.0 h1:aR+Utnh+Y4mMkS+2qLQwcQ/cF9mOTpdwnzlaw//rG24= github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= @@ -891,6 +898,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/install/charts/dir/apiserver/Chart.lock b/install/charts/dir/apiserver/Chart.lock index 2af8c458d..5c4744b0d 100644 --- a/install/charts/dir/apiserver/Chart.lock +++ b/install/charts/dir/apiserver/Chart.lock @@ -5,5 +5,8 @@ dependencies: - name: oasf repository: oci://ghcr.io/agntcy/oasf/helm-charts version: v0.5.1 -digest: sha256:d8692235a2bb4c08e5ad81770ef5724b4fa6fee9856c8f6353a46757e3c4bffd -generated: "2026-01-16T09:12:48.873231+01:00" +- name: envoy-authz + repository: file://../../envoy-authz + version: 0.1.0 +digest: sha256:825cacb82bff5ce99d37efd41c95ad5d4331e9a950d165d1a7bbfc60545db620 +generated: "2026-01-17T10:25:39.99824+01:00" diff --git a/install/charts/dir/apiserver/Chart.yaml b/install/charts/dir/apiserver/Chart.yaml index cfbdb5968..bb77e2aa3 100644 --- a/install/charts/dir/apiserver/Chart.yaml +++ b/install/charts/dir/apiserver/Chart.yaml @@ -37,3 +37,10 @@ dependencies: version: v0.5.1 repository: oci://ghcr.io/agntcy/oasf/helm-charts condition: oasf.enabled + # Envoy gateway with GitHub authentication (OPTIONAL) + # Only installed when explicitly enabled via: envoyAuthz.enabled: true + # Default: disabled (envoyAuthz.enabled: false in values.yaml) + - name: envoy-authz + version: "0.1.0" + repository: "file://../../envoy-authz" + condition: envoyAuthz.enabled \ No newline at end of file diff --git a/install/charts/dir/apiserver/values.yaml b/install/charts/dir/apiserver/values.yaml index d986b4557..540a81d53 100644 --- a/install/charts/dir/apiserver/values.yaml +++ b/install/charts/dir/apiserver/values.yaml @@ -552,3 +552,73 @@ externalSecrets: # OASF is NOT installed by default. Set enabled: true to deploy an OASF schema server instance. oasf: enabled: false + +# Envoy auth gateway subchart configuration (OPTIONAL) +# Provides GitHub authentication via Envoy gateway with ext_authz +# NOT installed by default. Set enabled: true to deploy. +# Section 1: Enable/disable the subchart +envoyAuthz: + enabled: false + +# Section 2: Configuration values for the envoy-authz subchart +# NOTE: Only takes effect when envoyAuthz.enabled: true +envoy-authz: + # Envoy gateway configuration + envoy: + replicaCount: 2 + + # Backend Directory API address (required when enabled) + # Set to the full service name of Directory API + backend: + address: "" # e.g., dir-dir-dev-argoapp-apiserver.dir-dev-dir.svc.cluster.local + port: 8888 + + # SPIFFE integration for mTLS with Directory + spiffe: + enabled: true + # trustDomain: example.org + # className: dir-spire + + # Authorization server configuration + authServer: + replicaCount: 2 + + # Image configuration + # Override these values to use custom images (e.g., development branches) + # image: + # repository: ghcr.io/agntcy/envoy-authz-dev + # tag: feat-feat-github-auth-ad4a58e + # pullPolicy: IfNotPresent + + # Authorization rules + authorization: + # Allowed GitHub organizations + # Empty list = allow all authenticated users + allowedOrgConstructs: [] + # Example: ["agntcy", "spiffe"] + + # Explicitly allowed users (format: "github:username") + userAllowList: [] + # Example: ["github:tkircsi"] + + # Explicitly denied users (format: "github:username") + userDenyList: [] + # Example: ["github:malicious-user"] + + # GitHub provider configuration + github: + enabled: true + cacheTTL: 5m + apiTimeout: 10s + + # Ingress configuration (optional - for external access) + ingress: + enabled: false + className: nginx + host: "" # e.g., gateway.dev.ads.outshift.io + annotations: + cert-manager.io/cluster-issuer: letsencrypt + external-dns.alpha.kubernetes.io/hostname: "" + # gRPC backend configuration (required for dirctl client over HTTPS) + nginx.ingress.kubernetes.io/backend-protocol: "GRPC" + nginx.ingress.kubernetes.io/grpc-backend: "true" \ No newline at end of file diff --git a/install/charts/dirctl/values.yaml b/install/charts/dirctl/values.yaml index 46dfe0b1d..b7449654e 100644 --- a/install/charts/dirctl/values.yaml +++ b/install/charts/dirctl/values.yaml @@ -30,7 +30,7 @@ cronjobs: schedule: '* * * * *' args: - 'info' - - 'baeareiesad3lyuacjirp6gxudrzheltwbodtsg7ieqpox36w5j637rchwq' + - 'baeareiewcutw33ouiesfbhlbqdthqlpms6zgsomw7drlnt2ys3hbzruszm' # Push cronjob push: diff --git a/install/charts/envoy-authz/Chart.yaml b/install/charts/envoy-authz/Chart.yaml new file mode 100644 index 000000000..f077e88cd --- /dev/null +++ b/install/charts/envoy-authz/Chart.yaml @@ -0,0 +1,20 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +apiVersion: v2 +name: envoy-authz +description: Envoy gateway with external authorization for GitHub authentication +type: application +version: 0.1.0 +appVersion: "0.1.0" + +keywords: + - envoy + - authentication + - authorization + - github + - oauth + +maintainers: + - name: AGNTCY Contributors + url: https://github.com/agntcy diff --git a/install/charts/envoy-authz/templates/_helpers.tpl b/install/charts/envoy-authz/templates/_helpers.tpl new file mode 100644 index 000000000..74fa3e0c6 --- /dev/null +++ b/install/charts/envoy-authz/templates/_helpers.tpl @@ -0,0 +1,104 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +{{/* +Expand the name of the chart. +*/}} +{{- define "envoy-authz.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "envoy-authz.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "envoy-authz.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "envoy-authz.labels" -}} +helm.sh/chart: {{ include "envoy-authz.chart" . }} +{{ include "envoy-authz.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "envoy-authz.selectorLabels" -}} +app.kubernetes.io/name: {{ include "envoy-authz.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Envoy gateway labels +*/}} +{{- define "envoy-authz.envoyLabels" -}} +{{ include "envoy-authz.labels" . }} +app.kubernetes.io/component: envoy-gateway +{{- end }} + +{{/* +Envoy gateway selector labels +*/}} +{{- define "envoy-authz.envoySelector" -}} +{{ include "envoy-authz.selectorLabels" . }} +app.kubernetes.io/component: envoy-gateway +{{- end }} + +{{/* +AuthZ server labels +*/}} +{{- define "envoy-authz.authzLabels" -}} +{{ include "envoy-authz.labels" . }} +app.kubernetes.io/component: authz-server +{{- end }} + +{{/* +AuthZ server selector labels +*/}} +{{- define "envoy-authz.authzSelector" -}} +{{ include "envoy-authz.selectorLabels" . }} +app.kubernetes.io/component: authz-server +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "envoy-authz.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "envoy-authz.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Envoy service account name (for SPIFFE ID) +*/}} +{{- define "envoy-authz.envoyServiceAccountName" -}} +{{- printf "%s-envoy-gateway" (include "envoy-authz.fullname" .) }} +{{- end }} diff --git a/install/charts/envoy-authz/templates/clusterspiffeid.yaml b/install/charts/envoy-authz/templates/clusterspiffeid.yaml new file mode 100644 index 000000000..d91b1110e --- /dev/null +++ b/install/charts/envoy-authz/templates/clusterspiffeid.yaml @@ -0,0 +1,20 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +{{- if .Values.envoy.spiffe.enabled }} +apiVersion: spire.spiffe.io/v1alpha1 +kind: ClusterSPIFFEID +metadata: + name: {{ include "envoy-authz.fullname" . }}-envoy-gateway +spec: + className: {{ .Values.envoy.spiffe.className | default "dir-spire" }} + spiffeIDTemplate: "spiffe://{{ .Values.envoy.spiffe.trustDomain }}/ns/{{ .Release.Namespace }}/sa/{{ include "envoy-authz.envoyServiceAccountName" . }}" + podSelector: + matchLabels: + {{- include "envoy-authz.envoySelector" . | nindent 6 }} + workloadSelectorTemplates: + - "k8s:ns:{{ .Release.Namespace }}" + - "k8s:sa:{{ include "envoy-authz.envoyServiceAccountName" . }}" +{{- end }} diff --git a/install/charts/envoy-authz/templates/configmap-authz.yaml b/install/charts/envoy-authz/templates/configmap-authz.yaml new file mode 100644 index 000000000..7865f0af4 --- /dev/null +++ b/install/charts/envoy-authz/templates/configmap-authz.yaml @@ -0,0 +1,28 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "envoy-authz.fullname" . }}-authz-config + labels: + {{- include "envoy-authz.authzLabels" . | nindent 4 }} +data: + # Server configuration + LISTEN_ADDRESS: {{ .Values.authServer.config.listenAddress | quote }} + LOG_LEVEL: {{ .Values.authServer.config.logLevel | quote }} + DEFAULT_PROVIDER: {{ .Values.authServer.config.defaultProvider | quote }} + + # Authorization rules + ALLOWED_ORG_CONSTRUCTS: {{ join "," .Values.authServer.authorization.allowedOrgConstructs | quote }} + USER_ALLOW_LIST: {{ join "," .Values.authServer.authorization.userAllowList | quote }} + USER_DENY_LIST: {{ join "," .Values.authServer.authorization.userDenyList | quote }} + + # GitHub provider configuration + GITHUB_ENABLED: {{ .Values.authServer.github.enabled | quote }} + {{- if .Values.authServer.github.enabled }} + GITHUB_CACHE_TTL: {{ .Values.authServer.github.cacheTTL | quote }} + GITHUB_API_TIMEOUT: {{ .Values.authServer.github.apiTimeout | quote }} + {{- end }} diff --git a/install/charts/envoy-authz/templates/configmap-envoy.yaml b/install/charts/envoy-authz/templates/configmap-envoy.yaml new file mode 100644 index 000000000..12661df93 --- /dev/null +++ b/install/charts/envoy-authz/templates/configmap-envoy.yaml @@ -0,0 +1,151 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "envoy-authz.fullname" . }}-envoy-config + labels: + {{- include "envoy-authz.envoyLabels" . | nindent 4 }} +data: + envoy.yaml: | + node: + id: "{{ include "envoy-authz.fullname" . }}-envoy" + cluster: "{{ .Release.Namespace }}" + + {{- if .Values.envoy.admin.enabled }} + admin: + address: + socket_address: + address: 0.0.0.0 + port_value: {{ .Values.envoy.admin.port }} + {{- end }} + + static_resources: + listeners: + - name: main_listener + address: + socket_address: + address: 0.0.0.0 + port_value: 8080 + filter_chains: + - filters: + - name: envoy.filters.network.http_connection_manager + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager + stat_prefix: ingress_http + codec_type: AUTO + http2_protocol_options: {} + access_log: + - name: envoy.access_loggers.stdout + typed_config: + "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog + route_config: + name: local_route + virtual_hosts: + - name: backend + domains: ["*"] + routes: + # Health check - no auth + - match: + path: "/healthz" + direct_response: + status: 200 + body: + inline_string: '{"status":"healthy"}' + typed_per_filter_config: + envoy.filters.http.ext_authz: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute + disabled: true + # All other requests - require auth + - match: + prefix: "/" + route: + cluster: directory_backend + timeout: {{ .Values.envoy.backend.timeout }} + http_filters: + # External authorization filter + - name: envoy.filters.http.ext_authz + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz + transport_api_version: V3 + grpc_service: + envoy_grpc: + cluster_name: ext_authz + timeout: 5s + failure_mode_allow: false + # Router filter (must be last) + - name: envoy.filters.http.router + typed_config: + "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router + + clusters: + # ExtAuthz service + - name: ext_authz + type: STRICT_DNS + connect_timeout: 1s + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: ext_authz + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: {{ include "envoy-authz.fullname" . }}-authz + port_value: {{ .Values.authServer.service.port }} + + # Directory backend + - name: directory_backend + type: STRICT_DNS + connect_timeout: 1s + typed_extension_protocol_options: + envoy.extensions.upstreams.http.v3.HttpProtocolOptions: + "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions + explicit_http_config: + http2_protocol_options: {} + load_assignment: + cluster_name: directory_backend + endpoints: + - lb_endpoints: + - endpoint: + address: + socket_address: + address: {{ .Values.envoy.backend.address }} + port_value: {{ .Values.envoy.backend.port }} + {{- if .Values.envoy.spiffe.enabled }} + # SPIFFE mTLS configuration + transport_socket: + name: envoy.transport_sockets.tls + typed_config: + "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext + common_tls_context: + tls_certificate_sds_secret_configs: + - name: "spiffe://{{ .Values.envoy.spiffe.trustDomain | default "example.org" }}/ns/{{ .Release.Namespace }}/sa/{{ include "envoy-authz.envoyServiceAccountName" . }}" + sds_config: + api_config_source: + api_type: GRPC + grpc_services: + - envoy_grpc: + cluster_name: spiffe_agent + + # SPIRE agent cluster (for SDS) + - name: spiffe_agent + type: STATIC + connect_timeout: 1s + http2_protocol_options: {} + load_assignment: + cluster_name: spiffe_agent + endpoints: + - lb_endpoints: + - endpoint: + address: + pipe: + path: /run/spire/agent-sockets/api.sock + {{- end }} diff --git a/install/charts/envoy-authz/templates/deployment-authz.yaml b/install/charts/envoy-authz/templates/deployment-authz.yaml new file mode 100644 index 000000000..133536ee7 --- /dev/null +++ b/install/charts/envoy-authz/templates/deployment-authz.yaml @@ -0,0 +1,56 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "envoy-authz.fullname" . }}-authz + labels: + {{- include "envoy-authz.authzLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.authServer.replicaCount }} + selector: + matchLabels: + {{- include "envoy-authz.authzSelector" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-authz.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "envoy-authz.authzSelector" . | nindent 8 }} + spec: + serviceAccountName: {{ include "envoy-authz.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: authz-server + image: "{{ .Values.authServer.image.repository }}:{{ .Values.authServer.image.tag }}" + imagePullPolicy: {{ .Values.authServer.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + ports: + - name: grpc + containerPort: 9002 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "envoy-authz.fullname" . }}-authz-config + # Health probes disabled for distroless image + # TODO: Add grpc_health_probe binary to image or use tcpSocket probe + # livenessProbe: + # tcpSocket: + # port: 9002 + # initialDelaySeconds: 10 + # periodSeconds: 30 + # readinessProbe: + # tcpSocket: + # port: 9002 + # initialDelaySeconds: 5 + # periodSeconds: 10 + resources: + {{- toYaml .Values.authServer.resources | nindent 10 }} diff --git a/install/charts/envoy-authz/templates/deployment-envoy.yaml b/install/charts/envoy-authz/templates/deployment-envoy.yaml new file mode 100644 index 000000000..08607b2ac --- /dev/null +++ b/install/charts/envoy-authz/templates/deployment-envoy.yaml @@ -0,0 +1,83 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "envoy-authz.fullname" . }}-envoy + labels: + {{- include "envoy-authz.envoyLabels" . | nindent 4 }} +spec: + replicas: {{ .Values.envoy.replicaCount }} + selector: + matchLabels: + {{- include "envoy-authz.envoySelector" . | nindent 6 }} + template: + metadata: + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap-envoy.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "envoy-authz.envoySelector" . | nindent 8 }} + spec: + serviceAccountName: {{ include "envoy-authz.envoyServiceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: envoy + image: "{{ .Values.envoy.image.repository }}:{{ .Values.envoy.image.tag }}" + imagePullPolicy: {{ .Values.envoy.image.pullPolicy }} + securityContext: + {{- toYaml .Values.securityContext | nindent 10 }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + {{- if .Values.envoy.admin.enabled }} + - name: admin + containerPort: {{ .Values.envoy.admin.port }} + protocol: TCP + {{- end }} + command: + - /usr/local/bin/envoy + - -c + - /etc/envoy/envoy.yaml + - --service-cluster + - {{ include "envoy-authz.fullname" . }} + volumeMounts: + - name: envoy-config + mountPath: /etc/envoy + readOnly: true + {{- if .Values.envoy.spiffe.enabled }} + - name: spiffe-workload-api + mountPath: /run/spire/agent-sockets + readOnly: true + {{- end }} + livenessProbe: + httpGet: + path: /ready + port: admin + initialDelaySeconds: 10 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /ready + port: admin + initialDelaySeconds: 5 + periodSeconds: 10 + resources: + {{- toYaml .Values.envoy.resources | nindent 10 }} + volumes: + - name: envoy-config + configMap: + name: {{ include "envoy-authz.fullname" . }}-envoy-config + {{- if .Values.envoy.spiffe.enabled }} + - name: spiffe-workload-api + csi: + driver: csi.spiffe.io + readOnly: true + {{- end }} diff --git a/install/charts/envoy-authz/templates/ingress.yaml b/install/charts/envoy-authz/templates/ingress.yaml new file mode 100644 index 000000000..3f7e022a6 --- /dev/null +++ b/install/charts/envoy-authz/templates/ingress.yaml @@ -0,0 +1,42 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +{{- if .Values.ingress.enabled -}} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "envoy-authz.fullname" . }} + labels: + {{- include "envoy-authz.envoyLabels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if .Values.ingress.className }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls.enabled }} + tls: + - hosts: + - {{ .Values.ingress.host }} + {{- if .Values.ingress.tls.secretName }} + secretName: {{ .Values.ingress.tls.secretName }} + {{- else }} + secretName: {{ include "envoy-authz.fullname" . }}-tls + {{- end }} + {{- end }} + rules: + - host: {{ .Values.ingress.host }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "envoy-authz.fullname" . }}-envoy + port: + number: {{ .Values.envoy.service.port }} +{{- end }} diff --git a/install/charts/envoy-authz/templates/service-authz.yaml b/install/charts/envoy-authz/templates/service-authz.yaml new file mode 100644 index 000000000..2d99b0518 --- /dev/null +++ b/install/charts/envoy-authz/templates/service-authz.yaml @@ -0,0 +1,24 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: v1 +kind: Service +metadata: + name: {{ include "envoy-authz.fullname" . }}-authz + labels: + {{- include "envoy-authz.authzLabels" . | nindent 4 }} + {{- with .Values.authServer.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.authServer.service.type }} + ports: + - port: {{ .Values.authServer.service.port }} + targetPort: grpc + protocol: TCP + name: grpc + selector: + {{- include "envoy-authz.authzSelector" . | nindent 4 }} diff --git a/install/charts/envoy-authz/templates/service-envoy.yaml b/install/charts/envoy-authz/templates/service-envoy.yaml new file mode 100644 index 000000000..863b7624a --- /dev/null +++ b/install/charts/envoy-authz/templates/service-envoy.yaml @@ -0,0 +1,30 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +apiVersion: v1 +kind: Service +metadata: + name: {{ include "envoy-authz.fullname" . }}-envoy + labels: + {{- include "envoy-authz.envoyLabels" . | nindent 4 }} + {{- with .Values.envoy.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.envoy.service.type }} + ports: + - port: {{ .Values.envoy.service.port }} + targetPort: http + protocol: TCP + name: http + {{- if .Values.envoy.admin.enabled }} + - port: {{ .Values.envoy.admin.port }} + targetPort: admin + protocol: TCP + name: admin + {{- end }} + selector: + {{- include "envoy-authz.envoySelector" . | nindent 4 }} diff --git a/install/charts/envoy-authz/templates/serviceaccount.yaml b/install/charts/envoy-authz/templates/serviceaccount.yaml new file mode 100644 index 000000000..45f0fa74e --- /dev/null +++ b/install/charts/envoy-authz/templates/serviceaccount.yaml @@ -0,0 +1,25 @@ +{{/* +Copyright AGNTCY Contributors (https://github.com/agntcy) +SPDX-License-Identifier: Apache-2.0 +*/}} + +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "envoy-authz.serviceAccountName" . }} + labels: + {{- include "envoy-authz.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +--- +# Separate service account for Envoy (for SPIFFE ID) +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "envoy-authz.envoyServiceAccountName" . }} + labels: + {{- include "envoy-authz.envoyLabels" . | nindent 4 }} +{{- end }} diff --git a/install/charts/envoy-authz/values.yaml b/install/charts/envoy-authz/values.yaml new file mode 100644 index 000000000..5e4d0fe03 --- /dev/null +++ b/install/charts/envoy-authz/values.yaml @@ -0,0 +1,155 @@ +# Copyright AGNTCY Contributors (https://github.com/agntcy) +# SPDX-License-Identifier: Apache-2.0 + +# Global configuration +nameOverride: "" +fullnameOverride: "" + +# Envoy Gateway configuration +envoy: + replicaCount: 1 + + image: + repository: envoyproxy/envoy + tag: v1.31-latest + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 8080 + annotations: {} + + # Backend configuration (Directory API) + backend: + # Address of Directory API server + # Example: dir-dir-dev-argoapp-apiserver.dir-dev-dir.svc.cluster.local + address: "" + port: 8888 + # Connection timeout + timeout: 30s + + # SPIFFE integration for mTLS with Directory + spiffe: + enabled: true + # SPIFFE ID will be: spiffe://{trustDomain}/ns/{namespace}/sa/envoy-gateway + # Assigned automatically by SPIRE + + # Trust domain (must match SPIRE deployment) + trustDomain: example.org + + # SPIRE controller className (must match SPIRE Controller Manager) + className: dir-spire + + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + + # Admin interface (for debugging) + admin: + enabled: true + port: 9901 + +# Authorization Server configuration +authServer: + replicaCount: 1 + + # Image configuration + # Override these values to use custom images (e.g., development branches) + # image: + # repository: ghcr.io/agntcy/envoy-authz-dev + # tag: feat-feat-github-auth-ad4a58e + # pullPolicy: IfNotPresent + image: + repository: ghcr.io/agntcy/envoy-authz + tag: v0.1.0 + pullPolicy: IfNotPresent + + service: + type: ClusterIP + port: 9002 + + # Service configuration + config: + listenAddress: ":9002" + logLevel: info + defaultProvider: github + + # Authorization rules + authorization: + # Allowed GitHub organizations + # Empty list = allow all authenticated users + allowedOrgConstructs: [] + + # Explicitly allowed users (format: "github:username") + # Example: ["github:tkircsi"] + userAllowList: [] + + # Explicitly denied users (format: "github:username") + userDenyList: [] + + # GitHub provider configuration + github: + enabled: true + cacheTTL: 5m + apiTimeout: 10s + + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + +# Ingress configuration (optional) +ingress: + enabled: false + className: nginx + host: "" + annotations: + cert-manager.io/cluster-issuer: letsencrypt + external-dns.alpha.kubernetes.io/hostname: "" + # gRPC backend configuration (required for dirctl client over HTTPS) + nginx.ingress.kubernetes.io/backend-protocol: "GRPC" + nginx.ingress.kubernetes.io/grpc-backend: "true" + tls: + enabled: true + secretName: "" + +# Service account +serviceAccount: + create: true + name: "" + annotations: {} + +# Pod annotations +podAnnotations: {} + +# Pod security context +podSecurityContext: + runAsNonRoot: true + runAsUser: 65532 + fsGroup: 65532 + +# Container security context +securityContext: + allowPrivilegeEscalation: false + runAsNonRoot: true + runAsUser: 65532 + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + +# Node selector +nodeSelector: {} + +# Tolerations +tolerations: [] + +# Affinity +affinity: {}