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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 0.3.1

- SDK: OAuth token requests (PKCE authorization URL, code exchange, refresh) now go through [Authlib](https://authlib.org) 1.8.0's httpx2 client instead of hand-rolled request building; `authlib` is a new dependency. Bearer handling, proactive refresh, the single 401 replay, config persistence, and error types are unchanged. Refreshes use a dedicated token-endpoint client with a 30s timeout rather than the SDK's `http_client`, so proxies or transports configured on `http_client=` no longer apply to the token endpoint.

## 0.3.0 (2026-08-29)

- SDK: OAuth login. `Discolike(auth=...)` / `AsyncDiscolike(auth=...)` accept an `ApiKeyCredential` or `OAuthCredential` (both exported from `discolike`); `api_key=`, `DISCOLIKE_API_KEY`, and the config file keep working unchanged, and `auth=` wins over all of them. OAuth credentials send `Authorization: Bearer`, refresh proactively within 60s of expiry and once more after a 401, and write rotated refresh tokens back to the config file when they were loaded from it (an injected `auth=` is never persisted). A refresh that fails raises `AuthenticationError("OAuth session expired; run `discolike auth login`")`. Config file gains the shape `{"auth_method": "oauth", "oauth": {...}}` next to the existing `api_key` shape.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<a href="https://discolike.com">Website</a> ·
<a href="https://docs.discolike.com">API Docs</a> ·
<a href="https://app.discolike.com/account/management/keys">Get an API key</a> ·
<a href="https://auth.discolike.com/en/signup">Sign up</a> ·
<a href="https://discolike.com/signup">Sign up</a> ·
<a href="https://calendly.com/discolike/introductory-call">Book a demo</a> ·
<a href="https://discolike.com/blog/">Blog</a>
</p>
Expand Down Expand Up @@ -288,7 +288,7 @@ Committed request models track the dev spec (`--spec-url https://api.dev.discoli
## Support & contact

- **API documentation**: [docs.discolike.com](https://docs.discolike.com)
- **Sign up**: [auth.discolike.com/en/signup](https://auth.discolike.com/en/signup)
- **Sign up**: [discolike.com/signup](https://discolike.com/signup)
- **Book a demo**: [calendly.com/discolike/introductory-call](https://calendly.com/discolike/introductory-call)
- **LinkedIn**: [linkedin.com/company/discolike](https://www.linkedin.com/company/discolike/)
- **Issues with this SDK**: [GitHub issues](https://github.com/Discolike/discolike-python/issues)
Expand Down
4 changes: 2 additions & 2 deletions packages/discolike-cli/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ build-backend = "hatchling.build"

[project]
name = "discolike-cli"
version = "0.3.0"
version = "0.3.1"
description = "Official CLI for the DiscoLike API"
readme = "README.md"
license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
"discolike==0.3.0",
"discolike==0.3.1",
"typer>=0.12",
"rich>=13.0",
]
Expand Down
6 changes: 1 addition & 5 deletions packages/discolike-cli/src/discolike_cli/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
from discolike._oauth import build_authorization_url
from discolike._oauth import discover
from discolike._oauth import exchange_code
from discolike._oauth import pkce_pair
from discolike._oauth import register_client
from discolike_cli._loopback import CallbackServer
from discolike_cli._output import emit
Expand Down Expand Up @@ -135,13 +134,11 @@ def _authorize(
open_browser: bool,
http: httpx2.Client,
) -> OAuthCredential:
verifier, challenge = pkce_pair()
state = secrets.token_urlsafe(STATE_BYTES)
url = build_authorization_url(
url, verifier = build_authorization_url(
metadata,
client_id=registration.client_id,
redirect_uri=registration.redirect_uri,
code_challenge=challenge,
state=state,
resource=resource,
)
Expand All @@ -168,7 +165,6 @@ def _authorize(
code_verifier=verifier,
redirect_uri=registration.redirect_uri,
resource=resource,
client=http,
)
except OAuthError as exc:
if exc.error in DEAD_CLIENT_ERRORS:
Expand Down
12 changes: 7 additions & 5 deletions packages/discolike-cli/tests/test_auth_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,18 @@ def register_client(self, metadata: AuthServerMetadata, *, redirect_uris: list[s
self.register_calls.append(redirect_uris)
return "client-1"

def exchange_code(self, metadata: AuthServerMetadata, *, client: httpx2.Client, **kwargs: Any) -> OAuthCredential:
def exchange_code(self, metadata: AuthServerMetadata, **kwargs: Any) -> OAuthCredential:
self.exchange_calls.append(kwargs)
if self.exchange_failures:
raise self.exchange_failures.pop(0)
return CREDENTIAL

def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> str:
url = self.real_build_authorization_url(metadata, **kwargs)
def build_authorization_url(self, metadata: AuthServerMetadata, **kwargs: Any) -> tuple[str, str]:
url, verifier = self.real_build_authorization_url(metadata, **kwargs)
query = {key: values[0] for key, values in parse_qs(urlparse(url).query).items()}
callback = f"{query['redirect_uri']}?{self.callback_query(query)}"
threading.Thread(target=lambda: urlopen(callback).read(), daemon=True).start() # noqa: S310 -- loopback test server
return url
return url, verifier

def open(self, url: str) -> bool:
self.opened_urls.append(url)
Expand Down Expand Up @@ -170,7 +170,9 @@ def test_login_timeout_exits_1(
) -> None:
install_build_client(_usage_ok)
monkeypatch.setattr(auth_module, "LOGIN_TIMEOUT_SECONDS", 0.2)
monkeypatch.setattr(auth_module, "build_authorization_url", lambda metadata, **kwargs: "https://auth.test/never")
monkeypatch.setattr(
auth_module, "build_authorization_url", lambda metadata, **kwargs: ("https://auth.test/never", "v")
)
result = runner.invoke(app, ["auth", "login"])
assert result.exit_code == 1
assert "Timed out" in json.loads(result.stderr.splitlines()[-1])["message"]
Expand Down
3 changes: 2 additions & 1 deletion packages/discolike/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ license-files = ["LICENSE"]
requires-python = ">=3.10"
authors = [{ name = "DiscoLike", email = "support@discolike.com" }]
dependencies = [
"authlib>=1.8.0",
"httpx2>=2.9",
"pydantic>=2.7",
"typing-extensions>=4.1",
Expand All @@ -33,7 +34,7 @@ classifiers = [
]

[project.optional-dependencies]
cli = ["discolike-cli==0.3.0"]
cli = ["discolike-cli==0.3.1"]

[project.urls]
Homepage = "https://www.discolike.com"
Expand Down
63 changes: 19 additions & 44 deletions packages/discolike/src/discolike/_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,9 @@
from discolike._credentials import ApiKeyCredential
from discolike._credentials import Credential
from discolike._credentials import OAuthCredential
from discolike._exceptions import AuthenticationError
from discolike._oauth import REFRESH_LEEWAY_SECONDS
from discolike._oauth import SESSION_EXPIRED_MESSAGE
from discolike._oauth import parse_refresh_response
from discolike._oauth import refresh_request

# TODO: replace this module and _oauth.py with authlib's httpx2 OAuth2Client once a release
# includes authlib/authlib@e4fb941 (httpx2 support merged 2026-08-27; 1.7.2 predates it).
from discolike._oauth import refresh
from discolike._oauth import refresh_async

API_KEY_HEADER = "X-discolike-key"
UNAUTHORIZED = 401
Expand All @@ -32,7 +27,8 @@ def _set_bearer(request: httpx2.Request, credential: OAuthCredential) -> None:
class DiscolikeAuth(httpx2.Auth):
"""Sends the API key header, or a bearer token that is refreshed before expiry and once after a 401.

Refreshes go through the same client as the request they precede, so tests drive them via ``MockTransport``.
Refreshes go to the token endpoint through authlib's client, not the SDK client; ``token_transport``
lets tests intercept them.
"""

requires_response_body = False
Expand All @@ -43,10 +39,12 @@ def __init__(
*,
on_update: Callable[[OAuthCredential], None] | None = None,
reload: Callable[[], Credential | None] | None = None,
token_transport: httpx2.BaseTransport | httpx2.AsyncBaseTransport | None = None,
) -> None:
self._credential = credential
self.on_update = on_update
self.reload = reload
self._token_transport = token_transport
self._lock = threading.Lock()
self._async_lock = asyncio.Lock()

Expand All @@ -71,13 +69,7 @@ def _adopt_stored(self, credential: OAuthCredential) -> OAuthCredential | None:
self._credential = stored
return stored

def _store(self, response: httpx2.Response, *, credential: OAuthCredential) -> OAuthCredential:
try:
rotated = parse_refresh_response(response, credential=credential)
except AuthenticationError as exc:
raise AuthenticationError(
SESSION_EXPIRED_MESSAGE, status_code=exc.status_code, payload=exc.payload
) from exc
def _store(self, rotated: OAuthCredential) -> OAuthCredential:
self._credential = rotated
if self.on_update is not None:
self.on_update(rotated)
Expand All @@ -92,31 +84,22 @@ def sync_auth_flow(self, request: httpx2.Request) -> Generator[httpx2.Request, h
with self._lock:
credential = self._latest(credential)
if credential.expires_within(REFRESH_LEEWAY_SECONDS):
adopted = self._adopt_stored(credential)
if adopted is not None:
credential = adopted
else:
credential = yield from self._sync_refresh(credential)
credential = self._adopt_stored(credential) or self._store(
refresh(credential, transport=self._token_transport)
)
_set_bearer(request, credential)
response = yield request
if response.status_code != UNAUTHORIZED:
return
with self._lock:
latest = self._latest(credential)
if latest is credential:
adopted = self._adopt_stored(credential)
if adopted is not None:
latest = adopted
else:
latest = yield from self._sync_refresh(credential)
latest = self._adopt_stored(credential) or self._store(
refresh(credential, transport=self._token_transport)
)
_set_bearer(request, latest)
yield request

def _sync_refresh(self, credential: OAuthCredential) -> Generator[httpx2.Request, httpx2.Response, OAuthCredential]:
response = yield refresh_request(credential)
response.read()
return self._store(response, credential=credential)

async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
credential = self._credential
if isinstance(credential, ApiKeyCredential):
Expand All @@ -126,26 +109,18 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
async with self._async_lock:
credential = self._latest(credential)
if credential.expires_within(REFRESH_LEEWAY_SECONDS):
adopted = self._adopt_stored(credential)
if adopted is not None:
credential = adopted
else:
response = yield refresh_request(credential)
await response.aread()
credential = self._store(response, credential=credential)
credential = self._adopt_stored(credential) or self._store(
await refresh_async(credential, transport=self._token_transport)
)
_set_bearer(request, credential)
response = yield request
if response.status_code != UNAUTHORIZED:
return
async with self._async_lock:
latest = self._latest(credential)
if latest is credential:
adopted = self._adopt_stored(credential)
if adopted is not None:
latest = adopted
else:
response = yield refresh_request(credential)
await response.aread()
latest = self._store(response, credential=credential)
latest = self._adopt_stored(credential) or self._store(
await refresh_async(credential, transport=self._token_transport)
)
_set_bearer(request, latest)
yield request
Loading
Loading