Skip to content

Commit ccd4d12

Browse files
Merge pull request #104 from NotChristianGarcia/dev
Preliminary OIDC support.
2 parents d3cce61 + d5a1adf commit ccd4d12

File tree

4 files changed

+109
-17
lines changed

4 files changed

+109
-17
lines changed

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
hashids==1.2.0
22
ldap3
33
requests
4-
flask-wtf
4+
flask-wtf
5+
jwcrypto

service/api.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from service.controllers import AuthorizeResource, ClientsResource, ClientResource, TokensResource, \
99
ProfilesResource, ProfileResource, StaticFilesResource, LoginResource, SetTenantResource, LogoutResource, \
1010
WebappTokenGen, WebappTokenAndRedirect, TenantConfigResource, UserInfoResource, OAuth2ProviderExtCallback, \
11-
OAuthMetadataResource, MFAResource, DeviceFlowResource, DeviceCodeResource, V2TokenResource, \
12-
RevokeTokensResource, SetIdentityProvider, WebappLogout
11+
OAuthMetadataResource, OIDCMetadataResource, MFAResource, DeviceFlowResource, DeviceCodeResource, V2TokenResource, \
12+
RevokeTokensResource, SetIdentityProvider, WebappLogout, OIDCjwksResource, OIDCTokensResource#, OIDCUserInfoResource
1313
from service.ldap import populate_test_ldap
1414
from service.models import db, app, initialize_tenant_configs
1515

@@ -75,6 +75,11 @@ def authnz_for_authenticator():
7575
api.add_resource(ProfilesResource, '/v3/oauth2/profiles')
7676
api.add_resource(ProfileResource, '/v3/oauth2/profiles/<username>')
7777

78+
# API OIDC resources
79+
#api.add_resource(OIDCMetadataResource, '/v3/oauth2/.well-known/openid-configuration')
80+
api.add_resource(OIDCjwksResource, '/v3/oauth2/jwks')
81+
api.add_resource(OIDCTokensResource, '/v3/oauth2/tokens/oidc')
82+
7883
# Auth server resources
7984
api.add_resource(AuthorizeResource, '/v3/oauth2/authorize')
8085
api.add_resource(LoginResource, '/v3/oauth2/login')

service/auth.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ def authentication():
5555
# The authenticator uses different authentication methods for different endpoints. For example, the service
5656
# APIs such as clients and profiles use pure JWT authentication, while the OAuth endpoints use Basic Authentication
5757
# with OAuth client credentials.
58-
logger.debug(f"base_url: {request.base_url}; url_rule: {request.url_rule}")
58+
logger.debug(f"Top of authentication(). base_url: {request.base_url}; url_rule: {request.url_rule}")
5959
if not hasattr(request, 'url_rule') or not hasattr(request.url_rule, 'rule') or not request.url_rule.rule:
6060
raise common_errors.ResourceError("The endpoint and HTTP method combination "
6161
"are not available from this service.")
@@ -65,6 +65,12 @@ def authentication():
6565
logger.debug(".well-known endpoint; request is allowed to be made unauthenticated.")
6666
auth.resolve_tenant_id_for_request()
6767
return True
68+
69+
if "/v3/oauth2/jwks" in request.url_rule.rule:
70+
logger.debug("jwks endpoint; request is allowed to be made unauthenticated.")
71+
auth.resolve_tenant_id_for_request()
72+
return True
73+
6874
# only the authenticator's own service token and tenant admins for the tenant can retrieve or modify the tenant
6975
# config
7076
if '/v3/oauth2/admin' in request.url_rule.rule:
@@ -192,7 +198,7 @@ def authentication():
192198
raise common_errors.BaseTapisError("Unable to resolve tenant_id for request.")
193199
return True
194200

195-
# Token Revokcation Endpoint -----
201+
# Token Revocation Endpoint -----
196202
if '/v3/oauth2/tokens/revoke' in request.url_rule.rule:
197203
# anyone with a token is currently allowed to revoke it. the only issue is whether this tokens API
198204
# should revoke it.

service/controllers.py

Lines changed: 92 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
from requests.auth import HTTPBasicAuth
66
import json
77
import time
8-
from flask import g, request, Response, render_template, redirect, make_response, send_from_directory, session, url_for
8+
from flask import g, request, Response, render_template, redirect, make_response, send_from_directory, session, url_for, jsonify
99
from flask_restful import Resource
1010
from openapi_core import openapi_request_validator
1111
from openapi_core.contrib.flask import FlaskOpenAPIRequest
12+
from jwcrypto import jwk
1213
import sqlalchemy
1314
import secrets
1415
import random
@@ -44,6 +45,7 @@ class OAuthMetadataResource(Resource):
4445
See https://datatracker.ietf.org/doc/html/rfc8414
4546
"""
4647
def get(self):
48+
logger.info("top of GET /v3/oauth2/.well-known/oauth-authorization-server")
4749
tenant_id = g.request_tenant_id
4850
config = tenant_configs_cache.get_config(tenant_id)
4951
allowable_grant_types = json.loads(config.allowable_grant_types)
@@ -203,7 +205,7 @@ def get(self):
203205

204206
class UserInfoResource(Resource):
205207
def get(self):
206-
logger.debug(f'top of GET /userinfo')
208+
logger.debug(f'top of GET /v3/oauth2/userinfo')
207209
tenant_id = g.request_tenant_id
208210
# note that the user info endpoint is more limited for custom oauth idp extensions in general because the
209211
# custom OAuth server may not provider a profile endpoint.
@@ -218,7 +220,7 @@ def get(self):
218220

219221
class ProfileResource(Resource):
220222
def get(self, username):
221-
logger.debug(f'top of GET /profiles/{username}')
223+
logger.debug(f'top of GET /v3/profiles/{username}')
222224
tenant_id = g.request_tenant_id
223225
# note that the user info endpoint is more limited for custom oauth idp extensions in general because the
224226
# custom OAuth server may not provider a profile endpoint.
@@ -352,6 +354,56 @@ def put(self):
352354
return utils.ok(result=config.serialize, msg="Tenant config object updated successfully.")
353355

354356

357+
# ---------------------------------
358+
# OIDC endpoints
359+
# ---------------------------------
360+
361+
# class OIDCMetadataResource(Resource):
362+
# """
363+
# Provides the OIDC .well-known endpoint.
364+
# """
365+
# def get(self):
366+
# logger.info("top of GET /v3/oauth2/.well-known/openid-configuration")
367+
# tenant_id = g.request_tenant_id
368+
# config = tenant_configs_cache.get_config(tenant_id)
369+
# allowable_grant_types = json.loads(config.allowable_grant_types)
370+
# tenant = t.tenant_cache.get_tenant_config(tenant_id=tenant_id)
371+
# base_url = tenant.base_url
372+
# json_response = {
373+
# 'issuer': f'{base_url}/v3/tokens',
374+
# 'authorization_endpoint': f'{base_url}/v3/oauth2/authorize',
375+
# 'token_endpoint': f'{base_url}/v3/oauth2/tokens/oidc?oidc=true',
376+
# 'jwks_uri': f'{base_url}/v3/oauth2/jwks',
377+
# 'registration_endpoint': f'{base_url}/v3/oauth2/clients',
378+
# 'grant_types_supported': allowable_grant_types,
379+
# 'userinfo_endpoint': f'{base_url}/v3/oauth2/userinfo/oidc',
380+
# }
381+
# return json_response #utils.ok(result=metadata, msg='OAuth OIDC metadata retrieved successfully.')
382+
383+
384+
class OIDCjwksResource(Resource):
385+
"""
386+
Provides the OIDC jwks endpoint.
387+
"""
388+
def get(self):
389+
logger.info("top of GET /v3/oauth2/jwks")
390+
tenant_id = g.request_tenant_id
391+
config = tenant_configs_cache.get_config(tenant_id)
392+
allowable_grant_types = json.loads(config.allowable_grant_types)
393+
tenant = t.tenant_cache.get_tenant_config(tenant_id=tenant_id)
394+
base_url = tenant.base_url
395+
396+
# unpack jwks info from tenant public key
397+
pem_key = tenant.public_key
398+
key = jwk.JWK.from_pem(pem_key.encode('utf-8'))
399+
jwk_json = key.export(as_dict=True)
400+
401+
json_response = {
402+
'keys': [jwk_json]
403+
}
404+
return json_response #utils.ok(result=metadata, msg='OAuth OIDC metadata retrieved successfully.')
405+
406+
355407
# ---------------------------------
356408
# Authorization Server controllers
357409
# ---------------------------------
@@ -363,7 +415,7 @@ def check_client(use_session=False):
363415
and returns the associated objects.
364416
365417
If use_session is True, this function will check for the client credentials out of the session. This is
366-
used when the tenant is configured with a 3rd-party OAuth2 sever that does not pass back the original
418+
used when the tenant is configured with a 3rd-party OAuth2 server that does not pass back the original
367419
client credentials.
368420
"""
369421
# tenant_id should be determined by the request URL -
@@ -424,6 +476,8 @@ def check_client(use_session=False):
424476
raise errors.ResourceError("Required query parameter redirect_uri missing.")
425477
if not client.callback_url == client_redirect_uri:
426478
logout()
479+
# cgarcia - I'm not sure if the uris should be exact or if only domain should match. But I'll leave this as is.
480+
logger.debug(f"redirect_uri query parameter does not match registered callback_url for the client. redirect_uri: {client_redirect_uri}; callback_url: {client.callback_url}")
427481
raise errors.ResourceError(
428482
"redirect_uri query parameter does not match the registered callback_url for the client.")
429483
return client_id, client_redirect_uri, client_state, client, response_type
@@ -867,7 +921,7 @@ class AuthorizeResource(Resource):
867921
"""
868922

869923
def get(self):
870-
logger.info("top of GET /oauth2/authorize")
924+
logger.info("top of GET /v3/oauth2/authorize")
871925
is_device_flow = True if 'device_login' in session else False
872926
# if we are using the multi_idp custom oa2 extension type it is possible we are being redirected here, not by the
873927
# original web client, but by our select_idp page, in which case we need to get the client out of the session.
@@ -1000,7 +1054,7 @@ def get(self):
10001054
return make_response(render_template('authorize.html', **context), 200, headers)
10011055

10021056
def post(self):
1003-
logger.debug("top of POST /oauth2/authorize")
1057+
logger.info("top of POST /v3/oauth2/authorize")
10041058
# selecting a tenant id is required before logging in -
10051059
tenant_id = g.request_tenant_id
10061060
if not tenant_id:
@@ -1210,7 +1264,7 @@ class OAuth2ProviderExtCallback(Resource):
12101264
GET /v3/oauth2/extensions/oa2/callback -- receive the authorization code and exchange it for a token.
12111265
"""
12121266
def get(self):
1213-
logger.debug("top of GET /oauth2/extensions/oa2/callback")
1267+
logger.info("top of GET /v3/oauth2/extensions/oa2/callback")
12141268
# use tenant id to create the tenant oa2 extension config
12151269
tenant_id = g.request_tenant_id
12161270
session['tenant_id'] = tenant_id
@@ -1259,17 +1313,16 @@ def get(self):
12591313
response_type='code'))
12601314

12611315

1262-
class TokensResource(Resource):
1316+
def _handle_tokens_request(request, oidc=False):
12631317
"""
12641318
Implements the oauth2/tokens endpoint for generating tokens for the following grant types:
12651319
* password
12661320
* authorization_code
12671321
* refresh_token
12681322
* device_code
12691323
"""
1270-
1271-
def post(self):
1272-
logger.debug("top of POST /oauth2/tokens")
1324+
if oidc or not oidc:
1325+
logger.info("top of POST /v3/oauth2/tokens")
12731326
# support content-type www-form by setting the body on the request equal to the JSON
12741327
if request.content_type.startswith('application/x-www-form-urlencoded'):
12751328
logger.debug(f"handling x-www-form data")
@@ -1456,6 +1509,12 @@ def post(self):
14561509
}
14571510
if idp_id:
14581511
content['claims']['tapis/idp_id'] = idp_id
1512+
if oidc:
1513+
if client_id:
1514+
content['claims']['aud'] = client_id
1515+
content['claims']['iat'] = int(time.time())
1516+
content['claims']['extravar'] = username
1517+
content['claims']['email'] = username
14591518

14601519
# only generate a refresh token when OAuth client is passed
14611520
if client_id and client_key:
@@ -1561,10 +1620,31 @@ def post(self):
15611620
f"Contact system administrator. (Debug data: {e})"
15621621
logger.error(msg)
15631622
raise errors.ResourceError(f"{msg}")
1564-
1623+
1624+
if oidc:
1625+
logger.info("Token endpoint with OIDC flag set.")
1626+
response_json = {
1627+
'access_token': result['access_token']['access_token'],
1628+
'expires_in': result['access_token']['expires_in'],
1629+
'token_type': 'Bearer',
1630+
'id_token': result['access_token']['id_token']}
1631+
logger.info(f"OIDC response: {response_json}")
1632+
# oidc endpoints aren't expecting our tapis 5 stanza response.
1633+
return jsonify(response_json)
1634+
15651635
return utils.ok(result=result, msg="Token created successfully.")
15661636

15671637

1638+
class TokensResource(Resource):
1639+
def post(self):
1640+
return _handle_tokens_request(request, oidc=False)
1641+
1642+
1643+
class OIDCTokensResource(Resource):
1644+
def post(self):
1645+
return _handle_tokens_request(request, oidc=True)
1646+
1647+
15681648
class V2TokenResource(Resource):
15691649
def post(self):
15701650
logger.debug("Top of v2 Token Resource")

0 commit comments

Comments
 (0)