diff --git a/api/local.env b/api/local.env index f4f805609..9f56b5f7e 100644 --- a/api/local.env +++ b/api/local.env @@ -45,6 +45,7 @@ LOGIN_GOV_JWK_ENDPOINT=http://localhost:5001/issuer1/jwks LOGIN_GOV_ENDPOINT=http://localhost:5001 LOGIN_GOV_CLIENT_ID=TODO +ENABLE_AUTH_ENDPOINT=TRUE ############################ # DB Environment Variables ############################ diff --git a/api/openapi.generated.yml b/api/openapi.generated.yml index 768522e27..8c903de17 100644 --- a/api/openapi.generated.yml +++ b/api/openapi.generated.yml @@ -22,6 +22,7 @@ info: tags: - name: Health - name: Opportunity v1 +- name: User v1 servers: . paths: /health: @@ -43,6 +44,39 @@ paths: tags: - Health summary: Health + /v1/users/token: + post: + parameters: + - in: header + name: X-OAuth-login-gov + description: The login_gov header token + schema: + type: string + required: false + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserTokenResponse' + description: Successful response + '422': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Validation error + '401': + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: Authentication error + tags: + - User v1 + summary: User Token + security: + - ApiKeyAuth: [] /v1/opportunities/search: post: parameters: [] @@ -350,6 +384,55 @@ components: type: string description: An internal tracking ID example: 550e8400-e29b-41d4-a716-446655440000 + User: + type: object + properties: + user_id: + type: string + description: The internal ID of a user + example: 861a0148-cf2c-432b-b0b3-690016299ab1 + email: + type: string + description: The email address returned from Oauth2 provider + example: js@gmail.com + external_user_type: + description: The Oauth2 provider through which a user was authenticated + example: !!python/object/apply:src.constants.lookup_constants.ExternalUserType + - login_gov + enum: + - login_gov + type: + - string + UserToken: + type: object + properties: + token: + type: string + description: Internal token generated for a user + user: + type: + - object + allOf: + - $ref: '#/components/schemas/User' + is_user_new: + type: boolean + description: Whether or not the user existed in our database + UserTokenResponse: + type: object + properties: + message: + type: string + description: The message to return + example: Success + data: + type: + - object + allOf: + - $ref: '#/components/schemas/UserToken' + status_code: + type: integer + description: The HTTP status code + example: 200 FundingInstrumentFilterV1: type: object properties: diff --git a/api/src/api/users/__init__.py b/api/src/api/users/__init__.py new file mode 100644 index 000000000..b6fba234e --- /dev/null +++ b/api/src/api/users/__init__.py @@ -0,0 +1,6 @@ +from src.api.users.user_blueprint import user_blueprint + +# import user_routes module to register the API routes on the blueprint +import src.api.users.user_routes # noqa: F401 E402 isort:skip + +__all__ = ["user_blueprint"] diff --git a/api/src/api/users/user_blueprint.py b/api/src/api/users/user_blueprint.py new file mode 100644 index 000000000..59400104a --- /dev/null +++ b/api/src/api/users/user_blueprint.py @@ -0,0 +1,9 @@ +from apiflask import APIBlueprint + +user_blueprint = APIBlueprint( + "user_v1", + __name__, + tag="User v1", + cli_group="user_v1", + url_prefix="/v1/users", +) diff --git a/api/src/api/users/user_routes.py b/api/src/api/users/user_routes.py new file mode 100644 index 000000000..1c34f0b5a --- /dev/null +++ b/api/src/api/users/user_routes.py @@ -0,0 +1,36 @@ +import logging + +from src.api import response +from src.api.route_utils import raise_flask_error +from src.api.users import user_schemas +from src.api.users.user_blueprint import user_blueprint +from src.auth.api_key_auth import api_key_auth + +logger = logging.getLogger(__name__) + + +@user_blueprint.post("/token") +@user_blueprint.input( + user_schemas.UserTokenHeaderSchema, location="headers", arg_name="x_oauth_login_gov" +) +@user_blueprint.output(user_schemas.UserTokenResponseSchema) +@user_blueprint.auth_required(api_key_auth) +def user_token(x_oauth_login_gov: dict) -> response.ApiResponse: + logger.info("POST /v1/users/token") + + if x_oauth_login_gov: + data = { + "token": "the token goes here!", + "user": { + "user_id": "abc-...", + "email": "example@gmail.com", + "external_user_type": "login_gov", + }, + "is_user_new": True, + } + return response.ApiResponse(message="Success", data=data) + + message = "Missing X-OAuth-login-gov header" + logger.info(message) + + raise_flask_error(400, message) diff --git a/api/src/api/users/user_schemas.py b/api/src/api/users/user_schemas.py new file mode 100644 index 000000000..3ea05421f --- /dev/null +++ b/api/src/api/users/user_schemas.py @@ -0,0 +1,53 @@ +from src.api.schemas.extension import Schema, fields +from src.api.schemas.response_schema import AbstractResponseSchema +from src.constants.lookup_constants import ExternalUserType + + +class UserTokenHeaderSchema(Schema): + x_oauth_login_gov = fields.String( + data_key="X-OAuth-login-gov", + metadata={ + "description": "The login_gov header token", + }, + ) + + +class UserSchema(Schema): + user_id = fields.String( + metadata={ + "description": "The internal ID of a user", + "example": "861a0148-cf2c-432b-b0b3-690016299ab1", + } + ) + email = fields.String( + metadata={ + "description": "The email address returned from Oauth2 provider", + "example": "js@gmail.com", + } + ) + external_user_type = fields.Enum( + ExternalUserType, + metadata={ + "description": "The Oauth2 provider through which a user was authenticated", + "example": ExternalUserType.LOGIN_GOV, + }, + ) + + +class UserTokenSchema(Schema): + token = fields.String( + metadata={ + "description": "Internal token generated for a user", + } + ) + user = fields.Nested(UserSchema()) + is_user_new = fields.Boolean( + allow_none=False, + metadata={ + "description": "Whether or not the user existed in our database", + }, + ) + + +class UserTokenResponseSchema(AbstractResponseSchema): + data = fields.Nested(UserTokenSchema) diff --git a/api/src/app.py b/api/src/app.py index ad79d1810..fb20bda89 100644 --- a/api/src/app.py +++ b/api/src/app.py @@ -4,6 +4,7 @@ from apiflask import APIFlask, exceptions from flask_cors import CORS +from pydantic import Field import src.adapters.db as db import src.adapters.db.flask_db as flask_db @@ -18,11 +19,13 @@ from src.api.opportunities_v1 import opportunity_blueprint as opportunities_v1_blueprint from src.api.response import restructure_error_response 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.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 +from src.util.env_config import PydanticBaseEnvConfig logger = logging.getLogger(__name__) @@ -37,6 +40,10 @@ """ +class AuthEndpointConfig(PydanticBaseEnvConfig): + auth_endpoint: bool = Field(False, alias="ENABLE_AUTH_ENDPOINT") + + def create_app() -> APIFlask: app = APIFlask(__name__, title=TITLE, version=API_OVERALL_VERSION) @@ -119,6 +126,10 @@ def register_blueprints(app: APIFlask) -> None: app.register_blueprint(opportunities_v0_1_blueprint) app.register_blueprint(opportunities_v1_blueprint) + auth_endpoint_config = AuthEndpointConfig() + if auth_endpoint_config.auth_endpoint: + app.register_blueprint(user_blueprint) + # Non-api blueprints app.register_blueprint(data_migration_blueprint) app.register_blueprint(task_blueprint) diff --git a/api/tests/src/api/users/__init__.py b/api/tests/src/api/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/api/tests/src/api/users/test_user_route_token.py b/api/tests/src/api/users/test_user_route_token.py new file mode 100644 index 000000000..d0811de94 --- /dev/null +++ b/api/tests/src/api/users/test_user_route_token.py @@ -0,0 +1,27 @@ +################## +# POST /token +################## + + +def test_post_user_route_token_200(client, api_auth_token): + resp = client.post( + "/v1/users/token", headers={"X-Auth": api_auth_token, "X-OAuth-login-gov": "test"} + ) + response_data = resp.get_json()["data"] + expected_response_data = { + "token": "the token goes here!", + "user": { + "user_id": "abc-...", + "email": "example@gmail.com", + "external_user_type": "login_gov", + }, + "is_user_new": True, + } + assert resp.status_code == 200 + assert response_data == expected_response_data + + +def test_post_user_route_token_400(client, api_auth_token): + resp = client.post("v1/users/token", headers={"X-Auth": api_auth_token}) + assert resp.status_code == 400 + assert resp.get_json()["message"] == "Missing X-OAuth-login-gov header"