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

Interfaces and services for JWK management #10628

Merged
merged 29 commits into from
Feb 7, 2022
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ee9e753
python-version: bump to 3.8.9
woodruffw Jan 20, 2022
583dc09
ci, Dockerfile: bump Python versions
woodruffw Jan 20, 2022
c5d2346
Merge remote-tracking branch 'upstream/main' into tob-jwk-management
woodruffw Jan 20, 2022
816d1a6
requirements, warehouse: dependencies, skeleton for JWKs
woodruffw Jan 20, 2022
6068722
warehouse/oidc: format
woodruffw Jan 20, 2022
8aa296b
config, oidc, utils: fill in more groundwork
woodruffw Jan 20, 2022
22904cd
warehouse: add a basic `warehouse oidc` CLI, redis caching
woodruffw Jan 20, 2022
acd82ca
tasks: remove the separate OIDC queue
woodruffw Jan 20, 2022
cc80a76
warehouse: decompose OIDC urls a bit
woodruffw Jan 20, 2022
f6b8e63
warehouse/utils: docs
woodruffw Jan 20, 2022
60b63c9
Merge remote-tracking branch 'origin/main' into tob-jwk-management
woodruffw Jan 24, 2022
63a4d6d
warehouse: refactor JWKs to fetch on first use
woodruffw Jan 24, 2022
b439925
Merge remote-tracking branch 'upstream/main' into tob-jwk-management
woodruffw Jan 25, 2022
2e03654
tests/unit: fix config test
woodruffw Jan 25, 2022
8ca255b
Update requirements/main.txt
woodruffw Jan 26, 2022
9d7c0e1
Apply suggestions from code review
woodruffw Jan 26, 2022
418c226
warehouse: refactor JWKService
woodruffw Jan 26, 2022
e7f860d
oidc/services: appease flake8
woodruffw Jan 27, 2022
7600d20
warehouse: add metrics to JWKService, rewrite CLI
woodruffw Jan 27, 2022
46e33d6
warehouse/cli: remove oidc subcommand
woodruffw Jan 27, 2022
3b421ea
warehouse: rename JWKService to OIDCProviderService, refactor
woodruffw Jan 27, 2022
b349c78
warehouse/oidc: fix init
woodruffw Jan 27, 2022
a98934e
warehouse: remove oidc.utils, refactor
woodruffw Jan 27, 2022
0b2e6f8
warehouse/oidc: re-add provider attribute
woodruffw Jan 27, 2022
012af7a
tests: unit tests for warehouse.oidc.services
woodruffw Jan 28, 2022
dca4380
Merge remote-tracking branch 'upstream/main' into tob-jwk-management
woodruffw Jan 28, 2022
71b7b7f
Merge branch 'main' into tob-jwk-management
woodruffw Feb 2, 2022
80a95ad
Merge branch 'main' into tob-jwk-management
woodruffw Feb 3, 2022
fe2fa2e
Merge branch 'main' into tob-jwk-management
di Feb 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions requirements/main.in
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ pyramid_rpc>=0.7
pyramid_services>=2.1
pyramid_tm>=0.12
python-slugify
PyJWT[crypto]>=2.3.0
readme-renderer[md]>=0.7.0
requests
requests-aws4auth
Expand Down
7 changes: 6 additions & 1 deletion requirements/main.txt
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,8 @@ cryptography==36.0.1 \
--hash=sha256:ebc15b1c22e55c4d5566e3ca4db8689470a0ca2babef8e3a9ee057a8b82ce4b1 \
--hash=sha256:ec63da4e7e4a5f924b90af42eddf20b698a70e58d86a72d943857c4c6045b3ee
# via
# -r requirements/main.in
# -r main.in
# pyjwt
# pyopenssl
# webauthn
cssselect==1.1.0 \
Expand Down Expand Up @@ -883,6 +884,10 @@ pygments==2.10.0 \
--hash=sha256:b8e67fe6af78f492b3c4b3e2970c0624cbf08beb1e493b2c99b9fa1b67a20380 \
--hash=sha256:f398865f7eb6874156579fdf36bc840a03cab64d1cde9e93d68f46a425ec52c6
# via readme-renderer
pyjwt[crypto]==2.3.0 \
--hash=sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41 \
--hash=sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f
# via -r main.in
pymacaroons==0.13.0 \
--hash=sha256:1e6bba42a5f66c245adf38a5a4006a99dcc06a0703786ea636098667d42903b8 \
--hash=sha256:3e14dff6a262fdbf1a15e769ce635a8aea72e6f8f91e408f9a97166c53b91907
Expand Down
1 change: 1 addition & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ def __init__(self):
pretend.call(".email"),
pretend.call(".accounts"),
pretend.call(".macaroons"),
pretend.call(".oidc"),
pretend.call(".malware"),
pretend.call(".manage"),
pretend.call(".packaging"),
Expand Down
4 changes: 4 additions & 0 deletions warehouse/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def configure(settings=None):
maybe_set(settings, "celery.broker_url", "BROKER_URL")
maybe_set(settings, "celery.result_url", "REDIS_URL")
maybe_set(settings, "celery.scheduler_url", "REDIS_URL")
maybe_set(settings, "oidc.jwk_cache_url", "REDIS_URL")
maybe_set(settings, "database.url", "DATABASE_URL")
maybe_set(settings, "elasticsearch.url", "ELASTICSEARCH_URL")
maybe_set(settings, "elasticsearch.url", "ELASTICSEARCH_SIX_URL")
Expand Down Expand Up @@ -458,6 +459,9 @@ def configure(settings=None):
# Register support for Macaroon based authentication
config.include(".macaroons")

# Register support for OIDC provider based authentication
config.include(".oidc")

# Register support for malware checks
config.include(".malware")

Expand Down
20 changes: 20 additions & 0 deletions warehouse/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from warehouse.oidc.interfaces import IJWKService
from warehouse.oidc.services import JWKServiceFactory


def includeme(config):
config.register_service_factory(
JWKServiceFactory(provider="github"), IJWKService, name="github"
)
34 changes: 34 additions & 0 deletions warehouse/oidc/interfaces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


from zope.interface import Interface


class IJWKService(Interface):
def create_service(context, request):
"""
Create the service, given the context and request for which it is
being created.
"""
pass

def get_key(provider, key_id):
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
"""
Return the JWK identified by the given KID for the given provider,
fetching it if not already cached locally.

Returns None if the JWK does not exist or the access pattern is
invalid (i.e., exceeds our internal limit on JWK requests to
each provider).
"""
pass
168 changes: 168 additions & 0 deletions warehouse/oidc/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import json

import redis
import requests
import sentry_sdk

from jwt import PyJWK
from zope.interface import implementer

from warehouse.metrics.interfaces import IMetricsService
from warehouse.oidc.interfaces import IJWKService
from warehouse.utils import oidc


@implementer(IJWKService)
class JWKService:
def __init__(self, provider, cache_url, metrics):
self.provider
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
self.cache_url = cache_url
self.metrics = metrics

self._provider_jwk_key = f"/warehouse/oidc/jwks/{self.provider}"
self._provider_timeout_key = f"{self._provider_jwk_key}/timeout"

def _store_keyset(self, keys):
"""
Store the given keyset for the given provider, setting the timeout key
in the process.
"""

with redis.StrictRedis.from_url(self.cache_url) as r:
r.set(self._provider_jwk_key, json.dumps(keys))
r.setex(self._provider_timeout_key, 60, "placeholder")

def _get_keyset(self):
"""
Return the cached keyset for the given provider, or an empty
keyset if no keys are currently cached.
"""

with redis.StrictRedis.from_url(self.cache_url) as r:
keys = r.get(self._provider_jwk_key)
timeout = bool(r.exists(self._provider_timeout_key))
if keys is not None:
return (json.loads(keys), timeout)
else:
return ({}, timeout)

def _refresh_keyset(self):
"""
Attempt to refresh the keyset from the OIDC provider, assuming no
timeout is in effect.

Returns the refreshed keyset, or the cached keyset if a timeout is
in effect.

Returns the cached keyset on any provider access or format errors.
"""

# Fast path: we're in a cooldown from a previous refresh.
keys, timeout = self._get_keyset()
if timeout:
self.metrics.increment("warehouse.oidc.refresh_keyset.timeout")
di marked this conversation as resolved.
Show resolved Hide resolved
return keys

oidc_url = f"{oidc.OIDC_PROVIDERS[self.provider]}/{oidc.WELL_KNOWN_OIDC_CONF}"
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

resp = requests.get(oidc_url)

# For whatever reason, an OIDC provider's configuration URL might be
# offline. We don't want to completely explode here, since other
# providers might still be online (and need updating), so we spit
# out an error and return None instead of raising.
if not resp.ok:
sentry_sdk.capture_message(
f"OIDC provider {self.provider} failed to return configuration: "
f"{oidc_url}"
)
return keys

oidc_conf = resp.json()
jwks_url = oidc_conf.get("jwks_uri")

# A valid OIDC configuration MUST have a `jwks_uri`, but we
# defend against its absence anyways.
if jwks_url is None:
sentry_sdk.capture_message(
f"OIDC provider {self.provider} is returning malformed "
"configuration (no jwks_uri)"
)
return keys

resp = requests.get(jwks_url)

# Same reasoning as above.
if not resp.ok:
sentry_sdk.capture_message(
f"OIDC provider {self.provider} failed to return JWKS JSON: "
f"{jwks_url}"
)
return keys

jwks_conf = resp.json()
keys = jwks_conf.get("keys")

# Another sanity test: an OIDC provider should never return an empty
# keyset, but there's nothing stopping them from doing so. We don't
# want to cache an empty keyset just in case it's a short-lived error,
# so we check here, error, and return the current cache instead.
if not keys:
sentry_sdk.capture_message(
f"OIDC provider {self.provider} returned JWKS but no keys"
)
return keys

keys = {key["kid"]: key for key in keys}
self._store_keyset(keys)

return keys
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

def get_key(self, key_id):
"""
Return a JWK for the given key ID, or None if the key can't be found
in this provider's keyset.
"""

keyset, _ = self._get_keyset()
if key_id not in keyset:
keyset = self._refresh_keyset()
if key_id not in keyset:
self.metrics.increment(
"warehouse.oidc.get_key.error", tags=[f"key_id:{key_id}"]
)
di marked this conversation as resolved.
Show resolved Hide resolved
return None
return PyJWK(keyset[key_id])


class JWKServiceFactory:
def __init__(self, provider, service_class=JWKService):
self.provider = provider
self.service_class = service_class

def __call__(self, _context, request):
cache_url = request.registry.settings["oidc.jwk_cache_url"]
metrics = request.find_service(IMetricsService, context=None)

return self.service_class(self.provider, cache_url, metrics)

def __eq__(self, other):
if not isinstance(other, JWKServiceFactory):
return NotImplemented

return (self.provider, self.service_class) == (
other.provider,
other.service_class,
)
27 changes: 27 additions & 0 deletions warehouse/utils/oidc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.


# For now, only RS256 is supported.
# We probably won't need to support providers with only symmetric keys
# (e.g. HS256) in the foreseeable future.
VALID_ALGS = {"RS256"}
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

# This is a map of OIDC providers supported by Warehouse.
# The keys are human-readable names for each provider, and the values
# are the "issuer" (i.e., `iss`) FQDN that JWTs signed by their respective
# JWKs are checked against.
OIDC_PROVIDERS = {
"github": "https://token.actions.githubusercontent.com",
}
woodruffw marked this conversation as resolved.
Show resolved Hide resolved

WELL_KNOWN_OIDC_CONF = ".well-known/openid-configuration"
woodruffw marked this conversation as resolved.
Show resolved Hide resolved
woodruffw marked this conversation as resolved.
Show resolved Hide resolved