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}