File: oidc.rst

package info (click to toggle)
django-oauth-toolkit 3.0.1-1
  • links: PTS, VCS
  • area: main
  • in suites: sid, trixie
  • size: 2,156 kB
  • sloc: python: 11,100; makefile: 159; javascript: 9; sh: 6
file content (445 lines) | stat: -rw-r--r-- 17,993 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
OpenID Connect
++++++++++++++

OpenID Connect support
======================

``django-oauth-toolkit`` supports `OpenID Connect <https://openid.net/specs/openid-connect-core-1_0.html>`_
(OIDC), which standardizes authentication flows and provides a plug and play integration with other
systems. OIDC is built on top of OAuth 2.0 to provide:

* Generating ID tokens as part of the login process. These are JWT that
  describe the user, and can be used to authenticate them to your application.
* Metadata based auto-configuration for providers
* A user info endpoint, which applications can query to get more information
  about a user.

Enabling OIDC doesn't affect your existing OAuth 2.0 flows, these will
continue to work alongside OIDC.

We support:

* OpenID Connect Authorization Code Flow
* OpenID Connect Implicit Flow
* OpenID Connect Hybrid Flow

Furthermore ``django-oauth-toolkit`` also supports `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_.


Configuration
=============

OIDC is not enabled by default because it requires additional configuration
that must be provided. ``django-oauth-toolkit`` supports two different
algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a
public key and a private key), and ``HS256``, which uses a symmetric key.

It is preferable to use ``RS256``, because this produces a token that can be
verified by anyone using the public key (which is made available and
discoverable by OIDC service auto-discovery, included with
``django-oauth-toolkit``). ``HS256`` on the other hand uses the
``client_secret`` in order to verify keys. This is simpler to implement, but
makes it harder to safely verify tokens.

Using ``HS256`` also means that you cannot use the Implicit or Hybrid flows,
or verify the tokens in public clients, because you cannot disclose the
``client_secret`` to a public client. If you are using a public client, you
must use ``RS256``.


Creating RSA private key
~~~~~~~~~~~~~~~~~~~~~~~~

To use ``RS256`` requires an RSA private key, which is used for signing JWT. You
can generate this using the `openssl`_ tool::

    openssl genrsa -out oidc.key 4096

This will generate a 4096-bit RSA key, which will be sufficient for our needs.

.. _openssl: https://www.openssl.org

.. warning::
    The contents of this key *must* be kept a secret. Don't put it in your
    settings and commit it to version control!

    If the key is ever accidentally disclosed, an attacker could use it to
    forge JWT tokens that verify as issued by your OAuth provider, which is
    very bad!

    If it is ever disclosed, you should immediately replace the key.

    Safe ways to handle it would be:

    * Store it in a secure system like `Hashicorp Vault`_, and inject it in to
      your environment when running your server.
    * Store it in a secure file on your server, and use your initialization
      scripts to inject it in to your environment.

.. _Hashicorp Vault: https://www.hashicorp.com/products/vault

Now we need to add this key to our settings and allow the ``openid`` scope to
be used. Assuming we have set an environment variable called
``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``::

    import os

    OAUTH2_PROVIDER = {
        "OIDC_ENABLED": True,
        "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"),
        "SCOPES": {
            "openid": "OpenID Connect scope",
            # ... any other scopes that you use
        },
        # ... any other settings you want
    }

If you are adding OIDC support to an existing OAuth 2.0 provider site, and you
are currently using a custom class for ``OAUTH2_SERVER_CLASS``, you must
change this class to derive from ``oauthlib.openid.Server`` instead of
``oauthlib.oauth2.Server``.

With ``RSA`` key-pairs, the public key can be generated from the private key,
so there is no need to add a setting for the public key.


Rotating the RSA private key
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE``
setting. For example:::

    OAUTH2_PROVIDER = {
        "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"),
        "OIDC_RSA_PRIVATE_KEYS_INACTIVE": [
            os.environ.get("OIDC_RSA_PRIVATE_KEY_2"),
            os.environ.get("OIDC_RSA_PRIVATE_KEY_3")
        ]
        # ... other settings
    }

To rotate, follow these steps:

#. Generate a new key, and add it to the inactive set. Then deploy the app.
#. Swap the active and inactive keys, then re-deploy.
#. After some reasonable amount of time, remove the inactive key. At a minimum,
   you should wait ``ID_TOKEN_EXPIRE_SECONDS`` to ensure the key isn't removed
   before valid tokens expire.


Using ``HS256`` keys
~~~~~~~~~~~~~~~~~~~~

If you would prefer to use just ``HS256`` keys, you don't need to create any
additional keys, ``django-oauth-toolkit`` will just use the application's
``client_secret`` to sign the JWT token.

To be able to verify the JWT's signature using the ``client_secret``, you
must set the application's ``hash_client_secret`` to ``False``.

In this case, you just need to enable OIDC and add ``openid`` to your list of
scopes in your ``settings.py``::

    OAUTH2_PROVIDER = {
        "OIDC_ENABLED": True,
        "SCOPES": {
            "openid": "OpenID Connect scope",
            # ... any other scopes that you use
        },
        # ... any other settings you want
    }

.. note::
    ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must.
    Using ``RS256`` will allow you to keep your ``client_secret`` hashed.


RP-Initiated Logout
~~~~~~~~~~~~~~~~~~~
This feature has to be enabled separately as it is an extension to the core standard.

.. code-block:: python

   OAUTH2_PROVIDER = {
       # OIDC has to be enabled to use RP-Initiated Logout
       "OIDC_ENABLED": True,
       # Enable and configure RP-Initiated Logout
       "OIDC_RP_INITIATED_LOGOUT_ENABLED": True,
       "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True,
       # ... any other settings you want
   }


Setting up OIDC enabled clients
===============================

Setting up an OIDC client in ``django-oauth-toolkit`` is simple - in fact, all
existing OAuth 2.0 Authorization Code Flow and Implicit Flow applications that
are already configured can be easily updated to use OIDC by setting the
appropriate algorithm for them to use.

You can also switch existing apps to use OIDC Hybrid Flow by changing their
Authorization Grant Type and selecting a signing algorithm to use.

You can read about the pros and cons of the different flows in `this excellent
article`_ from Robert Broeckelmann.

.. _this excellent article: https://medium.com/@robert.broeckelmann/when-to-use-which-oauth2-grants-and-oidc-flows-ec6a5c00d864

OIDC Authorization Code Flow
~~~~~~~~~~~~~~~~~~~~~~~~~~~~

To create an OIDC Authorization Code Flow client, create an ``Application``
with the grant type ``Authorization code`` and select your desired signing
algorithm.

When making an authorization request, be sure to include ``openid`` as a
scope. When the code is exchanged for the access token, the response will
also contain an ID token JWT.

If the ``openid`` scope is not requested, authorization requests will be
treated as standard OAuth 2.0 Authorization Code Grant requests.

With ``PKCE`` enabled, even public clients can use this flow, and it is the most
secure and recommended flow.

OIDC Implicit Flow
~~~~~~~~~~~~~~~~~~

OIDC Implicit Flow is very similar to OAuth 2.0 Implicit Grant, except that
the client can request a ``response_type`` of ``id_token`` or ``id_token
token``. Requesting just ``token`` is also possible, but it would make it not
an OIDC flow and would fall back to being the same as OAuth 2.0 Implicit
Grant.

To setup an OIDC Implicit Flow client, simply create an ``Application`` with
the a grant type of ``Implicit`` and select your desired signing algorithm,
and configure the client to request the ``openid`` scope and an OIDC
``response_type`` (``id_token`` or ``id_token token``).


OIDC Hybrid Flow
~~~~~~~~~~~~~~~~

OIDC Hybrid Flow is a mixture of the previous two flows. It allows the ID
token and an access token to be returned to the frontend, whilst also
allowing the backend to retrieve the ID token and an access token (not
necessarily the same access token) on the backend.

To setup an OIDC Hybrid Flow application, create an ``Application`` with a
grant type of ``OpenID connect hybrid`` and select your desired signing
algorithm.


Customizing the OIDC responses
==============================

This basic configuration will give you a basic working OIDC setup, but your
ID tokens will have very few claims in them, and the ``UserInfo`` service will
just return the same claims as the ID token.

To configure all of these things we need to customize the
``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in
our project, eg ``my_project/oauth_validators.py``::

    from oauth2_provider.oauth2_validators import OAuth2Validator


    class CustomOAuth2Validator(OAuth2Validator):
        pass


and then configure our site to use this in our ``settings.py``::

    OAUTH2_PROVIDER = {
        "OAUTH2_VALIDATOR_CLASS": "my_project.oauth_validators.CustomOAuth2Validator",
        # ... other settings
    }

Now we can customize the tokens and the responses that are produced by adding
methods to our custom validator.


Adding claims to the ID token
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

By default the ID token will just have a ``sub`` claim (in addition to the
required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc),
and the ``sub`` claim will use the primary key of the user as the value.
You'll probably want to customize this and add additional claims or change
what is sent for the ``sub`` claim. To do so, you will need to add a method to
our custom validator. It takes one of two forms:

The first form gets passed a request object, and should return a dictionary
mapping a claim name to claim data::

    class CustomOAuth2Validator(OAuth2Validator):
        # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return,
        # otherwise the OIDC standard scopes are used.

        def get_additional_claims(self, request):
            return {
                "given_name": request.user.first_name,
                "family_name": request.user.last_name,
                "name": ' '.join([request.user.first_name, request.user.last_name]),
                "preferred_username": request.user.username,
                "email": request.user.email,
            }


The second form gets no request object, and should return a dictionary
mapping a claim name to a callable, accepting a request and producing
the claim data::
    class CustomOAuth2Validator(OAuth2Validator):
        # Extend the standard scopes to add a new "permissions" scope
        # which returns a "permissions" claim:
        oidc_claim_scope = OAuth2Validator.oidc_claim_scope
        oidc_claim_scope.update({"permissions": "permissions"})

        def get_additional_claims(self):
            return {
                "given_name": lambda request: request.user.first_name,
                "family_name": lambda request: request.user.last_name,
                "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]),
                "preferred_username": lambda request: request.user.username,
                "email": lambda request: request.user.email,
                "permissions": lambda request: list(request.user.get_group_permissions()),
            }


Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``.

Supported claims discovery
--------------------------

In order to help clients discover claims early, they can be advertised in the discovery
info, under the ``claims_supported`` key. In order for the discovery info view to automatically
add all claims your validator returns, you need to use the second form (producing callables),
because the discovery info views are requested with an unauthenticated request, so directly
producing claim data would fail. If you use the first form, producing claim data directly,
your claims will not be added to discovery info.

In some cases, it might be desirable to not list all claims in discovery info. To customize
which claims are advertised, you can override the ``get_discovery_claims`` method to return
a list of claim names to advertise. If your ``get_additional_claims`` uses the first form
and you still want to advertise claims, you can also override ``get_discovery_claims``.

Using OIDC scopes to determine which claims are returned
--------------------------------------------------------

The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's
`5.4 Requesting Claims using Scope Values`_ feature.
For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted.

To change the list of claims and which scopes result in their being returned,
override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope.
The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted::
    class CustomOAuth2Validator(OAuth2Validator):
        oidc_claim_scope = OAuth2Validator.oidc_claim_scope
        oidc_claim_scope.update({"foo": "bar"})

Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes.

You have to make sure you've added additional claims via ``get_additional_claims``
and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work.

.. note::
    This ``request`` object is not a ``django.http.Request`` object, but an
    ``oauthlib.common.Request`` object. This has a number of attributes that
    you can use to decide what claims to put in to the ID token:

    * ``request.scopes`` - the list of granted scopes.
    * ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_.
      These must be requested by the client when making an authorization request.
    * ``request.user`` - the `Django User`_ object.

.. _5.4 Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
.. _5.5 Requesting Claims using the "claims" Request Parameter: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter
.. _Django User: https://docs.djangoproject.com/en/stable/ref/contrib/auth/#user-model

What claims you decide to put in to the token is up to you to determine based
upon what the scopes and / or claims means to your provider.


Adding information to the ``UserInfo`` service
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The ``UserInfo`` service is supplied as part of the OIDC service, and is used
to retrieve information about the user given their Access Token.
It is optional to use the service. The service is accessed by making a request to the
``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token
retrieved at login as a ``Bearer`` token or as a form-encoded ``access_token`` body parameter
for a POST request.

Again, to modify the content delivered, we need to add a function to our
custom validator. The default implementation adds the claims from the ID
token, so you will probably want to reuse that::

    class CustomOAuth2Validator(OAuth2Validator):

        def get_userinfo_claims(self, request):
            claims = super().get_userinfo_claims(request)
            claims["color_scheme"] = get_color_scheme(request.user)
            return claims

Customizing the login flow
==========================

Clients can request that the user logs in each time a request to the
``/authorize`` endpoint is made during the OIDC Authorization Code Flow by
adding the ``prompt=login`` query parameter and value. Only ``login`` is
currently supported. See
OIDC's `3.1.2.1 Authentication Request <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_
for details.

OIDC Views
==========

Enabling OIDC support adds three views to ``django-oauth-toolkit``. When OIDC
is not enabled, these views will log that OIDC support is not enabled, and
return a ``404`` response, or if ``DEBUG`` is enabled, raise an
``ImproperlyConfigured`` exception.

In the docs below, it assumes that you have mounted the
``django-oauth-toolkit`` at ``/o/``. If you have mounted it elsewhere, adjust
the URLs accordingly.


Define where to store the profile
=================================

.. py:function:: OAuth2Validator.get_or_create_user_from_content(content)

An optional layer to define where to store the profile in ``UserModel`` or a separate model. For example ``UserOAuth``, where ``user = models.OneToOneField(UserModel)``.

The function is called after checking that the username is present in the content.

:return: An instance of the ``UserModel`` representing the user fetched or created.

ConnectDiscoveryInfoView
~~~~~~~~~~~~~~~~~~~~~~~~

Available at ``/o/.well-known/openid-configuration``, this view provides auto
discovery information to OIDC clients, telling them the JWT issuer to use, the
location of the JWKs to verify JWTs with, the token and userinfo endpoints to
query, and other details.


JwksInfoView
~~~~~~~~~~~~

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


UserInfoView
~~~~~~~~~~~~

Available at ``/o/userinfo/``, this view provides extra user details. You can
customize the details included in the response as described above.


RPInitiatedLogoutView
~~~~~~~~~~~~~~~~~~~~~

Available at ``/o/logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner`
is logged out at the :term:`Authorization Server` (OpenID Provider).