From a91aea805f5e4ddbeefa6e73b8e3833fec4a716e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Trkan?= Date: Sat, 11 Oct 2025 21:16:53 +0200 Subject: [PATCH] feat(auth): add BankID OAuth provider --- ...1_2107-5ab2e654c96e_change_token_lenght.py | 38 ++++++++++++++ 7project/backend/app/app.py | 11 +++++ 7project/backend/app/models/user.py | 7 ++- 7project/backend/app/oauth/bank_id.py | 49 +++++++++++++++++++ 7project/backend/app/services/user_service.py | 6 +++ 5 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py create mode 100644 7project/backend/app/oauth/bank_id.py diff --git a/7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py b/7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py new file mode 100644 index 0000000..e3b8e3a --- /dev/null +++ b/7project/backend/alembic/versions/2025_10_11_2107-5ab2e654c96e_change_token_lenght.py @@ -0,0 +1,38 @@ +"""change token lenght + +Revision ID: 5ab2e654c96e +Revises: 7af8f296d089 +Create Date: 2025-10-11 21:07:41.930470 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '5ab2e654c96e' +down_revision: Union[str, Sequence[str], None] = '7af8f296d089' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('oauth_account', 'access_token', + existing_type=mysql.VARCHAR(length=1024), + type_=sa.String(length=4096), + existing_nullable=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('oauth_account', 'access_token', + existing_type=sa.String(length=4096), + type_=mysql.VARCHAR(length=1024), + existing_nullable=False) + # ### end Alembic commands ### diff --git a/7project/backend/app/app.py b/7project/backend/app/app.py index c9e5ded..ea05748 100644 --- a/7project/backend/app/app.py +++ b/7project/backend/app/app.py @@ -56,6 +56,17 @@ fastApi.include_router( tags=["auth"], ) +fastApi.include_router( + fastapi_users.get_oauth_router( + app.services.user_service.get_oauth_provider("BankID"), + auth_backend, + "SECRET", + associate_by_email=True, + ), + prefix="/auth/bankid", + tags=["auth"], +) + # Liveness/root endpoint @fastApi.get("/", include_in_schema=False) diff --git a/7project/backend/app/models/user.py b/7project/backend/app/models/user.py index 6a1865c..3a47034 100644 --- a/7project/backend/app/models/user.py +++ b/7project/backend/app/models/user.py @@ -1,13 +1,12 @@ -from typing import List - from sqlalchemy import Column, String -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, mapped_column, Mapped from fastapi_users.db import SQLAlchemyBaseUserTableUUID, SQLAlchemyBaseOAuthAccountTableUUID from app.core.base import Base class OAuthAccount(SQLAlchemyBaseOAuthAccountTableUUID, Base): - pass + # BankID token is longer than default + access_token: Mapped[str] = mapped_column(String(length=4096), nullable=False) class User(SQLAlchemyBaseUserTableUUID, Base): diff --git a/7project/backend/app/oauth/bank_id.py b/7project/backend/app/oauth/bank_id.py new file mode 100644 index 0000000..a522f3a --- /dev/null +++ b/7project/backend/app/oauth/bank_id.py @@ -0,0 +1,49 @@ +import secrets +from typing import Optional, Literal + +from httpx_oauth.clients.openid import OpenID +from httpx_oauth.oauth2 import T + + +class BankID(OpenID): + def __init__(self, client_id: str, client_secret: str): + super().__init__( + client_id, + client_secret, + "https://oidc.sandbox.bankid.cz/.well-known/openid-configuration", + "BankID", + base_scopes=["openid", "profile.email", "profile.name"], + ) + + async def get_user_info(self, token: str) -> dict: + info = await self.get_profile(token) + + return { + "first_name": info.get("given_name"), + "last_name": info.get("family_name"), + } + + async def get_authorization_url( + self, + redirect_uri: str, + state: Optional[str] = None, + scope: Optional[list[str]] = None, + code_challenge: Optional[str] = None, + code_challenge_method: Optional[Literal["plain", "S256"]] = None, + extras_params: Optional[T] = None, + ) -> str: + if extras_params is None: + extras_params = {} + + # BankID requires random nonce parameter for security + # https://developer.bankid.cz/docs/security_sep + extras_params["nonce"] = secrets.token_urlsafe() + + return await super().get_authorization_url( + redirect_uri, + state, + scope, + code_challenge, + code_challenge_method, + extras_params, + ) diff --git a/7project/backend/app/services/user_service.py b/7project/backend/app/services/user_service.py index feee712..ab40760 100644 --- a/7project/backend/app/services/user_service.py +++ b/7project/backend/app/services/user_service.py @@ -12,11 +12,13 @@ from fastapi_users.authentication.strategy.jwt import JWTStrategy from fastapi_users.db import SQLAlchemyUserDatabase from app.models.user import User +from app.oauth.bank_id import BankID from app.oauth.moje_id import MojeIDOAuth from app.services.db import get_user_db from app.core.queue import enqueue_email SECRET = os.getenv("SECRET", "CHANGE_ME_SECRET") + FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") @@ -24,6 +26,10 @@ providers = { "MojeID": MojeIDOAuth( os.getenv("MOJEID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), os.getenv("MOJEID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), + ), + "BankID": BankID( + os.getenv("BANKID_CLIENT_ID", "CHANGE_ME_CLIENT_ID"), + os.getenv("BANKID_CLIENT_SECRET", "CHANGE_ME_CLIENT_SECRET"), ) }