feat: add oauth login auth command
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user