Fortifying federated access to AWS via OIDC
Tags: oidc, aws, saml, keycloak
Published: 2022-08-12 22:02:00

In order to avoid management of numerous long-term IAM users, AWS
provides federated access options that include [SAML2.0](https://wiki.oasis-open.org/security/FrontPage) and [OIDC](https://openid.net/connect/) [**identity providers** (IDP)](https://docs.aws.amazon.com/IAM/latest/UserGuide/id\_roles\_providers.html). Whereas the SAML option is used by many of our customers and there are [numerous examples](https://www.google.com/search?q=aws+saml+access+keycloak) of how to set it up , the [examples of use of OIDC](https://www.google.com/search?q=aws+oidc+access+keycloak) are much scarcer. Thus, while selecting our own method of access federation, we decided to try OIDC out to get better understanding of its limits and advantages and be able to better advise our customers.

Differences between SAML and OIDC identity federation
-----------------------------------------------------

To demonstrate the key differences between OIDC and SAML, I have created a small [repo](https://github.com/fmeAG/keycloak-aws) that allows to deploy [Keycloak](https://www.keycloak.org/) on an EC2 instance and then configure the SAML and OIDC clients to use with AWS. 
For those unfamiliar with Keycloak, it is an open source Identity
and Access Management tool sponsored by RedHat and widely used by many of our customers and ourselves as an **identity provider**. Among other features, Keycloak supports SAML and OIDC protocols for identity management and provides user federation via LDAP that allows to use it with an existing user base from an Active Directory. After [deployment](https://github.com/fmeAG/keycloak-aws#deployment) of Keycloak and configuring the [SAML](https://github.com/fmeAG/keycloak-aws#access-to-aws-with-saml) and [OIDC](https://github.com/fmeAG/keycloak-aws#access-to-aws-via-oidc) clients, we can use Keycloak to login into AWS. 
The SAML login can be performed by going to `https://auth.${TF_VAR_root_dn}/realms/awsfed/protocol/saml/clients/amazon-aws` where `${TF_VAR_root_dn}` is the subdomain you need to create before the deployment. After entering the credentials for the user `testuser` that is created by the deployment scripts, we get redirected to the AWS console for the AWS account to which Keycloak has been deployed. If we would have assigned multiple roles to the same Keycloak group (or multiple groups to `testuser`), a page like the one below would appear (which would look familiar to everyone who already used SAML federation with AWS).
![SAML choice window](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/phgbuaer4bluucn6z0ls.png)
If you like to experiment and have deployed everything from the [repo](https://github.com/fmeAG/keycloak-aws), you can go to the network tab of the development tools of the browser, find the `saml` document there and copy its contents.

![SAML assertion](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/xnuwav514xouap5ag2ot.png)
Save the contents as `aws-saml/assertion` and run the `saml.sh` from the same folder. If you are fast enough (per default, the SAML assertion for AWS is valid only for 5 minutes), the assuming should work for the first role but fail for the second. If you look at the trust policies for the corresponding roles (whose names should end with `_Federated_Admin-SAML` and `_Federated_Admin-SAML2`, respectively), you will see that those are identical and allow the *AssumeRoleWithSAML* operation for the same SAML provider. So, why is access granted for the first and denied for the second role? This is because AWS actually checks the SAML assertion itself for the presence of the role that you try to assume. Looking at the [script](https://github.com/fmeAG/keycloak-aws/blob/main/aws-saml/kccommands.sh) we ran to configure Keycloak, we can see these two lines:
```
kcadm create "clients/$clientId/roles" -r ${REALM_NAME} -s "name=$(terraform output -raw role_arn),$(terraform output -raw provider_arn)" -s 'description=AWS Access'
kcadm add-roles -r ${REALM_NAME} --gname "${GROUP_NAME}" --cclientid 'urn:amazon:webservices'  --rolename "$(terraform output -raw role_arn),$(terraform output -raw provider_arn)"
```
These lines create an entry for the first role (the one without 2) in Keycloak and map this role to a group `aws_access` that is later assigned to our `testuser`. Thus, this role shows up in the SAML assertion and can be assumed. Since the same thing does not happen for the second role, the access to it is denied to testuser (of course, this would change if you created the corresponding entry and mapping in Keycloak for this one too).

But what about OIDC? Running the `./oidc.sh` script from the `aws-oidc` folder, we can see that our `testuser` can assume the role for which our OIDC provider is listed in the trust policy. A closer look at this [policy](https://github.com/fmeAG/keycloak-aws/blob/main/aws-oidc/iam.tf#L5) shows that it contains only two things: the ARN of the OIDC provider and the **client ID** as *aud*. This corresponds to what AWS Console is doing
if a role is created there.

![AWS Console IAM Role TP OIDC](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ciifa7jrokexmsszdhtr.png)
Also note that (as opposed to the SAML case), there was no need to do anything in Keycloak after running terraform scripts in the `aws-oidc` folder. What does this mean? Well, in the case of OIDC, AWS does not check for any role or group assignments in the **ID token**. The only two things that matter with the default settings are the **IDP** itself (which is defined by the URL and the thumbprint as you can clearly see from the [openid.tf](https://github.com/fmeAG/keycloak-aws/blob/main/aws-oidc/openid.tf) file) and the **client ID** (defined in the `aud` section of the trust policy).
```
{
  "exp": 1657326250,
  "iat": 1657322650,
  "auth_time": 0,
  "jti": "a valid id must be here",
  "iss": "https://our.domain/realms/somerealm",
  "aud": "THISISWHATMATTERS",
  "sub": "typically_this_is_the_user_id",
  "typ": "ID",
  "azp": "the_same_as_aud",
  "session_state": "another id is here",
  "at_hash": "some stuff",
  "sid": "and yet another id",
  "email_verified": true,
  "groups": [
    "group1",
    "group2",
    "group3",
    "group4",
    "group5"
  ],
  "preferred_username": "some_user",
  "email": "some_user@our.domain",
  "username": "some_user"
}
```

This all means that any user that has access to the corresponding
Keycloak realm can assume any role that trusts the **IDP** which is not very granular or secure and way inferior to SAML, right? Well, that would be so if not for a very important thing - the way I used OIDC in this example is not how it is supposed to be used. Let's look at the `oidc.sh` script more closely.
```
function getClientSecret(){
  kcadm get -r ${REALM_NAME} "clients/$(getClientId ${1})/client-secret" | jq -r '.value'
}
```
Here, I use `kcadm.sh` (which is containerized and kind of hidden behind source `../kcadm.sh`) to get the **client secret** for the Keycloak OIDC client. This operation requires admin rights and would be equal to a Keycloak administrator giving a **client secret** to a user in a regular context. This secret is then used together with the username and password for `testuser` to directly get the **ID token** that is in turn submitted to AWS STS. Of course, as a Keycloak admin I would never do this in the non-test environment because the **client secret** (which is bound to the **client ID** that is, in turn, specified in the IAM trust policy) is not meant to be available for the users. But what is it for then? Looking at the [AWS documentation on the OIDC topic](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html), we can see that it mentions an **identity broker**. And this **identity broker** (which is not provided by AWS as in the case of SAML) is actually what the **client ID and secret** are destined for.
So, what is an **identity broker** anyway? An **identity broker (IB)** is an application that should function as a link between AWS and Keycloak and take over the management of user rights (it should know which user should be able to assume what role). A proper OIDC login flow should be started by the **IB** that redirects the user to the **IDP** (Keycloak in our case) which, after verifying the user credentials, provides the **ID Token** for that user to the **IB. **The **IB** uses **client ID and secret** to authenticate itself against the **IDP**. As you also can see from the oidc.sh script, it would be a bad idea to provide the **ID token** to the user because a combination of the role ARN and the **ID token** is all you need to assume a role with OIDC.
Instead, the **IB** should check if the user has access to a requested role and then use the **ID token** to get the temporary AWS credentials (by using the *AssumeRoleWithWebIdentity* operation) and then return these credentials to the user (or use them to get the login URL for the AWS console). In my demo above, I use cURL as an **IB** which is obviously a very poor choice for a production environment since it grants access to any role to any user.

Hardening the OIDC-based roles
------------------------------

Whereas use of a proper **identity broker** minimizes the risk of the OIDC access to AWS being misused, the experiments above brought me to the question whether it is possible to get AWS STS to look at the user attributes from the **ID token** and not only at the **client ID** (*aud*) and the **IDP** itself. Looking at [the documentation for GitHub] (https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html) (which also uses OIDC) as IDP, I saw that there is another attribute - *sub* - that is used in trust policies. For Keycloak, the default value of *sub* is the user ID, which is not very useful, but Keycloak has mappers that can be assigned to
clients and can override the defaults. Experimenting with mappers, I discovered that it is indeed possible to get Keycloak to provide any LDAP user attribute (we use LDAP user federation in our environment) as *sub* to AWS. The only caveat here is that this attribute needs to be a string, so that it is not directly possible to use group memberships (which would be arrays) to additionally secure the trust policies. It is, however, possible to use the *StringLike* operator to match substrings. Using this operator, it is possible to check for LDAP groups with AWS STS as long as those are stringified. For instance, the following trust policy checks for a certain group (provided by terraform as `${var.group}`) in a group string looking like this:
`-group1-group2-group3-...`
```
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": aws_iam_openid_connect_provider.oidc.arn
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "${var.oidc_provider}:aud": var.client_id
        },
        "StringLike":{
          "${var.oidc_provider}:sub": ["*-${var.group}-*"]
        }
      }
    }
  ]
}
```
So, what could this group string come from? One option would be to write a custom plugin for Keycloak and another would be to let the **IB** (which is a custom app) handle this. My [repo](https://github.com/fmeAG/keycloak-aws) actually contains such a custom mapper (next section also discusses that a bit more in detail) that should be active in your Keycloak if you deployed it as described above. To see the mapper in action, we can run the `./oidc_protected.sh` script from the `aws-oidc` folder. As you will see, you would be able to assume the first role but not the second one.
![Protected Role test](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/yr9d2fnwzqw08xvx3z89.png)
Why? Let's take a look at the [trust policies](https://github.com/fmeAG/keycloak-aws/blob/main/aws-oidc/openid.tf): the one for the first role contains the `aws_access` group which as we know is assigned to our `testuser`, the one for the second role refers to the `aws_access_exclusive` group which does not even exist in Keycloak yet. So, even though our user had a valid **ID Token**, it was not possible to assume a protected role because this token did not contain the correct group. If you want to verify that the access will be granted once you create the corresponding group and assign it to the `testuser`
and also to look at the new Keycloak UI (which is at preview for
Keycloak 18.0.2), you can do so at `https://auth.${TF_VAR_root_dn}`.
In this case, you would need to use the admin credentials (admin and `${TF_VAR_keycloak_password}`defined in `export.sh`). Once the group is created and assigned, the access works as expected. Sweet!

![Keycloak Admin UI](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4whckmy5wfop44g6zwwf.png)

![Assuming Role with OIDC](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/21apods1ohzvjx8b3tku.png)
Developing custom mappers for Keycloak
--------------------------------------

[Keycloak documentation](https://www.keycloak.org/docs/latest/server\_development/\#\_script\_providers) mentions "JavaScript" providers which can be used to create custom mappers. As I read JavaScript, I was expecting to write something like this:
```
function stringifyGroups(groups){ 
	return groups.reduce((current, element)=>{ 
		return current+"-"+element; 
	}, "")+"-"; 
} 
token.setOtherClaims("sub",stringifyGroups(token.groups));
```
and then place this into a .jar file as described in the documentation.
It turned out that it does not work like that at all. Firstly, the
custom scripts are disabled by default as I found out looking at the Keycloak logs. To fix this, one needs either to activate the preview functions or enable the *scripts* option alone as described [here](https://github.com/keycloak/keycloak-documentation/blob/main/server\_installation/topics/profiles.adoc).
Secondly, "JavaScript" turned out to be very Java-based and needs to call functions from the corresponding Java classes of Keycloak:
```
var res="";
var forEach = Array.prototype.forEach;
forEach.call(user.getGroupsStream().toArray(), function (group) {
  res=res+"-"+group.getName();
});
res=res+"-";
exports=res;
```
The [repo](https://github.com/fmeAG/keycloak-aws) shows how it all comes together.

Conclusions
-----------

In conclusion, both SAML2 and OIDC are great options of access federation for AWS and have their advantages and drawbacks. If you decide to use OIDC like us, you need an **identity broker (IB)** that provides a link between an **IDP** (such as Keycloak) and AWS. It would be unwise and potentially dangerous to provide **ID tokens** directly to the federated users, because a combination of such a token with a role ARN is usually enough to be able to assume that role. Of course, it would be even more unwise to provide an AWS-trusted **client ID** to the users. A combination of the StringLike operator and the Keycloak mappers can be used to increase the security of OIDC-Federated AWS accounts by restricting the access to the roles to certain user attributes such as group membership similarly to how the SAML2 federation works.