Files
synapbus/internal/auth/idp/github.go
T
Algis DumbrisandClaude Opus 4.6 87f24afd58 feat: enterprise identity provider support — GitHub, Google, Azure AD login
Add external IdP authentication via OAuth (GitHub) and OIDC (Google, Azure AD).
Users can sign in with enterprise credentials; accounts are auto-provisioned
and linked on first login. Configured entirely via environment variables.

- schema/011_external_auth.sql: user_identities table + email column on users
- internal/auth/idp/: provider interface, GitHub OAuth, generic OIDC, store,
  handlers (list providers, login redirect, callback with auto-provisioning)
- internal/auth/user_store.go: GetUserByEmail + SetEmail for IdP linking
- cmd/synapbus/main.go: wire IdP routes + agent provisioner adapter
- web/src/routes/login/+page.svelte: IdP buttons above password form
- Tests: domain restriction, store CRUD, provider listing, user provisioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 20:37:27 +02:00

141 lines
3.5 KiB
Go

package idp
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
// GitHubProvider implements GitHub OAuth authentication.
// GitHub does not support OIDC discovery, so this uses plain OAuth 2.0
// with GitHub's user API endpoints.
type GitHubProvider struct {
config *oauth2.Config
}
// NewGitHubProvider creates a new GitHub OAuth provider.
func NewGitHubProvider(clientID, clientSecret, redirectURL string) *GitHubProvider {
return &GitHubProvider{
config: &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: redirectURL,
Scopes: []string{"read:user", "user:email"},
Endpoint: github.Endpoint,
},
}
}
func (p *GitHubProvider) ID() string { return "github" }
func (p *GitHubProvider) Type() string { return "oauth" }
func (p *GitHubProvider) DisplayName() string { return "GitHub" }
func (p *GitHubProvider) AuthCodeURL(state string) string {
return p.config.AuthCodeURL(state)
}
func (p *GitHubProvider) Exchange(ctx context.Context, code string) (*ExternalUser, error) {
token, err := p.config.Exchange(ctx, code)
if err != nil {
return nil, fmt.Errorf("github: exchange code: %w", err)
}
client := p.config.Client(ctx, token)
// Fetch user profile
userInfo, err := fetchGitHubUser(client)
if err != nil {
return nil, err
}
// Fetch primary verified email
email, err := fetchGitHubPrimaryEmail(client)
if err != nil {
// Non-fatal: email might not be available
email = ""
}
// If user profile has an email and we didn't get one from the emails endpoint, use it
if email == "" {
if e, ok := userInfo["email"].(string); ok && e != "" {
email = e
}
}
idNum, _ := userInfo["id"].(float64)
login, _ := userInfo["login"].(string)
name, _ := userInfo["name"].(string)
return &ExternalUser{
ProviderID: "github",
ExternalID: strconv.FormatInt(int64(idNum), 10),
Email: email,
Name: name,
Username: login,
RawClaims: userInfo,
}, nil
}
func fetchGitHubUser(client *http.Client) (map[string]any, error) {
resp, err := client.Get("https://api.github.com/user")
if err != nil {
return nil, fmt.Errorf("github: fetch user: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("github: user API returned %d: %s", resp.StatusCode, body)
}
var user map[string]any
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("github: decode user: %w", err)
}
return user, nil
}
// githubEmail represents an email entry from the GitHub emails API.
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
func fetchGitHubPrimaryEmail(client *http.Client) (string, error) {
resp, err := client.Get("https://api.github.com/user/emails")
if err != nil {
return "", fmt.Errorf("github: fetch emails: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("github: emails API returned %d", resp.StatusCode)
}
var emails []githubEmail
if err := json.NewDecoder(resp.Body).Decode(&emails); err != nil {
return "", fmt.Errorf("github: decode emails: %w", err)
}
// Find primary verified email
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
// Fallback: first verified email
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", fmt.Errorf("github: no verified email found")
}