Skip to content

Commit

Permalink
add authorization plans to address #2 (#4)
Browse files Browse the repository at this point in the history
close #2
  • Loading branch information
sigpwned authored Jan 6, 2024
1 parent e888af8 commit 5821aa6
Show file tree
Hide file tree
Showing 4 changed files with 92 additions and 21 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
17 changes: 12 additions & 5 deletions cfn-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,39 +6,45 @@ 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 -.:+=@_/'
ContextTagPrefix:
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 -.:+=@_/'
DefaultPrincipalId:
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 -._'
AliasName:
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.'
Expand Down Expand Up @@ -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 ]
Expand Down
41 changes: 30 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# This is a sample Python script.
import base64
import re
from os import getenv
import boto3

Expand All @@ -10,6 +12,8 @@

DEFAULT_PRINCIPAL_ID = getenv("DEFAULT_PRINCIPAL_ID")

AUTHORIZATION_PLAN = getenv("AUTHORIZATION_PLAN", "authorization:bearer(plain)")

api_gateway_client = None


Expand Down Expand Up @@ -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):
Expand Down
48 changes: 45 additions & 3 deletions test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 5821aa6

Please sign in to comment.