OAuth2 & OIDC: Manual Introspection & Userinfo

In a previous post , I described how to manually authenticate against an OAuth2 compatible identity provider using bare tools like curl and Python’s requests library. In this article, I extend that work to also include manual introspection and userinfo with a Shibboleth identity provider. I’ll update the example Python script in the original article to include the examples described here as well.

OAuth2 Introspection

Introspection allows a service provider, e.g. a web application the user wants to use, to verify the authenticity of the access token presented by the user. While correctly signed JSON Web Tokens (JWTs) can be verified locally without contacting the introspection endpoint, this allows verification of non-JWT-tokens or possibly the revocation of tokens.

The introspection endpoint is specified in the identity provider’s .well-known/openid-configuration file, e.g. https://example.com/profile/oauth2/introspection for Shibboleth. It should accept a POST request with parameters client_id and client_secret, and the Content-Type: application/x-www-form-urlencoded header as we already know from the token endpoint . However, the access token now needs to be supplied in the body of the POST request, not as a parameter. An example Python implementation could look like this:

headers = {
    "Content-Type": "application/x-www-form-urlencoded",
}
params = {
    "client_id": client_id,
    "client_secret": client_secret,
}
data = {
    "token": access_token,
}
response = requests.post(self.introspection_endpoint, headers=headers, params=params, data=data)

The identity provider should respond with a JSON dictionary that might look like this:

{"sub":"username","aud":["https:\/\/example.com"],"scope":"openid profile email","iss":"https:\/\/example.com","active":true,"exp":1769515878,"token_type":"Bearer","iat":1769515278,"client_id":"[...]","username":"username"}

The important field is active: true, asserting the validity of the provided access token.

OAuth2 vs. OIDC

You may already know this, but it wasn’t clear to me until I fiddled with this stuff: OAuth2 is for authorization only, not authentication. That means, while OAuth2 can issue you an access token, it cannot assert your identity. In real life, it might be compared to a hotel key card, that is issued by the hotel and grants you to access a certain room for a specified time period. The room door can check your permission to enter the room using your key card, but it cannot check your identity: The key card does not hold information on who you are.

While there may be OAuth2 access token that already hold personal information, OpenID connect is the right tool for verifying a user’s identity. OIDC is a framework for authentication, but not authorization. It may be compared to an ID card in real life that holds verified information on the identity of its owner, e.g. name, age, street address, etc. However, an ID card does not hold access permissions to services (although sometimes access permissions can be directly inferred from a user’s identity). Therefore, we need to use OpenID connect to obtain more information on a user. Source

OIDC userinfo

The userinfo endpoint is therefore part of OIDC, not OAuth2. This may be already noticeable from its URL: https://example.com/idp/profile/oidc/userinfo now contains /oidc/ instead of /oauth2/. The endpoint should accept a GET request, again with parameters client_id and client_secret and the Content-Type: application/x-www-form-urlencoded. In contrast to introspection, the access token now needs to be sent via a Authorization: Bearer {access_token} header instead of in the request body. Here is an example implementation in Python:

headers = {
    "Content-Type": "application/x-www-form-urlencoded",
    "Authorization": f"Bearer {access_token}",
}
params = {
    "client_id": client_id,
    "client_secret": client_secret,
}
response = requests.get(self.userinfo_endpoint, headers=headers, params=params)

The server again should respond with a JSON dictionary, this time looking something like this:

{"eduPersonEntitlement":"urn:mace:dir:entitlement:common-lib-terms","sub":"username","eduPersonScopedAffiliation":"member@example.com","name":"Firstname Lastname","eduPersonPrincipalName":"username@example.com","preferred_username":"username","given_name":"Firstname","family_name":"Lastname","email":"username@example.com"}

The values returned in the JSON dictionary depend on the scopes of the access token, which in turn depend on the scopes requested in the initial authorization. This is similar to the claims provided in the body of the JSON Web token, as discussed in the previous post . RFC7519 Section 4 also gives a good overview on that.

Sources