From b37e5e5ff339cef622921b20a7e1163a2550ae8c Mon Sep 17 00:00:00 2001 From: Tek Raj Chhetri Date: Tue, 11 Aug 2026 16:54:21 +0545 Subject: [PATCH] usermanagement: let OAuth accounts exchange a refresh token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/auth/exchange looked the credential row up with the active-only jwt_user_repo.get_by_email, so it 401'd "Account inactive" for every Globus/ORCID/GitHub user. OAuth onboarding creates that row as a SHELL with is_active=False on purpose — an OAuth user has no usable password, and the shell exists only to supply a stable user_id claim (see the get_by_email_any_status docstring, which already says OAuth flows must use it). The result: an OAuth user could log in and mint a refresh token that nothing would ever accept. That broke two flows on the same line. The MCP/skill paste-code login (cli/start -> cli/exchange -> exchange) dead-ended at the last hop, so brainkb_whoami read authenticated:false right after a successful login and a PAT could never be minted — minting needs a session token, and the only way to one from a refresh token is this endpoint. The UI's silent renew goes through the same call (oauth.py mints web_refresh precisely "exchanged by the UI at /api/auth/exchange"), so web sessions died at TTL instead of renewing. Not a bare swap to get_by_email_any_status, because is_active is overloaded: POST /api/admin/users/deactivate flips the same column, so dropping the check would make deactivation a no-op here. The refresh token records how it was issued — auth_source="password" from /auth/login, the provider name from OAuth — so the check now applies only to password credentials, where is_active really is the deactivation switch. OAuth accounts are removed by banning, which the is_banned -> 403 check below already enforces. Also distinguishes a missing row ("Unknown account") from a switched-off one ("Account inactive"), which were previously the same message. --- usermanagement_service/core/routers/sso.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/usermanagement_service/core/routers/sso.py b/usermanagement_service/core/routers/sso.py index 33ae934..8f116f6 100644 --- a/usermanagement_service/core/routers/sso.py +++ b/usermanagement_service/core/routers/sso.py @@ -115,10 +115,25 @@ async def sso_exchange( ) email = payload.get("sub") + auth_source = payload.get("auth_source", "password") async with user_db_manager.get_async_session() as session: - # active-only lookup: a deactivated credential can no longer exchange. - jwt_user = await jwt_user_repo.get_by_email(session, email) + # Look the credential row up regardless of is_active, because an inactive + # row means two different things here. OAuth onboarding deliberately + # creates a SHELL row with is_active=False (provision_identity / + # _ensure_jwt_user_shell) — an OAuth user has no usable password, and the + # shell exists only to supply a stable user_id claim. An active-only + # lookup therefore rejected every OAuth user: Globus/ORCID/GitHub logins + # could mint a refresh token and then never exchange it, which broke both + # the MCP/CLI flow and the UI's silent renew. + jwt_user = await jwt_user_repo.get_by_email_any_status(session, email) if not jwt_user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unknown account") + # For PASSWORD credentials is_active is the deactivation switch that + # POST /api/admin/users/deactivate flips, so it must still be enforced — + # dropping the check outright would make deactivation a no-op here. + # OAuth accounts are removed by BANNING (the documented mechanism, since + # deletion is disabled), which the is_banned check below enforces. + if not jwt_user.is_active and auth_source == "password": raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Account inactive") scopes = await jwt_user_repo.get_user_scopes(session, jwt_user.id) or ["read"] profile = await user_profile_repo.get_by_email(session, email) @@ -133,7 +148,7 @@ async def sso_exchange( profile_id=profile_id, roles=roles, scopes=scopes, - auth_source=payload.get("auth_source", "password"), + auth_source=auth_source, jwt_user_id=jwt_user.id, ) return {