feat(auth): Authentik OIDC sign-in + Gravatar avatars
Adds optional SSO via Authentik (or any OIDC provider) alongside the existing password flow, and pulls profile images from the provider's `picture` claim or Gravatar so the sharing UI stops looking anonymous. Password login stays available as a recovery path; JIT provisioning and admin-group mapping are env-configurable. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
83
backend/alembic/versions/0015_oidc_and_avatar.py
Normal file
83
backend/alembic/versions/0015_oidc_and_avatar.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""OIDC identity + avatar / display_name on users
|
||||
|
||||
Revision ID: 0015_oidc_and_avatar
|
||||
Revises: 0014_share_status
|
||||
Create Date: 2026-04-22
|
||||
|
||||
Lets users sign in via an OIDC provider (Authentik) and carry a profile
|
||||
image / display name from the provider. Password-only users are
|
||||
unaffected.
|
||||
|
||||
1. Add users.oidc_issuer, users.oidc_sub (identity pair from the IdP).
|
||||
2. Add users.avatar_url, users.display_name (profile bits from claims
|
||||
or manually set).
|
||||
3. Make users.hashed_password nullable — OIDC-only users have no local
|
||||
password. Existing rows all have hashes so the NULLability change
|
||||
is backwards-compatible.
|
||||
4. Partial unique index on (oidc_issuer, oidc_sub) WHERE oidc_sub IS
|
||||
NOT NULL so multiple password-only users (both NULL) don't collide.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "0015_oidc_and_avatar"
|
||||
down_revision: Union[str, None] = "0014_share_status"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1 + 2. Add new columns (idempotent — create_all on fresh installs
|
||||
# already built them from the model).
|
||||
for col_def in (
|
||||
"oidc_issuer VARCHAR",
|
||||
"oidc_sub VARCHAR",
|
||||
"avatar_url VARCHAR",
|
||||
"display_name VARCHAR",
|
||||
):
|
||||
conn.execute(sa.text(f"ALTER TABLE users ADD COLUMN IF NOT EXISTS {col_def}"))
|
||||
|
||||
# 3. Drop NOT NULL on hashed_password. Postgres only — SQLite can't
|
||||
# alter column nullability in place, but the SQLite escape hatch is
|
||||
# used for fresh local dev where create_all already wrote the new
|
||||
# nullable definition.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL"
|
||||
))
|
||||
|
||||
# 4. Partial unique index — Postgres supports the WHERE clause so
|
||||
# NULLs don't collide; SQLite treats NULLs as distinct in unique
|
||||
# indexes already, so a plain unique index is safe there too.
|
||||
if conn.dialect.name == "postgresql":
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub) WHERE oidc_sub IS NOT NULL"
|
||||
))
|
||||
else:
|
||||
conn.execute(sa.text(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ix_users_oidc_identity "
|
||||
"ON users (oidc_issuer, oidc_sub)"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
conn.execute(sa.text("DROP INDEX IF EXISTS ix_users_oidc_identity"))
|
||||
|
||||
if conn.dialect.name == "postgresql":
|
||||
# Can't re-apply NOT NULL if any OIDC-only user has NULL — so
|
||||
# only do it when safe.
|
||||
conn.execute(sa.text(
|
||||
"UPDATE users SET hashed_password = '' WHERE hashed_password IS NULL"
|
||||
))
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL"
|
||||
))
|
||||
|
||||
for col in ("display_name", "avatar_url", "oidc_sub", "oidc_issuer"):
|
||||
conn.execute(sa.text(f"ALTER TABLE users DROP COLUMN IF EXISTS {col}"))
|
||||
Reference in New Issue
Block a user