Skip to content
Open
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
7 changes: 7 additions & 0 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -6565,6 +6565,13 @@ func (q *querier) UpdateUserLink(ctx context.Context, arg database.UpdateUserLin
return fetchAndQuery(q.log, q.auth, policy.ActionUpdatePersonal, fetch, q.db.UpdateUserLink)(ctx, arg)
}

func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUserLinkedIDParams) (database.UserLink, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceUserObject(arg.UserID)); err != nil {
return database.UserLink{}, err
}
return q.db.UpdateUserLinkedID(ctx, arg)
}

func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
return database.User{}, err
Expand Down
6 changes: 6 additions & 0 deletions coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2564,6 +2564,12 @@ func (s *MethodTestSuite) TestUser() {
dbm.EXPECT().UpdateUserLink(gomock.Any(), arg).Return(link, nil).AnyTimes()
check.Args(arg).Asserts(link, policy.ActionUpdatePersonal).Returns(link)
}))
s.Run("UpdateUserLinkedID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
link := testutil.Fake(s.T(), faker, database.UserLink{})
arg := database.UpdateUserLinkedIDParams{LinkedID: link.LinkedID, UserID: link.UserID, LoginType: link.LoginType}
dbm.EXPECT().UpdateUserLinkedID(gomock.Any(), arg).Return(link, nil).AnyTimes()
check.Args(arg).Asserts(link, policy.ActionUpdate).Returns(link)
}))
s.Run("UpdateUserRoles", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
u := testutil.Fake(s.T(), faker, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}})
o := u
Expand Down
8 changes: 8 additions & 0 deletions coderd/database/dbmetrics/querymetrics.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions coderd/database/dbmock/dbmock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions coderd/database/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

35 changes: 35 additions & 0 deletions coderd/database/queries.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions coderd/database/queries/user_links.sql
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@ SET
WHERE
user_id = $7 AND login_type = $8 RETURNING *;

-- name: UpdateUserLinkedID :one
-- Backfills linked_id for legacy user_links that were created before
-- linked_id tracking was added. Only updates when linked_id is empty
-- to avoid overwriting a valid binding.
UPDATE
user_links
SET
linked_id = @linked_id
WHERE
user_id = @user_id AND login_type = @login_type AND linked_id = '' RETURNING *;

-- name: OIDCClaimFields :many
-- OIDCClaimFields returns a list of distinct keys in the the merged_claims fields.
-- This query is used to generate the list of available sync fields for idp sync settings.
Expand Down
74 changes: 69 additions & 5 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,16 @@ func (api *API) userOAuth2Github(rw http.ResponseWriter, r *http.Request) {
})
return
}
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), verifiedEmail.GetEmail())
user, link, err := findLinkedUser(ctx, api.Database, githubLinkedID(ghUser), database.LoginTypeGithub, verifiedEmail.GetEmail())
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", verifiedEmail.GetEmail()),
)
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "This account is already linked to a different identity provider subject.",
})
return
}
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("gh_user", ghUser.Name), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Expand Down Expand Up @@ -1436,7 +1445,22 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
}
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))

user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), database.LoginTypeOIDC, email)
if errors.Is(err, errLinkedIDAlreadyBound) {
logger.Warn(ctx, "oauth2: blocked login, account already linked to different identity",
slog.F("email", email),
)
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
Status: http.StatusForbidden,
HideStatus: true,
Title: "Account already linked",
Description: "This account is already linked to a different identity provider subject. Contact your administrator.",
Actions: []site.Action{
{URL: "/login", Text: "Back to login"},
},
})
return
}
if err != nil {
logger.Error(ctx, "oauth2: unable to find linked user", slog.F("email", email), slog.Error(err))
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Expand Down Expand Up @@ -1870,6 +1894,31 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
if err != nil {
return xerrors.Errorf("update user link: %w", err)
}

// Defense-in-depth: if a concurrent transaction backfilled
// linked_id between findLinkedUser and this point, reject the
// login with a 403 instead of letting it bubble up as a 500.
if link.LinkedID != "" && link.LinkedID != params.LinkedID {
return &idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Account already linked",
Detail: "This account is already linked to a different identity provider subject. Contact your administrator.",
RenderStaticPage: true,
}
}

// Backfill linked_id for legacy links.
if link.LinkedID == "" && params.LinkedID != "" {
//nolint:gocritic // System needs to update the user link.
link, err = tx.UpdateUserLinkedID(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkedIDParams{
LinkedID: params.LinkedID,
UserID: user.ID,
LoginType: params.LoginType,
})
if err != nil {
return xerrors.Errorf("backfill user linked id: %w", err)
}
}
}

err = api.IDPSync.SyncOrganizations(ctx, tx, user, params.OrganizationSync)
Expand Down Expand Up @@ -2090,9 +2139,17 @@ func oidcLinkedID(tok *oidc.IDToken) string {
return strings.Join([]string{tok.Issuer, tok.Subject}, "||")
}

// errLinkedIDAlreadyBound is returned by findLinkedUser when the user
// found by email already has a user_link with a different linked_id.
var errLinkedIDAlreadyBound = xerrors.New("user account is already linked to a different identity provider subject")

// findLinkedUser tries to find a user by their unique OAuth-linked ID.
// If it doesn't not find it, it returns the user by their email.
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, emails ...string) (database.User, database.UserLink, error) {
// If it does not find a match, it falls back to email-based lookup.
// The email fallback is restricted to first-time account linking and
// legacy links (empty linked_id) only. If the user found by email
// already has a link with a different linked_id, errLinkedIDAlreadyBound
// is returned to prevent account takeover via IdP email reuse.
func findLinkedUser(ctx context.Context, db database.Store, linkedID string, loginType database.LoginType, emails ...string) (database.User, database.UserLink, error) {
var (
user database.User
link database.UserLink
Expand Down Expand Up @@ -2137,12 +2194,19 @@ func findLinkedUser(ctx context.Context, db database.Store, linkedID string, ema
// possible that a user_link exists without a populated 'linked_id'.
link, err = db.GetUserLinkByUserIDLoginType(ctx, database.GetUserLinkByUserIDLoginTypeParams{
UserID: user.ID,
LoginType: user.LoginType,
LoginType: loginType,
})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return database.User{}, database.UserLink{}, xerrors.Errorf("get user link by user id and login type: %w", err)
}

// Block email fallback when an existing link has a different linked_id.
// Prevents account takeover via IdP email reuse; first-time and legacy
// (empty linked_id) links pass through.
if err == nil && link.LinkedID != "" && link.LinkedID != linkedID {
return database.User{}, database.UserLink{}, errLinkedIDAlreadyBound
}

return user, link, nil
}

Expand Down
Loading
Loading