Skip to content

usermanagement: let OAuth accounts exchange a refresh token - #78

Merged
tekrajchhetri merged 1 commit into
improve-ingestion-query-servicefrom
fix/sso-exchange-oauth-shell
Aug 11, 2026
Merged

usermanagement: let OAuth accounts exchange a refresh token#78
tekrajchhetri merged 1 commit into
improve-ingestion-query-servicefrom
fix/sso-exchange-oauth-shell

Conversation

@tekrajchhetri

Copy link
Copy Markdown
Collaborator

Fix OAuth refresh-token exchange for shell users

/api/auth/exchange used jwt_user_repo.get_by_email, which only returns active users. This caused every Globus/ORCID/GitHub OAuth user to fail token exchange with 401 Account inactive.

OAuth onboarding intentionally creates a SHELL user with is_active=False: OAuth users do not have a usable password, and the row exists only to provide a stable user_id claim. As documented by get_by_email_any_status, OAuth flows must therefore be able to resolve these users regardless of is_active.

This broke both:

  • MCP/skill login: cli/start -> cli/exchange -> /api/auth/exchange succeeded initially but failed at the final token exchange, leaving brainkb_whoami as authenticated:false and preventing PAT creation.
  • Web session renewal: OAuth web_refresh tokens are exchanged through the same endpoint, so OAuth sessions expired at TTL instead of renewing silently.

The fix is not simply replacing the lookup with get_by_email_any_status. is_active is also used by /api/admin/users/deactivate, so ignoring it globally would make password-account deactivation ineffective.

Instead, /api/auth/exchange now:

  • resolves the user with get_by_email_any_status;
  • enforces is_active only when auth_source == "password";
  • allows OAuth shell users to exchange valid refresh tokens;
  • continues enforcing is_banned for OAuth account revocation;
  • distinguishes "Unknown account" from "Account inactive".

This restores OAuth refresh, MCP/skill authentication, and PAT minting without weakening password-account deactivation.

/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.
@tekrajchhetri
tekrajchhetri merged commit 0e64664 into improve-ingestion-query-service Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant