from os import environ import string import random from typing import Type, Tuple from pathlib import Path from pydantic import BaseModel, computed_field, AnyUrl from pydantic_settings import ( BaseSettings, SettingsConfigDict, PydanticBaseSettingsSource, YamlConfigSettingsSource, ) from starlette.requests import Request class Resource(BaseModel): """A resource with an URL that can be accessed with an OAuth2 access token""" resource_name: str name: str url: str class AuthProviderSettings(BaseModel): """Auth provider, can also be a resource server""" id: str name: str url: str client_id: str client_secret: str = "" # For PKCE (not implemented yet) code_challenge_method: str | None = None hint: str = "No hint" resources: list[Resource] = [] account_url_template: str | None = None info_url: str | None = ( None # Used eg. for Keycloak's public key (see https://stackoverflow.com/questions/54318633/getting-keycloaks-public-key) ) public_key: str | None = None public_key_url: str | None = None signature_alg: str = "RS256" resource_provider_scopes: list[str] = [] session_key: str = "sid" skip_verify_signature: bool = True disabled: bool = False @computed_field @property def openid_configuration(self) -> str: return self.url + "/.well-known/openid-configuration" @computed_field @property def token_url(self) -> str: return "auth/" + self.id def get_account_url(self, request: Request, user: dict) -> str | None: if self.account_url_template: if not (self.url.endswith("/") or self.account_url_template.startswith("/")): sep = "/" else: sep = "" return self.url + sep + self.account_url_template.format(request=request, user=user) else: return None class ResourceProvider(BaseModel): id: str name: str base_url: AnyUrl resources: list[Resource] = [] class AuthSettings(BaseModel): show_session_details: bool = False providers: list[AuthProviderSettings] = [] swagger_provider: str = "" class Insecure(BaseModel): """Warning: changing these defaults are only suitable for debugging""" skip_verify_signature: bool = False class DB(BaseModel): host: str = "localhost" port: int = 5432 db: str = "oidc-test" user: str = "oidc-test" password: str = "oidc-test" debug: bool = False pool_size: int = 10 max_overflow: int = 10 echo: bool = False @property def sqla_url(self): return ( f"postgresql+asyncpg://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}" ) def get_pg_url(self): return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.db}" class Settings(BaseSettings): """Settings wil be read from an .env file""" model_config = SettingsConfigDict(env_nested_delimiter="__") auth: AuthSettings = AuthSettings() resource_providers: list[ResourceProvider] = [] secret_key: str = "".join(random.choice(string.ascii_letters) for _ in range(16)) log: bool = False insecure: Insecure = Insecure() db: DB = DB() cors_origins: list[str] = [] debug_token: bool = False show_token: bool = False @classmethod def settings_customise_sources( cls, settings_cls: Type[BaseSettings], init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, ) -> Tuple[PydanticBaseSettingsSource, ...]: return ( init_settings, env_settings, file_secret_settings, YamlConfigSettingsSource( settings_cls, Path( Path( environ.get("OIDC_TEST_SETTINGS_FILE", Path.cwd() / "settings.yaml"), ) ), ), dotenv_settings, ) settings = Settings()