github.go

  1package provider
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"net/http"
  8
  9	"golang.org/x/oauth2"
 10	"golang.org/x/oauth2/github"
 11)
 12
 13var _ Provider = &GitHub{}
 14
 15// GitHub implements Provider for GitHub OAuth2.
 16// It uses the standard authorization-code flow (not the device flow used by
 17// the bridge) because the webui has a browser redirect available.
 18//
 19// GitHub does not support OpenID Connect, so this provider uses the GitHub
 20// REST API to fetch profile and public key data after the token exchange.
 21type GitHub struct {
 22	clientID     string
 23	clientSecret string
 24}
 25
 26func NewGitHub(clientID, clientSecret string) *GitHub {
 27	return &GitHub{clientID: clientID, clientSecret: clientSecret}
 28}
 29
 30func (g *GitHub) Name() string      { return "github" }
 31func (g *GitHub) HumanName() string { return "GitHub" }
 32
 33func (g *GitHub) config(callbackURL string) *oauth2.Config {
 34	return &oauth2.Config{
 35		ClientID:     g.clientID,
 36		ClientSecret: g.clientSecret,
 37		Endpoint:     github.Endpoint,
 38		RedirectURL:  callbackURL,
 39		// read:user for profile; user:email to get the primary email even when
 40		// the user's email is set to private on their GitHub profile.
 41		Scopes: []string{"read:user", "user:email"},
 42	}
 43}
 44
 45func (g *GitHub) AuthURL(state, callbackURL string) string {
 46	return g.config(callbackURL).AuthCodeURL(state, oauth2.AccessTypeOnline)
 47}
 48
 49func (g *GitHub) Exchange(ctx context.Context, code, callbackURL string) (*UserInfo, error) {
 50	token, err := g.config(callbackURL).Exchange(ctx, code)
 51	if err != nil {
 52		return nil, fmt.Errorf("github: token exchange: %w", err)
 53	}
 54
 55	client := g.config(callbackURL).Client(ctx, token)
 56
 57	user, err := g.fetchProfile(client)
 58	if err != nil {
 59		return nil, err
 60	}
 61
 62	user.PublicKeys, err = g.fetchPublicKeys(client, user.Login)
 63	if err != nil {
 64		// Public keys are best-effort; a failure here should not block login.
 65		user.PublicKeys = nil
 66	}
 67
 68	return user, nil
 69}
 70
 71func (g *GitHub) fetchProfile(client *http.Client) (*UserInfo, error) {
 72	resp, err := client.Get("https://api.github.com/user")
 73	if err != nil {
 74		return nil, fmt.Errorf("github: fetch profile: %w", err)
 75	}
 76	defer resp.Body.Close()
 77
 78	if resp.StatusCode != http.StatusOK {
 79		return nil, fmt.Errorf("github: unexpected status %d from /user", resp.StatusCode)
 80	}
 81
 82	var u struct {
 83		Login     string `json:"login"`
 84		Email     string `json:"email"`
 85		Name      string `json:"name"`
 86		AvatarURL string `json:"avatar_url"`
 87	}
 88	if err := json.NewDecoder(resp.Body).Decode(&u); err != nil {
 89		return nil, fmt.Errorf("github: decode profile: %w", err)
 90	}
 91
 92	return &UserInfo{
 93		Login:     u.Login,
 94		Email:     u.Email,
 95		Name:      u.Name,
 96		AvatarURL: u.AvatarURL,
 97	}, nil
 98}
 99
100// fetchPublicKeys retrieves the user's public SSH keys from the GitHub API.
101// Returns the raw key strings (e.g. "ssh-ed25519 AAAA...").
102func (g *GitHub) fetchPublicKeys(client *http.Client, login string) ([]string, error) {
103	resp, err := client.Get("https://api.github.com/users/" + login + "/keys")
104	if err != nil {
105		return nil, fmt.Errorf("github: fetch keys: %w", err)
106	}
107	defer resp.Body.Close()
108
109	if resp.StatusCode != http.StatusOK {
110		return nil, fmt.Errorf("github: unexpected status %d from /keys", resp.StatusCode)
111	}
112
113	var keys []struct {
114		Key string `json:"key"`
115	}
116	if err := json.NewDecoder(resp.Body).Decode(&keys); err != nil {
117		return nil, fmt.Errorf("github: decode keys: %w", err)
118	}
119
120	result := make([]string, len(keys))
121	for i, k := range keys {
122		result[i] = k.Key
123	}
124	return result, nil
125}