At Columbia we've implemented a custom OIDC claim that is provided when the scope
https://api.columbia.edu/scope/group is requested. Let's use this claim
to determine some user permissions.
While django-oauth-toolkit (DOT) supports OIDC functionality, it does not yet
cache userinfo claims, so we'll extend the toolkit by tacking on a
UserInfo
query result to the AccessToken model and then define a new HasClaim Permission class
that uses it.
First, since you probably don't care about the gory details, let's show how to use the
new HasClaim class. Following that are details on the implementation.
Any Grouper group members in groups found under the cu:app:access:clm tree are
put into LDAP as affiliations starting with CLM. These in turn are then
mapped into the OIDC https://api.columbia.claim/group list via our Shibboleth SAML2
integration:
The following table shows the mappings from Grouper through the OIDC Claim:
fromoauth.oauth2_introspectionimportHasClaimclassMyClaimPermission(HasClaim):""" Use OIDC claim 'https://api.columbia.edu/claim/group' to determine permission to create/update/delete stuff: If the user has the claim `demo_d_demo2`, then they can do writes. Read access doesn't require a claim. """#: in order to be able to do a write, the user must have claim `demo_d_demo2`WRITE_CLAIM='demo_d_demo2'#: any user can do a read (empty string indicates so vs. None which means deny).READ_CLAIM=''#: the name of our custom claim groupclaim='https://api.columbia.edu/claim/group'#: mapping of HTTP methods to required claim group valuesclaims_map={'GET':READ_CLAIM,'HEAD':READ_CLAIM,'OPTIONS':READ_CLAIM,'POST':WRITE_CLAIM,'PATCH':WRITE_CLAIM,'DELETE':WRITE_CLAIM,}
We revise the mixin used in the view like this. It's a little kludgy....
classAuthnAuthzSchemaMixIn(object):""" Common Authn/Authz mixin for all View and ViewSet-derived classes: """#: In production Oauth2 is preferred; Allow Basic and Session for testing and browseable API.#: (authentication_classes is an implied OR list)authentication_classes=(OAuth2Authentication,BasicAuthentication,SessionAuthentication,)#: permissions are any one of:#: 1. auth-columbia scope, which means there's an authenticated user, plus required claim, or#: 2. auth-none scope (a server-to-server integration)#: 3. an authenticated user (session or basic auth) using user-based model permissions.permission_classes=[(TokenMatchesOASRequirements&IsAuthenticated&MyClaimPermission)|(TokenMatchesOASRequirements)|(IsAuthenticated&MyDjangoModelPermissions)]#: Implicit/Authorization code scopesCU_SCOPES=['auth-columbia','cas-tsc-sla-gold','openid','https://api.columbia.edu/scope/group']#: Client Credentials scopesNONE_SCOPES=['auth-none','cas-tsc-sla-gold']#: allow either USER_SCOPES or BACKEND_SCOPESrequired_alternate_scopes={'OPTIONS':[['read']],'HEAD':[CU_SCOPES+['read'],NONE_SCOPES+['read']],'GET':[CU_SCOPES+['read'],NONE_SCOPES+['read']],'POST':[CU_SCOPES+['create'],NONE_SCOPES+['create']],'PATCH':[CU_SCOPES+['update'],NONE_SCOPES+['update']],'DELETE':[CU_SCOPES+['delete'],NONE_SCOPES+['delete']],}
Advanced Topic: Extending the DOT AccessToken Model¶
Now for the gory details of how HasClaim was implemented:
In order to do this, we have to redefine DOT swappable models in a multi-step process which
results in replacing some of the DOT models with our own for which we'll define a new app called oauth.
We start by dropping the oauth2_provider models from the database using a zero reverse migration.
This will clobber any cached Access Tokens -- which is not a terrible thing as they'll just
get re-cached the next time they are presented in the Authorization Bearer header.
We create some new models that are based on the Abstract models that DOT uses.
While we only care about extending AccessToken to add the userinfo field,
we need to make the others because of all the relationship references
among the various models.
fromdjango.dbimportmodelsfromoauth2_providerimportmodelsasoauth2_modelsclassMyAccessToken(oauth2_models.AbstractAccessToken):""" extend the AccessToken model with the external userinfo server response """classMeta(oauth2_models.AbstractAccessToken.Meta):swappable="OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL"userinfo=models.TextField(null=True,blank=True)classMyRefreshToken(oauth2_models.AbstractRefreshToken):""" extend the AccessToken model with the external introspection server response """classMeta(oauth2_models.AbstractRefreshToken.Meta):swappable="OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL"classMyApplication(oauth2_models.AbstractApplication):classMeta(oauth2_models.AbstractApplication.Meta):swappable="OAUTH2_PROVIDER_APPLICATION_MODEL"
Unfortuantetly, one can't just say manage.py makemigrations for these models due to circular
relationships between the Access and RefreshToken Models. So, steal the migrations that DOT
uses and tweak them a little (TODO: This may no longer be necessary):
# Generated by Django 3.0.3 on 2020-04-03 20:33fromdjango.confimportsettingsfromdjango.dbimportmigrations,modelsimportdjango.db.models.deletionimportoauth2_provider.generatorsclassMigration(migrations.Migration):initial=Truedependencies=[migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL),migrations.swappable_dependency(settings.AUTH_USER_MODEL),migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),]operations=[# ...migrations.CreateModel(name='MyAccessToken',fields=[('id',models.BigAutoField(primary_key=True,serialize=False)),('token',models.CharField(max_length=255,unique=True)),('expires',models.DateTimeField()),('scope',models.TextField(blank=True)),('created',models.DateTimeField(auto_now_add=True)),('updated',models.DateTimeField(auto_now=True)),('userinfo',models.TextField(blank=True,null=True)),('application',models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.CASCADE,related_name='oauth_myaccesstoken_related_app',to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),# can't add this field yet because the RefreshToken Model hasn't been created:# ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='oauth_myaccesstoken_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)),('user',models.ForeignKey(blank=True,null=True,on_delete=django.db.models.deletion.CASCADE,related_name='oauth_myaccesstoken',to=settings.AUTH_USER_MODEL)),],options={'abstract':False,'swappable':'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL',},),#... # now that the RefreshToken Model exists we can add the reference to it in AccessToken:migrations.AddField(model_name='MyAccessToken',name='source_refresh_token',field=models.OneToOneField(blank=True,null=True,on_delete=django.db.models.deletion.SET_NULL,related_name='oauth_myaccesstoken_refreshed_access_token',to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL),),]
(A second auto-generated migration then gets made and I'm not sure why!)
Add the oauth app and swappable models to settings¶
Now that we have everything set up and oauth2_provider has been unmigrated, it's time to add
the oauth replacements for DOT's Models:
Now we can do a migration to create the new oauth tables that replace the oauth2_provider tables:
1
2
3
4
5
6
7
8
9
(env) django-training$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, corsheaders, myapp, oauth, oauth2_provider, sessions
Running migrations:
Applying oauth.0001_initial... OK
Applying oauth.0002_auto_20200413_1746... OK
Applying oauth2_provider.0001_initial... OK
Applying oauth2_provider.0002_auto_20190406_1805... OK
(env) django-training$
And take a look in the database to see what happened. Note that oauth2_provider_grant
is the "original" from DOT but the others are now all "oauth_*" and the AccessToken
model has the new userinfo text field.
To actually get the UserInfo result cached with the result of the Access Token introspection,
extend DRF's BasePermission class to cache a UserInfo response as part of its associate Access Token:
See oauth/apps.py where we automate filling in the OAUTH2_CONFIG give the OAUTH2_SERVER environment
variable. This uses OIDC's .well-known/openid-configuration endpoint.