fix(coderd): reject suspended user during OIDC and GitHub OAuth login (#24996)

Previously, a suspended user authenticating via OIDC or GitHub OAuth was
silently issued a session cookie and redirected to the dashboard. The
very next API call (`/api/v2/users/me`) failed with `401` from the
suspended-user check in `httpmw.ExtractAPIKey`, the SPA treated the 401
as "signed out", and bounced the user back to `/login` with no
indication of why. The password login path does not have this bug
because `loginRequest` rejects suspended users *before* creating an API
key.

The shared `oauthLogin` handler in `coderd/userauth.go` only
special-cased the `dormant` status. Add a parallel check for `suspended`
that returns an `idpsync.HTTPError` with `RenderStaticPage: true`, so
the OIDC and GitHub callback handlers render an explanatory error page.
The GitHub device flow already clears `RenderStaticPage` for
`idpsync.HTTPError` responses, so it returns the same fields as JSON.
Returning from inside `db.InTx` rolls the transaction back, so no link
insert/update or IDP sync side-effects are persisted for a rejected
suspended user.

Closing https://github.com/coder/coder/issues/24614

<details>
<summary>Investigation notes</summary>

### Trace through the bug on `main`

1. `userOIDC` callback in `coderd/userauth.go` enters `oauthLogin`.
2. Inside the `db.InTx` closure, only `user.Status ==
database.UserStatusDormant` is special-cased (auto-activates). A
`suspended` user falls through and the transaction commits as-is.
3. `oauthLogin` then calls `api.createAPIKey(...)` and the session
cookie is set.
4. The handler issues `http.Redirect(rw, r, redirect,
http.StatusTemporaryRedirect)` to the post-login URL.
5. The SPA loads and calls `GET /api/v2/users/me`.
`httpmw.ExtractAPIKey` returns `401 "User is not active (status =
\"suspended\"). Contact an admin to reactivate your account."`
(`coderd/httpmw/apikey.go:685`).
6. `site/src/contexts/auth/RequireAuth.tsx` treats any `401` from
`/users/me` as "signed out" and redirects to `/login` without surfacing
the message body.

Verified by reverting the fix and re-running the new test: the OIDC
callback returns `307` (the bug) instead of the expected `403`.

### Why this placement

The new check is placed alongside the existing `Dormant` branch:

- It runs after the new-user creation block, so first-login signup is
unaffected (new users are always created `active`).
- Returning an `*idpsync.HTTPError` from inside `db.InTx` rolls the
transaction back, so no `user_links` insert/update or IDP sync is
persisted.
- `idpsync.HTTPError` with `RenderStaticPage: true` is already the
convention used by the OIDC and GitHub callbacks for "Email not
verified" and "Signups disabled" via `idpsync.IsHTTPError(err) ->
httpErr.Write(rw, r)`.
- `oauthLogin` is shared between OIDC and GitHub OAuth, so a single
change fixes both flows. The GitHub device-flow branch in
`userOAuth2Github` already clears `RenderStaticPage` for
`idpsync.HTTPError` and returns JSON, so device clients get the same
`403` with `Msg`/`Detail` fields.

### Test

`TestUserOIDC/OIDCSuspended` mirrors the existing `OIDCDormancy` test:

- Pre-seed a `database.User` with `LoginType: LoginTypeOIDC` and
`Status: UserStatusSuspended`.
- Drive the OIDC callback via `oidctest.FakeIDP.AttemptLogin`.
- Assert HTTP `403`, response body contains `"suspended"`, and the
user's DB status is unchanged.

### Out of scope

The issue mentions allowing admins to customize the suspension message
as an extra step. Not included; that would be a separate feature.

</details>

---

*This PR was created on behalf of @ericpaulsen by the Coder Agents AI
assistant.*
This commit is contained in:
Eric Paulsen
2026-06-11 14:39:21 +01:00
committed by GitHub
parent ba51ee8f98
commit b6033aee03
2 changed files with 65 additions and 0 deletions
+18
View File
@@ -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