Skip to content
Draft
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
6 changes: 3 additions & 3 deletions coderd/apidoc/docs.go

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

7 changes: 4 additions & 3 deletions coderd/apidoc/swagger.json

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

2 changes: 1 addition & 1 deletion coderd/coderdtest/oidctest/idp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1309,7 +1309,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler {
{
Key: f.locked.PrivateKey().Public(),
KeyID: "test-key",
Algorithm: "RSA",
Algorithm: "RS256",
},
},
}
Expand Down
20 changes: 20 additions & 0 deletions coderd/coderdtest/oidctest/idp_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,26 @@ func TestFakeIDPBasicFlow(t *testing.T) {
require.NotEmpty(t, refreshed.AccessToken, "access token is empty on refresh")
}

func TestFakeIDPRemoteKeySet(t *testing.T) {
t.Parallel()

fake := oidctest.NewFakeIDP(t, oidctest.WithServing())
oauthConfig := fake.OauthConfig(t, nil)
provider, err := oidc.NewProvider(context.Background(), fake.IssuerURL().String())
require.NoError(t, err)

token, err := fake.GenerateAuthenticatedToken(jwt.MapClaims{"sub": "test-subject"})
require.NoError(t, err)
idToken, ok := token.Extra("id_token").(string)
require.True(t, ok)

_, err = provider.Verifier(&oidc.Config{
ClientID: oauthConfig.ClientID,
SupportedSigningAlgs: []string{"RS256"},
}).Verify(context.Background(), idToken)
require.NoError(t, err)
}

// TestIDPIssuerMismatch emulates a situation where the IDP issuer url does
// not match the one in the well-known config and claims.
// This can happen in some edge cases and in some azure configurations.
Expand Down
28 changes: 27 additions & 1 deletion coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,26 @@ var (
}.WithCachedASTValue()
}

subjectLoginTypeConverter = func(userID uuid.UUID) rbac.Subject {
return rbac.Subject{
Type: rbac.SubjectTypeLoginTypeConverter,
FriendlyName: "Login Type Converter",
ID: userID.String(),
Roles: rbac.Roles([]rbac.Role{
{
Identifier: rbac.RoleIdentifier{Name: "logintypeconverter"},
DisplayName: "Login Type Converter",
Site: []rbac.Permission{},
User: rbac.Permissions(map[string][]policy.Action{
rbac.ResourceUser.Type: {policy.ActionRead, policy.ActionUpdatePersonal},
}),
ByOrgID: map[string]rbac.OrgPermissions{},
},
}),
Scope: rbac.ScopeAll,
}.WithCachedASTValue()
}

subjectSystemRestricted = rbac.Subject{
Type: rbac.SubjectTypeSystemRestricted,
FriendlyName: "System",
Expand Down Expand Up @@ -968,6 +988,12 @@ func AsChatdTokenOwner(ctx context.Context, userID uuid.UUID) context.Context {
return As(ctx, subjectChatdTokenOwner(userID))
}

// AsLoginTypeConverter returns a context scoped to the specified user's login
// type conversion.
func AsLoginTypeConverter(ctx context.Context, userID uuid.UUID) context.Context {
return As(ctx, subjectLoginTypeConverter(userID))
}

// AsSystemRestricted returns a context with an actor that has permissions
// required for various system operations (login, logout, metrics cache).
// DO NOT USE THIS UNLESS YOU HAVE ABSOLUTELY NO OTHER CHOICE. Prefer using a
Expand Down Expand Up @@ -8474,7 +8500,7 @@ func (q *querier) UpdateUserLinkedID(ctx context.Context, arg database.UpdateUse
}

func (q *querier) UpdateUserLoginType(ctx context.Context, arg database.UpdateUserLoginTypeParams) (database.User, error) {
if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil {
if err := q.authorizeContext(ctx, policy.ActionUpdatePersonal, rbac.ResourceUserObject(arg.UserID)); err != nil {
return database.User{}, err
}
return q.db.UpdateUserLoginType(ctx, arg)
Expand Down
29 changes: 28 additions & 1 deletion coderd/database/dbauthz/dbauthz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5539,7 +5539,7 @@ func (s *MethodTestSuite) TestSystemFunctions() {
u := testutil.Fake(s.T(), faker, database.User{})
arg := database.UpdateUserLoginTypeParams{NewLoginType: database.LoginTypePassword, UserID: u.ID}
dbm.EXPECT().UpdateUserLoginType(gomock.Any(), arg).Return(testutil.Fake(s.T(), faker, database.User{}), nil).AnyTimes()
check.Args(arg).Asserts(rbac.ResourceSystem, policy.ActionUpdate)
check.Args(arg).Asserts(rbac.ResourceUserObject(arg.UserID), policy.ActionUpdatePersonal)
}))
s.Run("GetWorkspaceAgentStatsAndLabels", s.Mocked(func(dbm *dbmock.MockStore, _ *gofakeit.Faker, check *expects) {
t := time.Time{}
Expand Down Expand Up @@ -7762,6 +7762,33 @@ func TestAsAPIKeyRevoker(t *testing.T) {
})
}

func TestAsLoginTypeConverter(t *testing.T) {
t.Parallel()

userID := uuid.New()
otherUserID := uuid.New()
ctx := dbauthz.AsLoginTypeConverter(context.Background(), userID)
actor, ok := dbauthz.ActorFromContext(ctx)
require.True(t, ok, "actor must be present")
require.Equal(t, rbac.SubjectTypeLoginTypeConverter, actor.Type)
require.Equal(t, userID.String(), actor.ID)

auth := rbac.NewStrictCachingAuthorizer(prometheus.NewRegistry())
for _, action := range rbac.ResourceUser.AvailableActions() {
err := auth.Authorize(ctx, actor, action, rbac.ResourceUserObject(userID))
if action == policy.ActionRead || action == policy.ActionUpdatePersonal {
require.NoError(t, err, "own user should allow %s", action)
continue
}
require.Error(t, err, "own user should deny %s", action)
}

for _, action := range []policy.Action{policy.ActionRead, policy.ActionUpdatePersonal} {
err := auth.Authorize(ctx, actor, action, rbac.ResourceUserObject(otherUserID))
require.Error(t, err, "other user should deny %s", action)
}
}

func TestAsChatdKeyMinter(t *testing.T) {
t.Parallel()

Expand Down
1 change: 1 addition & 0 deletions coderd/rbac/authz.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const (
SubjectTypeAPIKeyRevoker SubjectType = "api_key_revoker" // #nosec G101, not a credential.
SubjectTypeChatdKeyMinter SubjectType = "chatd_key_minter" // #nosec G101, not a credential.
SubjectTypeChatdTokenOwner SubjectType = "chatd_token_owner" // #nosec G101, not a credential.
SubjectTypeLoginTypeConverter SubjectType = "login_type_converter"
SubjectTypeNotifier SubjectType = "notifier"
SubjectTypeSubAgentAPI SubjectType = "sub_agent_api"
SubjectTypeFileReader SubjectType = "file_reader"
Expand Down
111 changes: 72 additions & 39 deletions coderd/userauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ func (o *OAuthConvertStateClaims) Validate(e jwt.Expected) error {
// postConvertLoginType replies with an oauth state token capable of converting
// the user to an oauth user.
//
// @Summary Convert user from password to oauth authentication
// @ID convert-user-from-password-to-oauth-authentication
// @Summary Convert user to oauth authentication
// @ID convert-user-to-oauth-authentication
// @Security CoderSessionToken
// @Accept json
// @Produce json
Expand Down Expand Up @@ -123,23 +123,34 @@ func (api *API) postConvertLoginType(rw http.ResponseWriter, r *http.Request) {
return
}

// This handles the email/pass checking.
user, _, ok := api.loginRequest(ctx, rw, codersdk.LoginWithPasswordRequest{
Email: user.Email,
Password: req.Password,
})
if !ok {
return
}
switch user.LoginType {
case database.LoginTypePassword:
authenticatedUser, _, ok := api.loginRequest(ctx, rw, codersdk.LoginWithPasswordRequest{
Email: user.Email,
Password: req.Password,
})
if !ok {
return
}
user = authenticatedUser
case database.LoginTypeGithub:
if req.ToType != codersdk.LoginTypeOIDC {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "GitHub-authenticated accounts can only convert to OpenID Connect.",
})
return
}

// Only support converting from password auth.
if user.LoginType != database.LoginTypePassword {
// This is checked in loginRequest, but checked again here in case that shared
// function changes its checks. Just some defensive programming.
// This login type is **required** to be password based to prevent
// users from converting other login types to OIDC.
apiKey := httpmw.APIKey(r)
if apiKey.UserID != user.ID || apiKey.LoginType != database.LoginTypeGithub {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "GitHub account conversion must be initiated by the user's GitHub session.",
})
return
}
default:
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "User account must have password based authentication.",
Message: "User account must have password or GitHub authentication.",
})
return
}
Expand Down Expand Up @@ -1520,9 +1531,8 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
return
}

// If a new user is authenticating for the first time
// the audit action is 'register', not 'login'
if user.ID == uuid.Nil {
// An unmatched OIDC conversion is not a registration.
if user.ID == uuid.Nil && !isMergeStateString(state.StateString) {
aReq.Action = database.AuditActionRegister
}

Expand Down Expand Up @@ -1759,8 +1769,6 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
user = params.User
link = params.Link

// If you do a convert to OIDC and your email does not match, we need to
// catch this and not make a new account.
if isMergeStateString(params.State.StateString) {
// Always clear this cookie. If it succeeds, we no longer need it.
// If it fails, we no longer care about it.
Expand Down Expand Up @@ -2053,7 +2061,10 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
if ok && oldKey != nil && isConvertLoginType {
// If this is a convert login type, and it succeeds, then delete the old
// session. Force the user to log back in.
err := api.Database.DeleteAPIKeyByID(r.Context(), oldKey.ID)
err := api.Database.DeleteAPIKeyByID(
dbauthz.AsAPIKeyRevoker(r.Context(), user.ID),
oldKey.ID,
)
if err != nil {
// Do not block this login if we fail to delete the old API key.
// Just delete the cookie and continue.
Expand Down Expand Up @@ -2090,20 +2101,9 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
return cookies, user, key, nil
}

// convertUserToOauth will convert a user from password base loginType to
// an oauth login type. If it fails, it will return a httpError
func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db database.Store, params *oauthLoginParams) (database.User, error) {
user := params.User

// Trying to convert to OIDC, but the email does not match.
// So do not make a new user, just block the request.
if user.ID == uuid.Nil {
return database.User{}, idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: fmt.Sprintf("The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue.", params.Email),
}
}

jwtCookie, err := r.Cookie(OAuthConvertCookieValue)
if err != nil {
return database.User{}, idpsync.HTTPError{
Expand All @@ -2129,8 +2129,6 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
}
}

// At this point, this request could be an attempt to convert from
// password auth to oauth auth. Always log these attempts.
var (
auditor = *api.Auditor.Load()
oauthConvertAudit = params.initAuditRequest(&audit.RequestParams{
Expand Down Expand Up @@ -2158,6 +2156,43 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
}
}

conversionCtx := dbauthz.AsLoginTypeConverter(ctx, claims.UserID)

if claims.FromLoginType == codersdk.LoginTypeGithub && claims.ToLoginType == codersdk.LoginTypeOIDC {
// Only a GitHub-authenticated conversion with signed state can bypass email matching.
sourceUser, err := db.GetUserByID(conversionCtx, claims.UserID)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return database.User{}, idpsync.HTTPError{
Code: http.StatusInternalServerError,
Msg: "Failed to load source account.",
}
}

return database.User{}, idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "Request to convert login type failed. The source account no longer exists.",
}
}

if user.ID != uuid.Nil && user.ID != sourceUser.ID {
return database.User{}, idpsync.HTTPError{
Code: http.StatusForbidden,
Msg: "The OpenID Connect identity is already linked to another Coder user.",
}
}
user = sourceUser
oauthConvertAudit.Old = user
}

// Other conversion paths require a matched user.
if user.ID == uuid.Nil {
return database.User{}, idpsync.HTTPError{
Code: http.StatusBadRequest,
Msg: fmt.Sprintf("The oidc account with the email %q does not match the email of the account you are trying to convert. Contact your administrator to resolve this issue.", params.Email),
}
}

// Make sure the merge state generated matches this OIDC login request.
// It needs to have the correct login type information for this
// user.
Expand All @@ -2173,9 +2208,7 @@ func (api *API) convertUserToOauth(ctx context.Context, r *http.Request, db data
// Convert the user and default to the normal login flow.
// If the login succeeds, this transaction will commit and the user
// will be converted.
// nolint:gocritic // system query to update user login type. The user already
// provided their password to authenticate this request.
user, err = db.UpdateUserLoginType(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLoginTypeParams{
user, err = db.UpdateUserLoginType(conversionCtx, database.UpdateUserLoginTypeParams{
NewLoginType: params.LoginType,
UserID: user.ID,
})
Expand Down
Loading
Loading