Skip to content
This repository was archived by the owner on Nov 22, 2024. It is now read-only.

Commit a30c818

Browse files
authoredOct 18, 2023
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: 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>
1 parent c05a89f commit a30c818

11 files changed

+493
-7
lines changed
 

‎.github/workflows/notarize.yml

+128
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
name: "SCITT Notary"
2+
3+
on:
4+
push:
5+
branches:
6+
- main
7+
paths-ignore:
8+
- '**.md'
9+
workflow_dispatch:
10+
inputs:
11+
scitt-url:
12+
description: 'URL of SCITT instance'
13+
type: string
14+
payload:
15+
description: 'Payload for claim'
16+
default: ''
17+
type: string
18+
workflow_call:
19+
inputs:
20+
scitt-url:
21+
description: 'URL of SCITT instance'
22+
type: string
23+
payload:
24+
description: 'Payload for claim'
25+
type: string
26+
27+
jobs:
28+
notarize:
29+
runs-on: ubuntu-latest
30+
permissions:
31+
id-token: write
32+
env:
33+
SCITT_URL: '${{ inputs.scitt-url || github.event.inputs.scitt-url }}'
34+
PAYLOAD: '${{ inputs.payload || github.event.inputs.payload }}'
35+
steps:
36+
- name: Set defaults if env vars not set (as happens with on.push trigger)
37+
run: |
38+
if [[ "x${SCITT_URL}" = "x" ]]; then
39+
echo "SCITT_URL=http://localhost:8080" >> "${GITHUB_ENV}"
40+
fi
41+
if [[ "x${PAYLOAD}" = "x" ]]; then
42+
echo 'PAYLOAD={"key": "value"}' >> "${GITHUB_ENV}"
43+
fi
44+
- uses: actions/checkout@v4
45+
- name: Set up Python 3.8
46+
uses: actions/setup-python@v4
47+
with:
48+
python-version: 3.8
49+
- name: Install SCITT API Emulator
50+
run: |
51+
pip install -U pip setuptools wheel
52+
pip install .[oidc]
53+
- name: Install github-script dependencies
54+
run: |
55+
npm install @actions/core
56+
- name: Get OIDC token to use as bearer token for auth to SCITT
57+
uses: actions/github-script@v6
58+
id: github-oidc
59+
with:
60+
script: |
61+
const {SCITT_URL} = process.env;
62+
core.setOutput('token', await core.getIDToken(SCITT_URL));
63+
- name: Create claim
64+
run: |
65+
scitt-emulator client create-claim --issuer did:web:example.org --content-type application/json --payload "${PAYLOAD}" --out claim.cose
66+
- name: Submit claim
67+
env:
68+
OIDC_TOKEN: '${{ steps.github-oidc.outputs.token }}'
69+
WORKFLOW_REF: '${{ github.workflow_ref }}'
70+
# Use of job_workflow_sha blocked by
71+
# https://github.com/actions/runner/issues/2417#issuecomment-1718369460
72+
JOB_WORKFLOW_SHA: '${{ github.sha }}'
73+
REPOSITORY_OWNER_ID: '${{ github.repository_owner_id }}'
74+
REPOSITORY_ID: '${{ github.repository_id }}'
75+
run: |
76+
# Create the middleware config file
77+
tee oidc-middleware-config.json <<EOF
78+
{
79+
"issuers": ["https://token.actions.githubusercontent.com"],
80+
"claim_schema": {
81+
"https://token.actions.githubusercontent.com": {
82+
"\$schema": "https://json-schema.org/draft/2020-12/schema",
83+
"required": [
84+
"job_workflow_ref",
85+
"job_workflow_sha",
86+
"repository_owner_id",
87+
"repository_id"
88+
],
89+
"properties": {
90+
"job_workflow_ref": {
91+
"type": "string",
92+
"enum": [
93+
"${WORKFLOW_REF}"
94+
]
95+
},
96+
"job_workflow_sha": {
97+
"type": "string",
98+
"enum": [
99+
"${JOB_WORKFLOW_SHA}"
100+
]
101+
},
102+
"repository_owner_id": {
103+
"type": "string",
104+
"enum": [
105+
"${REPOSITORY_OWNER_ID}"
106+
]
107+
},
108+
"repository_id": {
109+
"type": "string",
110+
"enum": [
111+
"${REPOSITORY_ID}"
112+
]
113+
}
114+
}
115+
}
116+
},
117+
"audience": "${SCITT_URL}"
118+
}
119+
EOF
120+
# Start SCITT using the `OIDCAuthMiddleware` and associated config.
121+
if [[ "x${SCITT_URL}" = "xhttp://localhost:8080" ]]; then
122+
scitt-emulator server --port 8080 --workspace workspace/ --tree-alg CCF \
123+
--middleware scitt_emulator.oidc:OIDCAuthMiddleware \
124+
--middleware-config-path oidc-middleware-config.json &
125+
sleep 1s
126+
fi
127+
# Submit the claim using OIDC token as auth
128+
scitt-emulator client submit-claim --token "${OIDC_TOKEN}" --url "${SCITT_URL}" --claim claim.cose --out claim.receipt.cbor

‎dev-requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ requests==2.31.0
55
requests-toolbelt==0.9
66
urllib3<2.0.0
77
myst-parser
8+
PyJWT
9+
jwcrypto

‎docs/oidc.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# OIDC Support
2+
3+
- References
4+
- [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)
5+
6+
[![asciicast-of-oidc-auth-issued-by-github-actions](https://asciinema.org/a/607600.svg)](https://asciinema.org/a/607600)
7+
8+
## Dependencies
9+
10+
Install the SCITT API Emulator with the `oidc` extra.
11+
12+
```console
13+
$ pip install -e .[oidc]
14+
```
15+
16+
## Usage example with GitHub Actions
17+
18+
See [`notarize.yml`](../.github/workflows/notarize.yml)
19+
20+
References:
21+
22+
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/using-openid-connect-with-reusable-workflows
23+
- https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect

‎environment.yml

+2
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,5 @@ dependencies:
3636
- urllib3<2.0.0
3737
- myst-parser==1.0.0
3838
- jsonschema==4.17.3
39+
- jwcrypto==1.5.0
40+
- PyJWT==2.8.0

‎run-tests.sh

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ if [ ! -f "venv/bin/activate" ]; then
88
echo "Setting up Python virtual environment."
99
python3 -m venv "venv"
1010
. ./venv/bin/activate
11+
pip install -q -U pip setuptools wheel
1112
pip install -q -r dev-requirements.txt
12-
pip install -q -e .
13+
pip install -q -e .[oidc]
1314
else
1415
. ./venv/bin/activate
1516
fi

‎scitt_emulator/oidc.py

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Copyright (c) SCITT Authors.
2+
# Licensed under the MIT License.
3+
import jwt
4+
import json
5+
import jwcrypto.jwk
6+
import jsonschema
7+
from flask import jsonify
8+
from werkzeug.wrappers import Request
9+
from scitt_emulator.client import HttpClient
10+
11+
12+
class OIDCAuthMiddleware:
13+
def __init__(self, app, config_path):
14+
self.app = app
15+
self.config = {}
16+
if config_path and config_path.exists():
17+
self.config = json.loads(config_path.read_text())
18+
19+
# Initialize JSON Web Key client for given issuer
20+
self.client = HttpClient()
21+
self.oidc_configs = {}
22+
self.jwks_clients = {}
23+
for issuer in self.config['issuers']:
24+
self.oidc_configs[issuer] = self.client.get(
25+
f"{issuer}/.well-known/openid-configuration"
26+
).json()
27+
self.jwks_clients[issuer] = jwt.PyJWKClient(self.oidc_configs[issuer]["jwks_uri"])
28+
29+
def __call__(self, environ, start_response):
30+
request = Request(environ)
31+
claims = self.validate_token(request.headers["Authorization"].replace("Bearer ", ""))
32+
if "claim_schema" in self.config and claims["iss"] in self.config["claim_schema"]:
33+
jsonschema.validate(claims, schema=self.config["claim_schema"][claims["iss"]])
34+
return self.app(environ, start_response)
35+
36+
def validate_token(self, token):
37+
validation_error = Exception(f"Failed to validate against all issuers: {self.jwks_clients.keys()!s}")
38+
for issuer, jwk_client in self.jwks_clients.items():
39+
try:
40+
return jwt.decode(
41+
token,
42+
key=jwk_client.get_signing_key_from_jwt(token).key,
43+
algorithms=self.oidc_configs[issuer]["id_token_signing_alg_values_supported"],
44+
audience=self.config.get("audience", None),
45+
issuer=self.oidc_configs[issuer]["issuer"],
46+
options={"strict_aud": self.config.get("strict_aud", True),},
47+
leeway=self.config.get("leeway", 0),
48+
)
49+
except jwt.PyJWTError as error:
50+
validation_error = error
51+
raise validation_error

‎scitt_emulator/plugin_helpers.py

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright (c) SCITT Authors.
2+
# Licensed under the MIT License.
3+
import os
4+
import sys
5+
import pathlib
6+
import importlib
7+
from typing import Iterator, Optional, Union, Any
8+
9+
10+
def entrypoint_style_load(
11+
*args: str, relative: Optional[Union[str, pathlib.Path]] = None
12+
) -> Iterator[Any]:
13+
"""
14+
Load objects given the entrypoint formatted path to the object. Roughly how
15+
the python stdlib docs say entrypoint loading works.
16+
"""
17+
# Push current directory into front of path so we can run things
18+
# relative to where we are in the shell
19+
if relative is not None:
20+
if relative == True:
21+
relative = os.getcwd()
22+
# str() in case of Path object
23+
sys.path.insert(0, str(relative))
24+
try:
25+
for entry in args:
26+
modname, qualname_separator, qualname = entry.partition(":")
27+
obj = importlib.import_module(modname)
28+
for attr in qualname.split("."):
29+
if hasattr(obj, "__getitem__"):
30+
obj = obj[attr]
31+
else:
32+
obj = getattr(obj, attr)
33+
yield obj
34+
finally:
35+
if relative is not None:
36+
sys.path.pop(0)

‎scitt_emulator/server.py

+12
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from flask import Flask, request, send_file, make_response
1010

1111
from scitt_emulator.tree_algs import TREE_ALGS
12+
from scitt_emulator.plugin_helpers import entrypoint_style_load
1213
from scitt_emulator.scitt import EntryNotFoundError, ClaimInvalidError, OperationNotFoundError
1314

1415

@@ -33,6 +34,9 @@ def create_flask_app(config):
3334
app.config.update(dict(DEBUG=True))
3435
app.config.update(config)
3536

37+
if app.config.get("middleware", None):
38+
app.wsgi_app = app.config["middleware"](app.wsgi_app, app.config.get("middleware_config_path", None))
39+
3640
error_rate = app.config["error_rate"]
3741
use_lro = app.config["use_lro"]
3842

@@ -117,10 +121,18 @@ def cli(fn):
117121
parser.add_argument("--use-lro", action="store_true", help="Create operations for submissions")
118122
parser.add_argument("--tree-alg", required=True, choices=list(TREE_ALGS.keys()))
119123
parser.add_argument("--workspace", type=Path, default=Path("workspace"))
124+
parser.add_argument(
125+
"--middleware",
126+
type=lambda value: list(entrypoint_style_load(value))[0],
127+
default=None,
128+
)
129+
parser.add_argument("--middleware-config-path", type=Path, default=None)
120130

121131
def cmd(args):
122132
app = create_flask_app(
123133
{
134+
"middleware": args.middleware,
135+
"middleware_config_path": args.middleware_config_path,
124136
"tree_alg": args.tree_alg,
125137
"workspace": args.workspace,
126138
"error_rate": args.error_rate,

‎setup.py

+7
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,11 @@
2121
"flask",
2222
"rkvst-archivist"
2323
],
24+
extras_require={
25+
"oidc": [
26+
"PyJWT",
27+
"jwcrypto",
28+
"jsonschema",
29+
]
30+
},
2431
)

‎tests/test_cli.py

+175-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33
import os
4+
import json
45
import threading
56
import pytest
7+
import jwt
8+
import jwcrypto
9+
from flask import Flask, jsonify
610
from werkzeug.serving import make_server
711
from scitt_emulator import cli, server
12+
from scitt_emulator.oidc import OIDCAuthMiddleware
813

914
issuer = "did:web:example.com"
1015
content_type = "application/json"
@@ -16,16 +21,23 @@ def execute_cli(argv):
1621

1722

1823
class Service:
19-
def __init__(self, config):
24+
def __init__(self, config, create_flask_app=None):
2025
self.config = config
26+
self.create_flask_app = (
27+
create_flask_app
28+
if create_flask_app is not None
29+
else server.create_flask_app
30+
)
2131

2232
def __enter__(self):
23-
app = server.create_flask_app(self.config)
24-
self.service_parameters_path = app.service_parameters_path
25-
host = "127.0.0.1"
26-
self.server = make_server(host, 0, app)
33+
app = self.create_flask_app(self.config)
34+
if hasattr(app, "service_parameters_path"):
35+
self.service_parameters_path = app.service_parameters_path
36+
self.host = "127.0.0.1"
37+
self.server = make_server(self.host, 0, app)
2738
port = self.server.port
28-
self.url = f"http://{host}:{port}"
39+
self.url = f"http://{self.host}:{port}"
40+
app.url = self.url
2941
self.thread = threading.Thread(name="server", target=self.server.serve_forever)
3042
self.thread.start()
3143
return self
@@ -142,3 +154,160 @@ def test_client_cli(use_lro: bool, tmp_path):
142154
with open(receipt_path_2, "rb") as f:
143155
receipt_2 = f.read()
144156
assert receipt == receipt_2
157+
158+
159+
def create_flask_app_oidc_server(config):
160+
app = Flask("oidc_server")
161+
162+
app.config.update(dict(DEBUG=True))
163+
app.config.update(config)
164+
165+
@app.route("/.well-known/openid-configuration", methods=["GET"])
166+
def openid_configuration():
167+
return jsonify(
168+
{
169+
"issuer": app.url,
170+
"jwks_uri": f"{app.url}/.well-known/jwks",
171+
"response_types_supported": ["id_token"],
172+
"claims_supported": ["sub", "aud", "exp", "iat", "iss"],
173+
"id_token_signing_alg_values_supported": app.config["algorithms"],
174+
"scopes_supported": ["openid"],
175+
}
176+
)
177+
178+
@app.route("/.well-known/jwks", methods=["GET"])
179+
def jwks():
180+
return jsonify(
181+
{
182+
"keys": [
183+
{
184+
**app.config["key"].export_public(as_dict=True),
185+
"use": "sig",
186+
"kid": app.config["key"].thumbprint(),
187+
}
188+
]
189+
}
190+
)
191+
192+
return app
193+
194+
195+
def test_client_cli_token(tmp_path):
196+
workspace_path = tmp_path / "workspace"
197+
198+
claim_path = tmp_path / "claim.cose"
199+
receipt_path = tmp_path / "claim.receipt.cbor"
200+
entry_id_path = tmp_path / "claim.entry_id.txt"
201+
retrieved_claim_path = tmp_path / "claim.retrieved.cose"
202+
203+
key = jwcrypto.jwk.JWK.generate(kty="RSA", size=2048)
204+
algorithm = "RS256"
205+
audience = "scitt.example.org"
206+
subject = "repo:scitt-community/scitt-api-emulator:ref:refs/heads/main"
207+
208+
with Service(
209+
{"key": key, "algorithms": [algorithm]},
210+
create_flask_app=create_flask_app_oidc_server,
211+
) as oidc_service:
212+
os.environ["no_proxy"] = ",".join(
213+
os.environ.get("no_proxy", "").split(",") + [oidc_service.host]
214+
)
215+
middleware_config_path = tmp_path / "oidc-middleware-config.json"
216+
middleware_config_path.write_text(
217+
json.dumps(
218+
{
219+
"issuers": [oidc_service.url],
220+
"audience": audience,
221+
"claim_schema": {
222+
oidc_service.url: {
223+
"$schema": "https://json-schema.org/draft/2020-12/schema",
224+
"required": ["sub"],
225+
"properties": {
226+
"sub": {"type": "string", "enum": [subject]},
227+
},
228+
}
229+
},
230+
}
231+
)
232+
)
233+
with Service(
234+
{
235+
"middleware": OIDCAuthMiddleware,
236+
"middleware_config_path": middleware_config_path,
237+
"tree_alg": "CCF",
238+
"workspace": workspace_path,
239+
"error_rate": 0.1,
240+
"use_lro": False,
241+
}
242+
) as service:
243+
# create claim
244+
command = [
245+
"client",
246+
"create-claim",
247+
"--out",
248+
claim_path,
249+
"--issuer",
250+
issuer,
251+
"--content-type",
252+
content_type,
253+
"--payload",
254+
payload,
255+
]
256+
execute_cli(command)
257+
assert os.path.exists(claim_path)
258+
259+
# submit claim without token
260+
command = [
261+
"client",
262+
"submit-claim",
263+
"--claim",
264+
claim_path,
265+
"--out",
266+
receipt_path,
267+
"--out-entry-id",
268+
entry_id_path,
269+
"--url",
270+
service.url,
271+
]
272+
check_error = None
273+
try:
274+
execute_cli(command)
275+
except Exception as error:
276+
check_error = error
277+
assert check_error
278+
assert not os.path.exists(receipt_path)
279+
assert not os.path.exists(entry_id_path)
280+
281+
# create token without subject
282+
token = jwt.encode(
283+
{"iss": oidc_service.url, "aud": audience},
284+
key.export_to_pem(private_key=True, password=None),
285+
algorithm=algorithm,
286+
headers={"kid": key.thumbprint()},
287+
)
288+
# submit claim with token lacking subject
289+
command += [
290+
"--token",
291+
token,
292+
]
293+
check_error = None
294+
try:
295+
execute_cli(command)
296+
except Exception as error:
297+
check_error = error
298+
assert check_error
299+
assert not os.path.exists(receipt_path)
300+
assert not os.path.exists(entry_id_path)
301+
302+
# create token with subject
303+
token = jwt.encode(
304+
{"iss": oidc_service.url, "aud": audience, "sub": subject},
305+
key.export_to_pem(private_key=True, password=None),
306+
algorithm=algorithm,
307+
headers={"kid": key.thumbprint()},
308+
)
309+
# submit claim with token containing subject
310+
command[-1] = token
311+
execute_cli(command)
312+
assert os.path.exists(receipt_path)
313+
assert os.path.exists(entry_id_path)

‎tests/test_plugin_helpers.py

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright (c) SCITT Authors.
2+
# Licensed under the MIT License.
3+
import os
4+
import textwrap
5+
6+
from scitt_emulator.plugin_helpers import entrypoint_style_load
7+
8+
9+
def test_entrypoint_style_load_relative(tmp_path):
10+
plugin_path = tmp_path / "myplugin.py"
11+
12+
plugin_path.write_text(
13+
textwrap.dedent(
14+
"""
15+
def my_cool_plugin():
16+
return "Hello World"
17+
18+
19+
class MyCoolClass:
20+
@staticmethod
21+
def my_cool_plugin():
22+
return my_cool_plugin()
23+
24+
25+
my_cool_dict = {
26+
"my_cool_plugin": my_cool_plugin,
27+
}
28+
""",
29+
)
30+
)
31+
32+
for load_within_file in [
33+
"my_cool_plugin",
34+
"MyCoolClass.my_cool_plugin",
35+
"my_cool_dict.my_cool_plugin",
36+
]:
37+
plugin_entrypoint_style_path = (
38+
str(plugin_path.relative_to(tmp_path).with_suffix("")).replace(
39+
os.path.sep, "."
40+
)
41+
+ ":"
42+
+ load_within_file
43+
)
44+
45+
loaded = list(
46+
entrypoint_style_load(plugin_entrypoint_style_path, relative=tmp_path)
47+
)[0]
48+
49+
os.chdir(tmp_path)
50+
51+
loaded = list(
52+
entrypoint_style_load(plugin_entrypoint_style_path, relative=True)
53+
)[0]
54+
55+
assert loaded() == "Hello World"

0 commit comments

Comments
 (0)
This repository has been archived.