diff --git a/coderd/userauth.go b/coderd/userauth.go index c8f329f5cf..bdcaad7397 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -1853,6 +1853,24 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C } } + // Reject the login if the linked user is suspended. Suspending only + // applies to existing users, so this check is intentionally placed + // after the new-user creation branch above. Returning an HTTPError + // rolls back the transaction so no link/sync side effects are + // persisted, and the caller renders a static error page describing + // what happened. + if user.Status == database.UserStatusSuspended { + return &idpsync.HTTPError{ + Code: http.StatusForbidden, + Msg: "Account suspended", + Detail: fmt.Sprintf( + "Your account %q has been suspended. Contact your Coder administrator to reactivate your account.", + user.Username, + ), + RenderStaticPage: true, + } + } + // Activate dormant user on sign-in if user.Status == database.UserStatusDormant { // This is necessary because transactions can be retried, and we diff --git a/coderd/userauth_test.go b/coderd/userauth_test.go index e73a2e9354..9c656b9c7b 100644 --- a/coderd/userauth_test.go +++ b/coderd/userauth_test.go @@ -2001,6 +2001,53 @@ func TestUserOIDC(t *testing.T) { "linked_id must not be modified when the login is blocked") }) + t.Run("OIDCSuspended", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitShort) + + fake := oidctest.NewFakeIDP(t, + oidctest.WithRefresh(func(_ string) error { + return xerrors.New("refreshing token should never occur") + }), + oidctest.WithServing(), + ) + cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) { + cfg.AllowSignups = true + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + OIDCConfig: cfg, + Logger: &logger, + }) + + // Pre-existing OIDC user that has been suspended by an admin. + user := dbgen.User(t, db, database.User{ + LoginType: database.LoginTypeOIDC, + Status: database.UserStatusSuspended, + }) + + _, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{ + "email": user.Email, + "sub": uuid.NewString(), + }) + // The OIDC handler should reject the login with an explanatory + // 403 instead of silently issuing a session and letting the SPA + // bounce the user back to /login with no message. + require.Equal(t, http.StatusForbidden, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "suspended", "error page should explain why login was rejected") + + // The user's status must remain suspended; nothing in the OAuth + // transaction should have been committed. + //nolint:gocritic // System read for verification. + dbUser, err := db.GetUserByID(dbauthz.AsSystemRestricted(ctx), user.ID) + require.NoError(t, err) + require.Equal(t, database.UserStatusSuspended, dbUser.Status) + }) + t.Run("OIDCConvert", func(t *testing.T) { t.Parallel()