Skip to content

Commit 0658109

Browse files
dylantackauvipy
andauthored
Multiple rsa keys (#978)
* Support rotation of RSA keys * add author * changelog for #950 Co-authored-by: Asif Saif Uddin <[email protected]>
1 parent 59ab199 commit 0658109

File tree

9 files changed

+108
-5
lines changed

9 files changed

+108
-5
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ David Smith
2626
Diego Garcia
2727
Dulmandakh Sukhbaatar
2828
Dylan Giesler
29+
Dylan Tack
2930
Emanuele Palazzetti
3031
Federico Dolce
3132
Frederico Vieira

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
### Added
2323
* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request`
2424
to provide compatibility with backends that need one.
25+
* #950 Add support for RSA key rotation.
2526

2627
### Fixed
2728
* #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True.

docs/oidc.rst

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,30 @@ change this class to derive from ``oauthlib.openid.Server`` instead of
100100
With ``RSA`` key-pairs, the public key can be generated from the private key,
101101
so there is no need to add a setting for the public key.
102102

103+
104+
Rotating the RSA private key
105+
~~~~~~~~~~~~~~~~~~~~~~~~
106+
Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE``
107+
setting. For example:::
108+
109+
OAUTH2_PROVIDER = {
110+
"OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"),
111+
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [
112+
os.environ.get("OIDC_RSA_PRIVATE_KEY_2"),
113+
os.environ.get("OIDC_RSA_PRIVATE_KEY_3")
114+
]
115+
# ... other settings
116+
}
117+
118+
To rotate, follow these steps:
119+
120+
#. Generate a new key, and add it to the inactive set. Then deploy the app.
121+
#. Swap the active and inactive keys, then re-deploy.
122+
#. After some reasonable amount of time, remove the inactive key. At a minimum,
123+
you should wait ``ID_TOKEN_EXPIRE_SECONDS`` to ensure the key isn't removed
124+
before valid tokens expire.
125+
126+
103127
Using ``HS256`` keys
104128
~~~~~~~~~~~~~~~~~~~~
105129

@@ -297,7 +321,7 @@ query, and other details.
297321
JwksInfoView
298322
~~~~~~~~~~~~
299323

300-
Available at ``/o/.well-known/jwks.json``, this view provides details of the key used to sign
324+
Available at ``/o/.well-known/jwks.json``, this view provides details of the keys used to sign
301325
the JWTs generated for ID tokens, so that clients are able to verify them.
302326

303327

docs/settings.rst

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,25 @@ Default: ``""``
264264

265265
The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled.
266266

267+
OIDC_RSA_PRIVATE_KEYS_INACTIVE
268+
~~~~~~~~~~~~~~~~~~~~
269+
Default: ``[]``
270+
271+
An array of *inactive* RSA private keys. These keys are not used to sign tokens,
272+
but are published in the jwks_uri location.
273+
274+
This is useful for providing a smooth transition during key rotation.
275+
``OIDC_RSA_PRIVATE_KEY`` can be replaced, and recently decommissioned keys
276+
should be retained in this inactive list.
277+
278+
OIDC_JWKS_MAX_AGE_SECONDS
279+
~~~~~~~~~~~~~~~~~~~~~~
280+
Default: ``3600``
281+
282+
The max-age value for the Cache-Control header on jwks_uri.
283+
284+
This enables the verifier to safely cache the JWK Set and not have to re-download
285+
the document for every token.
267286

268287
OIDC_USERINFO_ENDPOINT
269288
~~~~~~~~~~~~~~~~~~~~~~

oauth2_provider/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@
7272
"OIDC_ISS_ENDPOINT": "",
7373
"OIDC_USERINFO_ENDPOINT": "",
7474
"OIDC_RSA_PRIVATE_KEY": "",
75+
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": [],
76+
"OIDC_JWKS_MAX_AGE_SECONDS": 3600,
7577
"OIDC_RESPONSE_TYPES_SUPPORTED": [
7678
"code",
7779
"token",

oauth2_provider/views/oidc.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,23 @@ class JwksInfoView(OIDCOnlyMixin, View):
7171
def get(self, request, *args, **kwargs):
7272
keys = []
7373
if oauth2_settings.OIDC_RSA_PRIVATE_KEY:
74-
key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8"))
75-
data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()}
76-
data.update(json.loads(key.export_public()))
77-
keys.append(data)
74+
for pem in [
75+
oauth2_settings.OIDC_RSA_PRIVATE_KEY,
76+
*oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE,
77+
]:
78+
79+
key = jwk.JWK.from_pem(pem.encode("utf8"))
80+
data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()}
81+
data.update(json.loads(key.export_public()))
82+
keys.append(data)
7883
response = JsonResponse({"keys": keys})
7984
response["Access-Control-Allow-Origin"] = "*"
85+
response["Cache-Control"] = (
86+
"Cache-Control: public, "
87+
+ f"max-age={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
88+
+ f"stale-while-revalidate={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, "
89+
+ f"stale-if-error={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}"
90+
)
8091
return response
8192

8293

tests/presets.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"OIDC_ISS_ENDPOINT": "http://localhost/o",
1313
"OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/",
1414
"OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY,
15+
"OIDC_RSA_PRIVATE_KEYS_INACTIVE": settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE,
1516
"SCOPES": {
1617
"read": "Reading scope",
1718
"write": "Writing scope",

tests/settings.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,24 @@
134134
dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY
135135
-----END RSA PRIVATE KEY-----"""
136136

137+
OIDC_RSA_PRIVATE_KEYS_INACTIVE = [
138+
"""-----BEGIN RSA PRIVATE KEY-----
139+
MIICXAIBAAKBgQDSpXNtxaD9+DKBnSWJNoV6h0PZuSKeGPyA8n0/as/O+oboiYj1
140+
gqQSTwPFxzt5Zy52fDmIQvzDH+2CihpGIeJh9SsUEFd8DXkP/Xk91f/mAbytBsnt
141+
czFCtihFRxWbbBAMHh8i5HuxM+rH2nw5Hh/74GLE58zk5rtIRS1DyS+uUQIDAQAB
142+
AoGAca57Ci4TQZ02XL8bp9610Le5hYIlzZ78fvbfY19YwYJxVoQLVzxnIb5k8dMh
143+
JNbru2Q1hHVqhj/v5Xh0z46v5mTOeyQj8F1O6NCkzHtCfF029j8A9+pfNqyQhCa/
144+
nJqsNShFW+uhK67d7QfqtRRR6B30XsIHgND7QJuc14mDkdUCQQD3OpzLZugdTtuW
145+
u+DdrdSjMBbW2p1+NFr8T20Rv+LoMvweZLSuMelAoog8fNxF6xQs7wLw+Tf5z56L
146+
mptnur6TAkEA2h6WL3ippJ6/7H45suxP1dJI+Qal7V2KAMVGbv6Jal9rcKid0PpD
147+
K1uPZwx2o/hkdobPY0HRIFaxpOtwC4FKCwJAYTmWodMFY0k4yA14wBT1c3uc77+n
148+
ghM62NCvdvR8Wo56YcV+3KZaMYX5h7getAxfsdAI2xVXMxG4KvSROvjQqwJAaZ+W
149+
KrbLr6QQXH1jg3lbz7ddDvphL2i0g1sEmIs6EADVDmEYyzHlhQF5l/U5Hn4SaDMw
150+
Cmi81GQm8i3wvCGHsQJBAJC2VVcZ4VIehr3nAbI46w6cXGP6lpBbwT2FxSydRHqz
151+
wfGZQ+qAAThGg3OInQNMqItypEEo3oZhKKvjD1N/iTw=
152+
-----END RSA PRIVATE KEY-----"""
153+
]
154+
137155
OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken"
138156
OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application"
139157
OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken"

tests/test_oidc_views.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ def test_get_connect_discovery_info_without_rsa_key(self):
7171
@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW)
7272
class TestJwksInfoView(TestCase):
7373
def test_get_jwks_info(self):
74+
self.oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE = []
7475
expected_response = {
7576
"keys": [
7677
{
@@ -93,6 +94,31 @@ def test_get_jwks_info_no_rsa_key(self):
9394
self.assertEqual(response.status_code, 200)
9495
assert response.json() == {"keys": []}
9596

97+
def test_get_jwks_info_multiple_rsa_keys(self):
98+
expected_response = {
99+
"keys": [
100+
{
101+
"alg": "RS256",
102+
"e": "AQAB",
103+
"kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs",
104+
"kty": "RSA",
105+
"n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa
106+
"use": "sig",
107+
},
108+
{
109+
"alg": "RS256",
110+
"e": "AQAB",
111+
"kid": "AJ_IkYJUFWqiKKE2FvPIESroTvownbaj0OzL939oIIE",
112+
"kty": "RSA",
113+
"n": "0qVzbcWg_fgygZ0liTaFeodD2bkinhj8gPJ9P2rPzvqG6ImI9YKkEk8Dxcc7eWcudnw5iEL8wx_tgooaRiHiYfUrFBBXfA15D_15PdX_5gG8rQbJ7XMxQrYoRUcVm2wQDB4fIuR7sTPqx9p8OR4f--BixOfM5Oa7SEUtQ8kvrlE", # noqa
114+
"use": "sig",
115+
},
116+
]
117+
}
118+
response = self.client.get(reverse("oauth2_provider:jwks-info"))
119+
self.assertEqual(response.status_code, 200)
120+
assert response.json() == expected_response
121+
96122

97123
@pytest.mark.django_db
98124
@pytest.mark.parametrize("method", ["get", "post"])

0 commit comments

Comments
 (0)