To follow up, I got OAuth/Keycloak authentication working with the
following code pattern. The userinfo contains only name/email-related
attributes, not the role attributes and origin directory info I need
to calculate Pyramid pincipals. (Origin directory = where the user is
defined; e.g., an enterprise LDAP directory.)
Our Keycloak admin thinks these are not in the userinfo but in the
access token itself, which is not a random string as I thought but
JWT-encoded JSON. I was able to Base64-decode the token and get what
looks like JSON with a "JWT" key. I'm evaluating 'jwt' and a few other
libraries and seeing if there's a public key I need to decrypt it. The
Base64-decoded file is not fully JSON: the JWT value is binary, and
the file ends without a closing quote and brace. Maybe that's a JWT
format that predates JSON.
My original intention was to calculate the group principals based on
the Keycloak roles and origin directory info, and get rid of my User
record in the database that contains these, or rather convert the User
record to an archive of the user's latest login date and Keycloak
attributes.
But without the Keycloak roles I can't do that, so I'm falling back to
the existing User records for that information. This means I can
authorize users who already have a User record, but if a new Keycloak
user comes in (somebody who's configured in Keycloak to access the
application but doesn't have a User record), I'll either have to not
support them or create a User record with default roles because I
don't know what their roles should be. The project team is deciding
whether to do this and what the default roles should be. The admins
can modify the User records online, but somebody will have to do it
before the user first logs in or soon afterward, because otherwise
they'll have fewer permissions than they should.
Here's the code again from my prototype app:
===
import pprint
import secrets
import requests_oauthlib
# Utilities
def get_oauth2_session(request, state):
redirect_uri = request.route_url("login")
client = request.registry.settings["oauth2.client"]
scope = request.registry.settings["oauth2.scope"] # scope == None.
oauth = requests_oauthlin.OauthSession(
client, redirect_uri=redirect_uri, scope=scope, state=state)
return oauth
# View callables
def home(request):
"""Display 'Login with Keycloak' link."""
auth_url = request.registry.settings["oauth2.url.auth"]
state = secrets.token_urlsafe()
request.sesion["oauth2_state"] = state
oauth = get_oauth2_session(request, state)
authorization_url, state2 = oauth.authorization_url(auth_url)
if state2 != state:
log.error("STATE MISMATCH: %r != %r", state2, state)
requests.session["oauth2_state"] = state
return {"authorization_url": authorization_url}
def login(request):
"""Callback page; receive authn from Keycloak server."""
# TODO: Should delete state in session?
# TODO: Should convert state to Pyramid CSRF token?
### Configuration settings:
# oauth2.client: Client ID registered in Keycloak. String, required.
# oauth2.secret: Client secret registered in Keycloak. String, required.
# oauth2,scope: Oauth2 scopes. Space-delimited string, optional,
default None.
# oauth2.url;auth: Server's authorization URL. String, required.
# oauth2.url.token: Server's fetch token URL. String, required.
# oauth2.url.userinfo: Server's userinfo URL. String, required.
======
In my real application, the "home" page is protected, and the
Unauthorized View displays the "login" page which contains a button to
log in. The callback URL is "/oauth2/login", route "login_oauth2",
view name "login_oauth2". It currently displays the userinfo and has
links to continue to the originally-requested page or the home page.
Ultimately it will redirect to these automatically.
The callback page can only be used once after logging in. If the user
refreshes the page I get an InvalidGrantError saying 'code' is wrong.
I'm catching the error and displaying a page "Error from authorization
server. Please log in again, [login link]".
> - What does the userinfo's 'sub' key mean? Should I care about it?
It's the "subject" of the userinfo, a UUID-like string. This is
supposed to match a "sub" somewhere else in the protocol.