├── .gitmodules ├── fief ├── py.typed ├── cli │ ├── __init__.py │ └── __main__.py ├── crypto │ ├── __init__.py │ ├── jwk.py │ ├── code_challenge.py │ ├── password.py │ ├── token.py │ ├── verify_code.py │ └── encryption.py ├── apps │ ├── api │ │ ├── __init__.py │ │ └── routers │ │ │ └── __init__.py │ ├── auth │ │ ├── __init__.py │ │ ├── forms │ │ │ ├── __init__.py │ │ │ ├── verify_email.py │ │ │ ├── auth.py │ │ │ ├── reset.py │ │ │ ├── password.py │ │ │ └── profile.py │ │ └── routers │ │ │ └── __init__.py │ ├── dashboard │ │ ├── __init__.py │ │ ├── forms │ │ │ ├── __init__.py │ │ │ ├── api_key.py │ │ │ ├── email_template.py │ │ │ ├── permission.py │ │ │ ├── webhook.py │ │ │ └── role.py │ │ ├── routers │ │ │ └── __init__.py │ │ ├── responses.py │ │ ├── exception_handlers.py │ │ └── validators.py │ └── __init__.py ├── middlewares │ ├── __init__.py │ ├── cors.py │ ├── locale.py │ ├── x_forwarded_host.py │ └── security_headers.py ├── services │ ├── __init__.py │ ├── webhooks │ │ ├── __init__.py │ │ └── trigger.py │ ├── email_template │ │ ├── __init__.py │ │ ├── templates │ │ │ ├── welcome.html │ │ │ ├── verify_email.html │ │ │ └── forgot_password.html │ │ └── types.py │ ├── admin.py │ ├── theme.py │ ├── localhost.py │ ├── response_type.py │ ├── email │ │ ├── null.py │ │ ├── __init__.py │ │ ├── base.py │ │ └── postmark.py │ ├── user_role_permissions.py │ ├── acr.py │ ├── posthog.py │ └── password.py ├── dependencies │ ├── __init__.py │ ├── tasks.py │ ├── branding.py │ ├── telemetry.py │ ├── email_provider.py │ ├── webhooks.py │ ├── db.py │ ├── repositories.py │ ├── logger.py │ ├── tenant_email_domain.py │ ├── request.py │ ├── login_hint.py │ ├── authentication_flow.py │ ├── user_roles.py │ └── role.py ├── __init__.py ├── alembic │ ├── README │ ├── script.py.mako │ └── table_prefix_codemod.py ├── db │ ├── __init__.py │ ├── migration.py │ └── main.py ├── templates │ ├── auth │ │ ├── logout.html │ │ ├── authorize.html │ │ ├── reset_password.html │ │ ├── forgot_password.html │ │ ├── dashboard │ │ │ ├── password.html │ │ │ ├── email │ │ │ │ └── change.html │ │ │ └── layout.html │ │ └── consent.html │ ├── admin │ │ ├── roles │ │ │ ├── list_combobox.html │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ ├── list.html │ │ │ ├── create.html │ │ │ ├── edit.html │ │ │ └── get.html │ │ ├── clients │ │ │ ├── list_combobox.html │ │ │ ├── delete.html │ │ │ ├── list.html │ │ │ ├── encryption_key.html │ │ │ ├── table.html │ │ │ ├── get │ │ │ │ ├── lifetimes.html │ │ │ │ └── base.html │ │ │ ├── edit.html │ │ │ └── create.html │ │ ├── tenants │ │ │ ├── list_combobox.html │ │ │ ├── list.html │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ ├── get │ │ │ │ └── base.html │ │ │ └── create.html │ │ ├── permissions │ │ │ ├── list_combobox.html │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ └── list.html │ │ ├── users │ │ │ ├── get │ │ │ │ ├── verify_email_requested.html │ │ │ │ └── oauth_accounts.html │ │ │ ├── delete.html │ │ │ ├── access_token.html │ │ │ ├── access_token_result.html │ │ │ └── create.html │ │ ├── oauth_providers │ │ │ ├── list_combobox.html │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ ├── list.html │ │ │ └── get.html │ │ ├── themes │ │ │ ├── list_combobox.html │ │ │ ├── list.html │ │ │ ├── create.html │ │ │ └── table.html │ │ ├── layout_boost.html │ │ ├── email_templates │ │ │ ├── list.html │ │ │ └── table.html │ │ ├── webhooks │ │ │ ├── delete.html │ │ │ ├── logs │ │ │ │ └── list.html │ │ │ ├── list.html │ │ │ ├── table.html │ │ │ ├── create.html │ │ │ ├── edit.html │ │ │ └── secret.html │ │ ├── api_keys │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ ├── list.html │ │ │ ├── create.html │ │ │ └── token.html │ │ ├── user_fields │ │ │ ├── delete.html │ │ │ ├── table.html │ │ │ ├── list.html │ │ │ └── get.html │ │ ├── error.html │ │ └── index.html │ └── macros │ │ └── buttons.html ├── repositories │ ├── webhook.py │ ├── theme.py │ ├── audit_log.py │ ├── admin_api_key.py │ ├── email_domain.py │ ├── oauth_provider.py │ ├── admin_session_token.py │ ├── grant.py │ ├── email_template.py │ ├── role.py │ ├── webhook_log.py │ ├── login_session.py │ ├── oauth_session.py │ ├── refresh_token.py │ ├── session_token.py │ ├── registration_session.py │ ├── permission.py │ ├── authorization_code.py │ ├── tenant.py │ ├── user_role.py │ ├── user_field.py │ ├── email_verification.py │ ├── oauth_account.py │ └── client.py ├── paths.py ├── schemas │ ├── user_role.py │ ├── email_template.py │ ├── oauth_account.py │ ├── webhook_log.py │ ├── permission.py │ ├── user_permission.py │ ├── __init__.py │ ├── role.py │ ├── webhook.py │ └── tenant.py ├── models │ ├── admin_api_key.py │ ├── permission.py │ ├── email_template.py │ ├── webhook.py │ ├── session_token.py │ ├── email_verification.py │ ├── grant.py │ ├── user_role.py │ ├── base.py │ ├── admin_session_token.py │ ├── webhook_log.py │ ├── refresh_token.py │ └── user_permission.py ├── scheduler.py ├── worker.py ├── settings.py ├── tasks │ ├── __init__.py │ ├── heartbeat.py │ ├── cleanup.py │ └── register.py ├── static │ └── favicon.svg ├── alembic.ini └── lifespan.py ├── tests ├── __init__.py ├── test_tasks_cleanup.py ├── test_services_email_provider_base.py ├── test_services_is_localhost.py ├── test_tasks_register.py ├── test_services_webhooks_trigger.py ├── test_app.py ├── test_tasks_forgot_password.py ├── test_dependencies_login_hint.py ├── test_tasks_email_verification.py ├── types.py ├── test_apps_security_headers.py ├── test_repositories_user.py └── test_apps_auth_locale.py ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── bug_report.md ├── dependabot.yml ├── actions │ └── lint │ │ └── action.yml └── workflows │ ├── sentry.yml │ └── docker.yml ├── .devcontainer ├── hatch.config.toml ├── post-create.sh ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── js ├── dependencies.mjs └── code-editor.mjs ├── styles ├── additional-styles │ ├── theme.scss │ └── toggle-switch.scss └── globals.scss ├── babel.cfg ├── .editorconfig ├── docker ├── supervisord.conf └── Dockerfile ├── docker-compose.yml ├── .env.dist ├── SECURITY.md ├── package.json └── .vscode └── settings.json /.gitmodules: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/crypto/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/api/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/middlewares/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/services/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/api/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/dashboard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/dependencies/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/auth/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/services/webhooks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | polar: fief-dev 2 | -------------------------------------------------------------------------------- /fief/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.30.0" 2 | -------------------------------------------------------------------------------- /fief/apps/dashboard/routers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/services/email_template/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fief/alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /.devcontainer/hatch.config.toml: -------------------------------------------------------------------------------- 1 | [dirs.env] 2 | virtual = ".hatch" 3 | -------------------------------------------------------------------------------- /fief/dependencies/tasks.py: -------------------------------------------------------------------------------- 1 | from fief.tasks import SendTask, send_task 2 | 3 | 4 | async def get_send_task() -> SendTask: 5 | return send_task 6 | -------------------------------------------------------------------------------- /fief/dependencies/branding.py: -------------------------------------------------------------------------------- 1 | from fief.settings import settings 2 | 3 | 4 | async def get_show_branding() -> bool: 5 | return settings.branding 6 | -------------------------------------------------------------------------------- /fief/services/admin.py: -------------------------------------------------------------------------------- 1 | ADMIN_PERMISSION_CODENAME = "fief:admin" 2 | ADMIN_PERMISSION_NAME = "Fief Administrator" 3 | ADMIN_ROLE_NAME = "Administrator" 4 | -------------------------------------------------------------------------------- /fief/db/__init__.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession 2 | 3 | __all__ = ["AsyncConnection", "AsyncEngine", "AsyncSession"] 4 | -------------------------------------------------------------------------------- /fief/dependencies/telemetry.py: -------------------------------------------------------------------------------- 1 | from posthog import Posthog 2 | 3 | from fief.services.posthog import posthog 4 | 5 | 6 | async def get_posthog() -> Posthog: 7 | return posthog 8 | -------------------------------------------------------------------------------- /fief/templates/auth/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "auth/layout.html" %} 2 | 3 | {% block head_title_content %}{{ _('Sign out') }}{% endblock %} 4 | 5 | {% block title %}{{ _('Sign out') }}{% endblock %} 6 | 7 | -------------------------------------------------------------------------------- /js/dependencies.mjs: -------------------------------------------------------------------------------- 1 | import htmx from 'htmx.org' 2 | import _hyperscript from 'hyperscript.org' 3 | import slugify from 'slugify' 4 | 5 | window.htmx = htmx 6 | _hyperscript.browserInit(); 7 | window.slugify = slugify 8 | -------------------------------------------------------------------------------- /fief/dependencies/email_provider.py: -------------------------------------------------------------------------------- 1 | from fief.services.email import EmailProvider 2 | from fief.settings import settings 3 | 4 | 5 | async def get_email_provider() -> EmailProvider: 6 | return settings.get_email_provider() 7 | -------------------------------------------------------------------------------- /styles/additional-styles/theme.scss: -------------------------------------------------------------------------------- 1 | .form-input, 2 | .form-textarea, 3 | .form-multiselect, 4 | .form-select, 5 | .form-checkbox, 6 | .form-radio { 7 | 8 | &:focus { 9 | @apply ring-0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /fief/apps/__init__.py: -------------------------------------------------------------------------------- 1 | from fief.apps.api.app import app as api_app 2 | from fief.apps.auth.app import app as auth_app 3 | from fief.apps.dashboard.app import app as dashboard_app 4 | 5 | __all__ = ["api_app", "auth_app", "dashboard_app"] 6 | -------------------------------------------------------------------------------- /fief/templates/auth/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends "auth/layout.html" %} 2 | 3 | {% block head_title_content %}{{ _('Authorize') }}{% endblock %} 4 | 5 | {% block title %}{{ _('Authorize') }}{% endblock %} 6 | 7 | {% block auth_form %}{% endblock %} 8 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/api_key.py: -------------------------------------------------------------------------------- 1 | from wtforms import StringField, validators 2 | 3 | from fief.forms import CSRFBaseForm 4 | 5 | 6 | class APIKeyCreateForm(CSRFBaseForm): 7 | name = StringField("Name", validators=[validators.InputRequired()]) 8 | -------------------------------------------------------------------------------- /fief/repositories/webhook.py: -------------------------------------------------------------------------------- 1 | from fief.models import Webhook 2 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 3 | 4 | 5 | class WebhookRepository(BaseRepository[Webhook], UUIDRepositoryMixin[Webhook]): 6 | model = Webhook 7 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(role) forms.form_combobox_select_field_results(roles, "id", "name", "No role is matching your query.") %} 4 | {{ role.name }} 5 | {% endcall %} 6 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(client) forms.form_combobox_select_field_results(clients, "id", "name", "No client is matching your query.") %} 4 | {{ client.name }} 5 | {% endcall %} 6 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(tenant) forms.form_combobox_select_field_results(tenants, "id", "name", "No tenant is matching your query.") %} 4 | {{ tenant.name }} 5 | {% endcall %} 6 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/email_template.py: -------------------------------------------------------------------------------- 1 | from wtforms import StringField, TextAreaField 2 | 3 | from fief.forms import CSRFBaseForm 4 | 5 | 6 | class EmailTemplateUpdateForm(CSRFBaseForm): 7 | subject = StringField("Subject") 8 | content = TextAreaField("Content") 9 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | # Extraction from Python files 2 | [python: fief/**.py] 3 | 4 | # Extraction from Jinja2 template files 5 | [jinja2: fief/templates/**.html] 6 | encoding = utf-8 7 | 8 | # Extraction from Jinja2 template files 9 | [jinja2: fief/emails/**.html] 10 | encoding = utf-8 11 | -------------------------------------------------------------------------------- /fief/templates/admin/permissions/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(permission) forms.form_combobox_select_field_results(permissions, "id", "codename", "No permission is matching your query.") %} 4 | {{ permission.codename }} 5 | {% endcall %} 6 | -------------------------------------------------------------------------------- /fief/templates/admin/users/get/verify_email_requested.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /fief/templates/admin/oauth_providers/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(oauth_provider) forms.form_combobox_select_field_results(oauth_providers, "id", "display_name", "No OAuth Provider is matching your query.") %} 4 | {{ oauth_provider.display_name }} 5 | {% endcall %} 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | charset = utf-8 11 | end_of_line = lf 12 | 13 | [*.py] 14 | indent_size = 4 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /fief/services/theme.py: -------------------------------------------------------------------------------- 1 | from fief.models import Theme 2 | from fief.repositories import ThemeRepository 3 | 4 | 5 | async def init_default_theme(repository: ThemeRepository): 6 | default_theme = await repository.get_default() 7 | if default_theme is None: 8 | await repository.create(Theme.build_default()) 9 | -------------------------------------------------------------------------------- /fief/services/email_template/templates/welcome.html: -------------------------------------------------------------------------------- 1 | {% extends "BASE" %} 2 | 3 | {% block preheader %}Welcome to {{ tenant.name }}! We're thrilled to have you on board.{% endblock %} 4 | 5 | {% block main %} 6 |

Welcome!

7 |

Welcome to {{ tenant.name }}! We're thrilled to have you on board.

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/verify_email.py: -------------------------------------------------------------------------------- 1 | from wtforms import HiddenField, validators 2 | 3 | from fief.forms import CSRFBaseForm 4 | from fief.locale import gettext_lazy as _ 5 | 6 | 7 | class VerifyEmailForm(CSRFBaseForm): 8 | code = HiddenField(_("Verification code"), validators=[validators.InputRequired()]) 9 | 10 | class Meta: 11 | id = "verify-email-form" 12 | -------------------------------------------------------------------------------- /fief/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | ROOT_DIR = Path(__file__).parent 4 | 5 | ALEMBIC_CONFIG_FILE = str(ROOT_DIR / "alembic.ini") 6 | STATIC_DIRECTORY = ROOT_DIR / "static" 7 | LOCALE_DIRECTORY = ROOT_DIR / "locale" 8 | TEMPLATES_DIRECTORY = ROOT_DIR / "templates" 9 | ADMIN_TEMPLATES_DIRECTORY = ROOT_DIR / "templates" / "admin" 10 | EMAIL_TEMPLATES_DIRECTORY = ROOT_DIR / "emails" 11 | -------------------------------------------------------------------------------- /tests/test_tasks_cleanup.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from fief.tasks.cleanup import CleanupTask 6 | 7 | 8 | @pytest.mark.asyncio 9 | class TestTasksCleanup: 10 | async def test_cleanup(self, main_session_manager, send_task_mock: MagicMock): 11 | cleanup = CleanupTask(main_session_manager, send_task=send_task_mock) 12 | await cleanup.run() 13 | -------------------------------------------------------------------------------- /docker/supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | 4 | [program:redis] 5 | command=redis-server 6 | 7 | [program:fief-server] 8 | command=fief run-server 9 | stdout_logfile=/dev/stdout 10 | stdout_logfile_maxbytes=0 11 | redirect_stderr=true 12 | 13 | [program:fief-worker] 14 | command=fief run-worker -p 1 -t 1 15 | stdout_logfile=/dev/stdout 16 | stdout_logfile_maxbytes=0 17 | redirect_stderr=true 18 | -------------------------------------------------------------------------------- /fief/schemas/user_role.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | 3 | from fief.schemas.generics import BaseModel, CreatedUpdatedAt 4 | from fief.schemas.role import RoleEmbedded 5 | 6 | 7 | class UserRoleCreate(BaseModel): 8 | id: UUID4 9 | 10 | 11 | class BaseUserRole(CreatedUpdatedAt): 12 | user_id: UUID4 13 | role_id: UUID4 14 | 15 | 16 | class UserRole(BaseUserRole): 17 | role: RoleEmbedded 18 | -------------------------------------------------------------------------------- /fief/services/localhost.py: -------------------------------------------------------------------------------- 1 | import ipaddress 2 | import re 3 | 4 | LOCALHOST_HOST_PATTERN = re.compile(r"([^\.]+\.)?localhost(\d+)?", flags=re.IGNORECASE) 5 | 6 | 7 | def is_localhost(host: str) -> bool: 8 | try: 9 | return ipaddress.IPv4Address(host).is_private 10 | except ValueError: 11 | return LOCALHOST_HOST_PATTERN.match(host) is not None 12 | 13 | 14 | __all__ = ["is_localhost"] 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres:14-alpine 4 | environment: 5 | POSTGRES_USER: fief 6 | POSTGRES_DB: fief 7 | POSTGRES_PASSWORD: fiefpassword 8 | volumes: 9 | - postgres-data:/var/lib/postgresql/data/ 10 | ports: 11 | - "5432:5432" 12 | redis: 13 | image: redis:alpine 14 | ports: 15 | - "6379:6379" 16 | 17 | volumes: 18 | postgres-data: 19 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | ENVIRONMENT=development 2 | TELEMETRY_ENABLED=0 3 | SECRET=ThisShouldBeChangedInProduction 4 | DATABASE_TYPE=POSTGRESQL 5 | DATABASE_HOST=localhost 6 | DATABASE_PORT=5432 7 | DATABASE_USERNAME=fief 8 | DATABASE_PASSWORD=fiefpassword 9 | DATABASE_NAME=fief 10 | FIEF_CLIENT_ID=FiefClientID 11 | FIEF_CLIENT_SECRET=FiefClientSecret 12 | FIEF_MAIN_USER_EMAIL=anne@bretagne.duchy 13 | FIEF_MAIN_USER_PASSWORD=herminetincture 14 | -------------------------------------------------------------------------------- /fief/templates/admin/themes/list_combobox.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | 3 | {% call(theme) forms.form_combobox_select_field_results(themes, "id", "name", "No theme is matching your query.") %} 4 | {{ theme.name }} 5 | {% if theme.default %} 6 |
7 | Default 8 |
9 | {% endif %} 10 | {% endcall %} 11 | -------------------------------------------------------------------------------- /tests/test_services_email_provider_base.py: -------------------------------------------------------------------------------- 1 | from fief.services.email.base import format_address 2 | 3 | 4 | def test_format_address_with_only_email(): 5 | formatted = format_address("test@example.com") 6 | assert formatted == "test@example.com" 7 | 8 | 9 | def test_format_address_with_name_and_address(): 10 | formatted = format_address("test@example.com", "Test Person") 11 | assert formatted == "Test Person " 12 | -------------------------------------------------------------------------------- /fief/dependencies/webhooks.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from fastapi import Depends 4 | 5 | from fief.dependencies.tasks import get_send_task 6 | from fief.services.webhooks.trigger import TriggerWebhooks, trigger_webhooks 7 | from fief.tasks import SendTask 8 | 9 | 10 | async def get_trigger_webhooks( 11 | send_task: SendTask = Depends(get_send_task), 12 | ) -> TriggerWebhooks: 13 | return functools.partial(trigger_webhooks, send_task=send_task) 14 | -------------------------------------------------------------------------------- /fief/repositories/theme.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import Theme 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class ThemeRepository(BaseRepository[Theme], UUIDRepositoryMixin[Theme]): 8 | model = Theme 9 | 10 | async def get_default(self) -> Theme | None: 11 | statement = select(Theme).where(Theme.default == True) 12 | return await self.get_one_or_none(statement) 13 | -------------------------------------------------------------------------------- /fief/templates/admin/layout_boost.html: -------------------------------------------------------------------------------- 1 | {% block head_title %} 2 | 3 | {% block head_title_content %}Fief{% endblock %} 4 | 5 | {% endblock %} 6 | 7 | {% if hx_target == "main" %} 8 | {% block main %}{% endblock %} 9 | {% endif %} 10 | {% if hx_target == "aside" %} 11 | {% block aside %}{% endblock %} 12 | {% endif %} 13 | {% if hx_target == "modal" %} 14 | {% block modal %}{% endblock %} 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /fief/services/response_type.py: -------------------------------------------------------------------------------- 1 | from typing import Literal 2 | 3 | HYBRID_RESPONSE_TYPES = ["code id_token", "code token", "code id_token token"] 4 | ALLOWED_RESPONSE_TYPES = ["code"] + HYBRID_RESPONSE_TYPES 5 | NONCE_REQUIRED_RESPONSE_TYPES = HYBRID_RESPONSE_TYPES 6 | 7 | DEFAULT_RESPONSE_MODE: dict[str, Literal["query", "fragment"]] = { 8 | "code": "query", 9 | "code id_token": "fragment", 10 | "code token": "fragment", 11 | "code id_token token": "fragment", 12 | } 13 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We provide support for the latest version and recommend to upgrade as soon as a new version is released. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you have identified a vulnerability, please report it using [GitHub Security Advisory](https://github.com/fief-dev/fief/security/advisories/new) tool, to ensure confidentiality and security. 10 | 11 | We'll review it as soon as possible and publish a fix accordingly. 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 📖 I found an error in the documentation 3 | url: https://github.com/fief-dev/docs/issues/new?assignees=&labels=documentation&template=bug_report.md&title= 4 | about: I found an error or an example in the doc is not working. 5 | - name: 🤔 I have a question 6 | url: https://github.com/orgs/fief-dev/discussions/new?category=q-a 7 | about: If you have any question about Fief that's not clearly a bug, please open a discussion first. 8 | -------------------------------------------------------------------------------- /fief/models/admin_api_key.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from fief.models.base import Base 5 | from fief.models.generics import CreatedUpdatedAt, UUIDModel 6 | 7 | 8 | class AdminAPIKey(UUIDModel, CreatedUpdatedAt, Base): 9 | __tablename__ = "admin_api_key" 10 | 11 | name: Mapped[str] = mapped_column(String(length=255), nullable=False) 12 | token: Mapped[str] = mapped_column(String(length=255), unique=True, nullable=False) 13 | -------------------------------------------------------------------------------- /fief/repositories/audit_log.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import AuditLog 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class AuditLogRepository(BaseRepository[AuditLog], UUIDRepositoryMixin[AuditLog]): 8 | model = AuditLog 9 | 10 | async def get_latest(self) -> AuditLog | None: 11 | statement = select(AuditLog).order_by(AuditLog.timestamp.desc()).limit(1) 12 | return await self.get_one_or_none(statement) 13 | -------------------------------------------------------------------------------- /fief/schemas/email_template.py: -------------------------------------------------------------------------------- 1 | from fief.schemas.generics import BaseModel, CreatedUpdatedAt, UUIDSchema 2 | from fief.services.email_template.types import EmailTemplateType 3 | 4 | 5 | class EmailTemplateUpdate(BaseModel): 6 | subject: str | None = None 7 | content: str | None = None 8 | 9 | 10 | class EmailTemplateBase(UUIDSchema, CreatedUpdatedAt): 11 | type: EmailTemplateType 12 | subject: str 13 | content: str 14 | 15 | 16 | class EmailTemplate(EmailTemplateBase): 17 | pass 18 | -------------------------------------------------------------------------------- /tests/test_services_is_localhost.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fief.services.localhost import is_localhost 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "host,result", 8 | [ 9 | ("127.0.0.1", True), 10 | ("localhost", True), 11 | ("192.168.0.42", True), 12 | ("192.168.1.1", True), 13 | ("www.bretagne.duchy", False), 14 | ("www.fief.dev", False), 15 | ], 16 | ) 17 | def test_is_localhost(host: str, result: bool): 18 | assert is_localhost(host) == result 19 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "github-actions" # See documentation for possible values 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "weekly" 10 | - package-ecosystem: "pip" 11 | directory: "/" 12 | schedule: 13 | interval: "weekly" 14 | open-pull-requests-limit: 10 15 | -------------------------------------------------------------------------------- /fief/templates/admin/email_templates/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Email templates · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Email templates

12 |
13 | 14 |
15 | {% include "admin/email_templates/table.html" %} 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/permission.py: -------------------------------------------------------------------------------- 1 | from wtforms import StringField, validators 2 | 3 | from fief.forms import CSRFBaseForm 4 | 5 | 6 | class PermissionCreateForm(CSRFBaseForm): 7 | name = StringField( 8 | "Name", 9 | validators=[validators.InputRequired()], 10 | render_kw={"placeholder": "Create Castle"}, 11 | ) 12 | codename = StringField( 13 | "Codename", 14 | validators=[validators.InputRequired()], 15 | render_kw={"placeholder": "castles:create"}, 16 | ) 17 | -------------------------------------------------------------------------------- /fief/schemas/oauth_account.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import UUID4 4 | 5 | from fief.schemas.generics import CreatedUpdatedAt, UUIDSchema 6 | from fief.schemas.oauth_provider import OAuthProvider 7 | 8 | 9 | class OAuthAccount(UUIDSchema, CreatedUpdatedAt): 10 | account_id: str 11 | oauth_provider_id: UUID4 12 | oauth_provider: OAuthProvider 13 | 14 | 15 | class OAuthAccountAccessToken(UUIDSchema): 16 | account_id: str 17 | access_token: str 18 | expires_at: datetime | None = None 19 | -------------------------------------------------------------------------------- /fief/repositories/admin_api_key.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import AdminAPIKey 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class AdminAPIKeyRepository( 8 | BaseRepository[AdminAPIKey], UUIDRepositoryMixin[AdminAPIKey] 9 | ): 10 | model = AdminAPIKey 11 | 12 | async def get_by_token(self, token: str) -> AdminAPIKey | None: 13 | statement = select(AdminAPIKey).where(AdminAPIKey.token == token) 14 | return await self.get_one_or_none(statement) 15 | -------------------------------------------------------------------------------- /.github/actions/lint/action.yml: -------------------------------------------------------------------------------- 1 | name: "Lint" 2 | description: "Run the linters and type checkers" 3 | 4 | runs: 5 | using: "composite" 6 | 7 | steps: 8 | - name: Set up Python 3.12 9 | uses: actions/setup-python@v5 10 | with: 11 | python-version: "3.12" 12 | - name: Install dependencies 13 | shell: bash 14 | run: | 15 | python -m pip install --upgrade pip 16 | pip install hatch 17 | - name: Lint and typecheck 18 | shell: bash 19 | run: | 20 | hatch run lint-check 21 | -------------------------------------------------------------------------------- /fief/repositories/email_domain.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import EmailDomain 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class EmailDomainRepository( 8 | BaseRepository[EmailDomain], UUIDRepositoryMixin[EmailDomain] 9 | ): 10 | model = EmailDomain 11 | 12 | async def get_by_domain(self, domain: str) -> EmailDomain | None: 13 | statement = select(EmailDomain).where(EmailDomain.domain == domain) 14 | return await self.get_one_or_none(statement) 15 | -------------------------------------------------------------------------------- /fief/schemas/webhook_log.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | 3 | from fief.schemas.generics import CreatedUpdatedAt, UUIDSchema 4 | from fief.schemas.webhook import WebhookEventType 5 | 6 | 7 | class BaseWebhookLog(UUIDSchema, CreatedUpdatedAt): 8 | webhook_id: UUID4 9 | event: WebhookEventType 10 | attempt: int 11 | payload: str 12 | success: bool 13 | response: str | None = None 14 | error_type: str | None = None 15 | error_message: str | None = None 16 | 17 | 18 | class WebhookLog(BaseWebhookLog): 19 | pass 20 | -------------------------------------------------------------------------------- /fief/repositories/oauth_provider.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import OAuthProvider 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class OAuthProviderRepository( 8 | BaseRepository[OAuthProvider], UUIDRepositoryMixin[OAuthProvider] 9 | ): 10 | model = OAuthProvider 11 | 12 | async def all_by_name_and_provider(self) -> list[OAuthProvider]: 13 | return await self.list( 14 | select(OAuthProvider).order_by(OAuthProvider.name, OAuthProvider.provider) 15 | ) 16 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/auth.py: -------------------------------------------------------------------------------- 1 | from wtforms import EmailField, PasswordField, SubmitField, validators 2 | 3 | from fief.forms import CSRFBaseForm 4 | from fief.locale import gettext_lazy as _ 5 | 6 | 7 | class LoginForm(CSRFBaseForm): 8 | email = EmailField( 9 | _("Email address"), validators=[validators.InputRequired(), validators.Email()] 10 | ) 11 | password = PasswordField(_("Password"), validators=[validators.InputRequired()]) 12 | 13 | 14 | class ConsentForm(CSRFBaseForm): 15 | allow = SubmitField(_("Allow")) 16 | deny = SubmitField(_("Deny")) 17 | -------------------------------------------------------------------------------- /fief/schemas/permission.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from fief.schemas.generics import CreatedUpdatedAt, UUIDSchema 4 | 5 | 6 | class PermissionCreate(BaseModel): 7 | name: str 8 | codename: str 9 | 10 | 11 | class PermissionUpdate(BaseModel): 12 | name: str | None = None 13 | codename: str | None = None 14 | 15 | 16 | class BasePermission(UUIDSchema, CreatedUpdatedAt): 17 | name: str 18 | codename: str 19 | 20 | 21 | class Permission(BasePermission): 22 | pass 23 | 24 | 25 | class PermissionEmbedded(BasePermission): 26 | pass 27 | -------------------------------------------------------------------------------- /.devcontainer/post-create.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | set -e 4 | 5 | hatch env create 6 | npm install 7 | 8 | cp .env.dist .env 9 | echo "ENCRYPTION_KEY=$(hatch run python -c "from cryptography.fernet import Fernet;print(Fernet.generate_key().decode('utf-8'), end='')")" >> .env 10 | echo "FIEF_DOMAIN=${CODESPACE_NAME}-8000.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" >> .env 11 | hatch run python -m fief.cli migrate 12 | 13 | set +e 14 | hatch run translations.compile 15 | hatch run python -m fief.cli create-admin --user-email anne@bretagne.duchy --user-password herminetincture 16 | set -e 17 | -------------------------------------------------------------------------------- /fief/scheduler.py: -------------------------------------------------------------------------------- 1 | from apscheduler.schedulers.blocking import BlockingScheduler 2 | from apscheduler.triggers.cron import CronTrigger 3 | 4 | from fief import tasks 5 | 6 | 7 | def schedule(): 8 | scheduler = BlockingScheduler() 9 | scheduler.add_job( 10 | tasks.cleanup.send, 11 | CronTrigger.from_crontab("0 0 * * *"), 12 | ) 13 | scheduler.add_job( 14 | tasks.heartbeat.send, 15 | CronTrigger.from_crontab("0 0 * * *"), 16 | ) 17 | try: 18 | scheduler.start() 19 | except KeyboardInterrupt: 20 | scheduler.shutdown() 21 | -------------------------------------------------------------------------------- /fief/apps/dashboard/responses.py: -------------------------------------------------------------------------------- 1 | from fastapi.responses import RedirectResponse 2 | from starlette.background import BackgroundTask 3 | from starlette.datastructures import URL 4 | 5 | 6 | class HXRedirectResponse(RedirectResponse): 7 | def __init__( 8 | self, 9 | url: str | URL, 10 | status_code: int = 200, 11 | headers: dict[str, str] | None = None, 12 | background: BackgroundTask | None = None, 13 | ) -> None: 14 | super().__init__(url, status_code, headers, background) 15 | self.headers["HX-Redirect"] = self.headers["location"] 16 | -------------------------------------------------------------------------------- /fief/repositories/admin_session_token.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import AdminSessionToken 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class AdminSessionTokenRepository( 8 | BaseRepository[AdminSessionToken], UUIDRepositoryMixin[AdminSessionToken] 9 | ): 10 | model = AdminSessionToken 11 | 12 | async def get_by_token(self, token: str) -> AdminSessionToken | None: 13 | statement = select(AdminSessionToken).where(AdminSessionToken.token == token) 14 | return await self.get_one_or_none(statement) 15 | -------------------------------------------------------------------------------- /fief/schemas/user_permission.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | 3 | from fief.schemas.generics import BaseModel, CreatedUpdatedAt 4 | from fief.schemas.permission import PermissionEmbedded 5 | from fief.schemas.role import RoleEmbedded 6 | 7 | 8 | class UserPermissionCreate(BaseModel): 9 | id: UUID4 10 | 11 | 12 | class BaseUserPermission(CreatedUpdatedAt): 13 | user_id: UUID4 14 | permission_id: UUID4 15 | from_role_id: UUID4 | None = None 16 | 17 | 18 | class UserPermission(BaseUserPermission): 19 | permission: PermissionEmbedded 20 | from_role: RoleEmbedded | None 21 | -------------------------------------------------------------------------------- /fief/services/email_template/types.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class EmailTemplateType(StrEnum): 5 | BASE = "BASE" 6 | WELCOME = "WELCOME" 7 | VERIFY_EMAIL = "VERIFY_EMAIL" 8 | FORGOT_PASSWORD = "FORGOT_PASSWORD" 9 | 10 | def get_display_name(self) -> str: 11 | display_names = { 12 | EmailTemplateType.BASE: "Base", 13 | EmailTemplateType.WELCOME: "Welcome", 14 | EmailTemplateType.VERIFY_EMAIL: "Verify email", 15 | EmailTemplateType.FORGOT_PASSWORD: "Forgot password", 16 | } 17 | return display_names[self] 18 | -------------------------------------------------------------------------------- /fief/repositories/grant.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import select 3 | 4 | from fief.models import Grant 5 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 6 | 7 | 8 | class GrantRepository(BaseRepository[Grant], UUIDRepositoryMixin[Grant]): 9 | model = Grant 10 | 11 | async def get_by_user_and_client( 12 | self, user_id: UUID4, client_id: UUID4 13 | ) -> Grant | None: 14 | statement = select(Grant).where( 15 | Grant.user_id == user_id, Grant.client_id == client_id 16 | ) 17 | return await self.get_one_or_none(statement) 18 | -------------------------------------------------------------------------------- /fief/worker.py: -------------------------------------------------------------------------------- 1 | import sentry_dramatiq 2 | import sentry_sdk 3 | from sentry_sdk.integrations.redis import RedisIntegration 4 | 5 | from fief import __version__ 6 | from fief.logger import init_logger, logger 7 | from fief.settings import settings 8 | 9 | sentry_sdk.init( 10 | dsn=settings.sentry_dsn_worker, 11 | environment=settings.environment.value, 12 | release=__version__, 13 | integrations=[sentry_dramatiq.DramatiqIntegration(), RedisIntegration()], 14 | ) 15 | 16 | from fief import tasks # noqa: E402 17 | 18 | init_logger() 19 | logger.info("Fief Worker started", version=__version__) 20 | 21 | __all__ = ["tasks"] 22 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.12-slim 2 | 3 | ARG FIEF_VERSION 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | WORKDIR /app 7 | 8 | RUN apt-get update && apt-get install -y build-essential redis libpq-dev \ 9 | && pip install --upgrade pip supervisor \ 10 | && python --version \ 11 | && pip install fief-server==${FIEF_VERSION} \ 12 | && apt-get autoremove -y build-essential \ 13 | && mkdir -p /data/db 14 | 15 | COPY supervisord.conf /etc/supervisord.conf 16 | 17 | ENV DATABASE_LOCATION=/data/db 18 | ENV SECRETS_DIR /run/secrets 19 | 20 | ENV PORT=8000 21 | EXPOSE ${PORT} 22 | 23 | CMD ["supervisord", "-c", "/etc/supervisord.conf"] 24 | -------------------------------------------------------------------------------- /fief/repositories/email_template.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import EmailTemplate 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | from fief.services.email_template.types import EmailTemplateType 6 | 7 | 8 | class EmailTemplateRepository( 9 | BaseRepository[EmailTemplate], UUIDRepositoryMixin[EmailTemplate] 10 | ): 11 | model = EmailTemplate 12 | 13 | async def get_by_type(self, type: EmailTemplateType) -> EmailTemplate | None: 14 | statement = select(EmailTemplate).where(EmailTemplate.type == type.value) 15 | return await self.get_one_or_none(statement) 16 | -------------------------------------------------------------------------------- /fief/repositories/role.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import Role 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class RoleRepository(BaseRepository[Role], UUIDRepositoryMixin[Role]): 8 | model = Role 9 | 10 | async def get_granted_by_default(self) -> list[Role]: 11 | statement = select(Role).where(Role.granted_by_default == True) 12 | return await self.list(statement) 13 | 14 | async def get_by_name(self, name: str) -> Role | None: 15 | statement = select(Role).where(Role.name == name) 16 | return await self.get_one_or_none(statement) 17 | -------------------------------------------------------------------------------- /fief/models/permission.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from fief.models.base import Base 5 | from fief.models.generics import CreatedUpdatedAt, UUIDModel 6 | 7 | 8 | class Permission(UUIDModel, CreatedUpdatedAt, Base): 9 | __tablename__ = "permissions" 10 | 11 | name: Mapped[str] = mapped_column(String(length=255), nullable=False) 12 | codename: Mapped[str] = mapped_column( 13 | String(length=255), nullable=False, unique=True 14 | ) 15 | 16 | def __repr__(self) -> str: 17 | return f"Permission(id={self.id}, name={self.name}, codename={self.codename})" 18 | -------------------------------------------------------------------------------- /fief/repositories/webhook_log.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy import select 4 | 5 | from fief.models import WebhookLog 6 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 7 | 8 | 9 | class WebhookLogRepository(BaseRepository[WebhookLog], UUIDRepositoryMixin[WebhookLog]): 10 | model = WebhookLog 11 | 12 | async def get_by_id_and_webhook( 13 | self, id: uuid.UUID, webhook: uuid.UUID 14 | ) -> WebhookLog | None: 15 | statement = select(WebhookLog).where( 16 | WebhookLog.id == id, WebhookLog.webhook_id == webhook 17 | ) 18 | return await self.get_one_or_none(statement) 19 | -------------------------------------------------------------------------------- /fief/dependencies/db.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | 3 | from fastapi import HTTPException, Request, status 4 | 5 | from fief.db import AsyncSession 6 | from fief.errors import APIErrorCode 7 | 8 | 9 | async def get_main_async_session( 10 | request: Request, 11 | ) -> AsyncGenerator[AsyncSession, None]: 12 | try: 13 | async with request.state.main_async_session_maker() as session: 14 | yield session 15 | except ConnectionRefusedError as e: 16 | raise HTTPException( 17 | status_code=status.HTTP_503_SERVICE_UNAVAILABLE, 18 | detail=APIErrorCode.SERVER_DATABASE_NOT_AVAILABLE, 19 | ) from e 20 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/webhook.py: -------------------------------------------------------------------------------- 1 | from wtforms import URLField, validators 2 | 3 | from fief.forms import CSRFBaseForm, SelectMultipleFieldCheckbox 4 | from fief.services.webhooks.models import WEBHOOK_EVENTS 5 | 6 | 7 | class BaseWebhookForm(CSRFBaseForm): 8 | url = URLField( 9 | "URL", 10 | validators=[validators.InputRequired(), validators.URL(require_tld=False)], 11 | ) 12 | events = SelectMultipleFieldCheckbox( 13 | "Events to notify", choices=[event.key() for event in WEBHOOK_EVENTS] 14 | ) 15 | 16 | 17 | class WebhookCreateForm(BaseWebhookForm): 18 | pass 19 | 20 | 21 | class WebhookUpdateForm(BaseWebhookForm): 22 | pass 23 | -------------------------------------------------------------------------------- /fief/services/email/null.py: -------------------------------------------------------------------------------- 1 | from fief.services.email.base import EmailDomain, EmailProvider 2 | 3 | 4 | class Null(EmailProvider): 5 | DOMAIN_AUTHENTICATION = False 6 | 7 | def send_email( 8 | self, 9 | *, 10 | sender: tuple[str, str | None], 11 | recipient: tuple[str, str | None], 12 | subject: str, 13 | html: str | None = None, 14 | text: str | None = None, 15 | ): 16 | return 17 | 18 | def create_domain(self, domain: str) -> EmailDomain: 19 | raise NotImplementedError() 20 | 21 | def verify_domain(self, email_domain: EmailDomain) -> EmailDomain: 22 | raise NotImplementedError() 23 | -------------------------------------------------------------------------------- /fief/dependencies/repositories.py: -------------------------------------------------------------------------------- 1 | from typing import Generic 2 | 3 | from fastapi import Depends 4 | 5 | from fief.db import AsyncSession 6 | from fief.dependencies.db import get_main_async_session 7 | from fief.repositories import get_repository as _get_repository 8 | from fief.repositories.base import REPOSITORY 9 | 10 | 11 | class get_repository(Generic[REPOSITORY]): 12 | def __init__(self, repository_class: type[REPOSITORY]): 13 | self.repository_class = repository_class 14 | 15 | async def __call__( 16 | self, session: AsyncSession = Depends(get_main_async_session) 17 | ) -> REPOSITORY: 18 | return _get_repository(self.repository_class, session) 19 | -------------------------------------------------------------------------------- /fief/settings.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | from typing import TYPE_CHECKING 3 | 4 | if TYPE_CHECKING: 5 | from fief.settings_class import Settings 6 | 7 | 8 | def _get_settings() -> "Settings": 9 | try: 10 | stronghold_settings_module = importlib.import_module( 11 | "fief_stronghold.settings_class" 12 | ) 13 | SettingsClass = getattr(stronghold_settings_module, "Settings") 14 | except (ImportError, AttributeError): 15 | base_settings_module = importlib.import_module("fief.settings_class") 16 | SettingsClass = getattr(base_settings_module, "Settings") 17 | return SettingsClass() # pyright: ignore 18 | 19 | 20 | settings = _get_settings() 21 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/webhooks/list.html" %} 7 | 8 | {% block head_title_content %}{{ webhook.url }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the Webhook \"" ~ webhook.url ~ "\"?", 16 | "If you delete this webhook, you won't receive any event anymore on this URL.", 17 | url_for("dashboard.webhooks:delete", id=webhook.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:3.12 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV HATCH_CONFIG /workspaces/fief/.devcontainer/hatch.config.toml 5 | ENV FORWARDED_ALLOW_IPS * 6 | 7 | # [Optional] If your requirements rarely change, uncomment this section to add them to the image. 8 | # COPY requirements.txt /tmp/pip-tmp/ 9 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 10 | # && rm -rf /tmp/pip-tmp 11 | 12 | # [Optional] Uncomment this section to install additional OS packages. 13 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 14 | # && apt-get -y install --no-install-recommends 15 | -------------------------------------------------------------------------------- /fief/crypto/jwk.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | from typing import Literal 3 | 4 | from jwcrypto import jwk 5 | 6 | from fief.settings import settings 7 | 8 | 9 | class AuthException(Exception): 10 | pass 11 | 12 | 13 | class InvalidAccessToken(AuthException): 14 | pass 15 | 16 | 17 | def generate_jwk(kid: str, use: Literal["sig", "enc"]) -> jwk.JWK: 18 | return jwk.JWK.generate( 19 | kty="RSA", size=settings.generated_jwk_size, use=use, kid=kid 20 | ) 21 | 22 | 23 | def load_jwk(json: str) -> jwk.JWK: 24 | return jwk.JWK.from_json(json) 25 | 26 | 27 | def generate_signature_jwk_string() -> str: 28 | key = generate_jwk(secrets.token_urlsafe(), "sig") 29 | return key.export() 30 | -------------------------------------------------------------------------------- /fief/templates/admin/users/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/users/list.html" %} 7 | 8 | {% block head_title_content %}{{ user.email }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the User \"" ~ user.email ~ "\"?", 16 | "If you delete this user, they won't be able to access your application anymore. All their data will be deleted.", 17 | url_for("dashboard.users:delete", id=user.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/templates/admin/api_keys/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/api_keys/list.html" %} 7 | 8 | {% block head_title_content %}{{ api_key.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the API Key \"" ~ api_key.name ~ "\"?", 16 | "If you delete this key, every applications using this key won't be able to access the API anymore.", 17 | url_for("dashboard.api_keys:delete", id=api_key.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/clients/list.html" %} 7 | 8 | {% block head_title_content %}{{ client.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the Client \"" ~ client.name ~ "\"?", 16 | "If you delete this client, all the applications using this client won't be able to authenticate users anymore.", 17 | url_for("dashboard.clients:delete", id=client.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🪲 I found a bug 3 | about: I'm sure it's a bug and I want to report it. 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Describe the bug 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## To Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Configuration 27 | 28 | * Cloud or self-hosted: Cloud 29 | * If self-hosted, Fief version: 30 | 31 | ## Additional context 32 | 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/reset.py: -------------------------------------------------------------------------------- 1 | from wtforms import EmailField, HiddenField, PasswordField, validators, widgets 2 | 3 | from fief.forms import CSRFBaseForm, PasswordValidator 4 | from fief.locale import gettext_lazy as _ 5 | 6 | 7 | class ForgotPasswordForm(CSRFBaseForm): 8 | email = EmailField( 9 | _("Email address"), validators=[validators.InputRequired(), validators.Email()] 10 | ) 11 | 12 | 13 | class ResetPasswordForm(CSRFBaseForm): 14 | password = PasswordField( 15 | _("New password"), 16 | widget=widgets.PasswordInput(hide_value=False), 17 | validators=[validators.InputRequired(), PasswordValidator()], 18 | ) 19 | token = HiddenField(validators=[validators.InputRequired()]) 20 | -------------------------------------------------------------------------------- /fief/templates/admin/user_fields/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/user_fields/list.html" %} 7 | 8 | {% block head_title_content %}{{ user_field.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the User field \"" ~ user_field.name ~ "\"?", 16 | "If you delete this field, the associated values on every users will also be deleted.", 17 | url_for("dashboard.user_fields:delete", id=user_field.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/schemas/__init__.py: -------------------------------------------------------------------------------- 1 | from fief.schemas import ( 2 | auth, 3 | client, 4 | email_template, 5 | generics, 6 | oauth_account, 7 | oauth_provider, 8 | permission, 9 | role, 10 | tenant, 11 | user, 12 | user_field, 13 | user_permission, 14 | user_role, 15 | webhook, 16 | webhook_log, 17 | well_known, 18 | ) 19 | 20 | __all__ = [ 21 | "auth", 22 | "client", 23 | "email_template", 24 | "generics", 25 | "oauth_account", 26 | "oauth_provider", 27 | "permission", 28 | "role", 29 | "tenant", 30 | "user", 31 | "user_field", 32 | "user_permission", 33 | "user_role", 34 | "webhook", 35 | "webhook_log", 36 | "well_known", 37 | ] 38 | -------------------------------------------------------------------------------- /fief/schemas/role.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | 3 | from fief.schemas.generics import BaseModel, CreatedUpdatedAt, UUIDSchema 4 | from fief.schemas.permission import PermissionEmbedded 5 | 6 | 7 | class RoleCreate(BaseModel): 8 | name: str 9 | granted_by_default: bool 10 | permissions: list[UUID4] 11 | 12 | 13 | class RoleUpdate(BaseModel): 14 | name: str | None = None 15 | granted_by_default: bool | None = None 16 | permissions: list[UUID4] | None = None 17 | 18 | 19 | class BaseRole(UUIDSchema, CreatedUpdatedAt): 20 | name: str 21 | granted_by_default: bool 22 | 23 | 24 | class Role(BaseRole): 25 | permissions: list[PermissionEmbedded] 26 | 27 | 28 | class RoleEmbedded(BaseRole): 29 | pass 30 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/roles/list.html" %} 7 | 8 | {% block head_title_content %}{{ role.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the Role \"" ~ role.name ~ "\"?", 16 | "If you delete this role, it'll be removed from all the users on which it was assigned. Make sure you don't use it anymore in your application.", 17 | url_for("dashboard.roles:delete", id=role.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/db/migration.py: -------------------------------------------------------------------------------- 1 | from alembic import command 2 | from alembic.config import Config 3 | from sqlalchemy.engine import Connection 4 | 5 | from fief.db import AsyncEngine 6 | from fief.paths import ALEMBIC_CONFIG_FILE 7 | 8 | 9 | def _get_alembic_config(connection: Connection) -> Config: 10 | config = Config(ALEMBIC_CONFIG_FILE) 11 | config.attributes["connection"] = connection 12 | return config 13 | 14 | 15 | async def migrate_schema(engine: AsyncEngine) -> None: 16 | async with engine.begin() as connection: 17 | 18 | def _run_upgrade(connection): 19 | alembic_config = _get_alembic_config(connection) 20 | command.upgrade(alembic_config, "head") 21 | 22 | await connection.run_sync(_run_upgrade) 23 | -------------------------------------------------------------------------------- /fief/repositories/login_session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import LoginSession 4 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 5 | 6 | 7 | class LoginSessionRepository( 8 | BaseRepository[LoginSession], 9 | UUIDRepositoryMixin[LoginSession], 10 | ExpiresAtMixin[LoginSession], 11 | ): 12 | model = LoginSession 13 | 14 | async def get_by_token( 15 | self, token: str, *, fresh: bool = True 16 | ) -> LoginSession | None: 17 | statement = select(LoginSession).where(LoginSession.token == token) 18 | if fresh: 19 | statement = statement.where(LoginSession.is_expired.is_(False)) 20 | return await self.get_one_or_none(statement) 21 | -------------------------------------------------------------------------------- /fief/repositories/oauth_session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import OAuthSession 4 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 5 | 6 | 7 | class OAuthSessionRepository( 8 | BaseRepository[OAuthSession], 9 | UUIDRepositoryMixin[OAuthSession], 10 | ExpiresAtMixin[OAuthSession], 11 | ): 12 | model = OAuthSession 13 | 14 | async def get_by_token( 15 | self, token: str, *, fresh: bool = True 16 | ) -> OAuthSession | None: 17 | statement = select(OAuthSession).where(OAuthSession.token == token) 18 | if fresh: 19 | statement = statement.where(OAuthSession.is_expired.is_(False)) 20 | return await self.get_one_or_none(statement) 21 | -------------------------------------------------------------------------------- /fief/alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from alembic import op 9 | import sqlalchemy as sa 10 | ${imports if imports else ""} 11 | 12 | import fief 13 | 14 | # revision identifiers, used by Alembic. 15 | revision = ${repr(up_revision)} 16 | down_revision = ${repr(down_revision)} 17 | branch_labels = ${repr(branch_labels)} 18 | depends_on = ${repr(depends_on)} 19 | 20 | 21 | def upgrade(): 22 | table_prefix = op.get_context().opts["table_prefix"] 23 | ${upgrades if upgrades else "pass"} 24 | 25 | 26 | def downgrade(): 27 | table_prefix = op.get_context().opts["table_prefix"] 28 | ${downgrades if downgrades else "pass"} 29 | -------------------------------------------------------------------------------- /fief/repositories/refresh_token.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import RefreshToken 4 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 5 | 6 | 7 | class RefreshTokenRepository( 8 | BaseRepository[RefreshToken], 9 | UUIDRepositoryMixin[RefreshToken], 10 | ExpiresAtMixin[RefreshToken], 11 | ): 12 | model = RefreshToken 13 | 14 | async def get_by_token( 15 | self, token: str, *, fresh: bool = True 16 | ) -> RefreshToken | None: 17 | statement = select(RefreshToken).where(RefreshToken.token == token) 18 | if fresh: 19 | statement = statement.where(RefreshToken.is_expired.is_(False)) 20 | 21 | return await self.get_one_or_none(statement) 22 | -------------------------------------------------------------------------------- /fief/repositories/session_token.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import SessionToken 4 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 5 | 6 | 7 | class SessionTokenRepository( 8 | BaseRepository[SessionToken], 9 | UUIDRepositoryMixin[SessionToken], 10 | ExpiresAtMixin[SessionToken], 11 | ): 12 | model = SessionToken 13 | 14 | async def get_by_token( 15 | self, token: str, *, fresh: bool = True 16 | ) -> SessionToken | None: 17 | statement = select(SessionToken).where(SessionToken.token == token) 18 | if fresh: 19 | statement = statement.where(SessionToken.is_expired.is_(False)) 20 | 21 | return await self.get_one_or_none(statement) 22 | -------------------------------------------------------------------------------- /tests/test_tasks_register.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from fief.services.email import EmailProvider 6 | from fief.tasks.register import OnAfterRegisterTask 7 | from tests.data import TestData 8 | 9 | 10 | @pytest.mark.asyncio 11 | class TestTasksOnAfterRegister: 12 | async def test_send_welcome_email(self, main_session_manager, test_data: TestData): 13 | email_provider_mock = MagicMock(spec=EmailProvider) 14 | 15 | on_after_register = OnAfterRegisterTask( 16 | main_session_manager, email_provider_mock 17 | ) 18 | 19 | user = test_data["users"]["regular"] 20 | await on_after_register.run(str(user.id)) 21 | 22 | email_provider_mock.send_email.assert_called_once() 23 | -------------------------------------------------------------------------------- /.github/workflows/sentry.yml: -------------------------------------------------------------------------------- 1 | name: Sentry release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | sentry-release: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set raw version variable 14 | shell: bash 15 | run: | 16 | echo "${{ github.ref_name }}" | sed "s/v/RAW_VERSION=$1/" >> $GITHUB_ENV 17 | - name: Create Sentry release 18 | uses: getsentry/action-release@v1 19 | env: 20 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 21 | SENTRY_ORG: ${{ secrets.SENTRY_ORG }} 22 | with: 23 | projects: fief-server fief-worker 24 | environment: production 25 | version: ${{ env.RAW_VERSION }} 26 | -------------------------------------------------------------------------------- /fief/crypto/code_challenge.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import secrets 4 | 5 | 6 | def get_code_verifier_hash(verifier: str) -> str: 7 | hasher = hashlib.sha256() 8 | hasher.update(verifier.encode("utf-8")) 9 | digest = hasher.digest() 10 | b64_digest = base64.urlsafe_b64encode(digest).decode("utf-8") 11 | return b64_digest[:-1] # Remove the padding "=" at the end 12 | 13 | 14 | def verify_code_verifier(verifier: str, challenge: str, method: str) -> bool: 15 | if method == "plain": 16 | return secrets.compare_digest(verifier, challenge) 17 | elif method == "S256": 18 | verifier_hash = get_code_verifier_hash(verifier) 19 | return secrets.compare_digest(verifier_hash, challenge) 20 | return False # pragma: no cover 21 | -------------------------------------------------------------------------------- /fief/models/email_template.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import String, Text 2 | from sqlalchemy.orm import Mapped, mapped_column 3 | 4 | from fief.models.base import Base 5 | from fief.models.generics import CreatedUpdatedAt, UUIDModel 6 | from fief.services.email_template.types import EmailTemplateType 7 | 8 | 9 | class EmailTemplate(UUIDModel, CreatedUpdatedAt, Base): 10 | __tablename__ = "email_templates" 11 | 12 | type: Mapped[EmailTemplateType] = mapped_column( 13 | String(length=255), nullable=False, unique=True 14 | ) 15 | subject: Mapped[str] = mapped_column(Text, nullable=False) 16 | content: Mapped[str] = mapped_column(Text, nullable=False) 17 | 18 | def get_type_display_name(self) -> str: 19 | return EmailTemplateType[self.type].get_display_name() 20 | -------------------------------------------------------------------------------- /fief/crypto/password.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from pwdlib import PasswordHash 4 | from pwdlib.hashers.argon2 import Argon2Hasher 5 | from pwdlib.hashers.bcrypt import BcryptHasher 6 | 7 | 8 | class PasswordHelper: 9 | def __init__(self) -> None: 10 | self.password_hash = PasswordHash((BcryptHasher(), Argon2Hasher())) 11 | 12 | def verify_and_update( 13 | self, plain_password: str, hashed_password: str 14 | ) -> tuple[bool, str | None]: 15 | return self.password_hash.verify_and_update(plain_password, hashed_password) 16 | 17 | def hash(self, password: str) -> str: 18 | return self.password_hash.hash(password) 19 | 20 | def generate(self) -> str: 21 | return secrets.token_urlsafe() 22 | 23 | 24 | password_helper = PasswordHelper() 25 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro name_column(role) %} 4 | 11 | {{ role.name }} 12 | 13 | {% endmacro %} 14 | 15 | {% macro granted_by_default_column(role) %} 16 | {% if role.granted_by_default %} 17 | {{ icons.check('w-4 h-4') }} 18 | {% else %} 19 | {{ icons.x_mark('w-4 h-4') }} 20 | {% endif %} 21 | {% endmacro %} 22 | 23 | {{ 24 | datatable.datatable( 25 | roles, 26 | count, 27 | datatable_query_parameters, 28 | "Roles", 29 | columns | map("get_column_macro") | list, 30 | ) 31 | }} 32 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Manual Docker build 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | fief_version: 7 | description: "Fief version" 8 | required: true 9 | tags: 10 | description: "Tags (following Docker metadata syntax)" 11 | required: true 12 | 13 | jobs: 14 | docker-build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build Docker image 20 | uses: ./.github/actions/docker-build 21 | with: 22 | fief_version: ${{ inputs.fief_version }} 23 | tags: ${{ inputs.tags }} 24 | registry: ghcr.io 25 | image_name: ${{ github.repository }} 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /fief/dependencies/logger.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fief.dependencies.admin_api_key import get_optional_admin_api_key 4 | from fief.dependencies.admin_session import get_optional_admin_session_token 5 | from fief.logger import AuditLogger, logger 6 | from fief.models import AdminAPIKey, AdminSessionToken 7 | 8 | 9 | async def get_audit_logger( 10 | admin_session_token: AdminSessionToken | None = Depends( 11 | get_optional_admin_session_token 12 | ), 13 | admin_api_key: AdminAPIKey | None = Depends(get_optional_admin_api_key), 14 | ) -> AuditLogger: 15 | return AuditLogger( 16 | logger, 17 | admin_user_id=admin_session_token.user_id if admin_session_token else None, 18 | admin_api_key_id=admin_api_key.id if admin_api_key else None, 19 | ) 20 | -------------------------------------------------------------------------------- /fief/templates/admin/permissions/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/permissions/list.html" %} 7 | 8 | {% block head_title_content %}{{ permission.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the Permission \"" ~ permission.name ~ "\"?", 16 | "If you delete this permission, it'll be removed from all the roles and users on which it was assigned. Make sure you don't use it anymore in your application.", 17 | url_for("dashboard.permissions:delete", id=permission.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/forms.html' as forms %} 2 | 3 | {% extends "auth/layout.html" %} 4 | 5 | {% block head_title_content %}{{ _('Reset password') }}{% endblock %} 6 | 7 | {% block title %}{{ _('Reset password') }}{% endblock %} 8 | 9 | {% block auth_form %} 10 |
11 |
12 | {{ forms.form_field(form.password) }} 13 | {{ form.token }} 14 | {{ forms.form_csrf_token(form) }} 15 |
16 |
17 |
18 | 19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /fief/services/email_template/templates/verify_email.html: -------------------------------------------------------------------------------- 1 | {% extends "BASE" %} 2 | 3 | {% block preheader %}Use this code to verify your email address. This code is only valid for 1 hour.{% endblock %} 4 | 5 | {% block main %} 6 |

Verify your email address

7 |

You recently created or updated your email on your {{ tenant.name }}'s account. To verify your email address, please enter the verification code below.

8 | 9 | 10 | 14 | 15 | 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /tests/test_services_webhooks_trigger.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | from fief import schemas 4 | from fief.services.webhooks.models import ClientCreated, WebhookEvent 5 | from fief.services.webhooks.trigger import trigger_webhooks 6 | from tests.data import TestData 7 | 8 | 9 | def test_trigger_webhooks(test_data: TestData, send_task_mock: MagicMock): 10 | object = test_data["clients"]["default_tenant"] 11 | 12 | trigger_webhooks( 13 | ClientCreated, object, schemas.client.Client, send_task=send_task_mock 14 | ) 15 | 16 | send_task_mock.assert_called_once() 17 | 18 | event_json = send_task_mock.call_args[1]["event"] 19 | event = WebhookEvent.model_validate_json(event_json) 20 | assert event.type == ClientCreated.key() 21 | assert event.data["id"] == str(object.id) 22 | -------------------------------------------------------------------------------- /fief/crypto/token.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import secrets 4 | 5 | from fief.settings import settings 6 | 7 | 8 | def get_token_hash( 9 | token: str, *, secret: str = settings.secret.get_secret_value() 10 | ) -> str: 11 | hash = hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256) 12 | return hash.hexdigest() 13 | 14 | 15 | def generate_token( 16 | *, secret: str = settings.secret.get_secret_value() 17 | ) -> tuple[str, str]: 18 | """ 19 | Generate a token suitable for sensitive values 20 | like authorization codes or refresh tokens. 21 | 22 | Returns both the actual value and its HMAC-SHA256 hash. 23 | Only the latter shall be stored in database. 24 | """ 25 | token = secrets.token_urlsafe() 26 | return token, get_token_hash(token, secret=secret) 27 | -------------------------------------------------------------------------------- /fief/middlewares/cors.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from fastapi.middleware.cors import CORSMiddleware 4 | from starlette.types import Receive, Scope, Send 5 | 6 | 7 | class CORSMiddlewarePath(CORSMiddleware): 8 | def __init__(self, *args, path_regex: str, **kwargs) -> None: 9 | super().__init__(*args, **kwargs) 10 | self.path_regex = re.compile(path_regex) 11 | 12 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 13 | if scope["type"] != "http": # pragma: no cover 14 | await self.app(scope, receive, send) 15 | return 16 | 17 | path = scope["path"] 18 | if not self.path_regex.match(path): 19 | await self.app(scope, receive, send) 20 | return 21 | 22 | return await super().__call__(scope, receive, send) 23 | -------------------------------------------------------------------------------- /fief/models/webhook.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | 3 | from sqlalchemy import JSON, String 4 | from sqlalchemy.orm import Mapped, mapped_column 5 | 6 | from fief.models.base import Base 7 | from fief.models.generics import CreatedUpdatedAt, PydanticUrlString, UUIDModel 8 | 9 | 10 | class Webhook(UUIDModel, CreatedUpdatedAt, Base): 11 | __tablename__ = "webhooks" 12 | 13 | url: Mapped[str] = mapped_column( 14 | PydanticUrlString(String)(length=255), nullable=False 15 | ) 16 | secret: Mapped[str] = mapped_column( 17 | String(length=255), default=secrets.token_urlsafe, nullable=False 18 | ) 19 | events: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) 20 | 21 | def regenerate_secret(self) -> str: 22 | self.secret = secrets.token_urlsafe() 23 | return self.secret 24 | -------------------------------------------------------------------------------- /tests/test_app.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator 2 | 3 | import httpx 4 | import pytest 5 | import pytest_asyncio 6 | from fastapi import status 7 | 8 | from fief.app import app 9 | from tests.conftest import HTTPClientGeneratorType 10 | 11 | 12 | @pytest_asyncio.fixture 13 | async def test_client_app( 14 | test_client_generator: HTTPClientGeneratorType, 15 | ) -> AsyncGenerator[httpx.AsyncClient, None]: 16 | async with test_client_generator(app) as test_client: 17 | yield test_client 18 | 19 | 20 | @pytest.mark.asyncio 21 | async def test_admin_trailing_slash_redirection_(test_client_app: httpx.AsyncClient): 22 | response = await test_client_app.get("/admin") 23 | 24 | assert response.status_code == status.HTTP_308_PERMANENT_REDIRECT 25 | assert response.headers["location"] == "/admin/" 26 | -------------------------------------------------------------------------------- /fief/templates/admin/email_templates/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro type_column(email_template) %} 4 | {{ email_template.get_type_display_name() }} 5 | {% endmacro %} 6 | 7 | {% macro subject_column(email_template) %} 8 | {{ email_template.subject }} 9 | {% endmacro %} 10 | 11 | {% macro actions_column(email_template) %} 12 | 20 | {% endmacro %} 21 | 22 | {{ 23 | datatable.datatable( 24 | email_templates, 25 | count, 26 | datatable_query_parameters, 27 | "Email templates", 28 | columns | map("get_column_macro") | list, 29 | ) 30 | }} 31 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/password.py: -------------------------------------------------------------------------------- 1 | from wtforms import PasswordField, validators 2 | 3 | from fief.forms import CSRFBaseForm, PasswordValidator 4 | from fief.locale import gettext_lazy as _ 5 | 6 | 7 | class ChangePasswordForm(CSRFBaseForm): 8 | old_password = PasswordField( 9 | _("Old password"), 10 | validators=[validators.InputRequired()], 11 | render_kw={"autocomplete": "current-password"}, 12 | ) 13 | new_password = PasswordField( 14 | _("New password"), 15 | validators=[validators.InputRequired(), PasswordValidator()], 16 | render_kw={"autocomplete": "new-password"}, 17 | ) 18 | new_password_confirm = PasswordField( 19 | _("Confirm new password"), 20 | validators=[validators.InputRequired()], 21 | render_kw={"autocomplete": "new-password"}, 22 | ) 23 | -------------------------------------------------------------------------------- /fief/templates/admin/api_keys/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro name_column(api_key) %} 4 | {{ api_key.name }} 5 | {% endmacro %} 6 | 7 | {% macro created_at_column(api_key) %} 8 | {{ api_key.created_at.strftime('%x %X') }} 9 | {% endmacro %} 10 | 11 | {% macro actions_column(api_key) %} 12 | 23 | {% endmacro %} 24 | 25 | {{ 26 | datatable.datatable( 27 | api_keys, 28 | count, 29 | datatable_query_parameters, 30 | "API Keys", 31 | columns | map("get_column_macro") | list, 32 | ) 33 | }} 34 | -------------------------------------------------------------------------------- /fief/templates/admin/oauth_providers/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/oauth_providers/list.html" %} 7 | 8 | {% block head_title_content %}{{ oauth_provider.display_name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {{ 14 | modal.delete_modal( 15 | "Delete the OAuth Provider \"" ~ oauth_provider.display_name ~ "\"?", 16 | "If you delete this provider, the associated OAuth accounts will be deleted as well. The associated users won't be deleted, but they will have to sign in with another method.", 17 | url_for("dashboard.oauth_providers:delete", id=oauth_provider.id), 18 | ) 19 | }} 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /fief/schemas/webhook.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from pydantic import BaseModel, HttpUrl 4 | 5 | from fief.schemas.generics import CreatedUpdatedAt, UUIDSchema 6 | from fief.services.webhooks.models import WEBHOOK_EVENTS 7 | 8 | WebhookEventType = StrEnum( # type: ignore 9 | "WebhookEventType", 10 | [event.key() for event in WEBHOOK_EVENTS], 11 | ) 12 | 13 | 14 | class WebhookCreate(BaseModel): 15 | url: HttpUrl 16 | events: list[WebhookEventType] 17 | 18 | 19 | class WebhookUpdate(BaseModel): 20 | url: HttpUrl | None = None 21 | events: list[WebhookEventType] | None = None 22 | 23 | 24 | class BaseWebhook(UUIDSchema, CreatedUpdatedAt): 25 | url: HttpUrl 26 | events: list[WebhookEventType] 27 | 28 | 29 | class Webhook(BaseWebhook): 30 | pass 31 | 32 | 33 | class WebhookSecret(BaseWebhook): 34 | secret: str 35 | -------------------------------------------------------------------------------- /js/code-editor.mjs: -------------------------------------------------------------------------------- 1 | import { EditorView, basicSetup } from 'codemirror'; 2 | import { html } from '@codemirror/lang-html'; 3 | import { EditorState } from '@codemirror/state'; 4 | import { githubDark } from '@uiw/codemirror-theme-github'; 5 | 6 | const createCodeEditor = (element, initialContent, onChange) => { 7 | const listenerExtension = EditorView.updateListener.of((v) => { 8 | if (v.docChanged && typeof onChange === 'function') { 9 | const doc = v.state.doc; 10 | const value = doc.toString(); 11 | onChange(value); 12 | } 13 | }); 14 | const state = EditorState.create({ 15 | doc: initialContent, 16 | extensions: [basicSetup, githubDark, html(), listenerExtension], 17 | }); 18 | return new EditorView({ 19 | state, 20 | parent: element, 21 | }); 22 | }; 23 | 24 | window.fief = { 25 | createCodeEditor, 26 | }; 27 | -------------------------------------------------------------------------------- /fief/repositories/registration_session.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import RegistrationSession 4 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 5 | 6 | 7 | class RegistrationSessionRepository( 8 | BaseRepository[RegistrationSession], 9 | UUIDRepositoryMixin[RegistrationSession], 10 | ExpiresAtMixin[RegistrationSession], 11 | ): 12 | model = RegistrationSession 13 | 14 | async def get_by_token( 15 | self, token: str, *, fresh: bool = True 16 | ) -> RegistrationSession | None: 17 | statement = select(RegistrationSession).where( 18 | RegistrationSession.token == token 19 | ) 20 | if fresh: 21 | statement = statement.where(RegistrationSession.is_expired.is_(False)) 22 | return await self.get_one_or_none(statement) 23 | -------------------------------------------------------------------------------- /fief/templates/admin/permissions/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro name_column(permission) %} 4 | {{ permission.name }} 5 | {% endmacro %} 6 | 7 | {% macro codename_column(permission) %} 8 | {{ permission.codename }} 9 | {% endmacro %} 10 | 11 | {% macro actions_column(permission) %} 12 | 23 | {% endmacro %} 24 | 25 | {{ 26 | datatable.datatable( 27 | permissions, 28 | count, 29 | datatable_query_parameters, 30 | "Permissions", 31 | columns | map("get_column_macro") | list, 32 | ) 33 | }} 34 | -------------------------------------------------------------------------------- /fief/apps/dashboard/exception_handlers.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | 3 | from fastapi import Request 4 | from starlette.exceptions import HTTPException as StarletteHTTPException 5 | 6 | from fief.templates import templates 7 | 8 | exception_handlers: dict[type[Exception], Callable] = {} 9 | 10 | 11 | async def http_exception_handler(request: Request, exc: StarletteHTTPException): 12 | headers = getattr(exc, "headers", None) 13 | return templates.TemplateResponse( 14 | request, 15 | "admin/error.html", 16 | { 17 | "status_code": exc.status_code, 18 | "detail": exc.detail, 19 | }, 20 | status_code=exc.status_code, 21 | headers=headers, 22 | ) 23 | 24 | 25 | exception_handlers[StarletteHTTPException] = http_exception_handler 26 | 27 | 28 | __all__ = ["exception_handlers"] 29 | -------------------------------------------------------------------------------- /fief/templates/admin/oauth_providers/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | {% import "macros/datatable.html" as datatable %} 4 | 5 | {% macro provider_column(oauth_provider) %} 6 | 13 | {{ oauth_provider.get_provider_display_name() }} 14 | 15 | {% endmacro %} 16 | 17 | {% macro name_column(oauth_provider) %} 18 | {{ oauth_provider.name }} 19 | {% endmacro %} 20 | 21 | {{ 22 | datatable.datatable( 23 | oauth_providers, 24 | count, 25 | datatable_query_parameters, 26 | "OAuth Providers", 27 | columns | map("get_column_macro") | list, 28 | ) 29 | }} 30 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/logs/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Webhook logs · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Webhook logs

12 |
13 | 14 | 21 | 22 |
23 | {% include "admin/webhooks/logs/table.html" %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /fief/templates/admin/error.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block head_title_content %}Error {{ status_code }}{{ super() }}{% endblock %} 4 | 5 | {% block body %} 6 |
7 | 8 | 11 | 12 |
13 |
14 |
15 |

{{ status_code }}

16 |

{{ detail }}

17 |
18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /fief/apps/dashboard/forms/role.py: -------------------------------------------------------------------------------- 1 | from wtforms import BooleanField, StringField, validators 2 | 3 | from fief.forms import ComboboxSelectMultipleField, CSRFBaseForm 4 | 5 | 6 | class BaseRoleForm(CSRFBaseForm): 7 | name = StringField("Name", validators=[validators.InputRequired()]) 8 | granted_by_default = BooleanField( 9 | "Granted by default", 10 | description="When enabled, the role will automatically be assigned to users when they register.", 11 | ) 12 | permissions = ComboboxSelectMultipleField( 13 | "Permissions", 14 | query_endpoint_path="/admin/access-control/permissions/", 15 | label_attr="codename", 16 | validators=[validators.InputRequired()], 17 | choices=[], 18 | validate_choice=False, 19 | ) 20 | 21 | 22 | class RoleCreateForm(BaseRoleForm): 23 | pass 24 | 25 | 26 | class RoleUpdateForm(BaseRoleForm): 27 | pass 28 | -------------------------------------------------------------------------------- /fief/templates/admin/user_fields/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | {% import "macros/datatable.html" as datatable %} 4 | 5 | {% macro name_column(user_field) %} 6 | 13 | {{ user_field.name }} 14 | 15 | {% endmacro %} 16 | 17 | {% macro slug_column(user_field) %} 18 | {{ user_field.slug }} 19 | {% endmacro %} 20 | 21 | {% macro type_column(user_field) %} 22 | {{ user_field.get_type_display_name() }} 23 | {% endmacro %} 24 | 25 | {{ 26 | datatable.datatable( 27 | user_fields, 28 | count, 29 | datatable_query_parameters, 30 | "User fields", 31 | columns | map("get_column_macro") | list, 32 | ) 33 | }} 34 | -------------------------------------------------------------------------------- /tests/test_tasks_forgot_password.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from fief.services.email import EmailProvider 6 | from fief.tasks.forgot_password import OnAfterForgotPasswordTask 7 | from tests.data import TestData 8 | 9 | 10 | @pytest.mark.asyncio 11 | class TestTasksOnAfterForgotPassword: 12 | async def test_send_forgot_password_email( 13 | self, main_session_manager, test_data: TestData 14 | ): 15 | email_provider_mock = MagicMock(spec=EmailProvider) 16 | 17 | on_after_forgot_password = OnAfterForgotPasswordTask( 18 | main_session_manager, email_provider_mock 19 | ) 20 | 21 | user = test_data["users"]["regular"] 22 | await on_after_forgot_password.run( 23 | str(user.id), "https://bretagne.fief.dev/reset?token=AAA" 24 | ) 25 | 26 | email_provider_mock.send_email.assert_called_once() 27 | -------------------------------------------------------------------------------- /fief/models/session_token.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import ForeignKey, String 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | 5 | from fief.models.base import Base 6 | from fief.models.generics import GUID, CreatedUpdatedAt, ExpiresAt, UUIDModel 7 | from fief.models.user import User 8 | from fief.settings import settings 9 | 10 | 11 | class SessionToken(UUIDModel, CreatedUpdatedAt, ExpiresAt, Base): 12 | __tablename__ = "session_tokens" 13 | __lifetime_seconds__ = settings.session_lifetime_seconds 14 | 15 | token: Mapped[str] = mapped_column( 16 | String(length=255), 17 | nullable=False, 18 | index=True, 19 | unique=True, 20 | ) 21 | 22 | user_id: Mapped[UUID4] = mapped_column( 23 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 24 | ) 25 | user: Mapped[User] = relationship("User", lazy="joined") 26 | -------------------------------------------------------------------------------- /fief/templates/macros/buttons.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% macro clipboard(text) %} 4 | 18 | {% endmacro %} 19 | 20 | {% macro submit(class) %} 21 | 28 | {% endmacro %} 29 | -------------------------------------------------------------------------------- /fief/repositories/permission.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import select 3 | from sqlalchemy.sql import Select 4 | 5 | from fief.models import Permission, UserPermission 6 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 7 | 8 | 9 | class PermissionRepository(BaseRepository[Permission], UUIDRepositoryMixin[Permission]): 10 | model = Permission 11 | 12 | async def get_by_codename(self, codename: str) -> Permission | None: 13 | statement = select(Permission).where(Permission.codename == codename) 14 | return await self.get_one_or_none(statement) 15 | 16 | def get_user_permissions_statement(self, user: UUID4) -> Select: 17 | statement = ( 18 | select(Permission) 19 | .join(UserPermission, UserPermission.permission_id == Permission.id) 20 | .where(UserPermission.user_id == user) 21 | ) 22 | 23 | return statement 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fief", 3 | "private": true, 4 | "scripts": { 5 | "build": "rollup -c rollup.config.js" 6 | }, 7 | "dependencies": { 8 | "@babel/core": "^7.23.9", 9 | "@babel/plugin-transform-runtime": "^7.23.9", 10 | "@babel/runtime": "^7.23.9", 11 | "@codemirror/lang-html": "^6.4.8", 12 | "@rollup/plugin-babel": "^6.0.4", 13 | "@rollup/plugin-commonjs": "^28.0.0", 14 | "@rollup/plugin-node-resolve": "^15.2.3", 15 | "@rollup/plugin-terser": "^0.4.4", 16 | "@tailwindcss/forms": "^0.5.7", 17 | "@uiw/codemirror-theme-github": "^4.21.21", 18 | "autoprefixer": "^10.4.17", 19 | "codemirror": "^6.0.1", 20 | "htmx.org": "^2.0.3", 21 | "hyperscript.org": "^0.9.12", 22 | "postcss": "^8.4.33", 23 | "rollup": "^3.29.4", 24 | "rollup-plugin-postcss": "^4.0.2", 25 | "sass": "^1.70.0", 26 | "slugify": "^1.6.6", 27 | "tailwindcss": "^3.4.1" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /fief/templates/admin/themes/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Themes · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Themes

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/themes/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/dependencies/tenant_email_domain.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fief.dependencies.email_provider import get_email_provider 4 | from fief.dependencies.repositories import get_repository 5 | from fief.repositories import EmailDomainRepository, TenantRepository 6 | from fief.services.email import EmailProvider 7 | from fief.services.tenant_email_domain import TenantEmailDomain 8 | from fief.settings import settings 9 | 10 | 11 | async def get_tenant_email_domain( 12 | tenant_repository: TenantRepository = Depends(TenantRepository), 13 | email_domain_repository: EmailDomainRepository = Depends( 14 | get_repository(EmailDomainRepository) 15 | ), 16 | email_provider: EmailProvider = Depends(get_email_provider), 17 | ) -> TenantEmailDomain: 18 | return TenantEmailDomain( 19 | email_provider, 20 | settings.email_provider, 21 | tenant_repository, 22 | email_domain_repository, 23 | ) 24 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Clients · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Clients

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/clients/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Tenants · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Tenants

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/tenants/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.analysis.autoImportCompletions": true, 4 | "python.envFile": "${workspaceFolder}/.env.testing", 5 | "python.terminal.activateEnvironment": true, 6 | "python.terminal.activateEnvInCurrentTerminal": true, 7 | "python.testing.unittestEnabled": false, 8 | "python.testing.pytestEnabled": true, 9 | "editor.rulers": [88], 10 | "python.defaultInterpreterPath": "${workspaceFolder}/.hatch/fief-server/bin/python", 11 | "python.testing.pytestPath": "${workspaceFolder}/.hatch/fief-server/bin/pytest", 12 | "python.testing.cwd": "${workspaceFolder}", 13 | "python.testing.pytestArgs": ["-n 0", "--no-cov"], 14 | "[python]": { 15 | "editor.formatOnSave": true, 16 | "editor.codeActionsOnSave": { 17 | "source.fixAll": "explicit", 18 | "source.organizeImports": "explicit" 19 | }, 20 | "editor.defaultFormatter": "charliermarsh.ruff" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /fief/dependencies/request.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from fastapi import Request 5 | from fastapi.exceptions import RequestValidationError 6 | 7 | 8 | async def get_request_json(request: Request) -> dict[str, Any]: 9 | try: 10 | return await request.json() 11 | except json.JSONDecodeError as e: 12 | # Taken from FastAPI to mimic its builtin logic when encountering invalid JSON 13 | # Ref: https://github.com/tiangolo/fastapi/blob/f7e3559bd5997f831fb9b02bef9c767a50facbc3/fastapi/routing.py#L244-L256 14 | raise RequestValidationError( 15 | [ 16 | { 17 | "type": "json_invalid", 18 | "loc": ("body", e.pos), 19 | "msg": "JSON decode error", 20 | "input": {}, 21 | "ctx": {"error": e.msg}, 22 | } 23 | ], 24 | body=e.doc, 25 | ) from e 26 | -------------------------------------------------------------------------------- /fief/templates/admin/api_keys/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}API Keys · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

API Keys

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/api_keys/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}Webhooks · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

Webhooks

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/webhooks/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/services/webhooks/trigger.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol 2 | 3 | from fief.models import M 4 | from fief.schemas.generics import PM 5 | from fief.services.webhooks.models import WebhookEvent, WebhookEventType 6 | from fief.tasks import SendTask 7 | from fief.tasks import trigger_webhooks as trigger_webhooks_task 8 | 9 | 10 | def trigger_webhooks( 11 | event_type: type[WebhookEventType], 12 | object: M, 13 | schema_class: type[PM], 14 | *, 15 | send_task: SendTask, 16 | ) -> None: 17 | event: WebhookEvent = WebhookEvent( 18 | type=event_type.key(), 19 | data=schema_class.model_validate(object).model_dump(mode="json"), 20 | ) 21 | send_task( 22 | trigger_webhooks_task, 23 | event=event.model_dump_json(), 24 | ) 25 | 26 | 27 | class TriggerWebhooks(Protocol): 28 | def __call__( 29 | self, event_type: type[WebhookEventType], object: M, schema_class: type[PM] 30 | ) -> None: ... 31 | -------------------------------------------------------------------------------- /fief/templates/auth/forgot_password.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/forms.html' as forms %} 2 | 3 | {% extends "auth/layout.html" %} 4 | 5 | {% block head_title_content %}{{ _('Forgot password') }}{% endblock %} 6 | 7 | {% block title %}{{ _('Forgot password') }}{% endblock %} 8 | 9 | {% block auth_form %} 10 |
11 |
12 | {{ forms.form_field(form.email) }} 13 | {{ forms.form_csrf_token(form) }} 14 |
15 |
16 | 19 | 20 |
21 |
22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /fief/crypto/verify_code.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import secrets 4 | import string 5 | 6 | from fief.settings import settings 7 | 8 | 9 | def get_verify_code_hash( 10 | code: str, *, secret: str = settings.secret.get_secret_value() 11 | ) -> str: 12 | hash = hmac.new(secret.encode("utf-8"), code.encode("utf-8"), hashlib.sha256) 13 | return hash.hexdigest() 14 | 15 | 16 | def generate_verify_code( 17 | *, 18 | length: int = settings.email_verification_code_length, 19 | secret: str = settings.secret.get_secret_value(), 20 | ) -> tuple[str, str]: 21 | """ 22 | Generate a verify code for email verification. 23 | 24 | Returns both the actual value and its HMAC-SHA256 hash. 25 | Only the latter shall be stored in database. 26 | """ 27 | code = "".join( 28 | secrets.choice(string.ascii_uppercase + string.digits) for _ in range(length) 29 | ) 30 | return code, get_verify_code_hash(code, secret=secret) 31 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/delete.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/tenants/list.html" %} 7 | 8 | {% block head_title_content %}{{ tenant.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 | {% call modal.delete_modal_core( 14 | "Delete the Tenant \"" ~ tenant.name ~ "\"?", 15 | url_for("dashboard.tenants:delete", id=tenant.id), 16 | ) 17 | %} 18 |

If you delete this tenant, all the associated clients and users will be deleted as well.

19 | 23 | {% endcall %} 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /fief/templates/admin/user_fields/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}User fields · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

User fields

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/user_fields/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/templates/auth/dashboard/password.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/forms.html" as forms %} 4 | 5 | {% extends "auth/dashboard/layout.html" %} 6 | 7 | {% block head_title_content %}{{ _("Password") }}{% endblock %} 8 | 9 | {% block content %} 10 |

{{ _("Change password") }}

11 |
12 |
13 | {% if success %} 14 | {% call alerts.success() %} 15 | {{ success }} 16 | {% endcall %} 17 | {% endif %} 18 | {{ forms.form_field(form.old_password) }} 19 | {{ forms.form_field(form.new_password) }} 20 | {{ forms.form_field(form.new_password_confirm) }} 21 | {{ forms.form_csrf_token(form) }} 22 | {% call buttons.submit('btn') %} 23 | {{ _("Change password") }} 24 | {% endcall %} 25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fief/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from fief.tasks.audit_log import write_audit_log 2 | from fief.tasks.base import SendTask, send_task 3 | from fief.tasks.cleanup import cleanup 4 | from fief.tasks.email_verification import on_email_verification_requested 5 | from fief.tasks.forgot_password import on_after_forgot_password 6 | from fief.tasks.heartbeat import heartbeat 7 | from fief.tasks.register import on_after_register 8 | from fief.tasks.roles import on_role_updated 9 | from fief.tasks.user_roles import on_user_role_created, on_user_role_deleted 10 | from fief.tasks.webhooks import deliver_webhook, trigger_webhooks 11 | 12 | __all__ = [ 13 | "send_task", 14 | "SendTask", 15 | "cleanup", 16 | "heartbeat", 17 | "on_after_forgot_password", 18 | "on_after_register", 19 | "on_email_verification_requested", 20 | "on_role_updated", 21 | "on_user_role_created", 22 | "on_user_role_deleted", 23 | "deliver_webhook", 24 | "trigger_webhooks", 25 | "write_audit_log", 26 | ] 27 | -------------------------------------------------------------------------------- /fief/templates/admin/oauth_providers/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/icons.html" as icons %} 2 | 3 | {% extends layout %} 4 | 5 | {% block head_title_content %}OAuth Providers · {{ super() }}{% endblock %} 6 | 7 | {% block main %} 8 |
9 | 10 |
11 |

OAuth Providers

12 |
13 | 14 | 26 | 27 |
28 | {% include "admin/oauth_providers/table.html" %} 29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /fief/db/main.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from collections.abc import AsyncGenerator 3 | 4 | from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession 5 | 6 | from fief.db.engine import create_async_session_maker, create_engine 7 | from fief.settings import settings 8 | 9 | 10 | def create_main_engine() -> AsyncEngine: 11 | return create_engine(settings.get_database_connection_parameters()) 12 | 13 | 14 | def create_main_async_session_maker(engine: AsyncEngine): 15 | return create_async_session_maker(engine) 16 | 17 | 18 | @contextlib.asynccontextmanager 19 | async def get_single_main_async_session() -> AsyncGenerator[AsyncSession, None]: 20 | engine = create_main_engine() 21 | session_maker = create_async_session_maker(engine) 22 | async with session_maker() as session: 23 | yield session 24 | await engine.dispose() 25 | 26 | 27 | __all__ = [ 28 | "create_main_engine", 29 | "create_main_async_session_maker", 30 | "get_single_main_async_session", 31 | ] 32 | -------------------------------------------------------------------------------- /fief/tasks/heartbeat.py: -------------------------------------------------------------------------------- 1 | import dramatiq 2 | 3 | from fief.logger import logger 4 | from fief.services.posthog import get_server_id, get_server_properties, posthog 5 | from fief.settings import settings 6 | from fief.tasks.base import TaskBase 7 | 8 | 9 | class HeartbeatTask(TaskBase): 10 | __name__ = "heartbeat" 11 | 12 | async def run(self): 13 | if not settings.telemetry_enabled: 14 | logger.debug("Telemetry is disabled") 15 | return 16 | 17 | async with self.get_main_session() as session: 18 | server_id = get_server_id() 19 | server_properties = await get_server_properties(session) 20 | posthog.group_identify("server", server_id, properties=server_properties) 21 | logger.debug( 22 | "Heartbeat sent to Posthog", 23 | server_id=server_id, 24 | server_properties=server_properties, 25 | ) 26 | 27 | 28 | heartbeat = dramatiq.actor(HeartbeatTask()) 29 | -------------------------------------------------------------------------------- /fief/templates/auth/dashboard/email/change.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | 4 |
5 |
12 |
13 | {{ forms.form_field(form.email) }} 14 | {{ forms.form_field(form.current_password) }} 15 | {{ forms.form_csrf_token(form) }} 16 |
17 | {{ _("Cancel") }} 22 | {% call buttons.submit('btn') %} 23 | {{ _("Change email address") }} 24 | {% endcall %} 25 |
26 |
27 |
28 |
29 | -------------------------------------------------------------------------------- /tests/test_dependencies_login_hint.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fief.dependencies.login_hint import LoginHint, get_login_hint 4 | from tests.data import TestData 5 | 6 | 7 | @pytest.mark.parametrize( 8 | "input,output", 9 | [ 10 | (None, None), 11 | ("INVALID_VALUE", None), 12 | ("anne@bretagne.duchy", "anne@bretagne.duchy"), 13 | ("anne%40bretagne.duchy", "anne@bretagne.duchy"), 14 | ("b0133c88-04fa-4653-8dcc-4bf6c49d2d25", None), 15 | ], 16 | ) 17 | @pytest.mark.asyncio 18 | async def test_get_login_hint(input: str | None, output: LoginHint | None): 19 | assert await get_login_hint(input, []) == output 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_get_login_hint_oauth_provider(test_data: TestData): 24 | oauth_provider = test_data["oauth_providers"]["google"] 25 | assert ( 26 | await get_login_hint( 27 | str(oauth_provider.id), list(test_data["oauth_providers"].values()) 28 | ) 29 | == oauth_provider 30 | ) 31 | -------------------------------------------------------------------------------- /fief/repositories/authorization_code.py: -------------------------------------------------------------------------------- 1 | from datetime import UTC, datetime 2 | 3 | from sqlalchemy import select 4 | 5 | from fief.models import AuthorizationCode 6 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 7 | 8 | 9 | class AuthorizationCodeRepository( 10 | BaseRepository[AuthorizationCode], 11 | UUIDRepositoryMixin[AuthorizationCode], 12 | ExpiresAtMixin[AuthorizationCode], 13 | ): 14 | model = AuthorizationCode 15 | 16 | async def get_valid_by_code(self, code: str) -> AuthorizationCode | None: 17 | statement = select(AuthorizationCode).where( 18 | AuthorizationCode.code == code, 19 | AuthorizationCode.expires_at > datetime.now(UTC), 20 | ) 21 | return await self.get_one_or_none(statement) 22 | 23 | async def get_by_code(self, code: str) -> AuthorizationCode | None: 24 | statement = select(AuthorizationCode).where(AuthorizationCode.code == code) 25 | return await self.get_one_or_none(statement) 26 | -------------------------------------------------------------------------------- /tests/test_tasks_email_verification.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import MagicMock 2 | 3 | import pytest 4 | 5 | from fief.services.email import EmailProvider 6 | from fief.tasks.email_verification import OnEmailVerificationRequestedTask 7 | from tests.data import TestData, email_verification_codes 8 | 9 | 10 | @pytest.mark.asyncio 11 | class TestTasksOnEmailVerificationRequestedTask: 12 | async def test_send_verify_email(self, main_session_manager, test_data: TestData): 13 | email_provider_mock = MagicMock(spec=EmailProvider) 14 | 15 | on_email_verification_requested = OnEmailVerificationRequestedTask( 16 | main_session_manager, email_provider_mock 17 | ) 18 | 19 | email_verification = test_data["email_verifications"]["not_verified_email"] 20 | await on_email_verification_requested.run( 21 | str(email_verification.id), 22 | email_verification_codes["not_verified_email"][0], 23 | ) 24 | 25 | email_provider_mock.send_email.assert_called_once() 26 | -------------------------------------------------------------------------------- /fief/models/email_verification.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import ForeignKey 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy.sql.sqltypes import String 5 | 6 | from fief.models.base import Base 7 | from fief.models.generics import GUID, CreatedUpdatedAt, ExpiresAt, UUIDModel 8 | from fief.models.user import User 9 | from fief.settings import settings 10 | 11 | 12 | class EmailVerification(UUIDModel, CreatedUpdatedAt, ExpiresAt, Base): 13 | __tablename__ = "email_verifications" 14 | __lifetime_seconds__ = settings.email_verification_lifetime_seconds 15 | 16 | code: Mapped[str] = mapped_column( 17 | String(length=255), nullable=False, index=True, unique=True 18 | ) 19 | email: Mapped[str] = mapped_column(String(length=320), index=True, nullable=False) 20 | 21 | user_id: Mapped[UUID4] = mapped_column( 22 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 23 | ) 24 | user: Mapped[User] = relationship("User", lazy="joined") 25 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/icons.html" as icons %} 4 | 5 | {% extends layout %} 6 | 7 | {% block head_title_content %}Roles · {{ super() }}{% endblock %} 8 | 9 | {% block main %} 10 |
11 | 12 |
13 |

Roles

14 |
15 | 16 | 28 | 29 |
30 | 31 | {% include "admin/roles/table.html" %} 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /fief/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends layout %} 2 | 3 | {% block head_title_content %}Dashboard · {{ super() }}{% endblock %} 4 | 5 | {% block main %} 6 |
7 |
8 |

Welcome to Fief, my lord 👑

9 |

You're ready to authenticate your users! Here's some useful links to help you get started:

10 | 16 | Read the documentation 17 | 18 | 24 | Ask questions 25 | 26 |
27 |
28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /fief/services/user_role_permissions.py: -------------------------------------------------------------------------------- 1 | from fief.models import Role, User, UserPermission 2 | from fief.repositories import UserPermissionRepository 3 | 4 | 5 | class UserRolePermissionsService: 6 | def __init__(self, user_permission_repository: UserPermissionRepository) -> None: 7 | self.user_permission_repository = user_permission_repository 8 | 9 | async def add_role_permissions(self, user: User, role: Role) -> None: 10 | user_permissions: list[UserPermission] = [] 11 | for permission in role.permissions: 12 | user_permissions.append( 13 | UserPermission( 14 | user_id=user.id, 15 | permission_id=permission.id, 16 | from_role_id=role.id, 17 | ) 18 | ) 19 | await self.user_permission_repository.create_many(user_permissions) 20 | 21 | async def delete_role_permissions(self, user: User, role: Role) -> None: 22 | await self.user_permission_repository.delete_by_user_and_role(user.id, role.id) 23 | -------------------------------------------------------------------------------- /fief/models/grant.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import JSON, ForeignKey 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy.sql.schema import UniqueConstraint 5 | 6 | from fief.models.base import Base 7 | from fief.models.client import Client 8 | from fief.models.generics import GUID, CreatedUpdatedAt, UUIDModel 9 | from fief.models.user import User 10 | 11 | 12 | class Grant(UUIDModel, CreatedUpdatedAt, Base): 13 | __tablename__ = "grants" 14 | __table_args__ = (UniqueConstraint("user_id", "client_id"),) 15 | 16 | scope: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) 17 | 18 | user_id: Mapped[UUID4] = mapped_column( 19 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 20 | ) 21 | user: Mapped[User] = relationship("User") 22 | 23 | client_id: Mapped[UUID4] = mapped_column( 24 | GUID, ForeignKey(Client.id, ondelete="CASCADE"), nullable=False 25 | ) 26 | client: Mapped[Client] = relationship("Client", lazy="joined") 27 | -------------------------------------------------------------------------------- /tests/types.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from collections.abc import Callable 3 | from contextlib import AbstractAsyncContextManager 4 | 5 | import httpx 6 | from fastapi import FastAPI 7 | 8 | from fief.db.types import DatabaseConnectionParameters, DatabaseType 9 | from fief.models import ( 10 | Client, 11 | LoginSession, 12 | RegistrationSession, 13 | SessionToken, 14 | Tenant, 15 | User, 16 | ) 17 | 18 | 19 | @dataclasses.dataclass 20 | class TenantParams: 21 | path_prefix: str 22 | tenant: Tenant 23 | client: Client 24 | user: User 25 | login_session: LoginSession 26 | registration_session_password: RegistrationSession 27 | registration_session_oauth: RegistrationSession 28 | session_token: SessionToken 29 | session_token_token: tuple[str, str] 30 | 31 | 32 | HTTPClientGeneratorType = Callable[ 33 | [FastAPI], AbstractAsyncContextManager[httpx.AsyncClient] 34 | ] 35 | 36 | GetTestDatabase = Callable[ 37 | ..., AbstractAsyncContextManager[tuple[DatabaseConnectionParameters, DatabaseType]] 38 | ] 39 | -------------------------------------------------------------------------------- /fief/models/user_role.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import ForeignKey 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy.sql.schema import UniqueConstraint 5 | 6 | from fief.models.base import Base 7 | from fief.models.generics import GUID, CreatedUpdatedAt, UUIDModel 8 | from fief.models.role import Role 9 | from fief.models.user import User 10 | 11 | 12 | class UserRole(UUIDModel, CreatedUpdatedAt, Base): 13 | __tablename__ = "user_roles" 14 | __table_args__ = (UniqueConstraint("user_id", "role_id"),) 15 | 16 | user_id: Mapped[UUID4] = mapped_column( 17 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 18 | ) 19 | role_id: Mapped[UUID4] = mapped_column( 20 | GUID, ForeignKey(Role.id, ondelete="CASCADE"), nullable=False 21 | ) 22 | 23 | user: Mapped[User] = relationship("User") 24 | role: Mapped[Role] = relationship("Role") 25 | 26 | def __repr__(self) -> str: 27 | return f"UserRole(id={self.id}, user_id={self.user_id}, role_id={self.role_id})" 28 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/encryption_key.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | 5 | {% extends layout %} 6 | 7 | {% block head_title_content %}{{ client.name }} · {{ super() }}{% endblock %} 8 | 9 | {% set open_modal = true %} 10 | 11 | {% block modal %} 12 | {% call modal.body() %} 13 | {% call alerts.warning() %} 14 | The key below will be shown only once. Make sure to copy it and store it somewhere safe. 15 | {% endcall %} 16 |
17 | {{ buttons.clipboard(key | tojson | trim | forceescape) }} 18 |
19 |
{{ key | tojson(indent=4) | trim }}
20 | {% endcall %} 21 | {% call modal.footer() %} 22 | 29 | {% endcall %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro name_column(client) %} 4 | 11 | {{ client.name }} 12 | 13 | {% if client.first_party %} 14 |
15 | First-party 16 |
17 | {% endif %} 18 | {% endmacro %} 19 | 20 | {% macro type_column(client) %} 21 | {{ client.client_type.get_display_name() }} 22 | {% endmacro %} 23 | 24 | {% macro tenant_column(client) %} 25 | {{ client.tenant.name }} 26 | {% endmacro %} 27 | 28 | {% macro client_id_column(client) %} 29 | {{ client.client_id }} 30 | {% endmacro %} 31 | 32 | {{ 33 | datatable.datatable( 34 | clients, 35 | count, 36 | datatable_query_parameters, 37 | "Clients", 38 | columns | map("get_column_macro") | list, 39 | ) 40 | }} 41 | -------------------------------------------------------------------------------- /fief/models/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy import MetaData 4 | from sqlalchemy.orm import DeclarativeBase 5 | 6 | from fief.settings import settings 7 | 8 | TABLE_PREFIX_PLACEHOLDER = "__FIEF__" 9 | GENERATE_MIGRATION = os.environ.get("GENERATE_MIGRATION") == "1" 10 | TABLE_PREFIX = ( 11 | TABLE_PREFIX_PLACEHOLDER if GENERATE_MIGRATION else settings.database_table_prefix 12 | ) 13 | 14 | 15 | def get_prefixed_tablename(name: str) -> str: 16 | return f"{TABLE_PREFIX}{name}" 17 | 18 | 19 | class Base(DeclarativeBase): 20 | metadata = MetaData( 21 | naming_convention={ 22 | "ix": "ix_%(column_0_label)s", 23 | "uq": "%(table_name)s_%(column_0_N_name)s_key", 24 | "ck": "ck_%(table_name)s_`%(constraint_name)s`", 25 | "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", 26 | "pk": "pk_%(table_name)s", 27 | } 28 | ) 29 | 30 | def __init_subclass__(cls) -> None: 31 | cls.__tablename__ = get_prefixed_tablename(cls.__tablename__) 32 | super().__init_subclass__() 33 | -------------------------------------------------------------------------------- /fief/templates/auth/dashboard/layout.html: -------------------------------------------------------------------------------- 1 | {% import "macros/branding.html" as branding %} 2 | 3 | {% extends "auth/base.html" %} 4 | 5 | {% block body %} 6 |
7 |
8 | {% if tenant.logo_url %} 9 | {{ tenant.name }} 10 | {% else %} 11 | {{ tenant.name }} 12 | {% endif %} 13 |
14 | 15 |
16 |
17 |
18 | {% include "auth/dashboard/sidebar.html" %} 19 |
20 |
21 | {% block content %}{% endblock %} 22 |
23 |
24 |
25 |
26 |
27 | {% if show_branding %} 28 | {{ branding.branding() }} 29 | {% endif %} 30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /fief/templates/admin/user_fields/get.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% extends "admin/user_fields/list.html" %} 5 | 6 | {% block head_title_content %}{{ user_field.name }} · {{ super() }}{% endblock %} 7 | 8 | {% set open_aside = true %} 9 | 10 | {% block aside %} 11 |

{{ user_field.name }}

12 |
13 |
14 | 25 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /styles/additional-styles/toggle-switch.scss: -------------------------------------------------------------------------------- 1 | // Switch element 2 | .form-switch { 3 | @apply relative select-none; 4 | width: 44px; 5 | 6 | label { 7 | @apply block overflow-hidden cursor-pointer h-6 rounded-full; 8 | 9 | > span:first-child { 10 | @apply absolute block rounded-full; 11 | width: 20px; 12 | height: 20px; 13 | top: 2px; 14 | left: 2px; 15 | right: 50%; 16 | transition: all .15s ease-out; 17 | } 18 | } 19 | 20 | input[type="checkbox"] { 21 | 22 | &:checked { 23 | 24 | + label { 25 | @apply bg-primary; 26 | 27 | > span:first-child { 28 | left: 22px; 29 | } 30 | } 31 | } 32 | 33 | &:disabled { 34 | 35 | + label { 36 | @apply cursor-not-allowed bg-gray-100 border border-gray-200; 37 | 38 | > span:first-child { 39 | @apply bg-gray-400; 40 | } 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /fief/repositories/tenant.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | from slugify import slugify 5 | from sqlalchemy import select 6 | 7 | from fief.models import Tenant 8 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 9 | 10 | 11 | class TenantRepository(BaseRepository[Tenant], UUIDRepositoryMixin[Tenant]): 12 | model = Tenant 13 | 14 | async def get_default(self) -> Tenant | None: 15 | statement = select(Tenant).where(Tenant.default == True) 16 | return await self.get_one_or_none(statement) 17 | 18 | async def get_by_slug(self, slug: str) -> Tenant | None: 19 | statement = select(Tenant).where(Tenant.slug == slug) 20 | return await self.get_one_or_none(statement) 21 | 22 | async def get_available_slug(self, name: str) -> str: 23 | slug = slugify(name) 24 | tenant = await self.get_by_slug(slug) 25 | 26 | if tenant is None: 27 | return slug 28 | 29 | random_string = "".join( 30 | random.choices(string.ascii_lowercase + string.digits, k=6) 31 | ) 32 | return f"{slug}-{random_string}" 33 | -------------------------------------------------------------------------------- /fief/templates/auth/consent.html: -------------------------------------------------------------------------------- 1 | {% import 'macros/forms.html' as forms %} 2 | 3 | {% extends "auth/layout.html" %} 4 | 5 | {% block head_title_content %}{{ _('Consent') }}{% endblock %} 6 | 7 | {% block title %}{{ _('%(name)s wants to access your account', name=client.name) }}{% endblock %} 8 | 9 | {% block auth_form %} 10 |
11 |
12 |
{{ _('This will allow %(name)s to:', name=client.name) }}
13 | {% for scope in scopes %} 14 |
15 |
16 | {{ scope }} 17 |
18 |
19 | {% endfor %} 20 |
21 |
22 | {{ form.deny(class="btn border-slate-200 hover:border-slate-300 text-slate-600") }} 23 | {{ form.allow(class="btn bg-primary hover:bg-primary-hover text-white") }} 24 | {{ forms.form_csrf_token(form) }} 25 |
26 |
27 | {% endblock %} 28 | -------------------------------------------------------------------------------- /fief/tasks/cleanup.py: -------------------------------------------------------------------------------- 1 | import dramatiq 2 | 3 | from fief.repositories import ( 4 | AuthorizationCodeRepository, 5 | EmailVerificationRepository, 6 | LoginSessionRepository, 7 | OAuthSessionRepository, 8 | RefreshTokenRepository, 9 | RegistrationSessionRepository, 10 | SessionTokenRepository, 11 | ) 12 | from fief.repositories.base import ExpiresAtRepositoryProtocol 13 | from fief.tasks.base import TaskBase 14 | 15 | repository_classes: list[type[ExpiresAtRepositoryProtocol]] = [ 16 | AuthorizationCodeRepository, 17 | EmailVerificationRepository, 18 | LoginSessionRepository, 19 | OAuthSessionRepository, 20 | RefreshTokenRepository, 21 | RegistrationSessionRepository, 22 | SessionTokenRepository, 23 | ] 24 | 25 | 26 | class CleanupTask(TaskBase): 27 | __name__ = "cleanup" 28 | 29 | async def run(self): 30 | async with self.get_main_session() as session: 31 | for repository_class in repository_classes: 32 | repository = repository_class(session) 33 | await repository.delete_expired() 34 | 35 | 36 | cleanup = dramatiq.actor(CleanupTask()) 37 | -------------------------------------------------------------------------------- /fief/services/email_template/templates/forgot_password.html: -------------------------------------------------------------------------------- 1 | {% extends "BASE" %} 2 | 3 | {% block preheader %}Use this link to reset your password. This link is only valid for 1 hour.{% endblock %} 4 | 5 | {% block main %} 6 |

Reset your password

7 |

You recently requested to reset your password for your {{ tenant.name }} account. Use the button below to reset it. This password reset link is only valid for the next hour.

8 | 9 | 10 | 21 | 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | 3 | {% macro url_column(webhook) %} 4 | 11 | {{ webhook.url }} 12 | 13 | {% endmacro %} 14 | 15 | {% macro created_at_column(webhook) %} 16 | {{ webhook.created_at.strftime('%x %X') }} 17 | {% endmacro %} 18 | 19 | {% macro events_column(webhook) %} 20 | {{ webhook.events | join(', ') }} 21 | {% endmacro %} 22 | 23 | {% macro actions_column(webhook) %} 24 | 34 | {% endmacro %} 35 | 36 | {{ 37 | datatable.datatable( 38 | webhooks, 39 | count, 40 | datatable_query_parameters, 41 | "Webhooks", 42 | columns | map("get_column_macro") | list, 43 | ) 44 | }} 45 | -------------------------------------------------------------------------------- /fief/repositories/user_role.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import select 3 | from sqlalchemy.orm import joinedload 4 | from sqlalchemy.sql import Select 5 | 6 | from fief.models import UserRole 7 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 8 | 9 | 10 | class UserRoleRepository(BaseRepository[UserRole], UUIDRepositoryMixin[UserRole]): 11 | model = UserRole 12 | 13 | def get_by_user_statement(self, user: UUID4) -> Select: 14 | statement = ( 15 | select(UserRole) 16 | .where(UserRole.user_id == user) 17 | .options(joinedload(UserRole.role)) 18 | ) 19 | 20 | return statement 21 | 22 | async def get_by_role_and_user(self, user: UUID4, role: UUID4) -> UserRole | None: 23 | return await self.get_one_or_none( 24 | select(UserRole) 25 | .where(UserRole.user_id == user, UserRole.role_id == role) 26 | .options(joinedload(UserRole.role)) 27 | ) 28 | 29 | async def get_by_role(self, role: UUID4) -> list[UserRole]: 30 | return await self.list(select(UserRole).where(UserRole.role_id == role)) 31 | -------------------------------------------------------------------------------- /fief/repositories/user_field.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import select 2 | 3 | from fief.models import UserField 4 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 5 | 6 | 7 | class UserFieldRepository(BaseRepository[UserField], UUIDRepositoryMixin[UserField]): 8 | model = UserField 9 | 10 | async def get_by_slug(self, slug: str) -> UserField | None: 11 | statement = select(UserField).where(UserField.slug == slug) 12 | return await self.get_one_or_none(statement) 13 | 14 | async def get_registration_fields(self) -> list[UserField]: 15 | statement = select(UserField) 16 | user_fields = await self.list(statement) 17 | return [ 18 | user_field 19 | for user_field in user_fields 20 | if user_field.configuration["at_registration"] 21 | ] 22 | 23 | async def get_update_fields(self) -> list[UserField]: 24 | statement = select(UserField) 25 | user_fields = await self.list(statement) 26 | return [ 27 | user_field 28 | for user_field in user_fields 29 | if user_field.configuration["at_update"] 30 | ] 31 | -------------------------------------------------------------------------------- /fief/middlewares/locale.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Mapping 2 | from typing import Any 3 | 4 | import asgi_babel 5 | import wtforms.i18n 6 | from asgi_tools import Request 7 | from babel import Locale, UnknownLocaleError, support 8 | 9 | from fief.paths import LOCALE_DIRECTORY 10 | from fief.settings import settings 11 | 12 | Translations = support.Translations 13 | 14 | 15 | async def select_locale(request: Request) -> str | None: 16 | user_locale_cookie = request.cookies.get(settings.user_locale_cookie_name) 17 | if user_locale_cookie is not None: 18 | try: 19 | Locale.parse(user_locale_cookie, sep="-") 20 | except (ValueError, UnknownLocaleError): 21 | pass 22 | else: 23 | return user_locale_cookie 24 | return await asgi_babel.select_locale_by_request(request) 25 | 26 | 27 | class BabelMiddleware(asgi_babel.BabelMiddleware): 28 | def __post_init__(self): 29 | self.locale_selector = select_locale 30 | return super().__post_init__() 31 | 32 | 33 | def get_babel_middleware_kwargs() -> Mapping[str, Any]: 34 | return dict(locales_dirs=[LOCALE_DIRECTORY, wtforms.i18n.messages_path()]) 35 | -------------------------------------------------------------------------------- /tests/test_apps_security_headers.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from fastapi import status 4 | 5 | from tests.conftest import TenantParams 6 | from tests.helpers import security_headers_assertions 7 | 8 | 9 | @pytest.mark.asyncio 10 | async def test_admin_app(test_client_api: httpx.AsyncClient): 11 | response = await test_client_api.get("/openapi.json") 12 | 13 | assert response.status_code == status.HTTP_200_OK 14 | security_headers_assertions(response) 15 | 16 | 17 | @pytest.mark.asyncio 18 | @pytest.mark.authenticated_admin(mode="session") 19 | async def test_dashboard_app(test_client_dashboard: httpx.AsyncClient): 20 | response = await test_client_dashboard.get("/") 21 | 22 | assert response.status_code == status.HTTP_200_OK 23 | security_headers_assertions(response) 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_auth_app( 28 | tenant_params: TenantParams, test_client_auth: httpx.AsyncClient 29 | ): 30 | response = await test_client_auth.get( 31 | f"{tenant_params.path_prefix}/.well-known/jwks.json" 32 | ) 33 | 34 | assert response.status_code == status.HTTP_200_OK 35 | security_headers_assertions(response) 36 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/postgres 3 | { 4 | "name": "Fief Development", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | "features": { 9 | "ghcr.io/devcontainers/features/node:1": {}, 10 | "ghcr.io/devcontainers-contrib/features/hatch:2": {} 11 | }, 12 | 13 | // Features to add to the dev container. More info: https://containers.dev/features. 14 | // "features": {}, 15 | 16 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 17 | // This can be used to network with other containers or the host. 18 | "forwardPorts": [5432, 6379], 19 | 20 | // Use 'postCreateCommand' to run commands after the container is created. 21 | "postCreateCommand": ".devcontainer/post-create.sh" 22 | 23 | // Configure tool-specific properties. 24 | // "customizations": {}, 25 | 26 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 27 | // "remoteUser": "root" 28 | } 29 | -------------------------------------------------------------------------------- /fief/services/email/__init__.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | from fief.services.email.base import ( 4 | CreateDomainError, 5 | EmailDomain, 6 | EmailDomainDNSRecord, 7 | EmailError, 8 | EmailProvider, 9 | SendEmailError, 10 | VerifyDomainError, 11 | ) 12 | from fief.services.email.null import Null 13 | from fief.services.email.postmark import Postmark 14 | from fief.services.email.sendgrid import Sendgrid 15 | from fief.services.email.smtp import SMTP 16 | 17 | 18 | class AvailableEmailProvider(StrEnum): 19 | NULL = "NULL" 20 | POSTMARK = "POSTMARK" 21 | SMTP = "SMTP" 22 | SENDGRID = "SENDGRID" 23 | 24 | 25 | EMAIL_PROVIDERS: dict[AvailableEmailProvider, type[EmailProvider]] = { 26 | AvailableEmailProvider.NULL: Null, 27 | AvailableEmailProvider.POSTMARK: Postmark, 28 | AvailableEmailProvider.SMTP: SMTP, 29 | AvailableEmailProvider.SENDGRID: Sendgrid, 30 | } 31 | 32 | __all__ = [ 33 | "AvailableEmailProvider", 34 | "EMAIL_PROVIDERS", 35 | "EmailError", 36 | "EmailProvider", 37 | "EmailDomain", 38 | "EmailDomainDNSRecord", 39 | "SendEmailError", 40 | "CreateDomainError", 41 | "VerifyDomainError", 42 | ] 43 | -------------------------------------------------------------------------------- /fief/templates/admin/themes/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/themes/list.html" %} 7 | 8 | {% block head_title_content %}Create Theme · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Create Theme{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_csrf_token(form) }} 27 |
28 | {% endcall %} 29 | {% call modal.footer() %} 30 | 31 | {% call buttons.submit('btn-sm') %} 32 | Create 33 | {% endcall %} 34 | {% endcall %} 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /fief/static/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /fief/models/admin_session_token.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import json 3 | import uuid 4 | 5 | from fief_client import FiefTokenResponse, FiefUserInfo 6 | from pydantic import UUID4 7 | from sqlalchemy import String, Text 8 | from sqlalchemy.orm import Mapped, mapped_column 9 | 10 | from fief.models.base import Base 11 | from fief.models.generics import CreatedUpdatedAt, UUIDModel 12 | 13 | 14 | class AdminSessionToken(UUIDModel, CreatedUpdatedAt, Base): 15 | __tablename__ = "admin_session_tokens" 16 | 17 | token: Mapped[str] = mapped_column(String(length=255), unique=True, nullable=False) 18 | raw_tokens: Mapped[str] = mapped_column(Text, nullable=False) 19 | raw_userinfo: Mapped[str] = mapped_column(Text, nullable=False) 20 | 21 | def __repr__(self) -> str: 22 | return f"AdminSessionToken(id={self.id})" 23 | 24 | @functools.cached_property 25 | def tokens(self) -> FiefTokenResponse: 26 | return json.loads(self.raw_tokens) 27 | 28 | @functools.cached_property 29 | def userinfo(self) -> FiefUserInfo: 30 | return json.loads(self.raw_userinfo) 31 | 32 | @functools.cached_property 33 | def user_id(self) -> UUID4: 34 | return uuid.UUID(self.userinfo["sub"]) 35 | -------------------------------------------------------------------------------- /fief/templates/admin/api_keys/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/api_keys/list.html" %} 7 | 8 | {% block head_title_content %}Create API Key · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Create API Key{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_csrf_token(form) }} 27 |
28 | {% endcall %} 29 | {% call modal.footer() %} 30 | 31 | {% call buttons.submit('btn-sm') %} 32 | Create 33 | {% endcall %} 34 | {% endcall %} 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/get/lifetimes.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/forms.html" as forms %} 3 | 4 | {% extends "admin/clients/get/base.html" %} 5 | 6 | {% block tab %} 7 |
8 | {% call alerts.warning() %} 9 |

This is an advanced feature allowing you to customize the lifetime of the tokens generated after a successful user authentication. The default values should be enough for most applications.

10 |

If you're not sure of what you're doing, please ask for help.

11 | {% endcall %} 12 |
13 |
19 |
20 | {{ forms.form_field(form.authorization_code_lifetime_seconds) }} 21 | {{ forms.form_field(form.access_id_token_lifetime_seconds) }} 22 | {{ forms.form_field(form.refresh_token_lifetime_seconds) }} 23 | {{ forms.form_csrf_token(form) }} 24 | 30 |
31 |
32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /fief/templates/admin/themes/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/datatable.html" as datatable %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% macro name_column(theme) %} 5 | {{ theme.name }} 6 | {% endmacro %} 7 | 8 | {% macro default_column(theme) %} 9 | {% if theme.default %} 10 | {{ icons.check('w-4 h-4') }} 11 | {% else %} 12 | {{ icons.x_mark('w-4 h-4') }} 13 | {% endif %} 14 | {% endmacro %} 15 | 16 | {% macro actions_column(theme) %} 17 |
18 | 22 | Update 23 | 24 | 31 |
32 | {% endmacro %} 33 | 34 | {{ 35 | datatable.datatable( 36 | themes, 37 | count, 38 | datatable_query_parameters, 39 | "Themes", 40 | columns | map("get_column_macro") | list, 41 | ) 42 | }} 43 | -------------------------------------------------------------------------------- /fief/repositories/email_verification.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import delete, select 3 | 4 | from fief.models import EmailVerification 5 | from fief.repositories.base import BaseRepository, ExpiresAtMixin, UUIDRepositoryMixin 6 | 7 | 8 | class EmailVerificationRepository( 9 | BaseRepository[EmailVerification], 10 | UUIDRepositoryMixin[EmailVerification], 11 | ExpiresAtMixin[EmailVerification], 12 | ): 13 | model = EmailVerification 14 | 15 | async def get_by_code_and_user( 16 | self, code: str, user: UUID4 17 | ) -> EmailVerification | None: 18 | statement = select(EmailVerification).where( 19 | EmailVerification.code == code, EmailVerification.user_id == user 20 | ) 21 | return await self.get_one_or_none(statement) 22 | 23 | async def delete_by_user(self, user: UUID4) -> None: 24 | statement = delete(EmailVerification).where(EmailVerification.user_id == user) 25 | await self._execute_statement(statement) 26 | 27 | async def get_by_user(self, user: UUID4) -> list[EmailVerification]: 28 | statement = select(EmailVerification).where(EmailVerification.user_id == user) 29 | return await self.list(statement) 30 | -------------------------------------------------------------------------------- /fief/services/acr.py: -------------------------------------------------------------------------------- 1 | from enum import StrEnum 2 | 3 | 4 | class ACR(StrEnum): 5 | """ 6 | List of defined Authentication Context Class Reference. 7 | """ 8 | 9 | LEVEL_ZERO = "0" 10 | """Level 0. No authentication was performed, a previous session was used.""" 11 | 12 | LEVEL_ONE = "1" 13 | """Level 1. Password authentication was performed.""" 14 | 15 | def __lt__(self, other: object) -> bool: 16 | return self._compare(other, True, True) 17 | 18 | def __le__(self, other: object) -> bool: 19 | return self._compare(other, False, True) 20 | 21 | def __gt__(self, other: object) -> bool: 22 | return self._compare(other, True, False) 23 | 24 | def __ge__(self, other: object) -> bool: 25 | return self._compare(other, False, False) 26 | 27 | def _compare(self, other: object, strict: bool, asc: bool) -> bool: 28 | if not isinstance(other, ACR): 29 | return NotImplemented 30 | 31 | if self == other: 32 | return not strict 33 | 34 | for elem in ACR: 35 | if self == elem: 36 | return asc 37 | elif other == elem: 38 | return not asc 39 | raise RuntimeError() 40 | -------------------------------------------------------------------------------- /tests/test_repositories_user.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from fief.db import AsyncSession 4 | from fief.repositories import UserRepository 5 | from tests.data import TestData 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "email,tenant_alias,user_alias", 10 | [ 11 | ("anne@bretagne.duchy", "default", "regular"), 12 | ("ANNE@bretagne.DUCHY", "default", "regular"), 13 | ("ANNE@nantes.city", "default", None), 14 | ("anne@nantes.city", "secondary", "regular_secondary"), 15 | ("Claude@bretagne.duchy", "default", "cased_email"), 16 | ("claude@bretagne.duchy", "default", "cased_email"), 17 | ], 18 | ) 19 | @pytest.mark.asyncio 20 | async def test_get_by_email_and_tenant( 21 | email: str, 22 | tenant_alias: str, 23 | user_alias: str | None, 24 | main_session: AsyncSession, 25 | test_data: TestData, 26 | ): 27 | tenant = test_data["tenants"][tenant_alias] 28 | 29 | user_repository = UserRepository(main_session) 30 | 31 | user = await user_repository.get_by_email_and_tenant(email, tenant.id) 32 | 33 | if user_alias is None: 34 | assert user is None 35 | else: 36 | assert user is not None 37 | assert user.id == test_data["users"][user_alias].id 38 | -------------------------------------------------------------------------------- /fief/middlewares/x_forwarded_host.py: -------------------------------------------------------------------------------- 1 | from starlette.datastructures import MutableHeaders 2 | from starlette.types import ASGIApp, Receive, Scope, Send 3 | 4 | 5 | class XForwardedHostMiddleware: 6 | def __init__(self, app: ASGIApp, trusted_hosts: str | list[str] = "127.0.0.1"): 7 | self.app = app 8 | if isinstance(trusted_hosts, str): 9 | self.trusted_hosts = {item.strip() for item in trusted_hosts.split(",")} 10 | else: 11 | self.trusted_hosts = set(trusted_hosts) 12 | self.always_trust = "*" in self.trusted_hosts 13 | 14 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 15 | if scope["type"] in ("http", "websocket"): 16 | client_addr: tuple[str, int] | None = scope.get("client") 17 | client_host = client_addr[0] if client_addr else None 18 | 19 | if self.always_trust or client_host in self.trusted_hosts: 20 | headers = MutableHeaders(scope=scope) 21 | 22 | if "x-forwarded-host" in headers: 23 | headers.update({"host": headers["x-forwarded-host"]}) 24 | scope["headers"] = headers.raw 25 | 26 | return await self.app(scope, receive, send) 27 | -------------------------------------------------------------------------------- /fief/templates/admin/permissions/list.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/icons.html" as icons %} 4 | 5 | {% extends layout %} 6 | 7 | {% block head_title_content %}Permissions · {{ super() }}{% endblock %} 8 | 9 | {% block main %} 10 |
11 | 12 |
13 |

Permissions

14 |
15 | 16 |
17 | 18 |
26 |
27 | {{ forms.form_field(form.name, class="grow") }} 28 | {{ forms.form_field(form.codename, class="grow") }} 29 |
30 | {% call buttons.submit('btn') %} 31 | {{ icons.plus('w-4 h-4')}} 32 | 33 | {% endcall %} 34 |
35 |
36 | {{ forms.form_csrf_token(form) }} 37 |
38 | 39 | {% include "admin/permissions/table.html" %} 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /fief/apps/dashboard/validators.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from wtforms import validators 4 | 5 | from fief.services.localhost import is_localhost 6 | from fief.settings import settings 7 | 8 | 9 | class NotHTTPSURLValidationError(validators.ValidationError): 10 | def __init__(self): 11 | super().__init__("An HTTPS URL is required.") 12 | 13 | 14 | class RedirectURLValidator(validators.Regexp): 15 | def __init__(self, message=None): 16 | regex = ( 17 | r"^(?P[a-z]+)://" 18 | r"(?P[^\/\?:]+)" 19 | r"(?P:[0-9]+)?" 20 | r"(?P\/.*?)?" 21 | r"(?P\?.*)?$" 22 | ) 23 | super().__init__(regex, re.IGNORECASE, message) 24 | 25 | def __call__(self, form, field): 26 | message = self.message 27 | if message is None: 28 | message = "Invalid URL." 29 | 30 | match = super().__call__(form, field, message) 31 | scheme = match.group("scheme") 32 | host = match.group("host") 33 | 34 | if ( 35 | settings.client_redirect_uri_ssl_required 36 | and scheme == "http" 37 | and not is_localhost(host) 38 | ): 39 | raise NotHTTPSURLValidationError() 40 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/webhooks/list.html" %} 7 | 8 | {% block head_title_content %}Create Webhook · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Create Webhook{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.url) }} 26 | {{ forms.form_field(form.events) }} 27 | {{ forms.form_csrf_token(form) }} 28 |
29 | {% endcall %} 30 | {% call modal.footer() %} 31 | 32 | {% call buttons.submit('btn-sm') %} 33 | Create 34 | {% endcall %} 35 | {% endcall %} 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /fief/alembic/table_prefix_codemod.py: -------------------------------------------------------------------------------- 1 | import libcst as cst 2 | from libcst.codemod import VisitorBasedCodemodCommand 3 | 4 | from fief.models.base import TABLE_PREFIX_PLACEHOLDER 5 | 6 | 7 | class ConvertTablePrefixStrings(VisitorBasedCodemodCommand): 8 | DESCRIPTION: str = ( 9 | "Converts strings containing table prefix placeholder " 10 | "to a format-string with dynamic table prefix." 11 | ) 12 | 13 | def leave_SimpleString( 14 | self, original_node: cst.SimpleString, updated_node: cst.SimpleString 15 | ) -> cst.SimpleString | cst.FormattedString: 16 | value = updated_node.evaluated_value 17 | 18 | if not isinstance(value, str): 19 | return updated_node 20 | 21 | if TABLE_PREFIX_PLACEHOLDER in value: 22 | before, after = value.split(TABLE_PREFIX_PLACEHOLDER) 23 | before = before.replace('"', '\\"') 24 | after = after.replace('"', '\\"') 25 | return cst.FormattedString( 26 | [ 27 | cst.FormattedStringText(before), 28 | cst.FormattedStringExpression(cst.Name("table_prefix")), 29 | cst.FormattedStringText(after), 30 | ] 31 | ) 32 | 33 | return updated_node 34 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ../..:/workspaces:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 16 | network_mode: service:db 17 | 18 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 19 | # (Adding the "ports" property to this file will not forward from a Codespace.) 20 | 21 | db: 22 | image: postgres:14-alpine 23 | restart: unless-stopped 24 | volumes: 25 | - postgres-data:/var/lib/postgresql/data 26 | environment: 27 | POSTGRES_USER: fief 28 | POSTGRES_DB: fief 29 | POSTGRES_PASSWORD: fiefpassword 30 | 31 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 32 | # (Adding the "ports" property to this file will not forward from a Codespace.) 33 | 34 | redis: 35 | image: redis:alpine 36 | restart: unless-stopped 37 | network_mode: service:db 38 | 39 | volumes: 40 | postgres-data: 41 | -------------------------------------------------------------------------------- /fief/cli/__main__.py: -------------------------------------------------------------------------------- 1 | import click 2 | import typer 3 | import typer.core 4 | from pydantic import ValidationError 5 | 6 | from fief.cli.quickstart import add_commands as quickstart_add_commands 7 | 8 | settings_validation_errors: ValidationError | None = None 9 | 10 | 11 | class CustomUnknownCommandGroup(typer.core.TyperGroup): 12 | def resolve_command(self, ctx, args): 13 | try: 14 | return super().resolve_command(ctx, args) 15 | except click.exceptions.UsageError: 16 | global settings_validation_errors 17 | if settings_validation_errors: 18 | message = typer.style("Some settings are invalid or missing.", fg="red") 19 | typer.echo(f"{message}\n{str(settings_validation_errors)}", err=True) 20 | raise typer.Exit(code=1) from settings_validation_errors 21 | raise 22 | 23 | 24 | app = typer.Typer( 25 | help="Commands to manage your Fief instance.", cls=CustomUnknownCommandGroup 26 | ) 27 | app = quickstart_add_commands(app) 28 | 29 | try: 30 | from fief.cli.admin import add_commands as admin_add_commands 31 | 32 | app = admin_add_commands(app) 33 | except ValidationError as e: 34 | settings_validation_errors = e 35 | 36 | if __name__ == "__main__": 37 | app() 38 | -------------------------------------------------------------------------------- /fief/dependencies/login_hint.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import uuid 3 | 4 | from email_validator import EmailNotValidError, validate_email 5 | from fastapi import Cookie, Depends 6 | 7 | from fief.dependencies.oauth_provider import get_oauth_providers 8 | from fief.models import OAuthProvider 9 | from fief.settings import settings 10 | 11 | LoginHint = str | OAuthProvider 12 | 13 | 14 | async def get_login_hint( 15 | login_hint: str | None = Cookie(None, alias=settings.login_hint_cookie_name), 16 | oauth_providers: list[OAuthProvider] = Depends(get_oauth_providers), 17 | ) -> LoginHint | None: 18 | if login_hint is None: 19 | return None 20 | 21 | unquoted_login_hint = urllib.parse.unquote(login_hint) 22 | 23 | try: 24 | validated_email = validate_email( 25 | unquoted_login_hint, check_deliverability=False 26 | ) 27 | except EmailNotValidError: 28 | pass 29 | else: 30 | return validated_email.normalized 31 | 32 | try: 33 | oauth_provider_id = uuid.UUID(unquoted_login_hint) 34 | for oauth_provider in oauth_providers: 35 | if oauth_provider.id == oauth_provider_id: 36 | return oauth_provider 37 | except ValueError: 38 | pass 39 | 40 | return None 41 | -------------------------------------------------------------------------------- /fief/schemas/tenant.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4, HttpUrl 2 | 3 | from fief.schemas.generics import BaseModel, CreatedUpdatedAt, UUIDSchema 4 | from fief.schemas.oauth_provider import OAuthProviderEmbedded 5 | 6 | 7 | class TenantCreate(BaseModel): 8 | name: str 9 | registration_allowed: bool = True 10 | theme_id: UUID4 | None = None 11 | logo_url: HttpUrl | None = None 12 | application_url: HttpUrl | None = None 13 | oauth_providers: list[UUID4] | None = None 14 | 15 | 16 | class TenantUpdate(BaseModel): 17 | name: str | None = None 18 | registration_allowed: bool | None = None 19 | theme_id: UUID4 | None = None 20 | logo_url: HttpUrl | None = None 21 | application_url: HttpUrl | None = None 22 | oauth_providers: list[UUID4] | None = None 23 | 24 | 25 | class BaseTenant(UUIDSchema, CreatedUpdatedAt): 26 | name: str 27 | default: bool 28 | slug: str 29 | registration_allowed: bool 30 | theme_id: UUID4 | None = None 31 | logo_url: HttpUrl | None = None 32 | application_url: HttpUrl | None = None 33 | 34 | 35 | class Tenant(BaseTenant): 36 | oauth_providers: list[OAuthProviderEmbedded] 37 | 38 | 39 | class TenantEmbedded(BaseTenant): 40 | pass 41 | 42 | 43 | class TenantEmailContext(BaseTenant): 44 | pass 45 | -------------------------------------------------------------------------------- /fief/crypto/encryption.py: -------------------------------------------------------------------------------- 1 | import binascii 2 | 3 | from cryptography.fernet import Fernet 4 | from sqlalchemy import Text 5 | from sqlalchemy_utils.types.encrypted.encrypted_type import ( 6 | FernetEngine as BaseFernetEngine, 7 | ) 8 | from sqlalchemy_utils.types.encrypted.encrypted_type import ( 9 | StringEncryptedType as BaseStringEncryptedType, 10 | ) 11 | 12 | 13 | class StringEncryptedType(BaseStringEncryptedType): 14 | impl = Text 15 | 16 | 17 | def generate_key() -> bytes: 18 | return Fernet.generate_key() 19 | 20 | 21 | def is_valid_key(key: bytes) -> bool: 22 | try: 23 | Fernet(key) 24 | except binascii.Error: 25 | return False 26 | else: 27 | return True 28 | 29 | 30 | class FernetEngine(BaseFernetEngine): 31 | """ 32 | Overload of the built-in SQLAlchemy Utils Fernet engine. 33 | 34 | For unknown reasons, they hash the encryption key before using it. 35 | 36 | For backward compatibility, we adjust the implementation to use the key as provided. 37 | """ 38 | 39 | def _update_key(self, key: bytes): 40 | return self._initialize_engine(key) 41 | 42 | def _initialize_engine(self, parent_class_key: bytes): 43 | self.secret_key = parent_class_key 44 | self.fernet = Fernet(self.secret_key) 45 | -------------------------------------------------------------------------------- /fief/templates/admin/users/access_token.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/users/list.html" %} 7 | 8 | {% block head_title_content %}{{ user.email }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Create an access token{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.client) }} 26 | {{ forms.form_field(form.scopes) }} 27 | {{ forms.form_csrf_token(form) }} 28 |
29 | {% endcall %} 30 | {% call modal.footer() %} 31 | 32 | {% call buttons.submit('btn-sm') %} 33 | Create 34 | {% endcall %} 35 | {% endcall %} 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /fief/services/posthog.py: -------------------------------------------------------------------------------- 1 | import functools 2 | import hashlib 3 | from typing import Any 4 | 5 | from posthog import Posthog 6 | 7 | from fief import __version__ 8 | from fief.db import AsyncSession 9 | from fief.repositories.user import UserRepository 10 | from fief.services.localhost import is_localhost 11 | from fief.settings import settings 12 | 13 | POSTHOG_API_KEY = "__POSTHOG_API_KEY__" 14 | 15 | posthog = Posthog( 16 | POSTHOG_API_KEY, 17 | host="https://eu.posthog.com", 18 | disabled=not settings.telemetry_enabled, 19 | sync_mode=True, 20 | ) 21 | 22 | 23 | @functools.cache 24 | def get_server_id() -> str: 25 | domain = settings.fief_domain 26 | server_id_hash = hashlib.sha256() 27 | server_id_hash.update(domain.encode("utf-8")) 28 | server_id_hash.update(settings.secret.get_secret_value().encode("utf-8")) 29 | return server_id_hash.hexdigest() 30 | 31 | 32 | async def get_server_properties(session: AsyncSession) -> dict[str, Any]: 33 | user_repository = UserRepository(session) 34 | users_count = await user_repository.count_all() 35 | return { 36 | "version": __version__, 37 | "is_localhost": is_localhost(settings.fief_domain), 38 | "database_type": settings.database_type, 39 | "users_count": users_count, 40 | } 41 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/edit.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/webhooks/list.html" %} 7 | 8 | {% block head_title_content %}{{ webhook.url }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Edit Webhook {{ webhook.url }}{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.url) }} 26 | {{ forms.form_field(form.events) }} 27 | {{ forms.form_csrf_token(form) }} 28 |
29 | {% endcall %} 30 | {% call modal.footer() %} 31 | 32 | {% call buttons.submit('btn-sm') %} 33 | Update 34 | {% endcall %} 35 | {% endcall %} 36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/table.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | {% import "macros/datatable.html" as datatable %} 4 | 5 | {% macro name_column(tenant) %} 6 | 13 | {{ tenant.name }} 14 | 15 | {% if tenant.default %} 16 |
17 | Default 18 |
19 | {% endif %} 20 | {% endmacro %} 21 | 22 | {% macro base_url_column(tenant) %} 23 | {% set base_url = tenant.get_host() %} 24 |
25 | {{ base_url }} 26 | {{ buttons.clipboard(base_url) }} 27 |
28 | {% endmacro %} 29 | 30 | {% macro registration_allowed_column(tenant) %} 31 | {% if tenant.registration_allowed %} 32 | {{ icons.check('w-4 h-4') }} 33 | {% else %} 34 | {{ icons.x_mark('w-4 h-4') }} 35 | {% endif %} 36 | {% endmacro %} 37 | 38 | {{ 39 | datatable.datatable( 40 | tenants, 41 | count, 42 | datatable_query_parameters, 43 | "Tenants", 44 | columns | map("get_column_macro") | list, 45 | ) 46 | }} 47 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/roles/list.html" %} 7 | 8 | {% block head_title_content %}Create Role · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Create Role{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_field(form.granted_by_default) }} 27 | {{ forms.form_field(form.permissions) }} 28 | {{ forms.form_csrf_token(form) }} 29 |
30 | {% endcall %} 31 | {% call modal.footer() %} 32 | 33 | {% call buttons.submit('btn-sm') %} 34 | Create 35 | {% endcall %} 36 | {% endcall %} 37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /fief/repositories/oauth_account.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import select 3 | from sqlalchemy.sql import Select 4 | 5 | from fief.models import OAuthAccount 6 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 7 | 8 | 9 | class OAuthAccountRepository( 10 | BaseRepository[OAuthAccount], UUIDRepositoryMixin[OAuthAccount] 11 | ): 12 | model = OAuthAccount 13 | 14 | async def get_by_provider_and_account_id( 15 | self, provider_id: UUID4, account_id: str 16 | ) -> OAuthAccount | None: 17 | statement = select(OAuthAccount).where( 18 | OAuthAccount.oauth_provider_id == provider_id, 19 | OAuthAccount.account_id == account_id, 20 | ) 21 | return await self.get_one_or_none(statement) 22 | 23 | async def get_by_provider_and_user( 24 | self, provider_id: UUID4, user: UUID4 25 | ) -> OAuthAccount | None: 26 | statement = select(OAuthAccount).where( 27 | OAuthAccount.oauth_provider_id == provider_id, 28 | OAuthAccount.user_id == user, 29 | ) 30 | return await self.get_one_or_none(statement) 31 | 32 | def get_by_user_statement(self, user: UUID4) -> Select: 33 | statement = select(OAuthAccount).where(OAuthAccount.user_id == user) 34 | return statement 35 | -------------------------------------------------------------------------------- /fief/templates/admin/api_keys/token.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/api_keys/list.html" %} 7 | 8 | {% block head_title_content %}{{ api_key.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
14 | {% call modal.header() %} 15 | {% call modal.title() %}Save your API Key token{% endcall %} 16 | {% endcall %} 17 | {% call modal.body() %} 18 | {% call alerts.warning() %} 19 | The token below will be shown only once. Be sure to store it somewhere safe. 20 | {% endcall %} 21 |
22 | {{ buttons.clipboard(token | trim) }} 23 |
24 |
{{ token | trim }}
25 | {% endcall %} 26 | {% call modal.footer() %} 27 | 34 | {% endcall %} 35 |
36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /fief/models/webhook_log.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import Any 3 | 4 | from pydantic import UUID4 5 | from sqlalchemy import Boolean, ForeignKey, Integer, String, Text 6 | from sqlalchemy.orm import Mapped, mapped_column, relationship 7 | 8 | from fief.models.base import Base 9 | from fief.models.generics import GUID, CreatedUpdatedAt, UUIDModel 10 | from fief.models.webhook import Webhook 11 | 12 | 13 | class WebhookLog(UUIDModel, CreatedUpdatedAt, Base): 14 | __tablename__ = "webhook_logs" 15 | 16 | webhook_id: Mapped[UUID4] = mapped_column( 17 | GUID, ForeignKey(Webhook.id, ondelete="CASCADE"), nullable=False 18 | ) 19 | event: Mapped[str] = mapped_column(String(255), nullable=False) 20 | attempt: Mapped[int] = mapped_column(Integer, nullable=False) 21 | payload: Mapped[str] = mapped_column(Text, nullable=False) 22 | success: Mapped[bool] = mapped_column(Boolean, nullable=False) 23 | response: Mapped[str | None] = mapped_column(Text, nullable=True) 24 | error_type: Mapped[str | None] = mapped_column(String(255), nullable=True) 25 | error_message: Mapped[str | None] = mapped_column(Text, nullable=True) 26 | 27 | webhook: Mapped[Webhook] = relationship("Webhook") 28 | 29 | @property 30 | def payload_dict(self) -> dict[str, Any]: 31 | return json.loads(self.payload) 32 | -------------------------------------------------------------------------------- /styles/globals.scss: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=fallback'); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | 6 | // Additional styles 7 | @import 'additional-styles/utility-patterns.scss'; 8 | @import 'additional-styles/range-slider.scss'; 9 | @import 'additional-styles/toggle-switch.scss'; 10 | @import 'additional-styles/theme.scss'; 11 | 12 | @tailwind utilities; 13 | 14 | // See Alpine.js: https://github.com/alpinejs/alpine#x-cloak 15 | [x-cloak=""] { 16 | display: none; 17 | } 18 | 19 | @media screen and (max-width: theme('screens.lg')) { 20 | [x-cloak="lg"] { display: none; } 21 | } 22 | 23 | :root { 24 | --color-primary-50: #fff1f2; 25 | --color-primary-100: #ffe4e6; 26 | --color-primary-200: #fecdd3; 27 | --color-primary-300: #fda4af; 28 | --color-primary-400: #fb7185; 29 | --color-primary-500: #f43f5e; 30 | --color-primary-600: #e11d48; 31 | --color-primary-700: #be123c; 32 | --color-primary-800: #9f1239; 33 | --color-primary-900: #881337; 34 | --color-accent: #1e293b; 35 | --color-light: #e2e8f0; 36 | --color-light-hover: #cbd5e1; 37 | --color-input: #1e293b; 38 | --color-bg-input: #ffffff; 39 | 40 | font-size: 16px; 41 | font-family: 'Inter', sans-serif; 42 | color: #475569; 43 | background-color: #ffffff; 44 | } 45 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/edit.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/roles/list.html" %} 7 | 8 | {% block head_title_content %}{{ role.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
20 | {% call modal.header() %} 21 | {% call modal.title() %}Edit Role {{ role.name }}{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_field(form.granted_by_default) }} 27 | {{ forms.form_field(form.permissions) }} 28 | {{ forms.form_csrf_token(form) }} 29 |
30 | {% endcall %} 31 | {% call modal.footer() %} 32 | 33 | {% call buttons.submit('btn-sm') %} 34 | Update 35 | {% endcall %} 36 | {% endcall %} 37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /fief/services/email/base.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import Protocol 3 | 4 | 5 | @dataclasses.dataclass 6 | class EmailDomainDNSRecord: 7 | id: str 8 | type: str 9 | host: str 10 | value: str 11 | verified: bool 12 | 13 | 14 | @dataclasses.dataclass 15 | class EmailDomain: 16 | domain_id: str 17 | domain: str 18 | records: list[EmailDomainDNSRecord] 19 | 20 | 21 | class EmailProvider(Protocol): 22 | DOMAIN_AUTHENTICATION: bool 23 | 24 | def send_email( 25 | self, 26 | *, 27 | sender: tuple[str, str | None], 28 | recipient: tuple[str, str | None], 29 | subject: str, 30 | html: str | None = None, 31 | text: str | None = None, 32 | ): ... 33 | 34 | def create_domain(self, domain: str) -> EmailDomain: ... 35 | 36 | def verify_domain(self, email_domain: EmailDomain) -> EmailDomain: ... 37 | 38 | 39 | class EmailError(Exception): 40 | def __init__(self, message: str): 41 | self.message = message 42 | 43 | 44 | class SendEmailError(EmailError): 45 | pass 46 | 47 | 48 | class CreateDomainError(EmailError): 49 | pass 50 | 51 | 52 | class VerifyDomainError(EmailError): 53 | pass 54 | 55 | 56 | def format_address(email: str, name: str | None = None): 57 | return email if name is None else f"{name} <{email}>" 58 | -------------------------------------------------------------------------------- /fief/dependencies/authentication_flow.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fief.dependencies.permission import ( 4 | UserPermissionsGetter, 5 | get_user_permissions_getter, 6 | ) 7 | from fief.dependencies.repositories import get_repository 8 | from fief.repositories import ( 9 | AuthorizationCodeRepository, 10 | GrantRepository, 11 | LoginSessionRepository, 12 | SessionTokenRepository, 13 | ) 14 | from fief.services.authentication_flow import AuthenticationFlow 15 | 16 | 17 | async def get_authentication_flow( 18 | authorization_code_repository: AuthorizationCodeRepository = Depends( 19 | get_repository(AuthorizationCodeRepository) 20 | ), 21 | login_session_repository: LoginSessionRepository = Depends( 22 | get_repository(LoginSessionRepository) 23 | ), 24 | session_token_repository: SessionTokenRepository = Depends( 25 | get_repository(SessionTokenRepository) 26 | ), 27 | grant_repository: GrantRepository = Depends(GrantRepository), 28 | get_user_permissions: UserPermissionsGetter = Depends(get_user_permissions_getter), 29 | ) -> AuthenticationFlow: 30 | return AuthenticationFlow( 31 | authorization_code_repository, 32 | login_session_repository, 33 | session_token_repository, 34 | grant_repository, 35 | get_user_permissions, 36 | ) 37 | -------------------------------------------------------------------------------- /fief/repositories/client.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from sqlalchemy import select 4 | 5 | from fief.models import Client 6 | from fief.repositories.base import BaseRepository, UUIDRepositoryMixin 7 | 8 | 9 | class ClientRepository(BaseRepository[Client], UUIDRepositoryMixin[Client]): 10 | model = Client 11 | 12 | async def get_by_client_id(self, client_id: str) -> Client | None: 13 | statement = select(Client).where(Client.client_id == client_id) 14 | return await self.get_one_or_none(statement) 15 | 16 | async def get_by_client_id_and_tenant( 17 | self, client_id: str, tenant: uuid.UUID 18 | ) -> Client | None: 19 | statement = select(Client).where( 20 | Client.client_id == client_id, Client.tenant_id == tenant 21 | ) 22 | return await self.get_one_or_none(statement) 23 | 24 | async def get_by_client_id_and_secret( 25 | self, client_id: str, client_secret: str 26 | ) -> Client | None: 27 | statement = select(Client).where( 28 | Client.client_id == client_id, Client.client_secret == client_secret 29 | ) 30 | return await self.get_one_or_none(statement) 31 | 32 | async def count_by_tenant(self, tenant: uuid.UUID) -> int: 33 | statement = select(Client).where(Client.tenant_id == tenant) 34 | return await self._count(statement) 35 | -------------------------------------------------------------------------------- /fief/middlewares/security_headers.py: -------------------------------------------------------------------------------- 1 | import functools 2 | 3 | from starlette.datastructures import MutableHeaders 4 | from starlette.types import ASGIApp, Message, Receive, Scope, Send 5 | 6 | 7 | class SecurityHeadersMiddleware: 8 | def __init__(self, app: ASGIApp): 9 | self.app = app 10 | 11 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: 12 | if scope["type"] != "http": # pragma: no cover 13 | await self.app(scope, receive, send) 14 | return 15 | 16 | send = functools.partial(self.send, send=send, scope=scope) 17 | await self.app(scope, receive, send) 18 | 19 | async def send(self, message: Message, send: Send, scope: Scope) -> None: 20 | if message["type"] != "http.response.start": 21 | await send(message) 22 | return 23 | 24 | message.setdefault("headers", []) 25 | headers = MutableHeaders(scope=message) 26 | 27 | headers.append("content-security-policy", "frame-ancestors 'self'") 28 | headers.append("x-frame-options", "SAMEORIGIN") 29 | headers.append("referrer-policy", "strict-origin-when-cross-origin") 30 | headers.append("x-content-type-options", "nosniff") 31 | headers.append("permissions-policy", "geolocation=(), camera=(), microphone=()") 32 | 33 | await send(message) 34 | -------------------------------------------------------------------------------- /fief/apps/auth/forms/profile.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from fastapi import Depends 4 | from wtforms import EmailField, FormField, PasswordField, validators 5 | 6 | from fief.dependencies.user_field import get_update_user_fields 7 | from fief.forms import BaseForm, CSRFBaseForm, get_form_field 8 | from fief.locale import gettext_lazy as _ 9 | from fief.models import UserField 10 | 11 | 12 | class ChangeEmailForm(CSRFBaseForm): 13 | email = EmailField( 14 | _("Email address"), validators=[validators.InputRequired(), validators.Email()] 15 | ) 16 | current_password = PasswordField( 17 | _("Confirm your password"), 18 | validators=[validators.InputRequired()], 19 | render_kw={"autocomplete": "current-password"}, 20 | ) 21 | 22 | 23 | class ProfileFormBase(CSRFBaseForm): 24 | fields: FormField 25 | 26 | 27 | PF = TypeVar("PF", bound=ProfileFormBase) 28 | 29 | 30 | async def get_profile_form_class( 31 | update_user_fields: list[UserField] = Depends(get_update_user_fields), 32 | ) -> type[PF]: 33 | class ProfileFormFields(BaseForm): 34 | pass 35 | 36 | for field in update_user_fields: 37 | setattr(ProfileFormFields, field.slug, get_form_field(field)) 38 | 39 | class ProfileForm(ProfileFormBase): 40 | fields = FormField(ProfileFormFields, separator=".") 41 | 42 | return ProfileForm 43 | -------------------------------------------------------------------------------- /fief/models/refresh_token.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import UUID4 4 | from sqlalchemy import JSON, ForeignKey, String 5 | from sqlalchemy.orm import Mapped, mapped_column, relationship 6 | 7 | from fief.models.base import Base 8 | from fief.models.client import Client 9 | from fief.models.generics import ( 10 | GUID, 11 | CreatedUpdatedAt, 12 | ExpiresAt, 13 | TIMESTAMPAware, 14 | UUIDModel, 15 | ) 16 | from fief.models.user import User 17 | 18 | 19 | class RefreshToken(UUIDModel, CreatedUpdatedAt, ExpiresAt, Base): 20 | __tablename__ = "refresh_tokens" 21 | 22 | token: Mapped[str] = mapped_column( 23 | String(length=255), 24 | nullable=False, 25 | index=True, 26 | unique=True, 27 | ) 28 | scope: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list) 29 | authenticated_at: Mapped[datetime] = mapped_column( 30 | TIMESTAMPAware(timezone=True), nullable=False 31 | ) 32 | 33 | user_id: Mapped[UUID4] = mapped_column( 34 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 35 | ) 36 | user: Mapped[User] = relationship("User") 37 | 38 | client_id: Mapped[UUID4] = mapped_column( 39 | GUID, ForeignKey(Client.id, ondelete="CASCADE"), nullable=False 40 | ) 41 | client: Mapped[Client] = relationship("Client", lazy="joined") 42 | -------------------------------------------------------------------------------- /fief/templates/admin/users/access_token_result.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/forms.html" as forms %} 4 | {% import "macros/modal.html" as modal %} 5 | {% import "macros/icons.html" as icons %} 6 | 7 | {% extends "admin/users/list.html" %} 8 | 9 | {% block head_title_content %}{{ user.email }} · {{ super() }}{% endblock %} 10 | 11 | {% set open_modal = true %} 12 | 13 | {% block modal %} 14 | {% call modal.header() %} 15 | {% call modal.title() %}Create an access token{% endcall %} 16 | {% endcall %} 17 | {% call modal.body() %} 18 | {% call alerts.warning() %} 19 | Treat this access token with extreme care: it gives access to a user account. Don't save it in a file and don't share it online. 20 | {% endcall %} 21 |
22 | {{ buttons.clipboard(access_token | trim) }} 23 |
24 |
{{ access_token | trim }}
25 |
It will expire in {{ expires_in }} seconds.
26 | {% endcall %} 27 | {% call modal.footer() %} 28 | 29 | {% endcall %} 30 | {% endblock %} 31 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/get/base.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% extends "admin/tenants/list.html" %} 5 | 6 | {% block head_title_content %}{{ tenant.name }} · {{ super() }}{% endblock %} 7 | 8 | {% set open_aside = true %} 9 | 10 | {% macro tab_header(title, route, active) %} 11 |
  • 12 | 18 | {{ title }} 19 | 20 |
  • 21 | {% endmacro %} 22 | 23 | {% block aside %} 24 |

    {{ tenant.name }}

    25 |
    26 | 27 |
      28 | {{ tab_header("General", route="dashboard.tenants:get", active=tab == "general") }} 29 | {{ tab_header("Email", route="dashboard.tenants:email", active=tab == "email") }} 30 |
    31 |
    32 | {% block tab %}{% endblock %} 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /fief/dependencies/user_roles.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends 2 | 3 | from fief.dependencies.logger import get_audit_logger 4 | from fief.dependencies.repositories import get_repository 5 | from fief.dependencies.tasks import get_send_task 6 | from fief.dependencies.webhooks import TriggerWebhooks, get_trigger_webhooks 7 | from fief.logger import AuditLogger 8 | from fief.repositories import ( 9 | RoleRepository, 10 | UserPermissionRepository, 11 | UserRoleRepository, 12 | ) 13 | from fief.services.user_roles import UserRolesService 14 | from fief.tasks import SendTask 15 | 16 | 17 | async def get_user_roles_service( 18 | user_role_repository: UserRoleRepository = Depends( 19 | get_repository(UserRoleRepository) 20 | ), 21 | user_permission_repository: UserPermissionRepository = Depends( 22 | get_repository(UserPermissionRepository) 23 | ), 24 | role_repository: RoleRepository = Depends(get_repository(RoleRepository)), 25 | audit_logger: AuditLogger = Depends(get_audit_logger), 26 | send_task: SendTask = Depends(get_send_task), 27 | trigger_webhooks: TriggerWebhooks = Depends(get_trigger_webhooks), 28 | ) -> UserRolesService: 29 | return UserRolesService( 30 | user_role_repository, 31 | user_permission_repository, 32 | role_repository, 33 | audit_logger, 34 | trigger_webhooks, 35 | send_task, 36 | ) 37 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/edit.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/clients/list.html" %} 7 | 8 | {% block head_title_content %}{{ client.name }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
    20 | {% call modal.header() %} 21 | {% call modal.title() %}Edit Client {{ client.name }}{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
    25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_field(form.first_party) }} 27 | {{ forms.form_field(form.client_type) }} 28 | {{ forms.form_field(form.redirect_uris) }} 29 | {{ forms.form_csrf_token(form) }} 30 |
    31 | {% endcall %} 32 | {% call modal.footer() %} 33 | 34 | {% call buttons.submit('btn-sm') %} 35 | Update 36 | {% endcall %} 37 | {% endcall %} 38 |
    39 | {% endblock %} 40 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/get/base.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% extends "admin/clients/list.html" %} 5 | 6 | {% block head_title_content %}{{ client.name }} · {{ super() }}{% endblock %} 7 | 8 | {% set open_aside = true %} 9 | 10 | {% macro tab_header(title, route, active) %} 11 |
  • 12 | 18 | {{ title }} 19 | 20 |
  • 21 | {% endmacro %} 22 | 23 | {% block aside %} 24 |

    {{ client.name }}

    25 |
    26 | 27 |
      28 | {{ tab_header("General", route="dashboard.clients:get", active=tab == "general") }} 29 | {{ tab_header("Lifetimes", route="dashboard.clients:lifetimes", active=tab == "lifetimes") }} 30 |
    31 |
    32 | {% block tab %}{% endblock %} 33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /fief/templates/admin/webhooks/secret.html: -------------------------------------------------------------------------------- 1 | {% import "macros/alerts.html" as alerts %} 2 | {% import "macros/buttons.html" as buttons %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/webhooks/list.html" %} 7 | 8 | {% block head_title_content %}{{ webhook.url }} · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
    14 | {% call modal.header() %} 15 | {% call modal.title() %}Save your Webhook secret{% endcall %} 16 | {% endcall %} 17 | {% call modal.body() %} 18 | {% call alerts.warning() %} 19 | The secret below will be shown only once. Be sure to store it somewhere safe. You'll need it to validate the webhook's signature in your application. 20 | {% endcall %} 21 |
    22 | {{ buttons.clipboard(secret | trim) }} 23 |
    24 |
    {{ secret | trim }}
    25 | {% endcall %} 26 | {% call modal.footer() %} 27 | 34 | {% endcall %} 35 |
    36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /fief/templates/admin/clients/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/clients/list.html" %} 7 | 8 | {% block head_title_content %}Create Client · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
    20 | {% call modal.header() %} 21 | {% call modal.title() %}Create Client{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
    25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_field(form.first_party) }} 27 | {{ forms.form_field(form.client_type) }} 28 | {{ forms.form_field(form.redirect_uris) }} 29 | {{ forms.form_field(form.tenant) }} 30 | {{ forms.form_csrf_token(form) }} 31 |
    32 | {% endcall %} 33 | {% call modal.footer() %} 34 | 35 | {% call buttons.submit('btn-sm') %} 36 | Create 37 | {% endcall %} 38 | {% endcall %} 39 |
    40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /fief/templates/admin/roles/get.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% extends "admin/roles/list.html" %} 5 | 6 | {% block head_title_content %}{{ role.name }} · {{ super() }}{% endblock %} 7 | 8 | {% set open_aside = true %} 9 | 10 | {% block aside %} 11 |

    {{ role.name }}

    12 |
    13 |
    Permissions
    14 |
      15 | {% for permission in role.permissions %} 16 |
    • 17 |
      {{ permission.codename }}
      18 |
    • 19 | {% endfor %} 20 |
    21 |
    22 | 33 | 44 | {% endblock %} 45 | -------------------------------------------------------------------------------- /fief/dependencies/role.py: -------------------------------------------------------------------------------- 1 | from fastapi import Depends, HTTPException, Query, status 2 | from pydantic import UUID4 3 | from sqlalchemy import select 4 | 5 | from fief.dependencies.pagination import ( 6 | GetPaginatedObjects, 7 | Ordering, 8 | OrderingGetter, 9 | PaginatedObjects, 10 | Pagination, 11 | get_paginated_objects_getter, 12 | get_pagination, 13 | ) 14 | from fief.models import Role 15 | from fief.repositories import RoleRepository 16 | 17 | 18 | async def get_paginated_roles( 19 | query: str | None = Query(None), 20 | pagination: Pagination = Depends(get_pagination), 21 | ordering: Ordering = Depends(OrderingGetter()), 22 | repository: RoleRepository = Depends(RoleRepository), 23 | get_paginated_objects: GetPaginatedObjects[Role] = Depends( 24 | get_paginated_objects_getter 25 | ), 26 | ) -> PaginatedObjects[Role]: 27 | statement = select(Role) 28 | 29 | if query is not None: 30 | statement = statement.where(Role.name.ilike(f"%{query}%")) 31 | 32 | return await get_paginated_objects(statement, pagination, ordering, repository) 33 | 34 | 35 | async def get_role_by_id_or_404( 36 | id: UUID4, 37 | repository: RoleRepository = Depends(RoleRepository), 38 | ) -> Role: 39 | role = await repository.get_by_id(id) 40 | 41 | if role is None: 42 | raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) 43 | 44 | return role 45 | -------------------------------------------------------------------------------- /tests/test_apps_auth_locale.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | import pytest 3 | from bs4 import BeautifulSoup 4 | from fastapi import status 5 | 6 | from fief.settings import settings 7 | from tests.data import TestData 8 | 9 | 10 | @pytest.mark.asyncio 11 | async def test_switch_language( 12 | test_client_auth: httpx.AsyncClient, test_data: TestData 13 | ): 14 | login_session = test_data["login_sessions"]["default"] 15 | client = login_session.client 16 | tenant = client.tenant 17 | path_prefix = tenant.slug if not tenant.default else "" 18 | 19 | cookies = {} 20 | cookies[settings.login_session_cookie_name] = login_session.token 21 | 22 | response = await test_client_auth.get( 23 | f"{path_prefix}/register", cookies=cookies, headers={"accept-language": "en-US"} 24 | ) 25 | 26 | assert response.status_code == status.HTTP_200_OK 27 | 28 | html = BeautifulSoup(response.text, features="html.parser") 29 | assert html.find("label", attrs={"for": "email"}).text.strip() == "Email address\n*" 30 | 31 | response = await test_client_auth.get( 32 | f"{path_prefix}/register", cookies=cookies, headers={"accept-language": "fr-FR"} 33 | ) 34 | 35 | assert response.status_code == status.HTTP_200_OK 36 | 37 | html = BeautifulSoup(response.text, features="html.parser") 38 | assert ( 39 | html.find("label", attrs={"for": "email"}).text.strip() == "Adresse e-mail\n*" 40 | ) 41 | -------------------------------------------------------------------------------- /fief/services/email/postmark.py: -------------------------------------------------------------------------------- 1 | from postmarker.core import PostmarkClient 2 | from postmarker.exceptions import ClientError 3 | 4 | from fief.services.email.base import ( 5 | EmailDomain, 6 | EmailProvider, 7 | SendEmailError, 8 | format_address, 9 | ) 10 | 11 | 12 | class Postmark(EmailProvider): 13 | DOMAIN_AUTHENTICATION = False 14 | 15 | def __init__(self, server_token: str) -> None: 16 | self._client = PostmarkClient(server_token=server_token) 17 | 18 | def send_email( 19 | self, 20 | *, 21 | sender: tuple[str, str | None], 22 | recipient: tuple[str, str | None], 23 | subject: str, 24 | html: str | None = None, 25 | text: str | None = None, 26 | ): 27 | from_email, from_name = sender 28 | to_email, to_name = recipient 29 | try: 30 | self._client.emails.send( 31 | From=format_address(from_email, from_name), 32 | To=format_address(to_email, to_name), 33 | Subject=subject, 34 | HtmlBody=html, 35 | TextBody=text, 36 | ) 37 | except ClientError as e: 38 | raise SendEmailError(str(e)) from e 39 | 40 | def create_domain(self, domain: str) -> EmailDomain: 41 | raise NotImplementedError() 42 | 43 | def verify_domain(self, email_domain: EmailDomain) -> EmailDomain: 44 | raise NotImplementedError() 45 | -------------------------------------------------------------------------------- /fief/models/user_permission.py: -------------------------------------------------------------------------------- 1 | from pydantic import UUID4 2 | from sqlalchemy import ForeignKey 3 | from sqlalchemy.orm import Mapped, mapped_column, relationship 4 | from sqlalchemy.sql.schema import UniqueConstraint 5 | 6 | from fief.models.base import Base 7 | from fief.models.generics import GUID, CreatedUpdatedAt, UUIDModel 8 | from fief.models.permission import Permission 9 | from fief.models.role import Role 10 | from fief.models.user import User 11 | 12 | 13 | class UserPermission(UUIDModel, CreatedUpdatedAt, Base): 14 | __tablename__ = "user_permissions" 15 | __table_args__ = (UniqueConstraint("user_id", "permission_id", "from_role_id"),) 16 | 17 | user_id: Mapped[UUID4] = mapped_column( 18 | GUID, ForeignKey(User.id, ondelete="CASCADE"), nullable=False 19 | ) 20 | permission_id: Mapped[UUID4] = mapped_column( 21 | GUID, ForeignKey(Permission.id, ondelete="CASCADE"), nullable=False 22 | ) 23 | from_role_id: Mapped[UUID4 | None] = mapped_column( 24 | GUID, ForeignKey(Role.id, ondelete="CASCADE"), nullable=True 25 | ) 26 | 27 | user: Mapped[User] = relationship("User") 28 | permission: Mapped[Permission] = relationship("Permission") 29 | from_role: Mapped[Role] = relationship("Role", back_populates="user_permissions") 30 | 31 | def __repr__(self) -> str: 32 | return f"UserPermission(id={self.id}, user_id={self.user_id}, permission_id={self.permission_id}), from_role_id={self.from_role_id}" 33 | -------------------------------------------------------------------------------- /fief/services/password.py: -------------------------------------------------------------------------------- 1 | from typing import Self 2 | 3 | from zxcvbn_rs_py import zxcvbn 4 | 5 | from fief.locale import gettext_lazy as _ 6 | 7 | MAX_PASSWORD_LENGTH = 128 8 | 9 | 10 | class PasswordValidation: 11 | def __init__(self, valid: bool, score: int, messages: list[str]) -> None: 12 | self.valid = valid 13 | self.score = score 14 | self.messages = messages or [] 15 | 16 | @classmethod 17 | def validate(cls, password: str, *, min_length: int, min_score: int) -> Self: 18 | valid = True 19 | messages: list[str] = [] 20 | 21 | if len(password) < min_length: 22 | valid = False 23 | messages.append( 24 | _( 25 | "Password must be at least %(min)d characters long.", 26 | min=min_length, 27 | ) 28 | ) 29 | elif len(password) > MAX_PASSWORD_LENGTH: 30 | valid = False 31 | messages.append( 32 | _( 33 | "Password must be at most %(max)d characters long.", 34 | max=MAX_PASSWORD_LENGTH, 35 | ) 36 | ) 37 | 38 | password_strength = zxcvbn(password[0:MAX_PASSWORD_LENGTH]) 39 | if password_strength.score < min_score: 40 | valid = False 41 | messages.append(_("Password is not strong enough.")) 42 | 43 | return cls(valid, password_strength.score, messages) 44 | -------------------------------------------------------------------------------- /fief/alembic.ini: -------------------------------------------------------------------------------- 1 | [alembic] 2 | file_template = %%(year)d-%%(month).2d-%%(day).2d_%%(slug)s 3 | script_location = fief:alembic 4 | version_locations = fief:alembic/versions 5 | version_table_name = alembic_version 6 | target_base = Base 7 | sqlalchemy.url = postgresql+asyncpg://fief:fiefpassword@localhost:5432/fief 8 | prepend_sys_path = . 9 | 10 | [post_write_hooks] 11 | hooks = codemod,format,lint 12 | 13 | codemod.type = exec 14 | codemod.executable = python 15 | codemod.options = -m libcst.tool codemod --no-format -x fief.alembic.table_prefix_codemod.ConvertTablePrefixStrings REVISION_SCRIPT_FILENAME 16 | 17 | format.type = exec 18 | format.executable = ruff 19 | format.options = format REVISION_SCRIPT_FILENAME 20 | 21 | lint.type = exec 22 | lint.executable = ruff 23 | lint.options = check --fix REVISION_SCRIPT_FILENAME 24 | 25 | # Logging configuration 26 | [loggers] 27 | keys = root,sqlalchemy,alembic 28 | 29 | [handlers] 30 | keys = console 31 | 32 | [formatters] 33 | keys = generic 34 | 35 | [logger_root] 36 | level = WARN 37 | handlers = console 38 | qualname = 39 | 40 | [logger_sqlalchemy] 41 | level = WARN 42 | handlers = 43 | qualname = sqlalchemy.engine 44 | 45 | [logger_alembic] 46 | level = INFO 47 | handlers = 48 | qualname = alembic 49 | 50 | [handler_console] 51 | class = StreamHandler 52 | args = (sys.stderr,) 53 | level = NOTSET 54 | formatter = generic 55 | 56 | [formatter_generic] 57 | format = %(levelname)-5.5s [%(name)s] %(message)s 58 | datefmt = %H:%M:%S 59 | -------------------------------------------------------------------------------- /fief/templates/admin/tenants/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/tenants/list.html" %} 7 | 8 | {% block head_title_content %}Create Tenant · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
    20 | {% call modal.header() %} 21 | {% call modal.title() %}Create Tenant{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
    25 | {{ forms.form_field(form.name) }} 26 | {{ forms.form_field(form.registration_allowed) }} 27 | {{ forms.form_field(form.logo_url) }} 28 | {{ forms.form_field(form.application_url) }} 29 | {{ forms.form_field(form.theme) }} 30 | {{ forms.form_field(form.oauth_providers) }} 31 | {{ forms.form_csrf_token(form) }} 32 |
    33 | {% endcall %} 34 | {% call modal.footer() %} 35 | 36 | {% call buttons.submit('btn-sm') %} 37 | Create 38 | {% endcall %} 39 | {% endcall %} 40 |
    41 | {% endblock %} 42 | -------------------------------------------------------------------------------- /fief/templates/admin/users/create.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/forms.html" as forms %} 3 | {% import "macros/modal.html" as modal %} 4 | {% import "macros/icons.html" as icons %} 5 | 6 | {% extends "admin/users/list.html" %} 7 | 8 | {% block head_title_content %}Create User · {{ super() }}{% endblock %} 9 | 10 | {% set open_modal = true %} 11 | 12 | {% block modal %} 13 |
    20 | {% call modal.header() %} 21 | {% call modal.title() %}Create User{% endcall %} 22 | {% endcall %} 23 | {% call modal.body() %} 24 |
    25 | {{ forms.form_field(form.email) }} 26 | {{ forms.form_field(form.email_verified) }} 27 | {{ forms.form_field(form.password) }} 28 | {{ forms.form_field(form.tenant) }} 29 |
    30 | {% for field in form.fields %} 31 | {{ forms.form_field(field) }} 32 | {% endfor %} 33 | {{ forms.form_csrf_token(form) }} 34 |
    35 | {% endcall %} 36 | {% call modal.footer() %} 37 | 38 | {% call buttons.submit('btn-sm') %} 39 | Create 40 | {% endcall %} 41 | {% endcall %} 42 |
    43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /fief/tasks/register.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | import dramatiq 4 | 5 | from fief import schemas 6 | from fief.services.email_template.contexts import WelcomeContext 7 | from fief.services.email_template.types import EmailTemplateType 8 | from fief.tasks.base import TaskBase 9 | 10 | 11 | class OnAfterRegisterTask(TaskBase): 12 | __name__ = "on_after_register" 13 | 14 | async def run(self, user_id: str): 15 | user = await self._get_user(uuid.UUID(user_id)) 16 | tenant = await self._get_tenant(user.tenant_id) 17 | 18 | # Send welcome email 19 | context = WelcomeContext( 20 | tenant=schemas.tenant.Tenant.model_validate(tenant), 21 | user=schemas.user.UserEmailContext.model_validate(user), 22 | ) 23 | async with self._get_email_subject_renderer() as email_subject_renderer: 24 | subject = await email_subject_renderer.render( 25 | EmailTemplateType.WELCOME, context 26 | ) 27 | 28 | async with self._get_email_template_renderer() as email_template_renderer: 29 | html = await email_template_renderer.render( 30 | EmailTemplateType.WELCOME, context 31 | ) 32 | 33 | self.email_provider.send_email( 34 | sender=tenant.get_email_sender(), 35 | recipient=(user.email, None), 36 | subject=subject, 37 | html=html, 38 | ) 39 | 40 | 41 | on_after_register = dramatiq.actor(OnAfterRegisterTask()) 42 | -------------------------------------------------------------------------------- /fief/lifespan.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | from collections.abc import AsyncGenerator 3 | from typing import Any, TypedDict 4 | 5 | from fastapi import FastAPI 6 | 7 | from fief import __version__, tasks 8 | from fief.db.main import create_main_async_session_maker, create_main_engine 9 | from fief.logger import init_logger, logger 10 | from fief.services.posthog import get_server_id 11 | from fief.settings import settings 12 | 13 | 14 | class LifespanState(TypedDict): 15 | main_async_session_maker: Any 16 | server_id: str 17 | 18 | 19 | @contextlib.asynccontextmanager 20 | async def lifespan(app: FastAPI) -> AsyncGenerator[LifespanState, None]: 21 | init_logger() 22 | 23 | main_engine = create_main_engine() 24 | 25 | logger.info("Fief Server started", version=__version__) 26 | 27 | if settings.telemetry_enabled: 28 | logger.warning( 29 | "Telemetry is enabled.\n" 30 | "We will collect data to better understand how Fief is used and improve the project.\n" 31 | "You can opt-out by setting the environment variable `TELEMETRY_ENABLED=false`.\n" 32 | "Read more about Fief's telemetry here: https://docs.fief.dev/telemetry" 33 | ) 34 | tasks.send_task(tasks.heartbeat) 35 | 36 | yield { 37 | "main_async_session_maker": create_main_async_session_maker(main_engine), 38 | "server_id": get_server_id(), 39 | } 40 | 41 | await main_engine.dispose() 42 | 43 | logger.info("Fief Server stopped") 44 | -------------------------------------------------------------------------------- /fief/templates/admin/oauth_providers/get.html: -------------------------------------------------------------------------------- 1 | {% import "macros/buttons.html" as buttons %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | {% extends "admin/oauth_providers/list.html" %} 5 | 6 | {% block head_title_content %}{{ oauth_provider.display_name }} · {{ super() }}{% endblock %} 7 | 8 | {% set open_aside = true %} 9 | 10 | {% block aside %} 11 |

    {{ oauth_provider.display_name }}

    12 |
    13 |
      14 |
    • 15 |
      ID
      16 |
      {{ oauth_provider.id }}
      17 | {{ buttons.clipboard(oauth_provider.id) }} 18 |
    • 19 |
    20 |
    21 | 32 | 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /fief/templates/admin/users/get/oauth_accounts.html: -------------------------------------------------------------------------------- 1 | {% import "macros/forms.html" as forms %} 2 | {% import "macros/icons.html" as icons %} 3 | 4 | 5 | {% extends "admin/users/get/base.html" %} 6 | 7 | {% block tab %} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% if oauth_accounts | length == 0 %} 17 | 18 | 19 | 20 | {% endif %} 21 | {% for oauth_account in oauth_accounts %} 22 | 23 | 24 | 27 | 28 | {% endfor %} 29 | 30 |
    ProviderLast used
    No OAuth account associated to this user
    {{ oauth_account.oauth_provider.get_provider_display_name() }} 25 | {{ oauth_account.updated_at.strftime('%x %X') }} 26 |
    31 | {% endblock %} 32 | --------------------------------------------------------------------------------