diff --git a/fastapi_auth_jwt/README.rst b/fastapi_auth_jwt/README.rst new file mode 100644 index 000000000..539ab268d --- /dev/null +++ b/fastapi_auth_jwt/README.rst @@ -0,0 +1,153 @@ +======================== +FastAPI Auth JWT support +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2829a34d48a1906819029e7b796d33a1ee2ad2a47693396da96f92ede04ec17d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/18.0/fastapi_auth_jwt + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-18-0/rest-framework-18-0-fastapi_auth_jwt + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module provides ``FastAPI`` ``Depends`` to allow authentication +with +`auth_jwt `__. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +The following FastAPI dependencies are provided and importable from +``odoo.addons.fastapi_auth_jwt.dependencies``: + +``def auth_jwt_authenticated_payload() -> Payload`` + + Return the authenticated JWT payload. Raise a 401 (unauthorized) if + absent or invalid. + +``def auth_jwt_optionally_authenticated_payload() -> Payload | None`` + + Return the authenticated JWT payload, or ``None`` if the + ``Authorization`` header and cookie are absent. Raise a 401 + (unauthorized) if present and invalid. + +``def auth_jwt_authenticated_partner() -> Partner`` + + Obtain the authenticated partner corresponding to the provided JWT + token, according to the partner strategy defined on the ``auth_jwt`` + validator. Raise a 401 (unauthorized) if the partner could not be + determined for any reason. + + This is function suitable and intended to override + ``odoo.addons.fastapi.dependencies.authenticated_partner_impl``. + + The partner record returned by this function is bound to an + environment that uses the Odoo user obtained from the user strategy + defined on the ``auth_jwt`` validator. When used + ``authenticated_partner_impl`` this in turn ensures that + ``odoo.addons.fastapi.dependencies.authenticated_partner_env`` is + also bound to the correct Odoo user. + +``def auth_jwt_optionally_authenticated_partner() -> Partner`` + + Same as ``auth_jwt_partner`` except it returns an empty recordset + bound to the ``public`` user if the ``Authorization`` header and + cookie are absent, or if the JWT validator could not find the partner + and declares that the partner is not required. + +``def auth_jwt_authenticated_odoo_env() -> Environment`` + + Return an Odoo environment using the the Odoo user obtained from the + user strategy defined on the ``auth_jwt`` validator, if the request + could be authenticated using a JWT validator. Raise a 401 + (unauthorized) otherwise. + + This is function suitable and intended to override + ``odoo.addons.fastapi.dependencies.authenticated_odoo_env_impl``. + +``def auth_jwt_default_validator_name() -> str | None`` + + Return the name of the default JWT validator to use. + + The default implementation returns ``None`` meaning only one active + JWT validator is allowed. This dependency is meant to be overridden. + +``def auth_jwt_http_header_authorization() -> str | None`` + + By default, return the credentials part of the ``Authorization`` + header, or ``None`` if absent. This dependency is meant to be + overridden, in particular with + ``fastapi.security.OAuth2AuthorizationCodeBearer`` to let swagger + handle OAuth2 authorization (such override is only necessary for + comfort when using the swagger interface). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* ACSONE SA/NV + +Contributors +------------ + +Mohamed Alkobrosli + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-sbidoul| image:: https://github.com/sbidoul.png?size=40px + :target: https://github.com/sbidoul + :alt: sbidoul + +Current `maintainer `__: + +|maintainer-sbidoul| + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_auth_jwt/__init__.py b/fastapi_auth_jwt/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fastapi_auth_jwt/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_auth_jwt/__manifest__.py b/fastapi_auth_jwt/__manifest__.py new file mode 100644 index 000000000..3dd1170f9 --- /dev/null +++ b/fastapi_auth_jwt/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "FastAPI Auth JWT support", + "summary": """ + JWT bearer token authentication for FastAPI.""", + "version": "18.0.1.0.0", + "license": "LGPL-3", + "author": "ACSONE SA/NV,Odoo Community Association (OCA)", + "maintainers": ["sbidoul"], + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "fastapi", + "auth_jwt", + ], + "data": [], + "demo": [], + "installable": True, +} diff --git a/fastapi_auth_jwt/dependencies.py b/fastapi_auth_jwt/dependencies.py new file mode 100644 index 000000000..d72c61a3f --- /dev/null +++ b/fastapi_auth_jwt/dependencies.py @@ -0,0 +1,247 @@ +# Copyright 2023 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +import logging +from typing import Annotated, Any + +from fastapi import Depends, HTTPException, Request, Response +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from starlette.status import HTTP_401_UNAUTHORIZED + +from odoo.api import Environment + +from odoo.addons.auth_jwt.exceptions import ( + ConfigurationError, + Unauthorized, + UnauthorizedCompositeJwtError, + UnauthorizedMissingAuthorizationHeader, + UnauthorizedMissingCookie, +) +from odoo.addons.auth_jwt.models.auth_jwt_validator import AuthJwtValidator +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import odoo_env + +_logger = logging.getLogger(__name__) + + +Payload = dict[str, Any] + + +def _get_auth_jwt_validator( + validator_name: str | None, + env: Environment, +) -> AuthJwtValidator: + validator = env["auth.jwt.validator"].sudo()._get_validator_by_name(validator_name) + assert len(validator) == 1 + return validator + + +def _request_has_authentication( + request: Request, + authorization_header: str | None, + validator: AuthJwtValidator, +) -> Payload | None: + if authorization_header is not None: + return True + if not validator.cookie_enabled: + # no Authorization header and cookies not enabled + return False + return request.cookies.get(validator.cookie_name) is not None + + +def _get_jwt_payload( + request: Request, + authorization_header: str | None, + validator: AuthJwtValidator, +) -> Payload: + """Obtain and validate the JWT payload from the request authorization header or + cookie (if enabled on the validator).""" + if authorization_header is not None: + return validator._decode(authorization_header) + if not validator.cookie_enabled: + _logger.info("Missing or malformed authorization header.") + raise UnauthorizedMissingAuthorizationHeader() + assert validator.cookie_name + cookie_token = request.cookies.get(validator.cookie_name) + if not cookie_token: + _logger.info( + "Missing or malformed authorization header, and %s cookie not present.", + validator.cookie_name, + ) + raise UnauthorizedMissingCookie() + return validator._decode(cookie_token, secret=validator._get_jwt_cookie_secret()) + + +def _get_jwt_payload_and_validator( + request: Request, + response: Response, + authorization_header: str | None, + validator: AuthJwtValidator, +) -> tuple[Payload, AuthJwtValidator]: + try: + payload = None + exceptions = {} + while validator: + try: + payload = _get_jwt_payload(request, authorization_header, validator) + break + except Unauthorized as e: + exceptions[validator.name] = e + validator = validator.next_validator_id + + if not payload: + if len(exceptions) == 1: + raise list(exceptions.values())[0] + raise UnauthorizedCompositeJwtError(exceptions) + + if validator.cookie_enabled: + if not validator.cookie_name: + _logger.info("Cookie name not set for validator %s", validator.name) + raise ConfigurationError() + response.set_cookie( + key=validator.cookie_name, + value=validator._encode( + payload, + secret=validator._get_jwt_cookie_secret(), + expire=validator.cookie_max_age, + ), + max_age=validator.cookie_max_age, + path=validator.cookie_path or "/", + secure=validator.cookie_secure, + httponly=True, + ) + + return payload, validator + except Unauthorized as e: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) from e + + +def auth_jwt_default_validator_name() -> str | None: + return None + + +def auth_jwt_http_header_authorization( + credentials: Annotated[ + HTTPAuthorizationCredentials | None, + Depends(HTTPBearer(auto_error=False)), + ], +): + if credentials is None: + return None + return credentials.credentials + + +class BaseAuthJwt: # noqa: B903 + def __init__( + self, validator_name: str | None = None, allow_unauthenticated: bool = False + ): + self.validator_name = validator_name + self.allow_unauthenticated = allow_unauthenticated + + +class AuthJwtPayload(BaseAuthJwt): + def __call__( + self, + request: Request, + response: Response, + authorization_header: Annotated[ + str | None, + Depends(auth_jwt_http_header_authorization), + ], + default_validator_name: Annotated[ + str | None, + Depends(auth_jwt_default_validator_name), + ], + env: Annotated[ + Environment, + Depends(odoo_env), + ], + ) -> Payload | None: + validator = _get_auth_jwt_validator( + self.validator_name or default_validator_name, env + ) + if self.allow_unauthenticated and not _request_has_authentication( + request, authorization_header, validator + ): + return None + return _get_jwt_payload_and_validator( + request, response, authorization_header, validator + )[0] + + +class AuthJwtPartner(BaseAuthJwt): + def __call__( + self, + request: Request, + response: Response, + authorization_header: Annotated[ + str | None, + Depends(auth_jwt_http_header_authorization), + ], + default_validator_name: Annotated[ + str | None, + Depends(auth_jwt_default_validator_name), + ], + env: Annotated[ + Environment, + Depends(odoo_env), + ], + ) -> Partner: + validator = _get_auth_jwt_validator( + self.validator_name or default_validator_name, env + ) + if self.allow_unauthenticated and not _request_has_authentication( + request, authorization_header, validator + ): + return env["res.partner"].with_user(env.ref("base.public_user")).browse() + payload, validator = _get_jwt_payload_and_validator( + request, response, authorization_header, validator + ) + try: + uid = validator._get_and_check_uid(payload) + partner_id = validator._get_and_check_partner_id(payload) + except Unauthorized as e: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) from e + if not partner_id: + _logger.info("Could not determine partner from JWT payload.") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) + return env["res.partner"].with_user(uid).browse(partner_id) + + +class AuthJwtOdooEnv(BaseAuthJwt): + def __call__( + self, + request: Request, + response: Response, + authorization_header: Annotated[ + str | None, + Depends(auth_jwt_http_header_authorization), + ], + default_validator_name: Annotated[ + str | None, + Depends(auth_jwt_default_validator_name), + ], + env: Annotated[ + Environment, + Depends(odoo_env), + ], + ) -> Environment: + validator = _get_auth_jwt_validator( + self.validator_name or default_validator_name, env + ) + payload, validator = _get_jwt_payload_and_validator( + request, response, authorization_header, validator + ) + uid = validator._get_and_check_uid(payload) + return odoo_env(user=uid) + + +auth_jwt_authenticated_payload = AuthJwtPayload() + +auth_jwt_optionally_authenticated_payload = AuthJwtPayload(allow_unauthenticated=True) + +auth_jwt_authenticated_partner = AuthJwtPartner() + +auth_jwt_optionally_authenticated_partner = AuthJwtPartner(allow_unauthenticated=True) + +auth_jwt_authenticated_odoo_env = AuthJwtOdooEnv() diff --git a/fastapi_auth_jwt/i18n/fastapi_auth_jwt.pot b/fastapi_auth_jwt/i18n/fastapi_auth_jwt.pot new file mode 100644 index 000000000..78d58d53f --- /dev/null +++ b/fastapi_auth_jwt/i18n/fastapi_auth_jwt.pot @@ -0,0 +1,13 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" diff --git a/fastapi_auth_jwt/models/__init__.py b/fastapi_auth_jwt/models/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/fastapi_auth_jwt/pyproject.toml b/fastapi_auth_jwt/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_auth_jwt/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_auth_jwt/readme/CONTRIBUTORS.md b/fastapi_auth_jwt/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..db9d73c08 --- /dev/null +++ b/fastapi_auth_jwt/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +Mohamed Alkobrosli \<\> diff --git a/fastapi_auth_jwt/readme/DESCRIPTION.md b/fastapi_auth_jwt/readme/DESCRIPTION.md new file mode 100644 index 000000000..da99d32a1 --- /dev/null +++ b/fastapi_auth_jwt/readme/DESCRIPTION.md @@ -0,0 +1,2 @@ +This module provides `FastAPI` `Depends` to allow authentication with +[auth_jwt](https://github.com/OCA/server-auth/tree/16.0/auth_jwt). diff --git a/fastapi_auth_jwt/readme/USAGE.md b/fastapi_auth_jwt/readme/USAGE.md new file mode 100644 index 000000000..fe95c103e --- /dev/null +++ b/fastapi_auth_jwt/readme/USAGE.md @@ -0,0 +1,62 @@ +The following FastAPI dependencies are provided and importable from +`odoo.addons.fastapi_auth_jwt.dependencies`: + +`def auth_jwt_authenticated_payload() -> Payload` + +> Return the authenticated JWT payload. Raise a 401 (unauthorized) if +> absent or invalid. + +`def auth_jwt_optionally_authenticated_payload() -> Payload | None` + +> Return the authenticated JWT payload, or `None` if the `Authorization` +> header and cookie are absent. Raise a 401 (unauthorized) if present +> and invalid. + +`def auth_jwt_authenticated_partner() -> Partner` + +> Obtain the authenticated partner corresponding to the provided JWT +> token, according to the partner strategy defined on the `auth_jwt` +> validator. Raise a 401 (unauthorized) if the partner could not be +> determined for any reason. +> +> This is function suitable and intended to override +> `odoo.addons.fastapi.dependencies.authenticated_partner_impl`. +> +> The partner record returned by this function is bound to an +> environment that uses the Odoo user obtained from the user strategy +> defined on the `auth_jwt` validator. When used +> `authenticated_partner_impl` this in turn ensures that +> `odoo.addons.fastapi.dependencies.authenticated_partner_env` is also +> bound to the correct Odoo user. + +`def auth_jwt_optionally_authenticated_partner() -> Partner` + +> Same as `auth_jwt_partner` except it returns an empty recordset bound +> to the `public` user if the `Authorization` header and cookie are +> absent, or if the JWT validator could not find the partner and +> declares that the partner is not required. + +`def auth_jwt_authenticated_odoo_env() -> Environment` + +> Return an Odoo environment using the the Odoo user obtained from the +> user strategy defined on the `auth_jwt` validator, if the request +> could be authenticated using a JWT validator. Raise a 401 +> (unauthorized) otherwise. +> +> This is function suitable and intended to override +> `odoo.addons.fastapi.dependencies.authenticated_odoo_env_impl`. + +`def auth_jwt_default_validator_name() -> str | None` + +> Return the name of the default JWT validator to use. +> +> The default implementation returns `None` meaning only one active JWT +> validator is allowed. This dependency is meant to be overridden. + +`def auth_jwt_http_header_authorization() -> str | None` + +> By default, return the credentials part of the `Authorization` header, +> or `None` if absent. This dependency is meant to be overridden, in +> particular with `fastapi.security.OAuth2AuthorizationCodeBearer` to +> let swagger handle OAuth2 authorization (such override is only +> necessary for comfort when using the swagger interface). diff --git a/fastapi_auth_jwt/static/description/icon.png b/fastapi_auth_jwt/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/fastapi_auth_jwt/static/description/icon.png differ diff --git a/fastapi_auth_jwt/static/description/index.html b/fastapi_auth_jwt/static/description/index.html new file mode 100644 index 000000000..d5fff9b53 --- /dev/null +++ b/fastapi_auth_jwt/static/description/index.html @@ -0,0 +1,484 @@ + + + + + +FastAPI Auth JWT support + + + +
+

FastAPI Auth JWT support

+ + +

Beta License: LGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module provides FastAPI Depends to allow authentication +with +auth_jwt.

+

Table of contents

+ +
+

Usage

+

The following FastAPI dependencies are provided and importable from +odoo.addons.fastapi_auth_jwt.dependencies:

+

def auth_jwt_authenticated_payload() -> Payload

+
+Return the authenticated JWT payload. Raise a 401 (unauthorized) if +absent or invalid.
+

def auth_jwt_optionally_authenticated_payload() -> Payload | None

+
+Return the authenticated JWT payload, or None if the +Authorization header and cookie are absent. Raise a 401 +(unauthorized) if present and invalid.
+

def auth_jwt_authenticated_partner() -> Partner

+
+

Obtain the authenticated partner corresponding to the provided JWT +token, according to the partner strategy defined on the auth_jwt +validator. Raise a 401 (unauthorized) if the partner could not be +determined for any reason.

+

This is function suitable and intended to override +odoo.addons.fastapi.dependencies.authenticated_partner_impl.

+

The partner record returned by this function is bound to an +environment that uses the Odoo user obtained from the user strategy +defined on the auth_jwt validator. When used +authenticated_partner_impl this in turn ensures that +odoo.addons.fastapi.dependencies.authenticated_partner_env is +also bound to the correct Odoo user.

+
+

def auth_jwt_optionally_authenticated_partner() -> Partner

+
+Same as auth_jwt_partner except it returns an empty recordset +bound to the public user if the Authorization header and +cookie are absent, or if the JWT validator could not find the partner +and declares that the partner is not required.
+

def auth_jwt_authenticated_odoo_env() -> Environment

+
+

Return an Odoo environment using the the Odoo user obtained from the +user strategy defined on the auth_jwt validator, if the request +could be authenticated using a JWT validator. Raise a 401 +(unauthorized) otherwise.

+

This is function suitable and intended to override +odoo.addons.fastapi.dependencies.authenticated_odoo_env_impl.

+
+

def auth_jwt_default_validator_name() -> str | None

+
+

Return the name of the default JWT validator to use.

+

The default implementation returns None meaning only one active +JWT validator is allowed. This dependency is meant to be overridden.

+
+

def auth_jwt_http_header_authorization() -> str | None

+
+By default, return the credentials part of the Authorization +header, or None if absent. This dependency is meant to be +overridden, in particular with +fastapi.security.OAuth2AuthorizationCodeBearer to let swagger +handle OAuth2 authorization (such override is only necessary for +comfort when using the swagger interface).
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • ACSONE SA/NV
  • +
+
+
+

Contributors

+

Mohamed Alkobrosli <malkobrosly@kencove.com>

+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

sbidoul

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ +