├── .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 |
6 | Code sent!
7 |
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 |
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 |
11 | {{ code }}
12 | This verification code is only valid for the next hour.
13 |
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 |
9 |
10 |
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 |
15 | {{ icons.clipboard('h-4 w-4') }}
16 | {{ icons.clipboard_document_check('h-4 w-4 hidden') }}
17 |
18 | {% endmacro %}
19 |
20 | {% macro submit(class) %}
21 |
25 | {{ icons.spinner('hidden group-disabled:block w-4 h-4 shrink-0 mr-2') }}
26 | {{ caller() }}
27 |
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 |
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 |
20 | {{ users_count }} users will be deleted .
21 | {{ clients_count }} clients will be deleted .
22 |
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 |
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 |
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 |
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 |
27 | Close
28 |
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 |
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 |
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 |
11 |
13 |
20 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
32 | Close
33 |
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 |
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 | Close
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 |
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 |
32 | Close
33 |
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 |
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 |
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 |
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 |
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 | Provider
12 | Last used
13 |
14 |
15 |
16 | {% if oauth_accounts | length == 0 %}
17 |
18 | No OAuth account associated to this user
19 |
20 | {% endif %}
21 | {% for oauth_account in oauth_accounts %}
22 |
23 | {{ oauth_account.oauth_provider.get_provider_display_name() }}
24 |
25 | {{ oauth_account.updated_at.strftime('%x %X') }}
26 |
27 |
28 | {% endfor %}
29 |
30 |
31 | {% endblock %}
32 |
--------------------------------------------------------------------------------