"""Tests for the OAuth browser flow + refresh logic."""
from __future__ import annotations
import base64
import hashlib
import json
import time
import httpx
import pytest
from omi_cli import config as cfg
from omi_cli.auth import oauth
from omi_cli.auth.store import store_oauth_tokens
from omi_cli.errors import AuthError, UsageError
from omi_cli.main import app
def test_needs_refresh_returns_false_for_api_key_profile(config_path) -> None:
profile = cfg.Profile(name="default", auth_method="api_key", api_key="omi_dev_xxx")
assert oauth.needs_refresh(profile) is False
def test_needs_refresh_true_when_no_expiry(config_path) -> None:
profile = cfg.Profile(name="default", auth_method="oauth", id_token="t", refresh_token="r")
assert oauth.needs_refresh(profile) is True
def test_needs_refresh_true_when_expired(config_path) -> None:
profile = cfg.Profile(
name="default",
auth_method="oauth",
id_token="t",
refresh_token="r",
id_token_expires_at=time.time() - 5,
)
assert oauth.needs_refresh(profile) is True
def test_needs_refresh_false_when_far_from_expiry(config_path) -> None:
profile = cfg.Profile(
name="default",
auth_method="oauth",
id_token="t",
refresh_token="r",
id_token_expires_at=time.time() + 3000,
)
assert oauth.needs_refresh(profile) is False
def test_refresh_rejects_non_oauth_profile(config_path) -> None:
config = cfg.load()
profile = config.get_profile("default")
profile.auth_method = "api_key"
profile.api_key = "omi_dev_x"
config.set_profile(profile)
cfg.save(config)
with pytest.raises(UsageError):
oauth.refresh_id_token("default")
def test_refresh_persists_new_id_token(config_path, monkeypatch) -> None:
store_oauth_tokens(
"default",
id_token="old_id",
refresh_token="refr_1",
expires_at=time.time() - 10,
api_base="https://api.test.omi.local",
)
captured: dict = {}
def fake_post(self, url, **kwargs):
captured["url"] = url
captured["data"] = kwargs.get("data")
return httpx.Response(
200,
json={"id_token": "new_id_token", "refresh_token": "refr_1", "expires_in": "3600"},
)
monkeypatch.setattr(httpx.Client, "post", fake_post)
new = oauth.refresh_id_token("default")
assert new == "new_id_token"
assert captured["data"] == {"grant_type": "refresh_token", "refresh_token": "refr_1"}
reloaded = cfg.load().get_profile("default")
assert reloaded.id_token == "new_id_token"
assert reloaded.refresh_token == "refr_1"
assert abs((reloaded.id_token_expires_at or 0) - (time.time() + 3540)) < 5
def test_refresh_persists_rotated_refresh_token(config_path, monkeypatch) -> None:
store_oauth_tokens(
"default",
id_token="old_id",
refresh_token="refr_old",
expires_at=time.time() - 10,
api_base="https://api.test.omi.local",
)
def fake_post(self, url, **kwargs):
return httpx.Response(
200,
json={"id_token": "new_id", "refresh_token": "refr_new_rotated", "expires_in": "3600"},
)
monkeypatch.setattr(httpx.Client, "post", fake_post)
oauth.refresh_id_token("default")
reloaded = cfg.load().get_profile("default")
assert reloaded.id_token == "new_id"
assert reloaded.refresh_token == "refr_new_rotated"
def test_refresh_emits_json_payload(config_path, cli_runner, monkeypatch) -> None:
"""Regression: `omi --json auth refresh` must write the refreshed profile
to stdout, like `auth login`/`logout`/`status` already do. Previously it
wrote nothing, so scripts relying on the CLI's JSON contract got an empty
string and crashed on json.loads()."""
store_oauth_tokens(
"default",
id_token="old_id",
refresh_token="refr_1",
expires_at=time.time() - 10,
api_base="https://api.test.omi.local",
)
def fake_post(self, url, **kwargs):
return httpx.Response(
200,
json={"id_token": "new_id", "refresh_token": "refr_1", "expires_in": "3600"},
)
monkeypatch.setattr(httpx.Client, "post", fake_post)
result = cli_runner.invoke(app, ["--json", "auth", "refresh"])
assert result.exit_code == 0, result.output
payload = json.loads(result.stdout)
assert payload["profile"] == "default"
assert payload["auth_method"] == "oauth"
assert abs(payload["id_token_expires_at"] - (time.time() + 3540)) < 5
def test_refresh_surfaces_firebase_error(config_path, monkeypatch) -> None:
store_oauth_tokens(
"default",
id_token="old_id",
refresh_token="refr_bad",
expires_at=time.time() - 10,
api_base="https://api.test.omi.local",
)
def fake_post(self, url, **kwargs):
return httpx.Response(401, json={"error": {"message": "INVALID_REFRESH_TOKEN"}})
monkeypatch.setattr(httpx.Client, "post", fake_post)
with pytest.raises(AuthError) as info:
oauth.refresh_id_token("default")
assert "401" in str(info.value)
def test_login_with_browser_rejects_unknown_provider(config_path) -> None:
with pytest.raises(UsageError):
oauth.login_with_browser(
"default",
api_base="https://api.test.omi.local",
provider="microsoft",
open_browser=False,
)
def test_browser_login_status_goes_to_stderr_not_stdout(monkeypatch, capsys) -> None:
"""Human status messages during browser login must not pollute stdout.
In --json mode stdout carries only the JSON payload, so the
"Opening browser…" / fallback-URL messages belong on stderr.
"""
monkeypatch.setattr(oauth.webbrowser, "open", lambda *a, **k: True)
monkeypatch.setattr(oauth, "_BROWSER_TIMEOUT_SECONDS", 0.2)
with pytest.raises(oauth.AuthError):
oauth.login_with_browser(
"default",
api_base="https://api.test.omi.local",
provider="google",
open_browser=True,
)
captured = capsys.readouterr()
assert captured.out == "", f"stdout should stay clean, got: {captured.out!r}"
assert "Opening browser for google sign-in..." in captured.err
assert "api.test.omi.local" in captured.err
def test_exchange_code_for_custom_token_happy_path(monkeypatch) -> None:
captured: dict = {}
def fake_post(self, url, **kwargs):
captured["url"] = url
captured["data"] = kwargs.get("data")
return httpx.Response(200, json={"custom_token": "ct_abc", "id_token": "google_id"})
monkeypatch.setattr(httpx.Client, "post", fake_post)
token = oauth._exchange_code_for_custom_token(
"https://api.test.omi.local",
code="auth_code",
redirect_uri="http://127.0.0.1:5555/callback",
code_verifier="verifier-123",
)
assert token == "ct_abc"
assert captured["url"] == "https://api.test.omi.local/v1/auth/token"
assert captured["data"]["grant_type"] == "authorization_code"
assert captured["data"]["use_custom_token"] == "true"
assert captured["data"]["code_verifier"] == "verifier-123"
def test_generate_pkce_pair_uses_s256_challenge() -> None:
code_verifier, code_challenge = oauth._generate_pkce_pair()
expected = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest()).rstrip(b"=").decode("ascii")
)
assert 43 <= len(code_verifier) <= 128
assert code_challenge == expected
def test_exchange_code_raises_on_non_200(monkeypatch) -> None:
def fake_post(self, url, **kwargs):
return httpx.Response(400, json={"detail": "Invalid or expired code"})
monkeypatch.setattr(httpx.Client, "post", fake_post)
with pytest.raises(AuthError):
oauth._exchange_code_for_custom_token(
"https://api.test.omi.local",
code="bad",
redirect_uri="http://127.0.0.1:5555/callback",
code_verifier="verifier-123",
)
def test_exchange_code_raises_when_custom_token_missing(monkeypatch) -> None:
def fake_post(self, url, **kwargs):
return httpx.Response(200, json={"id_token": "google_only", "access_token": "g"})
monkeypatch.setattr(httpx.Client, "post", fake_post)
with pytest.raises(AuthError) as info:
oauth._exchange_code_for_custom_token(
"https://api.test.omi.local",
code="ok",
redirect_uri="http://127.0.0.1:5555/callback",
code_verifier="verifier-123",
)
assert "custom token" in str(info.value).lower()
def test_firebase_signin_with_custom_token_returns_tokens(monkeypatch) -> None:
def fake_post(self, url, **kwargs):
assert "signInWithCustomToken" in url
return httpx.Response(
200,
json={"idToken": "fb_id", "refreshToken": "fb_refr", "expiresIn": "3600"},
)
monkeypatch.setattr(httpx.Client, "post", fake_post)
id_token, refresh_token, expires_in = oauth._firebase_signin_with_custom_token("ct")
assert id_token == "fb_id"
assert refresh_token == "fb_refr"
assert expires_in == 3600
def test_firebase_signin_raises_on_missing_tokens(monkeypatch) -> None:
def fake_post(self, url, **kwargs):
return httpx.Response(200, json={"idToken": "fb_id"})
monkeypatch.setattr(httpx.Client, "post", fake_post)
with pytest.raises(AuthError):
oauth._firebase_signin_with_custom_token("ct")
def test_exchange_firebase_token_mints_key_and_replaces_own(monkeypatch) -> None:
calls: dict = {"deleted": [], "post": None}
name = oauth._cli_key_name()
def fake_get(self, url, **kwargs):
return httpx.Response(
200,
json=[
{"id": "ours-1", "name": name},
{"id": "other", "name": "someone elses key"},
],
)
def fake_delete(self, url, **kwargs):
calls["deleted"].append(url)
return httpx.Response(204)
def fake_post(self, url, **kwargs):
calls["post"] = (url, kwargs.get("json"), kwargs.get("headers"))
return httpx.Response(200, json={"id": "new", "name": name, "key": "omi_dev_minted"})
monkeypatch.setattr(httpx.Client, "get", fake_get)
monkeypatch.setattr(httpx.Client, "delete", fake_delete)
monkeypatch.setattr(httpx.Client, "post", fake_post)
key = oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local/", "fb_id_tok")
assert key == "omi_dev_minted"
assert calls["deleted"] == ["https://api.test.omi.local/v1/dev/keys/ours-1"]
url, body, headers = calls["post"]
assert url == "https://api.test.omi.local/v1/dev/keys"
assert body["name"] == name
assert body["scopes"] == oauth._CLI_KEY_SCOPES
assert headers["Authorization"] == "Bearer fb_id_tok"
def test_exchange_firebase_token_proceeds_when_listing_fails(monkeypatch) -> None:
def fake_get(self, url, **kwargs):
raise httpx.ConnectError("listing unavailable")
def fake_post(self, url, **kwargs):
return httpx.Response(200, json={"key": "omi_dev_after_failed_list"})
monkeypatch.setattr(httpx.Client, "get", fake_get)
monkeypatch.setattr(httpx.Client, "post", fake_post)
key = oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local", "tok")
assert key == "omi_dev_after_failed_list"
@pytest.mark.parametrize(
"listing",
[
httpx.Response(200, text="<html>gateway</html>"),
httpx.Response(200, json=5),
httpx.Response(200, json={"keys": []}),
],
ids=["not-json", "scalar", "object"],
)
def test_exchange_firebase_token_proceeds_when_listing_is_malformed(monkeypatch, listing) -> None:
"""Cleanup of our own stale keys is best-effort by design: a duplicate key
is recoverable, a failed login is not. A 200 carrying something other than
a JSON list must not abort the mint any more than a transport error does."""
posted = []
def fake_post(self, url, **kwargs):
posted.append(url)
return httpx.Response(200, json={"key": "omi_dev_after_bad_list"})
monkeypatch.setattr(httpx.Client, "get", lambda self, url, **kw: listing)
monkeypatch.setattr(httpx.Client, "post", fake_post)
key = oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local", "tok")
assert key == "omi_dev_after_bad_list"
assert posted, "the mint request must still be sent"
@pytest.mark.parametrize(
"mint",
[
httpx.Response(200, text="<html>gateway</html>"),
httpx.Response(200, json=["not", "an", "object"]),
httpx.Response(200, json="plain string"),
],
ids=["not-json", "list", "string"],
)
def test_exchange_firebase_token_raises_on_malformed_mint_response(monkeypatch, mint) -> None:
"""A mint response that is not a JSON object carrying `key` is the same
failure to the caller as a missing field, and must surface as the AuthError
this function already defines rather than a raw parse error."""
monkeypatch.setattr(httpx.Client, "get", lambda self, url, **kw: httpx.Response(200, json=[]))
monkeypatch.setattr(httpx.Client, "post", lambda self, url, **kw: mint)
with pytest.raises(AuthError) as info:
oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local", "tok")
assert "missing the api key" in str(info.value).lower()
def test_exchange_firebase_token_raises_on_non_200(monkeypatch) -> None:
monkeypatch.setattr(httpx.Client, "get", lambda self, url, **kw: httpx.Response(200, json=[]))
monkeypatch.setattr(httpx.Client, "post", lambda self, url, **kw: httpx.Response(403, json={"detail": "nope"}))
with pytest.raises(AuthError) as info:
oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local", "tok")
assert "create an api key" in str(info.value).lower()
def test_exchange_firebase_token_raises_when_key_field_missing(monkeypatch) -> None:
monkeypatch.setattr(httpx.Client, "get", lambda self, url, **kw: httpx.Response(200, json=[]))
monkeypatch.setattr(
httpx.Client, "post", lambda self, url, **kw: httpx.Response(200, json={"id": "x", "name": "y"})
)
with pytest.raises(AuthError) as info:
oauth._exchange_firebase_token_for_dev_key("https://api.test.omi.local", "tok")
assert "missing the api key" in str(info.value).lower()
def test_store_oauth_tokens_can_keep_a_minted_dev_key(config_path) -> None:
"""The browser-login profile must be oauth (for chat) AND keep the dev key.
#21223: browser login minted an api_key profile, so `omi chat` rejected it
with "Chat requires browser sign-in". store_oauth_tokens now accepts an
optional api_key so the profile is oauth AND the /v1/dev/* calls still
have their key.
"""
from omi_cli.auth.store import store_oauth_tokens
profile = store_oauth_tokens(
"default",
id_token="idt",
refresh_token="rt",
expires_at=9999.0,
api_key="omi_dev_key",
)
assert profile.auth_method == "oauth"
assert profile.api_key == "omi_dev_key"
assert profile.id_token == "idt"
assert profile.refresh_token == "rt"
def test_store_oauth_tokens_without_key_still_nulls_it(config_path) -> None:
"""Backwards-compatible: no api_key passed -> api_key cleared as before."""
from omi_cli.auth.store import store_oauth_tokens
profile = store_oauth_tokens("default", id_token="idt", refresh_token="rt", expires_at=9999.0)
assert profile.auth_method == "oauth"
assert profile.api_key is None
def test_refresh_rotation_preserves_stored_dev_key(config_path, monkeypatch) -> None:
"""Regression: when Firebase rotates the refresh token, store_oauth_tokens
must carry the co-stored dev key — dropping it silently kills every
/v1/dev/user/* call on the next CLI invocation."""
store_oauth_tokens(
"default",
id_token="old_id",
refresh_token="refr_old",
expires_at=time.time() - 10,
api_base="https://api.test.omi.local",
api_key="omi_dev_keepme",
)
def fake_post(self, url, **kwargs):
return httpx.Response(
200,
json={"id_token": "new_id", "refresh_token": "refr_rotated", "expires_in": "3600"},
)
monkeypatch.setattr(httpx.Client, "post", fake_post)
oauth.refresh_id_token("default")
reloaded = cfg.load().get_profile("default")
assert reloaded.id_token == "new_id"
assert reloaded.refresh_token == "refr_rotated"
assert reloaded.api_key == "omi_dev_keepme"