• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pomerium / pomerium / 30027186414

23 Jul 2026 04:55PM UTC coverage: 52.531% (-0.001%) from 52.532%
30027186414

push

github

web-flow
feat(github): use node_ids for user ids (#6573)

## Summary
Currently we use `login` as the `user_id` in the GitHub IdP. This PR
switches it to `node_id`. It also adds some additional claims. The
original `login` is available as the `preferred_username`, which is a
standard claim in the spec.

This is a breaking change since any user IDs referenced in policies or
directory sync would now be different.

## Related issues
-
[ENG-4266](https://linear.app/pomerium/issue/ENG-4266/coregithub-use-github-node-id-as-user-id)


## AI disclosure
none

## Checklist

- [x] reference any related issues
- [x] updated unit tests
- [x] add appropriate label (`enhancement`, `bug`, `breaking`,
`dependencies`, `ci`)
- [x] disclosed AI usage (or wrote "none") per AI_POLICY.md
- [x] ready for review

28 of 29 new or added lines in 3 files covered. (96.55%)

29 existing lines in 9 files now uncovered.

37255 of 70920 relevant lines covered (52.53%)

462.38 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

55.72
/pkg/identity/oauth/github/github.go
1
// Package github implements OAuth2 based authentication for github
2
//
3
// https://www.pomerium.com/docs/identity-providers/github
4
package github
5

6
import (
7
        "context"
8
        "encoding/json"
9
        "errors"
10
        "fmt"
11
        "net/http"
12
        "net/url"
13
        "strings"
14
        "time"
15

16
        "github.com/go-jose/go-jose/v3/jwt"
17
        "golang.org/x/oauth2"
18

19
        "github.com/pomerium/pomerium/internal/httputil"
20
        "github.com/pomerium/pomerium/internal/jwtutil"
21
        "github.com/pomerium/pomerium/internal/log"
22
        "github.com/pomerium/pomerium/internal/urlutil"
23
        "github.com/pomerium/pomerium/internal/version"
24
        "github.com/pomerium/pomerium/pkg/identity/identity"
25
        "github.com/pomerium/pomerium/pkg/identity/oauth"
26
        "github.com/pomerium/pomerium/pkg/identity/oidc"
27
        "github.com/pomerium/pomerium/pkg/identity/pkce"
28
)
29

30
// Name identifies the GitHub identity provider
31
const Name = "github"
32

33
// Version identifies the GitHub identity provider version.
34
// Version 0 used logins as the user id. Version 1 uses node_ids.
35
const Version = 1
36

37
const (
38
        defaultProviderURL = "https://github.com"
39
        githubAPIURL       = "https://api.github.com"
40
        userPath           = "/user"
41
        revokePath         = "/applications/%s/grant"
42
        emailPath          = "/user/emails"
43
        // https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps
44
        authURL  = "/login/oauth/authorize"
45
        tokenURL = "/login/oauth/access_token" //nolint:gosec
46

47
        // since github doesn't implement oidc, we need this to refresh the user session
48
        refreshDeadline = time.Minute * 60
49
)
50

51
var maxTime = time.Unix(253370793661, 0) // year 9999
52

53
// https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
54
var defaultScopes = []string{"user:email", "read:org"}
55

56
// Provider is an implementation of the OAuth Provider.
57
type Provider struct {
58
        Oauth *oauth2.Config
59

60
        userEndpoint  string
61
        emailEndpoint string
62
}
63

64
// New instantiates an OAuth2 provider for Github.
65
func New(_ context.Context, o *oauth.Options) (*Provider, error) {
1✔
66
        p := Provider{}
1✔
67
        if o.ProviderURL == "" {
1✔
68
                o.ProviderURL = defaultProviderURL
×
69
        }
×
70

71
        // when the default provider url is used, use the Github API endpoint
72
        if o.ProviderURL == defaultProviderURL {
1✔
73
                p.userEndpoint = urlutil.Join(githubAPIURL, userPath)
×
74
                p.emailEndpoint = urlutil.Join(githubAPIURL, emailPath)
×
75
        } else {
1✔
76
                p.userEndpoint = urlutil.Join(o.ProviderURL, userPath)
1✔
77
                p.emailEndpoint = urlutil.Join(o.ProviderURL, emailPath)
1✔
78
        }
1✔
79

80
        if len(o.Scopes) == 0 {
2✔
81
                o.Scopes = defaultScopes
1✔
82
        }
1✔
83
        p.Oauth = &oauth2.Config{
1✔
84
                ClientID:     o.ClientID,
1✔
85
                ClientSecret: o.ClientSecret,
1✔
86
                Scopes:       o.Scopes,
1✔
87
                RedirectURL:  o.RedirectURL.String(),
1✔
88
                Endpoint: oauth2.Endpoint{
1✔
89
                        AuthURL:  urlutil.Join(o.ProviderURL, authURL),
1✔
90
                        TokenURL: urlutil.Join(o.ProviderURL, tokenURL),
1✔
91
                },
1✔
92
        }
1✔
93
        return &p, nil
1✔
94
}
95

96
// Authenticate creates an identity session with github from a authorization code, and follows up
97
// call to the user and user group endpoint with the
98
func (p *Provider) Authenticate(ctx context.Context, code string, v identity.State) (*oauth2.Token, error) {
×
99
        oauth2Token, err := p.Oauth.Exchange(ctx, code)
×
100
        if err != nil {
×
101
                return nil, fmt.Errorf("github: token exchange failed %w", err)
×
102
        }
×
103

104
        // github tokens never expire
105
        oauth2Token.Expiry = maxTime
×
106

×
107
        err = p.UpdateUserInfo(ctx, oauth2Token, v)
×
108
        if err != nil {
×
109
                return nil, err
×
110
        }
×
111

112
        return oauth2Token, nil
×
113
}
114

115
// UpdateUserInfo will get the user information from github and also retrieve the user's team(s)
116
//
117
// https://developer.github.com/v3/users/#get-the-authenticated-user
118
func (p *Provider) UpdateUserInfo(ctx context.Context, t *oauth2.Token, v any) error {
×
119
        err := p.userInfo(ctx, t, v)
×
120
        if err != nil {
×
121
                return fmt.Errorf("github: could not retrieve user info %w", err)
×
122
        }
×
123

124
        err = p.userEmail(ctx, t, v)
×
125
        if err != nil {
×
126
                return fmt.Errorf("github: could not retrieve user email %w", err)
×
127
        }
×
128

129
        return nil
×
130
}
131

132
// Refresh is a no-op for github, because github sessions never expire.
133
func (p *Provider) Refresh(_ context.Context, t *oauth2.Token, _ identity.State) (*oauth2.Token, error) {
×
134
        t.Expiry = time.Now().Add(refreshDeadline)
×
135
        return t, nil
×
136
}
×
137

138
// userEmail returns the primary email of the user by making
139
// a query to github API.
140
//
141
// https://developer.github.com/v3/users/emails/#list-email-addresses-for-a-user
142
// https://developer.github.com/v3/auth/
143
func (p *Provider) userEmail(ctx context.Context, t *oauth2.Token, v any) error {
1✔
144
        // response represents the github user email
1✔
145
        // https://developer.github.com/v3/users/emails/#response
1✔
146
        var response []struct {
1✔
147
                Email      string `json:"email"`
1✔
148
                Verified   bool   `json:"verified"`
1✔
149
                Primary    bool   `json:"primary"`
1✔
150
                Visibility string `json:"visibility"`
1✔
151
        }
1✔
152
        headers := map[string]string{"Authorization": fmt.Sprintf("token %s", t.AccessToken)}
1✔
153
        err := httputil.Do(ctx, http.MethodGet, p.emailEndpoint, version.UserAgent(), headers, nil, &response)
1✔
154
        if err != nil {
1✔
155
                return err
×
156
        }
×
157
        var out struct {
1✔
158
                Email    string `json:"email"`
1✔
159
                Verified bool   `json:"email_verified"`
1✔
160
        }
1✔
161
        log.Ctx(ctx).Debug().Interface("emails", response).Msg("github: user emails")
1✔
162
        for _, email := range response {
2✔
163
                if email.Primary && email.Verified {
2✔
164
                        out.Email = email.Email
1✔
165
                        out.Verified = true
1✔
166
                        break
1✔
167
                }
168
        }
169
        b, err := json.Marshal(out)
1✔
170
        if err != nil {
1✔
171
                return err
×
172
        }
×
173
        return json.Unmarshal(b, v)
1✔
174
}
175

176
func (p *Provider) userInfo(ctx context.Context, t *oauth2.Token, v any) error {
1✔
177
        var response struct {
1✔
178
                AvatarURL string `json:"avatar_url,omitempty"`
1✔
179
                Blog      string `json:"blog,omitempty"`
1✔
180
                HTMLURL   string `json:"html_url,omitempty"`
1✔
181
                ID        int    `json:"id"`
1✔
182
                Login     string `json:"login"`
1✔
183
                Name      string `json:"name,omitempty"`
1✔
184
                NodeID    string `json:"node_id"`
1✔
185
        }
1✔
186

1✔
187
        headers := map[string]string{
1✔
188
                "Authorization": fmt.Sprintf("token %s", t.AccessToken),
1✔
189
                "Accept":        "application/vnd.github.v3+json",
1✔
190
        }
1✔
191
        err := httputil.Do(ctx, http.MethodGet, p.userEndpoint, version.UserAgent(), headers, nil, &response)
1✔
192
        if err != nil {
1✔
193
                return err
×
194
        }
×
195
        var out struct {
1✔
196
                Name              string `json:"name,omitempty"`
1✔
197
                Picture           string `json:"picture,omitempty"`
1✔
198
                PreferredUsername string `json:"preferred_username"`
1✔
199
                Profile           string `json:"profile,omitempty"`
1✔
200
                Subject           string `json:"sub"`
1✔
201
                User              string `json:"user"`
1✔
202
                Website           string `json:"website,omitempty"`
1✔
203
                // needs to be set manually
1✔
204
                Expiry    *jwt.NumericDate `json:"exp,omitempty"`
1✔
205
                IssuedAt  *jwt.NumericDate `json:"iat,omitempty"`
1✔
206
                NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
1✔
207
        }
1✔
208

1✔
209
        out.Expiry = jwt.NewNumericDate(time.Now().Add(refreshDeadline))
1✔
210
        out.IssuedAt = jwt.NewNumericDate(time.Now())
1✔
211
        out.Name = response.Name
1✔
212
        out.NotBefore = jwt.NewNumericDate(time.Now())
1✔
213
        out.Picture = response.AvatarURL
1✔
214
        out.PreferredUsername = response.Login
1✔
215
        out.Profile = response.HTMLURL
1✔
216
        out.Subject = firstNonZero(response.NodeID, fmt.Sprint(response.ID))
1✔
217
        out.User = response.Login
1✔
218
        out.Website = response.Blog
1✔
219

1✔
220
        b, err := json.Marshal(out)
1✔
221
        if err != nil {
1✔
222
                return err
×
223
        }
×
224
        return json.Unmarshal(b, v)
1✔
225
}
226

227
// Revoke method will remove all the github grants the user
228
// gave pomerium application during authorization.
229
//
230
// https://developer.github.com/v3/apps/oauth_applications/#delete-an-app-authorization
231
func (p *Provider) Revoke(ctx context.Context, token *oauth2.Token) error {
×
232
        // build the basic authentication request
×
233
        basicAuth := url.UserPassword(p.Oauth.ClientID, p.Oauth.ClientSecret)
×
234
        revokeURL := url.URL{
×
235
                Scheme: "https",
×
236
                User:   basicAuth,
×
237
                Host:   "api.github.com",
×
238
                Path:   fmt.Sprintf(revokePath, p.Oauth.ClientID),
×
239
        }
×
240
        reqBody := strings.NewReader(fmt.Sprintf(`{"access_token": "%s"}`, token.AccessToken))
×
241
        req, err := http.NewRequestWithContext(ctx, http.MethodDelete, revokeURL.String(), reqBody)
×
242
        if err != nil {
×
243
                return errors.New("github: could not create revoke request")
×
244
        }
×
245

246
        req.Header.Set("Content-Type", "application/json")
×
247
        client := &http.Client{}
×
248
        resp, err := client.Do(req)
×
249
        if err != nil {
×
250
                return err
×
251
        }
×
252
        defer resp.Body.Close()
×
253

×
254
        return nil
×
255
}
256

257
// Name returns the provider name.
258
func (p *Provider) Name() string {
×
259
        return Name
×
260
}
×
261

262
// SignIn redirects to the OAuth 2.0 provider's consent page
263
// that asks for permissions for the required scopes explicitly.
264
func (p *Provider) SignIn(w http.ResponseWriter, r *http.Request, state string) error {
×
265
        opts := []oauth2.AuthCodeOption{oauth2.AccessTypeOffline}
×
266
        if pkceParams, ok := pkce.FromContext(r.Context()); ok {
×
267
                opts = append(opts, pkce.AuthCodeOptions(pkceParams)...)
×
268
        }
×
269
        signInURL := p.Oauth.AuthCodeURL(state, opts...)
×
270
        httputil.Redirect(w, r, signInURL, http.StatusFound)
×
271
        return nil
×
272
}
273

274
// SignOut is not implemented.
275
func (p *Provider) SignOut(_ http.ResponseWriter, _ *http.Request, _, _, _ string) error {
×
276
        return oidc.ErrSignoutNotImplemented
×
277
}
×
278

279
func (p *Provider) DeviceAuth(_ context.Context) (*oauth2.DeviceAuthResponse, error) {
×
280
        return nil, oidc.ErrDeviceAuthNotImplemented
×
281
}
×
282

283
func (p *Provider) DeviceAccessToken(_ context.Context, _ *oauth2.DeviceAuthResponse, _ identity.State) (*oauth2.Token, error) {
×
284
        return nil, oidc.ErrDeviceAuthNotImplemented
×
285
}
×
286

287
// VerifyAccessToken verifies an access token.
288
func (p *Provider) VerifyAccessToken(ctx context.Context, rawAccessToken string) (claims map[string]any, err error) {
1✔
289
        claims = jwtutil.Claims(map[string]any{})
1✔
290

1✔
291
        err = p.userInfo(ctx, &oauth2.Token{
1✔
292
                TokenType:   "Bearer",
1✔
293
                AccessToken: rawAccessToken,
1✔
294
        }, &claims)
1✔
295
        if err != nil {
1✔
296
                return nil, fmt.Errorf("error retrieving user info with access token: %w", err)
×
297
        }
×
298

299
        err = p.userEmail(ctx, &oauth2.Token{
1✔
300
                TokenType:   "Bearer",
1✔
301
                AccessToken: rawAccessToken,
1✔
302
        }, &claims)
1✔
303
        if err != nil {
1✔
304
                return nil, fmt.Errorf("error retrieving user email with access token: %w", err)
×
305
        }
×
306

307
        return claims, nil
1✔
308
}
309

310
// VerifyIdentityToken verifies an identity token.
311
func (p *Provider) VerifyIdentityToken(_ context.Context, _ string) (claims map[string]any, err error) {
×
312
        return nil, identity.ErrVerifyIdentityTokenNotSupported
×
313
}
×
314

315
func firstNonZero[T comparable](values ...T) T {
1✔
316
        var zero T
1✔
317
        for _, value := range values {
2✔
318
                if value != zero {
2✔
319
                        return value
1✔
320
                }
1✔
321
        }
NEW
322
        return zero
×
323
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc