Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 1 addition & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,7 @@
常に既定のキーで実行されていたため、代替ログインで得たセッションでは
更新が 401 になることがありました。セッションを確立したキーを記憶し、
更新時とセッション復元後にもそのキーを使います(署名処理には影響
しません)。

> このリリースでの変更は Go のみです(TypeScript / Python / Rust への
> 反映は追って行います)。
しません)。全言語(Go / TypeScript / Python / Rust)に反映済みです。

## v2.2.0

Expand Down
20 changes: 19 additions & 1 deletion PORTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
2 changes: 1 addition & 1 deletion packages/python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
99 changes: 99 additions & 0 deletions packages/python/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
2 changes: 1 addition & 1 deletion packages/python/yaylib/__init__.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion packages/python/yaylib/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion packages/python/yaylib/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
),
Expand Down Expand Up @@ -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)."""
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions packages/python/yaylib/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
10 changes: 7 additions & 3 deletions packages/python/yaylib/session_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "",
)

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion packages/python/yaylib/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/rust/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading
Loading