In one of my previous blog posts (Hacking your way around AWS IAM Roles), we demonstrated how users can access AWS resources without having to store AWS credentials on disk. This was achieved by setting up an OpenVPN server and client-side route that gets automatically pushed when the user is connected to the VPN. To this date, I really find this as a complaint-friendly solution without forcing users to do any manual configuration on their system. It also makes sense to have access to AWS resources as long as they are connected on VPN. One of the downsides to this method is maintaining an OpenVPN server, keeping it secure and having it running in a highly available (HA) state. If the OpenVPN server is compromised, our credentials are at stake. Secondly, all the users connected on VPN get the same level of access.
In this blog post, we present to you a CLI utility written in Rust that writes temporary AWS credentials to a user profile (~/.aws/credentials file) using web browser navigated Google authentication. This utility is inspired by gimme-aws-creds (written in python for Okta authenticated AWS farm) and heroku cli (written in nodejs and utilizes oclif framework). We will refer to our utility as aws-authcreds throughout this post.
If you have an apple and I have an apple and we exchange these apples then you and I will still each have one apple. But if you have an idea and I have an idea and we exchange these ideas, then each of us will have two ideas.
– George Bernard Shaw
What does this CLI utility (auth-awscreds) do?
When the user fires a command (auth-awscreds) on the terminal, our program reads utility configuration from file .auth-awscreds
located in the user home directory. If this file is not present, the utility prompts for setting the configuration for the first time. Utility configuration file is INI format. Program then opens a default web browser and navigates to the URL read from the configuration file. At this point, the utility waits for the browser URL to navigate and authorize. Web UI then navigates to Google Authentication. If authentication is successful, a callback is shared with CLI utility along with temporary AWS credentials, which is then written to ~/.aws/credentials
file.
Tech Stack Used
As stated earlier, we wrote this utility in Rust. One of the reasons for choosing Rust is because we wanted a statically typed binary (ELF) file (executed independent of interpreter), which ships as it is when compiled. Unlike programs written in Python or Node.js, one needs a language interpreter and has supporting libraries installed for your program. The golang would have also suffice our purpose, but I prefer Rust over golang.
Software Stack:
- Rust (for CLI utility)
- Actix Web – HTTP Server
- Node.js, Express, ReactJS, serverless-http, aws-sdk, AWS Amplify, axios
- Terraform and serverless framework
Infrastructure Stack:
- AWS Cognito (User Pool and Federated Identities)
- AWS API Gateway (HTTP API)
- AWS Lambda
- AWS S3 Bucket (React App)
- AWS CloudFront (For Serving React App)
- AWS ACM (SSL Certificate)
Recipe
CLI Utility: auth-awscreds
Our goal is, when the auth-awscreds command is fired, we first check if the user’s home directory ~/.aws/credentials file exists. If not, we create a ~/.aws directory. This is the default AWS credentials directory, where usually AWS SDK looks for credentials (unless exclusively specified by env var AWS_SHARED_CREDENTIALS_FILE). The next step would be to check if a ~/.auth-awscredds file exists. If this file doesn’t exist, we create a prompt user with two inputs:
- AWS credentials profile name (used by SDK, default is preferred)
- Application domain URL (Our backend app domain is used for authentication)
let app_profile_file = format!("{}/.auth-awscreds",&user_home_dir); let config_exist : bool = Path::new(&app_profile_file).exists(); let mut profile_name = String::new(); let mut app_domain = String::new(); if !config_exist { //ask the series of questions print!("Which profile to write AWS Credentials [default] : "); io::stdout().flush().unwrap(); io::stdin() .read_line(&mut profile_name) .expect("Failed to read line"); print!("App Domain : "); io::stdout().flush().unwrap(); io::stdin() .read_line(&mut app_domain) .expect("Failed to read line"); profile_name=String::from(profile_name.trim()); app_domain=String::from(app_domain.trim()); config_profile(&profile_name,&app_domain); } else { (profile_name,app_domain) = read_profile(); }
These two properties are written in ~/.auth-awscreds under the default section. Followed by this, our utility generates RSA asymmetric 1024 bit public and private key. Both the keypair are converted to base64.
pub fn genkeypairs() -> (String,String) { let rsa = Rsa::generate(1024).unwrap(); let private_key: Vec<u8> = rsa.private_key_to_pem_passphrase(Cipher::aes_128_cbc(),"Sagar Barai".as_bytes()).unwrap(); let public_key: Vec<u8> = rsa.public_key_to_pem().unwrap(); (base64::encode(private_key) , base64::encode(public_key)) }
We then launch a browser window and navigate to the specified app domain URL. At this stage, our utility starts a temporary web server with the help of the Actix Web framework and listens on 63442 port of localhost.
println!("Opening web ui for authentication...!"); open::that(&app_domain).unwrap(); HttpServer::new(move || { //let stopper = tx.clone(); let cors = Cors::permissive(); App::new() .wrap(cors) //.app_data(stopper) .app_data(crypto_data.clone()) .service(get_public_key) .service(set_aws_creds) }) .bind(("127.0.0.1",63442))? .run() .await
Localhost web server has two end points.
- GET Endpoint (/publickey): This endpoint is called by our React app after authentication and returns the public key created during the initialization process. Since the web server hosted by the Rust application is insecure (non ssl), when actual AWS credentials are received, they should be posted as an encrypted string with the help of this public key.
#[get("/publickey")] pub async fn get_public_key(data: web::Data<AppData>) -> impl Responder { let public_key = &data.public_key; web::Json(HTTPResponseData{ status: 200, msg: String::from("Ok"), success: true, data: String::from(public_key) }) }
- POST Endpoint (/setcreds): This endpoint is called when the react app has successfully retrieved credentials from API Gateway. Credentials are decrypted by private key and then written to ~/.aws/credentials file defined by profile name in utility configuration.
let encrypted_data = payload["data"].as_array().unwrap(); let username = payload["username"].as_str().unwrap(); let mut decypted_payload = vec![]; for str in encrypted_data.iter() { //println!("{}",str.to_string()); let s = str.as_str().unwrap(); let decrypted = decrypt_data(&private_key, &s.to_string()); decypted_payload.extend_from_slice(&decrypted); } let credentials : serde_json::Value = serde_json::from_str(&String::from_utf8(decypted_payload).unwrap()).unwrap(); let aws_creds = AWSCreds{ profile_name: String::from(profile_name), aws_access_key_id: String::from(credentials["AccessKeyId"].as_str().unwrap()), aws_secret_access_key: String::from(credentials["SecretAccessKey"].as_str().unwrap()), aws_session_token: String::from(credentials["SessionToken"].as_str().unwrap()) }; println!("Authenticated as {}",username); println!("Updating AWS Credentials File...!"); configcreds(&aws_creds);
One of the interesting parts of this code is the decryption process, which iterates through an array of strings and is joined by method decypted_payload.extend_from_slice(&decrypted);. RSA 1024 is 128-byte encryption, and we used OAEP padding, which uses 42 bytes for padding and the rest for encrypted data. Thus, 86 bytes can be encrypted at max. So, when credentials are received they are an array of 128 bytes long base64 encoded data. One has to decode the bas64 string to a data buffer and then decrypt data piece by piece.
To generate a statically typed binary file, run: cargo build –release
AWS Cognito and Google Authentication
This guide does not cover how to set up Cognito and integration with Google Authentication. You can refer to our old post for a detailed guide on setting up authentication and authorization. (Refer to the sections Setup Authentication and Setup Authorization).
React App:
The React app is launched via our Rust CLI utility. This application is served right from the S3 bucket via CloudFront. When our React app is loaded, it checks if the current session is authenticated. If not, then with the help of the AWS Amplify framework, our app is redirected to Cognito-hosted UI authentication, which in turn auto redirects to Google Login page.
render(){ return ( <div className="centerdiv"> { this.state.appInitialised ? this.state.user === null ? Auth.federatedSignIn({provider: 'Google'}) : <Aux> {this.state.pageContent} </Aux> : <Loader/> } </div> ) }
Once the session is authenticated, we set the react state variables and then retrieve the public key from the actix web server (Rust CLI App: auth-awscreds) by calling /publickey GET method. Followed by this, an Ajax POST request (/auth-creds) is made via axios library to API Gateway. The payload contains a public key, and JWT token for authentication. Expected response from API gateway is encrypted AWS temporary credentials which is then proxied to our CLI application.
To ease this deployment, we have written a terraform code (available in the repository) that takes care of creating an S3 bucket, CloudFront distribution, ACM, React build, and deploying it to the S3 bucket. Navigate to vars.tf file and change the respective default variables). The Terraform script will fail at first launch since the ACM needs a DNS record validation. You can create a CNAME record for DNS validation and re-run the Terraform script to continue deployment. The React app expects few environment variables. Below is the sample .env file; update the respective values for your environment.
REACT_APP_IDENTITY_POOL_ID= REACT_APP_COGNITO_REGION= REACT_APP_COGNITO_USER_POOL_ID= REACT_APP_COGNTIO_DOMAIN_NAME= REACT_APP_DOMAIN_NAME= REACT_APP_CLIENT_ID= REACT_APP_CLI_APP_URL= REACT_APP_API_APP_URL=
Finally, deploy the React app using below sample commands.
$ terraform plan -out plan #creates plan for revision $ terraform apply plan #apply plan and deploy
API Gateway HTTP API and Lambda Function
When a request is first intercepted by API Gateway, it validates the JWT token on its own. API Gateway natively supports Cognito integration. Thus, any payload with invalid authorization header is rejected at API Gateway itself. This eases our authentication process and validates the identity. If the request is valid, it is then received by our Lambda function. Our Lambda function is written in Node.js and wrapped by serverless-http framework around express app. The Express app has only one endpoint.
/auth-creds (POST): once the request is received, it retrieves the ID from Cognito and logs it to stdout for audit purpose.
let identityParams = { IdentityPoolId: process.env.IDENTITY_POOL_ID, Logins: {} }; identityParams.Logins[`${process.env.COGNITOIDP}`] = req.headers.authorization; const ci = new CognitoIdentity({region : process.env.AWSREGION}); let idpResponse = await ci.getId(identityParams).promise(); console.log("Auth Creds Request Received from ",JSON.stringify(idpResponse));
The app then extracts the base64 encoded public key. Followed by this, an STS api call (Security Token Service) is made and temporary credentials are derived. These credentials are then encrypted with a public key in chunks of 86 bytes.
const pemPublicKey = Buffer.from(public_key,'base64').toString(); const authdata=await sts.assumeRole({ ExternalId: process.env.STS_EXTERNAL_ID, RoleArn: process.env.IAM_ROLE_ARN, RoleSessionName: "DemoAWSAuthSession" }).promise(); const creds = JSON.stringify(authdata.Credentials); const splitData = creds.match(/.{1,86}/g); const encryptedData = splitData.map(d=>{ return publicEncrypt(pemPublicKey,Buffer.from(d)).toString('base64'); });
Here, the assumeRole calls the IAM role, which has appropriate policy documents attached. For the sake of this demo, we attached an Administrator role. However, one should consider a hardening policy document and avoid attaching Administrator policy directly to the role.
resources: Resources: AuthCredsAssumeRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: "2012-10-17" Statement: - Effect: Allow Principal: AWS: !GetAtt IamRoleLambdaExecution.Arn Action: sts:AssumeRole Condition: StringEquals: sts:ExternalId: ${env:STS_EXTERNAL_ID} RoleName: auth-awscreds-api ManagedPolicyArns: - arn:aws:iam::aws:policy/AdministratorAccess
Finally, the response is sent to the React app.
We have used the Serverless framework to deploy the API. The Serverless framework creates API gateway, lambda function, Lambda Layer, and IAM role, and takes care of code deployment to lambda function.
To deploy this application, follow the below steps.
cd layer/nodejs && npm install && cd ../.. && npm install
npm install -g serverless
(on mac you can skip this step and use thenpx serverless
command instead)- Create .env file and below environment variables to file and set the respective values.
AWSREGION=ap-south-1 COGNITO_USER_POOL_ID= IDENTITY_POOL_ID= COGNITOIDP= APP_CLIENT_ID= STS_EXTERNAL_ID= IAM_ROLE_ARN= DEPLOYMENT_BUCKET= APP_DOMAIN=
- serverless deploy or npx serverless deploy
Entire codebase for CLI APP, React App, and Backend API is available on the GitHub repository.
Testing:
Assuming that you have compiled binary (auth-awscreds) available in your local machine and for the sake of testing you have installed aws-cli
, you can then run /path/to/your/auth-awscreds.
If you selected your AWS profile name as “demo-awscreds,” you can then export the AWS_PROFILE environment variable. If you prefer a “default” profile, you don’t need to export the environment variable as AWS SDK selects a “default” profile on its own.
[demo-awscreds] aws_access_key_id=ASIAUAOF2CHC77SJUPZU aws_secret_access_key=r21J4vwPDnDYWiwdyJe3ET+yhyzFEj7Wi1XxdIaq aws_session_token=FwoGZXIvYXdzEIj//////////wEaDHVLdvxSNEqaQZPPQyK2AeuaSlfAGtgaV1q2aKBCvK9c8GCJqcRLlNrixCAFga9n+9Vsh/5AWV2fmea6HwWGqGYU9uUr3mqTSFfh+6/9VQH3RTTwfWEnQONuZ6+E7KT9vYxPockyIZku2hjAUtx9dSyBvOHpIn2muMFmizZH/8EvcZFuzxFrbcy0LyLFHt2HI/gy9k6bLCMbcG9w7Ej2l8vfF3dQ6y1peVOQ5Q8dDMahhS+CMm1q/T1TdNeoon7mgqKGruO4KJrKiZoGMi1JZvXeEIVGiGAW0ro0/Vlp8DY1MaL7Af8BlWI1ZuJJwDJXbEi2Y7rHme5JjbA=
To validate, you can then run aws s3 ls.
You should see S3 buckets listed from your AWS account. Note that these credentials are only valid for 60 minutes. This means you will have to re-run the command and acquire a new pair of AWS credentials. Of course, you can configure your IAM role to extend expiry for an “assume role.”
auth-awscreds in Action:
Summary
Currently, “auth-awscreds” is at its early development stage. This post demonstrates how AWS credentials can be acquired temporarily without having to worry about key rotation. One of the features that we are currently working on is RBAC, with the help of AWS Cognito. Since this tool currently doesn’t support any command line argument, we can’t reconfigure utility configuration. You can manually edit or delete the utility configuration file, which triggers a prompt for configuring during the next run. We also want to add multiple profiles so that multiple AWS accounts can be used.
References
- https://sagarbarai.com/hacking-your-way-around-aws-iam-role-for-your-laptop/
- https://sagarbarai.com/my-alternative-to-google-photos-serverless-solution-with-aws/
- https://github.com/Nike-Inc/gimme-aws-creds
- https://info.townsendsecurity.com/bid/29195/how-much-data-can-you-encrypt-with-rsa-keys
- https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRole.html
- https://aws.amazon.com/premiumsupport/knowledge-center/cognito-google-social-identity-provider/