• United States+1
  • United Kingdom+44
  • Afghanistan (‫افغانستان‬‎)+93
  • Albania (Shqipëri)+355
  • Algeria (‫الجزائر‬‎)+213
  • American Samoa+1684
  • Andorra+376
  • Angola+244
  • Anguilla+1264
  • Antigua and Barbuda+1268
  • Argentina+54
  • Armenia (Հայաստան)+374
  • Aruba+297
  • Australia+61
  • Austria (Österreich)+43
  • Azerbaijan (Azərbaycan)+994
  • Bahamas+1242
  • Bahrain (‫البحرين‬‎)+973
  • Bangladesh (বাংলাদেশ)+880
  • Barbados+1246
  • Belarus (Беларусь)+375
  • Belgium (België)+32
  • Belize+501
  • Benin (Bénin)+229
  • Bermuda+1441
  • Bhutan (འབྲུག)+975
  • Bolivia+591
  • Bosnia and Herzegovina (Босна и Херцеговина)+387
  • Botswana+267
  • Brazil (Brasil)+55
  • British Indian Ocean Territory+246
  • British Virgin Islands+1284
  • Brunei+673
  • Bulgaria (България)+359
  • Burkina Faso+226
  • Burundi (Uburundi)+257
  • Cambodia (កម្ពុជា)+855
  • Cameroon (Cameroun)+237
  • Canada+1
  • Cape Verde (Kabu Verdi)+238
  • Caribbean Netherlands+599
  • Cayman Islands+1345
  • Central African Republic (République centrafricaine)+236
  • Chad (Tchad)+235
  • Chile+56
  • China (中国)+86
  • Christmas Island+61
  • Cocos (Keeling) Islands+61
  • Colombia+57
  • Comoros (‫جزر القمر‬‎)+269
  • Congo (DRC) (Jamhuri ya Kidemokrasia ya Kongo)+243
  • Congo (Republic) (Congo-Brazzaville)+242
  • Cook Islands+682
  • Costa Rica+506
  • Côte d’Ivoire+225
  • Croatia (Hrvatska)+385
  • Cuba+53
  • Curaçao+599
  • Cyprus (Κύπρος)+357
  • Czech Republic (Česká republika)+420
  • Denmark (Danmark)+45
  • Djibouti+253
  • Dominica+1767
  • Dominican Republic (República Dominicana)+1
  • Ecuador+593
  • Egypt (‫مصر‬‎)+20
  • El Salvador+503
  • Equatorial Guinea (Guinea Ecuatorial)+240
  • Eritrea+291
  • Estonia (Eesti)+372
  • Ethiopia+251
  • Falkland Islands (Islas Malvinas)+500
  • Faroe Islands (Føroyar)+298
  • Fiji+679
  • Finland (Suomi)+358
  • France+33
  • French Guiana (Guyane française)+594
  • French Polynesia (Polynésie française)+689
  • Gabon+241
  • Gambia+220
  • Georgia (საქართველო)+995
  • Germany (Deutschland)+49
  • Ghana (Gaana)+233
  • Gibraltar+350
  • Greece (Ελλάδα)+30
  • Greenland (Kalaallit Nunaat)+299
  • Grenada+1473
  • Guadeloupe+590
  • Guam+1671
  • Guatemala+502
  • Guernsey+44
  • Guinea (Guinée)+224
  • Guinea-Bissau (Guiné Bissau)+245
  • Guyana+592
  • Haiti+509
  • Honduras+504
  • Hong Kong (香港)+852
  • Hungary (Magyarország)+36
  • Iceland (Ísland)+354
  • India (भारत)+91
  • Indonesia+62
  • Iran (‫ایران‬‎)+98
  • Iraq (‫العراق‬‎)+964
  • Ireland+353
  • Isle of Man+44
  • Israel (‫ישראל‬‎)+972
  • Italy (Italia)+39
  • Jamaica+1876
  • Japan (日本)+81
  • Jersey+44
  • Jordan (‫الأردن‬‎)+962
  • Kazakhstan (Казахстан)+7
  • Kenya+254
  • Kiribati+686
  • Kosovo+383
  • Kuwait (‫الكويت‬‎)+965
  • Kyrgyzstan (Кыргызстан)+996
  • Laos (ລາວ)+856
  • Latvia (Latvija)+371
  • Lebanon (‫لبنان‬‎)+961
  • Lesotho+266
  • Liberia+231
  • Libya (‫ليبيا‬‎)+218
  • Liechtenstein+423
  • Lithuania (Lietuva)+370
  • Luxembourg+352
  • Macau (澳門)+853
  • Macedonia (FYROM) (Македонија)+389
  • Madagascar (Madagasikara)+261
  • Malawi+265
  • Malaysia+60
  • Maldives+960
  • Mali+223
  • Malta+356
  • Marshall Islands+692
  • Martinique+596
  • Mauritania (‫موريتانيا‬‎)+222
  • Mauritius (Moris)+230
  • Mayotte+262
  • Mexico (México)+52
  • Micronesia+691
  • Moldova (Republica Moldova)+373
  • Monaco+377
  • Mongolia (Монгол)+976
  • Montenegro (Crna Gora)+382
  • Montserrat+1664
  • Morocco (‫المغرب‬‎)+212
  • Mozambique (Moçambique)+258
  • Myanmar (Burma) (မြန်မာ)+95
  • Namibia (Namibië)+264
  • Nauru+674
  • Nepal (नेपाल)+977
  • Netherlands (Nederland)+31
  • New Caledonia (Nouvelle-Calédonie)+687
  • New Zealand+64
  • Nicaragua+505
  • Niger (Nijar)+227
  • Nigeria+234
  • Niue+683
  • Norfolk Island+672
  • North Korea (조선 민주주의 인민 공화국)+850
  • Northern Mariana Islands+1670
  • Norway (Norge)+47
  • Oman (‫عُمان‬‎)+968
  • Pakistan (‫پاکستان‬‎)+92
  • Palau+680
  • Palestine (‫فلسطين‬‎)+970
  • Panama (Panamá)+507
  • Papua New Guinea+675
  • Paraguay+595
  • Peru (Perú)+51
  • Philippines+63
  • Poland (Polska)+48
  • Portugal+351
  • Puerto Rico+1
  • Qatar (‫قطر‬‎)+974
  • Réunion (La Réunion)+262
  • Romania (România)+40
  • Russia (Россия)+7
  • Rwanda+250
  • Saint Barthélemy (Saint-Barthélemy)+590
  • Saint Helena+290
  • Saint Kitts and Nevis+1869
  • Saint Lucia+1758
  • Saint Martin (Saint-Martin (partie française))+590
  • Saint Pierre and Miquelon (Saint-Pierre-et-Miquelon)+508
  • Saint Vincent and the Grenadines+1784
  • Samoa+685
  • San Marino+378
  • São Tomé and Príncipe (São Tomé e Príncipe)+239
  • Saudi Arabia (‫المملكة العربية السعودية‬‎)+966
  • Senegal (Sénégal)+221
  • Serbia (Србија)+381
  • Seychelles+248
  • Sierra Leone+232
  • Singapore+65
  • Sint Maarten+1721
  • Slovakia (Slovensko)+421
  • Slovenia (Slovenija)+386
  • Solomon Islands+677
  • Somalia (Soomaaliya)+252
  • South Africa+27
  • South Korea (대한민국)+82
  • South Sudan (‫جنوب السودان‬‎)+211
  • Spain (España)+34
  • Sri Lanka (ශ්‍රී ලංකාව)+94
  • Sudan (‫السودان‬‎)+249
  • Suriname+597
  • Svalbard and Jan Mayen+47
  • Swaziland+268
  • Sweden (Sverige)+46
  • Switzerland (Schweiz)+41
  • Syria (‫سوريا‬‎)+963
  • Taiwan (台灣)+886
  • Tajikistan+992
  • Tanzania+255
  • Thailand (ไทย)+66
  • Timor-Leste+670
  • Togo+228
  • Tokelau+690
  • Tonga+676
  • Trinidad and Tobago+1868
  • Tunisia (‫تونس‬‎)+216
  • Turkey (Türkiye)+90
  • Turkmenistan+993
  • Turks and Caicos Islands+1649
  • Tuvalu+688
  • U.S. Virgin Islands+1340
  • Uganda+256
  • Ukraine (Україна)+380
  • United Arab Emirates (‫الإمارات العربية المتحدة‬‎)+971
  • United Kingdom+44
  • United States+1
  • Uruguay+598
  • Uzbekistan (Oʻzbekiston)+998
  • Vanuatu+678
  • Vatican City (Città del Vaticano)+39
  • Venezuela+58
  • Vietnam (Việt Nam)+84
  • Wallis and Futuna+681
  • Western Sahara (‫الصحراء الغربية‬‎)+212
  • Yemen (‫اليمن‬‎)+967
  • Zambia+260
  • Zimbabwe+263
  • Åland Islands+358
Thanks! We'll be in touch in the next 12 hours
Oops! Something went wrong while submitting the form.

Acquiring Temporary AWS Credentials with Browser Navigated Authentication

Sagar Barai

Cloud & DevOps

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.

Block Diagram

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

Architecture Diagram

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: 

1. AWS credentials profile name (used by SDK, default is preferred) 

2. 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();
}
view raw .rs hosted with ❤ by GitHub

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))
}
view raw .rs hosted with ❤ by GitHub

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
view raw .rs hosted with ❤ by GitHub

Localhost web server has two end points.

1. 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)
})
}
view raw .rs hosted with ❤ by GitHub

2. 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);
view raw .rs hosted with ❤ by GitHub

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>
)
}
view raw .js hosted with ❤ by GitHub

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=
view raw .env hosted with ❤ by GitHub

Finally, deploy the React app using below sample commands.

$ terraform plan -out plan #creates plan for revision
$ terraform apply plan #apply plan and deploy
view raw .sh hosted with ❤ by GitHub

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));
view raw .js hosted with ❤ by GitHub

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');
});
view raw .js hosted with ❤ by GitHub

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
view raw .js hosted with ❤ by GitHub

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.

1. cd layer/nodejs && npm install && cd ../.. && npm install

2. npm install -g serverless (on mac you can skip this step and use the npx serverless command instead) 

3. 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=
view raw .env hosted with ❤ by GitHub

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

App Testing

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=
view raw .ini hosted with ❤ by GitHub

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.

Get the latest engineering blogs delivered straight to your inbox.
No spam. Only expert insights.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Did you like the blog? If yes, we're sure you'll also like to work with the people who write them - our best-in-class engineering team.

We're looking for talented developers who are passionate about new emerging technologies. If that's you, get in touch with us.

Explore current openings

Acquiring Temporary AWS Credentials with Browser Navigated Authentication

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.

Block Diagram

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

Architecture Diagram

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: 

1. AWS credentials profile name (used by SDK, default is preferred) 

2. 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();
}
view raw .rs hosted with ❤ by GitHub

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))
}
view raw .rs hosted with ❤ by GitHub

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
view raw .rs hosted with ❤ by GitHub

Localhost web server has two end points.

1. 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)
})
}
view raw .rs hosted with ❤ by GitHub

2. 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);
view raw .rs hosted with ❤ by GitHub

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>
)
}
view raw .js hosted with ❤ by GitHub

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=
view raw .env hosted with ❤ by GitHub

Finally, deploy the React app using below sample commands.

$ terraform plan -out plan #creates plan for revision
$ terraform apply plan #apply plan and deploy
view raw .sh hosted with ❤ by GitHub

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));
view raw .js hosted with ❤ by GitHub

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');
});
view raw .js hosted with ❤ by GitHub

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
view raw .js hosted with ❤ by GitHub

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.

1. cd layer/nodejs && npm install && cd ../.. && npm install

2. npm install -g serverless (on mac you can skip this step and use the npx serverless command instead) 

3. 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=
view raw .env hosted with ❤ by GitHub

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

App Testing

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=
view raw .ini hosted with ❤ by GitHub

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.

Did you like the blog? If yes, we're sure you'll also like to work with the people who write them - our best-in-class engineering team.

We're looking for talented developers who are passionate about new emerging technologies. If that's you, get in touch with us.

Explore current openings