feat: add oauth login auth command

This commit is contained in:
lda
2026-06-13 02:39:04 +07:00 Verified
parent 38c9be850c
commit 17fa743fe2
2 changed files with 150 additions and 1 deletions
+80 -1
View File
@@ -1,14 +1,16 @@
from __future__ import annotations from __future__ import annotations
import json import json
import os
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
import typer import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import config_path_from_context, load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from wf_cli.io import emit_json from wf_cli.io import emit_json
from wf_cli.oauth import OAuthCodeLoginFlow, OAuthLoginResult, build_oauth_record
from wf_cli.remote_errors import run_cli_operation from wf_cli.remote_errors import run_cli_operation
app = typer.Typer( app = typer.Typer(
@@ -140,3 +142,80 @@ def delete_auth_record(
context = load_cli_context_from_typer(ctx) context = load_cli_context_from_typer(ctx)
result = run_cli_operation(context, context.admin.delete_auth_record(auth_ref)) result = run_cli_operation(context, context.admin.delete_auth_record(auth_ref))
emit_json(result) emit_json(result)
async def _login_with_pasted_response(
*,
provider,
client_id: str,
client_secret: str | None,
authorization_response: str,
) -> OAuthLoginResult:
from authlib.integrations.httpx_client import AsyncOAuth2Client
flow = OAuthCodeLoginFlow(client_factory=AsyncOAuth2Client) # type: ignore[arg-type]
return await flow.login_with_authorization_response(
provider=provider,
client_id=client_id,
client_secret=client_secret,
authorization_response=authorization_response,
)
@app.command("oauth-login")
def oauth_login(
ctx: typer.Context,
provider_name: Annotated[str, typer.Argument(help="Auth provider profile name.")],
auth_ref: Annotated[str, typer.Option("--id", help="Auth record id/ref to save.")],
authorization_response: Annotated[
str,
typer.Option(
"--authorization-response",
help="Full redirected callback URL after login.",
),
],
) -> None:
"""Run an OAuth login flow and save the resulting refresh token as an auth record."""
from wf_config import load_workflow_config
config_path = Path(config_path_from_context(ctx))
config = load_workflow_config(config_path)
provider = config.auth.providers.get(provider_name)
if provider is None:
raise typer.BadParameter(f"unknown auth provider {provider_name!r}")
client_id = os.environ.get(provider.client_id_env)
if not client_id:
raise typer.BadParameter(f"missing env var {provider.client_id_env}")
client_secret = (
os.environ.get(provider.client_secret_env)
if provider.client_secret_env is not None
else None
)
result = run_cli_operation(
load_cli_context_from_typer(ctx),
_login_with_pasted_response(
provider=provider,
client_id=client_id,
client_secret=client_secret,
authorization_response=authorization_response,
),
)
record = build_oauth_record(
auth_ref=auth_ref,
provider_name=provider_name,
provider=provider,
client_id=client_id,
client_secret=client_secret,
result=result,
)
context = load_cli_context_from_typer(ctx)
saved = run_cli_operation(
context,
context.admin.save_auth_record(
auth_ref=record.id,
scheme="oauth_refresh_token",
payload=record.auth.model_dump(mode="json", exclude={"kind"}),
metadata=record.metadata,
),
)
emit_json(saved)
+70
View File
@@ -1,8 +1,12 @@
from __future__ import annotations from __future__ import annotations
import json
import pytest import pytest
from typer.testing import CliRunner
from wf_api.auth import OAuthRefreshTokenAuth from wf_api.auth import OAuthRefreshTokenAuth
from wf_cli.app import app
from wf_cli.oauth import OAuthCodeLoginFlow, OAuthLoginResult, build_oauth_record from wf_cli.oauth import OAuthCodeLoginFlow, OAuthLoginResult, build_oauth_record
from wf_config import OAuthProviderConfig from wf_config import OAuthProviderConfig
@@ -97,3 +101,69 @@ async def test_oauth_code_login_flow_uses_injected_client() -> None:
assert result.refresh_token == "refresh" assert result.refresh_token == "refresh"
assert result.scopes == ("https://www.googleapis.com/auth/drive.readonly",) assert result.scopes == ("https://www.googleapis.com/auth/drive.readonly",)
assert client.fetch_calls == ["http://127.0.0.1/callback?code=abc&state=state-123"] assert client.fetch_calls == ["http://127.0.0.1/callback?code=abc&state=state-123"]
def test_auth_oauth_login_saves_record_from_provider_profile(monkeypatch, tmp_path) -> None:
saved: list[object] = []
class _FakeAdmin:
async def save_auth_record(self, **kwargs: object) -> dict[str, object]:
saved.append(kwargs)
return {"id": kwargs["auth_ref"], "scheme": "oauth_refresh_token"}
class _FakeContext:
admin = _FakeAdmin()
async def _fake_login(*args: object, **kwargs: object) -> OAuthLoginResult:
return OAuthLoginResult(refresh_token="refresh")
monkeypatch.setattr(
"wf_cli.commands.auth_admin.load_cli_context_from_typer",
lambda ctx: _FakeContext(),
)
monkeypatch.setattr(
"wf_cli.commands.auth_admin._login_with_pasted_response",
_fake_login,
)
config_path = tmp_path / "wf.config.json"
config_path.write_text(
json.dumps(
{
"auth": {
"providers": {
"google": {
"kind": "oauth_authorization_code_pkce",
"auth_url": "https://accounts.google.com/o/oauth2/v2/auth",
"token_url": "https://oauth2.googleapis.com/token",
"client_id_env": "GOOGLE_OAUTH_CLIENT_ID",
"scopes": ["https://www.googleapis.com/auth/drive.readonly"],
}
}
}
}
),
encoding="utf-8",
)
monkeypatch.setenv("GOOGLE_OAUTH_CLIENT_ID", "client")
result = CliRunner().invoke(
app,
[
"--config",
str(config_path),
"admin",
"auth",
"oauth-login",
"google",
"--id",
"google.drive.personal",
"--authorization-response",
"http://127.0.0.1/callback?code=abc&state=state",
],
)
assert result.exit_code == 0, result.output
assert saved
assert saved[0]["auth_ref"] == "google.drive.personal"
assert saved[0]["scheme"] == "oauth_refresh_token"