Skip to content

Commit 18b05d0

Browse files
JAORMXclaudejhrozek
authored
Add GitHub.com OAuth authentication provider for token introspection (#2322)
* Add GitHub.com OAuth authentication provider for token introspection Implements a custom token introspection provider for GitHub.com OAuth that validates GitHub OAuth tokens via GitHub's token validation API. This enables per-user authentication scenarios where users authenticate with their own GitHub tokens. The provider implements the TokenIntrospector interface following the same pattern as the existing GoogleProvider, with automatic registration when GitHub API URLs are detected. Key Features: - Validates GitHub OAuth tokens via POST /applications/{client_id}/token - Maps GitHub user attributes to JWT claims for Cedar authorization - Supports claims: sub, login, email, scopes, site_admin, etc. - Integrates with existing OIDC middleware for automatic opaque token detection Security Hardening: - Strict URL validation (api.github.com only, HTTPS required) - SSRF protection via secured HTTP client with private IP blocking - Local rate limiting (100 req/sec) to prevent DoS attacks - GitHub API rate limit handling (429 responses with retry-after) Testing: - 10 comprehensive unit tests (all passing) - Security tests for SSRF, HTTPS enforcement, rate limiting - Linter clean (0 issues) Note: Configuration examples and documentation will be added after #2321 is resolved to enable secure secret management via SecretKeyRef. Related: #2321 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> Signed-off-by: Juan Antonio Osorio <[email protected]> * Refactor NewTokenValidator to reduce complexity Extract provider registration logic into a separate registerIntrospectionProviders helper function to improve code organization and reduce cyclomatic complexity. Changes: - Add registerIntrospectionProviders helper function that handles Google, GitHub, and RFC7662 provider registration - Move client secret environment variable loading before provider registration for better separation of concerns - Pass resolved clientSecret as parameter to helper function - Reduce NewTokenValidator cyclomatic complexity for linter compliance The refactoring maintains all existing behavior while making the code more maintainable and easier to test. Affected components: pkg/auth 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --------- Signed-off-by: Juan Antonio Osorio <[email protected]> Co-authored-by: Claude <[email protected]> Co-authored-by: Jakub Hrozek <[email protected]>
1 parent bb0e6c9 commit 18b05d0

File tree

3 files changed

+695
-21
lines changed

3 files changed

+695
-21
lines changed

pkg/auth/github_provider.go

Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Package auth provides authentication and authorization utilities.
2+
package auth
3+
4+
import (
5+
"bytes"
6+
"context"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"net/url"
12+
"strings"
13+
"time"
14+
15+
"github.com/golang-jwt/jwt/v5"
16+
"golang.org/x/time/rate"
17+
18+
"github.com/stacklok/toolhive/pkg/auth/oauth"
19+
"github.com/stacklok/toolhive/pkg/logger"
20+
"github.com/stacklok/toolhive/pkg/networking"
21+
)
22+
23+
// GitHubTokenCheckURL is the base URL pattern for GitHub OAuth token validation
24+
//
25+
//nolint:gosec // This is a URL pattern, not a credential
26+
const GitHubTokenCheckURL = "api.github.com/applications"
27+
28+
// GitHubProvider implements token introspection for GitHub.com's OAuth token validation API
29+
// GitHub uses a non-standard token validation endpoint that differs from RFC 7662
30+
// Endpoint: POST /applications/{client_id}/token
31+
// Auth: Basic (client_id:client_secret)
32+
// Body: {"access_token": "gho_..."}
33+
//
34+
// Note: This provider is designed for GitHub.com only, not GitHub Enterprise Server
35+
type GitHubProvider struct {
36+
client *http.Client
37+
clientID string
38+
clientSecret string
39+
baseURL string
40+
rateLimiter *rate.Limiter
41+
}
42+
43+
// NewGitHubProvider creates a new GitHub token introspection provider
44+
// Parameters:
45+
// - introspectURL: GitHub token validation endpoint (must be api.github.com with HTTPS)
46+
// - clientID: OAuth App client ID
47+
// - clientSecret: OAuth App client secret
48+
// - caCertPath: Path to CA certificate bundle (optional)
49+
// - allowPrivateIP: Allow private IP addresses (should be false for production)
50+
func NewGitHubProvider(
51+
introspectURL, clientID, clientSecret, caCertPath string,
52+
allowPrivateIP bool,
53+
) (*GitHubProvider, error) {
54+
return newGitHubProviderWithClient(introspectURL, clientID, clientSecret, caCertPath, allowPrivateIP, nil)
55+
}
56+
57+
// newGitHubProviderWithClient creates a new GitHub provider with custom client (for testing)
58+
func newGitHubProviderWithClient(
59+
introspectURL, clientID, clientSecret, caCertPath string,
60+
allowPrivateIP bool,
61+
customClient *http.Client,
62+
) (*GitHubProvider, error) {
63+
var client *http.Client
64+
var err error
65+
66+
if customClient != nil {
67+
// Use provided client (for testing)
68+
client = customClient
69+
} else {
70+
// Create secured HTTP client
71+
// Note: insecureAllowHTTP is always false for GitHub.com (requires HTTPS)
72+
client, err = networking.NewHttpClientBuilder().
73+
WithCABundle(caCertPath).
74+
WithPrivateIPs(allowPrivateIP).
75+
Build()
76+
if err != nil {
77+
return nil, fmt.Errorf("failed to create HTTP client: %w", err)
78+
}
79+
}
80+
81+
// Create rate limiter: 100 requests per second with burst of 200
82+
// GitHub API allows 5,000 requests/hour, but we rate limit locally to prevent abuse
83+
limiter := rate.NewLimiter(100, 200)
84+
85+
return &GitHubProvider{
86+
client: client,
87+
clientID: clientID,
88+
clientSecret: clientSecret,
89+
baseURL: introspectURL,
90+
rateLimiter: limiter,
91+
}, nil
92+
}
93+
94+
// Name returns the provider name
95+
func (*GitHubProvider) Name() string {
96+
return "github"
97+
}
98+
99+
// CanHandle returns true if this provider can handle the given introspection URL
100+
// This validates that the URL is a legitimate GitHub.com token validation endpoint
101+
// Note: GitHub Enterprise Server is NOT supported - use corporate IdP instead
102+
func (*GitHubProvider) CanHandle(introspectURL string) bool {
103+
// Parse URL to validate structure
104+
u, err := url.Parse(introspectURL)
105+
if err != nil {
106+
return false
107+
}
108+
109+
// Validate scheme (must be HTTPS)
110+
if u.Scheme != "https" {
111+
return false
112+
}
113+
114+
// Validate host - must be exactly api.github.com (GitHub.com only, no enterprise)
115+
if u.Host != "api.github.com" {
116+
return false
117+
}
118+
119+
// Validate path structure: /applications/{client_id}/token
120+
path := u.Path
121+
return strings.Contains(path, "/applications/") && strings.HasSuffix(path, "/token")
122+
}
123+
124+
// IntrospectToken introspects a GitHub OAuth token and returns JWT claims
125+
// This calls GitHub's token validation API to verify the token and extract user information
126+
func (g *GitHubProvider) IntrospectToken(ctx context.Context, token string) (jwt.MapClaims, error) {
127+
logger.Debugf("Using GitHub token validation provider: %s", g.baseURL)
128+
129+
// Apply rate limiting to prevent DoS and respect GitHub API limits
130+
if err := g.rateLimiter.Wait(ctx); err != nil {
131+
return nil, fmt.Errorf("rate limit wait failed: %w", err)
132+
}
133+
134+
// Create request body with the access token
135+
reqBody := map[string]string{"access_token": token}
136+
bodyBytes, err := json.Marshal(reqBody)
137+
if err != nil {
138+
return nil, fmt.Errorf("failed to marshal request body: %w", err)
139+
}
140+
141+
// Create POST request
142+
req, err := http.NewRequestWithContext(ctx, "POST", g.baseURL, bytes.NewReader(bodyBytes))
143+
if err != nil {
144+
return nil, fmt.Errorf("failed to create GitHub validation request: %w", err)
145+
}
146+
147+
// Set headers
148+
req.Header.Set("Content-Type", "application/json")
149+
req.Header.Set("Accept", "application/json")
150+
req.Header.Set("User-Agent", oauth.UserAgent)
151+
152+
// GitHub requires Basic Auth with OAuth App credentials
153+
req.SetBasicAuth(g.clientID, g.clientSecret)
154+
155+
// Make the request
156+
resp, err := g.client.Do(req)
157+
if err != nil {
158+
return nil, fmt.Errorf("github validation request failed: %w", err)
159+
}
160+
defer resp.Body.Close()
161+
162+
// Read the response with a reasonable limit to prevent DoS attacks
163+
const maxResponseSize = 64 * 1024 // 64KB should be more than enough
164+
limitedReader := io.LimitReader(resp.Body, maxResponseSize)
165+
body, err := io.ReadAll(limitedReader)
166+
if err != nil {
167+
return nil, fmt.Errorf("failed to read GitHub validation response: %w", err)
168+
}
169+
170+
// Check for HTTP errors
171+
if resp.StatusCode == http.StatusNotFound {
172+
// 404 means token is invalid or doesn't belong to this OAuth App
173+
return nil, ErrInvalidToken
174+
}
175+
if resp.StatusCode == http.StatusTooManyRequests {
176+
// 429 means we've hit GitHub's rate limit
177+
retryAfter := resp.Header.Get("Retry-After")
178+
remaining := resp.Header.Get("X-RateLimit-Remaining")
179+
reset := resp.Header.Get("X-RateLimit-Reset")
180+
logger.Warnf("GitHub rate limit exceeded - Retry-After: %s, Remaining: %s, Reset: %s",
181+
retryAfter, remaining, reset)
182+
return nil, fmt.Errorf("github rate limit exceeded, retry after: %s", retryAfter)
183+
}
184+
if resp.StatusCode != http.StatusOK {
185+
return nil, fmt.Errorf("github validation failed with status %d: %s", resp.StatusCode, string(body))
186+
}
187+
188+
// Parse the GitHub response and convert to JWT claims
189+
logger.Debugf("Successfully validated GitHub token (status: %d)", resp.StatusCode)
190+
return g.parseGitHubResponse(body)
191+
}
192+
193+
// parseGitHubResponse parses GitHub's token validation response and converts it to JWT claims
194+
func (*GitHubProvider) parseGitHubResponse(body []byte) (jwt.MapClaims, error) {
195+
// Parse GitHub's response format
196+
// Reference: https://docs.github.com/en/rest/apps/oauth-applications#check-a-token
197+
var githubResp struct {
198+
ID int64 `json:"id"`
199+
Token string `json:"token"`
200+
User struct {
201+
Login string `json:"login"`
202+
ID int64 `json:"id"`
203+
NodeID string `json:"node_id"`
204+
Email string `json:"email"`
205+
Name string `json:"name"`
206+
Type string `json:"type"`
207+
SiteAdmin bool `json:"site_admin"`
208+
} `json:"user"`
209+
Scopes []string `json:"scopes"`
210+
CreatedAt string `json:"created_at"`
211+
UpdatedAt string `json:"updated_at"`
212+
App struct {
213+
Name string `json:"name"`
214+
URL string `json:"url"`
215+
ClientID string `json:"client_id"`
216+
} `json:"app"`
217+
}
218+
219+
if err := json.Unmarshal(body, &githubResp); err != nil {
220+
return nil, fmt.Errorf("failed to decode GitHub response: %w", err)
221+
}
222+
223+
// Convert to JWT MapClaims format
224+
claims := jwt.MapClaims{
225+
"iss": "https://github.com", // Fixed issuer for GitHub
226+
"aud": "https://github.com", // Use issuer as audience
227+
// Mark token as active (consistent with RFC 7662 behavior)
228+
"active": true,
229+
}
230+
231+
// Subject (sub) - use GitHub user ID as the unique identifier
232+
if githubResp.User.ID != 0 {
233+
claims["sub"] = fmt.Sprintf("%d", githubResp.User.ID)
234+
} else {
235+
return nil, fmt.Errorf("missing user ID in GitHub response")
236+
}
237+
238+
// User information
239+
if githubResp.User.Login != "" {
240+
claims["preferred_username"] = githubResp.User.Login
241+
claims["login"] = githubResp.User.Login // GitHub-specific claim
242+
}
243+
if githubResp.User.Email != "" {
244+
claims["email"] = githubResp.User.Email
245+
}
246+
if githubResp.User.Name != "" {
247+
claims["name"] = githubResp.User.Name
248+
}
249+
250+
// Parse created_at for iat (issued at) claim
251+
if githubResp.CreatedAt != "" {
252+
if t, err := time.Parse(time.RFC3339, githubResp.CreatedAt); err == nil {
253+
claims["iat"] = float64(t.Unix())
254+
}
255+
}
256+
257+
// Add scopes - GitHub returns them as an array
258+
if len(githubResp.Scopes) > 0 {
259+
claims["scopes"] = githubResp.Scopes
260+
// Also add as space-separated string for compatibility
261+
claims["scope"] = strings.Join(githubResp.Scopes, " ")
262+
}
263+
264+
// GitHub-specific claims for advanced policies
265+
if githubResp.User.Type != "" {
266+
claims["user_type"] = githubResp.User.Type
267+
}
268+
if githubResp.User.SiteAdmin {
269+
claims["site_admin"] = true
270+
}
271+
if githubResp.App.Name != "" {
272+
claims["app_name"] = githubResp.App.Name
273+
}
274+
275+
// Note: GitHub OAuth tokens don't have a standard expiration
276+
// They remain valid until revoked by the user or the app
277+
// We rely on the introspection call to validate token freshness
278+
279+
return claims, nil
280+
}

0 commit comments

Comments
 (0)