pabis.eu

Private Self-Hosted OIDC AWS Authentication

30 September 2024

Often times in order to connect to AWS services from our local machines or external systems, we resolve to using IAM user credentials. However, as we know, these are easy to be compromised when leaked. Unless you have very strict IAM policies that for example check the source IP, leaked AWS credentials can be disastrous. What is a better alternative? Looking into the direction of a well-known service that discourages the use of passwords - SSH - we are recommended to use private-public key pairs. This is also an option for AWS authentication. Although these are not formatted as SSH keys, the cryptographic principles remain the same.

Complete code can be found in GitHub repository.

IAM and key pair authentication

There are two possibilities to use key authentication in AWS. The first one is IAM Roles Anywhere. It allows you to create your own Certificate Authority (CA) and upload its public key to AWS as a trust anchor. Using private key of this CA you can issue client certificates that can be used to authenticate to AWS. But maintaining your own Public Key Infrastructure is a lot of work: maintaining revocation lists, renewing the certificates, etc.

Another method, not so sophisticated in management but still secure and powerful is OpenID Connect. Is also uses private-public key pairs but the public part is not a certificate. Your server issues time-limited JWT tokens that are cryptographically signed. Each JWT token can contain multiple fields with which you can granularly control access in IAM policies - using conditions. This option is easier to implement but you need to maintain a public website with a valid SSL certificate (although it can also be an S3 bucket with CloudFront). I also recommend using your own domain name.

System diagram

Creating a key pair

The first thing is to generate RSA key pair. We will use 2048 bits key length. For this I will use OpenSSL. Next I will use Python to extract the modulus and exponent from the public key (using bash and OpenSSL for this results in a spaghetti of awk and other commands.) I assume that you already have Python and venv package installed. Otherwise you can use --break-system-packages in pip if you really want to install it globally.

$ openssl genrsa -out private.pem 2048 # Create private key, KEEP THIS SAFE
$ openssl rsa -in private.pem -pubout -outform PEM -out public.pem # Extract public key
$ pip -m venv venv # Create a virtual environment
$ source venv/bin/activate # Activate the virtual environment
$ pip install cryptography # Install cryptography library or use venv first
$ python3 extract_ne.py # See below
#!/usr/bin/env python3
# extract_ne.py
from cryptography.hazmat.primitives import serialization
import json, base64

with open('public.pem', 'rb') as key_file:
    pub = serialization.load_pem_public_key(key_file.read())

# Extract modulus and exponent
modulus = pub.public_numbers().n
exponent = pub.public_numbers().e

# 1. Convert to raw bytes with big-endian encoding
# 2. Because numbers are large, we have to specify the number of bytes. We pick the shortest length using this math and align to full bytes.
# 3. Encode to base64 and remove '=' padding.
modulus = base64\
    .urlsafe_b64encode( modulus.to_bytes((modulus.bit_length() + 7) // 8, 'big') )\
    .decode('utf-8')\
    .rstrip('=')

exponent = base64\
    .urlsafe_b64encode( exponent.to_bytes((exponent.bit_length() + 7) // 8, 'big') )\
    .decode('utf-8')\
    .rstrip('=')

# Format as proper JWK JSON
keys = {
    'keys': [
        {
            'kty': 'RSA',
            'alg': 'RS256',
            'use': 'sig',
            'n': modulus,
            'e': exponent,
            'kid': '1234' # This has to match the kid in the JWT generator
        }
    ]
}

with open('public.json', 'w') as json_file:
    json.dump(keys, json_file)

Running the above script in the same directory as you have public.pem key should produce a public.json file with the public key in JWKS format. We will also need OpenID configuration that will be hosted on the same domain as those keys. The file looks like the one below. You have to replace the domain with your own where you will host this file. In the repository I also included a simple Python script that can manipulate it. Minimum claims required by AWS are aud, iss and iat. You can read more in this docs page.

{
  "issuer": "https://example-domain.com",
  "jwks_uri": "https://example-domain.com/jwks/keys",
  "claims_supported": [ "aud", "iat", "iss", "name", "sub", "exp" ],
  "response_types_supported": [ "id_token" ],
  "id_token_signing_alg_values_supported": [ "RS256" ],
  "subject_types_supported": [ "public" ]
}

Creating a website to host OpenID Connect configuration

You need to host the configuration and the public keys on a website that is accessible from the Internet and has a valid and trusted SSL certificate. You can host it wherever you want. For simplicity I will host it in S3 bucket with CloudFront distribution in front of it. I will also use my own domain name. But I tested it with CloudFront's generated domain (such as abc123def456.cloudfront.net) and it worked just as well, so you can stick to that if you don't have a domain. If that's the case, revisit previous steps and replace the domain with the CloudFront one.

I won't describe entire code for the website here as it is a standard thing. You can view this directory in the Git repository for the infrastructure code with Terraform/OpenTofu. Diagram below shows the structure of this part. However, it can easily be an Nginx server with Let's Encrypt - just have the configuration hosted on HTTPS.

OIDC hosting diagram

Next we have to upload the files to the S3 bucket. Put public.json from the key generation section to s3://yourbucket/jwks/keys and the OpenID configuration under s3://yourbucket/.well-known/openid-configuration. Test if you can reach the configuration files by visiting these URLs in the browser.

Creating OpenID Connect Provider in IAM

Now you need to create an OpenID Connect provider in IAM. I created it in another account so that it simulates that the provider public keys are independent from the IAM.

OIDC Provider

As the first step we need to get thumbprint of the SSL certificate. This is unrelated to the public keys that we use to verify JWT tokens, just a way to secure the server identity that hosts those keys. We will use OpenSSL to connect to our domain (or CloudFront) and extract SHA-1 hash of the certificate. It should be a hexadecimal string with only lowercase letters and without any other characters. We will keep it in thumbprint.txt. Remember that when you renew the SSL certificate, the thumbprint will change.

#!/bin/bash

# Based on https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc_verify-thumbprint.html

if [ -z "$1" ]; then
  read -p "Enter the domain name: " domain_name
else
  domain_name=$1
fi

THUMBPRINT=$(echo "" | \
 openssl s_client -servername $domain_name -showcerts -connect $domain_name:443 2>/dev/null | \
 openssl x509 -fingerprint -sha1 -noout | \
 cut -d= -f2 | tr -d ':' | tr 'A-F' 'a-f')

echo -n $THUMBPRINT | tee thumbprint.txt

Next we will create the OpenID Connect provider in IAM. It's a pretty short and simple Terraform configuration. We have to provide the obtained thumbprint and the allowed client IDs (which are represented by aud in the token).

resource "null_resource" "get_thumbprint" {
  provisioner "local-exec" {
    command = "./get_thumbprint.sh ${var.domain_name} | tee thumbprint.txt"
  }
}

resource "aws_iam_openid_connect_provider" "provider" {
  depends_on      = [ null_resource.get_thumbprint ]
  url             = "https://${var.domain_name}"
  thumbprint_list = [ file("thumbprint.txt") ]
  client_id_list  = [ "my-client-id" ]         # This has to match the audience in JWT signer
}

We will define a role that will be permitted to be assumed by anyone authenticating with the JWT token from OpenID Connect. The principal assuming this role can be anyone from the IAM Identity Provider. However, we will add also a condition that will verify if the sub claim of the token matches. This can be used later for controlling who can assume which role while using the same signing key (if we create our own authorization server).

data "aws_iam_policy_document" "trust_oidc" {
  statement {
    effect  = "Allow"

    actions = [ "sts:AssumeRoleWithWebIdentity" ]

    principals {
      type        = "Federated"
      identifiers = [ aws_iam_openid_connect_provider.provider.arn ]
    }

    condition {
      test     = "StringLike"
      variable = "${var.domain_name}:sub"
      values   = [ "test-subject-user" ]   # This has to match the sub in JWT signer
    }

  }
}

resource "aws_iam_role" "oidc_role" {
  name               = "RoleForCustomOIDCProvider"
  assume_role_policy = data.aws_iam_policy_document.trust_oidc.json
}

output "oidc_role_to_assume" {
  value = aws_iam_role.oidc_role.arn
}

Generating the token

Now comes the time to create the script that will generate the JWT token that can be further passed to STS in exchange for AWS temporary credentials. We will use pyjwt library. Install it using pip (ideally in a new venv).

The script below generates a JWT and prints it to the console - thus the output is sensitive. You should also adjust the values. Set iss to your domain or CloudFront domain prepended with https://. sub should match what you configured in the role's trust policy extra condition. aud should match the client_id_list in OIDC provider IAM configuration. Also, check if key_path can reach the private key. Moreover, if you changed the key ID when generating the jwks/keys (public.json), also make it to match here.

import jwt, datetime

# These three things must be correct. Match the issuer to your website.
# Match sub and aud to the values in Terraform config.
iss = "https://oidc.mydomain.com"
sub = "test-subject-user"
aud = "my-client-id"

iat = datetime.datetime.now(datetime.timezone.utc)
exp = iat + datetime.timedelta(minutes=20)

# Specify the path to the private key you generated in step 1
key_path = "../oidc_keys/private.pem"

with open(key_path, "rb") as key_file:
    private_key = key_file.read()

# Creating JWT
new_token = jwt.encode(
    {"sub": sub, "aud": aud, "iss": iss, "iat": iat, "exp": exp},
    private_key,
    algorithm="RS256",
    headers={"kid": "1234"} # 'kid' should also match openid-configuration
)

print(new_token) # Print to stdout

So we have generated the token. However, you can additionally verify if the token will even work with the OpenID configuration you created on your website. The code below will connect to your website, get the public key and verify JWT token against it. If the command below does not raise any exceptions, it will likely work if AWS configuration is also correct.

# Verify if the JWT is usable with our endpoint.
from jwt import PyJWKClient
jwks_client = PyJWKClient(f'{iss}/jwks/keys')
signing_key = jwks_client.get_signing_key_from_jwt(new_token)
jwt.decode(
    new_token,
    signing_key,
    audience=aud,
    options={"verify_exp": True},
    algorithms=["RS256"],
)

Assuming the role

If everything went well, you can now try to assume the role you defined in the secondary account. The bash snippet below will take the ARN of the role, generate a token and try to assume the role in AWS. If you configured everything correctly, you should get a response with AWS credentials.

$ cd ../oidc_role
$ ROLE_ARN=$(tofu output -raw oidc_role_to_assume)
$ cd ../token_signer
$ TOKEN=$(python3 generate_token.py)
$ aws sts assume-role-with-web-identity \
 --role-session-name test-session-abc123def \
 --duration-seconds 900 \
 --role-arn ${ROLE_ARN} \
 --web-identity-token ${TOKEN}
{
    "Credentials": {
        "AccessKeyId": "ASIA2***********",
        "SecretAccessKey": "l**************************",
        "SessionToken": "Fw****************************************************",
        "Expiration": "2024-10-01T20:18:19Z"
    },
    "SubjectFromWebIdentityToken": "test-subject-user",
    "AssumedRoleUser": {
        "AssumedRoleId": "AROA2*************:test-session-abc123def",
        "Arn": "arn:aws:sts::1234567890:assumed-role/RoleForCustomOIDCProvider/test-session-abc123def"
    },
    "Provider": "arn:aws:iam::1234567890:oidc-provider/oidc.mydomain.com",
    "Audience": "my-client-id"
}

As an exercise, you can try to change aud in the token generator and try to generate a role with a new token. You should see then:

An error occurred (InvalidIdentityToken) when calling the AssumeRoleWithWebIdentity operation: Incorrect token audience

Revert aud and change sub to see the different effect. Despite the fact that PyJWT validated the token against your public key, AWS will not accept it because configured values don't match.