From 5821aa60842b9107883ae7205dc684b2dacc1059 Mon Sep 17 00:00:00 2001 From: Andy Boothe Date: Fri, 5 Jan 2024 20:14:34 -0600 Subject: [PATCH] add authorization plans to address #2 (#4) close #2 --- README.md | 7 +++++-- cfn-deploy.yml | 17 ++++++++++++----- main.py | 41 ++++++++++++++++++++++++++++++----------- test_main.py | 48 +++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 92 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 247f26a..110f76c 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ The authorizer and CloudFormation template support this workflow out of the box. The implementation supports several important customizations out of the box in the form of CloudFormation template parameters: * `FunctionName` - An explicit name for the authorizer Lambda function. Useful to make ARN predictable. If left blank, a name will be generated automatically. +* `AuthorizationPlan` - A comma-separated (`,`) list of one or more places to look for an API key, first one wins: + * `authorization:bearer(plain)` - A [bearer token](https://datatracker.ietf.org/doc/html/rfc6750) in plain text + * `authorization:bearer(base64)` - A [bearer token](https://datatracker.ietf.org/doc/html/rfc6750) in base64 encoding + * `header:$HEADER_NAME()` - An HTTP header of the given name contains the API key * `PrincipalIdTagName` - The API key tag name to extract the request [`principalId`](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html) from. * `ContextTagPrefix` - A prefix to use to decide which API key tags to include in request context. The prefix value is removed from tag keys before copying to request context. If left blank, then all tags are copied to request context without modification. * `DefaultPrincipalId` - The default value to use for [`principalId`](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-lambda-authorizer-output.html) if the given `PrincipalIdTagName` tag is missing. Leave blank to cause authentication to fail in this case. @@ -56,9 +60,8 @@ The implementation supports several important customizations out of the box in t ### Other -Of course, users are free to modify however they like, but the following changes are expected: +Of course, users are free to modify however they like, but changes like the following are expected: -* Change approach to extracting API key (e.g., `x-api-key` header instead of bearer token) * Different approaches to loading API keys, e.g., [`customerId`](https://docs.aws.amazon.com/apigateway/latest/api/API_GetApiKeys.html#API_GetApiKeys_RequestSyntax) * Custom access policies * Append additional, bespoke request context diff --git a/cfn-deploy.yml b/cfn-deploy.yml index 6d0c8fb..b7b9ac2 100644 --- a/cfn-deploy.yml +++ b/cfn-deploy.yml @@ -6,15 +6,21 @@ Parameters: Type: String Description: 'The name to give to the function. Leave blank to generate automatically.' Default: ApiKeyTagContextLambdaAuthorizer - AllowedPattern: '^[-a-zA-Z0-9_/]*$' + AllowedPattern: '[-a-zA-Z0-9_/]*' MinLength: 0 MaxLength: 64 ConstraintDescription: 'Blank or String of length 1-64 comprised of numbers, letters, and any of -_/' + AuthorizationPlan: + Type: CommaDelimitedList + Description: 'A list of one or more places to look for the request API key, first one wins' + Default: 'authorization:bearer(plain)' + AllowedPattern: 'authorization:bearer[(]plain[)]|authorization:bearer[(]base64[)]|header:[a-zA-Z0-9_-]+[(][)]' + ConstraintDescription: 'Any of the following: authorization:bearer(plain), authorization:bearer(base64), header:$HEADER_NAME()' PrincipalIdTagName: Type: String Description: 'The API key tag value to use as principal ID.' Default: principal - AllowedPattern: '^[-a-zA-Z0-9.:+=@_/]+$' + AllowedPattern: '[-a-zA-Z0-9.:+=@_/]+' MinLength: 1 MaxLength: 128 ConstraintDescription: 'String of length 1-128 comprised of numbers, letters, and any of -.:+=@_/' @@ -22,7 +28,7 @@ Parameters: Type: String Description: 'The prefix of API key tag names to copy to request context. Prefix is removed before adding to context. Leave blank to include all tags without modification.' Default: "context:" - AllowedPattern: "^[-a-zA-Z0-9.:+=@_/]+$" + AllowedPattern: "[-a-zA-Z0-9.:+=@_/]+" MinLength: 0 MaxLength: 127 ConstraintDescription: 'Blank or String of length 1-127 comprised of numbers, letters, and any of -.:+=@_/' @@ -30,7 +36,7 @@ Parameters: Type: String Description: 'The default principal ID to assign if API has no principal tag. Leave blank to fail authorization on missing tag.' Default: "user" - AllowedPattern: "^[-a-zA-Z0-9._]*$" + AllowedPattern: "[-a-zA-Z0-9._]*" MinLength: 0 MaxLength: 80 ConstraintDescription: 'Blank or String of length 1-80 comprised of numbers, letters, and any of -._' @@ -38,7 +44,7 @@ Parameters: Type: String Description: 'The Lambda alias to publish automatically on deploy. Leave blank not to publish an alias.' Default: stag - AllowedPattern: "^(?!^[0-9]+$)([a-zA-Z0-9-_]+)$" + AllowedPattern: "(?!^[0-9]+$)([a-zA-Z0-9-_]+)" MinLength: 0 MaxLength: 128 ConstraintDescription: 'Blank or String of length 1-128 comprised of numbers, letters, and any of -_. Must not consist entirely of numbers.' @@ -70,6 +76,7 @@ Resources: VersionDescription: !If [ AliasNameOrVersionDescriptionIsBlank, !Ref 'AWS::NoValue', !Ref VersionDescription ] Environment: Variables: + AUTHORIZATION_PLAN: !Join [ ",", !Ref AuthorizationPlan ] PRINCIPAL_ID_TAG_NAME: !Ref PrincipalIdTagName CONTEXT_TAG_PREFIX: !Ref ContextTagPrefix DEFAULT_PRINCIPAL_ID: !If [ DefaultPrincipalIdIsBlank, !Ref 'AWS::NoValue', !Ref DefaultPrincipalId ] diff --git a/main.py b/main.py index 2fd1d2e..b96a2f8 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ # This is a sample Python script. +import base64 +import re from os import getenv import boto3 @@ -10,6 +12,8 @@ DEFAULT_PRINCIPAL_ID = getenv("DEFAULT_PRINCIPAL_ID") +AUTHORIZATION_PLAN = getenv("AUTHORIZATION_PLAN", "authorization:bearer(plain)") + api_gateway_client = None @@ -44,20 +48,35 @@ def find_first_header_value(request, header_name): return value -def find_api_key(request): - """ Extract bearer token if exists and is valid, or else None """ +HEADER_AUTHORIZATION_PLAN_STEP = re.compile(r"header:([a-zA-Z0-9_-]+)[(][)]") - authorization = find_first_header_value(request, "authorization") - if authorization is None: - return None +AUTHORIZATION_AUTHORIZATION_PLAN_STEP = re.compile(r"authorization:bearer[(](plain|base64)[)]") - parts = authorization.split(" ", 1) - if len(parts) != 2: - return None - if parts[0].lower() != "bearer": - return None - return parts[1] +def find_api_key(request): + """ Extract bearer token if exists and is valid, or else None """ + + authorization_plan_steps = AUTHORIZATION_PLAN.split(",") + for authorization_plan_step in authorization_plan_steps: + if AUTHORIZATION_AUTHORIZATION_PLAN_STEP.fullmatch(authorization_plan_step) is not None: + match = AUTHORIZATION_AUTHORIZATION_PLAN_STEP.fullmatch(authorization_plan_step) + instruction = match.group(1) + authorization = find_first_header_value(request, "authorization") + if authorization is not None: + parts = authorization.split(" ", 1) + if len(parts) == 2 and parts[0].lower() == "bearer": + token = parts[1] + if instruction == "base64": + token = base64.b64decode(token).decode("utf-8") + return token + elif HEADER_AUTHORIZATION_PLAN_STEP.fullmatch(authorization_plan_step) is not None: + match = HEADER_AUTHORIZATION_PLAN_STEP.fullmatch(authorization_plan_step) + header_name = match.group(1) + header_value = find_first_header_value(request, header_name) + if header_value is not None: + return header_value + else: + print("WARNING: Ignoring unrecognized authorization plan step: " + authorization_plan_step) def fetch_api_key(value): diff --git a/test_main.py b/test_main.py index 853ccc9..d864e89 100644 --- a/test_main.py +++ b/test_main.py @@ -223,16 +223,58 @@ def test_find_first_header_value_present_multiple(): # find_api_key -def test_find_api_key_absent(): +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(plain)") +def test_find_api_key_authorization_bearer_absent(): api_key = find_api_key({"headers": {}}) assert api_key is None -def test_find_api_key_present_not_bearer(): +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(plain)") +def test_find_api_key_authorization_bearer_present_not_bearer(): api_key = find_api_key({"headers": {"authorization": "foobar hello"}}) assert api_key is None -def test_find_api_key_present_bearer(): +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(plain)") +def test_find_api_key_authorization_bearer_present_plain_bearer(): api_key = find_api_key({"headers": {"authorization": "bearer hello"}}) assert api_key == "hello" + + +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(base64)") +def test_find_api_key_authorization_bearer_present_base64_bearer(): + api_key = find_api_key({"headers": {"authorization": "bearer aGVsbG8="}}) + assert api_key == "hello" + + +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(plain),header:alpha-bravo-charlie()") +def test_find_api_key_present_first_one_wins(): + api_key = find_api_key({ + "headers": { + "authorization": "bearer zulu", + "alpha-bravo-charlie": "yankee" + } + }) + assert api_key == "zulu" + + +@patch("main.AUTHORIZATION_PLAN", "authorization:bearer(plain),header:alpha-bravo-charlie()") +def test_find_api_key_present_second_one_works(): + api_key = find_api_key({ + "headers": { + "alpha-bravo-charlie": "yankee" + } + }) + assert api_key == "yankee" + + +@patch("main.AUTHORIZATION_PLAN", "header:alpha-bravo-charlie()") +def test_find_api_key_header_present(): + api_key = find_api_key({"headers": {"alpha-bravo-charlie": "yankee"}}) + assert api_key == "yankee" + + +@patch("main.AUTHORIZATION_PLAN", "header:alpha-bravo-charlie()") +def test_find_api_key_header_absent(): + api_key = find_api_key({"headers": {}}) + assert api_key is None