Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Issue #2809] Handle parsing the jwt we created, and connect to a user #2959

Merged
merged 34 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4271713
WIP
chouinar Nov 12, 2024
b5e8ff4
Merge branch 'main' into chouinar/2721-jwk-backend
chouinar Nov 12, 2024
f83f0d1
Tests and cleanup
chouinar Nov 13, 2024
638d332
Merge branch 'main' into chouinar/2721-jwk-backend
chouinar Nov 13, 2024
8eff255
Minor config update
chouinar Nov 13, 2024
8a38474
Testing whether this would fix it (although probably is too much)
chouinar Nov 13, 2024
6be928a
trivy yaml
chouinar Nov 13, 2024
6f588d0
trivyignore stuff
chouinar Nov 13, 2024
4d2e229
Merge branch 'main' into chouinar/2721-jwk-backend
chouinar Nov 13, 2024
ddef681
Fix path
chouinar Nov 13, 2024
7942476
Wallkicks will work
chouinar Nov 13, 2024
f2c0e75
Merge branch 'main' into chouinar/2721-jwk-backend
chouinar Nov 13, 2024
970e54d
direct path
chouinar Nov 14, 2024
c74d1e8
Adjust path?
chouinar Nov 14, 2024
9756844
Try skip files
chouinar Nov 14, 2024
f2ef7cf
Try a glob for even more simplicity
chouinar Nov 14, 2024
3883512
Undo extra trivy changes
chouinar Nov 14, 2024
1e53074
[Issue #2808] Setup logic for creating a JWT
chouinar Nov 15, 2024
020521c
Merge branch 'main' into chouinar/2721-jwk-backend
chouinar Nov 18, 2024
99660a8
Merge branch 'main' into chouinar/2808-create-a-jwt
chouinar Nov 18, 2024
25635ce
Add migration and cleanup
chouinar Nov 18, 2024
844b0f0
Merge branch 'chouinar/2721-jwk-backend' into chouinar/2808-create-a-jwt
chouinar Nov 18, 2024
bbbb65a
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Nov 18, 2024
6691783
Merge branch 'main' into chouinar/2808-create-a-jwt
chouinar Nov 18, 2024
09f0528
Merge branch 'main' into chouinar/2808-create-a-jwt
chouinar Nov 19, 2024
92664ed
[Issue #2809] Handle parsing the jwt we created, and connect to a user
chouinar Nov 20, 2024
3dc50ad
Merge branch 'main' into chouinar/2808-create-a-jwt
chouinar Nov 20, 2024
5b8dab5
Merge branch 'chouinar/2808-create-a-jwt' into chouinar/2809-parse-a-jwt
chouinar Nov 20, 2024
f40f286
Don't initialize jwt non-locally
chouinar Nov 20, 2024
76cd414
Setup the env file, but with shell scripts
chouinar Nov 20, 2024
c0e77c1
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Nov 20, 2024
039bf15
Merge branch 'main' into chouinar/2809-parse-a-jwt
chouinar Nov 21, 2024
c4631ae
Merge branch 'main' into chouinar/2809-parse-a-jwt
chouinar Nov 21, 2024
da76c75
Create ERD diagram and Update OpenAPI spec
nava-platform-bot Nov 21, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion api/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ coverage.*
# Environment variables
.env
.envrc
override.env

# mypy
.mypy_cache
Expand All @@ -31,4 +32,9 @@ coverage.*
/test-results/

# localstack
/volume
/volume

# All pem/pub/secret keys
*.key
*.pub
*.pem
5 changes: 4 additions & 1 deletion api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ setup-local:
# Install dependencies
poetry install --no-root --all-extras --with dev

setup-env-override-file:
./bin/setup-env-override-file.sh $(args)

##################################################
# API Build & Run
##################################################
Expand All @@ -100,7 +103,7 @@ start-debug:
run-logs: start ## Start the API and follow the logs
docker compose logs --follow --no-color $(APP_NAME)

init: build init-db init-opensearch init-localstack
init: setup-env-override-file build init-db init-opensearch init-localstack

clean-volumes: ## Remove project docker volumes - which includes the DB, and OpenSearch state
docker compose down --volumes
Expand Down
123 changes: 123 additions & 0 deletions api/bin/setup-env-override-file.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
# setup-env-override-file.sh
#
# Generate an override.env file
# with secrets pre-populated for local development.
#
# Examples:
# ./setup-env-override-file.sh
# ./setup-env-override-file.sh --recreate
#

set -o errexit -o pipefail

PROGRAM_NAME=$(basename "$0")

CYAN='\033[96m'
GREEN='\033[92m'
RED='\033[01;31m'
END='\033[0m'

USAGE="Usage: $PROGRAM_NAME [OPTION]

--recreate Recreate the override.env file, fully overwriting any existing file
"

main() {
print_log "Running $PROGRAM_NAME"

for arg in "$@"
do
if [ "$arg" == "--recreate" ]; then
recreate=1
else
echo "$USAGE"
exit 1
fi
done

OVERRIDE_FILE="override.env"

if [ -f "$OVERRIDE_FILE" ] ; then
if [ $recreate ] ; then
print_log "Recreating existing override.env file"
else
print_log "override.env already exists, not recreating"
exit 0
fi
fi

# Delete any key files that may be leftover from a prior run
cleanup_files

# Generate RSA keys
# note ssh-keygen generates a different format for
# the public key so we run it through openssl to fix it
ssh-keygen -t rsa -b 2048 -m PEM -N '' -f tmp_jwk.key 2>&1 >/dev/null
openssl rsa -in tmp_jwk.key -pubout -outform PEM -out tmp_jwk.pub

PUBLIC_KEY=`cat tmp_jwk.pub`
PRIVATE_KEY=`cat tmp_jwk.key`

cat > $OVERRIDE_FILE <<EOF
# override.env
#
# Any environment variables written to this file
# will take precedence over those defined in local.env
#
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fancy docs!

# This file will not be checked into github and it is safe
# to store secrets here, however you should still follow caution
# with using any secrets locally if they cause the app to interact
# with external systems.
#
# This file was generated by running:
# make setup-env-override-file
#
# Which runs as part of our "make init" flow.
#
# If you would like to re-generate this file, please run:
# make setup-env-override-file args="--recreate"
#
# Note that this will completely erase any existing configuration you may have

############################
# Authentication
############################

API_JWT_PRIVATE_KEY="$PRIVATE_KEY"

API_JWT_PUBLIC_KEY="$PUBLIC_KEY"
EOF


print_log "Created new override.env"

# Cleanup all keys generated in this run
cleanup_files
}

# Cleanup a single file if it exists
cleanup_file()
{
FILE=$1
shift;

if [ -f "$FILE" ] ; then
rm "$FILE"
fi
}

# Cleanup all miscellaneous keys generated
cleanup_files()
{
cleanup_file tmp_jwk.key
cleanup_file tmp_jwk.pub
cleanup_file tmp_jwk.key.pub
}

print_log() {
printf "$CYAN%s $GREEN%s: $END%s\\n" "$(date "+%Y-%m-%d %H:%M:%S")" "$PROGRAM_NAME" "$*"
}

# Entry point
main "$@"
6 changes: 5 additions & 1 deletion api/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,11 @@ services:
"--reload",
]
container_name: grants-api
env_file: ./local.env
env_file:
- path: ./local.env
required: true
- path: ./override.env
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool! It's just a list so lower ones override, nice

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, when I came across it in the docs I realized it was exactly what I'd been looking for in env var management from Docker for years

required: false
ports:
- 8080:8080
volumes:
Expand Down
6 changes: 6 additions & 0 deletions api/local.env
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,13 @@ LOGIN_GOV_JWK_ENDPOINT=http://localhost:5001/issuer1/jwks
LOGIN_GOV_ENDPOINT=http://localhost:5001
LOGIN_GOV_CLIENT_ID=TODO

# These should be set in your override.env file
# which can be created by running `make setup-env-override-file`
API_JWT_PRIVATE_KEY=
API_JWT_PUBLIC_KEY=

ENABLE_AUTH_ENDPOINT=TRUE

############################
# DB Environment Variables
############################
Expand Down
4 changes: 4 additions & 0 deletions api/openapi.generated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1423,4 +1423,8 @@ components:
type: apiKey
in: header
name: X-Auth
ApiJwtAuth:
type: apiKey
in: header
name: X-SGG-Token

9 changes: 8 additions & 1 deletion api/src/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
from src.api.schemas import response_schema
from src.api.users.user_blueprint import user_blueprint
from src.app_config import AppConfig
from src.auth.api_key_auth import get_app_security_scheme
from src.auth.api_jwt_auth import initialize_jwt_auth
from src.auth.auth_utils import get_app_security_scheme
from src.data_migration.data_migration_blueprint import data_migration_blueprint
from src.search.backend.load_search_data_blueprint import load_search_data_blueprint
from src.task import task_blueprint
Expand Down Expand Up @@ -58,6 +59,12 @@ def create_app() -> APIFlask:
register_index(app)
register_search_client(app)

# TODO - once we merge the auth changes for setting up the initial route
# will reuse the config from it, for now we'll do this a bit hacky
# This cannot be removed non-locally until we setup RSA keys for non-local envs
if os.getenv("ENVIRONMENT") == "local":
initialize_jwt_auth()

return app


Expand Down
89 changes: 73 additions & 16 deletions api/src/auth/api_jwt_auth.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
import logging
import uuid
from datetime import timedelta
from typing import Tuple

import jwt
from apiflask import HTTPTokenAuth
from pydantic import Field
from sqlalchemy import select
from sqlalchemy.orm import selectinload

import src.util.datetime_util as datetime_util
from src.adapters import db
from src.adapters.db import flask_db
from src.api.route_utils import raise_flask_error
from src.auth.auth_errors import JwtValidationError
from src.db.models.user_models import User, UserTokenSession
from src.logging.flask_logger import add_extra_data_to_current_request_logs
from src.util.env_config import PydanticBaseEnvConfig

logger = logging.getLogger(__name__)

api_jwt_auth = HTTPTokenAuth("ApiKey", header="X-SGG-Token", security_scheme_name="ApiJwtAuth")


class ApiJwtConfig(PydanticBaseEnvConfig):

Expand All @@ -32,7 +40,7 @@ class ApiJwtConfig(PydanticBaseEnvConfig):
_config: ApiJwtConfig | None = None


def initialize() -> None:
def initialize_jwt_auth() -> None:
global _config
if not _config:
_config = ApiJwtConfig()
Expand All @@ -53,14 +61,14 @@ def get_config() -> ApiJwtConfig:
global _config

if _config is None:
raise Exception("No JWT configuration - initialize() must be run first")
raise Exception("No JWT configuration - initialize_jwt_auth() must be run first")

return _config


def create_jwt_for_user(
user: User, db_session: db.Session, config: ApiJwtConfig | None = None
) -> str:
) -> Tuple[str, UserTokenSession]:
if config is None:
config = get_config()

Expand All @@ -72,13 +80,8 @@ def create_jwt_for_user(
expiration_time = current_time + timedelta(minutes=config.token_expiration_minutes)

# Create the session in the DB
db_session.add(
UserTokenSession(
user=user,
token_id=token_id,
expires_at=expiration_time,
)
)
user_token_session = UserTokenSession(user=user, token_id=token_id, expires_at=expiration_time)
db_session.add(user_token_session)

# Create the JWT with information we'll want to receive back
payload = {
Expand All @@ -89,13 +92,21 @@ def create_jwt_for_user(
"iss": config.issuer,
}

return jwt.encode(payload, config.private_key, algorithm="RS256")
logger.info(
"Created JWT token",
extra={
"auth.user_id": str(user_token_session.user_id),
"auth.token_id": str(user_token_session.token_id),
},
)

return jwt.encode(payload, config.private_key, algorithm="RS256"), user_token_session


def parse_jwt_for_user(
token: str, db_session: db.Session, config: ApiJwtConfig | None = None
) -> User:
# TODO - more implementation/validation to come in https://github.com/HHS/simpler-grants-gov/issues/2809
) -> UserTokenSession:
"""Handle processing a jwt token, and connecting it to a user token session in our DB"""
if config is None:
config = get_config()

Expand Down Expand Up @@ -135,8 +146,10 @@ def parse_jwt_for_user(
raise JwtValidationError("Token missing sub field")

token_session: UserTokenSession | None = db_session.execute(
select(UserTokenSession).join(User).where(UserTokenSession.token_id == sub_id)
).scalar_one_or_none()
select(UserTokenSession)
.where(UserTokenSession.token_id == sub_id)
.options(selectinload("*"))
).scalar()

# We check both the token expires_at timestamp as well as an
# is_valid flag to make sure the token is still valid.
Expand All @@ -147,4 +160,48 @@ def parse_jwt_for_user(
if token_session.is_valid is False:
raise JwtValidationError("Token is no longer valid")

return token_session.user
return token_session


@api_jwt_auth.verify_token
@flask_db.with_db_session()
def decode_token(db_session: db.Session, token: str) -> UserTokenSession:
"""
Process an internal jwt token as created by the above create_jwt_for_user method.

To add this auth to an endpoint, simply put::

from src.auth.api_jwt_auth import api_jwt_auth

@example_blueprint.get("/example")
@example_blueprint.auth_required(api_jwt_auth)
@flask_db.with_db_session()
def example_method(db_session: db.Session) -> response.ApiResponse:
# The token session object can be fetched from the auth object
token_session: UserTokenSession = api_jwt_auth.current_user

# If you want to modify the token_session or user, you will
# need to add it to the DB session otherwise it won't do anything
db_session.add(token_session)
token_session.expires_at = ...
...
"""

try:
user_token_session = parse_jwt_for_user(token, db_session)

add_extra_data_to_current_request_logs(
{
"auth.user_id": str(user_token_session.user_id),
"auth.token_id": str(user_token_session.token_id),
}
)
logger.info("JWT Authentication Successful")

# Return the user token session object
return user_token_session
except JwtValidationError as e:
# If validation of the jwt fails, pass the error message back to the user
# The message is just the value we set when constructing the JwtValidationError
logger.info("JWT Authentication Failed for provided token", extra={"auth.issue": e.message})
raise_flask_error(401, e.message)
Loading