Skip to content

chore: ensure github uids are unique #11826

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion coderd/coderdtest/oidctest/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -67,6 +68,9 @@ type FakeIDP struct {
handler http.Handler
cfg *oauth2.Config

// callbackPath allows changing where the callback path to coderd is expected.
// This only affects using the Login helper functions.
callbackPath string
// clientID to be used by coderd
clientID string
clientSecret string
Expand Down Expand Up @@ -161,6 +165,12 @@ func WithDefaultExpire(d time.Duration) func(*FakeIDP) {
}
}

func WithCallbackPath(path string) func(*FakeIDP) {
return func(f *FakeIDP) {
f.callbackPath = path
}
}

func WithStaticCredentials(id, secret string) func(*FakeIDP) {
return func(f *FakeIDP) {
if id != "" {
Expand Down Expand Up @@ -369,6 +379,12 @@ func (f *FakeIDP) Login(t testing.TB, client *codersdk.Client, idTokenClaims jwt
t.Helper()

client, resp := f.AttemptLogin(t, client, idTokenClaims, opts...)
if resp.StatusCode != http.StatusOK {
data, err := httputil.DumpResponse(resp, true)
if err == nil {
t.Logf("Attempt Login response payload\n%s", string(data))
}
}
require.Equal(t, http.StatusOK, resp.StatusCode, "client failed to login")
return client, resp
}
Expand Down Expand Up @@ -398,7 +414,11 @@ func (f *FakeIDP) AttemptLogin(t testing.TB, client *codersdk.Client, idTokenCla
func (f *FakeIDP) LoginWithClient(t testing.TB, client *codersdk.Client, idTokenClaims jwt.MapClaims, opts ...func(r *http.Request)) (*codersdk.Client, *http.Response) {
t.Helper()

coderOauthURL, err := client.URL.Parse("/api/v2/users/oidc/callback")
path := "/api/v2/users/oidc/callback"
if f.callbackPath != "" {
path = f.callbackPath
}
coderOauthURL, err := client.URL.Parse(path)
require.NoError(t, err)
f.SetRedirect(t, coderOauthURL.String())

Expand Down
19 changes: 19 additions & 0 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,25 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
return
}

// If we have a nil GitHub ID, that is a big problem. That would mean we link
// this user and all other users with this bug to the same uuid.
// We should instead throw an error. This should never occur in production.
//
// Verified that the lowest ID on GitHub is "1", so 0 should never occur.
if ghUser.GetID() == 0 {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "The GitHub user ID is missing, this should never happen. Please report this error.",
// If this happens, the User could either be:
// - Empty, in which case all these fields would also be empty.
// - Not a user, in which case the "Type" would be something other than "User"
Detail: fmt.Sprintf("Other user fields: name=%q, email=%q, type=%q",
ghUser.GetName(),
ghUser.GetEmail(),
ghUser.GetType(),
Comment on lines +618 to +621
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this potentially leak another user's email? Would it be safer to log this instead?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No it cannot. This comes from the authenticated request to github using your oauth token.

So this payload comes from:

  • User does github oauth flow with coder
  • Oauth flow succeeds
  • Coder uses their oauth token to determine to make a new user or link to an existing
    • In this flow, the AuthentictedUser request fails from an id=0.

To leak someone else email, you would need to authenticate as that user via github.

),
})
return
}
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("gh_user", ghUser.Name), slog.Error(err))
Expand Down
97 changes: 95 additions & 2 deletions coderd/userauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/golang-jwt/jwt/v4"
"github.com/google/go-github/v43/github"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"

Expand All @@ -25,6 +26,7 @@ import (
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbgen"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/promoauth"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
Expand Down Expand Up @@ -205,6 +207,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("kyle"),
}, nil
},
Expand Down Expand Up @@ -264,7 +267,9 @@ func TestUserOAuth2Github(t *testing.T) {
}}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{}, nil
return &github.User{
ID: github.Int64(100),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Expand Down Expand Up @@ -295,7 +300,9 @@ func TestUserOAuth2Github(t *testing.T) {
}}, nil
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{}, nil
return &github.User{
ID: github.Int64(100),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return []*github.UserEmail{{
Expand Down Expand Up @@ -390,6 +397,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("kyle"),
}, nil
},
Expand Down Expand Up @@ -442,6 +450,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
}, nil
},
Expand Down Expand Up @@ -494,6 +503,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
}, nil
},
Expand Down Expand Up @@ -532,6 +542,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("mathias"),
}, nil
},
Expand Down Expand Up @@ -574,6 +585,7 @@ func TestUserOAuth2Github(t *testing.T) {
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
ID: github.Int64(100),
Login: github.String("kyle"),
}, nil
},
Expand All @@ -591,6 +603,87 @@ func TestUserOAuth2Github(t *testing.T) {

require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
})
t.Run("ChangedEmail", func(t *testing.T) {
t.Parallel()

fake := oidctest.NewFakeIDP(t,
oidctest.WithServing(),
oidctest.WithCallbackPath("/api/v2/users/oauth2/github/callback"),
)
const ghID = int64(7777)
auditor := audit.NewMock()
coderEmail := &github.UserEmail{
Email: github.String("alice@coder.com"),
Verified: github.Bool(true),
Primary: github.Bool(true),
}
gmailEmail := &github.UserEmail{
Email: github.String("alice@gmail.com"),
Verified: github.Bool(true),
Primary: github.Bool(false),
}
emails := []*github.UserEmail{
gmailEmail,
coderEmail,
}

client := coderdtest.New(t, &coderdtest.Options{
Auditor: auditor,
GithubOAuth2Config: &coderd.GithubOAuth2Config{
AllowSignups: true,
AllowEveryone: true,
OAuth2Config: promoauth.NewFactory(prometheus.NewRegistry()).NewGithub("test-github", fake.OIDCConfig(t, []string{})),
ListOrganizationMemberships: func(ctx context.Context, client *http.Client) ([]*github.Membership, error) {
return []*github.Membership{}, nil
},
TeamMembership: func(ctx context.Context, client *http.Client, org, team, username string) (*github.Membership, error) {
return nil, xerrors.New("no teams")
},
AuthenticatedUser: func(ctx context.Context, client *http.Client) (*github.User, error) {
return &github.User{
Login: github.String("alice"),
ID: github.Int64(ghID),
}, nil
},
ListEmails: func(ctx context.Context, client *http.Client) ([]*github.UserEmail, error) {
return emails, nil
},
},
})

ctx := testutil.Context(t, testutil.WaitMedium)
// This should register the user
client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err := client.User(ctx, "me")
require.NoError(t, err)
userID := user.ID
require.Equal(t, user.Email, *coderEmail.Email)

// Now the user is registered, let's change their primary email.
coderEmail.Primary = github.Bool(false)
gmailEmail.Primary = github.Bool(true)

client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err = client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, userID)
require.Equal(t, user.Email, *gmailEmail.Email)

// Entirely change emails.
newEmail := "alice@newdomain.com"
emails = []*github.UserEmail{
{
Email: github.String("alice@newdomain.com"),
Primary: github.Bool(true),
Verified: github.Bool(true),
},
}
client, _ = fake.Login(t, client, jwt.MapClaims{})
user, err = client.User(ctx, "me")
require.NoError(t, err)
require.Equal(t, user.ID, userID)
require.Equal(t, user.Email, newEmail)
})
Comment on lines +606 to +686
Copy link
Member Author

@Emyrk Emyrk Jan 26, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a change email unit test using the fake idp.

}

// nolint:bodyclose
Expand Down