Compare commits

...

3 Commits

Author SHA1 Message Date
openhands
bf3028f7a4 fix: preserve login_method when redirecting from login page
The login page is outside root-layout.tsx, so when an authenticated
user lands on /login?login_method=github after OAuth callback, the
redirect to '/' was losing the login_method query parameter.

Now the login page explicitly preserves login_method when redirecting
authenticated users to their destination.
2026-02-19 20:44:39 +00:00
Chuck Butkus
7bf6129b86 Lint fixes 2026-02-19 15:07:54 -05:00
openhands
34eece2003 fix: pass login_method through OAuth flow to local storage
The login_method was not being stored in browser local storage because:
1. Frontend was not including login_method in the OAuth state JSON
2. Backend was not extracting login_method from state or adding it to redirect URL

Changes:
- Add login_method to OAuth state in login-content.tsx handleAuthRedirect
- Update _extract_oauth_state() in auth.py to extract login_method
- Add login_method as query param in keycloak_callback redirect URL
- Add test to verify login_method is included in OAuth state
2026-02-19 20:02:52 +00:00
4 changed files with 62 additions and 11 deletions

View File

@@ -112,15 +112,17 @@ def get_cookie_samesite(request: Request) -> Literal['lax', 'strict']:
)
def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, and invitation token from OAuth state.
def _extract_oauth_state(
state: str | None,
) -> tuple[str, str | None, str | None, str | None]:
"""Extract redirect URL, reCAPTCHA token, invitation token, and login method from OAuth state.
Returns:
Tuple of (redirect_url, recaptcha_token, invitation_token).
Tokens may be None.
Tuple of (redirect_url, recaptcha_token, invitation_token, login_method).
Tokens and login_method may be None.
"""
if not state:
return '', None, None
return '', None, None, None
try:
# Try to decode as JSON (new format with reCAPTCHA and/or invitation)
@@ -129,10 +131,11 @@ def _extract_oauth_state(state: str | None) -> tuple[str, str | None, str | None
state_data.get('redirect_url', ''),
state_data.get('recaptcha_token'),
state_data.get('invitation_token'),
state_data.get('login_method'),
)
except Exception:
# Old format - state is just the redirect URL
return state, None, None
return state, None, None, None
# Keep alias for backward compatibility
@@ -144,7 +147,7 @@ def _extract_recaptcha_state(state: str | None) -> tuple[str, str | None]:
Returns:
Tuple of (redirect_url, recaptcha_token). Token may be None.
"""
redirect_url, recaptcha_token, _ = _extract_oauth_state(state)
redirect_url, recaptcha_token, _, _ = _extract_oauth_state(state)
return redirect_url, recaptcha_token
@@ -156,8 +159,10 @@ async def keycloak_callback(
error: Optional[str] = None,
error_description: Optional[str] = None,
):
# Extract redirect URL, reCAPTCHA token, and invitation token from state
redirect_url, recaptcha_token, invitation_token = _extract_oauth_state(state)
# Extract redirect URL, reCAPTCHA token, invitation token, and login method from state
redirect_url, recaptcha_token, invitation_token, login_method = (
_extract_oauth_state(state)
)
if not redirect_url:
redirect_url = str(request.base_url)
@@ -484,6 +489,13 @@ async def keycloak_callback(
else:
redirect_url = f'{redirect_url}?invitation_error=true'
# Add login_method to redirect URL so frontend can store it in local storage
if login_method:
if '?' in redirect_url:
redirect_url = f'{redirect_url}&login_method={login_method}'
else:
redirect_url = f'{redirect_url}?login_method={login_method}'
# If the user hasn't accepted the TOS, redirect to the TOS page
if not has_accepted_tos:
encoded_redirect_url = quote(redirect_url, safe='')

View File

@@ -301,4 +301,34 @@ describe("LoginContent", () => {
}
});
});
it("should include login_method in state when clicking auth button", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/login/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
});
await user.click(githubButton);
await waitFor(() => {
const redirectUrl = window.location.href;
expect(redirectUrl).toContain("state=");
const url = new URL(redirectUrl);
const state = url.searchParams.get("state");
if (state) {
const decodedState = JSON.parse(atob(state));
expect(decodedState.login_method).toBe("github");
}
});
});
});

View File

@@ -72,6 +72,7 @@ export function LoginContent({
// Build base state data
let stateData: Record<string, string> = {
redirect_url: currentState,
login_method: provider,
};
// Add invitation token if present

View File

@@ -41,9 +41,17 @@ export default function LoginPage() {
// Redirect authenticated users away from login page
React.useEffect(() => {
if (!isAuthLoading && isAuthed) {
navigate(returnTo, { replace: true });
// Preserve login_method query param when redirecting
const loginMethod = searchParams.get("login_method");
let destination = returnTo;
if (loginMethod) {
const destUrl = new URL(destination, window.location.origin);
destUrl.searchParams.set("login_method", loginMethod);
destination = destUrl.pathname + destUrl.search;
}
navigate(destination, { replace: true });
}
}, [isAuthed, isAuthLoading, navigate, returnTo]);
}, [isAuthed, isAuthLoading, navigate, returnTo, searchParams]);
if (isAuthLoading || config.isLoading) {
return (