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.
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.
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.
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.