Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adapt ambient OIDC tests to support interactive flow for local testing #576

Merged
merged 10 commits into from
Mar 23, 2023
Merged
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ jobs:
- name: test
run: make test TEST_ARGS="-vv --showlocals"

- name: test (interactive)
if: (github.event_name != 'pull_request') || !github.event.pull_request.head.repo.fork
run: make test-interactive TEST_ARGS="-vv --showlocals"

- uses: ./.github/actions/upload-coverage
# only aggregate test coverage over linux-based tests to avoid any OS-specific filesystem information stored in
# coverage metadata.
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ All versions prior to 0.9.0 are untracked.

### Added

* The whole test suite can now be run locally with `make test-interactive`.
([#576](https://github.com/sigstore/sigstore-python/pull/576))
Users will be prompted to authenticate with their identity provider twice to
generate staging and production OIDC tokens, which are used to test the
`sigstore.sign` module. All signing tests need to be completed before token
expiry, which is currently 60 seconds after issuance.
* Network-related errors from the `sigstore._internal.tuf` module now have better
diagnostics.

Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,16 @@ You can run the tests locally with:
make test
```

or:

```bash
make test-interactive
```

to run tests that require OIDC credentials (will prompt for authentication to generate tokens).
Note that `test-interactive` may fail if you have a slow network, as the tokens generated are only
valid for 60 seconds after their issuance.

You can also filter by a pattern (uses `pytest -k`):

```bash
Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,15 @@ reformat: $(VENV)/pyvenv.cfg
.PHONY: test
test: $(VENV)/pyvenv.cfg
. $(VENV_BIN)/activate && \
pytest --cov=$(PY_MODULE) $(T) $(TEST_ARGS) && \
$(TEST_ENV) pytest --cov=$(PY_MODULE) $(T) $(TEST_ARGS) && \
python -m coverage report -m $(COV_ARGS)

.PHONY: test-interactive
test-interactive: TEST_ENV += \
SIGSTORE_IDENTITY_TOKEN_production=$$($(MAKE) -s run ARGS="get-identity-token") \
SIGSTORE_IDENTITY_TOKEN_staging=$$($(MAKE) -s run ARGS="--staging get-identity-token")
test-interactive: test

.PHONY: doc
doc: $(VENV)/pyvenv.cfg
. $(VENV_BIN)/activate && \
Expand Down
6 changes: 4 additions & 2 deletions sigstore/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

import logging
import os
import sys
import time
import urllib.parse
import webbrowser
Expand Down Expand Up @@ -125,11 +126,12 @@ def identity_token( # nosec: B107
with _OAuthFlow(client_id, client_secret, self) as server:
# Launch web browser
if not force_oob and webbrowser.open(server.base_uri):
print("Waiting for browser interaction...")
print("Waiting for browser interaction...", file=sys.stderr)
else:
server.enable_oob()
print(
f"Go to the following link in a browser:\n\n\t{server.auth_endpoint}"
f"Go to the following link in a browser:\n\n\t{server.auth_endpoint}",
file=sys.stderr,
Comment on lines -128 to +134
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

)

if not server.is_oob():
Expand Down
24 changes: 22 additions & 2 deletions test/unit/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

from sigstore._internal import tuf
from sigstore._internal.oidc import DEFAULT_AUDIENCE
from sigstore.sign import Signer
from sigstore.verify import VerificationMaterials
from sigstore.verify.policy import VerificationSuccess

Expand All @@ -41,7 +42,11 @@
assert _TUF_ASSETS.is_dir()


def _is_ambient_env():
def _has_oidc_id():
# If there are tokens manually defined for us in the environment, use them.
if os.getenv("SIGSTORE_IDENTITY_TOKEN_production") is not None:
return True

try:
token = detect_credential(DEFAULT_AUDIENCE)
if token is None:
Expand Down Expand Up @@ -76,7 +81,7 @@ def pytest_runtest_setup(item):
pytest.skip(
"skipping test that requires network connectivity due to `--skip-online` flag"
)
elif "ambient_oidc" in item.keywords and not _is_ambient_env():
elif "ambient_oidc" in item.keywords and not _has_oidc_id():
pytest.skip("skipping test that requires an ambient OIDC credential")


Expand Down Expand Up @@ -187,3 +192,18 @@ def tuf_dirs(monkeypatch, tmp_path):
monkeypatch.setattr(tuf, "_get_dirs", lambda u: (data_dir, cache_dir))

return (data_dir, cache_dir)


@pytest.fixture(
params=[("production", Signer.production), ("staging", Signer.staging)],
ids=["production", "staging"],
)
def id_config(request):
env, signer = request.param
# Detect env variable for local interactive tests.
token = os.getenv(f"SIGSTORE_IDENTITY_TOKEN_{env}")
if not token:
# If the variable is not defined, try getting an ambient token.
token = detect_credential(DEFAULT_AUDIENCE)

return signer, token
31 changes: 13 additions & 18 deletions test/unit/test_sign.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,10 @@
import jwt
import pretend
import pytest
from id import IdentityError, detect_credential
from id import IdentityError

import sigstore._internal.oidc
from sigstore._internal.keyring import KeyringError, KeyringLookupError
from sigstore._internal.oidc import DEFAULT_AUDIENCE
from sigstore._internal.sct import InvalidSCTError, InvalidSCTKeyError
from sigstore.sign import Signer

Expand All @@ -40,13 +39,12 @@ def test_signer_staging(mock_staging_tuf):

@pytest.mark.online
@pytest.mark.ambient_oidc
@pytest.mark.parametrize("signer", [Signer.production, Signer.staging])
def test_sign_rekor_entry_consistent(signer):
def test_sign_rekor_entry_consistent(id_config):
signer, token = id_config

# NOTE: The actual signer instance is produced lazily, so that parameter
# expansion doesn't fail in offline tests.
signer = signer()

token = detect_credential(DEFAULT_AUDIENCE)
assert token is not None

payload = io.BytesIO(secrets.token_bytes(32))
Expand All @@ -62,13 +60,12 @@ def test_sign_rekor_entry_consistent(signer):

@pytest.mark.online
@pytest.mark.ambient_oidc
@pytest.mark.parametrize("signer", [Signer.production, Signer.staging])
def test_sct_verify_keyring_lookup_error(signer, monkeypatch):
def test_sct_verify_keyring_lookup_error(id_config, monkeypatch):
signer, token = id_config

# a signer whose keyring always fails to lookup a given key.
signer = signer()
signer._rekor._ct_keyring = pretend.stub(verify=pretend.raiser(KeyringLookupError))

token = detect_credential(DEFAULT_AUDIENCE)
assert token is not None

payload = io.BytesIO(secrets.token_bytes(32))
Expand All @@ -84,13 +81,12 @@ def test_sct_verify_keyring_lookup_error(signer, monkeypatch):

@pytest.mark.online
@pytest.mark.ambient_oidc
@pytest.mark.parametrize("signer", [Signer.production, Signer.staging])
def test_sct_verify_keyring_error(signer, monkeypatch):
def test_sct_verify_keyring_error(id_config, monkeypatch):
signer, token = id_config

# a signer whose keyring throws an internal error.
signer = signer()
signer._rekor._ct_keyring = pretend.stub(verify=pretend.raiser(KeyringError))

token = detect_credential(DEFAULT_AUDIENCE)
assert token is not None

payload = io.BytesIO(secrets.token_bytes(32))
Expand All @@ -101,11 +97,10 @@ def test_sct_verify_keyring_error(signer, monkeypatch):

@pytest.mark.online
@pytest.mark.ambient_oidc
@pytest.mark.parametrize("signer", [Signer.production, Signer.staging])
def test_identity_proof_claim_lookup(signer, monkeypatch):
signer = signer()
def test_identity_proof_claim_lookup(id_config, monkeypatch):
signer, token = id_config

token = detect_credential(DEFAULT_AUDIENCE)
signer = signer()
assert token is not None

# clear out the known issuers, forcing the `Identity`'s `proof_claim` to be looked up.
Expand Down