From 338bd6b6193d1f25ab6cf7683ae6c6ce728a77e9 Mon Sep 17 00:00:00 2001 From: ekkx <77382767+ekkx@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:13:38 +0900 Subject: [PATCH] fix(ts,py,rust): refresh web-fallback sessions with the correct key (v2.2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Go v2.2.1 fix to TypeScript, Python and Rust: a session obtained via the alternate login endpoint is issued under the web API key, so its OAuth token refresh must use that key for the Basic credential — refreshing with the app key 401s. Each SDK now tracks the key that established the session, uses it as the OAuth Basic credential (header injection + refresh), persists it in the session (omitted for default app sessions) and restores it on a cache hit. Signing is unaffected and keeps using the app key. Brings TS / Py / Rust to v2.2.1, matching Go. Contract in PORTING.md 6.2. --- CHANGELOG.md | 5 +- PORTING.md | 20 +++- packages/python/pyproject.toml | 2 +- packages/python/tests/test_auth.py | 99 +++++++++++++++++ packages/python/yaylib/__init__.py | 2 +- packages/python/yaylib/auth.py | 14 ++- packages/python/yaylib/client.py | 29 ++++- packages/python/yaylib/session.py | 5 + packages/python/yaylib/session_file.py | 10 +- packages/python/yaylib/transport.py | 6 +- packages/rust/Cargo.toml | 2 +- packages/rust/src/auth.rs | 141 ++++++++++++++++++++++++- packages/rust/src/client.rs | 36 +++++++ packages/rust/src/session.rs | 9 ++ packages/rust/src/transport.rs | 37 ++++++- packages/typescript/package.json | 2 +- packages/typescript/src/auth.ts | 15 ++- packages/typescript/src/client.ts | 31 +++++- packages/typescript/src/session.ts | 5 + packages/typescript/src/transport.ts | 6 +- packages/typescript/tests/auth.test.ts | 108 +++++++++++++++++++ 21 files changed, 560 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6075b964..132fca1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,7 @@ 常に既定のキーで実行されていたため、代替ログインで得たセッションでは 更新が 401 になることがありました。セッションを確立したキーを記憶し、 更新時とセッション復元後にもそのキーを使います(署名処理には影響 - しません)。 - -> このリリースでの変更は Go のみです(TypeScript / Python / Rust への -> 反映は追って行います)。 + しません)。全言語(Go / TypeScript / Python / Rust)に反映済みです。 ## v2.2.0 diff --git a/PORTING.md b/PORTING.md index 9e68e892..7863aeb3 100644 --- a/PORTING.md +++ b/PORTING.md @@ -329,8 +329,26 @@ Invariants every port MUST match: - On success, activate tokens and persist the session exactly as the primary path does. +**OAuth refresh MUST use the session's establishing key.** A web-fallback +session is issued under `webApiKey`, so its token refresh (`POST +/api/v1/oauth/token`, `Authorization: Basic base64(key)`) MUST use that +same key — refreshing a web session with the app key 401s. Every port: + +- Tracks, as mutable client state, the key that established the active + session (defaults to `apiKey`; set to `webApiKey` on a fallback login, + or to a caller-pinned key). Call it the *OAuth key*. +- Uses the OAuth key as the Basic credential everywhere the OAuth token + endpoint is hit (both the transport's header injection and any bespoke + refresh call). +- Persists it in the session as `oauth_key` (OMITTED for a default app + session so those files stay byte-identical; §5.1) and restores it on a + cache hit, so a restored web session refreshes correctly. +- Leaves **signing (`signed_info`) on `apiKey`** — only the OAuth Basic + credential is session-scoped. This is the app-key/OAuth-key split. + Parity scenarios live in each `auth` test suite (fires on 401 / no fire -on 2FA / no fire when key pinned / disabled / 2xx-decode-fail surfaced). +on 2FA / no fire when key pinned / disabled / 2xx-decode-fail surfaced; +refresh uses web key / app key / restored web key). --- diff --git a/packages/python/pyproject.toml b/packages/python/pyproject.toml index 36d37d3e..618256cd 100644 --- a/packages/python/pyproject.toml +++ b/packages/python/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "yaylib" -version = "2.2.0" +version = "2.2.1" description = "Yay! API client SDK" authors = ["ekkx"] license = "MIT" diff --git a/packages/python/tests/test_auth.py b/packages/python/tests/test_auth.py index 6f4de32e..549bf80e 100644 --- a/packages/python/tests/test_auth.py +++ b/packages/python/tests/test_auth.py @@ -349,3 +349,102 @@ def handler(path, method, body): assert client.tokens.access == "" finally: await client.close() + + +# --- OAuth refresh uses the session's key (PORTING.md §6.2) ------------------- + +import base64 as _b64 + +APP_KEY = "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6" + + +def _basic(key): + return "Basic " + _b64.b64encode(key.encode("utf-8")).decode("ascii") + + +async def test_refresh_web_session_uses_web_key(): + seen = {} + + def handler(path, method, body, headers): + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + return (200, json.dumps( + {"access_token": "A1", "refresh_token": "R1", "user_id": 1}), {}) + if "/api/v1/oauth/token" in path: + seen["auth"] = headers.get("Authorization") + return (200, json.dumps( + {"access_token": "A2", "refresh_token": "R2", "id": 1}), {}) + return 404, "", {} + + async with serve(handler) as base_url: + client = Client(base_url=base_url) + try: + await client.login_with_email(email="u@example.com", password="pw") + assert client._oauth_key_snapshot() == DEFAULT_WEB_KEY + assert await client._do_refresh(client.tokens.access) is True + assert seen["auth"] == _basic(DEFAULT_WEB_KEY) + assert client.tokens.access == "A2" + finally: + await client.close() + + +async def test_refresh_app_session_uses_app_key(tmp_path): + seen = {} + + def handler(path, method, body, headers): + if path.endswith("/login_with_email"): + return (200, json.dumps( + {"access_token": "A1", "refresh_token": "R1", "user_id": 1}), {}) + if "/api/v1/oauth/token" in path: + seen["auth"] = headers.get("Authorization") + return (200, json.dumps( + {"access_token": "A2", "refresh_token": "R2", "id": 1}), {}) + return 404, "", {} + + async with serve(handler) as base_url: + store = MemorySessionStore() + client = Client(base_url=base_url, session_store=store) + try: + await client.login_with_email(email="u@example.com", password="pw") + assert client._oauth_key_snapshot() == APP_KEY + sess = await store.load("u@example.com") + assert sess.oauth_key == "" # app sessions don't persist a key + assert await client._do_refresh(client.tokens.access) is True + assert seen["auth"] == _basic(APP_KEY) + finally: + await client.close() + + +async def test_refresh_restored_web_session_uses_web_key(): + seen = {} + + def handler(path, method, body, headers): + if path == "/v3/users/login_with_email": + return _reject_401() + if path == "/v2/users/login_with_email": + return (200, json.dumps( + {"access_token": "A1", "refresh_token": "R1", "user_id": 1}), {}) + if "/api/v1/oauth/token" in path: + seen["auth"] = headers.get("Authorization") + return (200, json.dumps( + {"access_token": "A2", "refresh_token": "R2", "id": 1}), {}) + return 404, "", {} + + async with serve(handler) as base_url: + store = MemorySessionStore() + c1 = Client(base_url=base_url, session_store=store) + try: + await c1.login_with_email(email="u@example.com", password="pw") + assert (await store.load("u@example.com")).oauth_key == DEFAULT_WEB_KEY + finally: + await c1.close() + + c2 = Client(base_url=base_url, session_store=store) + try: + await c2.login_with_email(email="u@example.com", password="pw") + assert c2._oauth_key_snapshot() == DEFAULT_WEB_KEY # restored + assert await c2._do_refresh(c2.tokens.access) is True + assert seen["auth"] == _basic(DEFAULT_WEB_KEY) + finally: + await c2.close() diff --git a/packages/python/yaylib/__init__.py b/packages/python/yaylib/__init__.py index 4390298f..9f4e3f22 100644 --- a/packages/python/yaylib/__init__.py +++ b/packages/python/yaylib/__init__.py @@ -14,7 +14,7 @@ """ # noqa: E501 -__version__ = "2.2.0" +__version__ = "2.2.1" # import apis into sdk package from yaylib.api.activities_api import ActivitiesApi diff --git a/packages/python/yaylib/auth.py b/packages/python/yaylib/auth.py index a6d591c5..041d0dee 100644 --- a/packages/python/yaylib/auth.py +++ b/packages/python/yaylib/auth.py @@ -61,6 +61,9 @@ def _activate_cached_session(client, session: Session) -> None: if session.device_uuid: client.set_device_uuid(session.device_uuid) client.set_login_identity(session.email, session.user_id) + # Restore which key refreshes this session (empty → app key) so a + # cached web session refreshes with the web key after a restart. + client.set_oauth_key(session.oauth_key) async def _login_with_email_web( @@ -153,13 +156,17 @@ async def _execute_login( _activate_cached_session(client, cached) return _synthesize_login_response(cached) + login_key = api_key or client.api_key body = LoginEmailUserRequest( - api_key=api_key or client.api_key, + api_key=login_key, email=email, password=password, uuid=uuid or client.device_uuid, two_f_a_code=two_fa_code, ) + # The key that establishes the session also refreshes its tokens; + # the fallback switches it to web_api_key below. + oauth_key = login_key try: resp = await client.users_api.login_with_email( @@ -193,6 +200,9 @@ async def _execute_login( extra={"event": "login_fallback", "outcome": "ok"}, ) resp = fallback + # This session was established with the web key, so its token + # refresh must use it too. + oauth_key = client.web_api_key else: raise api_err @@ -201,6 +211,7 @@ async def _execute_login( user_id = resp.user_id or 0 client.set_tokens(access, refresh) client.set_login_identity(email, user_id) + client.set_oauth_key(oauth_key) if client.session_store is not None: session = Session( @@ -209,6 +220,7 @@ async def _execute_login( access_token=access, refresh_token=refresh, device_uuid=client.device_uuid, + oauth_key=client._session_oauth_key(), updated_at="", ) try: diff --git a/packages/python/yaylib/client.py b/packages/python/yaylib/client.py index 5c6ffe2c..62031b2a 100644 --- a/packages/python/yaylib/client.py +++ b/packages/python/yaylib/client.py @@ -173,6 +173,10 @@ def __init__( self._tokens: Tokens = empty_tokens() self._user_id = 0 self._current_email = "" + # API key that established the active session, used as the Basic + # credential for OAuth token refresh. Empty means "use api_key"; + # becomes web_api_key for a browser-login session (§6.2). + self._oauth_key = "" self._client_ip = "" self._client_ip_fetching = False self._bg_tasks: "set[asyncio.Task]" = set() @@ -191,6 +195,7 @@ def __init__( api_key=self.api_key, client_ip=lambda: self._client_ip, access_token=lambda: self._tokens.access, + oauth_key=lambda: self._oauth_key_snapshot(), cassandra_base_url=self.cassandra_base_url, logger=self._logger, ), @@ -259,6 +264,22 @@ def set_login_identity(self, email: str, user_id: int) -> None: self._current_email = email self._user_id = user_id + def set_oauth_key(self, key: str) -> None: + """Internal — record the key that established the session (see + _oauth_key). Passing "" resets to the default (api_key).""" + self._oauth_key = key + + def _oauth_key_snapshot(self) -> str: + """Basic credential for OAuth token refresh: the session's + oauth_key, or api_key when none was recorded.""" + return self._oauth_key or self.api_key + + def _session_oauth_key(self) -> str: + """Value to persist in Session.oauth_key: empty for a default + (app) session, the actual key for a web / pinned session.""" + k = self._oauth_key_snapshot() + return k if k != self.api_key else "" + def set_device_uuid(self, uuid: str) -> None: """Internal — used by auth.py when a restored session carries a persisted device UUID (PORTING.md §14).""" @@ -611,7 +632,12 @@ async def _do_refresh(self, stale_access: str) -> bool: url = f"{self.base_url}/api/v1/oauth/token" import base64 - basic = base64.b64encode(self.api_key.encode("utf-8")).decode("ascii") + # Basic credential is the key that established this session (app + # key, or web_api_key for a browser-login session) — refreshing a + # web session with the app key would 401. + basic = base64.b64encode( + self._oauth_key_snapshot().encode("utf-8") + ).decode("ascii") form = urlencode( { "grant_type": "refresh_token", @@ -672,6 +698,7 @@ async def _do_refresh(self, stale_access: str) -> bool: access_token=self._tokens.access, refresh_token=self._tokens.refresh, device_uuid=self._device_uuid, + oauth_key=self._session_oauth_key(), updated_at="", ) try: diff --git a/packages/python/yaylib/session.py b/packages/python/yaylib/session.py index 736c9902..12e6fdbd 100644 --- a/packages/python/yaylib/session.py +++ b/packages/python/yaylib/session.py @@ -31,6 +31,11 @@ class Session: access_token: str = "" refresh_token: str = "" device_uuid: str = "" + # API key used to refresh this session's tokens. Empty means the + # default app key; set for sessions obtained via the browser-login + # fallback. Absent on older session files, which restore as app + # sessions. + oauth_key: str = "" updated_at: str = "" # RFC3339 diff --git a/packages/python/yaylib/session_file.py b/packages/python/yaylib/session_file.py index 856e86a6..663040a5 100644 --- a/packages/python/yaylib/session_file.py +++ b/packages/python/yaylib/session_file.py @@ -36,6 +36,7 @@ def _session_from_dict(email: str, raw: dict) -> Session: access_token=raw.get("access_token") or "", refresh_token=raw.get("refresh_token") or "", device_uuid=raw.get("device_uuid") or "", + oauth_key=raw.get("oauth_key") or "", updated_at=raw.get("updated_at") or "", ) @@ -72,9 +73,12 @@ async def save(self, session: Session) -> None: data = self._read_all() record = dataclasses.asdict(session) record["updated_at"] = session.updated_at or _now_rfc3339() - data["sessions"][session.email] = { - k: record[k] for k in _FIELDS - } + out = {k: record[k] for k in _FIELDS} + # Omit oauth_key for default (app) sessions so their files stay + # unchanged; write it only for web / caller-pinned sessions. + if session.oauth_key: + out["oauth_key"] = session.oauth_key + data["sessions"][session.email] = out self._write_all(data) async def delete(self, email: str) -> None: diff --git a/packages/python/yaylib/transport.py b/packages/python/yaylib/transport.py index cb9b3824..1ce55787 100644 --- a/packages/python/yaylib/transport.py +++ b/packages/python/yaylib/transport.py @@ -100,6 +100,10 @@ class TransportContext: api_key: str client_ip: Callable[[], str] access_token: Callable[[], str] + # Basic credential for the OAuth token endpoint: the key that + # established the active session (api_key, or web_api_key for a + # browser-login session). See PORTING.md §6.2. + oauth_key: Callable[[], str] # Auxiliary host for activity-feed operations (see # yaylib._host_routes). The transport rewrites the request origin to # this for host-routed operations; everything else stays on the @@ -280,7 +284,7 @@ def _build_headers(self, base: dict, url: str) -> dict: if _find_header(headers, "authorization") is None: if _is_oauth_token_path(url): token = base64.b64encode( - ctx.api_key.encode("utf-8") + ctx.oauth_key().encode("utf-8") ).decode("ascii") headers["Authorization"] = f"Basic {token}" else: diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index 0c27cc6a..d29e3f9b 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "yaylib" -version = "2.2.0" +version = "2.2.1" edition = "2021" rust-version = "1.88" description = "Rust client for the Yay! (SNS) API" diff --git a/packages/rust/src/auth.rs b/packages/rust/src/auth.rs index c2e4dd10..f0d9bd85 100644 --- a/packages/rust/src/auth.rs +++ b/packages/rust/src/auth.rs @@ -122,6 +122,10 @@ impl<'a> LoginWithEmailRequest<'a> { // default). A pinned key opts out of the browser-login fallback. let caller_pinned = api_key.as_str() != self.client.api_key(); + // The key that establishes the session also refreshes its tokens; + // the fallback switches it to web_api_key below. + let mut oauth_key = api_key.clone(); + // Build and execute the HTTP login request. let body = LoginEmailUserRequestBody { email: email.clone(), @@ -151,6 +155,8 @@ impl<'a> LoginWithEmailRequest<'a> { event = "login_fallback", outcome = "ok" ); + // Established with the web key → refresh with it. + oauth_key = self.client.web_api_key().to_string(); resp } // Fallback rejected → surface the primary error. @@ -168,6 +174,7 @@ impl<'a> LoginWithEmailRequest<'a> { // session persistence fails below. self.client.set_login_identity(&email, resp.user_id); self.client.set_tokens(&resp.access_token, &resp.refresh_token); + self.client.set_oauth_key(&oauth_key); if let Some(store) = self.client.session_store() { if !email.is_empty() { @@ -177,6 +184,7 @@ impl<'a> LoginWithEmailRequest<'a> { access_token: resp.access_token.clone(), refresh_token: resp.refresh_token.clone(), device_uuid, + oauth_key: self.client.session_oauth_key(), updated_at: Utc::now(), }; if let Err(e) = store.save(&session).await { @@ -223,11 +231,12 @@ mod fallback_tests { use crate::client::Client; use crate::error_codes::ErrorCode; use crate::errors::code_of; - use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::matchers::{body_string_contains, header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; const WEB_KEY: &str = "e9f1ae4c4470f29a92c0168dc42b13637cc332692103f23e626bc2b016f66603"; + const APP_KEY: &str = crate::config::DEFAULT_API_KEY; fn reject_401(code: i32) -> ResponseTemplate { ResponseTemplate::new(401).set_body_string(format!( @@ -366,4 +375,134 @@ mod fallback_tests { assert_ne!(code_of(&err), ErrorCode::Unauthorized); assert_eq!(client.tokens().access, ""); } + + // --- OAuth refresh uses the session's key (PORTING.md §6.2) -------------- + + // Mount the timestamp endpoint to 401 once (triggering a refresh) then + // 200, and the oauth endpoint to accept ONLY the given Basic key — so a + // successful get_user_timestamp proves the refresh used that key. + async fn mock_refresh_flow(server: &MockServer, expected_key: &str) { + Mock::given(method("GET")) + .and(path("/v2/users/timestamp")) + .respond_with(ResponseTemplate::new(401).set_body_string(r#"{"error_code":-1}"#)) + .up_to_n_times(1) + .with_priority(1) + .mount(server) + .await; + Mock::given(method("GET")) + .and(path("/v2/users/timestamp")) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"time":1700000000}"#)) + .with_priority(2) + .mount(server) + .await; + Mock::given(method("POST")) + .and(path("/api/v1/oauth/token")) + .and(header( + "authorization", + format!("Basic {}", crate::transport::basic_credentials(expected_key)), + )) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"access_token":"A2","refresh_token":"R2","id":1}"#, + )) + .mount(server) + .await; + } + + #[tokio::test] + async fn web_session_refreshes_with_web_key() { + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"access_token":"A1","refresh_token":"R1","user_id":1}"#, + )) + .mount(&server) + .await; + mock_refresh_flow(&server, WEB_KEY).await; + + let client = Client::builder().base_url(server.uri()).build(); + client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect("web login"); + assert_eq!(client.oauth_key_snapshot(), WEB_KEY); + // 401 on the timestamp GET triggers a refresh; it succeeds only if + // the oauth request carried the web key. + let ts = client + .get_user_timestamp() + .await + .expect("timestamp after web-key refresh"); + assert_eq!(ts.time.flatten().unwrap_or(0), 1700000000); + } + + #[tokio::test] + async fn app_session_refreshes_with_app_key() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/v3/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"access_token":"A1","refresh_token":"R1","user_id":1}"#, + )) + .mount(&server) + .await; + mock_refresh_flow(&server, APP_KEY).await; + + let client = Client::builder().base_url(server.uri()).build(); + client + .login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect("app login"); + assert_eq!(client.oauth_key_snapshot(), APP_KEY); + let ts = client + .get_user_timestamp() + .await + .expect("timestamp after app-key refresh"); + assert_eq!(ts.time.flatten().unwrap_or(0), 1700000000); + } + + #[tokio::test] + async fn restored_web_session_tracks_web_key() { + use std::sync::Arc; + let server = MockServer::start().await; + mock_v3_reject(&server, 401).await; + Mock::given(method("POST")) + .and(path("/v2/users/login_with_email")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"access_token":"A1","refresh_token":"R1","user_id":1}"#, + )) + .mount(&server) + .await; + + let store = Arc::new(crate::session::MemorySessionStore::new()); + let c1 = Client::builder() + .base_url(server.uri()) + .session_store_arc(store.clone()) + .build(); + c1.login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect("web login"); + + // A fresh client restores the cached web session and tracks the key. + let c2 = Client::builder() + .base_url(server.uri()) + .session_store_arc(store) + .build(); + c2.login_with_email() + .email("u@example.com") + .password("pw") + .execute() + .await + .expect("restore login"); + assert_eq!(c2.oauth_key_snapshot(), WEB_KEY); + } } diff --git a/packages/rust/src/client.rs b/packages/rust/src/client.rs index f67b44d3..390bcf0b 100644 --- a/packages/rust/src/client.rs +++ b/packages/rust/src/client.rs @@ -41,6 +41,10 @@ pub(crate) struct SharedState { pub(crate) current_email: String, pub(crate) device_uuid: String, pub(crate) client_ip: String, + /// API key that established the active session, used as the Basic + /// credential for OAuth token refresh. Empty means "use api_key"; + /// becomes web_api_key for a browser-login session (§6.2). + pub(crate) oauth_key: String, /// Reserved for lazy IP fetch (not yet implemented). #[allow(dead_code)] pub(crate) client_ip_fetching: bool, @@ -216,6 +220,7 @@ impl ClientBuilder { current_email: String::new(), device_uuid: self.device_uuid.unwrap_or_else(new_uuid_v4), client_ip: self.client_ip.unwrap_or_default(), + oauth_key: String::new(), client_ip_fetching: false, })); @@ -348,6 +353,34 @@ impl Client { state.refresh_token = refresh.to_string(); } + /// Records the key that established the session (see SharedState.oauth_key). + /// Passing "" resets to the default (api_key). + pub(crate) fn set_oauth_key(&self, key: &str) { + self.state.write().unwrap_or_else(|e| e.into_inner()).oauth_key = key.to_string(); + } + + /// Basic credential for OAuth token refresh: the session's oauth_key, + /// or api_key when none was recorded. + pub(crate) fn oauth_key_snapshot(&self) -> String { + let k = self.state.read().unwrap_or_else(|e| e.into_inner()).oauth_key.clone(); + if k.is_empty() { + self.config.api_key.clone() + } else { + k + } + } + + /// Value to persist in Session.oauth_key: empty for a default (app) + /// session, the actual key for a web / caller-pinned session. + pub(crate) fn session_oauth_key(&self) -> String { + let k = self.oauth_key_snapshot(); + if k == self.config.api_key { + String::new() + } else { + k + } + } + pub fn user_id(&self) -> i64 { self.state.read().unwrap_or_else(|e| e.into_inner()).user_id } @@ -491,6 +524,9 @@ impl Client { self.set_device_uuid(session.device_uuid); } self.set_login_identity(email, session.user_id); + // Restore which key refreshes this session (empty → app key) + // so a cached web session refreshes with the web key. + self.set_oauth_key(&session.oauth_key); Ok(Some(LoginResponse { user_id: session.user_id, access_token: session.access_token, diff --git a/packages/rust/src/session.rs b/packages/rust/src/session.rs index 09b6c16b..8f3fdd57 100644 --- a/packages/rust/src/session.rs +++ b/packages/rust/src/session.rs @@ -16,6 +16,12 @@ pub struct Session { pub refresh_token: String, #[serde(default, skip_serializing_if = "String::is_empty")] pub device_uuid: String, + /// API key used to refresh this session's tokens. Empty means the + /// default app key; set for sessions obtained via the browser-login + /// fallback (§6.2). Absent on older session files, which restore as + /// app sessions. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub oauth_key: String, pub updated_at: DateTime, } @@ -241,6 +247,7 @@ mod tests { access_token: "access".into(), refresh_token: "refresh".into(), device_uuid: "00000000-0000-0000-0000-000000000000".into(), + oauth_key: String::new(), updated_at: Utc::now(), }; @@ -275,6 +282,7 @@ mod tests { access_token: "a".into(), refresh_token: "r".into(), device_uuid: String::new(), + oauth_key: String::new(), updated_at: Utc::now(), }; assert!(matches!( @@ -301,6 +309,7 @@ mod tests { access_token: "at".into(), refresh_token: "rt".into(), device_uuid: "dev-uuid".into(), + oauth_key: String::new(), updated_at: Utc::now(), }; diff --git a/packages/rust/src/transport.rs b/packages/rust/src/transport.rs index b0fca3a5..b7fc93cf 100644 --- a/packages/rust/src/transport.rs +++ b/packages/rust/src/transport.rs @@ -151,16 +151,25 @@ impl Transport { } fn set_headers(&self, req: &mut reqwest::Request) { - let (access_token, device_uuid, client_ip) = { + let (access_token, device_uuid, client_ip, oauth_key) = { let state = self.state.read().unwrap_or_else(|e| e.into_inner()); ( state.access_token.clone(), state.device_uuid.clone(), state.client_ip.clone(), + state.oauth_key.clone(), ) }; let config = &self.config; + // Basic credential for the OAuth endpoint is the key that + // established the session (app key, or web key for a browser-login + // session) — refreshing a web session with the app key would 401. + let oauth_key = if oauth_key.is_empty() { + config.api_key.as_str() + } else { + oauth_key.as_str() + }; // Snapshot the path before taking a mutable borrow on headers. let is_oauth = is_oauth_token_path(req.url().path()); @@ -228,7 +237,7 @@ impl Transport { // Authorization: Basic for oauth/token, Bearer otherwise. if !headers.contains_key(AUTHORIZATION) { if is_oauth { - let cred = basic_credentials(&config.api_key); + let cred = basic_credentials(oauth_key); if let Ok(v) = HeaderValue::from_str(&format!("Basic {}", cred)) { headers.insert(AUTHORIZATION, v); } @@ -297,15 +306,25 @@ impl Transport { let _guard = self.refresh_lock.lock().await; // Another task may have already refreshed while we waited for the lock. - let (current_email, device_uuid) = { + let (current_email, device_uuid, oauth_key) = { let state = self.state.read().unwrap_or_else(|e| e.into_inner()); if state.access_token != stale_access { return Ok(()); } - (state.current_email.clone(), state.device_uuid.clone()) + ( + state.current_email.clone(), + state.device_uuid.clone(), + state.oauth_key.clone(), + ) }; let config = &self.config; + // Refresh with the key that established the session (§6.2). + let oauth_key = if oauth_key.is_empty() { + config.api_key.as_str() + } else { + oauth_key.as_str() + }; tracing::debug!(event = crate::logging::LOG_EVENT_TOKEN_REFRESH, outcome = "start"); @@ -320,7 +339,7 @@ impl Transport { .post(format!("{}/api/v1/oauth/token", config.base_url)) .header( AUTHORIZATION, - format!("Basic {}", basic_credentials(&config.api_key)), + format!("Basic {}", basic_credentials(oauth_key)), ) .header(USER_AGENT, config.user_agent.as_str()) .header("x-app-version", config.api_version_name.as_str()) @@ -374,6 +393,13 @@ impl Transport { access_token: new_access, refresh_token: new_refresh, device_uuid, + // Omit for a default (app) session so its file stays + // unchanged; carry it forward for web / pinned sessions. + oauth_key: if oauth_key == config.api_key.as_str() { + String::new() + } else { + oauth_key.to_string() + }, updated_at: chrono::Utc::now(), }; tokio::spawn(async move { @@ -444,6 +470,7 @@ impl Default for Transport { current_email: String::new(), device_uuid: crate::uuid::new_uuid_v4(), client_ip: String::new(), + oauth_key: String::new(), client_ip_fetching: false, })); Self { diff --git a/packages/typescript/package.json b/packages/typescript/package.json index d0679913..ef438b6a 100644 --- a/packages/typescript/package.json +++ b/packages/typescript/package.json @@ -1,6 +1,6 @@ { "name": "yaylib", - "version": "2.2.0", + "version": "2.2.1", "description": "Yay! API client SDK", "author": "ekkx", "license": "MIT", diff --git a/packages/typescript/src/auth.ts b/packages/typescript/src/auth.ts index aa215019..2cce03cc 100644 --- a/packages/typescript/src/auth.ts +++ b/packages/typescript/src/auth.ts @@ -79,13 +79,17 @@ async function executeLogin( } // Fresh HTTP login. APIKey / Uuid auto-fill matches the Go reference. + const loginKey = params.apiKey ?? client.apiKey; const body = { email: params.email, password: params.password, - apiKey: params.apiKey ?? client.apiKey, + apiKey: loginKey, uuid: params.uuid ?? client.deviceUUID, twoFACode: params.twoFACode, }; + // The key that establishes the session also refreshes its tokens; the + // fallback switches it to webApiKey below. + let oauthKey = loginKey; let resp: LoginUserResponse; try { @@ -116,6 +120,9 @@ async function executeLogin( outcome: "ok", }); resp = fallback; + // This session was established with the web key, so its token + // refresh must use it too. + oauthKey = client.webApiKey; } else { throw apiErr; } @@ -127,6 +134,7 @@ async function executeLogin( const userID = resp.userId ?? 0; client.setTokens(access, refresh); client.setLoginIdentity(params.email, userID); + client.setOAuthKey(oauthKey); // Persist the session. PORTING.md §5: a persist failure here returns // SessionSaveFailedError, but the tokens are still active in memory so @@ -138,6 +146,8 @@ async function executeLogin( accessToken: access, refreshToken: refresh, deviceUUID: client.deviceUUID, + // Omit for a default (app) session so its file stays unchanged. + oauthKey: oauthKey === client.apiKey ? undefined : oauthKey, updatedAt: new Date().toISOString(), }; try { @@ -155,6 +165,9 @@ function activateCachedSession(client: Client, session: Session): void { if (session.deviceUUID) { client.setDeviceUUID(session.deviceUUID); } + // Restore which key refreshes this session (empty → app key) so a cached + // web session refreshes with the web key after a restart. + client.setOAuthKey(session.oauthKey ?? ""); client.setLoginIdentity(session.email, session.userID); } diff --git a/packages/typescript/src/client.ts b/packages/typescript/src/client.ts index e1e35a26..442a6857 100644 --- a/packages/typescript/src/client.ts +++ b/packages/typescript/src/client.ts @@ -180,6 +180,10 @@ export class Client { private _tokens: Tokens = emptyTokens(); private _userID = 0; private _currentEmail = ""; + // API key that established the active session, used as the Basic + // credential for OAuth token refresh. Empty means "use apiKey"; becomes + // webApiKey for a browser-login session (§6.2). Signing is unaffected. + private _oauthKey = ""; private _clientIP = ""; private _clientIPFetching = false; // Single-flight refresh tracking — concurrent 401s collapse onto one @@ -277,6 +281,7 @@ export class Client { apiKey: this.apiKey, clientIP: () => this._clientIP, accessToken: () => this._tokens.access, + oauthKey: () => this._oauthKeySnapshot(), }), buildAuthRefreshMiddleware({ refresh: (stale) => this._tryRefresh(stale), @@ -351,6 +356,26 @@ export class Client { this._userID = userID; } + /** + * Internal — records the key that established the session (see _oauthKey). + * Passing "" resets to the default (apiKey). + */ + setOAuthKey(key: string): void { + this._oauthKey = key; + } + + /** Basic credential for OAuth token refresh: the session's key or apiKey. */ + private _oauthKeySnapshot(): string { + return this._oauthKey || this.apiKey; + } + + /** Value to persist in Session.oauthKey: undefined for a default (app) + * session so its file stays unchanged, the key for a web/pinned one. */ + private _sessionOAuthKey(): string | undefined { + const k = this._oauthKeySnapshot(); + return k !== this.apiKey ? k : undefined; + } + /** Internal — used by auth.ts when LoadSession restores a persisted UUID. */ setDeviceUUID(uuid: string): void { this._deviceUUID = uuid; @@ -700,7 +725,10 @@ export class Client { res = await fetchImpl(`${this.baseURL}/api/v1/oauth/token`, { method: "POST", headers: { - Authorization: `Basic ${btoa(this.apiKey)}`, + // The key that established this session (app key, or web key + // for a browser-login session) — refreshing a web session with + // the app key would 401. + Authorization: `Basic ${btoa(this._oauthKeySnapshot())}`, "Content-Type": "application/x-www-form-urlencoded", }, body: form.toString(), @@ -766,6 +794,7 @@ export class Client { accessToken: this._tokens.access, refreshToken: this._tokens.refresh, deviceUUID: this._deviceUUID, + oauthKey: this._sessionOAuthKey(), updatedAt: new Date().toISOString(), }; try { diff --git a/packages/typescript/src/session.ts b/packages/typescript/src/session.ts index e1515a28..05f4668e 100644 --- a/packages/typescript/src/session.ts +++ b/packages/typescript/src/session.ts @@ -9,6 +9,11 @@ export interface Session { accessToken: string; refreshToken: string; deviceUUID: string; + // API key used to refresh this session's tokens. Empty/absent means the + // default app key; set for sessions obtained via the browser-login + // fallback (§6.2). Older session files without it restore as app + // sessions. + oauthKey?: string; updatedAt: string; // RFC3339 } diff --git a/packages/typescript/src/transport.ts b/packages/typescript/src/transport.ts index ee7b6f2a..3feb75bf 100644 --- a/packages/typescript/src/transport.ts +++ b/packages/typescript/src/transport.ts @@ -25,6 +25,10 @@ export interface TransportContext { apiKey: string; clientIP(): string; accessToken(): string; + // Basic credential for the OAuth token endpoint: the key that + // established the active session (apiKey, or webApiKey for a + // browser-login session). See PORTING.md §6.2. + oauthKey(): string; } // RefreshFn attempts a one-shot refresh of the access token using the @@ -184,7 +188,7 @@ export function buildHeadersMiddleware(ctx: TransportContext): Middleware { ); if (!hasAuth) { if (isOAuthTokenPath(req.url)) { - headers["Authorization"] = `Basic ${base64Encode(ctx.apiKey)}`; + headers["Authorization"] = `Basic ${base64Encode(ctx.oauthKey())}`; } else { const access = ctx.accessToken(); if (access) headers["Authorization"] = `Bearer ${access}`; diff --git a/packages/typescript/tests/auth.test.ts b/packages/typescript/tests/auth.test.ts index 7e90aed0..9759d59c 100644 --- a/packages/typescript/tests/auth.test.ts +++ b/packages/typescript/tests/auth.test.ts @@ -417,6 +417,111 @@ async function scenarioWebDecodeFailureSurfaced(): Promise { ); } +// --- OAuth refresh uses the session's key (PORTING.md §6.2) ------------------ + +const APP_KEY = + "ccd59ee269c01511ba763467045c115779fcae3050238a252f1bd1a4b65cfec6"; +const basic = (key: string) => `Basic ${Buffer.from(key).toString("base64")}`; + +// Server that captures the Authorization header on the oauth/token path. +async function withRefreshServer( + loginHandler: (path: string) => { status: number; body: string } | null, + fn: (baseURL: string, getOauthAuth: () => string) => Promise, +): Promise { + let oauthAuth = ""; + const server: Server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + const path = req.url ?? ""; + res.setHeader("Content-Type", "application/json"); + if (path.includes("/api/v1/oauth/token")) { + oauthAuth = req.headers["authorization"] ?? ""; + res.writeHead(200); + res.end( + JSON.stringify({ access_token: "A2", refresh_token: "R2", id: 1 }), + ); + return; + } + const out = loginHandler(path); + if (out) { + res.writeHead(out.status); + res.end(out.body); + } else { + res.writeHead(404); + res.end(""); + } + }); + }); + await new Promise((resolve) => server.listen(0, resolve)); + const addr = server.address() as AddressInfo; + try { + return await fn(`http://127.0.0.1:${addr.port}`, () => oauthAuth); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +const webLoginHandler = (path: string) => { + if (path === "/v3/users/login_with_email") return reject401(); + if (path === "/v2/users/login_with_email") + return { + status: 200, + body: JSON.stringify({ access_token: "A1", refresh_token: "R1", user_id: 1 }), + }; + return null; +}; + +async function scenarioRefreshWebSessionUsesWebKey(): Promise { + await withRefreshServer(webLoginHandler, async (baseURL, getOauthAuth) => { + const client = new Client({ baseURL }); + await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const ok = await (client as any)._doRefresh(client.tokens().access); + assertEq("refresh web: succeeds", ok, true); + assertEq("refresh web: uses web-key Basic", getOauthAuth(), basic(DEFAULT_WEB_KEY)); + assertEq("refresh web: tokens rotated", client.tokens().access, "A2"); + }); +} + +async function scenarioRefreshAppSessionUsesAppKey(): Promise { + const appLogin = (path: string) => + path.endsWith("/login_with_email") + ? { + status: 200, + body: JSON.stringify({ access_token: "A1", refresh_token: "R1", user_id: 1 }), + } + : null; + await withRefreshServer(appLogin, async (baseURL, getOauthAuth) => { + const store = new MemorySessionStore(); + const client = new Client({ baseURL, sessionStore: store }); + await client.loginWithEmail({ email: "u@example.com", password: "pw" }); + const sess = await store.load("u@example.com"); + assertEq("refresh app: no oauthKey persisted", sess.oauthKey, undefined); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (client as any)._doRefresh(client.tokens().access); + assertEq("refresh app: uses app-key Basic", getOauthAuth(), basic(APP_KEY)); + }); +} + +async function scenarioRefreshRestoredWebSessionUsesWebKey(): Promise { + await withRefreshServer(webLoginHandler, async (baseURL, getOauthAuth) => { + const store = new MemorySessionStore(); + const c1 = new Client({ baseURL, sessionStore: store }); + await c1.loginWithEmail({ email: "u@example.com", password: "pw" }); + assertEq( + "restore: web oauthKey persisted", + (await store.load("u@example.com")).oauthKey, + DEFAULT_WEB_KEY, + ); + const c2 = new Client({ baseURL, sessionStore: store }); + await c2.loginWithEmail({ email: "u@example.com", password: "pw" }); // cache hit + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await (c2 as any)._doRefresh(c2.tokens().access); + assertEq("restore: refresh uses web-key Basic", getOauthAuth(), basic(DEFAULT_WEB_KEY)); + }); +} + (async () => { await scenarioFreshLogin(); await scenarioCacheHit(); @@ -428,6 +533,9 @@ async function scenarioWebDecodeFailureSurfaced(): Promise { await scenarioNoFallbackWhenApiKeyPinned(); await scenarioFallbackDisabled(); await scenarioWebDecodeFailureSurfaced(); + await scenarioRefreshWebSessionUsesWebKey(); + await scenarioRefreshAppSessionUsesAppKey(); + await scenarioRefreshRestoredWebSessionUsesWebKey(); process.exit(failed === 0 ? 0 : 1); })().catch((e) => { // eslint-disable-next-line no-console