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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/reusable-release-helm.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
chart: [dir, dirctl]
chart: [dir, dirctl, envoy-authz]
permissions:
packages: write
steps:
Expand All @@ -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 }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions auth/authprovider/constants.go
Original file line number Diff line number Diff line change
@@ -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"
)
278 changes: 278 additions & 0 deletions auth/authprovider/github/provider.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading