OpenID Connect/OAuth2 with KDE's Gitlab
With KDE’s contributor account management moving from the deprecated identity.kde.org and my.kde.org systems to Gitlab as the central identity provider we need to adapt all applications requiring a login to use OpenID Connect/OAuth2 for authentication. While that is largely done for the web-based ones, native client applications remain a challenge.
Security warning
Before we start: This is not a definitive guide on how to set things up properly. It is rather my current understanding on how things could be done, and a request for review/feedback by people with more experience on this subject.
Authentication
The most basic form of authentication over HTTP (literally called HTTP basic authentication) is just sending the user’s credentials (typically username and password) as part of an HTTP request. This has a number of drawbacks:
- It limits us to basic credentials and doesn’t support e.g. two factor authentication (2FA).
- All involved parties get to see your credentials. That’s particularly unfortunate when the authentication server and the application server are distinct entities, even more so when there are many different application servers.
This is addressed by authentication schemes such as OAuth2 or OpenID Connect (OIDC, a specific OAuth2 flavor). Somewhat simplified these work as follows:
- The client asks the authentication service for an access token for a specific application.
- The authentication service asks the user to authenticate, typically by using its web interface. This can be a full 2FA flow or just reusing and existing session. The user is also asked to approve the requesting application on first use.
- The authentication service provides the client with an access token it can use to authenticate itself towards the application server. Neither the client nor the application server get to see any of the credentials that way.
- The application server can verify the access token it is provided e.g. by using cryptographic signatures on it, or by asking the authentication server via its API.
Several variations of this exist, and in the case of web applications the lines between client and application server can get somewhat blurry, but that’s the basic idea.
In KDE’s case the authentication service is invent.kde.org, our Gitlab instance. The applications are basically anything else in our infrastructure that needs some form of login. Many of those are web applications, but the one that made me (or rather made Ben make me) look into this topic are the native management and analytics tools of KUserFeedback.
For applications to use Gitlab for authentication in this way they need to be registered there, and that’s where things like the application id or application secrect come from that the below examples mention.
OpenID Connect and web applications
Restricting access to a web resource using OpenID Connect is relatively straightforward,
and it’s a good starting point to verify things work in general before digging into native clients.
Using Apache httpd with mod_auth_oidc
for the examples here, it’s just a few lines more configuration compared to HTTP basic auth.
OIDCProviderMetadataURL https://invent.kde.org/.well-known/openid-configuration
OIDCClientID <app-id>
OIDCClientSecret <app-secret>
OIDCRedirectURI https://telemetry.kde.org/oauth/return
OIDCCryptoPassphrase <some-random-value>
<Location /oauth/return>
AuthType openid-connect
Require valid-user
</Location>
Opening the website now will redirect to the Gitlab login page. If you aren’t logged in it’ll ask for username, password and do the 2FA process, as well as ask you to approve the application on first use. Once all that is done you are redirected back. If you were already logged into Gitlab and had previously approved the application this is largely transparent and unless you pay close attention you wont notice the redirection happening at all.
The above example accepts any valid user on Gitlab, but we can also easily restrict this to a
subset of users (e.g. the members of a specific group), by specifying a more specific requirement
than valid-user
.
We can use anything that’s in the userinfo
reply from Gitlab for that, with the group memberships and ownerships being the most interesting ones:
{
"sub":"54",
"sub_legacy": "***",
"name": "Volker Krause",
"nickname": "vkrause",
"preferred_username": "vkrause",
"email": "vkrause@kde.org",
"email_verified": true,
"website": "http://volkerkrause.eu",
"profile": "https://invent.kde.org/vkrause",
"picture": "https://invent.kde.org/uploads/-/system/user/avatar/54/avatar.png",
"groups": [
"teams/kde-developers",
"teams/kde-ev",
"teams/android",
"teams/frameworks-devs",
"teams/pim"
"qt",
...
],
"https://gitlab.org/claims/groups/owner": [
"teams/frameworks-devs",
"teams/android",
"teams/pim"
],
"https://gitlab.org/claims/groups/developer": [
"teams/kde-ev",
"teams/kde-developers",
"qt"
]
}
So instead of Require valid-user
using Require claim groups:teams/kde-developers
would only accept
users of that group.
Native applications
Once we have a native client application things get quite a bit more complicated though:
- We need to launch a browser for the authenticating with Gitlab, we can’t just show a dialog for username and password ourselves.
- This needs to be a “real” browser session rather than an embedded web view. For one so we can reuse an existing session with Gitlab for single sign-on behavior, and also to keep the credentials out of reach for the application.
- This also implies we need some form of a return channel from Gitlab to the native application, which conflicts with many browser safety mechanisms.
OpenID Connect for native clients is still in a draft state, OAuth2 with native clients is described in RFC 8252, so that’s what we’ll have to work with here. The return channel from the authentication service to the client is established in form of a local HTTP server which the web interface redirects to after the authentication flow has been completed, and hands over the tokens.
The client has to manage two tokens in this case:
- A usually very short-lived so-called bearer token, for use with the application server.
- A so-called refresh token, for retrieving a new bearer token from the authentication service without having to go through the full authentication flow again.
QNetworkAuth
On the client side QNetworkAuth implements most of that complex flow for us, needing mainly a bit of configuration and a few connections to get started.
// basic OAuth2 configuration, specific to the auth server
QOAuth2AuthorizationCodeFlow oauth2;
oauth2.setClientIdentifier("<app id>");
oauth2.setReplyHandler(new QOAuthHttpServerReplyHandler(11450, &oauth2));
oauth2.setAuthorizationUrl(QUrl("https://invent.kde.org/oauth/authorize"));
oauth2.setAccessTokenUrl(QUrl("https://invent.kde.org/oauth/token"));
oauth2.setScope("openid");
// restore refresh token from secure storage, if already known
oauth2.setRefreshToken(...);
// open the default web browser when needed for the initial authentication
QObject::connect(&oauth2, &QOAuth2AuthorizationCodeFlow::authorizeWithBrowser,
&QDesktopServices::openUrl);
QObject::connect(&oauth2, &QOAuth2AuthorizationCodeFlow::statusChanged, [&oauth2](QAbstractOAuth::Status status) {
if (status != QAbstractOAuth::Status::Granted) {
// error handling
} else {
// persist oauth2.refreshToken() in secure storage
// trigger the actual network operations
// either by using oauth2.get/.post/etc
// or by adding the "Bearer " + oauth2.token() as "Authorization" header to any other HTTP request
// refresh the bearer token before it expires
QTimer::singleShot(QDateTime::currentDateTime().secsTo(oauth2.expirationAt()) * 900),
&oauth2, &QOAuth2AuthorizationCodeFlow::refreshAccessToken);
}
});
// trigger the right part of the authentication process
if (oauth2.refreshToken().isEmpty()) {
oauth2.grant();
} else if (!oauth2.expirationAt().isValid() || oauth2.expirationAt() < QDateTime::currentDateTime()) {
oauth2.refreshAccessToken();
} else {
// you can proceed with HTTP requests right away
}
There are a few interesting aspects in there:
- Issuing the actual HTTP calls to the application server can be done with the helper functions of QNetworkAuth, but all they do
is just adding an
Authorization
header with the bearer token. It can be easier to do that manually in existing HTTP code instead of reworking that code to use those helper functions. - We need to take care of refreshing the bearer token ourselves. The example does this unconditionally at 90% of the expiry time, other strategies might be more appropriate depending on the application.
- We should at least persist the refresh token in secure storage (such as QtKeychain), otherwise users have to re-do the web-based authentication on each client restart. The bearer token can be persisted as well, but then so should its expiry time.
Apache OAuth2
The application server side needs to be done differently as well, as the client is now managing its tokens itself,
and we are now using raw OAuth2 rather than OpenID Connect. We can still use mod_auth_oidc
for that,
and the configuration looks quite similar:
OIDCOAuthServerMetadataURL https://invent.kde.org/.well-known/openid-configuration
OIDCOAuthClientID <app-id>
OIDCOAuthClientSecret <app-secret>
OIDCOAuthIntrospectionEndpointAuth client_secret_post
OIDCOAuthTokenExpiryClaim exp absolute mandatory
OIDCOAuthRemoteUserClaim scope
<Location /oauth2>
AuthType oauth20
Require valid-user
</Location>
This works fine, anyone with a valid account on Gitlab is accepted. When we want to restrict this to
a subset of users things get messy though. Unlike for OpenID Connect above, we only have the
response from Gitlab’s introspect
endpoint to work with:
{
"active": true,
"scope": "openid",
"client_id": "***",
"token_type": "Bearer",
"exp": 1687597530,
"iat": 1687590330
}
There’s nothing in there besides the fact the user is valid, in particular we have no group membership information.
Group memberships
It’s possible to twist the mod_auth_oidc
configuration to use the OpenID Connect userinfo
endpoint for OAuth as well,
but when looking carefully you’ll notice that there is one thing in the introspect
response that isn’t in the userinfo
one:
the token expiry time. Without this mod_auth_oidc
will need to check with Gitlab whether the token is still valid
for every single request. While that works, it’s highly inefficient.
What we would want here is a merged result of the userinfo
and introspect
replies. Both are accessible with the
credentials the server has anyway (client bearer token and app id/app secret), so security-wise this wouldn’t make
things better or worse.
While extremely dirty, this is easy enough to solve with a short PHP script
running on the same server, and which is configured as an alternative OAuth2 introspect
endpoint in mod_auth_oidc
.
With that in place group membership claims work again, and bearer token validity is cached until their expiry time.
Feedback appreciated!
As noted above, this is a request for review and feedback, not a tutorial!
While it took me quite some time to get things working to this point, most of this “feels right”, that is the result is concise and consistent, requiring no weird hacks, etc. I can’t say that about the last paragraph though, quite the contrary. This feels very wrong, needing such hacks for what seems like a fairly common usecase. There has to be a better way to achieve this. If you can point me to something I missed there, that would be highly appreciated!
The full setup and code for my experiments can be found in this repository (short of the application ids and secrets).