Skip to content

feat(login): OIDC (MAS) login for existing accounts — Phase 3b#119

Open
TigerInYourDream wants to merge 12 commits intofeat/register-phase-3a-dummy-uiaafrom
feat/oidc-login
Open

feat(login): OIDC (MAS) login for existing accounts — Phase 3b#119
TigerInYourDream wants to merge 12 commits intofeat/register-phase-3a-dummy-uiaafrom
feat/oidc-login

Conversation

@TigerInYourDream
Copy link
Copy Markdown

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:

What this adds

  • src/login/oidc_login.rsstart_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.rsMatrixRequest::StartOidcLogin / CancelOidcLogin entry points, LoginRequest::LoginByOidcSuccess pipeline (shares save_session + sync startup with password/SSO paths), build_client_for_oidc wrapper so oidc_login doesn't leak the private Cli struct. SessionChange::TokensRefreshed persists updated OAuth tokens.
  • src/persistence/matrix_state.rsPersistedAuthSession enum with #[serde(untagged)] so existing Matrix-session files still deserialize unchanged.
  • src/logout/logout_state_machine.rs — dispatches server logout by client.auth_api() (Matrix vs OAuth).
  • src/login/login_screen.rs — MAS branch UI: capability probe on first Login click, hides password/SSO widgets when login_mode resolves to MasOidc, shows ""Continue in browser"" card + in-flight status + cancel button.
  • src/homeserver.rs — shared CapabilityProbeAction + 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:

  1. docs: add oidc login implementation plan
  2. refactor(auth): share homeserver capability state
  3. feat(persistence): persist OAuth sessions alongside Matrix sessions
  4. feat(persistence): save session on TokensRefreshed
  5. feat(logout): dispatch server logout by client.auth_api()
  6. feat(login): add OIDC MatrixRequest variants + LoginAction signals
  7. feat(login): scaffold oidc_login module with error taxonomy
  8. feat(login): wire end-to-end OIDC login worker
  9. feat(login): add should_probe_homeserver predicate (TDD red→green)
  10. i18n(login): add OIDC MAS branch strings (en + zh-CN)
  11. feat(login): wire LoginScreen MAS branch end-to-end

Build + test evidence

  • cargo build — clean
  • cargo test --lib login:: — 10 passed, 0 failed
  • cargo test --lib login_mode_ / persisted_auth_session_round_trips / map_oidc_error_ / capability_probe_is_ — all pass
  • Pre-existing failures in home::room_screen + room::room_input_bar bot tests are unrelated to OIDC (verified on clean HEAD before any of this work).

Test plan

  • Password regressionhttp://127.0.0.1:8128 (or dev homeserver) still logs in via the password path unchanged
  • MAS happy path on matrix.org — spinner → oidc_card → Continue in browser → system browser opens → complete sign-in → main UI
  • MAS happy path on alvin.meldry.com (primary acceptance target) — dynamic registration works against self-hosted MAS
  • Cancel before callback — start flow, click Cancel sign-in before completing in browser; card returns to idle with ""cancelled"" hint; retry works
  • Session restore across restart — quit + relaunch after OIDC login, land directly on main UI (no login screen)
  • OIDC logout — logout from MAS-authenticated session returns cleanly to login screen

Open follow-ups (not blocking this PR)

  • Client-ID caching (today: dynamic registration per flow). Tracked as OQ-7 in the design spec.
  • alvin.meldry.com UIAA registration path (Phase 3c, separate plan).

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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant