feat(login): OIDC (MAS) login for existing accounts — Phase 3b#119
Open
TigerInYourDream wants to merge 12 commits intofeat/register-phase-3a-dummy-uiaafrom
Open
feat(login): OIDC (MAS) login for existing accounts — Phase 3b#119TigerInYourDream wants to merge 12 commits intofeat/register-phase-3a-dummy-uiaafrom
TigerInYourDream wants to merge 12 commits intofeat/register-phase-3a-dummy-uiaafrom
Conversation
Introduce PersistedAuthSession (untagged enum of Matrix + OAuth) so
FullSessionPersisted can carry either auth kind. save_session() now
reads from client.session() and dispatches on AuthSession::{Matrix,
OAuth}; restore_session() converts the persisted enum back to
matrix_sdk::authentication::AuthSession via client.restore_session().
Untagged serde keeps the existing on-disk format readable without a
migration shim. The exhaustive match bail!'s on future AuthSession
variants so silent data loss stays impossible.
Codex drafted; Claude added the unused-import drop + wildcard arm to
close out compile errors and passed the round-trip test.
Round-trip test: persisted_auth_session_round_trips_oauth_variant.
Thread ClientSessionPersisted into handle_session_changes so the SessionChange::TokensRefreshed branch can call persistence::save_session. Prior to this, refreshed OAuth access/refresh tokens stayed in-memory and were lost on next launch — the restored session would try the old refresh token, fail, and punt the user back to login. The two handle_session_changes call sites already hold the client_session: the initial login path threads it through the 'login_loop break tuple; the account-switch path un-discards the previously-underscored _session from restore_session(). save_session() reads the fresh tokens from client.session() itself, so client_session here only contributes the db_path + passphrase for file-path derivation, making clone() a 3-String cost.
perform_server_logout() used to hard-call client.matrix_auth().logout(), which is a no-op for OIDC sessions and left stale OAuth tokens alive at the MAS issuer. Now it branches on client.auth_api(): - AuthApi::Matrix → matrix_auth().logout() - AuthApi::OAuth → oauth().logout() (revokes at the OAuth token revocation endpoint; requires matrix-sdk space_room_suggested branch) - None → nothing to revoke (already torn down) The two logout futures return different error types, so each arm is boxed into Pin<Box<dyn Future<Output = Result<(), String>>>>. Timeout and error-mapping stay in a single place. Closes the server-side revocation gap surfaced by ADR-002 of the OIDC login design spec.
Pre-flight for the OIDC worker: thread the UI↔worker contract before
touching the actual OAuth machinery.
Added:
- MatrixRequest::StartOidcLogin { homeserver_url, proxy } — begin flow
- MatrixRequest::CancelOidcLogin — abort in-flight
- LoginAction::OidcLoginStarted — browser launched
- LoginAction::OidcLoginCancelled — aborted/timed out
- LoginAction::OidcLoginFailed(String) — any other failure
Handlers stub with a placeholder OidcLoginFailed; real implementation
lands in the next commit. This split keeps the flow contract reviewable
on its own and lets LoginScreen UI work start in parallel if needed.
Introduces src/login/oidc_login.rs as the home for OAuth-flow orchestration and declares it under src/login/mod.rs. For this commit, the module only carries the error taxonomy and the user-facing message mapper. Variants are limited to what's actually constructed by existing code paths (the stub handler + tests); more land alongside the start_oidc_login() implementation in the next commit. This deliberately avoids padding enums with unreachable variants — the project disallows #[allow(dead_code)]. Covers plan Task 3 Step 1-2 (red → green on map_oidc_error).
Implements the full MAS OAuth 2.0 authorization-code flow with loopback callback, in-app Cancel, and 3-minute timeout. Reuses the existing login pipeline (LoginRequest → finalize_authenticated_client) so sync-service startup and UI transition work identically to password login. oidc_login::start_oidc_login() flow: 1. build_client_for_oidc (homeserver + proxy) — reuses existing plumbing 2. LocalServerBuilder::spawn — bind 127.0.0.1:<random> 3. OAuth.login(redirect_uri, reg_data).build — dynamic client registration 4. robius_open::Uri::open — launch the system browser 5. Post LoginAction::OidcLoginStarted so the UI shows "Waiting for callback..." 6. tokio::select! over (redirect_handle | cancel_rx | 180s timeout) 7. OAuth.finish_login — token exchange 8. Return (client, client_session, user_id) Cancellation: - OIDC_CANCEL_TX static holds a oneshot::Sender<()>. CancelOidcLogin pops it. - Each early-return path calls client.oauth().abort_login(&state) so matrix-sdk's pending CSRF state is cleaned up and retries start fresh. - redirect_handle dropping at end-of-scope tears the loopback server down. Error classification: - OAuthClientRegistrationError::NotSupported → DynamicRegistrationNotSupported (user sees "server doesn't support third-party sign-in apps yet"). - OAuthAuthorizationCodeError::Cancelled from finish_login (MAS error=access_denied) → collapses to OidcLoginError::Cancelled so the in-app Cancel path and the browser-side Cancel path produce identical UI. - All other OAuthError buckets → ServerMetadata / AuthorizeBuild / FinishLogin with the inner text preserved for logs. Contract: - LoginAction::OidcLoginStarted posted once browser is open - LoginAction::LoginSuccess (via LoginByOidcSuccess pipeline) on finish - LoginAction::OidcLoginCancelled on any cancel/timeout - LoginAction::OidcLoginFailed(String) on any other error Closes plan Task 3. LoginScreen wiring (Task 4) is next.
Pre-UI scaffold for LoginScreen's MAS branch: extracts the "do I need to probe this homeserver?" decision into a pure helper so it can be unit-tested without driving a LoginScreen instance. Covers plan Task 4 Step 1-2. UI wiring + DSL additions come in follow-up commits to keep each unit individually reviewable. Tests: - capability_probe_is_required_when_login_mode_is_unknown - capability_probe_is_not_required_when_mode_already_classified - capability_probe_is_not_required_while_oidc_login_is_in_flight
Eight new keys grouped near login.button.* for discoverability:
- login.button.continue_in_browser / .cancel_oidc — MAS-branch CTAs
- login.oidc.info_title / .info_body — pre-click card copy
- login.oidc.waiting_body — in-flight status
- login.oidc.cancelled — post-cancel hint
- login.status.checking_homeserver.title / .body — probe-in-progress status
en + zh-CN kept in lock-step; zh-CN uses the project's existing punctuation
convention (full-width colons + em-dashes) to match surrounding keys.
Copy is deliberately short and free of jargon ("browser sign-in" rather
than "OIDC" or "OAuth") — the user doesn't need to know the protocol.
Completes plan Task 4 D3: LoginScreen now drives the full OIDC login
UX alongside the existing password/SSO path.
DSL (oidc_card, hidden by default):
- oidc_info_title — "Browser sign-in required" header
- oidc_info_body — pre-click explainer, doubles as cancel hint
- oidc_continue_button — primary CTA
- oidc_status_label — in-flight status text
- oidc_cancel_button — shown while flow is in flight
State:
- login_mode — classified flavor from capability probe
- last_discovery_input_url — filters out-of-order probe results
- discovery_pending — blocks duplicate probes
- oidc_in_flight — blocks re-probe + duplicate StartOidcLogin
Login button click:
- invalidates stale login_mode when homeserver field changed
- drops the click in MAS mode (the hidden button shouldn't be reachable,
but we guard against stale clicks reaching the password path)
- for a non-empty unclassified homeserver, normalizes and dispatches
DiscoverHomeserverCapabilities, swaps the button label to "Checking..."
and opens the login_status_modal; empty homeserver keeps the
zero-latency password path for matrix.org
Continue-in-browser click:
- persists proxy config, dispatches StartOidcLogin with the user-typed
homeserver and resolved proxy; redraws so the oidc_card can reflect
the pending state once OidcLoginStarted comes back
Cancel-sign-in click:
- dispatches CancelOidcLogin (worker aborts the oauth flow and posts
OidcLoginCancelled, which restores the idle card)
CapabilityProbeAction subscription:
- filters on requested_url to stay out of RegisterScreen's lane
- MasOidc → hides password/SSO widgets, populates + shows oidc_card
- Password → restores the default password form
- Failed → surfaces the error via login_status_modal
LoginAction handlers:
- OidcLoginStarted — flips card to waiting state, shows cancel button
- OidcLoginCancelled — restores idle card, plants soft hint in info body
- OidcLoginFailed — restores idle card and surfaces error via modal
LoginSuccess reset: clears oidc_in_flight + login_mode +
last_discovery_input_url, hides oidc_card and re-shows password/SSO so
re-entering the screen (e.g. via Switch Account) starts clean.
Build: cargo build — clean.
Tests: cargo test --lib login:: — 10 passed, 0 failed
(includes the 3 capability_probe_* predicate tests from ebae0027).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 3b of the register/login stack: delegated OIDC (MSC2965) login for existing Matrix accounts. Stacks on top of #115 (Phase 3a UIAA registration).
Target homeservers validated by design:
matrix.org,alvin.meldry.com, and any MSC2965-conforming server.Stack:
fix/register → main— Phase 1+2 MAS browser signupfeat/register-phase-3a-dummy-uiaa → fix/register— Phase 3a UIAA dummyfeat/oidc-login → feat/register-phase-3a-dummy-uiaa— Phase 3b OIDC loginWhat this adds
src/login/oidc_login.rs—start_oidc_login()orchestrates the full OAuth 2.0 authorization-code flow: build client, spawn loopback redirect server, dynamic client registration, open browser, tokio::select! on (callback | cancel | 3-min timeout), finish_login. Error taxonomy + user-safe message mapping.src/sliding_sync.rs—MatrixRequest::StartOidcLogin/CancelOidcLoginentry points,LoginRequest::LoginByOidcSuccesspipeline (shares save_session + sync startup with password/SSO paths),build_client_for_oidcwrapper sooidc_logindoesn't leak the privateClistruct.SessionChange::TokensRefreshedpersists updated OAuth tokens.src/persistence/matrix_state.rs—PersistedAuthSessionenum with#[serde(untagged)]so existing Matrix-session files still deserialize unchanged.src/logout/logout_state_machine.rs— dispatches server logout byclient.auth_api()(Matrix vs OAuth).src/login/login_screen.rs— MAS branch UI: capability probe on first Login click, hides password/SSO widgets whenlogin_moderesolves to MasOidc, shows ""Continue in browser"" card + in-flight status + cancel button.src/homeserver.rs— sharedCapabilityProbeAction+login_mode()classifier (used by both LoginScreen and RegisterScreen).resources/i18n/{en,zh-CN}.json— 8 new keys (browser sign-in copy, status strings), kept in lock-step.Why layered this way
11 narrow commits (one logical unit each), no squash — lets reviewers bisect if a regression surfaces on the 6 manual flows below, and keeps per-commit context bounded:
docs: add oidc login implementation planrefactor(auth): share homeserver capability statefeat(persistence): persist OAuth sessions alongside Matrix sessionsfeat(persistence): save session on TokensRefreshedfeat(logout): dispatch server logout by client.auth_api()feat(login): add OIDC MatrixRequest variants + LoginAction signalsfeat(login): scaffold oidc_login module with error taxonomyfeat(login): wire end-to-end OIDC login workerfeat(login): add should_probe_homeserver predicate (TDD red→green)i18n(login): add OIDC MAS branch strings (en + zh-CN)feat(login): wire LoginScreen MAS branch end-to-endBuild + test evidence
cargo build— cleancargo test --lib login::— 10 passed, 0 failedcargo test --lib login_mode_ / persisted_auth_session_round_trips / map_oidc_error_ / capability_probe_is_— all passhome::room_screen+room::room_input_barbot tests are unrelated to OIDC (verified on clean HEAD before any of this work).Test plan
http://127.0.0.1:8128(or dev homeserver) still logs in via the password path unchangedmatrix.org— spinner → oidc_card → Continue in browser → system browser opens → complete sign-in → main UIalvin.meldry.com(primary acceptance target) — dynamic registration works against self-hosted MASOpen follow-ups (not blocking this PR)
alvin.meldry.comUIAA registration path (Phase 3c, separate plan).