Skip to content
Open
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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ dependencies = [
"httpx>=0.28.1,<1",
"idc-index-data==24.0.3",
"ijson>=3.4.0.post0,<4",
"joserfc>=1.6.7",
"jsf>=0.11.2,<1",
"jsonschema[format-nongpl]>=4.25.1,<5",
"loguru>=0.7.3,<1",
Expand All @@ -114,6 +115,8 @@ dependencies = [
"pyarrow>=23.0.1,<24; python_version >= '3.14'",
"pyjwt[crypto]>=2.13.0,<3", # CVE-2026-32597 requires >=2.12.0 (Renovate #475)
"python-dateutil>=2.9.0.post0,<3",
"python-engineio>=4.13.3",
"python-socketio>=5.16.3",
# "pywebview[qt6]>=5.4,<6; sys_platform == 'linux'",
"requests>=2.33.0,<3", # CVE-2026-25645 requires >= 2.33.0
"requests-oauthlib>=2.0.0,<3",
Expand Down
3 changes: 2 additions & 1 deletion src/aignostics/platform/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
from aignx.codegen.models import UserReadResponse as User
from aignx.codegen.models import VersionReadResponse as ApplicationVersion

from ._cli import cli_sdk, cli_user
from ._cli import cli_auth, cli_sdk, cli_user
from ._client import Client
from ._constants import (
API_ROOT_DEV,
Expand Down Expand Up @@ -184,6 +184,7 @@
"User",
"UserInfo",
"calculate_file_crc32c",
"cli_auth",
"cli_sdk",
"cli_user",
"download_file",
Expand Down
48 changes: 47 additions & 1 deletion src/aignostics/platform/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@

from aignostics.utils import console

from ._authentication import get_token
from ._sdk_metadata import get_item_sdk_metadata_json_schema, get_run_sdk_metadata_json_schema
from ._service import Service
from ._settings import settings

cli_user = typer.Typer(name="user", help="User operations such as login, logout and whoami.")

Expand Down Expand Up @@ -85,7 +87,6 @@ def whoami(
logger.exception(message)
console.print(message, style="error")
sys.exit(1)
sys.exit(1)


cli_sdk = typer.Typer(name="sdk", help="Platform operations such as dumping the SDK metadata schema.")
Expand Down Expand Up @@ -135,3 +136,48 @@ def item_sdk_metadata_schema(
logger.exception(message)
console.print(message, style="error")
sys.exit(1)


cli_auth = typer.Typer(name="auth", help="Authentication token operations for external integrations.")


@cli_user.command("token")
def auth_token() -> None:
"""Print an Aignostics access token for use as a gcloud external credential helper.

Outputs a JSON document to stdout in the format expected by gcloud's pluggable
authentication executable credential source (Workload Identity Federation).

Configure as an executable credential source by adding the following to your ADC
credentials JSON file, then run ``gcloud auth application-default login`` to activate
it. The GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES environment variable must be set
to 1 for gcloud to call this helper.

On success writes: {"version": 1, "success": true, "token_type": "...", "id_token": "...", "expiration_time": ...}

On failure writes: {"version": 1, "success": false, "code": "1", "message": "..."}
"""
try:
token = get_token(use_cache=True)
stored = settings().token_file.read_text(encoding="utf-8")
expiry = int(stored.rsplit(":", 1)[-1])
print(
json.dumps({
"version": 1,
"success": True,
"token_type": "urn:ietf:params:oauth:token-type:id_token",
"id_token": token,
"expiration_time": expiry,
})
)
except Exception as e:
logger.debug("Failed to obtain Aignostics token for WIF credential helper: {}", e)
print(
json.dumps({
"version": 1,
"success": False,
"code": "1",
"message": str(e),
})
)
sys.exit(1)
102 changes: 100 additions & 2 deletions tests/aignostics/platform/cli_test.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Tests to verify the CLI functionality of the platform module."""

from unittest.mock import patch
import json
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch

import pytest
from typer.testing import CliRunner
Expand Down Expand Up @@ -609,7 +611,6 @@ def test_sdk_item_metadata_schema_no_pretty(runner: CliRunner) -> None:
assert "schema_version" in output
assert "platform_bucket" in output
# In non-pretty mode, output should still be valid JSON
import json

# Try to parse the output as JSON (should not raise an error)
try:
Expand All @@ -621,3 +622,100 @@ def test_sdk_item_metadata_schema_no_pretty(runner: CliRunner) -> None:
pytest.fail("No JSON found in output")
except json.JSONDecodeError:
pytest.fail("Output is not valid JSON")


class TestAuthTokenCLI:
"""Test cases for the ``aignostics auth token`` command (gcloud WIF credential helper)."""

_MOCK_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MzQ1Njc4OTB9.sig" # noqa: S105

@staticmethod
def _settings_with_token_file(token_file) -> MagicMock:
"""Return a mock settings object pointing at the given token file path."""
mock = MagicMock()
mock.token_file = token_file
return mock

@pytest.mark.integration
@staticmethod
def test_auth_token_success_exit_code(record_property, runner: CliRunner, tmp_path) -> None:
"""Exit code is 0 when a cached token is available."""
record_property("tested-item-id", "SPEC-PLATFORM-CLI")
future_expiry = int((datetime.now(tz=UTC) + timedelta(hours=1)).timestamp())
token_file = tmp_path / "token"
token_file.write_text(f"{TestAuthTokenCLI._MOCK_TOKEN}:{future_expiry}", encoding="utf-8")
mock_settings = TestAuthTokenCLI._settings_with_token_file(token_file)

with (
patch("aignostics.platform._authentication.settings", return_value=mock_settings),
patch("aignostics.platform._cli.settings", return_value=mock_settings),
patch("aignostics.platform._authentication._inform_sentry_about_user"),
):
result = runner.invoke(cli, ["user", "token"])

assert result.exit_code == 0

@pytest.mark.integration
@staticmethod
def test_auth_token_success_json_structure(record_property, runner: CliRunner, tmp_path) -> None:
"""Valid gcloud external-credential-helper JSON is written to stdout on success."""
record_property("tested-item-id", "SPEC-PLATFORM-CLI")
future_expiry = int((datetime.now(tz=UTC) + timedelta(hours=1)).timestamp())
token_file = tmp_path / "token"
token_file.write_text(f"{TestAuthTokenCLI._MOCK_TOKEN}:{future_expiry}", encoding="utf-8")
mock_settings = TestAuthTokenCLI._settings_with_token_file(token_file)

with (
patch("aignostics.platform._authentication.settings", return_value=mock_settings),
patch("aignostics.platform._cli.settings", return_value=mock_settings),
patch("aignostics.platform._authentication._inform_sentry_about_user"),
):
result = runner.invoke(cli, ["user", "token"])

json_start = result.output.find("{")
response = json.loads(result.output[json_start:])

assert response["version"] == 1
assert response["success"] is True
assert response["token_type"] == "urn:ietf:params:oauth:token-type:id_token" # noqa: S105
assert response["id_token"] == TestAuthTokenCLI._MOCK_TOKEN
assert response["expiration_time"] == future_expiry

@pytest.mark.integration
@staticmethod
def test_auth_token_failure_exit_code(record_property, runner: CliRunner, tmp_path) -> None:
"""Exit code is 1 when token retrieval fails."""
record_property("tested-item-id", "SPEC-PLATFORM-CLI")
mock_settings = TestAuthTokenCLI._settings_with_token_file(tmp_path / "no_token")

with (
patch("aignostics.platform._authentication.settings", return_value=mock_settings),
patch("aignostics.platform._cli.settings", return_value=mock_settings),
patch("aignostics.platform._authentication._authenticate", side_effect=RuntimeError("no credentials")),
):
result = runner.invoke(cli, ["user", "token"])

assert result.exit_code == 1

@pytest.mark.integration
@staticmethod
def test_auth_token_failure_json_structure(record_property, runner: CliRunner, tmp_path) -> None:
"""Gcloud-compatible error JSON is written to stdout when token retrieval fails."""
record_property("tested-item-id", "SPEC-PLATFORM-CLI")
mock_settings = TestAuthTokenCLI._settings_with_token_file(tmp_path / "no_token")

with (
patch("aignostics.platform._authentication.settings", return_value=mock_settings),
patch("aignostics.platform._cli.settings", return_value=mock_settings),
patch("aignostics.platform._authentication._authenticate", side_effect=RuntimeError("no credentials")),
):
result = runner.invoke(cli, ["user", "token"])

json_start = result.output.find("{")
response = json.loads(result.output[json_start:])

assert response["version"] == 1
assert response["success"] is False
assert "code" in response
assert "message" in response
assert "no credentials" in response["message"]
24 changes: 15 additions & 9 deletions uv.lock

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

Loading