tududi/docs/feature-plans/00-oidc-sso.md
Chris ca77222eae
fix: resolve OIDC session loss and migration failures (#1023)
* fix: resolve OIDC session loss and migration failures

This commit fixes three critical issues affecting OIDC/SSO authentication:

1. Session Not Saved Before Redirect
   - Added explicit req.session.save() callback in OIDC callback handler
   - Ensures session is persisted before redirecting to /today
   - Prevents 401 errors after successful SSO authentication

2. Migration Resilience
   - Added DROP TABLE IF EXISTS users_new in migration
   - Prevents "table already exists" errors from failed migrations
   - Created cleanup script for orphaned migration tables

3. Trust Proxy Documentation
   - Documented TUDUDI_TRUST_PROXY requirement for reverse proxy deployments
   - Added troubleshooting guide for session loss issues
   - Updated .env.example with OIDC configuration examples

Fixes session loss when deployed behind reverse proxies (nginx, Traefik, etc.)

Changes:
- backend/modules/oidc/controller.js: Add session.save() before redirect
- backend/migrations/20260420000004-make-password-optional.js: Add DROP TABLE IF EXISTS
- backend/scripts/cleanup-failed-migration.js: New cleanup utility
- backend/.env.example: Add OIDC and trust proxy examples
- docs/10-oidc-sso.md: Add trust proxy configuration and troubleshooting
- docs/feature-plans/00-oidc-sso.md: Document required environment variables

* fix: prettier formatting in cleanup script
2026-04-14 07:53:55 +03:00

29 KiB

OIDC/SSO Implementation Plan for Tududi

GitHub Issue: #977 - Add SSO/OIDC Support for Enterprise Authentication

Context

Tududi currently only supports email/password authentication. This feature request adds OpenID Connect (OIDC) support to enable Single Sign-On via external identity providers (Google, Okta, Keycloak, Authentik, PocketID, etc.). This is a highly requested feature for both enterprise deployments and homelab users who standardize on SSO.

Key Requirements:

  • Support multiple OIDC providers configured via environment variables
  • Just-In-Time (JIT) user provisioning from OIDC claims
  • Account linking (connect OIDC to existing email/password accounts)
  • Hybrid authentication (users can choose email/password OR OIDC)
  • Simple .env-based configuration (self-hoster friendly)
  • Maintain backward compatibility with existing authentication

Community Interest: Users specifically mentioned PocketID support and requested this not be enterprise-gated.

Implementation Approach: Start with .env-based configuration for simplicity and faster delivery. Admin UI for provider management can be added in a future release if needed.


Implementation Summary

.env-Based Configuration

This implementation uses environment variables for OIDC provider configuration instead of database storage and admin UI.

Key Differences from Full Admin UI Approach:

Aspect .env Approach (This Plan) Admin UI Approach
Configuration Edit .env file, restart server Web UI, no restart needed
Tables 3 tables (identities, state, audit) 4 tables (+ providers table)
Timeline 15-19 days (3-4 weeks) 22-29 days (4-6 weeks)
Complexity Lower Higher
Target Audience Self-hosters with shell access Non-technical admins
Secret Storage .env plaintext (standard practice) Database with AES-256-GCM
Provider Limit Practical for 1-5 providers Scales to 10+ providers
Migration Path Can add admin UI later N/A

Why This Approach:

  • Faster delivery: Ship OIDC 7-10 days sooner
  • Simpler codebase: Less code to maintain
  • Familiar pattern: Self-hosters already edit .env for DB, SMTP, etc.
  • Sufficient for MVP: Most users need 1-2 providers
  • Clear upgrade path: Can always add UI later

Trade-offs:

  • ⚠️ Requires server restart to change providers
  • ⚠️ Requires shell/file access (not web-based)
  • ⚠️ No per-provider enable/disable toggle

Database Schema Changes

1. New Tables

CREATE TABLE oidc_identities (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider_slug STRING NOT NULL,           -- Matches slug from .env (e.g., "google", "okta")

  -- OIDC Claims
  subject STRING NOT NULL,                 -- Provider's unique user ID
  email STRING,
  name STRING,
  given_name STRING,
  family_name STRING,
  picture STRING,

  -- Metadata
  raw_claims JSON,
  first_login_at DATETIME,
  last_login_at DATETIME,

  created_at DATETIME,
  updated_at DATETIME,

  UNIQUE(provider_slug, subject)
);

CREATE INDEX idx_identities_user ON oidc_identities(user_id);
CREATE INDEX idx_identities_provider_slug ON oidc_identities(provider_slug);
CREATE INDEX idx_identities_email ON oidc_identities(email);

Migration: 20260420000001-create-oidc-identities.js

oidc_state_nonces - Temporary OAuth state validation (CSRF protection)

CREATE TABLE oidc_state_nonces (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  state STRING UNIQUE NOT NULL,
  nonce STRING NOT NULL,
  provider_slug STRING NOT NULL,           -- Matches slug from .env
  code_verifier STRING,                    -- For PKCE (future)
  redirect_uri STRING,
  expires_at DATETIME NOT NULL,            -- 10 minute TTL
  created_at DATETIME
);

CREATE INDEX idx_state_nonces_state ON oidc_state_nonces(state);
CREATE INDEX idx_state_nonces_expires ON oidc_state_nonces(expires_at);

Migration: 20260420000002-create-oidc-state-nonces.js

auth_audit_log - Security audit trail (Optional)

CREATE TABLE auth_audit_log (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  event_type STRING NOT NULL,              -- login_success, login_failed, logout, oidc_linked, oidc_unlinked
  auth_method STRING NOT NULL,             -- email_password, oidc, api_token
  provider_slug STRING,                    -- OIDC provider slug (if applicable)
  ip_address STRING,
  user_agent STRING,
  metadata JSON,
  created_at DATETIME
);

CREATE INDEX idx_audit_user ON auth_audit_log(user_id);
CREATE INDEX idx_audit_event ON auth_audit_log(event_type);
CREATE INDEX idx_audit_created ON auth_audit_log(created_at);

Migration: 20260420000003-create-auth-audit-log.js (optional, can be added later)

2. User Model Changes

Make password optional for OIDC-only users:

// backend/models/user.js
password_digest: {
    type: DataTypes.STRING,
    allowNull: true,  // Changed from false
    field: 'password_digest',
}

Add validation: Users must have either password_digest OR at least one oidc_identity.

Migration: 20260420000004-make-password-optional.js


Backend Implementation

Module Structure

Create new OIDC module at /backend/modules/oidc/:

backend/modules/oidc/
├── index.js                   # Module exports
├── routes.js                  # Express routes
├── controller.js              # HTTP handlers
├── service.js                 # Core OIDC flow (openid-client)
├── providerConfig.js          # Load providers from .env
├── oidcIdentityService.js     # Identity linking/unlinking
├── stateManager.js            # State/nonce management
├── provisioningService.js     # JIT user provisioning
└── auditService.js            # Auth event logging (optional)

Key Services

1. providerConfig.js - Load Providers from Environment

Purpose: Parse and validate OIDC provider configuration from .env

Methods:

  • loadProvidersFromEnv() → array of provider configs
  • getProvider(slug) → single provider config
  • getAllProviders() → all enabled providers

Environment Variables:

Single Provider:

OIDC_ENABLED=true
OIDC_PROVIDER_NAME=Google
OIDC_PROVIDER_SLUG=google
OIDC_ISSUER_URL=https://accounts.google.com
OIDC_CLIENT_ID=xxx
OIDC_CLIENT_SECRET=xxx
OIDC_SCOPE=openid profile email
OIDC_AUTO_PROVISION=true
OIDC_ADMIN_EMAIL_DOMAINS=example.com,company.com

Multiple Providers (Numbered):

OIDC_ENABLED=true

# Provider 1
OIDC_PROVIDER_1_NAME=Google
OIDC_PROVIDER_1_SLUG=google
OIDC_PROVIDER_1_ISSUER=https://accounts.google.com
OIDC_PROVIDER_1_CLIENT_ID=xxx
OIDC_PROVIDER_1_CLIENT_SECRET=xxx
OIDC_PROVIDER_1_SCOPE=openid profile email
OIDC_PROVIDER_1_AUTO_PROVISION=true

# Provider 2
OIDC_PROVIDER_2_NAME=Okta
OIDC_PROVIDER_2_SLUG=okta
OIDC_PROVIDER_2_ISSUER=https://company.okta.com
OIDC_PROVIDER_2_CLIENT_ID=yyy
OIDC_PROVIDER_2_CLIENT_SECRET=yyy
OIDC_PROVIDER_2_ADMIN_EMAIL_DOMAINS=company.com

Implementation:

function loadProvidersFromEnv() {
  if (process.env.OIDC_ENABLED !== 'true') {
    return [];
  }

  const providers = [];

  // Try numbered providers (OIDC_PROVIDER_1_*, OIDC_PROVIDER_2_*, ...)
  let i = 1;
  while (process.env[`OIDC_PROVIDER_${i}_NAME`]) {
    providers.push({
      slug: process.env[`OIDC_PROVIDER_${i}_SLUG`],
      name: process.env[`OIDC_PROVIDER_${i}_NAME`],
      issuer: process.env[`OIDC_PROVIDER_${i}_ISSUER`],
      clientId: process.env[`OIDC_PROVIDER_${i}_CLIENT_ID`],
      clientSecret: process.env[`OIDC_PROVIDER_${i}_CLIENT_SECRET`],
      scope: process.env[`OIDC_PROVIDER_${i}_SCOPE`] || 'openid profile email',
      autoProvision: process.env[`OIDC_PROVIDER_${i}_AUTO_PROVISION`] !== 'false',
      adminEmailDomains: parseCommaSeparated(
        process.env[`OIDC_PROVIDER_${i}_ADMIN_EMAIL_DOMAINS`]
      ),
    });
    i++;
  }

  // Fallback to single provider
  if (providers.length === 0 && process.env.OIDC_PROVIDER_NAME) {
    providers.push({
      slug: process.env.OIDC_PROVIDER_SLUG || 'default',
      name: process.env.OIDC_PROVIDER_NAME,
      issuer: process.env.OIDC_ISSUER_URL,
      clientId: process.env.OIDC_CLIENT_ID,
      clientSecret: process.env.OIDC_CLIENT_SECRET,
      scope: process.env.OIDC_SCOPE || 'openid profile email',
      autoProvision: process.env.OIDC_AUTO_PROVISION !== 'false',
      adminEmailDomains: parseCommaSeparated(
        process.env.OIDC_ADMIN_EMAIL_DOMAINS
      ),
    });
  }

  return providers;
}

2. service.js - Core OIDC Flow

Purpose: Handle OAuth 2.0 authorization code flow using openid-client library

Dependency: npm install openid-client@^6.2.0

Methods:

  • discoverProvider(issuerUrl) → cached OIDC metadata
  • initiateAuthFlow(providerSlug, req) → authorization URL
  • handleCallback(providerSlug, code, state) → user + tokens
  • validateIdToken(idToken, nonce, issuer) → claims

Flow:

  1. Initiate: Load provider from .env, generate state/nonce, store in DB, redirect to provider
  2. Callback: Validate state, exchange code for tokens, validate JWT
  3. Provision: Create or link user, update claims
  4. Session: Set req.session.userId (integrates with existing auth)

Key Implementation:

const { Issuer } = require('openid-client');
const providerConfig = require('./providerConfig');

async function initiateAuthFlow(providerSlug, req) {
  const config = providerConfig.getProvider(providerSlug);
  if (!config) throw new Error('Provider not found');

  const issuer = await Issuer.discover(config.issuer);
  const client = new issuer.Client({
    client_id: config.clientId,
    client_secret: config.clientSecret,
    redirect_uris: [`${process.env.BASE_URL}/api/oidc/callback/${providerSlug}`],
    response_types: ['code'],
  });

  const { state, nonce } = await stateManager.createState(providerSlug);

  const authUrl = client.authorizationUrl({
    scope: config.scope,
    state,
    nonce,
  });

  return authUrl;
}

3. provisioningService.js - JIT User Creation

Purpose: Auto-create or link users from OIDC claims

Logic:

  1. Check if oidc_identity exists (provider_slug + subject)
    • Exists: Update last_login_at, return user
  2. Check if user with email exists
    • Exists + auto_provision: Link identity to user
    • Not exists + auto_provision: Create new user (no password)
  3. Apply admin rules from .env (email domain matching)
  4. Store claims in oidc_identities

Implementation:

async function provisionUser(providerSlug, claims) {
  const config = providerConfig.getProvider(providerSlug);

  // Check existing identity
  let identity = await OIDCIdentity.findOne({
    where: { provider_slug: providerSlug, subject: claims.sub }
  });

  if (identity) {
    await identity.update({ last_login_at: new Date() });
    return await User.findByPk(identity.user_id);
  }

  // Check if auto-provision is enabled
  if (!config.autoProvision) {
    throw new Error('Auto-provisioning disabled for this provider');
  }

  // Find or create user
  let user = await User.findOne({ where: { email: claims.email } });

  if (!user) {
    // Create new user (no password)
    user = await User.create({
      email: claims.email,
      username: claims.email.split('@')[0],
      verified_email: true,  // Trust OIDC provider
      is_admin: shouldBeAdmin(config, claims.email),
    });
  }

  // Link identity
  await OIDCIdentity.create({
    user_id: user.id,
    provider_slug: providerSlug,
    subject: claims.sub,
    email: claims.email,
    name: claims.name,
    picture: claims.picture,
    raw_claims: claims,
    first_login_at: new Date(),
    last_login_at: new Date(),
  });

  return user;
}

function shouldBeAdmin(config, email) {
  if (!config.adminEmailDomains || config.adminEmailDomains.length === 0) {
    return false;
  }
  const domain = email.split('@')[1];
  return config.adminEmailDomains.includes(domain);
}

4. stateManager.js - OAuth State Management

Purpose: CSRF protection via state/nonce with 10-minute TTL

Methods:

  • createState(providerSlug){ state, nonce }
  • validateState(state){ nonce, providerSlug }
  • consumeState(state) → delete record (one-time use)

Implementation:

const crypto = require('crypto');
const { OIDCStateNonce } = require('../../models');

async function createState(providerSlug) {
  const state = crypto.randomBytes(32).toString('hex');
  const nonce = crypto.randomBytes(32).toString('hex');

  await OIDCStateNonce.create({
    state,
    nonce,
    provider_slug: providerSlug,
    expires_at: new Date(Date.now() + 10 * 60 * 1000), // 10 minutes
  });

  return { state, nonce };
}

async function validateState(state) {
  const record = await OIDCStateNonce.findOne({ where: { state } });

  if (!record) {
    throw new Error('Invalid state parameter');
  }

  if (new Date() > record.expires_at) {
    throw new Error('State expired');
  }

  return {
    nonce: record.nonce,
    providerSlug: record.provider_slug,
  };
}

async function consumeState(state) {
  await OIDCStateNonce.destroy({ where: { state } });
}

Routes

// Public routes
GET  /api/oidc/providers                    // List enabled providers from .env
GET  /api/oidc/auth/:slug                   // Initiate OIDC flow (redirects)
GET  /api/oidc/callback/:slug               // OAuth callback handler

// Authenticated routes
POST   /api/oidc/link/:slug                 // Link OIDC to current user
DELETE /api/oidc/unlink/:identityId         // Unlink OIDC identity
GET    /api/oidc/identities                 // List user's OIDC identities

Note: No admin routes needed - configuration is done via .env file.

Integration with Existing Auth

Key insight: No changes needed to /backend/middleware/auth.js!

OIDC callback creates standard session: req.session.userId = user.id

Existing middleware already supports this pattern, so OIDC users work seamlessly.

Auth Service Updates

Update /backend/modules/auth/service.js login method:

async login(email, password, session) {
    // ... existing validation ...

    const user = await User.findOne({ where: { email } });
    if (!user) {
        throw new UnauthorizedError('Invalid credentials');
    }

    // NEW: Check if OIDC-only user (no password)
    if (!user.password_digest) {
        throw new UnauthorizedError(
            'This account uses SSO. Please sign in with your SSO provider.'
        );
    }

    // ... rest of password validation ...
}

Frontend Implementation

1. Login Page Modifications

File: /frontend/components/Login.tsx

Changes:

  1. Fetch enabled providers on mount: GET /api/oidc/providers
  2. Render provider buttons above email/password form
  3. Add divider: "Or continue with email"

New Component: /frontend/components/Auth/OIDCProviderButtons.tsx

interface OIDCProvider {
  slug: string;
  name: string;
  button_text: string;
  button_icon_url?: string;
  type: string;
}

const OIDCProviderButtons: React.FC<{ providers: OIDCProvider[] }> = ({ providers }) => {
  const handleProviderClick = (slug: string) => {
    // Redirect to initiate OIDC flow
    window.location.href = `/api/oidc/auth/${slug}`;
  };

  return (
    <div className="oidc-providers space-y-3 mb-6">
      {providers.map(provider => (
        <button
          key={provider.slug}
          onClick={() => handleProviderClick(provider.slug)}
          className="w-full flex items-center justify-center gap-3 px-4 py-2 border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800"
        >
          {provider.button_icon_url && (
            <img src={provider.button_icon_url} className="w-5 h-5" />
          )}
          {provider.button_text.replace('{name}', provider.name)}
        </button>
      ))}
    </div>
  );
};

2. OAuth Callback Handler

File: /frontend/components/Auth/OIDCCallback.tsx Route: /auth/callback/:provider

Shows loading state while backend processes callback. Backend redirects to /today on success or /login?error=message on failure.

3. Profile Settings - Connected Accounts

File: /frontend/components/Profile/tabs/SecurityTab.tsx

Add new section: "Connected Accounts"

Features:

  • List linked OIDC identities (provider, email, linked date)
  • "Link {Provider}" buttons for available providers
  • "Unlink" button for each identity
  • Validation: Cannot unlink last auth method if no password set

APIs:

  • GET /api/oidc/identities - Fetch user's identities
  • POST /api/oidc/link/:provider - Initiate linking
  • DELETE /api/oidc/unlink/:identityId - Remove identity

Security Considerations

1. Secret Storage

Location: .env file (plaintext) Rationale:

  • Consistent with existing secrets (DB password, session secret, API keys)
  • Self-hosted deployments already secure .env files
  • Simpler than database encryption
  • Standard practice for environment-based configuration

Best Practices:

  • Never commit .env to version control (already in .gitignore)
  • Use proper file permissions (600 on Linux/macOS)
  • Use Docker secrets or Kubernetes secrets in production

2. CSRF Protection

State parameter: 32-byte cryptographically random string

  • Stored in DB with 10-minute TTL
  • Validated on callback
  • Consumed after use (one-time only)

3. Replay Protection

Nonce: 32-byte random string included in ID token validation

  • Prevents token reuse
  • Validated by openid-client library

4. JWT Validation

Use openid-client for automatic:

  • JWKS (JSON Web Key Set) fetching from provider
  • Signature validation using provider's public key
  • Issuer, audience, expiration verification
  • Nonce validation

5. Rate Limiting

Apply existing limiters:

  • OIDC auth/callback: 5 requests per 15 minutes per IP (authLimiter)
  • User linking/unlinking: authenticatedApiLimiter

6. Audit Logging (Optional)

Log all authentication events:

  • Login success/failure
  • OIDC linking/unlinking
  • Provider creation/deletion
  • Include: user ID, IP, user agent, timestamp

Implementation Steps

Phase 1: Database & Models (2 days)

  1. Create oidc_identities migration and model
  2. Create oidc_state_nonces migration and model
  3. Create migration to make password_digest nullable
  4. Update User model validation for password-optional users
  5. Add model associations in /backend/models/index.js
  6. (Optional) Create auth_audit_log migration and model

Testing: Unit tests for models and validation rules

Phase 2: Backend Core Services (3-4 days)

  1. Install openid-client dependency
  2. Implement providerConfig.js (load from .env)
  3. Implement stateManager.js (state lifecycle)
  4. Implement auditService.js (event logging, optional)

Testing: Unit tests for each service

Phase 3: OIDC Authentication Flow (4-5 days)

  1. Implement service.js (discovery, auth flow, callback)
  2. Implement provisioningService.js (JIT provisioning logic)
  3. Implement oidcIdentityService.js (linking/unlinking)
  4. Implement controller.js and routes.js
  5. Update auth service to handle OIDC-only users
  6. Add routes to Express app

Testing: Integration tests with mock OIDC provider

Phase 4: Frontend Login Flow (2-3 days)

  1. Create OIDCProviderButtons component
  2. Update Login.tsx to fetch and display providers
  3. Create OIDCCallback.tsx component
  4. Add callback route to App.tsx
  5. Add i18n translations for OIDC UI

Testing: E2E tests with Playwright (mock provider)

Phase 5: Frontend Account Linking (2-3 days)

  1. Create "Connected Accounts" section in SecurityTab
  2. Implement link/unlink flows
  3. Add validation for last auth method
  4. Add confirmation dialogs

Testing: E2E tests for linking workflows

Phase 6: Documentation & Polish (2 days)

  1. Create /docs/10-oidc-sso.md (user guide)
  2. Update README with .env configuration examples
  3. Add provider-specific setup guides (Google, Okta, Authentik, PocketID)
  4. Add i18n for all UI text
  5. Full regression testing

Total Estimated Time: 15-19 days (3-4 weeks)


Future: Admin UI (Optional Phase 7)

If .env configuration proves limiting, a future release can add admin UI:

Database Migration

  • Create oidc_providers table
  • Add migration script to import .env → database
  • Keep .env as fallback if table is empty

Admin UI Features

  • /admin/oidc-providers page
  • Provider CRUD operations
  • Enable/disable toggle
  • Test connection button
  • Audit log viewer

Estimated Additional Time: 3-4 days

This keeps the initial release simple while providing a clear upgrade path.


Testing Strategy

Unit Tests

  • Models: Validation rules, nullable password, composite unique constraints
  • Services: Encryption, state management, JWT validation, provisioning logic

Integration Tests

  • OIDC Flow: Initiate → callback → provision user (with mock provider)
  • Account Linking: Link to existing user, prevent duplicates
  • Admin Operations: CRUD providers, secret encryption

E2E Tests (Playwright)

  • Login: Click provider button → mock OIDC → callback → logged in
  • Linking: Email/password user links OIDC account
  • Admin: Create provider, enable/disable, delete

Security Tests

  • CSRF: Invalid state rejected
  • Replay: Reused state rejected
  • JWT Tampering: Invalid signature rejected
  • Expired State: Old state rejected

Configuration

Environment Variables

Option 1: Single Provider (Simplest)

# Enable OIDC
OIDC_ENABLED=true

# Provider Configuration
OIDC_PROVIDER_NAME=Google
OIDC_PROVIDER_SLUG=google
OIDC_ISSUER_URL=https://accounts.google.com
OIDC_CLIENT_ID=your-client-id.apps.googleusercontent.com
OIDC_CLIENT_SECRET=your-client-secret
OIDC_SCOPE=openid profile email

# Auto-provisioning
OIDC_AUTO_PROVISION=true
OIDC_ADMIN_EMAIL_DOMAINS=example.com,mycompany.com

# Optional Settings
OIDC_STATE_TTL_MINUTES=10
OIDC_JWKS_CACHE_TTL_SECONDS=3600

Option 2: Multiple Providers (Numbered)

# Enable OIDC
OIDC_ENABLED=true

# Provider 1: Google
OIDC_PROVIDER_1_NAME=Google
OIDC_PROVIDER_1_SLUG=google
OIDC_PROVIDER_1_ISSUER=https://accounts.google.com
OIDC_PROVIDER_1_CLIENT_ID=xxx.apps.googleusercontent.com
OIDC_PROVIDER_1_CLIENT_SECRET=xxx
OIDC_PROVIDER_1_SCOPE=openid profile email
OIDC_PROVIDER_1_AUTO_PROVISION=true

# Provider 2: Company Okta
OIDC_PROVIDER_2_NAME=Company Okta
OIDC_PROVIDER_2_SLUG=okta
OIDC_PROVIDER_2_ISSUER=https://company.okta.com
OIDC_PROVIDER_2_CLIENT_ID=yyy
OIDC_PROVIDER_2_CLIENT_SECRET=yyy
OIDC_PROVIDER_2_SCOPE=openid profile email
OIDC_PROVIDER_2_AUTO_PROVISION=true
OIDC_PROVIDER_2_ADMIN_EMAIL_DOMAINS=company.com

# Provider 3: Self-hosted Authentik
OIDC_PROVIDER_3_NAME=Authentik
OIDC_PROVIDER_3_SLUG=authentik
OIDC_PROVIDER_3_ISSUER=https://auth.example.com/application/o/tududi/
OIDC_PROVIDER_3_CLIENT_ID=zzz
OIDC_PROVIDER_3_CLIENT_SECRET=zzz
OIDC_PROVIDER_3_AUTO_PROVISION=true

Provider-Specific Issuer URLs

Popular Providers:

  • Google: https://accounts.google.com
  • Okta: https://{your-domain}.okta.com
  • Keycloak: https://{your-domain}/realms/{realm-name}
  • Authentik: https://{your-domain}/application/o/{application-slug}/
  • PocketID: https://pocketid.app
  • Azure AD: https://login.microsoftonline.com/{tenant-id}/v2.0
  • Generic: Any OIDC-compliant provider with .well-known/openid-configuration

Required Environment Variables

The following environment variables must be set for OAuth redirects:

# Base URL for callback redirects
BASE_URL=http://localhost:3002  # Development
BASE_URL=https://tududi.example.com  # Production

# Trust proxy (REQUIRED for production behind reverse proxy)
TUDUDI_TRUST_PROXY=true

Why TUDUDI_TRUST_PROXY is Required:

When deployed behind a reverse proxy (nginx, Traefar, Apache), Express must be configured to trust the proxy headers. Without this:

  • Sessions may not be saved properly after OIDC callback
  • Rate limiting will fail with X-Forwarded-For errors
  • Users will experience 401 errors after successful SSO login

The BASE_URL is used to construct the callback URL: ${BASE_URL}/api/oidc/callback/{slug}


Critical Files

Database Migrations

  • /backend/migrations/20260420000001-create-oidc-identities.js
  • /backend/migrations/20260420000002-create-oidc-state-nonces.js
  • /backend/migrations/20260420000003-create-auth-audit-log.js (optional)
  • /backend/migrations/20260420000004-make-password-optional.js

Backend Models

  • /backend/models/user.js - Make password optional, add validation
  • /backend/models/oidc_identity.js - New model
  • /backend/models/oidc_state_nonce.js - New model
  • /backend/models/auth_audit_log.js - New model (optional)

Backend Services

  • /backend/modules/oidc/providerConfig.js - Load providers from .env
  • /backend/modules/oidc/service.js - Core OIDC flow
  • /backend/modules/oidc/provisioningService.js - JIT provisioning
  • /backend/modules/oidc/stateManager.js - State/nonce management
  • /backend/modules/oidc/oidcIdentityService.js - Identity linking
  • /backend/modules/oidc/controller.js - HTTP handlers
  • /backend/modules/oidc/routes.js - Express routes
  • /backend/modules/auth/service.js - Update login for OIDC-only users

Frontend Components

  • /frontend/components/Login.tsx - Add provider buttons
  • /frontend/components/Auth/OIDCProviderButtons.tsx - New component
  • /frontend/components/Auth/OIDCCallback.tsx - New component
  • /frontend/components/Profile/tabs/SecurityTab.tsx - Add Connected Accounts

Verification Steps

After implementation, verify:

  1. Basic OIDC Login:

    • Add Google provider to .env
    • Restart server
    • Login page shows "Sign in with Google" button
    • User clicks button → redirects to Google → approves → redirected back
    • User is logged in, session created, redirected to /today
    • User profile shows Google as connected account
  2. Account Linking:

    • Existing email/password user goes to Profile → Security
    • Clicks "Link Google" → OIDC flow → returns to profile
    • Google account now listed under Connected Accounts
    • User can log in with either email/password OR Google
  3. JIT Provisioning:

    • New user (no tududi account) clicks "Sign in with Google"
    • User approves at Google
    • New tududi account auto-created with email from OIDC claims
    • User logged in and redirected to /today
  4. Admin Rules:

    • Set .env: OIDC_ADMIN_EMAIL_DOMAINS=example.com
    • User with email admin@example.com logs in via OIDC
    • User is auto-assigned admin role
    • User can access /admin routes
  5. Security:

    • Try invalid state parameter → rejected with 401
    • Try reusing state → rejected (consumed after use)
    • Check audit log: login events recorded (if enabled)
  6. Edge Cases:

    • OIDC-only user (no password) tries email/password login → error message
    • User tries to unlink last auth method → blocked with warning
    • OIDC_ENABLED=false in .env → no OIDC buttons on login page
    • Invalid provider slug in URL → 404 error
  7. Multiple Providers:

    • Configure 2+ providers in .env (numbered)
    • Restart server
    • Login page shows all provider buttons
    • Each provider works independently

Success Criteria

Users can log in via OIDC providers configured in .env First-time users auto-created with verified email (JIT provisioning) Existing users can link/unlink OIDC accounts Support for multiple OIDC providers via numbered .env variables Admin roles assigned per provider rules (email domain matching) Client secrets stored securely in .env (standard practice) JWT signatures validated against provider JWKS Email/password auth still works (backward compatible) Server restart required to update provider configuration (documented) All tests pass (unit, integration, E2E) Documentation complete (user guide, setup examples)


Migration Path: .env → Admin UI

If future requirements demand UI-based provider management, the migration path is straightforward:

Phase 1: Add Database Table

CREATE TABLE oidc_providers (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  slug STRING UNIQUE NOT NULL,
  name STRING NOT NULL,
  issuer STRING NOT NULL,
  client_id STRING NOT NULL,
  client_secret_encrypted TEXT NOT NULL,
  scope STRING DEFAULT 'openid profile email',
  auto_provision BOOLEAN DEFAULT 1,
  admin_email_domains TEXT,
  enabled BOOLEAN DEFAULT 1,
  created_at DATETIME,
  updated_at DATETIME
);

Phase 2: Dual-Source Configuration

Update providerConfig.js to:

  1. First check database for providers
  2. Fallback to .env if database is empty
  3. Allow admin UI to override .env

Phase 3: Migration Script

npm run oidc:migrate-env-to-db

Reads .env providers and inserts into database.

Phase 4: Admin UI

Build /admin/oidc-providers page with CRUD operations.

Benefits of This Approach

  • Ship OIDC faster (3-4 weeks vs 4-6 weeks)
  • Learn from user feedback before building UI
  • Keep initial implementation simple
  • Clear upgrade path when needed
  • .env configuration sufficient for most self-hosters

References