├── .gitignore ├── .vscode └── settings.json ├── docs ├── lovely-diagram.png ├── logo │ ├── oidc-authorizer-logo.png │ ├── oidc-authorizer-logo.afdesign │ └── oidc-authorizer-logo-small.png └── deploy.md ├── examples ├── cdk-from-sar │ ├── .npmignore │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── tsconfig.json │ ├── bin │ │ └── cdk.ts │ ├── cdk.json │ ├── lib │ │ └── cdk-stack.ts │ └── cloudformation.yaml ├── sam-from-sar │ └── template.yml └── sam │ └── template.yml ├── tests └── fixtures │ └── keys │ ├── eddsa │ ├── private.pem │ └── jwk.json │ ├── es256 │ ├── jwk.json │ └── private.pem │ ├── es384 │ ├── jwk.json │ └── private.pem │ ├── ps256 │ ├── jwk.json │ └── private.pem │ ├── ps384 │ ├── jwk.json │ └── private.pem │ ├── ps512 │ ├── jwk.json │ └── private.pem │ ├── rs256 │ ├── jwk.json │ └── private.pem │ ├── rs384 │ ├── jwk.json │ └── private.pem │ └── rs512 │ ├── jwk.json │ └── private.pem ├── .github ├── dependabot.yml ├── aws │ ├── README.md │ ├── samconfig.toml │ └── template.yml └── workflows │ ├── audit.yml │ ├── rust.yml │ └── release.yml ├── codecov.yml ├── samconfig.toml ├── LICENSE ├── Cargo.toml ├── src ├── parse_token_from_header.rs ├── accepted_claims.rs ├── main.rs ├── principalid_claims.rs ├── keysmap.rs ├── models.rs ├── accepted_algorithms.rs ├── keys_storage.rs └── handler.rs ├── template.yml └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .aws-sam/ 3 | .env 4 | tests/test-event.json 5 | tarpaulin-report.html -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.linkedProjects": [ 3 | "./Cargo.toml" 4 | ] 5 | } -------------------------------------------------------------------------------- /docs/lovely-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmammino/oidc-authorizer/HEAD/docs/lovely-diagram.png -------------------------------------------------------------------------------- /examples/cdk-from-sar/.npmignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | !*.d.ts 3 | 4 | # CDK asset staging directory 5 | .cdk.staging 6 | cdk.out 7 | -------------------------------------------------------------------------------- /docs/logo/oidc-authorizer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmammino/oidc-authorizer/HEAD/docs/logo/oidc-authorizer-logo.png -------------------------------------------------------------------------------- /docs/logo/oidc-authorizer-logo.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmammino/oidc-authorizer/HEAD/docs/logo/oidc-authorizer-logo.afdesign -------------------------------------------------------------------------------- /docs/logo/oidc-authorizer-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lmammino/oidc-authorizer/HEAD/docs/logo/oidc-authorizer-logo-small.png -------------------------------------------------------------------------------- /examples/cdk-from-sar/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | !jest.config.js 3 | *.d.ts 4 | node_modules 5 | 6 | # CDK asset staging directory 7 | .cdk.staging 8 | cdk.out 9 | -------------------------------------------------------------------------------- /tests/fixtures/keys/eddsa/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MC4CAQAwBQYDK2VwBCIEIFimpIZu7V8hIJ0EHu8RYNJm0x3l23Dty0JbYGBtmc4o 3 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" # Location of package manifests 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: 80% 6 | threshold: 5% 7 | patch: 8 | default: 9 | target: 80% 10 | threshold: 5% -------------------------------------------------------------------------------- /tests/fixtures/keys/eddsa/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "OKP", 4 | "kid": "test/keys/eddsa/public", 5 | "crv": "Ed25519", 6 | "alg": "EdDSA", 7 | "x": "63noKMXUhM0UaK-0twy6VaMz05qdpf1nJ7fOSAIa6Y0" 8 | } -------------------------------------------------------------------------------- /samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | 3 | [default] 4 | [default.global] 5 | [default.global.parameters] 6 | stack_name = "oidc-authorizer-lambda" 7 | 8 | [default.build.parameters] 9 | beta_features = true 10 | [default.sync.parameters] 11 | beta_features = true 12 | -------------------------------------------------------------------------------- /tests/fixtures/keys/es256/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "EC", 4 | "kid": "test/keys/es256/public", 5 | "crv": "P-256", 6 | "alg": "ES256", 7 | "x": "aCLrtmzSz3CxEaC_V49zGDbSbZ69SLUINW8_KL1Wqec", 8 | "y": "gMS7In0cu3AeH_CDfadSRQaT2_v70TdwZWwPL4e6jdg" 9 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/es256/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRwdQqisHuZ3kf3fu 3 | hXsV82UwT1rqGqaRqXwraKd6qO2hRANCAARoIuu2bNLPcLERoL9Xj3MYNtJtnr1I 4 | tQg1bz8ovVap54DEuyJ9HLtwHh/wg32nUkUGk9v7+9E3cGVsDy+Huo3Y 5 | -----END PRIVATE KEY----- 6 | -------------------------------------------------------------------------------- /tests/fixtures/keys/es384/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "EC", 4 | "kid": "test/keys/es384/public", 5 | "crv": "P-384", 6 | "alg": "ES384", 7 | "x": "OdRJjN9LE_5mRyE7FTPc5Rbiz0qo0-DoepRDX1hxvaB1H9GeieRT5nZBxG2pxRxq", 8 | "y": "iEPVCFKy7LEqQAk8IpEEGrIM_Zb_EPOnp6Fdtpmxh5nr5ekUH9xcxaG5sjykhV01" 9 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/es384/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIG2AgEAMBAGByqGSM49AgEGBSuBBAAiBIGeMIGbAgEBBDBF8Tr/93zgmN4zwf33 3 | NEfabs9P1+Ri78LcTlY6eQSTIzNy8uCGZOW3kUXUVpKL8h6hZANiAAQ51EmM30sT 4 | /mZHITsVM9zlFuLPSqjT4Oh6lENfWHG9oHUf0Z6J5FPmdkHEbanFHGqIQ9UIUrLs 5 | sSpACTwikQQasgz9lv8Q86enoV22mbGHmevl6RQf3FzFobmyPKSFXTU= 6 | -----END PRIVATE KEY----- 7 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/README.md: -------------------------------------------------------------------------------- 1 | # Example of using the SAR application with CDK 2 | 3 | ## Useful commands 4 | 5 | * `npm run build` compile typescript to js 6 | * `npm run watch` watch for changes and compile 7 | * `npm run test` perform the jest unit tests 8 | * `npx cdk deploy` deploy this stack to your default AWS account/region 9 | * `npx cdk diff` compare deployed stack with current state 10 | * `npx cdk synth` emits the synthesized CloudFormation template 11 | -------------------------------------------------------------------------------- /.github/aws/README.md: -------------------------------------------------------------------------------- 1 | This folder contains the SAM template that is used to bootstrap the necessary infrastructure and integration between GitHub and AWS. 2 | 3 | This is intended to be a one off operation to he deployed manually. Once deployed, the GitHub repository will be able to perform certain operations against the given AWS account (e.g. publish files in a bucket or publish to the Serverless Application Repository). 4 | 5 | Deploy with (from this folder): 6 | 7 | ```bash 8 | sam deploy 9 | ``` -------------------------------------------------------------------------------- /tests/fixtures/keys/ps256/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "RSA", 4 | "kid": "test/keys/ps256/public", 5 | "alg": "PS256", 6 | "n": "nks8qeVrV-g7E0iaY5_TMW-EgJueFlwmR4kzWjlllv2_sYcBBkozM-0qvPbvvomOVYmjfTp8vnab0REcl23jUK3Wn6QifhHkvF3VnVvFs0kbkV7issOeKHtbm8LnX3aVixUCNbgxCJG1Ury7Z4lIsXKQx2SSAL4vTCSjBNPss32uCIzqZ-WVg_qwVnpsMs1LVS3NROjrlyO-ZpCDL9xJEHpl-mD9McpUCAgj5hvGNtFcrWUR6b8WsydSJmMZxDyAjWsAxB_GOI4eoDB3L_esSs4jzq4v4p6L8-eb4bifoHdldtU-3ehINYRJXPf7t9oZIHRd6CKYg6nj0AoM_uCTwQ", 7 | "e": "AQAB" 8 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/ps384/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "RSA", 4 | "kid": "test/keys/ps384/public", 5 | "alg": "PS384", 6 | "n": "oscYkmSOcKzMHleaXYXhfjv3AL2rPgJvFfokerFxbnP3D8F9An_ZQFx7h_bt3Q2BJPFUlorFxdCm3XvbzGG3Q_AgK2wXZ8hCvvJj3Tcu8CFJtCT7mfgjVXXPGSLI_Oqem1QjdwGeyof9P5A1roLGKk4yhQgG0-DVA4Q9v70tm2OFqhaGNpX3Qm5ZABlt54yFV8wS8Af2Xx2Tx6axeJjIGQSzDhD8NGUzQov7fVZreNeOCg5MdDp6m-qf70q7a6WKpOW1qEruWOfo4soE7HXRN43FUIbuhLN1xwJwwOce2CMrShRM1kk3912f9DuHoPN1FZ-1ExV4D-QQWCCCceYMdw", 7 | "e": "AQAB" 8 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/ps512/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "RSA", 4 | "kid": "test/keys/ps512/public", 5 | "alg": "PS512", 6 | "n": "xDIrfgo9pcfF84i0a8Deb4Cc0C2fDNR1xcHBHJjoy6xAOzIK8Q-G5l9xFbYaoh4kueia7gHBzHE4d1L2ftr0o6XS81Y2ZhzP9xxZe_Ppt6i_u_VZF7PyPjhmlq99Hi0c_N_SNOH4w-aKtUnh1ahli64V23tb_-tqL1WLBgxcL9MjSQOQ97shh7N_MaOsR0s_wFZRK3Bz6eGTOvb7s0qvtQnf1iXBYVMDoIvzhnm5G6PTwBBxpXUcoqaHhQirQ3CRfTjGwwAvws__B6D7pNoOmk_p-wLftF90NwHO9LBBPZUFHlHCX3T2H2glvOSQy5rNHz-dVD4cTEhhaqkqYZgQQQ", 7 | "e": "AQAB" 8 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/rs256/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "kty": "RSA", 3 | "n": "0TF4RX87dOllFp12D8IZvSoJyp8D4IZ3JmlVG7Au2GOSp1WcrAqjyq3Gk-a_1tT31FHCLVqjH9vXE8g1sXika4mp8YCWyMfjT3KsfrciI_Fw-nBCawnqewBDcBo4cvBgTjHNBjcjGNr0U_4eCZPjP8pwqw6HrRgHf-ypNmtgWG6_2EaK-tOJtnNgGRtCYGZdqMDfKLDuqzU5-gT2ejt9P1kNAvFMMUm4dTOK-vJ7jwGKWZEzupHBlHMqu4K4IRoFbVr2XsAzV5YQ0u_r26NVtQTDUdTp9ixhexUp0eXye6m3uMklqUOHJbiqNjmH2ye4yXVJI0w6BFOeXXlwyR6slw", 4 | "e": "AQAB", 5 | "alg": "RS256", 6 | "kid": "test/keys/rs256/public", 7 | "use": "sig" 8 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/rs384/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "RSA", 4 | "kid": "test/keys/rs384/public", 5 | "alg": "RS384", 6 | "n": "jxz_uHUzx9x7uhdJztXGHGTAhoKE-1_xhSsrH3ha19_wGq6szwiGZ-IW82coie9Bhl5A-C9w_mjTdbUVarxhdU9XkNxk5NnpRva6grwRmGDvYb4gCrmIDlqxI1zndWf54XkNdwMk0KBAFatQfHhN70YkZltEkz2JXK3qx0OCGiEYiLPCwBp3h2FvKzXPvVy_Z3ZRfh1dpzoK8Ja5eCoKCtVJrGJ4HBeIytcPyt_hwEe23A8TG5mfQkLwS8-G4m7jVmHYrSFVjbs8D-QhNSGl_FBh8czoI82MyAlTOKSmY2vuRtTEYw25wawMRXHbkBjmkWhPJwebLFgpiwAUK2hbMQ", 7 | "e": "AQAB" 8 | } -------------------------------------------------------------------------------- /tests/fixtures/keys/rs512/jwk.json: -------------------------------------------------------------------------------- 1 | { 2 | "use": "sig", 3 | "kty": "RSA", 4 | "kid": "test/keys/rs512/public", 5 | "alg": "RS512", 6 | "n": "0fk6Y4HmapffnqmL--uJoyFtqYfxf8yTde34x33tTu0TOYq1XcnIF_Asfj7BCMT1uTcwmo2DgeUY9txK9sRRFGXBPqOQ7jZLofRYX1eLwD2HE9bQO9UAbEXgq3Ntqq9UJs5DSS5Ak_dVE2E4yovp1kTwJ1E8Z0l8QefFLQN5uz6PSeQuDnoYPSTe7hGm2YFpFo2GX8s6xwhxsvNsT9O8lvuoBa6LEmcaL-0pEeAabOH1dfM8QQ-0iMo6ib5nhO7oH3bTcTznFXWchu5eigyKVg7WF10B6VcWt3Sef4rvPiV_J5yy0WhCYEKbRM2jucRUiymxiIIaxTBmM_JBlKpJBQ", 7 | "e": "AQAB" 8 | } -------------------------------------------------------------------------------- /.github/aws/samconfig.toml: -------------------------------------------------------------------------------- 1 | version = 0.1 2 | [default.deploy.parameters] 3 | stack_name = "lmammino-oidc-authorized-github-actions" 4 | resolve_s3 = true 5 | s3_prefix = "lmammino-oidc-authorized-github-actions" 6 | region = "eu-west-1" 7 | confirm_changeset = true 8 | capabilities = [ 9 | "CAPABILITY_AUTO_EXPAND", 10 | "CAPABILITY_NAMED_IAM", 11 | "CAPABILITY_IAM", 12 | ] 13 | parameter_overrides = "GitHubThumbprint=\"1b511abead59c6ce207077c0bf0e0043b1382612\" GitHubRepoName=\"lmammino/oidc-authorizer\"" 14 | image_repositories = [] 15 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Cargo Audit 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '**/Cargo.toml' 8 | - '**/Cargo.lock' 9 | pull_request: 10 | branches: [ main ] 11 | paths: 12 | - '**/Cargo.toml' 13 | - '**/Cargo.lock' 14 | workflow_dispatch: ~ 15 | schedule: 16 | - cron: '0 0 * * *' 17 | 18 | env: 19 | CARGO_TERM_COLOR: always 20 | 21 | jobs: 22 | audit: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: rustsec/audit-check@v1.4.1 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /examples/cdk-from-sar/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cdk", 3 | "version": "0.1.0", 4 | "bin": { 5 | "cdk": "bin/cdk.js" 6 | }, 7 | "scripts": { 8 | "build": "tsc", 9 | "watch": "tsc -w", 10 | "test": "jest", 11 | "cdk": "cdk" 12 | }, 13 | "devDependencies": { 14 | "@types/jest": "^29.5.11", 15 | "@types/node": "20.10.4", 16 | "jest": "^29.7.0", 17 | "ts-jest": "^29.1.1", 18 | "aws-cdk": "2.115.0", 19 | "ts-node": "^10.9.2", 20 | "typescript": "~5.3.3" 21 | }, 22 | "dependencies": { 23 | "aws-cdk-lib": "2.115.0", 24 | "constructs": "^10.0.0", 25 | "source-map-support": "^0.5.21" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "commonjs", 5 | "lib": [ 6 | "es2020", 7 | "dom" 8 | ], 9 | "declaration": true, 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "strictNullChecks": true, 13 | "noImplicitThis": true, 14 | "alwaysStrict": true, 15 | "noUnusedLocals": false, 16 | "noUnusedParameters": false, 17 | "noImplicitReturns": true, 18 | "noFallthroughCasesInSwitch": false, 19 | "inlineSourceMap": true, 20 | "inlineSources": true, 21 | "experimentalDecorators": true, 22 | "strictPropertyInitialization": false, 23 | "typeRoots": [ 24 | "./node_modules/@types" 25 | ] 26 | }, 27 | "exclude": [ 28 | "node_modules", 29 | "cdk.out" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/bin/cdk.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import 'source-map-support/register'; 3 | import * as cdk from 'aws-cdk-lib'; 4 | import { CdkStack } from '../lib/cdk-stack'; 5 | 6 | const app = new cdk.App(); 7 | new CdkStack(app, 'CdkStack', { 8 | /* If you don't specify 'env', this stack will be environment-agnostic. 9 | * Account/Region-dependent features and context lookups will not work, 10 | * but a single synthesized template can be deployed anywhere. */ 11 | 12 | /* Uncomment the next line to specialize this stack for the AWS Account 13 | * and Region that are implied by the current CLI configuration. */ 14 | // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, 15 | 16 | /* Uncomment the next line if you know exactly what Account and Region you 17 | * want to deploy the stack to. */ 18 | // env: { account: '123456789012', region: 'us-east-1' }, 19 | 20 | /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ 21 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License Copyright (c) 2023 Luciano Mammino 2 | 3 | Permission is hereby granted, 4 | free of charge, to any person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, copy, modify, merge, 7 | publish, distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice 12 | (including the next paragraph) shall be included in all copies or substantial 13 | portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 16 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 18 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR 19 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "oidc-authorizer" 3 | version = "0.2.0" 4 | edition = "2021" 5 | license = "MIT" 6 | 7 | # Starting in Rust 1.62 you can use `cargo add` to add dependencies 8 | # to your project. 9 | # 10 | # If you're using an older Rust version, 11 | # download cargo-edit(https://github.com/killercup/cargo-edit#installation) 12 | # to install the `add` subcommand. 13 | # 14 | # Running `cargo add DEPENDENCY_NAME` will 15 | # add the latest version of a dependency to the list, 16 | # and it will keep the alphabetic ordering for you. 17 | 18 | [dependencies] 19 | chrono = "0.4.31" 20 | futures-util = "0.3.28" 21 | jsonwebtoken = "9.3.1" 22 | lambda_runtime = "0.14.2" 23 | reqwest = { version = "0.12.3", default-features = false, features = [ 24 | "json", 25 | "rustls-tls", 26 | "http2", 27 | ] } 28 | serde = "1.0.189" 29 | serde_json = "1.0.107" 30 | thiserror = "2.0.12" 31 | tokio = { version = "1", features = ["macros"] } 32 | tracing = { version = "0.1", features = ["log"] } 33 | tracing-subscriber = { version = "0.3", default-features = false, features = [ 34 | "fmt", 35 | ] } 36 | 37 | [dev-dependencies] 38 | httpmock = "0.7.0" 39 | tracing-test = "0.2.4" 40 | -------------------------------------------------------------------------------- /src/parse_token_from_header.rs: -------------------------------------------------------------------------------- 1 | static PARSE_ERROR_MESSAGE: &str = "Authorization token must start with 'Bearer '"; 2 | 3 | pub fn parse_token_from_header(authorization_token: &str) -> Result<&str, &'static str> { 4 | if authorization_token.len() >= 8 && &(authorization_token[0..7]) == "Bearer " { 5 | return Ok(&(authorization_token[7..])); 6 | } 7 | Err(PARSE_ERROR_MESSAGE) 8 | } 9 | 10 | #[cfg(test)] 11 | mod tests { 12 | use super::*; 13 | 14 | #[test] 15 | fn it_should_parse_a_token_from_a_valid_header() { 16 | let result = parse_token_from_header("Bearer sometoken"); 17 | assert!(result.is_ok()); 18 | assert_eq!(result.unwrap(), "sometoken"); 19 | } 20 | 21 | #[test] 22 | fn it_should_fail_to_parse_a_empty_header() { 23 | let result = parse_token_from_header(""); 24 | assert!(result.is_err()); 25 | assert_eq!(result.unwrap_err(), PARSE_ERROR_MESSAGE); 26 | } 27 | 28 | #[test] 29 | fn it_should_fail_to_parse_a_header_containing_a_string_shorter_than_bearer() { 30 | let result = parse_token_from_header("short"); 31 | assert!(result.is_err()); 32 | assert_eq!(result.unwrap_err(), PARSE_ERROR_MESSAGE); 33 | } 34 | 35 | #[test] 36 | fn it_should_fail_to_parse_a_header_that_does_not_start_with_bearer() { 37 | let result = parse_token_from_header("NotBearer sometoken"); 38 | assert!(result.is_err()); 39 | assert_eq!(result.unwrap_err(), PARSE_ERROR_MESSAGE); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/accepted_claims.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | #[derive(Debug, Clone, Default, Eq, PartialEq)] 4 | pub struct AcceptedClaims(HashSet, String); 5 | 6 | impl AcceptedClaims { 7 | pub fn new(accepted_values: HashSet, claim_name: String) -> Self { 8 | Self(accepted_values, claim_name) 9 | } 10 | 11 | pub fn accepted_values(&self) -> Vec { 12 | self.0.iter().cloned().collect() 13 | } 14 | 15 | pub fn from_comma_separated_values(comma_separated_values: &str, claim_name: String) -> Self { 16 | let accepted_values = comma_separated_values 17 | .split(',') 18 | .map(|s| s.trim().to_string()) 19 | .filter(|s| !s.is_empty()) 20 | .collect::>(); 21 | 22 | Self::new(accepted_values.into_iter().collect(), claim_name) 23 | } 24 | } 25 | 26 | #[cfg(test)] 27 | mod tests { 28 | 29 | use super::*; 30 | 31 | #[test] 32 | fn test_initialize_from_comma_separated_values() { 33 | let accepted_claims = AcceptedClaims::from_comma_separated_values( 34 | "https://example.com, https://example.org", 35 | "iss".to_string(), 36 | ); 37 | 38 | assert_eq!( 39 | accepted_claims, 40 | AcceptedClaims::new( 41 | vec![ 42 | "https://example.com".to_string(), 43 | "https://example.org".to_string() 44 | ] 45 | .into_iter() 46 | .collect(), 47 | "iss".to_string() 48 | ) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/fixtures/keys/ps256/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCeSzyp5WtX6DsT 3 | SJpjn9Mxb4SAm54WXCZHiTNaOWWW/b+xhwEGSjMz7Sq89u++iY5ViaN9Ony+dpvR 4 | ERyXbeNQrdafpCJ+EeS8XdWdW8WzSRuRXuKyw54oe1ubwudfdpWLFQI1uDEIkbVS 5 | vLtniUixcpDHZJIAvi9MJKME0+yzfa4IjOpn5ZWD+rBWemwyzUtVLc1E6OuXI75m 6 | kIMv3EkQemX6YP0xylQICCPmG8Y20VytZRHpvxazJ1ImYxnEPICNawDEH8Y4jh6g 7 | MHcv96xKziPOri/inovz55vhuJ+gd2V21T7d6Eg1hElc9/u32hkgdF3oIpiDqePQ 8 | Cgz+4JPBAgMBAAECggEAGoHR1kd3OsDGR7vkMqOca45Xm2uzxN3IgTkt8sFizC6Q 9 | z6siVknNmjfwbLVTP05smdFTIdDN+90DybY6WfdKVgPOSWxkZEUdzxdNgqzQBe5L 10 | ogPIJZRRY0kI9aPlN9y1PRWXCaTe/SbdPM4i+bsJA8ICEYm7U0mFxRPrgo70d7fj 11 | zYaSoGweFJmR9ftrQSfk1lXPKsmZdztoT+1R4QLT4Itywutsf2aCwpVO7pugq4CT 12 | ziRLhDEe8CcRx73TUCUhRkZeFF/JVj9bqKjnwerJLot0FZ9BimZhaLhZvoh32t8j 13 | Rat4c7HEwjUovxo9+z46A7b+QsMVu3VHhMkC4FoH+QKBgQDeuPkuhwPJwP0lPjvI 14 | 9rq8CnsvMNtr6/nrRk7WM+wYOzmRwqQKfXU3sZc2QmIkJgaEKjehvdy4SL0Y991C 15 | XhjOLODhaZzMDhtmPGg2thErIwRsfWltI3YLqE17XqomJ1W6hQeEQ3NYont9du/H 16 | Xhw3Bml/zmeahhRAypquaZdwdQKBgQC18eZIrdxQZ4u6/r1X4yivdEBB/ldGhZk4 17 | iA7H+ikcWuWFTmVPqnQPxiPSOIl94fg+g/Hni/DaCpaXrtpOda0p2/VV+rMtWBIg 18 | THE4s2lgkmNm1pXCXq4uCuwlFjJycaHSZQhJu5ZdPvFyigUNrNC2riTmntkzU4gn 19 | ig8SmWqsnQKBgQCulzXOgUw45a4LeeOHv9HWAilabuQyj4MUKcM3KvCSdirE1l2d 20 | U4eFpxMwF42zZJOLG0UM5zaUx4BkiDjMA9NSG+AVE6M0WoKj/Ap9iO+gbwzStuTk 21 | Bv5MWRyT7ztWOVN8qTehOPa8rnI7gfaoxF89yyr8YyLYAO7zc18aBfAO2QKBgEAP 22 | qvheFttMiftOJBakybd1JAbYaZxP/9HSvfmVUBJGwtd78dnp9zFynzwuYeKrxH8m 23 | b+8J68Spw7ome4DymATMCIa8XYgSoVcz8w12xhyVjmAwH8yvD37wo5KvTRXUgpiU 24 | sCVd4iR0vAiLJhpZhac8i4uT1H/o4mChkIsI73FVAoGANfeLwR9x25tkmiIsK35N 25 | 2MqSadYrdHQA8DB3fyNbpn4YD39kyTWETgG9eFQZb93diU9aefvo9ZYBSWv/4cA4 26 | o/Dq2IhV670EVN1e1EZBqRyRDRoRqZE6p406zpXy0UbT99FbuarFz1a2zOjSDpXx 27 | G+I16bBeoFASoTWZvHJyjbw= 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /tests/fixtures/keys/ps384/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCixxiSZI5wrMwe 3 | V5pdheF+O/cAvas+Am8V+iR6sXFuc/cPwX0Cf9lAXHuH9u3dDYEk8VSWisXF0Kbd 4 | e9vMYbdD8CArbBdnyEK+8mPdNy7wIUm0JPuZ+CNVdc8ZIsj86p6bVCN3AZ7Kh/0/ 5 | kDWugsYqTjKFCAbT4NUDhD2/vS2bY4WqFoY2lfdCblkAGW3njIVXzBLwB/ZfHZPH 6 | prF4mMgZBLMOEPw0ZTNCi/t9Vmt4144KDkx0Onqb6p/vSrtrpYqk5bWoSu5Y5+ji 7 | ygTsddE3jcVQhu6Es3XHAnDA5x7YIytKFEzWSTf3XZ/0O4eg83UVn7UTFXgP5BBY 8 | IIJx5gx3AgMBAAECggEAS84tjst+nFGUs5K3hFNbipaxdqpdy8CFbDY0c+EcAsTF 9 | q5Q/vAC/M55+NvkTmCz4VjR/YKZa6T883ouWdm2RU/XJfX9WvRHBp7wpLrXKybCu 10 | 6tKIS5GIHv4AxuNwt3wqBgtegscB5K8FDOuDXHRxhfUpyNNOnrCncv5MZXVb/m9R 11 | 687VjmXeLZNdSHxkuPRFtKOB8X2fF+EZPVcnsnbbwQlxOZtxgz2yZmvJS4KSwm5z 12 | X6S9eO1QmRsKMgqg4Xe72mRRIIiM051vxi3gi9fNyvEPNzyr+p5kOoK95+fAZVAY 13 | BgxqZzr9NstCqGOBGDf1wgmHq0DXA16usxwMe4kZGQKBgQDFXPZ0DE7Z2ScV5ww9 14 | Cdc/5la74gA3XAZFOTcp/LdSOEnG/cjM9VYkZSX2wG3N6kj85uABP/wPxpgrFg8G 15 | okPU48NR9LSQLc39EQQItDNQ/hElsZxot5q5a5QMCE+ztgqyu5Ph+yGkHAQXOkZn 16 | Gm+G4rTfj55LnJ2zNPM4GY6s6QKBgQDTI6MV3c14WR1dv0Z+n02Io354MNsbBJUc 17 | QEQykJTSzZMw6y5peiQI54EWnTIKQAyqqSVNErpZ4url6DgyUVaxsIIei5npsaTF 18 | Bl0YBgONr1dFdB00Q9tAEDOFJ8zEP7aINyoD2TFYezvuAxf4Wrjf3kx5iOE4/QFS 19 | vhKKej+SXwKBgAt2MKHgrRudrtVoy58n2SZO3C6hG14brAmNHxUDJipSNoal6mls 20 | vgnzDddcqYPR3VdCmTO5YYwQ/nlSGKFL/yB38bnquim9Xz8ZI+DVhj1n49sKi4jR 21 | UNz/0GM6gFZxdgGXPylaikOblOk8ayZFtMBinhp1nr3JZKcppg5V/aExAoGAGKIJ 22 | vt1XwXi0ImHUeaVgSFa6xI7+oRJRyy/8ROH1Wvq893IYwhmL5rYLq3W/hs6eK7L8 23 | NRfAQghlW1lSZRx0PtrW83VaCZe+H3Z0mf7pnzgbuHCpj5VzGPBK8ngIPcUpKI83 24 | CafnR+lovoYB3+nFs+idh3hevmVWKC8gvqMwk4UCgYBkAM1b++aR2PRPfXunTE1Y 25 | pO8Nxv+4cJk/u58mfHVPYPbJrbhkNYwuWxIqXG4Jdn8545HrUzWp6lz6gPgC1bY7 26 | Hqm2BkG0R1FVobaooHP8oS2K4dyB4pTDAigI+cWe0t+a9MbvD4ORSQ8nV+fcKSKx 27 | +KLwFTUW6vF65w1nfV688A== 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /tests/fixtures/keys/ps512/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDEMit+Cj2lx8Xz 3 | iLRrwN5vgJzQLZ8M1HXFwcEcmOjLrEA7MgrxD4bmX3EVthqiHiS56JruAcHMcTh3 4 | UvZ+2vSjpdLzVjZmHM/3HFl78+m3qL+79VkXs/I+OGaWr30eLRz839I04fjD5oq1 5 | SeHVqGWLrhXbe1v/62ovVYsGDFwv0yNJA5D3uyGHs38xo6xHSz/AVlErcHPp4ZM6 6 | 9vuzSq+1Cd/WJcFhUwOgi/OGebkbo9PAEHGldRyipoeFCKtDcJF9OMbDAC/Cz/8H 7 | oPuk2g6aT+n7At+0X3Q3Ac70sEE9lQUeUcJfdPYfaCW85JDLms0fP51UPhxMSGFq 8 | qSphmBBBAgMBAAECggEAB2auGdXYj/+qgb6bMcJnkbS8FaVW7eS44aImvwWsqHxB 9 | uwnu/Dt99wRML4m8VPfGomW6LHbQAP6XZvbv+gaX+nFntQrASBdXqAZ/gKvJmgZJ 10 | X1WIzBpbbcnyYsZRdbk1cfuegiSxHfC9NEwKcyUkWYHSbMJpTMzsZ0c2lJ6UPrCL 11 | IXz6/n8Fit0gL/teO+/Yt2DoI7+hZe8wu6gB50/EBdFtnm6epAD3p+UB42mU10A4 12 | GqRNIuECbBfzfCfD7wwVs+hM8KkJdZT4qvPOA4uTBUj4F9ikfwjfVfsbB6Z2Dm5B 13 | Ck6l4mR9L0HEAbdzerq2xtNv2lJBRK9PrjU+tvW/jQKBgQDtGs8NjCrtswusJuIU 14 | 1lennCVVoW6o76hq/OpjcxrMp2pfaq/GudKWGPn8QUCtbtWKHyiD0faBq1WK+Olh 15 | JuSpuDydoBZ9mlc4eT8qUHjSdEma4/JECruoKjPhl02gGbDO8cWtpM7RI2sj2OxJ 16 | 8vBHfx35ht8Jv+aEHmC9VU9QBQKBgQDT1MdZJgc6ft+UlzB4eWiJmGML/MN3golx 17 | QrW8iaNItEPXT+r7fuzNOTfZP9dl74mFGy6FWh6cGC42L/02Z54ui9ntcbdiaanO 18 | aG8glhlC5NDCuNy/AaUAVO7AUfhJ6Tl2L+SfK6JvQWxflCM6vP+WUZfMDmOlLjjE 19 | 750pzB0ADQKBgGLtXJZ6dS7YFXaugoXPKKN2Mt2XjWRPGiYJgIId9ICPGYLWMKDp 20 | x0N5CFSHpUS3icEnXvAhGojfw8FIOZefcqidhiz+LmQZkWquq/wrtz2X514I3Xwm 21 | PkXb1em6B1lVXr/5gFEDAoegD4PVbkEsa7RpBUinTUf0GWnmgtZ8UatBAoGAVlVE 22 | IetDyu1mhhLQaGli16FJzNrpd022YnjDxOF7sOf3NkuWA4YJUOLfBiXkzeDAdYVM 23 | goDmNMwOGXuZgZDdgS7yVLmh+fQChuM9V1SVWxQSmSnqndY3v1jeLXe677Sj/K0s 24 | SkroWtuZJaMkcI4SxNrgOQQsTGf4LxtRPHW55AkCgYB3O37BM4pxQ0GwGZ7GAEMu 25 | QGfSic9z0zVgnGkmSxyLsUXeZDUDqjc82vsTWGu5yfm6HQ+7xYE3RjafMfXHQQZm 26 | 4lcnpVeHUjIugjenf4erckGr6RSx1qcXUdeC9J3++hyFcF8oq2FKk3ZibA4lAxxz 27 | /hAh8yj3uUFeU4zawZpT6w== 28 | -----END PRIVATE KEY----- -------------------------------------------------------------------------------- /tests/fixtures/keys/rs256/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDRMXhFfzt06WUW 3 | nXYPwhm9KgnKnwPghncmaVUbsC7YY5KnVZysCqPKrcaT5r/W1PfUUcItWqMf29cT 4 | yDWxeKRrianxgJbIx+NPcqx+tyIj8XD6cEJrCep7AENwGjhy8GBOMc0GNyMY2vRT 5 | /h4Jk+M/ynCrDoetGAd/7Kk2a2BYbr/YRor604m2c2AZG0JgZl2owN8osO6rNTn6 6 | BPZ6O30/WQ0C8UwxSbh1M4r68nuPAYpZkTO6kcGUcyq7grghGgVtWvZewDNXlhDS 7 | 7+vbo1W1BMNR1On2LGF7FSnR5fJ7qbe4ySWpQ4cluKo2OYfbJ7jJdUkjTDoEU55d 8 | eXDJHqyXAgMBAAECggEABeF1zRZXjeDQcQNPgf/l3gB5h/2ntpRxHbOmVddCad4w 9 | DY4wOlHjlYYEaBH7i0baCgObtCSxI0B4hQYteZC3pozsfxfV+qJL4ebfsdbkxHfm 10 | wijiZRnr7TXuWg9n+NXdud+tMM3fVF/gsHUbU7pEOtMuZFCjQHAa0zN29PWtyVk+ 11 | pSpDZKj6JEIDdUmfHSYPdQzwBxWTBO+XM89io5rj5AsZ50U4/ZrWjlwTbv/9BmYS 12 | 0FxIGcnhFdb0p0S/pI0D36j/D5gysuXiy5Gi0P4MJDlmLMMg6FEqNFEUfatsxK4+ 13 | 0D0PGSzYyh4omXDaaFppekcGZD4PhabWlrFjXnTTaQKBgQDzIf8UX5Of0DSk9hpc 14 | P9tWH4k6k/9VXQ9bdZDVfj95T7f7iLqNqPrHnHk90SSTOGVypGItDE/R1tM0VxpL 15 | SgE9C2AoqAY5iSNTFXCn1UqIO3+CQGsAGH9NGnbUAxc2mSkaMqTjjhX6yu1d02VD 16 | 1hOaYqBJAZGuAM3WT7D5uUrWlQKBgQDcQ6eWn3WftiUz8FIJ6qakuWEt6S/Y99Gk 17 | y7OpQODlj4UxMzDTWm5SdSYQ5JfCHlHQsCsGZJ1W7h07DP8EfYTUhFHIBvWvc9yH 18 | KSByBiJcPE44GzDlQ8wiBUk6s0KPlwxhhWsT/LVPuq4EseyC2ESLbmeYzieFD0ib 19 | szApo72HewKBgQCCmgxvYTfnbOwagKiATT0A8kfA06cgr7CELI70X7Cw9YWa9ENh 20 | vReZChCGiEXhXzX/cxXZpPdrfL5PK0rQjpxDskyhCkDtvyDejHHLdeNncq53xSq1 21 | DnOzczfJgy+BAJz3maTBJ53e7gq7j8ahvekh0UDQdqtOkNgA+yXRvxrYUQKBgAK5 22 | x5oikQcP7vfskmdTAwrozF4QE6qArpR2fNusVNwiYYiRVJpY9SKKFpWA7qvVSjfl 23 | 5jJnDldkDPKNH57DmuAyXvy1sVahfWoixScEEmrHyEQDeiBsLVuCazyRuVIjkWCn 24 | kQKnTusUqVCfjwizem2rSRbn/i80WDatCfyJ7S9/AoGALdJOpf00Vn9dyCQb5nU2 25 | GDUlQfXXj8GPD5WpwzFhH4nUy/mU+xqdCfRsFa7oxrPGzrvgBOCnwHuFToKLFlFF 26 | RUTIsRPU3ULPLIJc0467Tk7NQJz8g1PxMAXExYCHO3b1tYJeRCG9CRExKWsn5mC7 27 | rVjkrqiyOgPDXLhXj20AhQ4= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/fixtures/keys/rs384/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCPHP+4dTPH3Hu6 3 | F0nO1cYcZMCGgoT7X/GFKysfeFrX3/AarqzPCIZn4hbzZyiJ70GGXkD4L3D+aNN1 4 | tRVqvGF1T1eQ3GTk2elG9rqCvBGYYO9hviAKuYgOWrEjXOd1Z/nheQ13AyTQoEAV 5 | q1B8eE3vRiRmW0STPYlcrerHQ4IaIRiIs8LAGneHYW8rNc+9XL9ndlF+HV2nOgrw 6 | lrl4KgoK1UmsYngcF4jK1w/K3+HAR7bcDxMbmZ9CQvBLz4bibuNWYditIVWNuzwP 7 | 5CE1IaX8UGHxzOgjzYzICVM4pKZja+5G1MRjDbnBrAxFcduQGOaRaE8nB5ssWCmL 8 | ABQraFsxAgMBAAECggEARanSito2VfPsIFYkvfOOh5S9qNxQ2kJGQVIVoDCIW+vs 9 | pzhe8zqNnnTOQjZ1Y8tcpIkt7AG/6X9ia7o1jirb7+wqzl+SF1pBtSL2AByHHhsV 10 | e4k+dKoCYiZgcrvPCXYPC2PPG58ipRFUkJe/So+PNEl6/34u39OFEWGxVsT0SD4Y 11 | gJPM8WEHn9GFGpa4tLLH7svsVkNDQ4oWFuWdBb+vg7rOd0IBs+5fvJGdymc9n3R4 12 | GY2a8VDVjop/jgMeb8QRbSj5yn1kzyF8q8hpENMf2YoXOB/qQtwJZcJXslK7XtWf 13 | XVRfSegVd8Lc6RNx2fOtBcKgln9KPLTqp1s1nywNHwKBgQDHWgiYciLlRs07ETfz 14 | qupM5K3N2UWvNdeyjyI63VRjs+wDP8c85KIDxvCL9KqbG7Sf0wlRkDpZLP4C41Fh 15 | uq71HJelZAZFcR9QobtoY6u8oK2VTff1rHx+dUHhHCkxJNlEIDYcayx3DozUZHOq 16 | SldA5VugRUdVPpM+yzeHQQJnEwKBgQC3x92h5zwtrxT373pppgNttBWL2QGbM4Xf 17 | ohWZXklaD7aoYJLUa7QI4KlBa49AsORJmD3fb+OsoLKvOH3aRxgbw5taPuq4QMxP 18 | aGu2nx6/9xlUhT+k/EJrvIz9e6G33Gcc7bToXUDDdhZht1V4PmfUtZkFM9pOsybL 19 | noCGYWUpKwKBgFPqJ0IckWnX0t4xUk8ku/ngLPVAp1+ol4JXU/5ZWoOZohiACVst 20 | lrFmVa1kMUiXcgHw8LQ/tQACu231yDUOQ0V0YrVBFI64nojve5bmlc0SCd+WcXEd 21 | yU3mB/Q9SW2haD+QG5b82jvHiSqCmlmtb2sm4NnnL67HZ24AVwB9Mgi3AoGBAK+/ 22 | IeNCDfpiA9tV1+pQJ9cw76ncy/xvOQazKZSQ5fSKMlKyh7c7h981eKZnBYcHokf+ 23 | nqydg0wCIN4PV8r1Uci7NkUHBc9NqBQH93mckPtigYaiJkrQMXR1yx5crDn3O2Qe 24 | rXHIO7avpperisCCbSiswLiI7at1BxSRbrcTTHohAoGAcLoPFbecbiucrf82iIqz 25 | Kq7fWhXFlInewvdVf4Y31obSIy2PaymQ0AxY815VaeBZoM5LV9il/24yw9dBHPdF 26 | x5MLQT4Yl5dGzz0givoOTNWH0H2jlfVT5Y7cfVStcCiTahqofcTbGoIA5D8WrXxn 27 | u1vUeFoL3DvwZ/bIkYckZmc= 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /tests/fixtures/keys/rs512/private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDR+TpjgeZql9+e 3 | qYv764mjIW2ph/F/zJN17fjHfe1O7RM5irVdycgX8Cx+PsEIxPW5NzCajYOB5Rj2 4 | 3Er2xFEUZcE+o5DuNkuh9FhfV4vAPYcT1tA71QBsReCrc22qr1QmzkNJLkCT91UT 5 | YTjKi+nWRPAnUTxnSXxB58UtA3m7Po9J5C4Oehg9JN7uEabZgWkWjYZfyzrHCHGy 6 | 82xP07yW+6gFrosSZxov7SkR4Bps4fV18zxBD7SIyjqJvmeE7ugfdtNxPOcVdZyG 7 | 7l6KDIpWDtYXXQHpVxa3dJ5/iu8+JX8nnLLRaEJgQptEzaO5xFSLKbGIghrFMGYz 8 | 8kGUqkkFAgMBAAECggEANn+LihxDI9N7dQDolmBR/wdEIUZphhQhCfDUK2Mh3uRl 9 | ov+/hzJCPwRVaAb9rzr/1Czg7pxgT6yYyCnxQw89RCANAicODOYGx2hWL5jXavQN 10 | RTbyITVtwKx6XcW95xlOrCvJBwt8M2t0p20KgnfCH50RAANzvBM0rx5q7P4D8HcZ 11 | +cVTR+dGekKiX1L5poFgnL7nQ9nZuyzuZNDGQL8cxn2Cj/bisRL/otSvGyJsx50x 12 | IrZPBNTN6kSEedTgkOujWgJQshvaZWBzzwAsWmQ3RufJh0wz4xHeKaMn+H3qMB1t 13 | Wde/C0OzboHj78ypk0mKUhsdV6ek1Z+UPEVf/tV7GQKBgQDqP+2uNLohYmXLfSkm 14 | OmvJ8UauxA14KBGtgaP4HydJrEE88fSEoBf4rHwrs9Xk5pk+nfi8yJM2OKt709Ja 15 | ZMypuP1L+m/rnnQpo2ZrxDsYqe/qbsq7YLDwFdqVsICM/TGyuAgRcgvi3eu0d43G 16 | bVIoug/dSq3G/BYmRWEmGC7OcwKBgQDleEJ0sfMJw6Sby15MhrfX8T3AevIUiZz0 17 | Yu12ZQboCrCmaXWoiQoKdZ7koL+XtcCH46KzUNE2qLKdYn6pf21WyVuozvSGqJj7 18 | 8Sql8v1VsU/i+S7eIMcfNDqQNB4t6NNUKuBCF0pVLoV1oXHuGFs19z77DxHA/8m0 19 | VKQ/NjT0pwKBgH4NPJcbpHVGf8SKa1Q457vZa9Grihv4Y9rcAiIIkZmhadGj2ZJs 20 | m7mjB4Z01UrBlAEP/MOJoz3wLMcLawxdZYyHETaLYyKFN7kYRosDI1HYUFP0nn9K 21 | PqxS9jbKrcIwlUe4pA7PMfo4tRd9pfr65NUpmlPYVb7X9AtQFkDrjnRlAoGAEpTx 22 | dLewQZspKkCGq1XBb2gmov6Rud4G6rdUGWFG9OVgtqkVkQc/2+b4bfUibquWIqrp 23 | UvH5bx6sEBiYvysFdKCrnnjbzke607QMyxFpOFY6bNvW0r/+v+Uf1OuTgg43hrpO 24 | HKv8Jfe1cPgY3Ln2MOGLpRLsUh+kesjb4A6IrUMCgYB61SkfGXYHa/LYgcD8lH1T 25 | 6rg4SKr1VauGhI6aeslF305NpE6DL/Gxy19HKGuTMIL9zQGvgqy3G3rkJYx67jY8 26 | hxIQ8bJj7qcPjExb+nTPp5yGSr6yEIk8cveOM5aBBWE08yq/uF2MJ86ltZcLYEgE 27 | Wbt+qtf8TgEUlJ7FyC6Yzg== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Install rust 20 | uses: dtolnay/rust-toolchain@master 21 | with: 22 | toolchain: stable 23 | components: clippy, rustfmt 24 | 25 | - name: Install cargo-llvm-cov 26 | uses: taiki-e/install-action@cargo-llvm-cov 27 | 28 | - uses: actions/cache@v3 29 | with: 30 | path: | 31 | ~/.cargo/registry 32 | ~/.cargo/git 33 | target 34 | key: ${{ runner.os }}-rust-test-${{ hashFiles('**/Cargo.lock') }} 35 | 36 | - name: Run cargo fmt 37 | run: cargo fmt -- --check 38 | 39 | - name: Run clippy 40 | run: cargo clippy -- -D warnings 41 | 42 | - name: Run cargo check 43 | run: cargo check --all-features --locked --release 44 | 45 | - name: Run cargo build 46 | run: cargo build --locked --release 47 | 48 | - name: Generate code coverage 49 | run: cargo llvm-cov --all-features --lcov --output-path lcov.info 50 | 51 | - name: Upload coverage to codecov 52 | uses: codecov/codecov-action@v4 53 | with: 54 | token: ${{ secrets.CODECOV_TOKEN }} 55 | files: lcov.info 56 | fail_ci_if_error: false 57 | verbose: true 58 | 59 | build: 60 | runs-on: ubuntu-latest 61 | 62 | steps: 63 | - uses: actions/checkout@v4 64 | 65 | - name: Install rust 66 | uses: dtolnay/rust-toolchain@master 67 | with: 68 | toolchain: stable 69 | 70 | - name: Install zig 71 | uses: goto-bus-stop/setup-zig@v2 72 | with: 73 | version: 0.10.0 74 | 75 | - uses: actions/cache@v3 76 | with: 77 | path: | 78 | ~/.cargo/registry 79 | ~/.cargo/git 80 | ~/.cargo/bin 81 | target 82 | key: ${{ runner.os }}-rust-build-${{ hashFiles('**/Cargo.lock') }} 83 | 84 | - name: Install Cargo Lambda 85 | uses: jaxxstorm/action-install-gh-release@v1.9.0 86 | with: 87 | repo: cargo-lambda/cargo-lambda 88 | platform: linux 89 | arch: x86_64 90 | 91 | - name: Run cargo lambda build 92 | run: cargo lambda build --locked --release -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use accepted_algorithms::AcceptedAlgorithms; 2 | use accepted_claims::AcceptedClaims; 3 | use chrono::Duration; 4 | use keys_storage::KeysStorage; 5 | use lambda_runtime::{run, tracing, Error}; 6 | use principalid_claims::PrincipalIDClaims; 7 | use reqwest::Url; 8 | use std::env; 9 | mod accepted_algorithms; 10 | mod accepted_claims; 11 | mod handler; 12 | mod keys_storage; 13 | mod keysmap; 14 | mod models; 15 | mod parse_token_from_header; 16 | mod principalid_claims; 17 | 18 | #[tokio::main] 19 | async fn main() -> Result<(), Error> { 20 | let jwks_uri = env::var("JWKS_URI")?; 21 | let jwks_uri: Url = jwks_uri.parse()?; 22 | let min_refresh_rate = env::var("MIN_REFRESH_RATE").unwrap_or("900".to_string()); 23 | let min_refresh_rate = 24 | Duration::try_seconds(min_refresh_rate.parse::()? as i64).ok_or(format!( 25 | "Invalid MIN_REFRESH_RATE value provided. '{}' should be less than {}", 26 | min_refresh_rate, 27 | i64::MAX / 1000 28 | ))?; 29 | let principal_id_claims = 30 | env::var("PRINCIPAL_ID_CLAIMS").unwrap_or("preferred_username, sub".to_string()); 31 | let default_principal_id = env::var("DEFAULT_PRINCIPAL_ID").unwrap_or("unknown".to_string()); 32 | let principal_id_claims = PrincipalIDClaims::from_comma_separated_values( 33 | principal_id_claims.as_str(), 34 | default_principal_id, 35 | ); 36 | let accepted_issuers = env::var("ACCEPTED_ISSUERS").unwrap_or_default(); 37 | let accepted_issuers = 38 | AcceptedClaims::from_comma_separated_values(accepted_issuers.as_str(), "iss".to_string()); 39 | let accepted_audiences = env::var("ACCEPTED_AUDIENCES").unwrap_or_default(); 40 | let accepted_audiences: AcceptedClaims = 41 | AcceptedClaims::from_comma_separated_values(accepted_audiences.as_str(), "aud".to_string()); 42 | let accepted_signing_algorithms = env::var("ACCEPTED_ALGORITHMS").unwrap_or_default(); 43 | let accepted_signing_algorithms: AcceptedAlgorithms = accepted_signing_algorithms.parse()?; // infallible 44 | 45 | tracing::init_default_subscriber(); 46 | 47 | let keys = KeysStorage::new(jwks_uri, min_refresh_rate); 48 | run(handler::Handler::new( 49 | Box::leak(Box::new(keys)), 50 | Box::leak(Box::new(principal_id_claims)), 51 | Box::leak(Box::new(accepted_issuers)), 52 | Box::leak(Box::new(accepted_audiences)), 53 | Box::leak(Box::new(accepted_signing_algorithms)), 54 | )) 55 | .await 56 | } 57 | -------------------------------------------------------------------------------- /src/principalid_claims.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | 3 | #[derive(Debug, Clone, Default)] 4 | pub struct PrincipalIDClaims { 5 | fields: Vec, 6 | default_value: String, 7 | } 8 | 9 | impl PrincipalIDClaims { 10 | pub fn new(fields: Vec, default_value: String) -> Self { 11 | Self { 12 | fields, 13 | default_value, 14 | } 15 | } 16 | 17 | pub fn from_comma_separated_values( 18 | comma_separated_values: &str, 19 | default_value: String, 20 | ) -> Self { 21 | let fields = comma_separated_values 22 | .split(',') 23 | .map(|s| s.trim().to_string()) 24 | .filter(|s| !s.is_empty()) 25 | .collect::>(); 26 | 27 | Self::new(fields, default_value) 28 | } 29 | 30 | pub fn get_principal_id_from_claims(&self, claims: &Value) -> String { 31 | for field in &self.fields { 32 | if let Some(claim_value) = claims.get(field) { 33 | return claim_value 34 | .as_str() 35 | .map(|s| s.to_string()) 36 | .unwrap_or_else(|| claim_value.to_string()); 37 | } 38 | } 39 | 40 | self.default_value.clone() 41 | } 42 | } 43 | 44 | #[cfg(test)] 45 | mod tests { 46 | use super::*; 47 | use serde_json::json; 48 | 49 | #[test] 50 | fn it_should_get_the_principal_id_from_the_claims() { 51 | let principal_id_claims = 52 | PrincipalIDClaims::from_comma_separated_values("foo, bar", "some_default".to_string()); 53 | // first match 54 | assert_eq!( 55 | principal_id_claims 56 | .get_principal_id_from_claims(&json!({"foo": "some_foo", "bar": "some_bar"})), 57 | "some_foo" 58 | ); 59 | // second match 60 | assert_eq!( 61 | principal_id_claims.get_principal_id_from_claims(&json!({"bar": "some_bar"})), 62 | "some_bar" 63 | ); 64 | // if it's not a string, it get's converted to a JSON string 65 | assert_eq!( 66 | principal_id_claims.get_principal_id_from_claims(&json!({"bar": {"a": "b"}})), 67 | "{\"a\":\"b\"}" 68 | ); 69 | } 70 | 71 | #[test] 72 | fn it_should_get_fallback_to_the_default_value_if_all_the_expected_claims_are_missing() { 73 | let principal_id_claims = 74 | PrincipalIDClaims::from_comma_separated_values("foo", "some_default".to_string()); 75 | assert_eq!( 76 | principal_id_claims.get_principal_id_from_claims(&json!({"bar": "some_bar"})), // foo is missing 77 | "some_default" 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: {} 5 | release: 6 | types: [created] 7 | 8 | env: 9 | AWS_REGION: eu-west-1 10 | SAR_ARTIFACT_BUCKET: ${{ secrets.SAR_ARTIFACT_BUCKET }} 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | 16 | permissions: 17 | id-token: write 18 | contents: write 19 | 20 | steps: 21 | - name: Dump GitHub context 22 | env: 23 | GITHUB_CONTEXT: ${{ toJson(github) }} 24 | run: | 25 | echo "$GITHUB_CONTEXT" 26 | 27 | - uses: actions/checkout@v4 28 | 29 | - uses: actions/setup-python@v3 30 | 31 | - uses: aws-actions/setup-sam@v2 32 | 33 | - name: Configure AWS Credentials 34 | uses: aws-actions/configure-aws-credentials@v4 35 | with: 36 | role-to-assume: ${{ secrets.AWS_REPO_ROLE_ARN }} 37 | aws-region: eu-west-1 38 | 39 | - name: Install rust 40 | uses: dtolnay/rust-toolchain@master 41 | with: 42 | toolchain: stable 43 | 44 | - name: Install zig 45 | uses: goto-bus-stop/setup-zig@v2 46 | with: 47 | version: 0.10.0 48 | 49 | - uses: actions/cache@v3 50 | with: 51 | path: | 52 | ~/.cargo/registry 53 | ~/.cargo/git 54 | ~/.cargo/bin 55 | target 56 | key: ${{ runner.os }}-release-${{ hashFiles('**/Cargo.lock') }} 57 | 58 | - name: Install Cargo Lambda 59 | uses: jaxxstorm/action-install-gh-release@v1.9.0 60 | with: 61 | repo: cargo-lambda/cargo-lambda 62 | platform: linux 63 | arch: x86_64 64 | 65 | - name: Sam build 66 | run: | 67 | sam build 68 | 69 | - name: Compress bootstrap 70 | working-directory: .aws-sam/build/OidcAuthorizer 71 | run: | 72 | zip -9 bootstrap.zip bootstrap 73 | 74 | - name: Upload bootstrap.zip to (existing) GitHub release 75 | uses: xresloader/upload-to-github-release@v1 76 | if: github.event.release 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | file: ".aws-sam/build/OidcAuthorizer/bootstrap.zip" 81 | release_id: ${{ github.event.release.id }} 82 | 83 | - name: Upload bootstrap.zip to (new draft) GitHub release 84 | uses: xresloader/upload-to-github-release@v1 85 | if: ${{ github.event_name == 'workflow_dispatch' }} 86 | env: 87 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 88 | with: 89 | file: ".aws-sam/build/OidcAuthorizer/bootstrap.zip" 90 | draft: true 91 | default_release_name: "Manual release (test)" 92 | 93 | - name: Sam publish to SAR 94 | if: github.event.release 95 | run: | 96 | sam package --output-template-file .aws-sam/packaged.yml --s3-bucket ${{ secrets.SAR_ARTIFACT_BUCKET }} 97 | sam publish --template .aws-sam/packaged.yml --region eu-west-1 98 | -------------------------------------------------------------------------------- /src/keysmap.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, fmt::Debug, ops::Deref}; 2 | 3 | use jsonwebtoken::{jwk::JwkSet, DecodingKey}; 4 | 5 | #[derive(Default)] 6 | pub struct KeysMap(HashMap); 7 | 8 | impl Deref for KeysMap { 9 | type Target = HashMap; 10 | 11 | fn deref(&self) -> &Self::Target { 12 | &(self.0) 13 | } 14 | } 15 | 16 | impl From for KeysMap { 17 | fn from(jwks: JwkSet) -> Self { 18 | let mut map = HashMap::with_capacity(jwks.keys.len()); 19 | for key in jwks.keys { 20 | if let Some(key_id) = &key.common.key_id { 21 | match DecodingKey::from_jwk(&key) { 22 | Ok(k) => { 23 | map.insert(key_id.clone(), k); 24 | } 25 | Err(e) => { 26 | tracing::warn!("Failed to create a decoding key from JWK: {}. This key won't be indexed and it will be ignored", e); 27 | continue; 28 | } 29 | } 30 | } 31 | } 32 | 33 | Self(map) 34 | } 35 | } 36 | 37 | impl Debug for KeysMap { 38 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 39 | f.debug_struct("KeysMap") 40 | .field("keys", &self.0.keys()) 41 | .finish() 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | use serde_json::json; 49 | 50 | #[test] 51 | fn it_should_create_a_map_from_a_jwkset() { 52 | let jwkset: JwkSet = serde_json::from_value(json!({ 53 | "keys": [ 54 | { 55 | "kty": "RSA", 56 | "n": "0TF4RX87dOllFp12D8IZvSoJyp8D4IZ3JmlVG7Au2GOSp1WcrAqjyq3Gk-a_1tT31FHCLVqjH9vXE8g1sXika4mp8YCWyMfjT3KsfrciI_Fw-nBCawnqewBDcBo4cvBgTjHNBjcjGNr0U_4eCZPjP8pwqw6HrRgHf-ypNmtgWG6_2EaK-tOJtnNgGRtCYGZdqMDfKLDuqzU5-gT2ejt9P1kNAvFMMUm4dTOK-vJ7jwGKWZEzupHBlHMqu4K4IRoFbVr2XsAzV5YQ0u_r26NVtQTDUdTp9ixhexUp0eXye6m3uMklqUOHJbiqNjmH2ye4yXVJI0w6BFOeXXlwyR6slw", 57 | "e": "AQAB", 58 | "alg": "RS256", 59 | "kid": "test/keys/rs256/public", 60 | "use": "sig" 61 | }, 62 | { 63 | "kty": "RSA", 64 | "e": "AQAB", 65 | "use": "sig", 66 | "kid": "test/keys/rs512/public", 67 | "alg": "RS512", 68 | "n": "jwrjFyp2WiJnr4m_M7kEJkLEFWhcKR2FTxb3frE27Fig6hqiY6_8nUMtTD4DCBu9bNzlWLcLGs1-XXV-sCQzXpK_N5tR-kd5iWuH9nzxhwewVy7q9ZxC0ejk1LKMfcWr3EalvcS0Iv-v7oZ9of23YFzBwELxeD3bjHZ1q22kpt3J_XbuYM29ZGYX_2BIl1NVJ0bhZPJDLPVVbvoDwLwL6W3AxHJUYQGNFR_mOBjpuISXxtkguErDUeTXbTdMOLCR_hLpRVwY96132-1Cd1amLLVo4nv6pV2-83GQe-qiXrtXGJ_VDwvJxW4F_p5KDKtSZ7lwXSQOHwobDyxQik1n6w" 69 | } 70 | ] 71 | })).unwrap(); 72 | let keysmap: KeysMap = jwkset.into(); 73 | assert_eq!(keysmap.len(), 2); 74 | assert!(keysmap.contains_key("test/keys/rs256/public")); 75 | assert!(keysmap.contains_key("test/keys/rs512/public")); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/cdk.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": "npx ts-node --prefer-ts-exts bin/cdk.ts", 3 | "watch": { 4 | "include": [ 5 | "**" 6 | ], 7 | "exclude": [ 8 | "README.md", 9 | "cdk*.json", 10 | "**/*.d.ts", 11 | "**/*.js", 12 | "tsconfig.json", 13 | "package*.json", 14 | "yarn.lock", 15 | "node_modules", 16 | "test" 17 | ] 18 | }, 19 | "context": { 20 | "@aws-cdk/aws-lambda:recognizeLayerVersion": true, 21 | "@aws-cdk/core:checkSecretUsage": true, 22 | "@aws-cdk/core:target-partitions": [ 23 | "aws", 24 | "aws-cn" 25 | ], 26 | "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, 27 | "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, 28 | "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, 29 | "@aws-cdk/aws-iam:minimizePolicies": true, 30 | "@aws-cdk/core:validateSnapshotRemovalPolicy": true, 31 | "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, 32 | "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, 33 | "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, 34 | "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, 35 | "@aws-cdk/core:enablePartitionLiterals": true, 36 | "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, 37 | "@aws-cdk/aws-iam:standardizedServicePrincipals": true, 38 | "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, 39 | "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, 40 | "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, 41 | "@aws-cdk/aws-route53-patters:useCertificate": true, 42 | "@aws-cdk/customresources:installLatestAwsSdkDefault": false, 43 | "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, 44 | "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, 45 | "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, 46 | "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, 47 | "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, 48 | "@aws-cdk/aws-redshift:columnId": true, 49 | "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, 50 | "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, 51 | "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, 52 | "@aws-cdk/aws-kms:aliasNameRef": true, 53 | "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, 54 | "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, 55 | "@aws-cdk/aws-efs:denyAnonymousAccess": true, 56 | "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, 57 | "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, 58 | "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, 59 | "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, 60 | "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, 61 | "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, 62 | "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /.github/aws/template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Description: "Provision the services required to setup the CI/CD using GitHub actions for lmammino/oidc-authorizer" 3 | 4 | Parameters: 5 | GitHubThumbprint: 6 | Type: String 7 | Description: The thumbprint of the GitHub TLS certificate 8 | Default: "1b511abead59c6ce207077c0bf0e0043b1382612" # Might need to be refreshed when the cert is rotated 9 | GitHubRepoName: 10 | Type: String 11 | Description: The name of the repository 12 | Default: "lmammino/oidc-authorizer" 13 | 14 | Resources: 15 | SarArtifactsBucket: 16 | Type: AWS::S3::Bucket 17 | 18 | SarArtifactBucketPolicy: 19 | Type: AWS::S3::BucketPolicy 20 | Properties: 21 | Bucket: !Ref SarArtifactsBucket 22 | PolicyDocument: 23 | Version: "2012-10-17" 24 | Statement: 25 | - Effect: Allow 26 | Principal: 27 | Service: serverlessrepo.amazonaws.com 28 | Action: s3:GetObject 29 | Resource: !Sub arn:aws:s3:::${SarArtifactsBucket}/* 30 | Condition: 31 | StringEquals: 32 | aws:SourceAccount: !Ref "AWS::AccountId" 33 | 34 | GitHubOIDCProvider: 35 | Type: AWS::IAM::OIDCProvider 36 | Properties: 37 | Url: "https://token.actions.githubusercontent.com" 38 | ClientIdList: 39 | - "sts.amazonaws.com" 40 | ThumbprintList: 41 | - !Ref GitHubThumbprint 42 | 43 | GitHubIAMRole: 44 | Type: AWS::IAM::Role 45 | Properties: 46 | Path: "/" 47 | RoleName: GitHubActionLmamminoOidcProvider 48 | AssumeRolePolicyDocument: 49 | Statement: 50 | - Effect: Allow 51 | Action: sts:AssumeRoleWithWebIdentity 52 | Principal: 53 | Federated: !Ref GitHubOIDCProvider 54 | Condition: 55 | StringLike: 56 | token.actions.githubusercontent.com:sub: !Sub repo:${GitHubRepoName}:* 57 | MaxSessionDuration: 3600 58 | Description: !Sub "Github Actions role for ${GitHubRepoName}" 59 | Policies: 60 | - PolicyName: "AllowWriteToSarArtifactsBucket" 61 | PolicyDocument: 62 | Version: "2012-10-17" 63 | Statement: 64 | - Effect: Allow 65 | Action: 66 | - "s3:PutObject*" 67 | Resource: 68 | - !Sub "arn:aws:s3:::${SarArtifactsBucket}/*" 69 | - PolicyName: "PublishToSar" 70 | PolicyDocument: 71 | Version: "2012-10-17" 72 | Statement: 73 | - Effect: Allow 74 | Action: 75 | - "serverlessrepo:CreateApplication" 76 | Resource: 77 | - !Sub "arn:aws:serverlessrepo:*:${AWS::AccountId}:applications/*" 78 | - Effect: Allow 79 | Action: 80 | - "serverlessrepo:UpdateApplication" 81 | - "serverlessrepo:ListApplicationVersions" 82 | - "serverlessrepo:CreateApplicationVersion" 83 | - "serverlessrepo:GetApplicationPolicy" 84 | - "serverlessrepo:PutApplicationPolicy" 85 | Resource: 86 | - !Sub "arn:aws:serverlessrepo:*:${AWS::AccountId}:applications/oidc-authorizer" 87 | 88 | Outputs: 89 | SarArtifactsBucket: 90 | Description: The name of the generated SAR artifacts bucket 91 | Value: !Ref SarArtifactsBucket 92 | GitHubIamRoleArn: 93 | Description: The ARN of the role that needs to be assumed by GitHub 94 | Value: !GetAtt GitHubIAMRole.Arn 95 | -------------------------------------------------------------------------------- /examples/sam-from-sar/template.yml: -------------------------------------------------------------------------------- 1 | # An example on how to use the authorizer from SAR in a SAM application 2 | # 3 | # Edit the details below and then deploy with: 4 | # 5 | # ```bash 6 | # sam build --beta-features --template examples/sam-from-sar/template.yml 7 | # sam deploy --template .aws-sam/build/template.yaml --guided --capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_NAMED_IAM CAPABILITY_IAM 8 | # ``` 9 | # 10 | # Clean up with: 11 | # 12 | # ```bash 13 | # sam delete --config-file .aws-sam/build/samconfig.toml 14 | # ``` 15 | 16 | AWSTemplateFormatVersion: "2010-09-09" 17 | Transform: AWS::Serverless-2016-10-31 18 | Description: AWS SAM template with a simple API definition 19 | 20 | Resources: 21 | # The oidc-authorizer imported from SAR 22 | oidcauthorizer: 23 | Type: AWS::Serverless::Application 24 | Properties: 25 | Location: 26 | ApplicationId: arn:aws:serverlessrepo:eu-west-1:795006566846:applications/oidc-authorizer 27 | SemanticVersion: 0.2.0 # 👀 CHANGE ME 28 | Parameters: 29 | # 👀 CHANGE THE FOLLOWING PARAMETERS 30 | AcceptedAlgorithms: "" 31 | AcceptedAudiences: "" 32 | AcceptedIssuers: "" 33 | DefaultPrincipalId: "unknown" 34 | JwksUri: "https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/discovery/v2.0/keys" 35 | MinRefreshRate: "900" 36 | PrincipalIdClaims: "preferred_username, sub" 37 | # The amount of memory (in MB) to give to the authorizer Lambda. 38 | LambdaMemorySize: "128" 39 | # The timeout to give to the authorizer Lambda. 40 | LambdaTimeout: "3" 41 | # Change this if you want to prefix the stack name (needed if you need to deploy this stack multiple times in the same account) 42 | StackPrefix: "MyApp" 43 | 44 | # YOUR APIs HERE 45 | ApiGatewayApi: 46 | Type: AWS::Serverless::Api 47 | Properties: 48 | StageName: prod 49 | Description: A demo app to test the OIDC authorizer # 👀 CHANGE IF NEEDED 50 | Auth: 51 | DefaultAuthorizer: OidcAuthorizer 52 | Authorizers: 53 | OidcAuthorizer: 54 | FunctionArn: !GetAtt oidcauthorizer.Outputs.OidcAuthorizerArn 55 | 56 | # 👀 CHANGE: Define your APIs here 57 | SampleApiFunction1: 58 | Type: AWS::Serverless::Function 59 | Properties: 60 | Events: 61 | ApiEvent: 62 | Type: Api 63 | Properties: 64 | Path: /1 65 | Method: get 66 | RestApiId: 67 | Ref: ApiGatewayApi 68 | Runtime: python3.9 69 | Handler: index.handler 70 | InlineCode: | 71 | def handler(event, context): 72 | return {'body': 'Hello from endpoint1!', 'statusCode': 200} 73 | 74 | SampleApiFunction2: 75 | Type: AWS::Serverless::Function 76 | Properties: 77 | Events: 78 | ApiEvent: 79 | Type: Api 80 | Properties: 81 | Path: /2 82 | Method: get 83 | RestApiId: 84 | Ref: ApiGatewayApi 85 | Runtime: python3.9 86 | Handler: index.handler 87 | InlineCode: | 88 | def handler(event, context): 89 | return {'body': 'Hello ' + event['requestContext']['authorizer']['principalId'] + ' from endpoint2!\nThese are your claims: ' + event['requestContext']['authorizer']['jwtClaims'], 'statusCode': 200} 90 | 91 | Outputs: 92 | ApiBaseUrl: 93 | Description: "API Gateway base URL" 94 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/" 95 | ApiEndpoint1: 96 | Description: "API Gateway endpoint 1" 97 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/1" 98 | ApiEndpoint2: 99 | Description: "API Gateway endpoint 2" 100 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/2" 101 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/lib/cdk-stack.ts: -------------------------------------------------------------------------------- 1 | import * as cdk from 'aws-cdk-lib'; 2 | import { Construct } from 'constructs'; 3 | import * as aws_apigw from 'aws-cdk-lib/aws-apigateway'; 4 | import * as aws_lambda from 'aws-cdk-lib/aws-lambda'; 5 | 6 | export class CdkStack extends cdk.Stack { 7 | constructor(scope: Construct, id: string, props?: cdk.StackProps) { 8 | super(scope, id, props); 9 | 10 | // import the authorizer lambda for the Serverless Application Repository 11 | const authorizerApp = new cdk.aws_sam.CfnApplication(this, 'AuthorizerApp', { 12 | location: { 13 | applicationId: 'arn:aws:serverlessrepo:eu-west-1:795006566846:applications/oidc-authorizer', 14 | semanticVersion: '0.2.0' // 👀 CHANGE ME 15 | }, 16 | parameters: { 17 | // 👀 CHANGE THE FOLLOWING PARAMETERS 18 | AcceptedAlgorithms: "", 19 | AcceptedAudiences: "", 20 | AcceptedIssuers: "", 21 | DefaultPrincipalId: "unknown", 22 | JwksUri: "https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/discovery/v2.0/keys", 23 | MinRefreshRate: "900", 24 | PrincipalIdClaims: "preferred_username, sub", 25 | // The amount of memory (in MB) to give to the authorizer Lambda. 26 | LambdaMemorySize: "128", 27 | // The timeout to give to the authorizer Lambda. 28 | LambdaTimeout: "3" 29 | } 30 | }) 31 | 32 | const lambdaAuthorizer = aws_lambda.Function.fromFunctionAttributes(this, 'AuthorizerFunction', { 33 | functionArn: authorizerApp.getAtt('Outputs.OidcAuthorizerArn').toString(), 34 | sameEnvironment: true, // Note: this is important since the lambda is created in another stack we need to make sure CDK knows it's in the same region 35 | }) 36 | 37 | // creates the authorizer definition 38 | const authorizer = new aws_apigw.TokenAuthorizer(this, 'Authorizer', { 39 | handler: lambdaAuthorizer, 40 | identitySource: aws_apigw.IdentitySource.header('authorization'), 41 | authorizerName: 'OidcAuthorizer', 42 | }); 43 | 44 | // Your API is here 45 | const apiGw = new aws_apigw.RestApi(this, 'api', { 46 | restApiName: 'OIDC Authorizer Demo', 47 | description: 'A demo app to test the OIDC authorizer', 48 | deployOptions: { 49 | stageName: 'prod', 50 | }, 51 | defaultCorsPreflightOptions: { 52 | allowOrigins: aws_apigw.Cors.ALL_ORIGINS, 53 | allowMethods: aws_apigw.Cors.ALL_METHODS, 54 | }, 55 | endpointTypes: [aws_apigw.EndpointType.REGIONAL], 56 | deploy: true, 57 | }); 58 | 59 | const sampleApiLambda1 = new aws_lambda.Function(this, 'sampleApiLambda1', { 60 | runtime: aws_lambda.Runtime.PYTHON_3_9, 61 | handler: 'index.handler', 62 | code: aws_lambda.Code.fromInline(` 63 | def handler(event, context): 64 | return {'body': 'Hello from endpoint1!', 'statusCode': 200} 65 | `) 66 | }); 67 | 68 | const sampleApiLambda2 = new aws_lambda.Function(this, 'sampleApiLambda2', { 69 | runtime: aws_lambda.Runtime.PYTHON_3_9, 70 | handler: 'index.handler', 71 | code: aws_lambda.Code.fromInline(` 72 | def handler(event, context): 73 | return {'body': 'Hello ' + event['requestContext']['authorizer']['principalId'] + ' from endpoint2! These are your claims: ' + event['requestContext']['authorizer']['jwtClaims'], 'statusCode': 200} 74 | `) 75 | }); 76 | 77 | apiGw 78 | .root 79 | .addResource('1') 80 | .addMethod('GET', new aws_apigw.LambdaIntegration(sampleApiLambda1), { 81 | authorizer: authorizer, 82 | authorizationType: aws_apigw.AuthorizationType.CUSTOM, 83 | }); 84 | 85 | apiGw 86 | .root 87 | .addResource('2') 88 | .addMethod('GET', new aws_apigw.LambdaIntegration(sampleApiLambda2), { 89 | authorizer: authorizer, 90 | authorizationType: aws_apigw.AuthorizationType.CUSTOM, 91 | }); 92 | 93 | const apiGwEndpoint1Output = new cdk.CfnOutput(this, 'ApiEndpoint1', { 94 | description: 'API Gateway endpoint 1', 95 | value: `${apiGw.url}1` 96 | }); 97 | 98 | const apiGwEndpoint2Output = new cdk.CfnOutput(this, 'ApiEndpoint2', { 99 | description: 'API Gateway endpoint 2', 100 | value: `${apiGw.url}2` 101 | }); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /examples/sam/template.yml: -------------------------------------------------------------------------------- 1 | # An example on how to package the authorizer in your own SAM-managed stack 2 | # 3 | # Edit the details below and then deploy with: 4 | # 5 | # ```bash 6 | # sam build --beta-features --template examples/sam/template.yml 7 | # sam deploy --template .aws-sam/build/template.yaml --guided --capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_NAMED_IAM CAPABILITY_IAM 8 | # ``` 9 | # 10 | # Clean up with: 11 | # 12 | # ```bash 13 | # sam delete --config-file .aws-sam/build/samconfig.toml 14 | # ``` 15 | 16 | AWSTemplateFormatVersion: "2010-09-09" 17 | Transform: AWS::Serverless-2016-10-31 18 | Description: AWS SAM template with a simple API definition 19 | 20 | Resources: 21 | ApiGatewayApi: 22 | Type: AWS::Serverless::Api 23 | DependsOn: ApiCWLRoleArn 24 | Properties: 25 | StageName: prod 26 | Description: A demo app to test the OIDC authorizer # 👀 CHANGE IF NEEDED 27 | # 👀 CHANGE IF NEEDED: enable traces and logs (best practice) 28 | TracingEnabled: true 29 | MethodSettings: 30 | - HttpMethod: "*" 31 | LoggingLevel: INFO 32 | ResourcePath: "/*" 33 | MetricsEnabled: true 34 | DataTraceEnabled: true 35 | Auth: 36 | DefaultAuthorizer: OidcAuthorizer 37 | Authorizers: 38 | OidcAuthorizer: 39 | FunctionArn: !GetAtt OidcLambdaAuthorizer.Arn 40 | 41 | ApiCWLRoleArn: 42 | Type: AWS::ApiGateway::Account 43 | Properties: 44 | CloudWatchRoleArn: !GetAtt CloudWatchRole.Arn 45 | 46 | CloudWatchRole: 47 | Type: AWS::IAM::Role 48 | Properties: 49 | AssumeRolePolicyDocument: 50 | Version: "2012-10-17" 51 | Statement: 52 | Action: "sts:AssumeRole" 53 | Effect: Allow 54 | Principal: 55 | Service: apigateway.amazonaws.com 56 | Path: / 57 | ManagedPolicyArns: 58 | - "arn:aws:iam::aws:policy/service-role/AmazonAPIGatewayPushToCloudWatchLogs" 59 | 60 | # Authorizer Lambda 61 | OidcLambdaAuthorizer: 62 | Type: AWS::Serverless::Function 63 | Metadata: 64 | BuildMethod: rust-cargolambda 65 | Properties: 66 | CodeUri: ../.. 67 | Handler: bootstrap 68 | Runtime: provided.al2 69 | Timeout: 3 # 👀 CHANGE IF NEEDED 70 | MemorySize: 128 # 👀 CHANGE IF NEEDED 71 | Architectures: 72 | - arm64 73 | Environment: 74 | Variables: 75 | # 👀 CHANGE THE FOLLOWING VALUES: 76 | JWKS_URI: "https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/discovery/v2.0/keys" 77 | MIN_REFRESH_RATE: "900" 78 | PRINCIPAL_ID_CLAIMS: "preferred_username, sub" 79 | DEFAULT_PRINCIPAL_ID: "unknown" 80 | ACCEPTED_ISSUERS: "https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/v2.0" 81 | ACCEPTED_AUDIENCES: "5c8efa13-dfa5-48b5-83c3-6fe8bb819a0f" 82 | ACCEPTED_ALGORITHMS: "" 83 | 84 | # 👀 CHANGE: Define your APIs here 85 | SampleApiFunction1: 86 | Type: AWS::Serverless::Function 87 | Properties: 88 | Events: 89 | ApiEvent: 90 | Type: Api 91 | Properties: 92 | Path: /1 93 | Method: get 94 | RestApiId: 95 | Ref: ApiGatewayApi 96 | Runtime: python3.9 97 | Handler: index.handler 98 | InlineCode: | 99 | def handler(event, context): 100 | return {'body': 'Hello from endpoint1!', 'statusCode': 200} 101 | 102 | SampleApiFunction2: 103 | Type: AWS::Serverless::Function 104 | Properties: 105 | Events: 106 | ApiEvent: 107 | Type: Api 108 | Properties: 109 | Path: /2 110 | Method: get 111 | RestApiId: 112 | Ref: ApiGatewayApi 113 | Runtime: python3.9 114 | Handler: index.handler 115 | InlineCode: | 116 | def handler(event, context): 117 | return {'body': 'Hello ' + event['requestContext']['authorizer']['principalId'] + ' from endpoint2!\nThese are your claims: ' + event['requestContext']['authorizer']['jwtClaims'], 'statusCode': 200} 118 | 119 | Outputs: 120 | ApiBaseUrl: 121 | Description: "API Gateway base URL" 122 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/" 123 | ApiEndpoint1: 124 | Description: "API Gateway endpoint 1" 125 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/1" 126 | ApiEndpoint2: 127 | Description: "API Gateway endpoint 2" 128 | Value: !Sub "https://${ApiGatewayApi}.execute-api.${AWS::Region}.amazonaws.com/prod/2" 129 | -------------------------------------------------------------------------------- /template.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: 2010-09-09 2 | Transform: AWS::Serverless-2016-10-31 3 | 4 | Metadata: 5 | AWS::ServerlessRepo::Application: 6 | Name: oidc-authorizer 7 | Description: A high-performance token-based API Gateway authorizer Lambda that can validate OIDC-issued JWT tokens. 8 | Author: Luciano Mammino 9 | SpdxLicenseId: MIT 10 | LicenseUrl: LICENSE 11 | ReadmeUrl: README.md 12 | Labels: ["apigateway", "authorizer", "lambda", "oidc"] 13 | HomePageUrl: https://github.com/lmammino/oidc-authorizer 14 | SemanticVersion: 0.2.0 15 | SourceCodeUrl: https://github.com/lmammino/oidc-authorizer 16 | 17 | Parameters: 18 | JwksUri: 19 | Type: String 20 | Description: The URL of the OIDC provider JWKS (Endpoint providing public keys for verification). 21 | MinRefreshRate: 22 | Type: String 23 | Description: The minumum number of seconds to wait before keys are refreshed when the given key is not found. 24 | Default: "900" # 15 minutes 25 | PrincipalIdClaims: 26 | Type: String 27 | Description: | 28 | A comma-separated list of claims defining the token fields that should be used to determine the principal Id 29 | from the token. The fields will be tested in order. If there's no match the value specified in the `DefaultPrincipalId` 30 | parameter will be used. 31 | Default: "preferred_username, sub" 32 | DefaultPrincipalId: 33 | Type: String 34 | Description: A fallback value for the Principal ID to be used when a principal ID claim is not found in the token. 35 | Default: "unknown" 36 | AcceptedIssuers: 37 | Type: String 38 | Description: | 39 | A comma-separated list of accepted values for the `iss` claim. If one of the provided values matches, 40 | the token issuer is considered valid. If left empty, any issuer will be accepted. 41 | Default: "" 42 | AcceptedAudiences: 43 | Type: String 44 | Description: | 45 | A comma-separated list of accepted values for the `aud` claim. If one of the provided values matches, 46 | the token audience is considered valid. If left empty, any issuer audience be accepted. 47 | Default: "" 48 | AcceptedAlgorithms: 49 | Type: String 50 | Description: | 51 | A comma-separated list of accepted signing algorithms. If one of the provided values matches, 52 | the token signing algorithm is considered valid. If left empty, any supported token signing 53 | algorithm is accepted. 54 | Supported values: 55 | - ES256 56 | - ES384 57 | - RS256 58 | - RS384 59 | - PS256 60 | - PS384 61 | - PS512 62 | - RS512 63 | - EdDSA 64 | Default: "" 65 | AwsLambdaLogLevel: 66 | Type: String 67 | Description: | 68 | The log level used when executing the authorizer lambda. You can set it to DEBUG to make it very verbose if you need more information 69 | to troubleshoot an issue. In general you should not change this, because if you produce more logs than necessary that might have an impact on cost. 70 | Default: INFO 71 | AllowedValues: 72 | - TRACE 73 | - DEBUG 74 | - INFO 75 | - WARN 76 | - ERROR 77 | LambdaTimeout: 78 | Type: Number 79 | Description: The timeout to give to the authorizer Lambda. 80 | Default: "3" 81 | LambdaMemorySize: 82 | Type: Number 83 | MinValue: "128" 84 | MaxValue: "10240" 85 | Description: The amount of memory (in MB) to give to the authorizer Lambda. 86 | Default: "128" 87 | StackPrefix: 88 | Type: String 89 | Description: A prefix to be used for exported outputs. Useful if you need to deploy this stack multiple times in the same account. 90 | Default: "" 91 | 92 | Resources: 93 | OidcAuthorizer: 94 | Type: AWS::Serverless::Function 95 | Metadata: 96 | BuildMethod: rust-cargolambda 97 | Properties: 98 | CodeUri: . 99 | Handler: bootstrap 100 | Runtime: provided.al2 101 | Timeout: !Ref LambdaTimeout 102 | MemorySize: !Ref LambdaMemorySize 103 | Architectures: 104 | - arm64 105 | Environment: 106 | Variables: 107 | AWS_LAMBDA_LOG_LEVEL: !Ref AwsLambdaLogLevel 108 | JWKS_URI: !Ref JwksUri 109 | MIN_REFRESH_RATE: !Ref MinRefreshRate 110 | PRINCIPAL_ID_CLAIMS: !Ref PrincipalIdClaims 111 | DEFAULT_PRINCIPAL_ID: !Ref DefaultPrincipalId 112 | ACCEPTED_ISSUERS: !Ref AcceptedIssuers 113 | ACCEPTED_AUDIENCES: !Ref AcceptedAudiences 114 | ACCEPTED_ALGORITHMS: !Ref AcceptedAlgorithms 115 | 116 | Outputs: 117 | OidcAuthorizerArn: 118 | Description: The ARN of the OIDC Authorizer Lambda function 119 | Value: !GetAtt OidcAuthorizer.Arn 120 | Export: 121 | Name: !Sub "${StackPrefix}OidcAuthorizerArn" 122 | -------------------------------------------------------------------------------- /src/models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | use std::collections::HashMap; 4 | 5 | #[derive(Clone, Debug, Deserialize)] 6 | pub struct TokenAuthorizerEvent { 7 | #[serde(rename = "authorizationToken")] 8 | pub authorization_token: String, 9 | #[serde(rename = "methodArn")] 10 | pub method_arn: String, 11 | } 12 | 13 | #[derive(Clone, Debug, Serialize)] 14 | pub struct PolicyStatement { 15 | #[serde(rename = "Action")] 16 | pub action: String, 17 | #[serde(rename = "Effect")] 18 | pub effect: String, 19 | #[serde(rename = "Resource")] 20 | pub resource: String, 21 | } 22 | 23 | #[derive(Clone, Debug, Serialize)] 24 | pub struct PolicyDocument { 25 | #[serde(rename = "Version")] 26 | pub version: String, 27 | #[serde(rename = "Statement")] 28 | pub statement: Vec, 29 | } 30 | 31 | #[derive(Clone, Debug, Serialize)] 32 | pub struct TokenAuthorizerResponse { 33 | #[serde(rename = "principalId")] 34 | pub principal_id: String, 35 | #[serde(rename = "policyDocument")] 36 | pub policy_document: PolicyDocument, 37 | pub context: HashMap, 38 | } 39 | 40 | impl TokenAuthorizerResponse { 41 | #[inline] 42 | pub fn allow(principal_id: &str, token_claims: &Value) -> Self { 43 | let mut context = HashMap::new(); 44 | context.insert( 45 | "jwtClaims".to_string(), 46 | serde_json::to_string(token_claims).unwrap(), 47 | ); 48 | 49 | Self { 50 | context, 51 | principal_id: principal_id.to_string(), 52 | policy_document: PolicyDocument { 53 | version: "2012-10-17".to_string(), 54 | statement: vec![PolicyStatement { 55 | effect: "Allow".to_string(), 56 | action: "execute-api:Invoke".to_string(), 57 | // NOTE: this is intentionally open to avoid cache conflicts 58 | // when enabling cache and using multiple endpoints. 59 | // For more details you can read: https://www.alexdebrie.com/posts/lambda-custom-authorizers/#caching-across-multiple-functions 60 | resource: "*".to_string(), 61 | }], 62 | }, 63 | } 64 | } 65 | 66 | #[inline] 67 | pub fn deny(resource: &str) -> Self { 68 | Self { 69 | context: HashMap::new(), 70 | principal_id: "none".to_string(), 71 | policy_document: PolicyDocument { 72 | version: "2012-10-17".to_string(), 73 | statement: vec![PolicyStatement { 74 | effect: "Deny".to_string(), 75 | action: "execute-api:Invoke".to_string(), 76 | resource: resource.to_string(), 77 | }], 78 | }, 79 | } 80 | } 81 | } 82 | 83 | #[cfg(test)] 84 | mod tests { 85 | use super::*; 86 | use serde_json::json; 87 | 88 | #[test] 89 | fn it_should_create_an_allow_response() { 90 | let principal_id = "John Doe"; 91 | let token_claims = json!({ 92 | "iat": 1516239022, 93 | "name": "John Doe", 94 | "sub": "1234567890" 95 | }); 96 | let response = TokenAuthorizerResponse::allow(principal_id, &token_claims); 97 | assert_eq!( 98 | serde_json::to_value(response).unwrap(), 99 | json!({ 100 | "principalId": "John Doe", 101 | "policyDocument": { 102 | "Version": "2012-10-17", 103 | "Statement": [ 104 | { 105 | "Action": "execute-api:Invoke", 106 | "Effect": "Allow", 107 | "Resource": "*" 108 | } 109 | ] 110 | }, 111 | "context": { 112 | "jwtClaims": "{\"iat\":1516239022,\"name\":\"John Doe\",\"sub\":\"1234567890\"}", 113 | } 114 | }) 115 | ); 116 | } 117 | 118 | #[test] 119 | fn it_create_a_deny_response() { 120 | let resource = "arn::some:resource"; 121 | let response = TokenAuthorizerResponse::deny(resource); 122 | assert_eq!( 123 | serde_json::to_value(response).unwrap(), 124 | json!({ 125 | "context": {}, 126 | "policyDocument": { 127 | "Statement": [ 128 | { 129 | "Action": "execute-api:Invoke", 130 | "Effect": "Deny", 131 | "Resource": "arn::some:resource" 132 | } 133 | ], 134 | "Version": "2012-10-17" 135 | }, 136 | "principalId": "none" 137 | }) 138 | ); 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/accepted_algorithms.rs: -------------------------------------------------------------------------------- 1 | use jsonwebtoken::Algorithm; 2 | use std::{collections::HashSet, str::FromStr}; 3 | use thiserror::Error; 4 | 5 | #[derive(Debug, Clone, Default)] 6 | pub struct AcceptedAlgorithms(HashSet); 7 | 8 | #[derive(Debug, Error)] 9 | pub enum AcceptedAlgorithmsError { 10 | #[error("Invalid algorithm name")] 11 | InvalidAlgorithmName(#[from] jsonwebtoken::errors::Error), 12 | #[error("Unsupported algorithm '{0:?}'. Only public-key algorithms are supported")] 13 | UnsupportedAlgorithm(Algorithm), 14 | } 15 | 16 | static SUPPORTED_ALGORITHMS: &[Algorithm] = &[ 17 | Algorithm::ES256, 18 | Algorithm::ES384, 19 | Algorithm::RS256, 20 | Algorithm::RS384, 21 | Algorithm::RS512, 22 | Algorithm::PS256, 23 | Algorithm::PS384, 24 | Algorithm::PS512, 25 | Algorithm::EdDSA, 26 | ]; 27 | 28 | impl AcceptedAlgorithms { 29 | pub fn is_accepted(&self, algorithm: &Algorithm) -> bool { 30 | self.0.is_empty() || self.0.contains(algorithm) 31 | } 32 | 33 | pub fn assert(&self, algorithm: &Algorithm) -> Result<(), String> { 34 | match self.is_accepted(algorithm) { 35 | true => Ok(()), 36 | false => Err(format!( 37 | "Unsupported algorithm (found='{:?}', supported={:?})", 38 | algorithm, self.0 39 | )), 40 | } 41 | } 42 | } 43 | 44 | impl FromStr for AcceptedAlgorithms { 45 | type Err = AcceptedAlgorithmsError; 46 | fn from_str(algorithms: &str) -> Result { 47 | let algorithms = algorithms 48 | .split(',') 49 | .map(|s| s.trim().to_string()) 50 | .filter(|s| !s.is_empty()) 51 | .map(|s| Algorithm::from_str(&s)) 52 | .collect::, _>>()?; 53 | 54 | for algorithm in &algorithms { 55 | if !SUPPORTED_ALGORITHMS.contains(algorithm) { 56 | return Err(AcceptedAlgorithmsError::UnsupportedAlgorithm(*algorithm)); 57 | } 58 | } 59 | 60 | Ok(Self(algorithms.into_iter().collect())) 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | 68 | #[test] 69 | fn it_should_initialize_from_a_string_with_one_valid_item() { 70 | let s = "RS512"; 71 | let accepted_alg: Result = s.parse(); 72 | assert!(accepted_alg.is_ok()); 73 | let accepted_alg = accepted_alg.unwrap(); 74 | assert!(accepted_alg.is_accepted(&Algorithm::RS512)); 75 | assert!(accepted_alg.assert(&Algorithm::RS512).is_ok()); 76 | assert!(!accepted_alg.is_accepted(&Algorithm::EdDSA)); 77 | assert!(accepted_alg.assert(&Algorithm::EdDSA).is_err()); 78 | } 79 | 80 | #[test] 81 | fn it_should_initialize_from_a_string_with_multiple_valid_items() { 82 | let s = "RS512,EdDSA, ES384"; // spacing is intentional to validate proper trimming 83 | let accepted_alg: Result = s.parse(); 84 | assert!(accepted_alg.is_ok()); 85 | let accepted_alg = accepted_alg.unwrap(); 86 | assert!(accepted_alg.is_accepted(&Algorithm::RS512)); 87 | assert!(accepted_alg.assert(&Algorithm::RS512).is_ok()); 88 | assert!(accepted_alg.is_accepted(&Algorithm::EdDSA)); 89 | assert!(accepted_alg.assert(&Algorithm::EdDSA).is_ok()); 90 | assert!(accepted_alg.is_accepted(&Algorithm::ES384)); 91 | assert!(accepted_alg.assert(&Algorithm::ES384).is_ok()); 92 | assert!(!accepted_alg.is_accepted(&Algorithm::ES256)); 93 | assert!(accepted_alg.assert(&Algorithm::ES256).is_err()); 94 | } 95 | 96 | #[test] 97 | fn it_should_accept_any_algorithm_with_an_empty_string() { 98 | let s = ""; 99 | let accepted_alg: Result = s.parse(); 100 | assert!(accepted_alg.is_ok()); 101 | let accepted_alg = accepted_alg.unwrap(); 102 | assert!(accepted_alg.is_accepted(&Algorithm::ES256)); 103 | assert!(accepted_alg.assert(&Algorithm::ES256).is_ok()); 104 | assert!(accepted_alg.is_accepted(&Algorithm::ES384)); 105 | assert!(accepted_alg.assert(&Algorithm::ES384).is_ok()); 106 | assert!(accepted_alg.is_accepted(&Algorithm::RS256)); 107 | assert!(accepted_alg.assert(&Algorithm::RS256).is_ok()); 108 | assert!(accepted_alg.is_accepted(&Algorithm::RS384)); 109 | assert!(accepted_alg.assert(&Algorithm::RS384).is_ok()); 110 | assert!(accepted_alg.is_accepted(&Algorithm::RS512)); 111 | assert!(accepted_alg.assert(&Algorithm::RS512).is_ok()); 112 | assert!(accepted_alg.is_accepted(&Algorithm::PS256)); 113 | assert!(accepted_alg.assert(&Algorithm::PS256).is_ok()); 114 | assert!(accepted_alg.is_accepted(&Algorithm::PS384)); 115 | assert!(accepted_alg.assert(&Algorithm::PS384).is_ok()); 116 | assert!(accepted_alg.is_accepted(&Algorithm::PS512)); 117 | assert!(accepted_alg.assert(&Algorithm::PS512).is_ok()); 118 | assert!(accepted_alg.is_accepted(&Algorithm::EdDSA)); 119 | assert!(accepted_alg.assert(&Algorithm::EdDSA).is_ok()); 120 | } 121 | 122 | #[test] 123 | fn it_should_accept_any_algorithm_by_default() { 124 | let accepted_alg: AcceptedAlgorithms = Default::default(); 125 | assert!(accepted_alg.is_accepted(&Algorithm::ES256)); 126 | assert!(accepted_alg.assert(&Algorithm::ES256).is_ok()); 127 | assert!(accepted_alg.is_accepted(&Algorithm::ES384)); 128 | assert!(accepted_alg.assert(&Algorithm::ES384).is_ok()); 129 | assert!(accepted_alg.is_accepted(&Algorithm::RS256)); 130 | assert!(accepted_alg.assert(&Algorithm::RS256).is_ok()); 131 | assert!(accepted_alg.is_accepted(&Algorithm::RS384)); 132 | assert!(accepted_alg.assert(&Algorithm::RS384).is_ok()); 133 | assert!(accepted_alg.is_accepted(&Algorithm::RS512)); 134 | assert!(accepted_alg.assert(&Algorithm::RS512).is_ok()); 135 | assert!(accepted_alg.is_accepted(&Algorithm::PS256)); 136 | assert!(accepted_alg.assert(&Algorithm::PS256).is_ok()); 137 | assert!(accepted_alg.is_accepted(&Algorithm::PS384)); 138 | assert!(accepted_alg.assert(&Algorithm::PS384).is_ok()); 139 | assert!(accepted_alg.is_accepted(&Algorithm::PS512)); 140 | assert!(accepted_alg.assert(&Algorithm::PS512).is_ok()); 141 | assert!(accepted_alg.is_accepted(&Algorithm::EdDSA)); 142 | assert!(accepted_alg.assert(&Algorithm::EdDSA).is_ok()); 143 | } 144 | 145 | #[test] 146 | fn it_should_fail_to_parse_if_invalid_algorithms_are_passed() { 147 | let s = "RS512, invalid, EdDSA"; 148 | let accepted_alg: Result = s.parse(); 149 | assert!(accepted_alg.is_err()); 150 | assert_eq!( 151 | accepted_alg.unwrap_err().to_string(), 152 | "Invalid algorithm name".to_string() 153 | ); 154 | } 155 | 156 | #[test] 157 | fn it_should_not_allow_secret_based_algorithms_to_be_instantiated() { 158 | let s = "HS256"; 159 | let accepted_alg: Result = s.parse(); 160 | assert!(accepted_alg.is_err()); 161 | assert_eq!( 162 | accepted_alg.unwrap_err().to_string(), 163 | "Unsupported algorithm 'HS256'. Only public-key algorithms are supported".to_string() 164 | ); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/keys_storage.rs: -------------------------------------------------------------------------------- 1 | use crate::keysmap::KeysMap; 2 | use chrono::{DateTime, Duration, Utc}; 3 | use jsonwebtoken::{jwk::JwkSet, DecodingKey}; 4 | use reqwest::{Client, Url}; 5 | use std::sync::Arc; 6 | use thiserror::Error; 7 | use tokio::sync::RwLock; 8 | 9 | #[derive(Debug, Error)] 10 | pub enum KeysStorageError { 11 | #[error("Failed to fetch JWKS: {0}")] 12 | FetchError(#[from] reqwest::Error), 13 | #[error("Failed to parse JWKS content: {0}")] 14 | JwksParseError(#[from] serde_json::Error), 15 | #[error("Key '{0}' not found")] 16 | KeyNotFound(String), 17 | } 18 | 19 | #[derive(Debug)] 20 | pub struct KeysStorage { 21 | jwks_uri: Url, 22 | client: Client, 23 | storage: Arc)>>, 24 | min_refresh_rate: Duration, 25 | } 26 | 27 | impl KeysStorage { 28 | pub fn new(jwks_uri: Url, min_refresh_rate: Duration) -> Self { 29 | Self { 30 | jwks_uri, 31 | min_refresh_rate, 32 | client: Client::builder() 33 | .user_agent(format!("oidc-authorizer/{}", env!("CARGO_PKG_VERSION"))) 34 | .build() 35 | .unwrap(), 36 | storage: Arc::new(RwLock::new((KeysMap::default(), Default::default()))), 37 | } 38 | } 39 | 40 | pub async fn get(&self, key_id: &str) -> Result { 41 | let read_guard = self.storage.read().await; 42 | let maybe_key = read_guard.0.get(key_id); 43 | match maybe_key { 44 | Some(key) => return Ok(key.clone()), 45 | None => { 46 | let should_refresh = read_guard.1 + self.min_refresh_rate < Utc::now(); 47 | if should_refresh { 48 | drop(read_guard); 49 | self.refresh().await?; 50 | let read_guard = self.storage.read().await; 51 | let maybe_key = read_guard.0.get(key_id); 52 | if let Some(key) = maybe_key { 53 | return Ok(key.clone()); 54 | } 55 | drop(read_guard); 56 | } 57 | } 58 | }; 59 | 60 | Err(KeysStorageError::KeyNotFound(key_id.to_string())) 61 | } 62 | 63 | async fn refresh(&self) -> Result<(), KeysStorageError> { 64 | tracing::debug!("Refreshing JWKS from '{}'", self.jwks_uri.as_ref()); 65 | let res = self.client.get(self.jwks_uri.as_ref()).send().await?; 66 | tracing::debug!("JWKS fetched got status: {}", res.status()); 67 | let jwks = res.text().await?; 68 | tracing::debug!("JWKS fetched got body: {}", jwks); 69 | let jwks: JwkSet = serde_json::from_str(&jwks)?; 70 | 71 | let mut write_guard = self.storage.write().await; 72 | write_guard.0 = jwks.into(); 73 | write_guard.1 = Utc::now(); 74 | Ok(()) 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | use httpmock::prelude::*; 82 | 83 | #[tokio::test] 84 | async fn it_should_initialize_an_empty_instance() { 85 | let jwks_uri = Url::parse("https://example.com/jwks.json").unwrap(); 86 | // SAFETY: safe to unwrap since (60 seconds) <= (i64::MAX / 1000) 87 | let min_refresh_rate = Duration::try_seconds(60).unwrap(); 88 | let keys_cache = KeysStorage::new(jwks_uri.clone(), min_refresh_rate); 89 | 90 | assert_eq!(keys_cache.jwks_uri, jwks_uri); 91 | assert_eq!(keys_cache.min_refresh_rate, min_refresh_rate); 92 | assert_eq!(keys_cache.storage.read().await.0.len(), 0); 93 | } 94 | 95 | #[tokio::test] 96 | async fn it_should_referesh_the_cache_when_retrieving_the_first_key() { 97 | let server = MockServer::start(); 98 | let jwks_mock = server.mock(|when, then| { 99 | when.method(GET) 100 | .path("/"); 101 | then.status(200) 102 | .header("content-type", "application/json") 103 | .body(r#" 104 | { 105 | "keys":[ 106 | { 107 | "kty":"RSA", 108 | "n":"0TF4RX87dOllFp12D8IZvSoJyp8D4IZ3JmlVG7Au2GOSp1WcrAqjyq3Gk-a_1tT31FHCLVqjH9vXE8g1sXika4mp8YCWyMfjT3KsfrciI_Fw-nBCawnqewBDcBo4cvBgTjHNBjcjGNr0U_4eCZPjP8pwqw6HrRgHf-ypNmtgWG6_2EaK-tOJtnNgGRtCYGZdqMDfKLDuqzU5-gT2ejt9P1kNAvFMMUm4dTOK-vJ7jwGKWZEzupHBlHMqu4K4IRoFbVr2XsAzV5YQ0u_r26NVtQTDUdTp9ixhexUp0eXye6m3uMklqUOHJbiqNjmH2ye4yXVJI0w6BFOeXXlwyR6slw", 109 | "e":"AQAB", 110 | "alg":"RS256", 111 | "kid":"test/keys/rs256/public", 112 | "use":"sig" 113 | } 114 | ] 115 | } 116 | "#); 117 | }); 118 | 119 | let key_id = "test/keys/rs256/public"; 120 | let jwks_uri = Url::parse(server.url("/").as_str()).unwrap(); 121 | // SAFETY: safe to unwrap since (60 seconds) <= (i64::MAX / 1000) 122 | let min_refresh_rate = Duration::try_seconds(60).unwrap(); 123 | let keys_cache = KeysStorage::new(jwks_uri.clone(), min_refresh_rate); 124 | let key_result = keys_cache.get(key_id).await; 125 | assert!(key_result.is_ok()); 126 | jwks_mock.assert_hits(1); 127 | 128 | // if it reads the key again it should be taken straight away from cache 129 | let key_result = keys_cache.get(key_id).await; 130 | assert!(key_result.is_ok()); 131 | jwks_mock.assert_hits(1); // no new hits 132 | } 133 | 134 | #[tokio::test] 135 | async fn it_should_return_an_error_if_the_key_is_not_found() { 136 | let server = MockServer::start(); 137 | let jwks_mock = server.mock(|when, then| { 138 | when.method(GET) 139 | .path("/"); 140 | then.status(200) 141 | .header("content-type", "application/json") 142 | .body(r#" 143 | { 144 | "keys":[ 145 | { 146 | "kty":"RSA", 147 | "n":"0TF4RX87dOllFp12D8IZvSoJyp8D4IZ3JmlVG7Au2GOSp1WcrAqjyq3Gk-a_1tT31FHCLVqjH9vXE8g1sXika4mp8YCWyMfjT3KsfrciI_Fw-nBCawnqewBDcBo4cvBgTjHNBjcjGNr0U_4eCZPjP8pwqw6HrRgHf-ypNmtgWG6_2EaK-tOJtnNgGRtCYGZdqMDfKLDuqzU5-gT2ejt9P1kNAvFMMUm4dTOK-vJ7jwGKWZEzupHBlHMqu4K4IRoFbVr2XsAzV5YQ0u_r26NVtQTDUdTp9ixhexUp0eXye6m3uMklqUOHJbiqNjmH2ye4yXVJI0w6BFOeXXlwyR6slw", 148 | "e":"AQAB", 149 | "alg":"RS256", 150 | "kid":"test/keys/rs256/public", 151 | "use":"sig" 152 | } 153 | ] 154 | } 155 | "#); 156 | }); 157 | 158 | let key_id = "invalid"; 159 | let jwks_uri = Url::parse(server.url("/").as_str()).unwrap(); 160 | // SAFETY: safe to unwrap since (60 seconds) <= (i64::MAX / 1000) 161 | let min_refresh_rate = Duration::try_seconds(60).unwrap(); 162 | let keys_cache = KeysStorage::new(jwks_uri.clone(), min_refresh_rate); 163 | let key_result = keys_cache.get(key_id).await; 164 | if let Err(KeysStorageError::KeyNotFound(_)) = key_result { 165 | // expected 166 | } else { 167 | panic!("Expected a KeyNotFound error"); 168 | } 169 | 170 | jwks_mock.assert(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | # Deploy 2 | 3 | There are a few different ways of getting and using the binary for this Lambda 4 | function: 5 | 6 | 1. [From SAR (Serverless Application Repository)](#deploy-from-sar-serverless-application-repository) 7 | 2. [Download a pre-built binary from GitHub](#download-a-pre-built-binary-from-github) 8 | 3. [Build the binary yourself](#build-the-binary-yourself) 9 | 4. [Other approaches](#other-approaches) 10 | 11 | ## Deploy from SAR (Serverless Application Repository) 12 | 13 | This Lambda is 14 | [hosted on SAR](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/795006566846/oidc-authorizer) 15 | which means you can deploy it directly from there. 16 | 17 | ### Use the SAR application with SAM: 18 | 19 | ```yaml 20 | AWSTemplateFormatVersion: "2010-09-09" 21 | Transform: AWS::Serverless-2016-10-31 22 | Description: AWS SAM template with a simple API definition 23 | 24 | Resources: 25 | # The oidc-authorizer imported from SAR 26 | oidcauthorizer: 27 | Type: AWS::Serverless::Application 28 | Properties: 29 | Location: 30 | ApplicationId: arn:aws:serverlessrepo:eu-west-1:795006566846:applications/oidc-authorizer 31 | SemanticVersion: 0.2.0 # ⬅️ CHANGE: SPECIFY THE EXACT VERSION 32 | Parameters: 33 | # 👀 CHANGE THE FOLLOWING PARAMETERS 34 | AcceptedAlgorithms: "" 35 | AcceptedAudiences: "" 36 | AcceptedIssuers: "" 37 | DefaultPrincipalId: "unknown" 38 | JwksUri: "THE ENDPOINT OF YOUR OIDC PROVIDER JWKS" 39 | MinRefreshRate: "900" 40 | PrincipalIdClaims: "preferred_username, sub" 41 | # The amount of memory (in MB) to give to the authorizer Lambda. 42 | LambdaMemorySize: "128" 43 | # The timeout to give to the authorizer Lambda. 44 | LambdaTimeout: "3" 45 | 46 | # YOUR APIs HERE 47 | ApiGatewayApi: 48 | Type: AWS::Serverless::Api 49 | Properties: 50 | StageName: prod 51 | Auth: 52 | DefaultAuthorizer: OidcAuthorizer 53 | Authorizers: 54 | OidcAuthorizer: 55 | FunctionArn: !GetAtt oidcauthorizer.Outputs.OidcAuthorizerArn # ⬅️ This is how your reference the actual lambda deployed by the SAR app 56 | ``` 57 | 58 | To deploy this stack with sam you'll need to enable the following capabilites: 59 | `CAPABILITY_AUTO_EXPAND`, `CAPABILITY_NAMED_IAM`, and `CAPABILITY_IAM`. 60 | 61 | A full example is available in the 62 | [`examples` folder](https://github.com/lmammino/oidc-authorizer/blob/main/examples/sam-from-sar/template.yml). 63 | 64 | ### Use the SAR application with CDK: 65 | 66 | The following snippet shows how to use the SAR application with CDK (using 67 | Typescript): 68 | 69 | ```typescript 70 | // import the authorizer lambda for the Serverless Application Repository 71 | const authorizerApp = new cdk.aws_sam.CfnApplication(this, "AuthorizerApp", { 72 | location: { 73 | applicationId: 74 | "arn:aws:serverlessrepo:eu-west-1:795006566846:applications/oidc-authorizer", 75 | semanticVersion: "0.2.0", // 👀 CHANGE ME 76 | }, 77 | parameters: { 78 | // 👀 CHANGE THE FOLLOWING PARAMETERS 79 | AcceptedAlgorithms: "", 80 | AcceptedAudiences: "", 81 | AcceptedIssuers: "", 82 | DefaultPrincipalId: "unknown", 83 | JwksUri: 84 | "https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/discovery/v2.0/keys", 85 | MinRefreshRate: "900", 86 | PrincipalIdClaims: "preferred_username, sub", 87 | // The amount of memory (in MB) to give to the authorizer Lambda. 88 | LambdaMemorySize: "128", 89 | // The timeout to give to the authorizer Lambda. 90 | LambdaTimeout: "3", 91 | }, 92 | }); 93 | 94 | const lambdaAuthorizer = aws_lambda.Function.fromFunctionAttributes( 95 | this, 96 | "AuthorizerFunction", 97 | { 98 | functionArn: authorizerApp.getAtt("Outputs.OidcAuthorizerArn").toString(), 99 | sameEnvironment: true, // Note: this is important since the lambda is created in another stack we need to make sure CDK knows it's in the same region 100 | }, 101 | ); 102 | 103 | // creates the authorizer definition 104 | const authorizer = new aws_apigw.TokenAuthorizer(this, "Authorizer", { 105 | handler: lambdaAuthorizer, 106 | identitySource: aws_apigw.IdentitySource.header("authorization"), 107 | authorizerName: "OidcAuthorizer", 108 | }); 109 | 110 | // Your API is here 111 | const apiGw = new aws_apigw.RestApi(this, "api", { 112 | restApiName: "OIDC Authorizer Demo", 113 | description: "A demo app to test the OIDC authorizer", 114 | deployOptions: { 115 | stageName: "prod", 116 | }, 117 | defaultCorsPreflightOptions: { 118 | allowOrigins: aws_apigw.Cors.ALL_ORIGINS, 119 | allowMethods: aws_apigw.Cors.ALL_METHODS, 120 | }, 121 | endpointTypes: [aws_apigw.EndpointType.REGIONAL], 122 | deploy: true, 123 | }); 124 | 125 | const sampleApiLambda1 = new aws_lambda.Function(this, "sampleApiLambda1", { 126 | runtime: aws_lambda.Runtime.PYTHON_3_9, 127 | handler: "index.handler", 128 | code: aws_lambda.Code.fromInline(` 129 | def handler(event, context): 130 | return {'body': 'Hello from endpoint1!', 'statusCode': 200} 131 | `), 132 | }); 133 | 134 | const sampleApiLambda2 = new aws_lambda.Function(this, "sampleApiLambda2", { 135 | runtime: aws_lambda.Runtime.PYTHON_3_9, 136 | handler: "index.handler", 137 | code: aws_lambda.Code.fromInline(` 138 | def handler(event, context): 139 | return {'body': 'Hello ' + event['requestContext']['authorizer']['principalId'] + ' from endpoint2! These are your claims: ' + event['requestContext']['authorizer']['jwtClaims'], 'statusCode': 200} 140 | `), 141 | }); 142 | 143 | apiGw 144 | .root 145 | .addResource("1") 146 | .addMethod("GET", new aws_apigw.LambdaIntegration(sampleApiLambda1), { 147 | authorizer: authorizer, 148 | authorizationType: aws_apigw.AuthorizationType.CUSTOM, 149 | }); 150 | 151 | apiGw 152 | .root 153 | .addResource("2") 154 | .addMethod("GET", new aws_apigw.LambdaIntegration(sampleApiLambda2), { 155 | authorizer: authorizer, 156 | authorizationType: aws_apigw.AuthorizationType.CUSTOM, 157 | }); 158 | 159 | const apiGwEndpoint1Output = new cdk.CfnOutput(this, "ApiEndpoint1", { 160 | description: "API Gateway endpoint 1", 161 | value: `${apiGw.url}1`, 162 | }); 163 | 164 | const apiGwEndpoint2Output = new cdk.CfnOutput(this, "ApiEndpoint2", { 165 | description: "API Gateway endpoint 2", 166 | value: `${apiGw.url}2`, 167 | }); 168 | ``` 169 | 170 | A full example is available in the 171 | [`examples` folder](https://github.com/lmammino/oidc-authorizer/blob/main/examples/cdk-from-sar/lib/cdk-stacks.ts). 172 | 173 | > **Note** If you don't want to use the public SAR application, you can 174 | > [publish your own](#maintain-your-own-sar-application). 175 | 176 | ## Download a pre-built binary from GitHub 177 | 178 | Every new release of this project is automatically built and published to GitHub 179 | as a release asset. 180 | 181 | You can easily download the `ARM64` binary for a given release by using the 182 | following URL template: 183 | 184 | ```plain 185 | https://github.com/lmammino/oidc-authorizer/releases/download//bootstrap.zip 186 | ``` 187 | 188 | Make sure to replace `` with the actual version you intend to use. 189 | 190 | Once you download the binary, you can easily add it to your project and 191 | reference it in your SAM template, CDK project, Terraform configuration or 192 | whatever you are using to deploy your Lambda functions. 193 | 194 | Just make sure to define set the following Lambda properties as follow: 195 | 196 | ```yaml 197 | CodeUri: . # OR the path to the **directory** containing lambda binary (which needs to be unzipped) 198 | Handler: bootstrap 199 | Runtime: provided.al2 200 | Architectures: [arm64] 201 | ``` 202 | 203 | > **Note** `x86` binaries are currently not provided. If you want to use those, 204 | > you have to build them by yourself. 205 | 206 | ## Build the binary yourself 207 | 208 | If you have the [Rust toolchain](https://rustup.rs/) and 209 | [Cargo Lambda](https://www.cargo-lambda.info/) installed in your system. You can 210 | compile the binary yourself with the following command: 211 | 212 | ```bash 213 | cargo lambda build --arm64 --release 214 | ``` 215 | 216 | The compiled binary will be available in 217 | `target/lambda/oidc-authorizer/bootstrap`. 218 | 219 | > **Note**: `cargo lambda` also allows you to cross-compile for other 220 | > architectures and operative systems. 221 | > [Check out the official documentation to learn how to do that](https://www.cargo-lambda.info/guide/cross-compiling.html). 222 | 223 | ## Other approaches 224 | 225 | Other ways of building and deploying the Lambda function. 226 | 227 | ### Maintain your own SAR application 228 | 229 | This application is already published to SAR and 230 | [you can deploy it directly from there](#deploy-from-sar-serverless-application-repository). 231 | But, if you want to publish and maintain your own version, here's how you can do 232 | it. 233 | 234 | #### 1. Create a new S3 bucket and give it the right permissions 235 | 236 | ```bash 237 | aws s3 mb s3://${YOUR_OWN_BUCKET_NAME} 238 | ``` 239 | 240 | Then apply the following policy to the bucket: 241 | 242 | ```json 243 | { 244 | "Version": "2012-10-17", 245 | "Statement": [ 246 | { 247 | "Effect": "Allow", 248 | "Principal": { 249 | "Service": "serverlessrepo.amazonaws.com" 250 | }, 251 | "Action": "s3:GetObject", 252 | "Resource": "arn:aws:s3:::${YOUR_OWN_BUCKET_NAME}/*", 253 | "Condition": { 254 | "StringEquals": { 255 | "aws:SourceAccount": "${YOUR_OWN_ACCOUNT}" 256 | } 257 | } 258 | } 259 | ] 260 | } 261 | ``` 262 | 263 | Make sure to replace both `${YOUR_OWN_BUCKET_NAME}` and `${YOUR_OWN_ACCOUNT}` 264 | with the right values. 265 | 266 | #### 2. Build the lambda 267 | 268 | Requires the Rust toolchain to be installed, an updated version of 269 | `cargo-lambda` and `sam`: 270 | 271 | ```bash 272 | sam build 273 | sam package --output-template-file .aws-sam/packaged.yml --s3-bucket ${YOUR_OWN_BUCKET_NAME} 274 | ``` 275 | 276 | Make sure to replace `${YOUR_OWN_BUCKET_NAME}` with the right value. 277 | 278 | #### 3. Publish the application to SAR 279 | 280 | ```bash 281 | sam publish --template .aws-sam/packaged.yml --region ${YOUR_OWN_REGION} 282 | ``` 283 | 284 | Make sure to replace `${YOUR_OWN_REGION}` with the right value. 285 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # oidc-authorizer 2 | 3 | [![Rust](https://github.com/lmammino/oidc-authorizer/actions/workflows/rust.yml/badge.svg)](https://github.com/lmammino/oidc-authorizer/actions/workflows/rust.yml) 4 | [![codecov](https://codecov.io/gh/lmammino/oidc-authorizer/graph/badge.svg?token=P9bsrl4YQB)](https://codecov.io/gh/lmammino/oidc-authorizer) 5 | [![Published on SAR: Serverless Application Repository](https://img.shields.io/badge/SAR-Serverless%20Application%20Repository-FFB71B?style=solid&logo=amazonaws)](https://serverlessrepo.aws.amazon.com/applications/eu-west-1/795006566846/oidc-authorizer) 6 | 7 | 8 | A high-performance token-based API Gateway authorizer Lambda that can validate OIDC-issued JWT tokens. 9 | 10 | ![The OIDC-Authorizer logo represents a determinate otter dressed like a knight with a sword and a shield. The shield has the API Gateway logo on it.)](https://github.com/lmammino/oidc-authorizer/raw/main/docs/logo/oidc-authorizer-logo-small.png) 11 | 12 | 13 | ## 🤌 Use case 14 | 15 | This project provides an easy-to-install AWS Lambda function that can be used as a custom authorizer for AWS API Gateway. This authorizer can validate OIDC-issued JWT tokens and it can be used to secure your API endpoints using your OIDC provider of choice (e.g. Apple, Auth0, AWS Cognito, Azure AD / Microsoft Entra ID, Facebook, GitLab, Google, Keycloak, LinkedIn, Okta, Salesforce, Twitch, etc.). 16 | 17 | ![A diagram illustrating how this project can be integrated. A user sends an authenticated request to API Gateway. API Gateway is configured to use a custom lambda as an authorizer (THIS PROJECT!). The lambda talks with your OIDC provider to get the public key to validate the user token and responds to API Gateway to Allow or Deny the request.](https://github.com/lmammino/oidc-authorizer/raw/main/docs/lovely-diagram.png) 18 | 19 | > A diagram illustrating how this project can be integrated. 20 | > 21 | > A user sends an authenticated request to API Gateway. API Gateway is configured to use a custom lambda as an authorizer (THIS PROJECT!). The lambda talks with your OIDC provider to get the public key to validate the user token and responds to API Gateway to Allow or Deny the request. 22 | 23 | API Gateway currently exists in 2 flavours: **HTTP APIs** and **REST APIs**. As of today, only HTTP APIs implement a [built-in JWT authorizer](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html) that supports OIDC-issued tokens. 24 | 25 | You might want to consider using this project in the following cases: 26 | 27 | - You are using REST APIs and you want to secure your endpoints using OIDC-issued tokens. For instance, if you want to build APIs that are only available in a private VPC, you are currently forced to use REST APIs. 28 | - You are using HTTP APIs but your OIDC provider gives you tokens that are not signed with the RSA algorithm (currently the only one supported by the built-in JWT authorizer). 29 | - You want more flexibility in the validation process of your tokens. For instance, you might want to validate the `aud` claim of your tokens against a list of values, instead of a single value (which is the only option available with the built-in JWT authorizer). 30 | - You want to customise the validation process even further. In this case, you can fork this project and customise the validation logic to your needs. 31 | 32 | 33 | ## ⚽️ Design goals 34 | 35 | This custom Lambda Authorizer is designed to be **easy to install and configure**, **cheap**, **highly performant**, and **memory-efficient**. It is currently written in Rust, which is currently the fastest lambda Runtime in terms of cold start and it produces binaries that can provide best-in-class execution performance and a low memory footprint. Rust makes it also easy to compile the Authorizer Lambda for ARM, which helps even further with performance and cost. Ideally this Lambda, should provide minimal cost, even when used to protect Lambda functions that are invoked very frequently. 36 | 37 | 38 | ## 🚀 Installation 39 | 40 | This project is meant to be integrated into existing applications (after all, an authorizer is useless without an API). 41 | 42 | Different deployment options are available. Check out the [deployment docs](https://github.com/lmammino/oidc-authorizer/blob/main/docs/deploy.md) for an extensive explanation of all the possible approaches. 43 | 44 | Alternatively, you can also consult some of the quick examples in the [`examples` folder](https://github.com/lmammino/oidc-authorizer/blob/main/examples) 45 | 46 | If you prefer, you can also learn [how to host your own SAR application](https://github.com/lmammino/oidc-authorizer/blob/main/docs/deploy.md#maintain-your-own-sar-application). 47 | 48 | 49 | ## 🛠️ Configuration 50 | 51 | The authorizer needs to be configured to be adapted to your needs and to be able to communicate with your OIDC provider of choice. 52 | 53 | Here's a list of the configuration options that are supported: 54 | 55 | ### JwksUri 56 | 57 | - **Environment variable**: `JWKS_URI` 58 | - **Description**: The URL of the OIDC provider JWKS (Endpoint providing public keys for verification). 59 | - **Mandatory**: Yes 60 | 61 | 62 | ### MinRefreshRate 63 | 64 | - **Environment variable**: `MIN_REFRESH_RATE` 65 | - **Description**: The minimum number of seconds to wait before keys are refreshed when the given key is not found. 66 | - **Mandatory**: No 67 | - **Default value**: `"900"` (15 minutes) 68 | 69 | ### PrincipalIdClaims 70 | 71 | - **Environment variable**: `PRINCIPAL_ID_CLAIMS` 72 | - **Description**: A comma-separated list of claims defining the token fields that should be used to determine the principal Id from the token. The fields will be tested in order. If there's no match the value specified in the `DefaultPrincipalId` parameter will be used. 73 | - **Mandatory**: No 74 | - **Default value**: `"preferred_username, sub"` 75 | 76 | ### DefaultPrincipalId 77 | 78 | - **Environment variable**: `DEFAULT_PRINCIPAL_ID` 79 | - **Description**: A fallback value for the Principal ID to be used when a principal ID claim is not found in the token. 80 | - **Mandatory**: No 81 | - **Default value**: `"unknown"` 82 | 83 | ### AcceptedIssuers 84 | 85 | - **Environment variable**: `ACCEPTED_ISSUERS` 86 | - **Description**: A comma-separated list of accepted values for the `iss` claim. If one of the provided values matches, the token issuer is considered valid. If left empty, any issuer will be accepted. 87 | - **Mandatory**: No 88 | - **Default value**: `""` 89 | 90 | ### AcceptedAudiences 91 | 92 | - **Environment variable**: `ACCEPTED_AUDIENCES` 93 | - **Description**: A comma-separated list of accepted values for the `aud` claim. If one of the provided values matches, the token audience is considered valid. If left empty, any issuer audience be accepted. 94 | - **Mandatory**: No 95 | - **Default value**: `""` 96 | 97 | ### AcceptedAlgorithms 98 | 99 | - **Environment variable**: `ACCEPTED_ALGORITHMS` 100 | - **Description**: A comma-separated list of accepted signing algorithms. If one of the provided values matches, the token signing algorithm is considered valid. If left empty, any supported token signing algorithm is accepted. Supported values: `ES256`, `ES384`, `RS256`, `RS384`, `PS256`, `PS384`, `PS512`, `RS512`, `EdDSA` 101 | - **Mandatory**: No 102 | - **Default value**: `""` 103 | 104 | ### AwsLambdaLogLevel 105 | 106 | - **Environment variable**: `AWS_LAMBDA_LOG_LEVEL` 107 | - **Description**: The log level used when executing the authorizer lambda. You can set it to DEBUG to make it very verbose if you need more information 108 | to troubleshoot an issue. In general, you should not change this, because if you produce more logs than necessary that might have an impact on cost. 109 | Allowed values: `TRACE`, `DEBUG`, `INFO`, `WARN`, `ERROR`. 110 | - **Mandatory**: No 111 | - **Default value**: `"INFO"` 112 | 113 | ### StackPrefix 114 | 115 | - **Environment variable**: N/A (only applies to CloudFormation deployments through SAR) 116 | - **Description**: A prefix to be used for exported outputs. Useful if you need to deploy this stack multiple times in the same account. 117 | - **Mandatory**: No 118 | - **Default value**: `""` 119 | 120 | For instance, If you set this parameter to `"Test"`, the ARN of the deployed authorizer when using SAR will be exported as `"TestOidcAuthorizerArn"`. 121 | 122 | 123 | ## 🛑 Validation Flow 124 | 125 | The following section describes the steps that are followed to validate a token: 126 | 127 | 1. The token is parsed from the `Authorization` header of the request. It is expected to be in the form `Bearer `, where `` needs to be a valid JWT token. 128 | 2. The token is decoded and the header is parsed to extract the `kid` (key id) and the `alg` (algorithm) claims. If the `kid` is not found, the token is rejected. If the `alg` is not supported, the token is rejected. 129 | 3. The `kid` is used to look up the public key in the JWKS (JSON Web Key Set) provided by the OIDC provider. If the key is not found, the key is refreshed and the lookup is retried. If the key is still not found, the token is rejected. The JWKS cache is optimistic, it does not automatically refresh keys unless a lookup fails. It also does not auto-refresh keys too often (to avoid unnecessary calls to the JWKS endpoint). You can configure the minimum refresh rate (in seconds) using the `MIN_REFRESH_RATE` environment variable. 130 | 4. The token is decoded and validated using the public key. If the validation fails, the token is rejected. This validation also checks the `exp` (expiration time) claim and the `nbf` (not before) claim. If the token is expired or not yet valid, the token is rejected. 131 | 5. The `iss` (issuer) claim is checked against the list of accepted issuers. If the issuer is not found in the list, the token is rejected. If the accept list is empty, any issuer is accepted. If the token contains multiple issuers (array of strings), this check will make sure that at least one of the issuers in the token matches the provided list of accepted issuers. 132 | 6. The `aud` (audience) claim is checked against the list of accepted audiences. If the audience is not found in the list, the token is rejected. If the list is empty, any audience is accepted. If the token contains multiple audiences (array of strings), this check will make sure that at least one of the audiences in the token matches the provided list of accepted audiences. 133 | 7. If all these checks are passed, the token is considered valid and the request is allowed to proceed. The principal ID is extracted from the token using the list of principal ID claims. If no principal ID claim is found, the default principal ID is used. 134 | 135 | 136 | ## 🤑 Context Enrichment 137 | 138 | The authorizer enriches the context of the request with the following values: 139 | 140 | - `principalId`: the principal ID extracted from the token. 141 | - `jwtClaims`: a JSON string containing the entire token payload (claims). 142 | 143 | These values are injected into the context of the request and can be used to enrich your logging, tracing or to implement app-level authentication. 144 | 145 | When you use the [Lambda-proxy integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-proxy-integrations.html#api-gateway-create-api-as-simple-proxy) these values are made available under `event.requestContext.authorizer`. 146 | 147 | For example, this is how you can access the `principalId` and `jwtClaims` values in a Lambda function written in Python: 148 | 149 | ```python 150 | import json 151 | 152 | def handler(event, context): 153 | print('principalId: ') 154 | print(event['requestContext']['authorizer']['principalId']) 155 | 156 | print('jwtClaims: ') 157 | jwtClaims = json.loads(event['requestContext']['authorizer']['jwtClaims']) 158 | print(jwtClaims) 159 | 160 | return {'body': 'Hello', 'statusCode': 200} 161 | ``` 162 | 163 | 164 | ## 🏃‍♂️ Benchmarks 165 | 166 | We have benchmarked this authorizer against an equivalent Python implementation. And these are some of the main findings: 167 | 168 | - The Rust version is about 16 times faster than the Python version when it comes to cold starts (~42ms vs ~670ms). 169 | - Execution times are quite comparable between the two implementations, with the Rust version being only slightly faster. This is probably because the Python library used to do the JWT validation is quite optimized. 170 | - Memory utilization is about 3.5 times smaller in Rust (22MB vs 77MB). This allows us to use a smaller memory size for the Rust version, which results in a lower cost. 171 | - The cost per request is about 3 times smaller in Rust compared to Python (~1.44 USD vs ~4.13 USD per every 100Mln invocations). 172 | 173 | If you want to have a more detailed look at the benchmark methodology and the results, you can check out the [dedicated benchmarking repository](https://github.com/lmammino/oidc-authorizer-benchmark). 174 | 175 | 176 | ## 🙌 Contributing 177 | 178 | Everyone is very welcome to contribute to this project. 179 | You can contribute just by submitting bugs or suggesting improvements by 180 | [opening an issue on GitHub](https://github.com/lmammino/oidc-authorizer/issues). 181 | 182 | 183 | ## 👨‍⚖️ License 184 | 185 | Licensed under [MIT License](LICENSE). © Luciano Mammino. 186 | 187 | 188 | ## 🙏 Acknowledgements 189 | 190 | Big thanks to: 191 | 192 | - [@Lodewyk11](https://github.com/Lodewyk11) & [@gsingh1](https://github.com/gsingh1) for writing the original Python implementation that inspired this work. 193 | - [@eoinsha](https://github.com/eoinsha) for suggesting [various ways to package and distribute this project](https://awsbites.com/101-package-and-distribute-lambda-functions-for-fun-and-profit/). 194 | - [@allevo](https://github.com/allevo) for tons of great Rust suggestions. 195 | - [@alexdebrie](https://github.com/alexdebrie) for his amazing [article on custom ApiGateway Authorizers](https://www.alexdebrie.com/posts/lambda-custom-authorizers/). 196 | -------------------------------------------------------------------------------- /examples/cdk-from-sar/cloudformation.yaml: -------------------------------------------------------------------------------- 1 | Transform: AWS::Serverless-2016-10-31 2 | Resources: 3 | AuthorizerApp: 4 | Type: AWS::Serverless::Application 5 | Properties: 6 | Location: 7 | ApplicationId: arn:aws:serverlessrepo:eu-west-1:795006566846:applications/oidc-authorizer 8 | SemanticVersion: 0.2.0 9 | Parameters: 10 | AcceptedAlgorithms: "" 11 | AcceptedAudiences: "" 12 | AcceptedIssuers: "" 13 | DefaultPrincipalId: unknown 14 | JwksUri: https://login.microsoftonline.com/3e4abf5a-fdc9-485c-9853-af03c4a32976/discovery/v2.0/keys 15 | MinRefreshRate: "900" 16 | PrincipalIdClaims: preferred_username, sub 17 | LambdaMemorySize: "128" 18 | LambdaTimeout: "3" 19 | Metadata: 20 | aws:cdk:path: CdkStack/AuthorizerApp 21 | AuthorizerBD825682: 22 | Type: AWS::ApiGateway::Authorizer 23 | Properties: 24 | AuthorizerResultTtlInSeconds: 300 25 | AuthorizerUri: 26 | Fn::Join: 27 | - "" 28 | - - "arn:" 29 | - Fn::Select: 30 | - 1 31 | - Fn::Split: 32 | - ":" 33 | - Fn::GetAtt: 34 | - AuthorizerApp 35 | - Outputs.OidcAuthorizerArn 36 | - ":apigateway:" 37 | - Fn::Select: 38 | - 3 39 | - Fn::Split: 40 | - ":" 41 | - Fn::GetAtt: 42 | - AuthorizerApp 43 | - Outputs.OidcAuthorizerArn 44 | - :lambda:path/2015-03-31/functions/ 45 | - Fn::GetAtt: 46 | - AuthorizerApp 47 | - Outputs.OidcAuthorizerArn 48 | - /invocations 49 | IdentitySource: method.request.header.Authorization 50 | Name: OidcAuthorizer 51 | RestApiId: 52 | Ref: apiC8550315 53 | Type: TOKEN 54 | Metadata: 55 | aws:cdk:path: CdkStack/Authorizer/Resource 56 | apiC8550315: 57 | Type: AWS::ApiGateway::RestApi 58 | Properties: 59 | Description: A demo app to test the OIDC authorizer 60 | EndpointConfiguration: 61 | Types: 62 | - REGIONAL 63 | Name: OIDC Authorizer Demo 64 | Metadata: 65 | aws:cdk:path: CdkStack/api/Resource 66 | apiDeployment149F1294fa23010acd6c6cfc40f88a7715a97da0: 67 | Type: AWS::ApiGateway::Deployment 68 | Properties: 69 | Description: A demo app to test the OIDC authorizer 70 | RestApiId: 71 | Ref: apiC8550315 72 | DependsOn: 73 | - api1GETC9B5EB93 74 | - api198080521 75 | - api2GET6EAF9814 76 | - api2FB7A46B5 77 | - AuthorizerBD825682 78 | Metadata: 79 | aws:cdk:path: CdkStack/api/Deployment/Resource 80 | apiDeploymentStageprod896C8101: 81 | Type: AWS::ApiGateway::Stage 82 | Properties: 83 | DeploymentId: 84 | Ref: apiDeployment149F1294fa23010acd6c6cfc40f88a7715a97da0 85 | MethodSettings: 86 | - DataTraceEnabled: true 87 | HttpMethod: "*" 88 | LoggingLevel: INFO 89 | MetricsEnabled: true 90 | ResourcePath: /* 91 | RestApiId: 92 | Ref: apiC8550315 93 | StageName: prod 94 | TracingEnabled: true 95 | Metadata: 96 | aws:cdk:path: CdkStack/api/DeploymentStage.prod/Resource 97 | api198080521: 98 | Type: AWS::ApiGateway::Resource 99 | Properties: 100 | ParentId: 101 | Fn::GetAtt: 102 | - apiC8550315 103 | - RootResourceId 104 | PathPart: "1" 105 | RestApiId: 106 | Ref: apiC8550315 107 | Metadata: 108 | aws:cdk:path: CdkStack/api/Default/1/Resource 109 | api1GETApiPermissionCdkStackapi621B3AACGET10F23FEED: 110 | Type: AWS::Lambda::Permission 111 | Properties: 112 | Action: lambda:InvokeFunction 113 | FunctionName: 114 | Fn::GetAtt: 115 | - sampleApiLambda1D400C123 116 | - Arn 117 | Principal: apigateway.amazonaws.com 118 | SourceArn: 119 | Fn::Join: 120 | - "" 121 | - - "arn:" 122 | - Ref: AWS::Partition 123 | - ":execute-api:" 124 | - Ref: AWS::Region 125 | - ":" 126 | - Ref: AWS::AccountId 127 | - ":" 128 | - Ref: apiC8550315 129 | - / 130 | - Ref: apiDeploymentStageprod896C8101 131 | - /GET/1 132 | Metadata: 133 | aws:cdk:path: CdkStack/api/Default/1/GET/ApiPermission.CdkStackapi621B3AAC.GET..1 134 | api1GETApiPermissionTestCdkStackapi621B3AACGET1F0F84B75: 135 | Type: AWS::Lambda::Permission 136 | Properties: 137 | Action: lambda:InvokeFunction 138 | FunctionName: 139 | Fn::GetAtt: 140 | - sampleApiLambda1D400C123 141 | - Arn 142 | Principal: apigateway.amazonaws.com 143 | SourceArn: 144 | Fn::Join: 145 | - "" 146 | - - "arn:" 147 | - Ref: AWS::Partition 148 | - ":execute-api:" 149 | - Ref: AWS::Region 150 | - ":" 151 | - Ref: AWS::AccountId 152 | - ":" 153 | - Ref: apiC8550315 154 | - /test-invoke-stage/GET/1 155 | Metadata: 156 | aws:cdk:path: CdkStack/api/Default/1/GET/ApiPermission.Test.CdkStackapi621B3AAC.GET..1 157 | api1GETC9B5EB93: 158 | Type: AWS::ApiGateway::Method 159 | Properties: 160 | AuthorizationType: CUSTOM 161 | AuthorizerId: 162 | Ref: AuthorizerBD825682 163 | HttpMethod: GET 164 | Integration: 165 | IntegrationHttpMethod: POST 166 | Type: AWS_PROXY 167 | Uri: 168 | Fn::Join: 169 | - "" 170 | - - "arn:" 171 | - Ref: AWS::Partition 172 | - ":apigateway:" 173 | - Ref: AWS::Region 174 | - :lambda:path/2015-03-31/functions/ 175 | - Fn::GetAtt: 176 | - sampleApiLambda1D400C123 177 | - Arn 178 | - /invocations 179 | ResourceId: 180 | Ref: api198080521 181 | RestApiId: 182 | Ref: apiC8550315 183 | Metadata: 184 | aws:cdk:path: CdkStack/api/Default/1/GET/Resource 185 | api2FB7A46B5: 186 | Type: AWS::ApiGateway::Resource 187 | Properties: 188 | ParentId: 189 | Fn::GetAtt: 190 | - apiC8550315 191 | - RootResourceId 192 | PathPart: "2" 193 | RestApiId: 194 | Ref: apiC8550315 195 | Metadata: 196 | aws:cdk:path: CdkStack/api/Default/2/Resource 197 | api2GETApiPermissionCdkStackapi621B3AACGET28201AE47: 198 | Type: AWS::Lambda::Permission 199 | Properties: 200 | Action: lambda:InvokeFunction 201 | FunctionName: 202 | Fn::GetAtt: 203 | - sampleApiLambda2CD2145FA 204 | - Arn 205 | Principal: apigateway.amazonaws.com 206 | SourceArn: 207 | Fn::Join: 208 | - "" 209 | - - "arn:" 210 | - Ref: AWS::Partition 211 | - ":execute-api:" 212 | - Ref: AWS::Region 213 | - ":" 214 | - Ref: AWS::AccountId 215 | - ":" 216 | - Ref: apiC8550315 217 | - / 218 | - Ref: apiDeploymentStageprod896C8101 219 | - /GET/2 220 | Metadata: 221 | aws:cdk:path: CdkStack/api/Default/2/GET/ApiPermission.CdkStackapi621B3AAC.GET..2 222 | api2GETApiPermissionTestCdkStackapi621B3AACGET2FFD20B2A: 223 | Type: AWS::Lambda::Permission 224 | Properties: 225 | Action: lambda:InvokeFunction 226 | FunctionName: 227 | Fn::GetAtt: 228 | - sampleApiLambda2CD2145FA 229 | - Arn 230 | Principal: apigateway.amazonaws.com 231 | SourceArn: 232 | Fn::Join: 233 | - "" 234 | - - "arn:" 235 | - Ref: AWS::Partition 236 | - ":execute-api:" 237 | - Ref: AWS::Region 238 | - ":" 239 | - Ref: AWS::AccountId 240 | - ":" 241 | - Ref: apiC8550315 242 | - /test-invoke-stage/GET/2 243 | Metadata: 244 | aws:cdk:path: CdkStack/api/Default/2/GET/ApiPermission.Test.CdkStackapi621B3AAC.GET..2 245 | api2GET6EAF9814: 246 | Type: AWS::ApiGateway::Method 247 | Properties: 248 | AuthorizationType: CUSTOM 249 | AuthorizerId: 250 | Ref: AuthorizerBD825682 251 | HttpMethod: GET 252 | Integration: 253 | IntegrationHttpMethod: POST 254 | Type: AWS_PROXY 255 | Uri: 256 | Fn::Join: 257 | - "" 258 | - - "arn:" 259 | - Ref: AWS::Partition 260 | - ":apigateway:" 261 | - Ref: AWS::Region 262 | - :lambda:path/2015-03-31/functions/ 263 | - Fn::GetAtt: 264 | - sampleApiLambda2CD2145FA 265 | - Arn 266 | - /invocations 267 | ResourceId: 268 | Ref: api2FB7A46B5 269 | RestApiId: 270 | Ref: apiC8550315 271 | Metadata: 272 | aws:cdk:path: CdkStack/api/Default/2/GET/Resource 273 | sampleApiLambda1ServiceRole592A5257: 274 | Type: AWS::IAM::Role 275 | Properties: 276 | AssumeRolePolicyDocument: 277 | Statement: 278 | - Action: sts:AssumeRole 279 | Effect: Allow 280 | Principal: 281 | Service: lambda.amazonaws.com 282 | Version: "2012-10-17" 283 | ManagedPolicyArns: 284 | - Fn::Join: 285 | - "" 286 | - - "arn:" 287 | - Ref: AWS::Partition 288 | - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 289 | Metadata: 290 | aws:cdk:path: CdkStack/sampleApiLambda1/ServiceRole/Resource 291 | sampleApiLambda1D400C123: 292 | Type: AWS::Lambda::Function 293 | Properties: 294 | Code: 295 | ZipFile: | 296 | 297 | def handler(event, context): 298 | return {'body': 'Hello from endpoint1!', 'statusCode': 200} 299 | Handler: index.handler 300 | Role: 301 | Fn::GetAtt: 302 | - sampleApiLambda1ServiceRole592A5257 303 | - Arn 304 | Runtime: python3.9 305 | DependsOn: 306 | - sampleApiLambda1ServiceRole592A5257 307 | Metadata: 308 | aws:cdk:path: CdkStack/sampleApiLambda1/Resource 309 | sampleApiLambda2ServiceRole7C21309E: 310 | Type: AWS::IAM::Role 311 | Properties: 312 | AssumeRolePolicyDocument: 313 | Statement: 314 | - Action: sts:AssumeRole 315 | Effect: Allow 316 | Principal: 317 | Service: lambda.amazonaws.com 318 | Version: "2012-10-17" 319 | ManagedPolicyArns: 320 | - Fn::Join: 321 | - "" 322 | - - "arn:" 323 | - Ref: AWS::Partition 324 | - :iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 325 | Metadata: 326 | aws:cdk:path: CdkStack/sampleApiLambda2/ServiceRole/Resource 327 | sampleApiLambda2CD2145FA: 328 | Type: AWS::Lambda::Function 329 | Properties: 330 | Code: 331 | ZipFile: | 332 | 333 | def handler(event, context): 334 | return {'body': 'Hello ' + event['requestContext']['authorizer']['principalId'] + ' from endpoint2! 335 | These are your claims: ' + event['requestContext']['authorizer']['jwtClaims'], 'statusCode': 200} 336 | Handler: index.handler 337 | Role: 338 | Fn::GetAtt: 339 | - sampleApiLambda2ServiceRole7C21309E 340 | - Arn 341 | Runtime: python3.9 342 | DependsOn: 343 | - sampleApiLambda2ServiceRole7C21309E 344 | Metadata: 345 | aws:cdk:path: CdkStack/sampleApiLambda2/Resource 346 | CDKMetadata: 347 | Type: AWS::CDK::Metadata 348 | Properties: 349 | Analytics: v2:deflate64:H4sIAAAAAAAA/22QOU8DMRCFfwu91zkkipRLEB0CbdKjiT2EyfqSj0TB2v8eO2alFFTzveenObzmq9UzXz7BJXRCjp2iA8+7CGJkxfrKATTffpveOUUCIlnDFOiDBJ7fkhHVeIGArGQ+0WsKoUbmp2rPPDFwdISIF7jyvLcjmj7FH+vpF30NPqgBQ+wdVXfGV3TKXjWaWN0HVZY93uc3KHmbvMD7VrP4a9T4HcscWa1GE6NyZB6sarlSp6nSR4ouxf+bFN5aI6ldZqxEfgqL83rJN+U3T4Go88lE0siHVm/FMheGagEAAA== 350 | Metadata: 351 | aws:cdk:path: CdkStack/CDKMetadata/Default 352 | Condition: CDKMetadataAvailable 353 | Outputs: 354 | apiEndpoint9349E63C: 355 | Value: 356 | Fn::Join: 357 | - "" 358 | - - https:// 359 | - Ref: apiC8550315 360 | - .execute-api. 361 | - Ref: AWS::Region 362 | - "." 363 | - Ref: AWS::URLSuffix 364 | - / 365 | - Ref: apiDeploymentStageprod896C8101 366 | - / 367 | ApiEndpoint1: 368 | Description: API Gateway endpoint 1 369 | Value: 370 | Fn::Join: 371 | - "" 372 | - - https:// 373 | - Ref: apiC8550315 374 | - .execute-api. 375 | - Ref: AWS::Region 376 | - "." 377 | - Ref: AWS::URLSuffix 378 | - / 379 | - Ref: apiDeploymentStageprod896C8101 380 | - /1 381 | ApiEndpoint2: 382 | Description: API Gateway endpoint 2 383 | Value: 384 | Fn::Join: 385 | - "" 386 | - - https:// 387 | - Ref: apiC8550315 388 | - .execute-api. 389 | - Ref: AWS::Region 390 | - "." 391 | - Ref: AWS::URLSuffix 392 | - / 393 | - Ref: apiDeploymentStageprod896C8101 394 | - /2 395 | Conditions: 396 | CDKMetadataAvailable: 397 | Fn::Or: 398 | - Fn::Or: 399 | - Fn::Equals: 400 | - Ref: AWS::Region 401 | - af-south-1 402 | - Fn::Equals: 403 | - Ref: AWS::Region 404 | - ap-east-1 405 | - Fn::Equals: 406 | - Ref: AWS::Region 407 | - ap-northeast-1 408 | - Fn::Equals: 409 | - Ref: AWS::Region 410 | - ap-northeast-2 411 | - Fn::Equals: 412 | - Ref: AWS::Region 413 | - ap-south-1 414 | - Fn::Equals: 415 | - Ref: AWS::Region 416 | - ap-southeast-1 417 | - Fn::Equals: 418 | - Ref: AWS::Region 419 | - ap-southeast-2 420 | - Fn::Equals: 421 | - Ref: AWS::Region 422 | - ca-central-1 423 | - Fn::Equals: 424 | - Ref: AWS::Region 425 | - cn-north-1 426 | - Fn::Equals: 427 | - Ref: AWS::Region 428 | - cn-northwest-1 429 | - Fn::Or: 430 | - Fn::Equals: 431 | - Ref: AWS::Region 432 | - eu-central-1 433 | - Fn::Equals: 434 | - Ref: AWS::Region 435 | - eu-north-1 436 | - Fn::Equals: 437 | - Ref: AWS::Region 438 | - eu-south-1 439 | - Fn::Equals: 440 | - Ref: AWS::Region 441 | - eu-west-1 442 | - Fn::Equals: 443 | - Ref: AWS::Region 444 | - eu-west-2 445 | - Fn::Equals: 446 | - Ref: AWS::Region 447 | - eu-west-3 448 | - Fn::Equals: 449 | - Ref: AWS::Region 450 | - il-central-1 451 | - Fn::Equals: 452 | - Ref: AWS::Region 453 | - me-central-1 454 | - Fn::Equals: 455 | - Ref: AWS::Region 456 | - me-south-1 457 | - Fn::Equals: 458 | - Ref: AWS::Region 459 | - sa-east-1 460 | - Fn::Or: 461 | - Fn::Equals: 462 | - Ref: AWS::Region 463 | - us-east-1 464 | - Fn::Equals: 465 | - Ref: AWS::Region 466 | - us-east-2 467 | - Fn::Equals: 468 | - Ref: AWS::Region 469 | - us-west-1 470 | - Fn::Equals: 471 | - Ref: AWS::Region 472 | - us-west-2 473 | Parameters: 474 | BootstrapVersion: 475 | Type: AWS::SSM::Parameter::Value 476 | Default: /cdk-bootstrap/hnb659fds/version 477 | Description: Version of the CDK Bootstrap resources in this environment, automatically retrieved from SSM Parameter Store. [cdk:skip] 478 | Rules: 479 | CheckBootstrapVersion: 480 | Assertions: 481 | - Assert: 482 | Fn::Not: 483 | - Fn::Contains: 484 | - - "1" 485 | - "2" 486 | - "3" 487 | - "4" 488 | - "5" 489 | - Ref: BootstrapVersion 490 | AssertDescription: CDK bootstrap stack version 6 required. Please run 'cdk bootstrap' with a recent version of the CDK CLI. 491 | -------------------------------------------------------------------------------- /src/handler.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | accepted_algorithms::AcceptedAlgorithms, 3 | accepted_claims::AcceptedClaims, 4 | keys_storage::KeysStorage, 5 | models::{TokenAuthorizerEvent, TokenAuthorizerResponse}, 6 | parse_token_from_header::parse_token_from_header, 7 | principalid_claims::PrincipalIDClaims, 8 | }; 9 | use futures_util::future::{BoxFuture, FutureExt}; 10 | use jsonwebtoken::{decode, decode_header, Validation}; 11 | use lambda_runtime::{Error, LambdaEvent, Service}; 12 | use std::task::{Context, Poll}; 13 | 14 | pub struct Handler { 15 | pub keys: &'static KeysStorage, 16 | pub principal_id_claims: &'static PrincipalIDClaims, 17 | pub accepted_issuers: &'static AcceptedClaims, 18 | pub accepted_audiences: &'static AcceptedClaims, 19 | pub accepted_signing_algorithms: &'static AcceptedAlgorithms, 20 | } 21 | 22 | impl Handler { 23 | pub fn new( 24 | keys: &'static KeysStorage, 25 | principal_id_claims: &'static PrincipalIDClaims, 26 | accepted_issuers: &'static AcceptedClaims, 27 | accepted_audiences: &'static AcceptedClaims, 28 | accepted_signing_algorithms: &'static AcceptedAlgorithms, 29 | ) -> Self { 30 | Self { 31 | keys, 32 | principal_id_claims, 33 | accepted_issuers, 34 | accepted_audiences, 35 | accepted_signing_algorithms, 36 | } 37 | } 38 | 39 | async fn do_call(self, event: TokenAuthorizerEvent) -> Result { 40 | // TODO: custom metrics using EMF logs 41 | // extract token from header 42 | let token = match parse_token_from_header(&event.authorization_token) { 43 | Ok(token) => token, 44 | Err(e) => { 45 | tracing::info!( 46 | "Failed to extract token fron header (header_value='{}'): {}", 47 | event.authorization_token, 48 | e 49 | ); 50 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 51 | } 52 | }; 53 | 54 | // parse token header 55 | let token_header = match decode_header(token) { 56 | Ok(token_header) => token_header, 57 | Err(e) => { 58 | tracing::info!("Failed to parse token header (token='{}'): {}", token, e); 59 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 60 | } 61 | }; 62 | 63 | // validate the signing algorithm 64 | if let Err(e) = self.accepted_signing_algorithms.assert(&token_header.alg) { 65 | tracing::info!(e); 66 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 67 | } 68 | 69 | let key = match token_header.kid { 70 | Some(key_id) => match self.keys.get(&key_id).await { 71 | Ok(key) => key, 72 | Err(e) => { 73 | tracing::info!("Failed to retrieve key (key_id='{}'): {}", key_id, e); 74 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 75 | } 76 | }, 77 | None => { 78 | tracing::info!( 79 | "Missing kid in token header (token_header='{:?}')", 80 | token_header 81 | ); 82 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 83 | } 84 | }; 85 | 86 | let mut validation = Validation::new(token_header.alg); 87 | validation.set_audience(&self.accepted_audiences.accepted_values()); 88 | validation.set_issuer(&self.accepted_issuers.accepted_values()); 89 | let token_payload = match decode::(token, &key, &validation) { 90 | Ok(token_payload) => token_payload, 91 | Err(e) => { 92 | tracing::info!("Failed to validate token (token='{}'): {}", token, e); 93 | return Ok(TokenAuthorizerResponse::deny(&event.method_arn)); 94 | } 95 | }; 96 | 97 | let principal_id = self 98 | .principal_id_claims 99 | .get_principal_id_from_claims(&token_payload.claims); 100 | 101 | Ok(TokenAuthorizerResponse::allow( 102 | &principal_id, 103 | &token_payload.claims, 104 | )) 105 | } 106 | } 107 | 108 | impl Clone for Handler { 109 | fn clone(&self) -> Self { 110 | Self { 111 | keys: self.keys, 112 | principal_id_claims: self.principal_id_claims, 113 | accepted_issuers: self.accepted_issuers, 114 | accepted_audiences: self.accepted_audiences, 115 | accepted_signing_algorithms: self.accepted_signing_algorithms, 116 | } 117 | } 118 | } 119 | 120 | impl Service> for Handler { 121 | type Response = TokenAuthorizerResponse; 122 | type Error = Error; 123 | type Future = BoxFuture<'static, Result>; 124 | 125 | fn poll_ready(&mut self, _: &mut Context<'_>) -> Poll> { 126 | Ok(()).into() 127 | } 128 | 129 | fn call(&mut self, req: LambdaEvent) -> Self::Future { 130 | let (event, _) = req.into_parts(); 131 | self.clone().do_call(event).boxed() 132 | } 133 | } 134 | 135 | #[cfg(test)] 136 | mod tests { 137 | use super::*; 138 | use chrono::{Duration, Utc}; 139 | use httpmock::prelude::*; 140 | use jsonwebtoken::{Algorithm, EncodingKey, Header}; 141 | use reqwest::Url; 142 | use serde_json::json; 143 | use tracing_test::traced_test; 144 | 145 | fn make_simple_handler() -> Handler { 146 | let key_storage = Box::leak(Box::new(KeysStorage::new( 147 | Url::parse("http://localhost").unwrap(), 148 | Duration::try_seconds(600).unwrap(), 149 | ))); 150 | let principal_id_claims = 151 | Box::leak(Box::new(PrincipalIDClaims::from_comma_separated_values( 152 | "preferred_username, sub", 153 | "unknown".to_string(), 154 | ))); 155 | let accepted_issuers = Box::leak(Box::new(AcceptedClaims::from_comma_separated_values( 156 | "http://localhost", 157 | "iss".to_string(), 158 | ))); 159 | let accepted_audiences = Box::leak(Box::new(AcceptedClaims::from_comma_separated_values( 160 | "test-app", 161 | "aud".to_string(), 162 | ))); 163 | let accepted_signing_algorithms = Box::leak(Box::default()); 164 | 165 | Handler::new( 166 | key_storage, 167 | principal_id_claims, 168 | accepted_issuers, 169 | accepted_audiences, 170 | accepted_signing_algorithms, 171 | ) 172 | } 173 | 174 | async fn test_with(algorithm: Algorithm, encoding_key: EncodingKey, jwk: &str, kid: &str) { 175 | // create a mock server that will serve the jwks 176 | let server = MockServer::start(); 177 | let jwks_mock = server.mock(|when, then| { 178 | when.method(GET).path("/"); 179 | then.status(200) 180 | .header("content-type", "application/json") 181 | .body(format!("{{\"keys\":[ {} ]}}", jwk)); 182 | }); 183 | 184 | let server_url = server.url("/"); 185 | let iss = server_url.clone(); 186 | let aud = "test-app"; 187 | // SAFETY: safe to unwrap since (1 hour = 3600 seconds) <= (i64::MAX / 1000) 188 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 189 | 190 | // creates a token using the private PEM 191 | let token_header: Header = 192 | serde_json::from_value(json!({ "alg": algorithm, "kid": kid })).unwrap(); 193 | let token = jsonwebtoken::encode( 194 | &token_header, 195 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 196 | &encoding_key, 197 | ); 198 | assert!(token.is_ok()); 199 | let token = token.unwrap(); 200 | 201 | // instantiates an handler 202 | // SAFETY: safe to unwrap since (600 seconds) <= (i64::MAX / 1000) 203 | let min_refresh_rate = Duration::try_seconds(600).unwrap(); 204 | let jwks_uri = server_url.clone(); 205 | let principal_id_claims = PrincipalIDClaims::from_comma_separated_values( 206 | "preferred_username, sub", 207 | "unknown".to_string(), 208 | ); 209 | let accepted_issuers = AcceptedClaims::from_comma_separated_values(&iss, "iss".to_string()); 210 | let accepted_audiences = 211 | AcceptedClaims::from_comma_separated_values(aud, "aud".to_string()); 212 | let accepted_signing_algorithms: AcceptedAlgorithms = Default::default(); 213 | let keys = KeysStorage::new(Url::parse(&jwks_uri).unwrap(), min_refresh_rate); 214 | let mut handler = Handler::new( 215 | Box::leak(Box::new(keys)), 216 | Box::leak(Box::new(principal_id_claims)), 217 | Box::leak(Box::new(accepted_issuers)), 218 | Box::leak(Box::new(accepted_audiences)), 219 | Box::leak(Box::new(accepted_signing_algorithms)), 220 | ); 221 | 222 | // creates the event 223 | let event = TokenAuthorizerEvent { 224 | authorization_token: format!("Bearer {}", token), 225 | method_arn: "arn:aws:execute-api:us-east-1:123456789012:ymy8tbxw7b/*/GET/".to_string(), 226 | }; 227 | 228 | // calls the handler service and get the response 229 | let request = LambdaEvent::new(event, Default::default()); 230 | let response = handler.call(request).await; 231 | 232 | jwks_mock.assert(); 233 | assert!(response.is_ok()); 234 | let response = response.unwrap(); 235 | assert_eq!( 236 | response.policy_document.statement.first().unwrap().effect, 237 | "Allow" 238 | ); 239 | assert_eq!(response.principal_id, "some_user"); 240 | } 241 | 242 | #[tokio::test] 243 | #[traced_test] 244 | async fn it_validates_rs256_tokens() { 245 | let private_key = include_str!("../tests/fixtures/keys/rs256/private.pem"); 246 | test_with( 247 | Algorithm::RS256, 248 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 249 | include_str!("../tests/fixtures/keys/rs256/jwk.json"), 250 | "test/keys/rs256/public", 251 | ) 252 | .await; 253 | } 254 | 255 | #[tokio::test] 256 | #[traced_test] 257 | async fn it_validates_rs384_tokens() { 258 | let private_key = include_str!("../tests/fixtures/keys/rs384/private.pem"); 259 | test_with( 260 | Algorithm::RS384, 261 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 262 | include_str!("../tests/fixtures/keys/rs384/jwk.json"), 263 | "test/keys/rs384/public", 264 | ) 265 | .await; 266 | } 267 | 268 | #[tokio::test] 269 | #[traced_test] 270 | async fn it_validates_rs512_tokens() { 271 | let private_key = include_str!("../tests/fixtures/keys/rs512/private.pem"); 272 | test_with( 273 | Algorithm::RS512, 274 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 275 | include_str!("../tests/fixtures/keys/rs512/jwk.json"), 276 | "test/keys/rs512/public", 277 | ) 278 | .await; 279 | } 280 | 281 | #[tokio::test] 282 | #[traced_test] 283 | async fn it_validates_es256_tokens() { 284 | let private_key = include_str!("../tests/fixtures/keys/es256/private.pem"); 285 | test_with( 286 | Algorithm::ES256, 287 | EncodingKey::from_ec_pem(private_key.as_bytes()).unwrap(), 288 | include_str!("../tests/fixtures/keys/es256/jwk.json"), 289 | "test/keys/es256/public", 290 | ) 291 | .await; 292 | } 293 | 294 | #[tokio::test] 295 | #[traced_test] 296 | async fn it_validates_es384_tokens() { 297 | let private_key = include_str!("../tests/fixtures/keys/es384/private.pem"); 298 | test_with( 299 | Algorithm::ES384, 300 | EncodingKey::from_ec_pem(private_key.as_bytes()).unwrap(), 301 | include_str!("../tests/fixtures/keys/es384/jwk.json"), 302 | "test/keys/es384/public", 303 | ) 304 | .await; 305 | } 306 | 307 | #[tokio::test] 308 | #[traced_test] 309 | async fn it_validates_ps256_tokens() { 310 | let private_key = include_str!("../tests/fixtures/keys/ps256/private.pem"); 311 | test_with( 312 | Algorithm::PS256, 313 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 314 | include_str!("../tests/fixtures/keys/ps256/jwk.json"), 315 | "test/keys/ps256/public", 316 | ) 317 | .await; 318 | } 319 | 320 | #[tokio::test] 321 | #[traced_test] 322 | async fn it_validates_ps384_tokens() { 323 | let private_key = include_str!("../tests/fixtures/keys/ps384/private.pem"); 324 | test_with( 325 | Algorithm::PS384, 326 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 327 | include_str!("../tests/fixtures/keys/ps384/jwk.json"), 328 | "test/keys/ps384/public", 329 | ) 330 | .await; 331 | } 332 | 333 | #[tokio::test] 334 | #[traced_test] 335 | async fn it_validates_ps512_tokens() { 336 | let private_key = include_str!("../tests/fixtures/keys/ps512/private.pem"); 337 | test_with( 338 | Algorithm::PS512, 339 | EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(), 340 | include_str!("../tests/fixtures/keys/ps512/jwk.json"), 341 | "test/keys/ps512/public", 342 | ) 343 | .await; 344 | } 345 | 346 | #[tokio::test] 347 | #[traced_test] 348 | async fn it_validates_eddsa_tokens() { 349 | let private_key = include_str!("../tests/fixtures/keys/eddsa/private.pem"); 350 | test_with( 351 | Algorithm::EdDSA, 352 | EncodingKey::from_ed_pem(private_key.as_bytes()).unwrap(), 353 | include_str!("../tests/fixtures/keys/eddsa/jwk.json"), 354 | "test/keys/eddsa/public", 355 | ) 356 | .await; 357 | } 358 | 359 | #[tokio::test] 360 | #[traced_test] 361 | async fn it_denies_if_cant_get_token_from_header() { 362 | let event = TokenAuthorizerEvent { 363 | authorization_token: "NotBearer sometoken".to_string(), 364 | method_arn: "some_arn".to_string(), 365 | }; 366 | 367 | let handler = make_simple_handler(); 368 | let response = handler.do_call(event).await; 369 | 370 | assert!(response.is_ok()); 371 | let response = response.unwrap(); 372 | let statement = response.policy_document.statement.first().unwrap(); 373 | assert_eq!(statement.effect, "Deny"); 374 | assert_eq!(statement.resource, "some_arn"); 375 | } 376 | 377 | #[tokio::test] 378 | #[traced_test] 379 | async fn it_denies_if_cant_parse_from_header() { 380 | let event = TokenAuthorizerEvent { 381 | authorization_token: "Bearer not_a_jwt".to_string(), 382 | method_arn: "some_arn".to_string(), 383 | }; 384 | let handler = make_simple_handler(); 385 | 386 | let response = handler.do_call(event).await; 387 | 388 | assert!(response.is_ok()); 389 | let response = response.unwrap(); 390 | let statement = response.policy_document.statement.first().unwrap(); 391 | assert_eq!(statement.effect, "Deny"); 392 | assert_eq!(statement.resource, "some_arn"); 393 | } 394 | 395 | #[tokio::test] 396 | #[traced_test] 397 | async fn it_denies_if_the_token_uses_an_unsupported_algorithm() { 398 | let iss = "some_iss"; 399 | let aud = "test-app"; 400 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 401 | let encoding_key = 402 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 403 | .unwrap(); 404 | let token_header: Header = 405 | serde_json::from_value(json!({ "alg": Algorithm::RS256, "kid": "somekey" })).unwrap(); 406 | let token = jsonwebtoken::encode( 407 | &token_header, 408 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 409 | &encoding_key, 410 | ).unwrap(); 411 | let event = TokenAuthorizerEvent { 412 | authorization_token: format!("Bearer {}", token), 413 | method_arn: "some_arn".to_string(), 414 | }; 415 | let accepted_signing_algorithms: AcceptedAlgorithms = "ES256".parse().unwrap(); 416 | let accepted_signing_algorithms = Box::leak(Box::new(accepted_signing_algorithms)); 417 | let mut handler = make_simple_handler(); 418 | handler.accepted_signing_algorithms = accepted_signing_algorithms; 419 | 420 | let response = handler.do_call(event).await; 421 | 422 | assert!(response.is_ok()); 423 | let response = response.unwrap(); 424 | let statement = response.policy_document.statement.first().unwrap(); 425 | assert_eq!(statement.effect, "Deny"); 426 | assert_eq!(statement.resource, "some_arn"); 427 | } 428 | 429 | #[tokio::test] 430 | #[traced_test] 431 | async fn it_denies_if_the_token_has_no_kid() { 432 | let iss = "some_iss"; 433 | let aud = "test-app"; 434 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 435 | let encoding_key = 436 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 437 | .unwrap(); 438 | let token_header: Header = 439 | serde_json::from_value(json!({ "alg": Algorithm::RS256 })).unwrap(); 440 | let token = jsonwebtoken::encode( 441 | &token_header, 442 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 443 | &encoding_key, 444 | ).unwrap(); 445 | let event = TokenAuthorizerEvent { 446 | authorization_token: format!("Bearer {}", token), 447 | method_arn: "some_arn".to_string(), 448 | }; 449 | let handler = make_simple_handler(); 450 | 451 | let response = handler.do_call(event).await; 452 | 453 | assert!(response.is_ok()); 454 | let response = response.unwrap(); 455 | let statement = response.policy_document.statement.first().unwrap(); 456 | assert_eq!(statement.effect, "Deny"); 457 | assert_eq!(statement.resource, "some_arn"); 458 | } 459 | 460 | #[tokio::test] 461 | #[traced_test] 462 | async fn it_denies_if_it_fails_to_retrieve_the_key() { 463 | // create a mock server that will serve an empty list of jwks 464 | let server = MockServer::start(); 465 | let _jwks_mock = server.mock(|when, then| { 466 | when.method(GET).path("/"); 467 | then.status(200) 468 | .header("content-type", "application/json") 469 | .body("{\"keys\":[]}"); 470 | }); 471 | 472 | let iss = "some_iss"; 473 | let aud = "test-app"; 474 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 475 | let encoding_key = 476 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 477 | .unwrap(); 478 | let token_header: Header = 479 | serde_json::from_value(json!({ "alg": Algorithm::RS256, "kid": "somekey" })).unwrap(); 480 | let token = jsonwebtoken::encode( 481 | &token_header, 482 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 483 | &encoding_key, 484 | ).unwrap(); 485 | let event = TokenAuthorizerEvent { 486 | authorization_token: format!("Bearer {}", token), 487 | method_arn: "some_arn".to_string(), 488 | }; 489 | let mut handler = make_simple_handler(); 490 | handler.keys = Box::leak(Box::new(KeysStorage::new( 491 | Url::parse(&server.url("/")).unwrap(), 492 | Duration::try_seconds(600).unwrap(), 493 | ))); 494 | 495 | let response = handler.do_call(event).await; 496 | 497 | assert!(response.is_ok()); 498 | let response = response.unwrap(); 499 | let statement = response.policy_document.statement.first().unwrap(); 500 | assert_eq!(statement.effect, "Deny"); 501 | assert_eq!(statement.resource, "some_arn"); 502 | } 503 | 504 | #[tokio::test] 505 | #[traced_test] 506 | async fn it_denies_if_it_fails_to_validate_the_token() { 507 | let server = MockServer::start(); 508 | let _jwks_mock = server.mock(|when, then| { 509 | when.method(GET).path("/"); 510 | then.status(200) 511 | .header("content-type", "application/json") 512 | .body(format!( 513 | "{{\"keys\":[ {} ]}}", 514 | include_str!("../tests/fixtures/keys/rs256/jwk.json") 515 | )); 516 | }); 517 | let iss = "some_iss"; 518 | let aud = "test-app"; 519 | // makes the token already expired by 1 hour 520 | let exp = (Utc::now() - Duration::try_hours(1).unwrap()).timestamp(); 521 | let encoding_key = 522 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 523 | .unwrap(); 524 | let token_header: Header = serde_json::from_value( 525 | json!({ "alg": Algorithm::RS256, "kid": "test/keys/rs256/public" }), 526 | ) 527 | .unwrap(); 528 | let token = jsonwebtoken::encode( 529 | &token_header, 530 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 531 | &encoding_key, 532 | ).unwrap(); 533 | let event = TokenAuthorizerEvent { 534 | authorization_token: format!("Bearer {}", token), 535 | method_arn: "some_arn".to_string(), 536 | }; 537 | let mut handler = make_simple_handler(); 538 | handler.keys = Box::leak(Box::new(KeysStorage::new( 539 | Url::parse(&server.url("/")).unwrap(), 540 | Duration::try_seconds(600).unwrap(), 541 | ))); 542 | 543 | let response = handler.do_call(event).await; 544 | 545 | assert!(response.is_ok()); 546 | let response = response.unwrap(); 547 | let statement = response.policy_document.statement.first().unwrap(); 548 | assert_eq!(statement.effect, "Deny"); 549 | assert_eq!(statement.resource, "some_arn"); 550 | } 551 | 552 | #[tokio::test] 553 | #[traced_test] 554 | async fn it_denies_if_it_fails_to_validate_the_issuer() { 555 | let server = MockServer::start(); 556 | let _jwks_mock = server.mock(|when, then| { 557 | when.method(GET).path("/"); 558 | then.status(200) 559 | .header("content-type", "application/json") 560 | .body(format!( 561 | "{{\"keys\":[ {} ]}}", 562 | include_str!("../tests/fixtures/keys/rs256/jwk.json") 563 | )); 564 | }); 565 | let iss = "http://notlocalhost"; // invalid issuer 566 | let aud = "test-app"; 567 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 568 | let encoding_key = 569 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 570 | .unwrap(); 571 | let token_header: Header = serde_json::from_value( 572 | json!({ "alg": Algorithm::RS256, "kid": "test/keys/rs256/public" }), 573 | ) 574 | .unwrap(); 575 | let token = jsonwebtoken::encode( 576 | &token_header, 577 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 578 | &encoding_key, 579 | ).unwrap(); 580 | let event = TokenAuthorizerEvent { 581 | authorization_token: format!("Bearer {}", token), 582 | method_arn: "some_arn".to_string(), 583 | }; 584 | let mut handler = make_simple_handler(); 585 | handler.keys = Box::leak(Box::new(KeysStorage::new( 586 | Url::parse(&server.url("/")).unwrap(), 587 | Duration::try_seconds(600).unwrap(), 588 | ))); 589 | 590 | let response = handler.do_call(event).await; 591 | 592 | assert!(response.is_ok()); 593 | let response = response.unwrap(); 594 | let statement = response.policy_document.statement.first().unwrap(); 595 | assert_eq!(statement.effect, "Deny"); 596 | assert_eq!(statement.resource, "some_arn"); 597 | } 598 | 599 | #[tokio::test] 600 | #[traced_test] 601 | async fn it_denies_if_it_fails_to_validate_the_audience() { 602 | let server = MockServer::start(); 603 | let _jwks_mock = server.mock(|when, then| { 604 | when.method(GET).path("/"); 605 | then.status(200) 606 | .header("content-type", "application/json") 607 | .body(format!( 608 | "{{\"keys\":[ {} ]}}", 609 | include_str!("../tests/fixtures/keys/rs256/jwk.json") 610 | )); 611 | }); 612 | let iss = "http://localhost"; 613 | let aud = "not-test-app"; // invalid audience 614 | let exp = (Utc::now() + Duration::try_hours(1).unwrap()).timestamp(); 615 | let encoding_key = 616 | EncodingKey::from_rsa_pem(include_bytes!("../tests/fixtures/keys/rs256/private.pem")) 617 | .unwrap(); 618 | let token_header: Header = serde_json::from_value( 619 | json!({ "alg": Algorithm::RS256, "kid": "test/keys/rs256/public" }), 620 | ) 621 | .unwrap(); 622 | let token = jsonwebtoken::encode( 623 | &token_header, 624 | &json!({ "iss": iss, "aud": aud, "exp": exp, "sub": "some_user", "preferred_username": "some_user" }), 625 | &encoding_key, 626 | ).unwrap(); 627 | let event = TokenAuthorizerEvent { 628 | authorization_token: format!("Bearer {}", token), 629 | method_arn: "some_arn".to_string(), 630 | }; 631 | let mut handler = make_simple_handler(); 632 | handler.keys = Box::leak(Box::new(KeysStorage::new( 633 | Url::parse(&server.url("/")).unwrap(), 634 | Duration::try_seconds(600).unwrap(), 635 | ))); 636 | 637 | let response = handler.do_call(event).await; 638 | 639 | assert!(response.is_ok()); 640 | let response = response.unwrap(); 641 | let statement = response.policy_document.statement.first().unwrap(); 642 | assert_eq!(statement.effect, "Deny"); 643 | assert_eq!(statement.resource, "some_arn"); 644 | } 645 | } 646 | --------------------------------------------------------------------------------