"""nova auth login/revoke/status tests (REQ-344, C-7.3). C-7.3: ``~/.nova/credentials.json`` stores OIDC token + PAT metadata (jti, exp, type) ONLY — NOT the raw PAT. Verified by asserting the file contains no ``raw_pat`` / ``pat`` field. """ from __future__ import annotations import importlib.util import json import os import stat import sys import time from pathlib import Path from unittest import mock import pytest sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1") os.environ.setdefault("AWS_ACCESS_KEY_ID", "test") os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "test") os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1") import boto3 from moto import mock_aws from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives import hashes, serialization import core.kms_signing as kms_signing import core.pat_lifecycle as pat_life import core.auth_store as auth_store # --------------------------------------------------------------------------- # Test keypair + mock KMS. # --------------------------------------------------------------------------- class _MockKms: def __init__(self, priv, pub_der): self._priv = priv self._pub_der = pub_der def sign(self, KeyId, Message, MessageType, SigningAlgorithm): return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))} def get_public_key(self, KeyId): return {"PublicKey": self._pub_der} @pytest.fixture def test_keypair(): priv = ec.generate_private_key(ec.SECP256R1()) pub = priv.public_key() pub_der = pub.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.SubjectPublicKeyInfo, ) return priv, pub, pub_der def _create_pats_table(ddb): ddb.create_table( TableName="nova-pats", KeySchema=[{"AttributeName": "jti", "KeyType": "HASH"}], AttributeDefinitions=[ {"AttributeName": "jti", "AttributeType": "S"}, {"AttributeName": "sub", "AttributeType": "S"}, {"AttributeName": "pat_hash", "AttributeType": "S"}, ], GlobalSecondaryIndexes=[ {"IndexName": "sub-index", "KeySchema": [{"AttributeName": "sub", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}, {"IndexName": "pat_hash-index", "KeySchema": [{"AttributeName": "pat_hash", "KeyType": "HASH"}], "Projection": {"ProjectionType": "ALL"}}, ], BillingMode="PAY_PER_REQUEST", ) @pytest.fixture(autouse=True) def _cred_file(tmp_path, monkeypatch): """Isolate credentials.json to a tmp path.""" cred = tmp_path / "credentials.json" monkeypatch.setenv("NOVA_CREDENTIALS_FILE", str(cred)) yield cred kms_signing.set_kms_client_for_testing(None) pat_life._dynamodb = None # --------------------------------------------------------------------------- # nova auth login # --------------------------------------------------------------------------- @mock_aws def test_login_stores_oidc_token_not_raw_pat(tmp_path, test_keypair, _cred_file): priv, _pub, pub_der = test_keypair ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) # Issue a PAT. pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) assert pat # raw PAT returned once # Run nova auth login via the local Lambda path. from nova.auth import login as login_mod args = mock.MagicMock() args.pat = pat args.session = None args.environment = "dev" args.endpoint = "" # local rc = login_mod.run(args) assert rc == 0 # Assert credentials.json exists + is 0600. assert _cred_file.exists() mode = stat.S_IMODE(os.stat(_cred_file).st_mode) assert mode == 0o600 data = json.loads(_cred_file.read_text()) # C-7.3: contains the OIDC token + metadata, NOT the raw PAT. cred = data["credentials"][0] assert "token" in cred # the OIDC token assert cred["type"] == "nova_oidc_token" assert "jti" in cred and "exp" in cred raw = _cred_file.read_text() assert "raw_pat" not in raw assert pat not in raw # the raw PAT string must NOT appear @mock_aws def test_login_denied_pat_returns_error(tmp_path, test_keypair, _cred_file): priv, _pub, pub_der = test_keypair ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) # Revoke it. # Extract jti from the PAT. import base64 payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "==")) pat_life.revoke_pat(payload["jti"]) from nova.auth import login as login_mod args = mock.MagicMock() args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = "" rc = login_mod.run(args) assert rc != 0 # denied # --------------------------------------------------------------------------- # nova auth status # --------------------------------------------------------------------------- def test_status_no_credential(_cred_file, capsys): from nova.auth import status as status_mod rc = status_mod.run(mock.MagicMock()) assert rc == 0 out = capsys.readouterr().out assert "no active credential" in out @mock_aws def test_status_shows_mode_and_jti(tmp_path, test_keypair, _cred_file, capsys): priv, _pub, pub_der = test_keypair ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) from nova.auth import login as login_mod args = mock.MagicMock() args.pat = pat; args.session = None; args.environment = "dev"; args.endpoint = "" login_mod.run(args) capsys.readouterr() # drain login output from nova.auth import status as status_mod rc = status_mod.run(mock.MagicMock()) assert rc == 0 out = capsys.readouterr().out data = json.loads(out) assert "mode" in data assert "selection_reason" in data assert data["type"] == "nova_oidc_token" # --------------------------------------------------------------------------- # nova auth revoke # --------------------------------------------------------------------------- @mock_aws def test_revoke_sets_status_revoked(tmp_path, test_keypair, _cred_file, capsys): priv, _pub, pub_der = test_keypair ddb = boto3.client("dynamodb", region_name="us-east-1") _create_pats_table(ddb) kms_signing.set_kms_client_for_testing(_MockKms(priv, pub_der)) pat = pat_life.issue_pat("user-1", ["developer"], "t1", ttl_seconds=3600) import base64 payload = json.loads(base64.urlsafe_b64decode(pat.split(".")[1] + "==")) jti = payload["jti"] from nova.auth import revoke as revoke_mod args = mock.MagicMock() args.pat = jti; args.endpoint = "" rc = revoke_mod.run(args) assert rc == 0 # Verify status=revoked in DDB. item = ddb.get_item(TableName="nova-pats", Key={"jti": {"S": jti}}, ConsistentRead=True) assert item["Item"]["status"]["S"] == "revoked"