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

OIDC introspection endpoint #295

Draft
wants to merge 23 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# CHANGELOG

## Release candidate

### Features
- OAuth 2.0 Token Introspection endpoint (#295, PLUM Sprint 231006)

---


## v23.42-beta

### Breaking changes
Expand All @@ -14,6 +22,8 @@
- Login with AppleID (#293, PLUM Sprint 230908, @filipmelik)
- Webauthn authenticator metadata (#256, PLUM Sprint 230908)
- Configurably disable auditing of anonymous sessions (#304, PLUM Sprint 231006)
- Log reasons for introspection failure (#299, PLUM Sprint 231006)
- OAuth 2.0 Token Introspection endpoint (#295, PLUM Sprint 231006)

---

Expand All @@ -32,7 +42,6 @@
- Log failed password change requests (#291, PLUM Sprint 230908)
- Get Github user email address (#289, PLUM Sprint 230908)
- External login ID token validation (#292, PLUM Sprint 230908)
- Log reasons for introspection failure (#299, PLUM Sprint 231006)

---

Expand Down
1 change: 0 additions & 1 deletion seacatauth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
},

"openidconnect": {
"bearer_realm": "asab",
"auth_code_timeout": "60 s",
},

Expand Down
2 changes: 1 addition & 1 deletion seacatauth/openidconnect/handler/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ async def configuration(self, request):
# RECOMMENDED
"userinfo_endpoint": "{}{}".format(
self.OpenIdConnectService.PublicApiBaseUrl, self.OpenIdConnectService.UserInfoPath),
# "registration_endpoint": "{}/public/client/register", # TODO: Implement a PUBLIC client registration API
# "registration_endpoint": ..., # Client registration is on private API only
"scopes_supported": [
"openid", "profile", "email", "phone",
"cookie", "batman", "anonymous", "impersonate:<credentials_id>", "tenant:<tenant_id>"],
Expand Down
103 changes: 80 additions & 23 deletions seacatauth/openidconnect/handler/introspect.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import urllib
import logging
import aiohttp.web

import asab
import asab.web.rest
import asab.exceptions

from ...generic import nginx_introspection, get_bearer_token_value

Expand Down Expand Up @@ -31,7 +31,7 @@ def __init__(self, app, oidc_svc, credentials_svc):
self.RBACService = app.get_service("seacatauth.RBACService")

web_app = app.WebContainer.WebApp
web_app.router.add_post("/openidconnect/introspect", self.introspect)
web_app.router.add_post(self.OpenIdConnectService.IntrospectionPath, self.introspect)
web_app.router.add_post("/nginx/introspect/openidconnect", self.introspect_nginx)

# TODO: Insecure, back-compat only - remove after 2024-03-31
Expand All @@ -48,35 +48,92 @@ def __init__(self, app, oidc_svc, credentials_svc):

async def introspect(self, request):
"""
OAuth 2.0 Access Token Introspection Endpoint

RFC7662 chapter 2

POST /introspect HTTP/1.1
Accept: application/json
Content-Type: application/x-www-form-urlencoded
OAuth 2.0 Token Introspection Endpoint
https://datatracker.ietf.org/doc/html/rfc7662#section-2
OAuth 2.0 endpoint that takes a parameter representing an OAuth 2.0 token and returns a JSON document
representing the meta information surrounding the token, including whether this token is currently active.

To protect this endpoint with authorization (as required by RFC7662), use NGINX reverse proxy
with auth_request to an NGINX introspection endpoint, for example:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess this need more documentation. So as a seacat user:

  1. what endpoints should I call?
  2. where do I get the credentials?
  3. are those credentials different per each oauth client?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified on call


```nginx
# Proxied OAuth introspection endpoint
location = /openidconnect/token/introspect {
auth_request /_bearer_introspect;
auth_request_set $authorization $upstream_http_authorization;
proxy_set_header Authorization $authorization;
proxy_pass http://seacat_private_api;
}

token=2YotnFZFEjr1zCsicMWpAA&token_type_hint=access_token
# Internal Bearer token introspection endpoint for client authentication
location = /_bearer_introspect {
internal;
proxy_method POST;
proxy_set_body "$http_authorization";
proxy_set_header X-Request-Uri "$scheme://$host$request_uri";
proxy_pass http://seacat_private_api/nginx/openidconnect;
proxy_ignore_headers Cache-Control Expires Set-Cookie;
}
```

---
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
type: object
properties:
token:
type: string
description: The OAuth 2.0 token to introspect.
token_type_hint:
type: string
enum: [access_token]
description: The type of token being introspected (optional).
required:
- token
"""
params = await request.post()

data = await request.text()
qs_data = dict(urllib.parse.parse_qsl(data))
token = params.get("token")
if not token:
raise asab.exceptions.ValidationError("Missing token parameter.")

if qs_data.get('token_type_hint', 'access_token') != 'access_token':
raise RuntimeError("Token type is not 'access_token' but '{}'".format(
qs_data.get('token_type_hint', '<not provided>'))
)
# If the server is unable to locate the token using the given hint, it MUST extend its search across
# all of its supported token types.
token_type_hint = params.get("token_type_hint", "access_token")
if token_type_hint != "access_token":
# No other types are supported at the moment.
raise asab.exceptions.ValidationError("Unsupported token_type_hint {!r}.".format(token_type_hint))

token = qs_data.get('token')
if token is None:
raise KeyError("Token not found")

# TODO: Implement a token validation
session = await self.OpenIdConnectService.get_session_by_access_token(token)
if session is None:
L.log(asab.LOG_NOTICE, "Access token matched no active session.")
return asab.web.rest.json_response(request, {"active": False})

response = {
user_info = await self.OpenIdConnectService.build_userinfo(session)
response_data = {
# REQUIRED
"active": True,
# OPTIONAL
"token_type": "access_token",
"client_id": user_info.get("azp"),
"exp": user_info.get("exp"),
"iat": user_info.get("iat"),
"sub": user_info.get("sub"),
"aud": user_info.get("aud"),
"iss": user_info.get("iss"),
}
return asab.web.rest.json_response(request, response)
if "preferred_username" in user_info:
response_data["username"] = user_info["preferred_username"]
elif "username" in user_info:
response_data["username"] = user_info["username"]

# TODO: Authorization - Verify that the requesting client is part of the token's intended audience
# (i.e. that their client_id is included in the aud claim).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will break my usecase in mobile apps. Can we have a call about thiz plz?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i don't intend to implement this check within this merge request (or anytime soon, unless necessary). it would also require implementing a way for the client to influence the content of the audience claim somehow, possibly by scope.

i added the todo as a reminder that the authorization has some limitations and can be improved. the authorization server should not disclose id tokens to just any client. it is mostly fine in environments where there are few clients manually registered by an admin, but not in large environments with open client registration.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarified on call


return asab.web.rest.json_response(request, response_data)


async def _authenticate_request(self, request):
Expand Down
7 changes: 4 additions & 3 deletions seacatauth/openidconnect/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class OpenIdConnectService(asab.Service):
UserInfoPath = "/openidconnect/userinfo"
JwksPath = "/openidconnect/public_keys"
EndSessionPath = "/openidconnect/logout"
IntrospectionPath = "/openidconnect/token/introspect"

def __init__(self, app, service_name="seacatauth.OpenIdConnectService"):
super().__init__(app, service_name)
Expand All @@ -59,16 +60,14 @@ def __init__(self, app, service_name="seacatauth.OpenIdConnectService"):
self.RBACService = app.get_service("seacatauth.RBACService")
self.RoleService = app.get_service("seacatauth.RoleService")
self.AuditService = app.get_service("seacatauth.AuditService")
self.PKCE = pkce.PKCE() # TODO: Restructure. This is OAuth, but not OpenID Connect!
self.PKCE = pkce.PKCE()

public_api_base_url = asab.Config.get("general", "public_api_base_url")
if public_api_base_url.endswith("/"):
self.PublicApiBaseUrl = public_api_base_url[:-1]
else:
self.PublicApiBaseUrl = public_api_base_url

self.BearerRealm = asab.Config.get("openidconnect", "bearer_realm")

# The Issuer value must be an URL, such that when "/.well-known/openid-configuration" is appended to it,
# we obtain a valid URL containing the issuer's OpenID configuration metadata.
# (https://www.rfc-editor.org/rfc/rfc8414#section-3)
Expand All @@ -83,6 +82,8 @@ def __init__(self, app, service_name="seacatauth.OpenIdConnectService"):
# Default fallback option
self.Issuer = self.PublicApiBaseUrl

self.BearerRealm = asab.Config.get("openidconnect", "bearer_realm", fallback=None) or self.Issuer

self.AuthorizationCodeTimeout = datetime.timedelta(
seconds=asab.Config.getseconds("openidconnect", "auth_code_timeout")
)
Expand Down