Skip to content

Commit

Permalink
OIDC auth middleware with GitHub Actions example workflow (#31)
Browse files Browse the repository at this point in the history
* 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: slsa-framework/slsa-github-generator#131
Related: slsa-framework/slsa-github-generator#358
Related: actions/runner#2417 (comment)

Signed-off-by: John Andersen <johnandersenpdx@gmail.com>
  • Loading branch information
pdxjohnny authored Oct 18, 2023
1 parent c05a89f commit a30c818
Show file tree
Hide file tree
Showing 11 changed files with 493 additions and 7 deletions.
128 changes: 128 additions & 0 deletions .github/workflows/notarize.yml
Original file line number Diff line number Diff line change
@@ -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 <<EOF
{
"issuers": ["https://token.actions.githubusercontent.com"],
"claim_schema": {
"https://token.actions.githubusercontent.com": {
"\$schema": "https://json-schema.org/draft/2020-12/schema",
"required": [
"job_workflow_ref",
"job_workflow_sha",
"repository_owner_id",
"repository_id"
],
"properties": {
"job_workflow_ref": {
"type": "string",
"enum": [
"${WORKFLOW_REF}"
]
},
"job_workflow_sha": {
"type": "string",
"enum": [
"${JOB_WORKFLOW_SHA}"
]
},
"repository_owner_id": {
"type": "string",
"enum": [
"${REPOSITORY_OWNER_ID}"
]
},
"repository_id": {
"type": "string",
"enum": [
"${REPOSITORY_ID}"
]
}
}
}
},
"audience": "${SCITT_URL}"
}
EOF
# Start SCITT using the `OIDCAuthMiddleware` and associated config.
if [[ "x${SCITT_URL}" = "xhttp://localhost:8080" ]]; then
scitt-emulator server --port 8080 --workspace workspace/ --tree-alg CCF \
--middleware scitt_emulator.oidc:OIDCAuthMiddleware \
--middleware-config-path oidc-middleware-config.json &
sleep 1s
fi
# Submit the claim using OIDC token as auth
scitt-emulator client submit-claim --token "${OIDC_TOKEN}" --url "${SCITT_URL}" --claim claim.cose --out claim.receipt.cbor
2 changes: 2 additions & 0 deletions dev-requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ requests==2.31.0
requests-toolbelt==0.9
urllib3<2.0.0
myst-parser
PyJWT
jwcrypto
23 changes: 23 additions & 0 deletions docs/oidc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# OIDC Support

- References
- [5.1.1.1.1.](https://github.com/ietf-wg-scitt/draft-ietf-scitt-architecture/blob/main/draft-ietf-scitt-architecture.md#comment-on-oidc)

[![asciicast-of-oidc-auth-issued-by-github-actions](https://asciinema.org/a/607600.svg)](https://asciinema.org/a/607600)

## Dependencies

Install the SCITT API Emulator with the `oidc` extra.

```console
$ pip install -e .[oidc]
```

## Usage example with GitHub Actions

See [`notarize.yml`](../.github/workflows/notarize.yml)

References:

- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/using-openid-connect-with-reusable-workflows
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
2 changes: 2 additions & 0 deletions environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ dependencies:
- urllib3<2.0.0
- myst-parser==1.0.0
- jsonschema==4.17.3
- jwcrypto==1.5.0
- PyJWT==2.8.0
3 changes: 2 additions & 1 deletion run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ if [ ! -f "venv/bin/activate" ]; then
echo "Setting up Python virtual environment."
python3 -m venv "venv"
. ./venv/bin/activate
pip install -q -U pip setuptools wheel
pip install -q -r dev-requirements.txt
pip install -q -e .
pip install -q -e .[oidc]
else
. ./venv/bin/activate
fi
Expand Down
51 changes: 51 additions & 0 deletions scitt_emulator/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Copyright (c) SCITT Authors.
# Licensed under the MIT License.
import jwt
import json
import jwcrypto.jwk
import jsonschema
from flask import jsonify
from werkzeug.wrappers import Request
from scitt_emulator.client import HttpClient


class OIDCAuthMiddleware:
def __init__(self, app, config_path):
self.app = app
self.config = {}
if config_path and config_path.exists():
self.config = json.loads(config_path.read_text())

# Initialize JSON Web Key client for given issuer
self.client = HttpClient()
self.oidc_configs = {}
self.jwks_clients = {}
for issuer in self.config['issuers']:
self.oidc_configs[issuer] = self.client.get(
f"{issuer}/.well-known/openid-configuration"
).json()
self.jwks_clients[issuer] = jwt.PyJWKClient(self.oidc_configs[issuer]["jwks_uri"])

def __call__(self, environ, start_response):
request = Request(environ)
claims = self.validate_token(request.headers["Authorization"].replace("Bearer ", ""))
if "claim_schema" in self.config and claims["iss"] in self.config["claim_schema"]:
jsonschema.validate(claims, schema=self.config["claim_schema"][claims["iss"]])
return self.app(environ, start_response)

def validate_token(self, token):
validation_error = Exception(f"Failed to validate against all issuers: {self.jwks_clients.keys()!s}")
for issuer, jwk_client in self.jwks_clients.items():
try:
return jwt.decode(
token,
key=jwk_client.get_signing_key_from_jwt(token).key,
algorithms=self.oidc_configs[issuer]["id_token_signing_alg_values_supported"],
audience=self.config.get("audience", None),
issuer=self.oidc_configs[issuer]["issuer"],
options={"strict_aud": self.config.get("strict_aud", True),},
leeway=self.config.get("leeway", 0),
)
except jwt.PyJWTError as error:
validation_error = error
raise validation_error
36 changes: 36 additions & 0 deletions scitt_emulator/plugin_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright (c) SCITT Authors.
# Licensed under the MIT License.
import os
import sys
import pathlib
import importlib
from typing import Iterator, Optional, Union, Any


def entrypoint_style_load(
*args: str, relative: Optional[Union[str, pathlib.Path]] = None
) -> 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)
12 changes: 12 additions & 0 deletions scitt_emulator/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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"]

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@
"flask",
"rkvst-archivist"
],
extras_require={
"oidc": [
"PyJWT",
"jwcrypto",
"jsonschema",
]
},
)
Loading

0 comments on commit a30c818

Please sign in to comment.