feat(P04): nova-idp-jwks Lambda — JWKS endpoint (REQ-338, D-230, backend-engineer)
---ci--- project: acdl phase: 4 milestone: v1.28 status: execute persona: backend-engineer ---
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
"""Nova IdP JWKS endpoint Lambda (REQ-338, D-230).
|
||||
|
||||
Serves the KMS public key as a JWK in a standard JWKS response. The
|
||||
endpoint is a Lambda function URL with ``AuthType: NONE`` (JWKS is
|
||||
public-key only — configured in CloudFormation, not in code).
|
||||
|
||||
Response:
|
||||
* ``Content-Type: application/json``
|
||||
* ``Cache-Control: public, max-age=3600`` (1h — clients cache the JWKS)
|
||||
* ``Access-Control-Allow-Origin: *`` (JWKS is public)
|
||||
* ``body: {"keys": [<jwk>]}``
|
||||
|
||||
The JWK is built via :func:`core.kms_signing.get_jwk` from the KMS
|
||||
public key (DER SPKI → ``cryptography`` → JWK).
|
||||
|
||||
Dual-use (REQ-329): ``__main__`` CLI block for local testing
|
||||
(``--print-jwks``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
OIDC_KMS_KEY_ID = os.environ.get("NOVA_OIDC_KMS_KEY_ID", "alias/nova-oidc-signing")
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
"""AWS Lambda handler — serve the JWKS response (REQ-338)."""
|
||||
try:
|
||||
from core.kms_signing import get_jwk
|
||||
jwk = get_jwk(key_id=OIDC_KMS_KEY_ID)
|
||||
return {
|
||||
"statusCode": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
"body": json.dumps({"keys": [jwk]}),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"statusCode": 500,
|
||||
"headers": {"Content-Type": "application/json"},
|
||||
"body": json.dumps({"error": str(e)}),
|
||||
}
|
||||
|
||||
|
||||
def cli_main(argv=None):
|
||||
"""CLI entry point (REQ-329 dual-use). ``--print-jwks`` → stdout."""
|
||||
raw = argv if argv is not None else sys.argv[1:]
|
||||
if "--print-jwks" in raw:
|
||||
resp = lambda_handler({}, None)
|
||||
sys.stdout.write(resp["body"] + "\n")
|
||||
return resp.get("statusCode", 200) - 200
|
||||
print("Usage: python3 -m core.lambda.nova_idp_jwks --print-jwks", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - CLI entry
|
||||
sys.exit(cli_main())
|
||||
@@ -0,0 +1,123 @@
|
||||
"""JWKS endpoint tests (REQ-338, D-230).
|
||||
|
||||
Mocks ``kms.get_public_key`` with a test ECDSA P-256 public key DER →
|
||||
asserts the Lambda returns 200 + the right headers + a valid JWK.
|
||||
Cross-verifies: a JWT signed with the test private key verifies with
|
||||
pyjwt using the JWKS key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
os.environ.setdefault("NOVA_LAMBDA_LOCAL_BYPASS", "1")
|
||||
|
||||
_SOURCE_PATH = (
|
||||
Path(__file__).resolve().parent.parent / "core" / "lambda" / "nova_idp_jwks.py"
|
||||
)
|
||||
_spec = importlib.util.spec_from_file_location("nova_idp_jwks", _SOURCE_PATH)
|
||||
jwks = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(jwks)
|
||||
|
||||
import core.kms_signing as kms_signing
|
||||
import jwt as pyjwt
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
|
||||
|
||||
class _MockKms:
|
||||
def __init__(self, pub_der):
|
||||
self._pub_der = pub_der
|
||||
|
||||
def get_public_key(self, KeyId):
|
||||
return {"PublicKey": self._pub_der, "KeyId": KeyId}
|
||||
|
||||
def sign(self, KeyId, Message, MessageType, SigningAlgorithm):
|
||||
# Provided so sign_jwt works in the cross-verify test.
|
||||
return {"Signature": self._priv.sign(Message, ec.ECDSA(hashes.SHA256()))}
|
||||
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset():
|
||||
yield
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
|
||||
|
||||
def test_jwks_returns_200_and_headers(test_keypair):
|
||||
_priv, _pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 200
|
||||
headers = resp["headers"]
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Cache-Control"] == "public, max-age=3600"
|
||||
assert headers["Access-Control-Allow-Origin"] == "*"
|
||||
|
||||
|
||||
def test_jwks_returns_valid_ec_jwk(test_keypair):
|
||||
_priv, _pub, pub_der = test_keypair
|
||||
kms_signing.set_kms_client_for_testing(_MockKms(pub_der))
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
body = json.loads(resp["body"])
|
||||
assert "keys" in body
|
||||
assert len(body["keys"]) == 1
|
||||
jwk = body["keys"][0]
|
||||
assert jwk["kty"] == "EC"
|
||||
assert jwk["crv"] == "P-256"
|
||||
assert "kid" in jwk
|
||||
assert "x" in jwk and "y" in jwk
|
||||
assert len(jwk["x"]) == 43 # 32 bytes → 43 base64url chars
|
||||
assert len(jwk["y"]) == 43
|
||||
|
||||
|
||||
def test_jwks_cross_verifies_jwt(test_keypair):
|
||||
"""A JWT signed with the test private key verifies with the JWKS key."""
|
||||
priv, _pub, pub_der = test_keypair
|
||||
# Mock KMS that can both sign (for sign_jwt) and serve the public key.
|
||||
mock_kms = _MockKms(pub_der)
|
||||
mock_kms._priv = priv
|
||||
kms_signing.set_kms_client_for_testing(mock_kms)
|
||||
|
||||
# Sign a JWT via kms_signing.sign_jwt.
|
||||
token = kms_signing.sign_jwt(
|
||||
{"sub": "user-1", "exp": 9999999999, "iat": 1, "jti": "j", "aud": "nova-cli"},
|
||||
key_id="alias/nova-oidc-signing",
|
||||
)
|
||||
# Fetch the JWKS via the Lambda.
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
jwk = json.loads(resp["body"])["keys"][0]
|
||||
# Verify the JWT with pyjwt using the JWK.
|
||||
key = pyjwt.PyJWK(jwk).key
|
||||
decoded = pyjwt.decode(token, key, algorithms=["ES256"], options={"verify_aud": False})
|
||||
assert decoded["sub"] == "user-1"
|
||||
assert decoded["jti"] == "j"
|
||||
|
||||
|
||||
def test_jwks_500_on_kms_error():
|
||||
kms_signing.set_kms_client_for_testing(None)
|
||||
# Force get_jwk to raise by using a broken client.
|
||||
broken = mock.MagicMock()
|
||||
broken.get_public_key.side_effect = RuntimeError("KMS down")
|
||||
kms_signing.set_kms_client_for_testing(broken)
|
||||
resp = jwks.lambda_handler({}, None)
|
||||
assert resp["statusCode"] == 500
|
||||
Reference in New Issue
Block a user