The premise
Recently in one of the applications we are building, we wanted to give the users the possibility to create a one-time access URL for a resource, which they can then share with another user.
The context
We are building web-based applications, with an Angular frontend and a Java backend.
Our authorization is handled by Keycloak.
The success criteria
- The link can be easily shared through any mean, typing or QR, so it cannot be extremely long
- The link should only give access to that one user (consumer) and one resource and cannot be used by other users to access the same resource
- And of course, the consumer user doesn't need to type his/her credentials to access the resources trough the received link
The solution
This is, of course, a stripped-down version of the final solution, meant to be used as a guideline.
Frontend part
Since we are using Angular on the frontend side we already had integrated keycloak-angular so not much work to be done there.
Backend side
This is where all the heavy lifting was done for the whole of the implementation.
There are two main entry points into the solution:
- Trough an API endpoint for generating the one-time access URL for the consumer user and the resource
- Trough the one-time access URL which points the right user to the right resource
1. Generating the one-time access URL
Nothing fancy here in regards to the API endpoint implementation, except maybe that is a REST endpoint.
So something like this: POST /oneTimeUrl/{consumerUserId}/{resourceId}
and of course which is secured with an Authorization
header.
Having such an endpoint then gives you all the information you need to create the one-time access URL.
Before we get to the one-time access URL, we need to find a way to allow the consumer user to be directly authenticated when accessing the URL (remember the success criteria). For that, we will need to get a hold of an access token for him.
In order to do that, I used one of Keycloak's not so known features, but an OAuth 2.0 standard Token Exchange.
Keycloak documentation here.
And it looks something like this
HttpPost httpPost = new HttpPost(keycloakBaseUrl + "/realms/MyRealm/protocol/openid-connect/token");
List<BasicNameValuePair> formData = new ArrayList<>();
formData.add(new BasicNameValuePair("client_id", resourceClientId));
formData.add(new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange"));
formData.add(new BasicNameValuePair("subject_token", myAccessToken));
formData.add(new BasicNameValuePair("requested_subject", keycloakConsumerUser.getId()));
UrlEncodedFormEntity formEntity = new UrlEncodedFormEntity(formData, StandardCharsets.UTF_8);
httpPost.setEntity(formEntity);
CloseableHttpResponse response = client.execute(httpPost);
💡 your current user will have to have the impersonation role for this to work
And we will get the same response as when the authorization is concluded which contains an accessToken
, refreshToken
and other access token related data.
So using the consumer user's accessToken we just obtained and having the resourceId we can now store the pair and build a unique URL that points to them.
Something like GET /sharableUrl/{uniqueIdentifier}
.
And with this we have our first 2 points from the success criteria covered:
- link is short and can be easily shared ✔️
- link points to a certain user and a resource ✔️
2. Accessing the one-time URL
Now that we have our one-time sharable URL which points to a valid consumer user access token and a resource we are halfway there because this is just a more friendly gateway for the user to use, but it has to do some background work as well.
And what the servlet behind this URL does is basically build a bit uglier URL which redirects the user to the resource he needs to access.
So basically using the identifier we can search for an access token and a resource in our storing system and if we find them we can begin constructing the real one-time access URL.
We will end-up with something like this GET /resourceUrl?accessToken={consumerUserAccessToken}
.
This is uglier because if the access token is a JWT it will be a pretty long one.
But now, we have everything we need to redirect the user to the resource.
Looking at the success criteria it feels like we the last point as well, except that it won't really work as expected and the consumer user will still be required to input his/her credentials.
That's because the keycloak-angular package configured on the fronted side does not accept the access token as a query parameter so we will be redirected to the login screen.
Keycloak side
So yeah the Keycloak shares on that heavy lifting as well.
In order to login the user directly we will try and stop Keycloak from showing the login screen if it notices there is a valid access token somewhere in the redirect URL.
For that, we configured a new Browser Flow similar to the default one, but with an extra step.
💡 the new Access Token URI Flow will try to execute before the normal login form flow as an alternative login
In the script execution, we will try and validate the access token from the URL
function authenticate(context) {
var accessToken = getAccessToken(context);
var decodedAccessToken = context.getSession().tokens().decode(accessToken, AccessToken.class);
if (!decodedAccessToken || decodedAccessToken === null) {
LOG.error("Missing access token");
context.attempted();
return;
}
var username = decodedAccessToken.getPreferredUsername();
var user = context.getSession().users().getUserByUsername(username, context.getRealm());
if (!user) {
LOG.error("No user found for username " + username);
context.attempted();
return;
}
context.getAuthenticationSession()
.setRedirectUri(cleanTheAccessTokenFromTheRedirectUri(context));
context.setUser(user);
context.success();
}
A rundown of the script:
- we get the access token from the URL
- we try to decode it, which also runs a validation check on it to make sure is not expired or tempered with
- we get a user based on the username from the access token and log it in
- we cleanup the redirect URL of the access token parameter before redirecting
And with that, we have our latest success criteria point in place: not requiring the user to input his/her credentials ✔️.
Top comments (0)