From a30c8182358f2eab1feb380a7bf920b5483060f9 Mon Sep 17 00:00:00 2001 From: John Andersen Date: Wed, 18 Oct 2023 13:39:11 -0700 Subject: [PATCH] OIDC auth middleware with GitHub Actions example workflow (#31) * Add plugin helper entrypoint_style_load() to assist with loading auth middleware * Add server CLI arg for Flask middleware loaded via entrypoint style load plugin helper * OIDC auth middleware plugin * Refactor test Service expose url with bound port to Flask app * In preperation for use by flask test app used as OIDC endpoints * Tests for OIDC based auth middleware * Update pip, setuptools, wheel to avoid deprecation warning on dependency install. * Example CI job for GitHub Actions OIDC authenticated notary * Token is not available within pull_request context. * Document OIDC authentication middleware usage with GitHub Actions * Validation of OIDC claims via JSON schema validator Related: https://github.com/slsa-framework/slsa-github-generator/issues/131 Related: https://github.com/slsa-framework/slsa-github-generator/issues/358 Related: https://github.com/actions/runner/issues/2417#issuecomment-1718369460 Signed-off-by: John Andersen --- .github/workflows/notarize.yml | 128 ++++++++++++++++++++++ dev-requirements.txt | 2 + docs/oidc.md | 23 ++++ environment.yml | 2 + run-tests.sh | 3 +- scitt_emulator/oidc.py | 51 +++++++++ scitt_emulator/plugin_helpers.py | 36 ++++++ scitt_emulator/server.py | 12 ++ setup.py | 7 ++ tests/test_cli.py | 181 ++++++++++++++++++++++++++++++- tests/test_plugin_helpers.py | 55 ++++++++++ 11 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/notarize.yml create mode 100644 docs/oidc.md create mode 100644 scitt_emulator/oidc.py create mode 100644 scitt_emulator/plugin_helpers.py create mode 100644 tests/test_plugin_helpers.py diff --git a/.github/workflows/notarize.yml b/.github/workflows/notarize.yml new file mode 100644 index 00000000..babc71b8 --- /dev/null +++ b/.github/workflows/notarize.yml @@ -0,0 +1,128 @@ +name: "SCITT Notary" + +on: + push: + branches: + - main + paths-ignore: + - '**.md' + workflow_dispatch: + inputs: + scitt-url: + description: 'URL of SCITT instance' + type: string + payload: + description: 'Payload for claim' + default: '' + type: string + workflow_call: + inputs: + scitt-url: + description: 'URL of SCITT instance' + type: string + payload: + description: 'Payload for claim' + type: string + +jobs: + notarize: + runs-on: ubuntu-latest + permissions: + id-token: write + env: + SCITT_URL: '${{ inputs.scitt-url || github.event.inputs.scitt-url }}' + PAYLOAD: '${{ inputs.payload || github.event.inputs.payload }}' + steps: + - name: Set defaults if env vars not set (as happens with on.push trigger) + run: | + if [[ "x${SCITT_URL}" = "x" ]]; then + echo "SCITT_URL=http://localhost:8080" >> "${GITHUB_ENV}" + fi + if [[ "x${PAYLOAD}" = "x" ]]; then + echo 'PAYLOAD={"key": "value"}' >> "${GITHUB_ENV}" + fi + - uses: actions/checkout@v4 + - name: Set up Python 3.8 + uses: actions/setup-python@v4 + with: + python-version: 3.8 + - name: Install SCITT API Emulator + run: | + pip install -U pip setuptools wheel + pip install .[oidc] + - name: Install github-script dependencies + run: | + npm install @actions/core + - name: Get OIDC token to use as bearer token for auth to SCITT + uses: actions/github-script@v6 + id: github-oidc + with: + script: | + const {SCITT_URL} = process.env; + core.setOutput('token', await core.getIDToken(SCITT_URL)); + - name: Create claim + run: | + scitt-emulator client create-claim --issuer did:web:example.org --content-type application/json --payload "${PAYLOAD}" --out claim.cose + - name: Submit claim + env: + OIDC_TOKEN: '${{ steps.github-oidc.outputs.token }}' + WORKFLOW_REF: '${{ github.workflow_ref }}' + # Use of job_workflow_sha blocked by + # https://github.com/actions/runner/issues/2417#issuecomment-1718369460 + JOB_WORKFLOW_SHA: '${{ github.sha }}' + REPOSITORY_OWNER_ID: '${{ github.repository_owner_id }}' + REPOSITORY_ID: '${{ github.repository_id }}' + run: | + # Create the middleware config file + tee oidc-middleware-config.json < Iterator[Any]: + """ + Load objects given the entrypoint formatted path to the object. Roughly how + the python stdlib docs say entrypoint loading works. + """ + # Push current directory into front of path so we can run things + # relative to where we are in the shell + if relative is not None: + if relative == True: + relative = os.getcwd() + # str() in case of Path object + sys.path.insert(0, str(relative)) + try: + for entry in args: + modname, qualname_separator, qualname = entry.partition(":") + obj = importlib.import_module(modname) + for attr in qualname.split("."): + if hasattr(obj, "__getitem__"): + obj = obj[attr] + else: + obj = getattr(obj, attr) + yield obj + finally: + if relative is not None: + sys.path.pop(0) diff --git a/scitt_emulator/server.py b/scitt_emulator/server.py index 094d0b6a..1ab4e60b 100644 --- a/scitt_emulator/server.py +++ b/scitt_emulator/server.py @@ -9,6 +9,7 @@ from flask import Flask, request, send_file, make_response from scitt_emulator.tree_algs import TREE_ALGS +from scitt_emulator.plugin_helpers import entrypoint_style_load from scitt_emulator.scitt import EntryNotFoundError, ClaimInvalidError, OperationNotFoundError @@ -33,6 +34,9 @@ def create_flask_app(config): app.config.update(dict(DEBUG=True)) app.config.update(config) + if app.config.get("middleware", None): + app.wsgi_app = app.config["middleware"](app.wsgi_app, app.config.get("middleware_config_path", None)) + error_rate = app.config["error_rate"] use_lro = app.config["use_lro"] @@ -117,10 +121,18 @@ def cli(fn): parser.add_argument("--use-lro", action="store_true", help="Create operations for submissions") parser.add_argument("--tree-alg", required=True, choices=list(TREE_ALGS.keys())) parser.add_argument("--workspace", type=Path, default=Path("workspace")) + parser.add_argument( + "--middleware", + type=lambda value: list(entrypoint_style_load(value))[0], + default=None, + ) + parser.add_argument("--middleware-config-path", type=Path, default=None) def cmd(args): app = create_flask_app( { + "middleware": args.middleware, + "middleware_config_path": args.middleware_config_path, "tree_alg": args.tree_alg, "workspace": args.workspace, "error_rate": args.error_rate, diff --git a/setup.py b/setup.py index a615085a..466dd6fc 100644 --- a/setup.py +++ b/setup.py @@ -21,4 +21,11 @@ "flask", "rkvst-archivist" ], + extras_require={ + "oidc": [ + "PyJWT", + "jwcrypto", + "jsonschema", + ] + }, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index f04f2cf5..95319901 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,10 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. import os +import json import threading import pytest +import jwt +import jwcrypto +from flask import Flask, jsonify from werkzeug.serving import make_server from scitt_emulator import cli, server +from scitt_emulator.oidc import OIDCAuthMiddleware issuer = "did:web:example.com" content_type = "application/json" @@ -16,16 +21,23 @@ def execute_cli(argv): class Service: - def __init__(self, config): + def __init__(self, config, create_flask_app=None): self.config = config + self.create_flask_app = ( + create_flask_app + if create_flask_app is not None + else server.create_flask_app + ) def __enter__(self): - app = server.create_flask_app(self.config) - self.service_parameters_path = app.service_parameters_path - host = "127.0.0.1" - self.server = make_server(host, 0, app) + app = self.create_flask_app(self.config) + if hasattr(app, "service_parameters_path"): + self.service_parameters_path = app.service_parameters_path + self.host = "127.0.0.1" + self.server = make_server(self.host, 0, app) port = self.server.port - self.url = f"http://{host}:{port}" + self.url = f"http://{self.host}:{port}" + app.url = self.url self.thread = threading.Thread(name="server", target=self.server.serve_forever) self.thread.start() return self @@ -142,3 +154,160 @@ def test_client_cli(use_lro: bool, tmp_path): with open(receipt_path_2, "rb") as f: receipt_2 = f.read() assert receipt == receipt_2 + + +def create_flask_app_oidc_server(config): + app = Flask("oidc_server") + + app.config.update(dict(DEBUG=True)) + app.config.update(config) + + @app.route("/.well-known/openid-configuration", methods=["GET"]) + def openid_configuration(): + return jsonify( + { + "issuer": app.url, + "jwks_uri": f"{app.url}/.well-known/jwks", + "response_types_supported": ["id_token"], + "claims_supported": ["sub", "aud", "exp", "iat", "iss"], + "id_token_signing_alg_values_supported": app.config["algorithms"], + "scopes_supported": ["openid"], + } + ) + + @app.route("/.well-known/jwks", methods=["GET"]) + def jwks(): + return jsonify( + { + "keys": [ + { + **app.config["key"].export_public(as_dict=True), + "use": "sig", + "kid": app.config["key"].thumbprint(), + } + ] + } + ) + + return app + + +def test_client_cli_token(tmp_path): + workspace_path = tmp_path / "workspace" + + claim_path = tmp_path / "claim.cose" + receipt_path = tmp_path / "claim.receipt.cbor" + entry_id_path = tmp_path / "claim.entry_id.txt" + retrieved_claim_path = tmp_path / "claim.retrieved.cose" + + key = jwcrypto.jwk.JWK.generate(kty="RSA", size=2048) + algorithm = "RS256" + audience = "scitt.example.org" + subject = "repo:scitt-community/scitt-api-emulator:ref:refs/heads/main" + + with Service( + {"key": key, "algorithms": [algorithm]}, + create_flask_app=create_flask_app_oidc_server, + ) as oidc_service: + os.environ["no_proxy"] = ",".join( + os.environ.get("no_proxy", "").split(",") + [oidc_service.host] + ) + middleware_config_path = tmp_path / "oidc-middleware-config.json" + middleware_config_path.write_text( + json.dumps( + { + "issuers": [oidc_service.url], + "audience": audience, + "claim_schema": { + oidc_service.url: { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "required": ["sub"], + "properties": { + "sub": {"type": "string", "enum": [subject]}, + }, + } + }, + } + ) + ) + with Service( + { + "middleware": OIDCAuthMiddleware, + "middleware_config_path": middleware_config_path, + "tree_alg": "CCF", + "workspace": workspace_path, + "error_rate": 0.1, + "use_lro": False, + } + ) as service: + # create claim + command = [ + "client", + "create-claim", + "--out", + claim_path, + "--issuer", + issuer, + "--content-type", + content_type, + "--payload", + payload, + ] + execute_cli(command) + assert os.path.exists(claim_path) + + # submit claim without token + command = [ + "client", + "submit-claim", + "--claim", + claim_path, + "--out", + receipt_path, + "--out-entry-id", + entry_id_path, + "--url", + service.url, + ] + check_error = None + try: + execute_cli(command) + except Exception as error: + check_error = error + assert check_error + assert not os.path.exists(receipt_path) + assert not os.path.exists(entry_id_path) + + # create token without subject + token = jwt.encode( + {"iss": oidc_service.url, "aud": audience}, + key.export_to_pem(private_key=True, password=None), + algorithm=algorithm, + headers={"kid": key.thumbprint()}, + ) + # submit claim with token lacking subject + command += [ + "--token", + token, + ] + check_error = None + try: + execute_cli(command) + except Exception as error: + check_error = error + assert check_error + assert not os.path.exists(receipt_path) + assert not os.path.exists(entry_id_path) + + # create token with subject + token = jwt.encode( + {"iss": oidc_service.url, "aud": audience, "sub": subject}, + key.export_to_pem(private_key=True, password=None), + algorithm=algorithm, + headers={"kid": key.thumbprint()}, + ) + # submit claim with token containing subject + command[-1] = token + execute_cli(command) + assert os.path.exists(receipt_path) + assert os.path.exists(entry_id_path) diff --git a/tests/test_plugin_helpers.py b/tests/test_plugin_helpers.py new file mode 100644 index 00000000..09dff17f --- /dev/null +++ b/tests/test_plugin_helpers.py @@ -0,0 +1,55 @@ +# Copyright (c) SCITT Authors. +# Licensed under the MIT License. +import os +import textwrap + +from scitt_emulator.plugin_helpers import entrypoint_style_load + + +def test_entrypoint_style_load_relative(tmp_path): + plugin_path = tmp_path / "myplugin.py" + + plugin_path.write_text( + textwrap.dedent( + """ + def my_cool_plugin(): + return "Hello World" + + + class MyCoolClass: + @staticmethod + def my_cool_plugin(): + return my_cool_plugin() + + + my_cool_dict = { + "my_cool_plugin": my_cool_plugin, + } + """, + ) + ) + + for load_within_file in [ + "my_cool_plugin", + "MyCoolClass.my_cool_plugin", + "my_cool_dict.my_cool_plugin", + ]: + plugin_entrypoint_style_path = ( + str(plugin_path.relative_to(tmp_path).with_suffix("")).replace( + os.path.sep, "." + ) + + ":" + + load_within_file + ) + + loaded = list( + entrypoint_style_load(plugin_entrypoint_style_path, relative=tmp_path) + )[0] + + os.chdir(tmp_path) + + loaded = list( + entrypoint_style_load(plugin_entrypoint_style_path, relative=True) + )[0] + + assert loaded() == "Hello World"