Skip to content

YourBOX Authentication

ADR-0004 sets the identity model: Authentik OIDC in production, Symfony form login in development and on the VPS smoke test, with a shared local user table that mirrors authenticated subjects. This page documents the firewall layout, the user lifecycle, the role model and the security defenses.

Two Symfony Security firewalls live side by side. Only one is active per request, selected by the URL pattern.

app/config/packages/security.yaml
security:
password_hashers:
App\Identity\Domain\User: 'auto' # Argon2id
providers:
users:
entity:
class: App\Identity\Domain\User
property: username
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
oidc:
pattern: ^/(oidc|app|admin)
stateless: false
oauth2_client:
client: authentik
check_path: /oidc/callback
user_provider:
service: App\Identity\Infrastructure\AuthentikUserProvider
logout:
path: /oidc/logout
target: '/'
form:
pattern: ^/(login|register|reset|profile)
stateless: false
form_login:
login_path: login
check_path: login_check
enable_csrf: true
logout:
path: logout
target: '/'
remember_me:
secret: '%env(APP_SECRET)%'
lifetime: 604800
samesite: strict
access_control:
- { path: ^/health, roles: PUBLIC_ACCESS }
- { path: ^/metrics, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/app, roles: ROLE_USER }
- { path: ^/profile, roles: ROLE_USER }

The form firewall is only loaded when APP_ENV equals dev or vps. The Symfony configuration uses environment conditional loading:

when@dev: &dev_form
security:
firewalls:
form:
pattern: ^/(login|register|reset|profile)
...
when@vps: *dev_form

The App\Identity\Domain\User entity carries the columns needed by both firewalls and by every bounded context that references a user. The OIDC path populates oidc_sub, the form path populates password. Both paths populate username and email.

namespace App\Identity\Domain;
#[ORM\Entity, ORM\Table(name: 'users')]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id, ORM\Column(type: 'ulid')]
private Ulid $id;
#[ORM\Column(length: 64, unique: true)]
private string $username;
#[ORM\Column(length: 255, unique: true)]
private string $email;
#[ORM\Column(length: 191, unique: true, nullable: true)]
private ?string $oidcSub = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $password = null;
#[ORM\Column(type: 'json')]
private array $roles = ['ROLE_USER'];
#[ORM\Column(length: 120, nullable: true)]
private ?string $displayName = null;
#[ORM\Column(type: 'datetime_immutable')]
private \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetime_immutable', nullable: true)]
private ?\DateTimeImmutable $lastSeenAt = null;
}

The entity exposes one method per use case: linkOidcSubject, refreshDisplayInfo, promoteToAdmin. Direct property writes from controllers are not allowed.

The OIDC callback is handled by App\Identity\Infrastructure\AuthentikUserProvider. On the first login the provider creates the user; on subsequent logins it refreshes display_name, email and last_seen_at.

sequenceDiagram
participant Browser
participant Symfony as Symfony firewall
participant Authentik
participant DB as PostgreSQL users
Browser->>Symfony: GET /oidc/login
Symfony-->>Browser: 302 to Authentik authorize URL
Browser->>Authentik: GET authorize
Authentik-->>Browser: 302 to /oidc/callback?code=...
Browser->>Symfony: GET /oidc/callback?code=...
Symfony->>Authentik: POST token (code, client_id, client_secret)
Authentik-->>Symfony: tokens + id_token (sub, email, name, groups)
Symfony->>DB: SELECT by oidc_sub
alt user not found
Symfony->>DB: INSERT new user with oidc_sub, email, display_name
else user found
Symfony->>DB: UPDATE display_name, email, last_seen_at
end
Symfony-->>Browser: 302 to / with session cookie

The Authentik client is configured with the redirect URI https://yourbox.site/oidc/callback, PKCE enabled, refresh tokens enabled and the openid email profile groups scope.

YourBOX has three roles enforced by Symfony voters:

  • ROLE_USER is granted automatically on first login. Required for upload, comment, vote and profile management.
  • ROLE_MODERATOR allows reviewing reports and soft deleting comments. Granted from the Moderation admin panel by a ROLE_ADMIN.
  • ROLE_ADMIN allows the full EasyAdmin surface (videos, users, moderation). Granted in OpenBao by listing the user’s Authentik sub in kv/yourbox/admins, propagated to the entity through the external secrets sync.

The voter that decides on a video edit checks two things: the actor is the uploader, or the actor has ROLE_MODERATOR and the request carries a valid X-Reason header captured for the audit log.

  • Every state changing endpoint requires a CSRF token. Symfony Forms inject it automatically; the few raw POST handlers under Engagement (vote endpoints) call csrf_token('vote_'.$id) in Twig and isCsrfTokenValid('vote_'.$id, ...) in PHP.
  • Session cookies are Secure, HttpOnly, SameSite=Lax. The session id rotates on login and on privilege elevation.
  • The remember me cookie uses Symfony’s built in remember me handler with a 7 day lifetime, signed with APP_SECRET. The broken 2020 cookie format is gone.
  • The form firewall rate limits failed logins at 5 attempts per minute per IP through the Symfony rate limiter. In production, Authentik enforces the equivalent policy.

In production, account recovery is delegated to Authentik. The “forgot password” flow in the legacy tree (forget.php, modify_password.php, reset.php) is deleted as part of the authentication migration step documented on the Symfony Migration page.

In development and on the VPS, the seeded admin and user accounts have known credentials documented in .env.example. There is no password reset flow on those environments because they are throwaway.

Every privileged action (admin login, role change, video deletion, user ban) emits a domain event consumed by the audit log writer in App\Moderation\Infrastructure\Audit. The audit log writes to a dedicated audit_log PostgreSQL table with the schema (id, occurred_at, actor_sub, actor_username, action, target_type, target_id, payload JSONB, ip_address). Records are append only; deletion is reserved to a manual database operation under change control.

The audit log is shipped to Loki through Promtail and indexed in OpenSearch for cross service correlation as documented on the Observability page.

  • The Authentik tenant configuration itself, including authentication factors, group mappings and admin role definitions. That belongs to the homelab platform documentation.
  • The OpenBao KV layout for application secrets. See the Secret Management page.
  • The network policies that gate the OIDC traffic between the YourBOX pod and the Authentik pod. See the Kubernetes GitOps page.