diff --git a/fastapi_ecom/app.py b/fastapi_ecom/app.py index 1464915..1aaae8a 100644 --- a/fastapi_ecom/app.py +++ b/fastapi_ecom/app.py @@ -1,5 +1,6 @@ import uvicorn from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware from fastapi_ecom.config import config from fastapi_ecom.router import business, customer, order, product @@ -18,8 +19,14 @@ description="E-Commerce API for businesses and end users using FastAPI.", version="0.1.0", openapi_tags=tags_metadata, + swagger_ui_init_oauth={ + "clientId": config.GOOGLE_CLIENT_ID, + "clientSecret": config.GOOGLE_CLIENT_SECRET, + } ) +app.add_middleware(SessionMiddleware, secret_key=config.GOOGLE_CLIENT_SECRET) + PREFIX = "/api/v1" @app.get("/") diff --git a/fastapi_ecom/config/config.py.example b/fastapi_ecom/config/config.py.example index 9825791..bdebb88 100644 --- a/fastapi_ecom/config/config.py.example +++ b/fastapi_ecom/config/config.py.example @@ -16,6 +16,9 @@ dtbsbport = "5432" # The driver used to access the database dtbsdriver = "postgresql+asyncpg" +# Set the echo for logging of all SQL statements +confecho = True + # The location of serving the application service servhost = "127.0.0.1" @@ -25,5 +28,8 @@ servport = 8080 # Automatically reload if the code is changed cgreload = True -# Set the echo for logging of all SQL statements -confecho = True +# Google client id +GOOGLE_CLIENT_ID = "example.apps.googleusercontent.com" + +# Google Client secret +GOOGLE_CLIENT_SECRET = "example" diff --git a/fastapi_ecom/database/models/business.py b/fastapi_ecom/database/models/business.py index cd5f089..14ec35a 100644 --- a/fastapi_ecom/database/models/business.py +++ b/fastapi_ecom/database/models/business.py @@ -23,6 +23,10 @@ class Business(baseobjc, UUIDCreatableMixin, DateCreatableMixin, DateUpdateableM :cvar city: City where the business is located. :cvar state: State where the business is located. :cvar is_verified: Flag indicating if the business account is verified. + :cvar oauth_provider: OAuth provider name. + :cvar oauth_id: OAuth provider's user ID + :cvar oauth_email: Email address from OAuth. + :cvar created_via_oauth: Flag indicating if the business account is created using OAuth. :cvar products: Relationship to the `Product` model, representing the products offered by the business. """ @@ -30,12 +34,16 @@ class Business(baseobjc, UUIDCreatableMixin, DateCreatableMixin, DateUpdateableM id = Column("id", Integer, primary_key=True, index=True, autoincrement=True) email = Column("email_address", String(100), unique=True, index=True, nullable=False) - password = Column("password", Text, nullable=False) + password = Column("password", Text, nullable=True) name = Column("business_name", String(100), nullable=False) - addr_line_1 = Column("address_line_1", Text, nullable=False) + addr_line_1 = Column("address_line_1", Text, nullable=True) addr_line_2 = Column("address_line_2", Text, nullable=True) - city = Column("city", Text, nullable=False) - state = Column("state", Text, nullable=False) + city = Column("city", Text, nullable=True) + state = Column("state", Text, nullable=True) is_verified = Column("is_verified", Boolean, default=False) + oauth_provider = Column("oauth_provider", String(50), nullable=True) + oauth_id = Column("oauth_id", String(100), nullable=True) + oauth_email = Column("oauth_email", String(100), nullable=True) + created_via_oauth = Column("created_via_oauth", Boolean, default=False) products = relationship("Product", back_populates="businesses") diff --git a/fastapi_ecom/database/models/customer.py b/fastapi_ecom/database/models/customer.py index 37e51a1..92bc1a3 100644 --- a/fastapi_ecom/database/models/customer.py +++ b/fastapi_ecom/database/models/customer.py @@ -23,18 +23,26 @@ class Customer(baseobjc, UUIDCreatableMixin, DateCreatableMixin, DateUpdateableM :cvar city: City where the customer resides. :cvar state: State where the customer resides. :cvar is_verified: Flag indicating if the customer account is verified. + :cvar oauth_provider: OAuth provider name. + :cvar oauth_id: OAuth provider's user ID + :cvar oauth_email: Email address from OAuth. + :cvar created_via_oauth: Flag indicating if the customer account is created using OAuth. :cvar orders: Relationship to the `Order` model, representing orders placed by the customer. """ __tablename__ = "customers" id = Column("id", Integer, primary_key=True, index=True, autoincrement=True) email = Column("email_address", String(100), unique=True, index=True, nullable=False) - password = Column("password", Text, nullable=False) + password = Column("password", Text, nullable=True) name = Column("full_name", String(100), nullable=False) - addr_line_1 = Column("address_line_1", Text, nullable=False) + addr_line_1 = Column("address_line_1", Text, nullable=True) addr_line_2 = Column("address_line_2", Text, nullable=True) - city = Column("city", Text, nullable=False) - state = Column("state", Text, nullable=False) + city = Column("city", Text, nullable=True) + state = Column("state", Text, nullable=True) is_verified = Column("is_verified", Boolean, default=False) + oauth_provider = Column("oauth_provider", String(50), nullable=True) + oauth_id = Column("oauth_id", String(100), nullable=True) + oauth_email = Column("oauth_email", String(100), nullable=True) + created_via_oauth = Column("created_via_oauth", Boolean, default=False) orders = relationship("Order", back_populates="customers") diff --git a/fastapi_ecom/database/pydantic_schemas/business.py b/fastapi_ecom/database/pydantic_schemas/business.py index 9285b61..0048e60 100644 --- a/fastapi_ecom/database/pydantic_schemas/business.py +++ b/fastapi_ecom/database/pydantic_schemas/business.py @@ -35,10 +35,10 @@ class BusinessView(BusinessBase): """ email: EmailStr name: str - addr_line_1: str - addr_line_2: str - city: str - state: str + addr_line_1: Optional[str] + addr_line_2: Optional[str] + city: Optional[str] + state: Optional[str] class BusinessCreate(BusinessView): diff --git a/fastapi_ecom/migrations/versions/02d907ef8e5b_add_oauth_related_fields.py b/fastapi_ecom/migrations/versions/02d907ef8e5b_add_oauth_related_fields.py new file mode 100644 index 0000000..1681988 --- /dev/null +++ b/fastapi_ecom/migrations/versions/02d907ef8e5b_add_oauth_related_fields.py @@ -0,0 +1,92 @@ +"""add oauth related fields + +Revision ID: 02d907ef8e5b +Revises: 60c8ccec25ac +Create Date: 2025-07-27 19:45:21.800069 + +""" +from collections.abc import Sequence +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '02d907ef8e5b' +down_revision: Union[str, None] = '60c8ccec25ac' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('businesses', sa.Column('oauth_provider', sa.String(length=50), nullable=True)) + op.add_column('businesses', sa.Column('oauth_id', sa.String(length=100), nullable=True)) + op.add_column('businesses', sa.Column('oauth_email', sa.String(length=100), nullable=True)) + op.add_column('businesses', sa.Column('created_via_oauth', sa.Boolean(), nullable=True)) + op.alter_column('businesses', 'password', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('businesses', 'address_line_1', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('businesses', 'city', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('businesses', 'state', + existing_type=sa.TEXT(), + nullable=True) + op.add_column('customers', sa.Column('oauth_provider', sa.String(length=50), nullable=True)) + op.add_column('customers', sa.Column('oauth_id', sa.String(length=100), nullable=True)) + op.add_column('customers', sa.Column('oauth_email', sa.String(length=100), nullable=True)) + op.add_column('customers', sa.Column('created_via_oauth', sa.Boolean(), nullable=True)) + op.alter_column('customers', 'password', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('customers', 'address_line_1', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('customers', 'city', + existing_type=sa.TEXT(), + nullable=True) + op.alter_column('customers', 'state', + existing_type=sa.TEXT(), + nullable=True) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('customers', 'state', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('customers', 'city', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('customers', 'address_line_1', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('customers', 'password', + existing_type=sa.TEXT(), + nullable=False) + op.drop_column('customers', 'created_via_oauth') + op.drop_column('customers', 'oauth_email') + op.drop_column('customers', 'oauth_id') + op.drop_column('customers', 'oauth_provider') + op.alter_column('businesses', 'state', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('businesses', 'city', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('businesses', 'address_line_1', + existing_type=sa.TEXT(), + nullable=False) + op.alter_column('businesses', 'password', + existing_type=sa.TEXT(), + nullable=False) + op.drop_column('businesses', 'created_via_oauth') + op.drop_column('businesses', 'oauth_email') + op.drop_column('businesses', 'oauth_id') + op.drop_column('businesses', 'oauth_provider') + # ### end Alembic commands ### diff --git a/fastapi_ecom/utils/auth.py b/fastapi_ecom/utils/auth.py index bf1140d..bc80b83 100644 --- a/fastapi_ecom/utils/auth.py +++ b/fastapi_ecom/utils/auth.py @@ -1,64 +1,67 @@ -import bcrypt +from typing import Optional + from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.future import select -from sqlalchemy.orm import selectinload -from fastapi_ecom.database.db_setup import get_db from fastapi_ecom.database.models.business import Business from fastapi_ecom.database.models.customer import Customer +from fastapi_ecom.utils.basic_auth import verify_basic_business_cred, verify_basic_customer_cred +from fastapi_ecom.utils.oauth import verify_oauth_business_cred, verify_oauth_customer_cred -# Initialize HTTP Basic Authentication. -# This will prompt users for a username and password when accessing secured endpoints. -security = HTTPBasic() -async def verify_cust_cred(credentials: HTTPBasicCredentials = Depends(security), db: AsyncSession = Depends(get_db)) -> Customer: +async def verify_cust_cred( + basic_customer: Optional[Customer] = Depends(verify_basic_customer_cred), + oauth_customer: Optional[Customer] = Depends(verify_oauth_customer_cred) +) -> Customer: """ - Verify customer credentials using HTTP Basic Authentication. - This function retrieves the customer record from the database using the provided username - (email address) and verifies the provided password against the stored hashed password. + Verify customer credentials using either HTTP Basic Authentication or OAuth. + + This function attempts to authenticate a customer using multiple authentication + methods. It first tries HTTP Basic Authentication, then falls back to OAuth + authentication if basic auth is not provided or fails. - :param credentials: HTTPBasicCredentials containing the username and password. - :param db: Database session to query customer data. + :param basic_customer: Customer object from HTTP Basic Authentication or None. + :param oauth_customer: Customer object from OAuth authentication or None. - :return: The customer object if authentication is successful. + :return: The authenticated customer object. - :raises HTTPException: If the username or password is incorrect. + :raises HTTPException: If neither authentication method succeeds. """ - query = select(Customer).where(Customer.email == credentials.username).options(selectinload("*")) - result = await db.execute(query) - customer_by_email = result.scalar_one_or_none() - if not customer_by_email or not bcrypt.checkpw(credentials.password.encode('utf-8'), customer_by_email.password.encode('utf-8')): - raise HTTPException( - status_code = status.HTTP_401_UNAUTHORIZED, - detail = "Invalid authentication credentials", - headers = {"WWW-Authenticate": "Basic"}, - ) - else: - return customer_by_email - -async def verify_business_cred(credentials: HTTPBasicCredentials = Depends(security), db: AsyncSession = Depends(get_db)) -> Business: + if basic_customer: + return basic_customer + + if oauth_customer: + return oauth_customer + + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Not Authenticated", + ) + +async def verify_business_cred( + basic_business: Optional[Business] = Depends(verify_basic_business_cred), + oauth_business: Optional[Business] = Depends(verify_oauth_business_cred) +) -> Business: """ - Verify business credentials using HTTP Basic Authentication. - This function retrieves the business record from the database using the provided username - (email address) and verifies the provided password against the stored hashed password. + Verify business credentials using either HTTP Basic Authentication or OAuth. + + This function attempts to authenticate a business using multiple authentication + methods. It first tries HTTP Basic Authentication, then falls back to OAuth + authentication if basic auth is not provided or fails. - :param credentials: HTTPBasicCredentials containing the username and password. - :param db: Database session to query business data. + :param basic_business: Business object from HTTP Basic Authentication or None. + :param oauth_business: Business object from OAuth authentication or None. - :return: The business object if authentication is successful. + :return: The authenticated business object. - :raises HTTPException: If the username or password is incorrect. + :raises HTTPException: If neither authentication method succeeds. """ - query = select(Business).where(Business.email == credentials.username).options(selectinload("*")) - result = await db.execute(query) - business_by_email = result.scalar_one_or_none() - if not business_by_email or not bcrypt.checkpw(credentials.password.encode('utf-8'), business_by_email.password.encode('utf-8')): - raise HTTPException( - status_code = status.HTTP_401_UNAUTHORIZED, - detail = "Invalid authentication credentials", - headers = {"WWW-Authenticate": "Basic"}, - ) - else: - return business_by_email + if basic_business: + return basic_business + + if oauth_business: + return oauth_business + + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Not Authenticated", + ) diff --git a/fastapi_ecom/utils/basic_auth.py b/fastapi_ecom/utils/basic_auth.py new file mode 100644 index 0000000..c5f401b --- /dev/null +++ b/fastapi_ecom/utils/basic_auth.py @@ -0,0 +1,66 @@ +import bcrypt +from fastapi import Depends +from fastapi.security import HTTPBasic, HTTPBasicCredentials +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload + +from fastapi_ecom.database.db_setup import get_db +from fastapi_ecom.database.models.business import Business +from fastapi_ecom.database.models.customer import Customer + +# Initialize HTTP Basic Authentication. +# This will prompt users for a username and password when accessing secured endpoints. +security = HTTPBasic(auto_error=False) + +async def verify_basic_customer_cred(credentials: HTTPBasicCredentials | None = Depends(security), db: AsyncSession = Depends(get_db)) -> Customer: + """ + Verify customer credentials using HTTP Basic Authentication. + + This function retrieves the customer record from the database using the provided username + (email address) and verifies the provided password against the stored hashed password. + + :param credentials: HTTPBasicCredentials containing the username and password. + :param db: Database session to query customer data. + + :return: The customer object if authentication is successful else returns None. + """ + if not credentials: #pragma: no cover + return None + + query = select(Customer).where(Customer.email == credentials.username).options(selectinload("*")) + result = await db.execute(query) + customer_by_email = result.scalar_one_or_none() + + if not customer_by_email: + return None + elif not bcrypt.checkpw(credentials.password.encode('utf-8'), customer_by_email.password.encode('utf-8')): + return None + else: + return customer_by_email + +async def verify_basic_business_cred(credentials: HTTPBasicCredentials | None = Depends(security), db: AsyncSession = Depends(get_db)) -> Business: + """ + Verify business credentials using HTTP Basic Authentication. + + This function retrieves the business record from the database using the provided username + (email address) and verifies the provided password against the stored hashed password. + + :param credentials: HTTPBasicCredentials containing the username and password. + :param db: Database session to query business data. + + :return: The business object if authentication is successful else returns None. + """ + if not credentials: #pragma: no cover + return None + + query = select(Business).where(Business.email == credentials.username).options(selectinload("*")) + result = await db.execute(query) + business_by_email = result.scalar_one_or_none() + + if not business_by_email: + return None + elif not bcrypt.checkpw(credentials.password.encode('utf-8'), business_by_email.password.encode('utf-8')): + return None + else: + return business_by_email diff --git a/fastapi_ecom/utils/oauth.py b/fastapi_ecom/utils/oauth.py new file mode 100644 index 0000000..d86d724 --- /dev/null +++ b/fastapi_ecom/utils/oauth.py @@ -0,0 +1,207 @@ +from datetime import datetime, timezone +from uuid import uuid4 + +from authlib.integrations.starlette_client import OAuth +from authlib.oidc.core import UserInfo +from fastapi import Depends, HTTPException, status +from fastapi.security import OpenIdConnect +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy.orm import selectinload + +from fastapi_ecom.config import config +from fastapi_ecom.database.db_setup import get_db +from fastapi_ecom.database.models.business import Business +from fastapi_ecom.database.models.customer import Customer + +server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration" + +oidc = OpenIdConnect( + openIdConnectUrl=server_metadata_url, + scheme_name="OpenID Connect", + auto_error=False +) + +oauth = OAuth() + +oauth.register( + name = "google", + client_id = config.GOOGLE_CLIENT_ID, + client_secret = config.GOOGLE_CLIENT_SECRET, + server_metadata_url = server_metadata_url, + client_kwargs = { + 'scope': 'openid email profile' + } +) + +class OIDCUser(BaseModel): + """ + Pydantic model representing an authenticated OIDC user. + + This model is used to validate and structure user information received from + OpenID Connect providers like Google OAuth. + + :cvar email: Email address of the authenticated user. + :cvar name: Full name of the authenticated user. + :cvar sub: Subject identifier from the OIDC provider. + """ + email: str + name: str | None = None + sub: str + + @classmethod + def from_userinfo(cls, userinfo: UserInfo) -> "OIDCUser": + fields = {field: userinfo[field] for field in cls.model_fields if field in userinfo} + return cls(**fields) + +async def current_user(token: str = Depends(oidc)) -> OIDCUser | None: + """ + Extract and validate the current authenticated user from OIDC token. + + This function processes the Bearer token from the Authorization header, validates it with + the Google OAuth provider, and returns the authenticated user information. + + :param token: Bearer token containing the OIDC access token. + + :return: The authenticated OIDC user object or None if authentication fails. + """ + if not token: + return None + try: + token_type, token = token.split(" ", 1) + except ValueError: + return None + if token_type.lower() != "bearer": + return None + try: + userinfo = await oauth.google.userinfo(token={"access_token": token}) + except Exception: + return None + return OIDCUser.from_userinfo(userinfo) + +async def verify_oauth_customer_cred(oidc: OIDCUser = Depends(current_user), db: AsyncSession = Depends(get_db)) -> Customer: + """ + Verify OAuth customer credentials and retrieve or create customer record. + + This function first checks if a customer with the OAuth email exists in the + regular email column (basic auth users). If found, it updates that record with + OAuth information. If not found, it checks for existing OAuth users, and finally + creates a new customer record if none exists. + + :param oidc: Authenticated OIDC user object containing user information. + :param db: Database session to query and create customer data. + + :return: The customer object if authentication is successful. + + :raises HTTPException: If database operations fail. + """ + if not oidc: + return None + + query = select(Customer).where(Customer.email == oidc.email).options(selectinload("*")) + result = await db.execute(query) + customer_by_email = result.scalar_one_or_none() + if customer_by_email: + customer_by_email.oauth_provider = "google" + customer_by_email.oauth_id = oidc.sub + customer_by_email.oauth_email = oidc.email + customer_by_email.is_verified = True + customer_by_email.update_date = datetime.now(timezone.utc) + db.add(customer_by_email) + try: + await db.flush() + except Exception as expt: #pragma: no cover + # HTTP status code 500 is already tested in other parts of the codebase + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected database error occurred." + ) from expt + else: + query = select(Customer).where(Customer.oauth_email == oidc.email).options(selectinload("*")) + result = await db.execute(query) + customer_by_email = result.scalar_one_or_none() + if not customer_by_email: + customer_by_email = Customer( + email = oidc.email, + name = oidc.name, + uuid = uuid4().hex[0:8], + is_verified = True, # OAuth emails are pre-verified + oauth_provider = "google", + oauth_id = oidc.sub, + oauth_email = oidc.email, + created_via_oauth = True + ) + db.add(customer_by_email) + try: + await db.flush() + except Exception as expt: #pragma: no cover + # HTTP status code 500 is already tested in other parts of the codebase + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected database error occurred." + ) from expt + return customer_by_email + +async def verify_oauth_business_cred(oidc: OIDCUser = Depends(current_user), db: AsyncSession = Depends(get_db)) -> Business: + """ + Verify OAuth business credentials and retrieve or create business record. + + This function first checks if a business with the OAuth email exists in the + regular email column (basic auth users). If found, it updates that record with + OAuth information. If not found, it checks for existing OAuth users, and finally + creates a new business record if none exists. + + :param oidc: Authenticated OIDC user object containing user information. + :param db: Database session to query and create business data. + + :return: The business object if authentication is successful. + + :raises HTTPException: If database operations fail. + """ + if not oidc: + return None + + query = select(Business).where(Business.email == oidc.email).options(selectinload("*")) + result = await db.execute(query) + business_by_email = result.scalar_one_or_none() + if business_by_email: + business_by_email.oauth_provider = "google" + business_by_email.oauth_id = oidc.sub + business_by_email.oauth_email = oidc.email + business_by_email.is_verified = True + business_by_email.update_date = datetime.now(timezone.utc) + db.add(business_by_email) + try: + await db.flush() + except Exception as expt: #pragma: no cover + # HTTP status code 500 is already tested in other parts of the codebase + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected database error occurred." + ) from expt + else: + query = select(Business).where(Business.oauth_email == oidc.email).options(selectinload("*")) + result = await db.execute(query) + business_by_email = result.scalar_one_or_none() + if not business_by_email: + business_by_email = Business( + email = oidc.email, + name = oidc.name, + uuid = uuid4().hex[0:8], + is_verified = True, # OAuth emails are pre-verified + oauth_provider = "google", + oauth_id = oidc.sub, + oauth_email = oidc.email, + created_via_oauth = True + ) + db.add(business_by_email) + try: + await db.flush() + except Exception as expt: #pragma: no cover + # HTTP status code 500 is already tested in other parts of the codebase + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="An unexpected database error occurred." + ) from expt + return business_by_email diff --git a/poetry.lock b/poetry.lock index 10de1a6..927ae5c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -132,6 +132,20 @@ docs = ["Sphinx (>=8.1.3,<8.2.0)", "sphinx-rtd-theme (>=1.2.2)"] gssauth = ["gssapi", "sspilib"] test = ["distro (>=1.9.0,<1.10.0)", "flake8 (>=6.1,<7.0)", "flake8-pyi (>=24.1.0,<24.2.0)", "gssapi", "k5test", "mypy (>=1.8.0,<1.9.0)", "sspilib", "uvloop (>=0.15.3)"] +[[package]] +name = "authlib" +version = "1.6.1" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.9" +files = [ + {file = "authlib-1.6.1-py2.py3-none-any.whl", hash = "sha256:e9d2031c34c6309373ab845afc24168fe9e93dc52d252631f52642f21f5ed06e"}, + {file = "authlib-1.6.1.tar.gz", hash = "sha256:4dffdbb1460ba6ec8c17981a4c67af7d8af131231b5a36a88a1e8c80c111cdfd"}, +] + +[package.dependencies] +cryptography = "*" + [[package]] name = "bcrypt" version = "4.3.0" @@ -207,6 +221,96 @@ files = [ {file = "cachetools-6.1.0.tar.gz", hash = "sha256:b4c4f404392848db3ce7aac34950d17be4d864da4b8b66911008e430bc544587"}, ] +[[package]] +name = "certifi" +version = "2025.7.14" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +files = [ + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, +] + +[[package]] +name = "cffi" +version = "1.17.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "chardet" version = "5.2.0" @@ -343,6 +447,65 @@ files = [ [package.extras] toml = ["tomli"] +[[package]] +name = "cryptography" +version = "45.0.5" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +files = [ + {file = "cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9"}, + {file = "cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e"}, + {file = "cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174"}, + {file = "cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9"}, + {file = "cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63"}, + {file = "cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42"}, + {file = "cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0"}, + {file = "cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a"}, + {file = "cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f"}, + {file = "cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e"}, + {file = "cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1"}, + {file = "cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f"}, + {file = "cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2)"] +pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "distlib" version = "0.3.9" @@ -523,6 +686,51 @@ files = [ {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, ] +[[package]] +name = "httpcore" +version = "1.0.8" +description = "A minimal low-level HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be"}, + {file = "httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +trio = ["trio (>=0.22.0,<1.0)"] + +[[package]] +name = "httpx" +version = "0.28.1" +description = "The next generation HTTP client." +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = "==1.*" +idna = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (==1.*)"] +zstd = ["zstandard (>=0.18.0)"] + [[package]] name = "idna" version = "3.10" @@ -548,6 +756,17 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +description = "Safely pass data to untrusted environments and back." +optional = false +python-versions = ">=3.8" +files = [ + {file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"}, + {file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"}, +] + [[package]] name = "mako" version = "1.3.8" @@ -756,6 +975,17 @@ files = [ {file = "psycopg2_binary-2.9.10-cp39-cp39-win_amd64.whl", hash = "sha256:30e34c4e97964805f715206c7b789d54a78b70f3ff19fbe590104b71c45600e5"}, ] +[[package]] +name = "pycparser" +version = "2.22" +description = "C parser in Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, + {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, +] + [[package]] name = "pydantic" version = "2.11.7" @@ -1235,4 +1465,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "1849cdde2db9f13c4e0849ba67619e7d11ab0fd2a25ffdfebb4e1baa81127de5" +content-hash = "e4c215efa4fc72bfbbb1633e4561cdc9f7a8c8da576e063d07340780a0d18191" diff --git a/pyproject.toml b/pyproject.toml index 432925e..2cff02e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ bcrypt = "^4.2.0" asyncpg = "^0.30.0" psycopg2-binary = "^2.9.10" pydantic = {extras = ["email"], version = "^2.11.7"} +authlib = "^1.6.1" +httpx = "^0.28.1" +itsdangerous = "^2.2.0" [tool.poetry.group.dev.dependencies] ruff = "^0.12.0" diff --git a/tests/business/test_business_get.py b/tests/business/test_business_get.py index f7b498e..6f43ef7 100644 --- a/tests/business/test_business_get.py +++ b/tests/business/test_business_get.py @@ -51,10 +51,10 @@ async def test_get_business_me( @pytest.mark.parametrize( "_", [ - pytest.param(None, id="BUSINESS GET Endpoint - Fail authentication for business") + pytest.param(None, id="BUSINESS GET Endpoint - Fail authentication for business with incorrect password") ] ) -async def test_get_business_me_fail( +async def test_get_business_me_fail_pwd( client: AsyncClient, db_test_create: None, db_test_data: None, @@ -87,7 +87,184 @@ async def test_get_business_me_fail( Test the response """ assert response.status_code == 401 - assert response.json()["detail"] == "Invalid authentication credentials" + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "_", + [ + pytest.param(None, id="BUSINESS GET Endpoint - Fail authentication for business with no user") + ] +) +async def test_get_business_me_fail_no_user( + client: AsyncClient, + db_test_create: None, + apply_security_override: None, + _: None +) -> None: + """ + Test the `get` endpoint for the incorrectly authenticated business of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param apply_security_override: Fixture to set up test client with dependency override for `security`. + + :return: + """ + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/business/me") + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "mock_oidc_user", + [ + pytest.param( + { + "email": "dummy_user@example.com", + "name": "dummy user", + "sub": "dummy_user_sub" + }, + id="BUSINESS GET Endpoint - Fetch email of the `dummy` authenticated business with oidc" + ), + pytest.param( + { + "email": "delete@example.com", + "name": "delete user", + "sub": "delete_sub" + }, + id="BUSINESS GET Endpoint - Fetch email of the `delete` authenticated business with oidc" + ) + ] +) +async def test_get_business_me_oauth( + client: AsyncClient, + db_test_create: None, + db_test_data: None, + mock_oidc_user: dict, + mocker: MockerFixture, +) -> None: + """ + Test the `get` endpoint for the currently authenticated business of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param db_test_data: Fixture to populate the test database with initial test data. + :param mock_oidc_user: Dictonary with dummy odic user details. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Use dummy user and mock the oidc implementation + """ + mocker.patch("fastapi_ecom.utils.oauth.oauth.google.userinfo", return_value=mock_oidc_user) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/business/me", headers={"Authorization": "Bearer dummy-token"}) + + """ + Test the response + """ + assert response.status_code == 200 + assert response.json() == { + "action": "get", + "email": f"{mock_oidc_user["email"]}" + } + +@pytest.mark.parametrize( + "token", + [ + pytest.param("Bearer", id="BUSINESS GET Endpoint - Invalid Bearer token length"), + pytest.param("Dummy token", id="BUSINESS GET Endpoint - Dummy Bearer token") + ] +) +async def test_get_business_me_oauth_token_issue( + client: AsyncClient, + db_test_create: None, + token: str, + mocker: MockerFixture, +) -> None: + """ + Test the `get` endpoint for the currently authenticated business of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param token: Dummy bearer token. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/business/me", headers={"Authorization": token}) + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "_", + [ + pytest.param(None, id="CUSTOMER GET Endpoint - Fail to get user info from oauth provider") + ] +) +async def test_get_business_me_oauth_fail( + client: AsyncClient, + db_test_create: None, + mocker: MockerFixture, + _, +) -> None: + """ + Test the `get` endpoint for the currently authenticated business of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Mock `userinfo` to induce side effect while fetching oauth details + """ + mocker.patch("fastapi_ecom.utils.oauth.oauth.google.userinfo", side_effect=Exception) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/business/me", headers={"Authorization": "Bearer dummy-token"}) + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" @pytest.mark.parametrize( "_", diff --git a/tests/conftest.py b/tests/conftest.py index b91fe70..1f3b7b9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,7 @@ from fastapi_ecom.app import app from fastapi_ecom.config import config as cnfg from fastapi_ecom.database import baseobjc, get_async_session, get_engine -from fastapi_ecom.utils.auth import security +from fastapi_ecom.utils.basic_auth import security from tests.business import _test_data_business from tests.customer import _test_data_customer from tests.order import _test_data_order_details, _test_data_orders diff --git a/tests/customer/test_customer_get.py b/tests/customer/test_customer_get.py index f6002d3..0bd83e6 100644 --- a/tests/customer/test_customer_get.py +++ b/tests/customer/test_customer_get.py @@ -54,7 +54,7 @@ async def test_get_customer_me( pytest.param(None, id="CUSTOMER GET Endpoint - Fail authentication for customer") ] ) -async def test_get_customer_me_fail( +async def test_get_customer_me_fail_pwd( client: AsyncClient, db_test_create: None, db_test_data: None, @@ -87,7 +87,184 @@ async def test_get_customer_me_fail( Test the response """ assert response.status_code == 401 - assert response.json()["detail"] == "Invalid authentication credentials" + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "_", + [ + pytest.param(None, id="CUSTOMER GET Endpoint - Fail authentication for customer with no user") + ] +) +async def test_get_customer_me_fail_no_user( + client: AsyncClient, + db_test_create: None, + apply_security_override: None, + _: None +) -> None: + """ + Test the `get` endpoint for the incorrectly authenticated customer of the Customer API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param apply_security_override: Fixture to set up test client with dependency override for `security`. + + :return: + """ + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/customer/me") + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "mock_oidc_user", + [ + pytest.param( + { + "email": "dummy_user@example.com", + "name": "dummy user", + "sub": "dummy_user_sub" + }, + id="CUSTOMER GET Endpoint - Fetch email of the `dummy` authenticated customer with oidc" + ), + pytest.param( + { + "email": "delete@example.com", + "name": "delete user", + "sub": "delete_sub" + }, + id="CUSTOMER GET Endpoint - Fetch email of the `delete` authenticated customer with oidc" + ) + ] +) +async def test_get_customer_me_oauth( + client: AsyncClient, + db_test_create: None, + db_test_data: None, + mock_oidc_user, + mocker: MockerFixture, +) -> None: + """ + Test the `get` endpoint for the currently authenticated customer of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param db_test_data: Fixture to populate the test database with initial test data. + :param mock_oidc_user: Dictonary with dummy odic user details. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Use dummy user and mock the oidc implementation + """ + mocker.patch("fastapi_ecom.utils.oauth.oauth.google.userinfo", return_value=mock_oidc_user) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/customer/me", headers={"Authorization": "Bearer dummy-token"}) + + """ + Test the response + """ + assert response.status_code == 200 + assert response.json() == { + "action": "get", + "email": f"{mock_oidc_user["email"]}" + } + +@pytest.mark.parametrize( + "token", + [ + pytest.param("Bearer", id="CUSTOMER GET Endpoint - Invalid Bearer token length"), + pytest.param("Dummy token", id="CUSTOMER GET Endpoint - Dummy Bearer token") + ] +) +async def test_get_customer_me_oauth_token_issue( + client: AsyncClient, + db_test_create: None, + token: str, + mocker: MockerFixture, +) -> None: + """ + Test the `get` endpoint for the currently authenticated customer of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param token: Dummy bearer token. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/customer/me", headers={"Authorization": token}) + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" + +@pytest.mark.parametrize( + "_", + [ + pytest.param(None, id="CUSTOMER GET Endpoint - Fail to get user info from oauth provider") + ] +) +async def test_get_customer_me_oauth_fail( + client: AsyncClient, + db_test_create: None, + mocker: MockerFixture, + _, +) -> None: + """ + Test the `get` endpoint for the currently authenticated customer of the Business API. + + :param client: The test client to send HTTP requests. + :param db_test_create: Fixture which creates a test database. + :param mocker: Mock fixture to be used for mocking desired functionality. + + :return: + """ + """ + Mock the password check for failing basic auth + """ + mocker.patch("bcrypt.checkpw", return_value=False) + + """ + Mock `userinfo` to induce side effect while fetching oauth details + """ + mocker.patch("fastapi_ecom.utils.oauth.oauth.google.userinfo", side_effect=Exception) + + """ + Perform the action of visiting the endpoint + """ + response = await client.get("/api/v1/customer/me", headers={"Authorization": "Bearer dummy-token"}) + + """ + Test the response + """ + assert response.status_code == 401 + assert response.json()["detail"] == "Not Authenticated" @pytest.mark.parametrize( "_", diff --git a/tests/order/test_order_get.py b/tests/order/test_order_get.py index 9b90434..f8960c3 100644 --- a/tests/order/test_order_get.py +++ b/tests/order/test_order_get.py @@ -5,7 +5,7 @@ from httpx import AsyncClient from pytest_mock import MockerFixture -from fastapi_ecom.utils.auth import security +from fastapi_ecom.utils.basic_auth import security from tests.order import _test_data_order_details, _test_data_orders diff --git a/tests/order/test_order_post.py b/tests/order/test_order_post.py index ab2f40a..69d5fb2 100644 --- a/tests/order/test_order_post.py +++ b/tests/order/test_order_post.py @@ -7,7 +7,7 @@ from httpx import AsyncClient from pytest_mock import MockerFixture -from fastapi_ecom.utils.auth import security +from fastapi_ecom.utils.basic_auth import security @pytest.mark.parametrize( diff --git a/tests/product/test_product_get.py b/tests/product/test_product_get.py index c65659d..43474f5 100644 --- a/tests/product/test_product_get.py +++ b/tests/product/test_product_get.py @@ -5,7 +5,7 @@ from httpx import AsyncClient from pytest_mock import MockerFixture -from fastapi_ecom.utils.auth import security +from fastapi_ecom.utils.basic_auth import security from tests.product import _test_data_product diff --git a/tests/product/test_product_post.py b/tests/product/test_product_post.py index eaa02bb..1e027fa 100644 --- a/tests/product/test_product_post.py +++ b/tests/product/test_product_post.py @@ -6,7 +6,7 @@ from httpx import AsyncClient from pytest_mock import MockerFixture -from fastapi_ecom.utils.auth import security +from fastapi_ecom.utils.basic_auth import security @pytest.mark.parametrize(