Skip to content

Commit 99e86b6

Browse files
Add OIDC authentication support with multi-provider configuration (#691)
* Add comprehensive OIDC integration with role mapping - Add OIDC authentication endpoints (login, callback, config) - Implement flexible role mapping from OIDC groups/roles to Gramps roles - Add configurable OIDC scopes and role claim sources - Support environment-driven configuration with GRAMPSWEB_ prefix - Add fallback to guest role when no role claims found - Include option to disable local authentication when OIDC enabled - Add auto-redirect functionality for OIDC-only deployments - Update token endpoint to respect OIDC authentication settings - Add comprehensive test suite for OIDC functionality - Add Authlib dependency for OpenID Connect support * Enhance OIDC integration: configurable username claims, flexible role management, security improvements - Add OIDC_USERNAME_CLAIM config for customizable username extraction - Implement optional role mapping: preserve existing roles when no OIDC_GROUP_* env vars set - Remove client_id exposure from public OIDC config endpoint - Upgrade Authlib dependency to 1.6.4 for security fixes and improvements * Implement multi-provider OIDC support with auto-detection - Add support for Google, Microsoft, and GitHub built-in providers - Auto-detect available providers from environment variables pattern OIDC_{PROVIDER}_CLIENT_ID - Remove dependency on primary provider, make all providers truly optional - Add provider-specific endpoints with validation and routing - Implement username prefixing to prevent conflicts between providers - Update configuration API to return list of available providers - Handle GitHub OAuth 2.0 flow alongside standard OIDC providers - Smart auto-redirect only when single provider is configured * Remove unnecessary gunicorn dependency The gunicorn package is not required for OIDC functionality. The project already uses waitress as its production WSGI server. The wsgi.py file supports gunicorn logging if available, but doesn't require it as a dependency. * Fix Flask application context issues in OIDC provider detection - Add app parameter to get_available_oidc_providers() function - Add app parameter to get_provider_config() function - Use Flask app config instead of os.getenv() for configuration access - Update init_oidc() to pass app instance to provider functions - Fixes RuntimeError when accessing current_app during app initialization This resolves the 'Working outside of application context' errors and enables proper OIDC provider detection during Flask startup. * Fix built-in OIDC providers to use Flask config instead of os.getenv * Add configurable custom OIDC provider name - Allow setting custom display name via GRAMPSWEB_OIDC_NAME environment variable - Fallback to 'OIDC' if no custom name is provided - Add OIDC configuration documentation to README * Move OIDC documentation to gramps-web-docs Removes OIDC configuration documentation from README.md as it should be documented in the main gramps-web-docs repository instead. * Update apispec.yaml with OIDC endpoints Adds OpenAPI documentation for the new OIDC authentication endpoints: - /oidc/config: Get OIDC configuration and available providers - /oidc/login/: Initiate OIDC login flow with provider - /oidc/callback/: Handle OIDC callback and create JWT tokens Includes schema definitions for OIDCConfig and OIDCProvider. * Addressed main concerns of @DavidMStraub's review - Fixed copyright headers to show only Alexander Bocken as author - Changed HTTP status codes from 404 to 405 for disabled OIDC endpoints - Updated error logging to use logger.exception() for better stack traces - Fixed module docstring from "blueprint" to "resources" - Moved current_app imports to top-level instead of local imports - Removed GitHub email fallback that generated fake noreply addresses - Updated log message to reference config options instead of env vars - Implemented FRONTEND_URL with BASE_URL fallback using get_config() - Added FRONTEND_URL to devcontainer environment for development - Added FRONTEND_URL to allowed database config keys - Removed default parameters from create_or_update_oidc_user function * Fix OIDC endpoint status codes from 404 to 405 in API spec * Improve OIDC token handover security with HTTP-only cookies Replaces insecure URL-based token passing with a secure cookie-based architecture: **Security Improvements:** - Tokens no longer exposed in browser URLs or history - HTTP-only cookies prevent XSS token access - Automatic cookie cleanup after token exchange - CORS credentials support for cross-origin cookie handling **Architecture Changes:** - Browser requests: Backend sets HTTP-only cookies → redirects to /oidc/complete - Frontend calls new /api/oidc/tokens/ endpoint to exchange cookies for localStorage tokens - API requests: Direct JSON response (backward compatible) - Development/production cookie security auto-detection **New Components:** - OIDCTokenExchangeResource: Secure cookie-to-token exchange endpoint - Enhanced CORS configuration with credentials support - Smart browser vs API request detection - Comprehensive error handling and logging The flow eliminates token exposure while maintaining compatibility with both browser and API clients. * Implement account source tracking and enhanced OIDC account matching - Add account_source field to user details showing authentication source - Create OIDC accounts table for secure account linking and tracking - Add database migration for OIDC accounts table structure - Enhance user API endpoints to include OIDC account information - Implement comprehensive account matching for multi-provider support - Update API specification to document new account_source field * Remove backwards compatibility from OIDC callback endpoint - Simplify OIDC callback to only handle browser redirects with cookies - Remove unnecessary API request detection and JSON response fallback - Update tests to verify redirect and cookie behavior instead of JSON - Improve OIDC standard compliance and security * Address @DavidMStraub's security and configuration concerns - Set GRAMPSWEB_FRONTEND_URL to empty by default in dev container - Change default OIDC user role from ROLE_GUEST to ROLE_DISABLED - Add email notification to admins for new OIDC users requiring approval This prevents automatic guest access for all users with external accounts and ensures proper admin oversight of new registrations. * add myself to copyright for files with signifcant changes, fix README newline * Improve OIDC authentication implementation and security Key changes: • Align OIDC user experience with local registration flow • Remove email matching feature to prevent privilege escalation across trees • Remove unnecessary last_login field from oidc_accounts table • Modernize Flask response handling (remove make_response wrapper) • Fix security vulnerability: ROLE_DISABLED users now get confirmation page instead of tokens • Improve code organization: move all imports to top-level • Extract development environment detection to utility function • Remove sensitive information from logging (no longer log all OIDC claims) • Use current_app.debug instead of environment variable checking • Simplify database schema by removing unused tracking fields Security improvements: • Prevent cross-tree privilege escalation via email matching • Default new OIDC users to ROLE_DISABLED requiring admin approval • Block token generation for disabled accounts • Remove potential PII exposure in debug logs Code quality improvements: • Consolidate duplicate environment detection logic • Organize imports consistently across OIDC modules • Remove unused functions and database fields • Modernize Flask patterns and response handling * Update gramps_webapi/api/resources/oidc.py Co-authored-by: David Straub <[email protected]> * Refactor: Extract "custom" provider ID to constant Replace hardcoded "custom" string with PROVIDER_CUSTOM constant for better maintainability and consistency throughout the OIDC authentication module. * Refactor and expand OIDC unit tests - Convert test_oidc_auth.py from unittest to pytest style - Remove if __name__ == "__main__" blocks (not needed for pytest) - Fix outdated function reference: get_role_from_groups -> get_role_from_claims - Add comprehensive test coverage for new OIDC features: * Multi-provider support (Google, Microsoft, GitHub, custom) * get_available_oidc_providers() function * get_provider_config() function * PROVIDER_CUSTOM constant usage * Custom and nested role claim support (e.g., realm_access.roles) * OIDC account table lookups via sub claim mapping * Username generation logic for different providers * Username conflict resolution with counter suffix * Role preservation when no mapping configured - Improve test organization with dedicated test classes per function - Add error handling tests for missing sub claim and invalid providers * Fix circular imports and update OIDC tests - Create oidc_helpers.py to break circular import chain * Moved is_oidc_enabled() to separate module with no api imports * Updated token.py, user.py, and oidc.py to import from oidc_helpers - Use lazy imports in oidc.py for api module functions * Import run_task, send_email_new_user, get_tree_id inside function * Added comment explaining this intentional exception to top-level import standard - Fix pre-existing syntax errors in oidc.py callback validation * Added missing if-statement body for invalid tree check * Fixed invalid '!tree' syntax to 'not tree' - Update OIDC unit tests to work without Flask app context * Mock current_app with context managers instead of decorators * Mock get_tree_id and run_task to avoid database/task dependencies * Remove run_task patches (no longer at module level) * Fix test parameter ordering after removing current_app patches All 34 OIDC unit tests now passing successfully. * Fix OIDC_AUTO_REDIRECT default to False Change default from True to False to match expected behavior where users should explicitly opt-in to auto-redirecting to OIDC login. * Add OIDC logout and backchannel logout support This commit implements comprehensive OIDC logout functionality including frontend SSO logout and OpenID Connect Back-Channel Logout support. Backend changes: - Add token blocklist system for JWT revocation (token_blocklist.py) - Add /api/oidc/logout/ endpoint to retrieve provider logout URLs - Add /api/oidc/backchannel-logout/ endpoint for OIDC Back-Channel Logout - Include oidc_provider claim in JWT tokens to track auth method - Integrate token blocklist with JWT manager for revocation checks - Fix OIDC_AUTO_REDIRECT default to False API changes: - Update apispec.yaml with new logout endpoint documentation - Add proper error handling and graceful degradation Tests: - Add 17 comprehensive tests for logout functionality - Test OIDC logout endpoint (disabled, invalid provider, missing client) - Test backchannel logout validation (all OIDC spec requirements) - Test token blocklist operations and cleanup - Test OIDC provider claim in tokens - All tests passing (17/17) * Fix OIDC server metadata URL construction and loading Fixed two issues preventing OIDC authentication from working: 1. Fixed server_metadata_url construction: When OIDC_OPENID_CONFIG_URL is not set, it now properly falls back to constructing the URL from the issuer. The previous .get() with default was not working because None is a valid value that prevented the fallback. 2. Added explicit metadata loading at startup: Call load_server_metadata() immediately after registration to ensure the authorize_url and other endpoints are available when needed, preventing 'Missing authorize_url' runtime errors. * Fix type annotation for oidc_provider parameter * Fix OIDC tests for Python 3.13 compatibility and update test assertions * Fix OIDC and JWT authentication tests - Import get_tokens from correct module (api.resources.token) - Patch is_oidc_enabled at usage locations, not definition - Use app.config instead of environment variables for runtime config - Fix expected status codes (405 for disabled OIDC endpoints) - Fix error response JSON structure assertions * last unit test fix, I swear * Add issue templates * Unpin sentence transformers (#695) --------- Co-authored-by: David Straub <[email protected]>
1 parent a8329f2 commit 99e86b6

File tree

19 files changed

+2956
-10
lines changed

19 files changed

+2956
-10
lines changed

.devcontainer/docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ services:
2727
GRAMPSWEB_CELERY_CONFIG__broker_url: redis://redis:6379/0
2828
GRAMPSWEB_CELERY_CONFIG__result_backend: redis://redis:6379/0
2929
GRAMPSWEB_LOG_LEVEL: DEBUG
30+
GRAMPSWEB_FRONTEND_URL: ""
3031

3132
redis:
3233
image: valkey/valkey:alpine
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""add oidc_accounts table for secure provider_id and subject_id mapping
2+
3+
Revision ID: 2082445b0769
4+
Revises: a8e57fe0d82e
5+
Create Date: 2025-09-28 17:21:49.676157
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
from gramps_webapi.auth.sql_guid import GUID
11+
12+
13+
# revision identifiers, used by Alembic.
14+
revision = '2082445b0769'
15+
down_revision = 'a8e57fe0d82e'
16+
branch_labels = None
17+
depends_on = None
18+
19+
20+
def upgrade():
21+
# Create oidc_accounts table for secure provider_id and subject_id mapping
22+
op.create_table(
23+
'oidc_accounts',
24+
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
25+
sa.Column('user_id', GUID(), nullable=False),
26+
sa.Column('provider_id', sa.String(64), nullable=False),
27+
sa.Column('subject_id', sa.String(255), nullable=False),
28+
sa.Column('email', sa.String(255), nullable=True),
29+
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
30+
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
31+
sa.UniqueConstraint('provider_id', 'subject_id', name='uq_oidc_provider_subject'),
32+
sa.Index('ix_oidc_accounts_user_id', 'user_id'),
33+
sa.Index('ix_oidc_accounts_email', 'email'),
34+
)
35+
36+
37+
def downgrade():
38+
# Drop oidc_accounts table
39+
op.drop_table('oidc_accounts')

gramps_webapi/api/__init__.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@
9292
TokenRefreshResource,
9393
TokenResource,
9494
)
95+
from .resources.oidc import (
96+
OIDCBackchannelLogoutResource,
97+
OIDCCallbackResource,
98+
OIDCConfigResource,
99+
OIDCLoginResource,
100+
OIDCLogoutResource,
101+
OIDCTokenExchangeResource,
102+
)
95103
from .resources.transactions import TransactionsResource
96104
from .resources.translations import TranslationResource, TranslationsResource
97105
from .resources.trees import (
@@ -148,6 +156,13 @@ def register_endpt(resource: Type[Resource], url: str, name: str):
148156
register_endpt(TokenResource, "/token/", "token")
149157
register_endpt(TokenRefreshResource, "/token/refresh/", "token_refresh")
150158
register_endpt(TokenCreateOwnerResource, "/token/create_owner/", "token_create_owner")
159+
# OIDC
160+
register_endpt(OIDCLoginResource, "/oidc/login/", "oidcloginresource")
161+
register_endpt(OIDCCallbackResource, "/oidc/callback/", "oidccallbackresource")
162+
register_endpt(OIDCConfigResource, "/oidc/config/", "oidcconfigresource")
163+
register_endpt(OIDCTokenExchangeResource, "/oidc/tokens/", "oidctokenexchangeresource")
164+
register_endpt(OIDCLogoutResource, "/oidc/logout/", "oidclogoutresource")
165+
register_endpt(OIDCBackchannelLogoutResource, "/oidc/backchannel-logout/", "oidcbackchannellogoutresource")
151166
# People
152167
register_endpt(
153168
PersonTimelineResource, "/people/<string:handle>/timeline", "person-timeline"

0 commit comments

Comments
 (0)