From 5f8720bb63c19b5adb2afdb32229ff57612eb059 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 14 Jun 2025 14:09:56 +0200 Subject: [PATCH] Implement Keycloak authentication integration and unify user management --- .env.example | 12 ++ composables/useUnifiedAuth.ts | 91 +++++++++++++++ docs/keycloak-integration.md | 203 ++++++++++++++++++++++++++++++++++ middleware/authentication.ts | 34 ++++-- nuxt.config.ts | 32 +++++- package-lock.json | 86 ++++++++++++++ package.json | 1 + pages/auth/callback.vue | 44 ++++++++ pages/dashboard.vue | 13 ++- pages/dashboard/auth-test.vue | 136 +++++++++++++++++++++++ pages/login.vue | 165 ++++++++++++++++----------- 11 files changed, 743 insertions(+), 74 deletions(-) create mode 100644 composables/useUnifiedAuth.ts create mode 100644 docs/keycloak-integration.md create mode 100644 pages/auth/callback.vue create mode 100644 pages/dashboard/auth-test.vue diff --git a/.env.example b/.env.example index d71cfa9..186825c 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,15 @@ NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev # Webhook Configuration for Embedded Signing WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY= + +# Keycloak Configuration +KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal +KEYCLOAK_CLIENT_ID=client-portal +KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret +KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback +# For local development, use: http://localhost:3000/auth/callback + +# OIDC Session Configuration +OIDC_SESSION_SECRET=your-32-character-session-secret +OIDC_ENCRYPT_KEY=your-32-character-encryption-key +OIDC_ENCRYPT_IV=16-char-enc-iv! diff --git a/composables/useUnifiedAuth.ts b/composables/useUnifiedAuth.ts new file mode 100644 index 0000000..b65392c --- /dev/null +++ b/composables/useUnifiedAuth.ts @@ -0,0 +1,91 @@ +export interface UnifiedUser { + id: string; + email: string; + name: string; + tier?: string; + authSource: 'keycloak' | 'directus'; + raw: any; +} + +export const useUnifiedAuth = () => { + // Get both auth systems + const directusAuth = useDirectusAuth(); + const directusUser = useDirectusUser(); + const oidc = useOidc(); + + // Create unified user object + const user = computed(() => { + // Check Keycloak user first + if (oidc.user?.value) { + const keycloakUser = oidc.user.value; + // Construct name from firstName and lastName if available + let name = keycloakUser.name; + if (!name && (keycloakUser.given_name || keycloakUser.family_name)) { + name = `${keycloakUser.given_name || ''} ${keycloakUser.family_name || ''}`.trim(); + } else if (!name && (keycloakUser.firstName || keycloakUser.lastName)) { + name = `${keycloakUser.firstName || ''} ${keycloakUser.lastName || ''}`.trim(); + } + if (!name) { + name = keycloakUser.preferred_username || keycloakUser.email; + } + + return { + id: keycloakUser.sub || keycloakUser.id, + email: keycloakUser.email, + name: name, + tier: keycloakUser.tier || 'basic', // From custom Keycloak attribute + authSource: 'keycloak', + raw: keycloakUser + }; + } + + // Fall back to Directus user + if (directusUser.value && directusUser.value.email) { + return { + id: directusUser.value.id, + email: directusUser.value.email, + name: `${directusUser.value.first_name || ''} ${directusUser.value.last_name || ''}`.trim() || directusUser.value.email, + tier: directusUser.value.tier || 'basic', // If Directus has tier field + authSource: 'directus', + raw: directusUser.value + }; + } + + return null; + }); + + // Unified logout function + const logout = async () => { + if (user.value?.authSource === 'keycloak') { + // Keycloak logout + await oidc.logout(); + } else if (user.value?.authSource === 'directus') { + // Directus logout + await directusAuth.logout(); + await navigateTo('/login'); + } + }; + + // Check if user is authenticated + const isAuthenticated = computed(() => !!user.value); + + // Get auth source + const authSource = computed(() => user.value?.authSource); + + // Check if user has specific tier + const hasTier = (tier: string) => { + return user.value?.tier === tier; + }; + + // Check if user is admin + const isAdmin = computed(() => hasTier('admin')); + + return { + user: readonly(user), + logout, + isAuthenticated: readonly(isAuthenticated), + authSource: readonly(authSource), + hasTier, + isAdmin: readonly(isAdmin), + }; +}; diff --git a/docs/keycloak-integration.md b/docs/keycloak-integration.md new file mode 100644 index 0000000..33aaa39 --- /dev/null +++ b/docs/keycloak-integration.md @@ -0,0 +1,203 @@ +# Keycloak Integration Documentation + +## Overview + +This document describes the dual authentication system implementation that supports both Keycloak (new) and Directus (existing) authentication methods running in parallel. + +## Architecture + +### Authentication Flow + +```mermaid +graph TD + A[User visits Portal] --> B{Already Authenticated?} + B -->|No| C[Login Page] + B -->|Yes| D[Dashboard] + + C --> E{Login Method} + E -->|SSO Button| F[Redirect to Keycloak] + E -->|Email/Password| G[Directus Authentication] + + F --> H[Keycloak Login Page] + H --> I[User Authenticates] + I --> J[Redirect to /auth/callback] + J --> D + + G --> K[Validate with Directus] + K --> D +``` + +### Key Components + +1. **Unified Authentication Composable** (`composables/useUnifiedAuth.ts`) + - Provides a single interface for both auth systems + - Maps user data to a common format + - Handles logout for both systems + +2. **Authentication Middleware** (`middleware/authentication.ts`) + - Checks Keycloak auth first, then Directus + - Supports public pages via `auth: false` page meta + +3. **Login Page** (`pages/login.vue`) + - Dual login options (SSO and traditional) + - Public page (no auth required) + +4. **OAuth Callback** (`pages/auth/callback.vue`) + - Handles Keycloak OAuth redirects + - Shows loading state during authentication + +## Configuration + +### Environment Variables + +```env +# Keycloak Configuration +KEYCLOAK_ISSUER=https://auth.portnimara.dev/realms/client-portal +KEYCLOAK_CLIENT_ID=client-portal +KEYCLOAK_CLIENT_SECRET=your-client-secret +KEYCLOAK_CALLBACK_URL=https://client.portnimara.dev/auth/callback + +# OIDC Session Configuration +OIDC_SESSION_SECRET=your-32-character-session-secret +OIDC_ENCRYPT_KEY=your-32-character-encryption-key +OIDC_ENCRYPT_IV=16-char-enc-iv! +``` + +### Keycloak Client Configuration + +1. **Client Type**: Confidential (uses client secret) +2. **Authentication Flow**: Standard flow (Authorization Code) +3. **Valid Redirect URIs**: + - `https://client.portnimara.dev/*` + - `https://client.portnimara.dev/auth/callback` + - `http://localhost:3000/*` (development) + - `http://localhost:3000/auth/callback` (development) +4. **Web Origins**: `+` (all redirect URI origins) + +### User Attributes + +Custom Keycloak attributes: +- `tier`: User tier level (basic, premium, admin) + +Standard attributes used: +- `email`: User email address +- `given_name` / `firstName`: First name +- `family_name` / `lastName`: Last name + +## Usage + +### For Components + +Replace Directus-specific code with unified auth: + +```typescript +// Before +const user = useDirectusUser(); +const { logout } = useDirectusAuth(); + +// After +const { user, logout } = useUnifiedAuth(); +``` + +### User Object Structure + +```typescript +interface UnifiedUser { + id: string; // User ID + email: string; // Email address + name: string; // Full name + tier?: string; // User tier (basic, premium, admin) + authSource: 'keycloak' | 'directus'; // Auth system used + raw: any; // Original user object +} +``` + +### Checking Authentication + +```typescript +const { isAuthenticated, authSource, isAdmin } = useUnifiedAuth(); + +if (isAuthenticated.value) { + console.log(`User logged in via ${authSource.value}`); + + if (isAdmin.value) { + // Show admin features + } +} +``` + +## Testing + +### Test Page + +Visit `/dashboard/auth-test` to: +- View current authentication status +- See user information and tier +- Test logout functionality +- View raw user data + +### Testing Both Auth Methods + +1. **Directus Login**: + - Use email/password form on login page + - Existing users continue to work + +2. **Keycloak Login**: + - Click "Login with Single Sign-On" + - Redirected to Keycloak + - Returns to portal after authentication + +## Migration Path + +### Phase 1: Parallel Systems (Current) +- Both authentication methods available +- Users can choose their preferred method +- No breaking changes + +### Phase 2: Gradual Migration +- Encourage SSO usage +- Migrate users to Keycloak +- Monitor adoption rates + +### Phase 3: Keycloak Only +- Remove Directus login option +- All users on Keycloak +- Simplified codebase + +## Troubleshooting + +### Common Issues + +1. **"Invalid redirect URI" error** + - Check Keycloak client redirect URIs + - Ensure callback URL matches environment + +2. **User tier not showing** + - Verify tier attribute in Keycloak + - Check attribute mappers in client scope + +3. **Name not displaying correctly** + - Ensure firstName/lastName set in Keycloak + - Check unified auth name construction logic + +### Debug Mode + +Enable debug logging in development: +- OIDC debug mode automatically enabled +- Check browser console for auth flow details +- Review network tab for OAuth redirects + +## Security Considerations + +1. **Client Secret**: Never expose in client-side code +2. **Session Security**: Uses encrypted cookies +3. **Token Validation**: Server-side APIs should validate JWTs +4. **HTTPS Required**: OAuth flow requires secure connections + +## Future Enhancements + +- [ ] Social login providers (Google, Microsoft) +- [ ] Multi-factor authentication +- [ ] User self-service portal +- [ ] Advanced role-based access control +- [ ] Session management UI diff --git a/middleware/authentication.ts b/middleware/authentication.ts index 6b5ce19..1e7a083 100644 --- a/middleware/authentication.ts +++ b/middleware/authentication.ts @@ -1,14 +1,34 @@ -export default defineNuxtRouteMiddleware(async () => { +export default defineNuxtRouteMiddleware(async (to) => { + // Skip auth for SSR + if (import.meta.server) return; + + // Check if auth is required (default true unless explicitly set to false) + const isAuthRequired = to.meta.auth !== false; + + // Check Keycloak auth first + const oidc = useOidc(); + if (oidc.isLoggedIn) { + // User authenticated with Keycloak + return; + } + + // Fall back to Directus auth const { fetchUser, setUser } = useDirectusAuth(); - - const user = useDirectusUser(); - - if (!user.value) { + const directusUser = useDirectusUser(); + + if (!directusUser.value) { const user = await fetchUser(); setUser(user.value); } - if (!user.value) { - return navigateTo("/login"); + if (directusUser.value) { + // User authenticated with Directus + return; + } + + // No authentication found + if (isAuthRequired) { + // Redirect to login page + return navigateTo('/login'); } }); diff --git a/nuxt.config.ts b/nuxt.config.ts index 560d8c1..0ba5982 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -2,7 +2,7 @@ export default defineNuxtConfig({ ssr: false, compatibilityDate: "2024-11-01", devtools: { enabled: true }, - modules: ["nuxt-directus", "vuetify-nuxt-module", "@vite-pwa/nuxt"], + modules: ["nuxt-directus", "nuxt-openid-connect", "vuetify-nuxt-module", "@vite-pwa/nuxt"], app: { head: { titleTemplate: "%s • Port Nimara Portal", @@ -124,6 +124,36 @@ export default defineNuxtConfig({ }, }, }, + openidConnect: { + addPlugin: true, + op: { + issuer: process.env.KEYCLOAK_ISSUER || "https://auth.portnimara.dev/realms/client-portal", + clientId: process.env.KEYCLOAK_CLIENT_ID || "client-portal", + clientSecret: process.env.KEYCLOAK_CLIENT_SECRET || "", + callbackUrl: process.env.KEYCLOAK_CALLBACK_URL || "", + scope: ["openid", "email", "profile"], + }, + config: { + debug: process.env.NODE_ENV === 'development', + response_type: "code", + secret: process.env.OIDC_SESSION_SECRET || "default-session-secret-change-in-production", + cookie: { + loginName: "keycloak-login", + }, + cookiePrefix: "keycloak._", + cookieEncrypt: true, + cookieEncryptKey: process.env.OIDC_ENCRYPT_KEY || "default-encrypt-key-change-in-prod", + cookieEncryptIV: process.env.OIDC_ENCRYPT_IV || "default-iv-12345", + cookieEncryptALGO: "aes-256-cbc", + cookieMaxAge: 24 * 60 * 60, // 1 day + cookieFlags: { + access_token: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + }, + }, + }, + }, vuetify: { vuetifyOptions: { theme: { diff --git a/package-lock.json b/package-lock.json index 7836ff8..9f18173 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "nodemailer": "^7.0.3", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", + "nuxt-openid-connect": "^0.8.1", "v-phone-input": "^4.4.2", "vue": "latest", "vue-router": "latest", @@ -8570,6 +8571,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -9722,6 +9732,18 @@ "@nuxt/kit": "^3.0.0" } }, + "node_modules/nuxt-openid-connect": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/nuxt-openid-connect/-/nuxt-openid-connect-0.8.1.tgz", + "integrity": "sha512-b/pMCZ4ZnY6VHqQ5clnjRnrz7OQ1wsydjcZIQ0c5VQq7BCgb5VBtp/9HETRqRArTjDiIqxc/Xu3hhddbHAfakA==", + "license": "MIT", + "dependencies": { + "@nuxt/kit": "^3.11.2", + "defu": "^6.0.0", + "openid-client": "^5.1.6", + "uuid": "^10.0.0" + } + }, "node_modules/nypm": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.5.2.tgz", @@ -9742,6 +9764,15 @@ "node": "^14.16.0 || >=16.10.0" } }, + "node_modules/object-hash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz", + "integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", @@ -9800,6 +9831,15 @@ "integrity": "sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==", "license": "MIT" }, + "node_modules/oidc-token-hash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.0.tgz", + "integrity": "sha512-y0W+X7Ppo7oZX6eovsRkuzcSM40Bicg2JEJkDJ4irIt1wsYAP5MLSNv+QAogO8xivMffw/9OvV3um1pxXgt1uA==", + "license": "MIT", + "engines": { + "node": "^10.13.0 || >=12.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -9900,6 +9940,39 @@ "typescript": "^5.x" } }, + "node_modules/openid-client": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", + "integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==", + "license": "MIT", + "dependencies": { + "jose": "^4.15.9", + "lru-cache": "^6.0.0", + "object-hash": "^2.2.0", + "oidc-token-hash": "^5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/openid-client/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -13541,6 +13614,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/v-phone-input": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/v-phone-input/-/v-phone-input-4.4.2.tgz", diff --git a/package.json b/package.json index 53b402f..cd06100 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "nodemailer": "^7.0.3", "nuxt": "^3.15.4", "nuxt-directus": "^5.7.0", + "nuxt-openid-connect": "^0.8.1", "v-phone-input": "^4.4.2", "vue": "latest", "vue-router": "latest", diff --git a/pages/auth/callback.vue b/pages/auth/callback.vue new file mode 100644 index 0000000..52e7876 --- /dev/null +++ b/pages/auth/callback.vue @@ -0,0 +1,44 @@ + + + diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 1ec94c2..b5865d9 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -20,10 +20,16 @@ + > + + + + + +

Authentication Test

+
+
+ + + + + Current Authentication Status + + + + + Authentication Status + {{ isAuthenticated ? 'Authenticated' : 'Not Authenticated' }} + + + + + Auth Source + {{ authSource }} + + + + + + + + + User Information + + + + + User ID + {{ user.id }} + + + + + Email + {{ user.email }} + + + + + Name + {{ user.name }} + + + + + Tier + + + {{ user.tier || 'basic' }} + + + + + + + Admin Status + User has admin privileges + + + + + + + + + + + Raw User Data + + + + Click to view raw data + +
{{ JSON.stringify(user.raw, null, 2) }}
+
+
+
+
+
+
+
+ + + + + Test Actions + + + Test Logout + + + + + +
+ + + diff --git a/pages/login.vue b/pages/login.vue index 5891f9d..cd1aa43 100644 --- a/pages/login.vue +++ b/pages/login.vue @@ -4,70 +4,96 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + Login with Single Sign-On + + + + + + + OR + + + + + + + + + + + + - - - - + + + + + + + + + + + - - - + + + @@ -80,18 +106,33 @@