mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-03 02:39:47 -05:00
5c11010095
- Add AUTH_METHOD switch (local | oidc | both); default remains local - Update login UI to conditionally show SSO button and/or local form - Add Authlib and initialize OAuth client (discovery-based) in app factory - Implement OIDC Authorization Code flow with PKCE: - GET /login/oidc → starts auth flow, preserves `next` - GET /auth/oidc/callback → exchanges code, parses ID token, fetches userinfo - Maps claims to username/full_name/email; admin mapping via group/email - Logs user in and redirects to intended page - Add optional OIDC end-session on logout (falls back gracefully if unsupported) - Extend User model with `email`, `oidc_issuer`, `oidc_sub` and unique constraint - Add Alembic migration 015 (adds columns, index, unique constraint) - Update env.example with OIDC variables and AUTH_METHOD - Add docs/OIDC_SETUP.md with provider-agnostic setup guide and examples - fix: remove invalid walrus usage in OIDC client registration Migration: - Run database migrations (e.g., `flask db upgrade`) to apply revision 015 Config: - AUTH_METHOD=local|oidc|both - OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_REDIRECT_URI - OIDC_SCOPES (default: "openid profile email") - OIDC_USERNAME_CLAIM, OIDC_FULL_NAME_CLAIM, OIDC_EMAIL_CLAIM, OIDC_GROUPS_CLAIM - OIDC_ADMIN_GROUP (optional), OIDC_ADMIN_EMAILS (optional) - OIDC_POST_LOGOUT_REDIRECT_URI (optional) Routes: - /login (respects AUTH_METHOD), /login/oidc, /auth/oidc/callback, /logout Docs: - See docs/OIDC_SETUP.md for full setup, provider notes, and troubleshooting
82 lines
2.3 KiB
Python
82 lines
2.3 KiB
Python
"""add OIDC fields to users
|
|
|
|
Revision ID: 015
|
|
Revises: 014
|
|
Create Date: 2025-10-05 00:00:00
|
|
"""
|
|
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision = '015'
|
|
down_revision = '014'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
|
return column_name in [col['name'] for col in inspector.get_columns(table_name)]
|
|
|
|
|
|
def _has_constraint(inspector, table_name: str, constraint_name: str) -> bool:
|
|
try:
|
|
constraints = inspector.get_unique_constraints(table_name)
|
|
return any(c.get('name') == constraint_name for c in constraints)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def upgrade() -> None:
|
|
bind = op.get_bind()
|
|
inspector = sa.inspect(bind)
|
|
|
|
# Ensure users table exists
|
|
if 'users' not in inspector.get_table_names():
|
|
return
|
|
|
|
# Add columns if missing
|
|
if not _has_column(inspector, 'users', 'email'):
|
|
op.add_column('users', sa.Column('email', sa.String(length=200), nullable=True))
|
|
try:
|
|
op.create_index('ix_users_email', 'users', ['email'], unique=False)
|
|
except Exception:
|
|
pass
|
|
|
|
if not _has_column(inspector, 'users', 'oidc_sub'):
|
|
op.add_column('users', sa.Column('oidc_sub', sa.String(length=255), nullable=True))
|
|
|
|
if not _has_column(inspector, 'users', 'oidc_issuer'):
|
|
op.add_column('users', sa.Column('oidc_issuer', sa.String(length=255), nullable=True))
|
|
|
|
# Add composite unique constraint if not present
|
|
if not _has_constraint(inspector, 'users', 'uq_users_oidc_issuer_sub'):
|
|
try:
|
|
op.create_unique_constraint('uq_users_oidc_issuer_sub', 'users', ['oidc_issuer', 'oidc_sub'])
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def downgrade() -> None:
|
|
bind = op.get_bind()
|
|
inspector = sa.inspect(bind)
|
|
|
|
if 'users' not in inspector.get_table_names():
|
|
return
|
|
|
|
# Drop unique constraint if exists
|
|
try:
|
|
op.drop_constraint('uq_users_oidc_issuer_sub', 'users', type_='unique')
|
|
except Exception:
|
|
pass
|
|
|
|
# Drop columns if exist (order doesn't matter)
|
|
for col in ['oidc_issuer', 'oidc_sub', 'email']:
|
|
if _has_column(inspector, 'users', col):
|
|
try:
|
|
op.drop_column('users', col)
|
|
except Exception:
|
|
pass
|
|
|