# OAuth2 Integration Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Integrate Vben Admin 5.0 with Fengling.AuthService using OAuth2 Authorization Code flow with PKCE for centralized authentication. **Architecture:** OAuth2 Authorization Code flow with PKCE. Vben acts as OAuth2 client redirecting to auth service for authentication, exchanging authorization code for JWT tokens, and storing tokens locally with automatic refresh on expiry. **Tech Stack:** Vue 3, TypeScript, OAuth2 with PKCE, JWT tokens, OpenIddict (auth service) --- ## Task 1: Create OAuth2 Configuration **Files:** - Create: `apps/web-antd/src/config/oauth.ts` **Step 1: Create OAuth configuration file** ```typescript export const oauthConfig = { clientId: import.meta.env.VITE_OAUTH_CLIENT_ID || 'vben-web', redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI || `${window.location.origin}/auth/callback`, authUrl: import.meta.env.VITE_AUTH_SERVICE_URL || 'http://localhost:5000', scope: import.meta.env.VITE_OAUTH_SCOPE || 'api offline_access openid profile email roles', endpoints: { authorize: '/connect/authorize', token: '/connect/token', logout: '/connect/logout', revocation: '/connect/revocation', }, storageKeys: { accessToken: 'oauth_access_token', refreshToken: 'oauth_refresh_token', expiresAt: 'oauth_expires_at', codeVerifier: 'oauth_code_verifier', state: 'oauth_state', returnTo: 'oauth_return_to', }, }; ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/config/oauth.ts git commit -m "feat: add OAuth2 configuration" ``` --- ## Task 2: Create OAuth2 Service **Files:** - Create: `apps/web-antd/src/api/core/oauth.ts` **Step 1: Create OAuth service with PKCE functions** ```typescript import { oauthConfig } from '#/config/oauth'; import { baseRequestClient } from '#/api/request'; export interface TokenResponse { access_token: string; refresh_token: string; token_type: string; expires_in: number; scope: string; } /** * Generate random string for code verifier */ export function generateRandomString(length: number = 128): string { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; let result = ''; const array = new Uint8Array(length); crypto.getRandomValues(array); for (let i = 0; i < length; i++) { result += chars[array[i] % chars.length]; } return result; } /** * Generate code verifier for PKCE */ export function generateCodeVerifier(): string { return generateRandomString(128); } /** * Generate code challenge from verifier using SHA-256 */ export async function generateCodeChallenge(verifier: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hash = await crypto.subtle.digest('SHA-256', data); return btoa(String.fromCharCode(...new Uint8Array(hash))) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } /** * Generate random state parameter */ export function generateState(): string { return generateRandomString(32); } /** * Store OAuth state in sessionStorage */ export function storeOAuthState(codeVerifier: string, state: string, returnTo?: string) { sessionStorage.setItem(oauthConfig.storageKeys.codeVerifier, codeVerifier); sessionStorage.setItem(oauthConfig.storageKeys.state, state); if (returnTo) { sessionStorage.setItem(oauthConfig.storageKeys.returnTo, returnTo); } } /** * Retrieve and clear OAuth state from sessionStorage */ export function getAndClearOAuthState() { const codeVerifier = sessionStorage.getItem(oauthConfig.storageKeys.codeVerifier); const state = sessionStorage.getItem(oauthConfig.storageKeys.state); const returnTo = sessionStorage.getItem(oauthConfig.storageKeys.returnTo); sessionStorage.removeItem(oauthConfig.storageKeys.codeVerifier); sessionStorage.removeItem(oauthConfig.storageKeys.state); sessionStorage.removeItem(oauthConfig.storageKeys.returnTo); return { codeVerifier, state, returnTo }; } /** * Redirect user to OAuth2 authorization endpoint */ export function redirectToAuth(returnTo?: string) { const codeVerifier = generateCodeVerifier(); const state = generateState(); generateCodeChallenge(codeVerifier).then((codeChallenge) => { storeOAuthState(codeVerifier, state, returnTo); const params = new URLSearchParams({ client_id: oauthConfig.clientId, redirect_uri: oauthConfig.redirectUri, response_type: 'code', scope: oauthConfig.scope, code_challenge: codeChallenge, code_challenge_method: 'S256', state: state, }); window.location.href = `${oauthConfig.authUrl}${oauthConfig.endpoints.authorize}?${params.toString()}`; }); } /** * Exchange authorization code for access token */ export async function exchangeCodeForToken(code: string, state: string): Promise { const { codeVerifier } = getAndClearOAuthState(); if (!codeVerifier) { throw new Error('Code verifier not found. OAuth2 state may be corrupted.'); } const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.token}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code, redirect_uri: oauthConfig.redirectUri, client_id: oauthConfig.clientId, code_verifier: codeVerifier, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to exchange code for token: ${error}`); } return await response.json(); } /** * Refresh access token using refresh token */ export async function refreshAccessToken(refreshToken: string): Promise { const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.token}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: oauthConfig.clientId, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to refresh token: ${error}`); } return await response.json(); } /** * Revoke refresh token */ export async function revokeToken(token: string) { const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.revocation}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ token: token, token_type_hint: 'refresh_token', client_id: oauthConfig.clientId, }), }); if (!response.ok) { const error = await response.text(); throw new Error(`Failed to revoke token: ${error}`); } } /** * Get logout URL for single logout */ export function getLogoutUrl(): string { const params = new URLSearchParams({ post_logout_redirect_uri: oauthConfig.redirectUri, }); return `${oauthConfig.authUrl}${oauthConfig.endpoints.logout}?${params.toString()}`; } ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/api/core/oauth.ts git commit -m "feat: add OAuth2 service with PKCE support" ``` --- ## Task 3: Update Environment Configuration **Files:** - Modify: `apps/web-antd/.env.development` - Modify: `apps/web-antd/.env.production` **Step 1: Add OAuth2 environment variables to .env.development** Add these lines to the end of `apps/web-antd/.env.development`: ```bash # OAuth2 Configuration VITE_OAUTH_CLIENT_ID=vben-web VITE_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback VITE_AUTH_SERVICE_URL=http://localhost:5000 VITE_OAUTH_SCOPE=api offline_access openid profile email roles ``` **Step 2: Add OAuth2 environment variables to .env.production** Add these lines to the end of `apps/web-antd/.env.production`: ```bash # OAuth2 Configuration VITE_OAUTH_CLIENT_ID=vben-web VITE_OAUTH_REDIRECT_URI=https://your-app.com/auth/callback VITE_AUTH_SERVICE_URL=https://auth.your-domain.com VITE_OAUTH_SCOPE=api offline_access openid profile email roles ``` **Step 3: Commit** ```bash cd apps/web-antd git add .env.development .env.production git commit -m "feat: add OAuth2 environment configuration" ``` --- ## Task 4: Update API Request Interceptor **Files:** - Modify: `apps/web-antd/src/api/request.ts` **Step 1: Import OAuth utilities and update token handling** Replace the entire content of `apps/web-antd/src/api/request.ts` with: ```typescript import { oauthConfig } from '#/config/oauth'; import type { RequestClientOptions } from '@vben/request'; import { useAppConfig } from '@vben/hooks'; import { preferences } from '@vben/preferences'; import { authenticateResponseInterceptor, defaultResponseInterceptor, errorMessageResponseInterceptor, RequestClient, } from '@vben/request'; import { useAccessStore } from '@vben/stores'; import { message } from 'ant-design-vue'; import { useAuthStore } from '#/store'; import { refreshAccessToken } from './core/oauth'; const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); function createRequestClient(baseURL: string, options?: RequestClientOptions) { const client = new RequestClient({ ...options, baseURL, }); /** * Check if access token is expired or will expire soon */ function isTokenExpired(expiresAt: number | null): boolean { if (!expiresAt) return true; // Refresh 5 minutes before expiry return Date.now() >= expiresAt - 5 * 60 * 1000; } /** * Re-authenticate logic */ async function doReAuthenticate() { console.warn('Access token or refresh token is invalid or expired.'); const accessStore = useAccessStore(); const authStore = useAuthStore(); accessStore.setAccessToken(null); if ( preferences.app.loginExpiredMode === 'modal' && accessStore.isAccessChecked ) { accessStore.setLoginExpired(true); } else { await authStore.logout(); } } /** * Refresh token logic */ async function doRefreshToken() { const accessStore = useAccessStore(); const refreshToken = accessStore.refreshToken; if (!refreshToken) { await doReAuthenticate(); throw new Error('No refresh token available'); } try { const tokenResponse = await refreshAccessToken(refreshToken); accessStore.setAccessToken(tokenResponse.access_token); accessStore.setRefreshToken(tokenResponse.refresh_token); accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000); return tokenResponse.access_token; } catch (error) { console.error('Failed to refresh token:', error); await doReAuthenticate(); throw error; } } function formatToken(token: null | string) { return token ? `Bearer ${token}` : null; } // Request interceptor: Add authorization header and handle token refresh client.addRequestInterceptor({ fulfilled: async (config) => { const accessStore = useAccessStore(); // Check if token needs refresh if (accessStore.accessToken && accessStore.expiresAt) { if (isTokenExpired(accessStore.expiresAt)) { try { const newToken = await doRefreshToken(); config.headers.Authorization = formatToken(newToken); } catch { config.headers.Authorization = formatToken(null); } } else { config.headers.Authorization = formatToken(accessStore.accessToken); } } config.headers['Accept-Language'] = preferences.app.locale; return config; }, }); // Response interceptor: Handle OAuth2 token response format client.addResponseInterceptor( defaultResponseInterceptor({ codeField: 'code', dataField: 'data', successCode: 0, }), ); // Token expiry and 401 error handling client.addResponseInterceptor( authenticateResponseInterceptor({ client, doReAuthenticate, doRefreshToken, enableRefreshToken: preferences.app.enableRefreshToken, formatToken, }), ); // Generic error handling client.addResponseInterceptor( errorMessageResponseInterceptor((msg: string, error) => { const responseData = error?.response?.data ?? {}; const errorMessage = responseData?.error ?? responseData?.message ?? ''; message.error(errorMessage || msg); }), ); return client; } export const requestClient = createRequestClient(apiURL, { responseReturn: 'data', }); export const baseRequestClient = new RequestClient({ baseURL: apiURL }); ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/api/request.ts git commit -m "feat: update request interceptor for OAuth2 token management" ``` --- ## Task 5: Update Auth API **Files:** - Modify: `apps/web-antd/src/api/core/auth.ts` **Step 1: Update auth API for OAuth2** Replace the entire content of `apps/web-antd/src/api/core/auth.ts` with: ```typescript import { baseRequestClient, requestClient } from '#/api/request'; import { oauthConfig } from '#/config/oauth'; export namespace AuthApi { /** Token response from OAuth2 */ export interface TokenResponse { access_token: string; refresh_token: string; token_type: string; expires_in: number; scope: string; } /** User info from JWT claims */ export interface UserInfo { sub: string; name: string; email: string; role: string[]; tenant_id: string; } } /** * Parse JWT token to extract claims */ export function parseJwt(token: string): AuthApi.UserInfo | null { try { const parts = token.split('.'); if (parts.length !== 3) return null; const payload = JSON.parse(atob(parts[1])); return { sub: payload.sub, name: payload.name || payload['unique_name'], email: payload.email, role: Array.isArray(payload.role) ? payload.role : [payload.role].filter(Boolean), tenant_id: payload.tenant_id || payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier'], }; } catch { return null; } } /** * Revoke refresh token (logout) */ export async function logoutApi(refreshToken: string) { const response = await fetch(`${oauthConfig.authUrl}${oauthConfig.endpoints.revocation}`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ token: refreshToken, token_type_hint: 'refresh_token', client_id: oauthConfig.clientId, }), }); if (!response.ok) { const error = await response.text(); console.error('Failed to revoke token:', error); // Don't throw - allow logout to continue even if revocation fails } } /** * Check if access token is still valid on server */ export async function validateTokenApi() { return requestClient.get('/health'); } ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/api/core/auth.ts git commit -m "feat: update auth API for OAuth2 token handling" ``` --- ## Task 6: Update Auth Store **Files:** - Modify: `apps/web-antd/src/store/auth.ts` **Step 1: Update auth store for OAuth2 flow** Replace the entire content of `apps/web-antd/src/store/auth.ts` with: ```typescript import type { UserInfo } from '@vben/types'; import { ref } from 'vue'; import { useRouter } from 'vue-router'; import { LOGIN_PATH } from '@vben/constants'; import { preferences } from '@vben/preferences'; import { resetAllStores, useAccessStore, useUserStore } from '@vben/stores'; import { notification } from 'ant-design-vue'; import { defineStore } from 'pinia'; import { parseJwt, logoutApi } from '#/api/core/auth'; import { exchangeCodeForToken, getLogoutUrl, redirectToAuth } from '#/api/core/oauth'; import { $t } from '#/locales'; export interface OAuth2State { code: string; state: string; } export const useAuthStore = defineStore('auth', () => { const accessStore = useAccessStore(); const userStore = useUserStore(); const router = useRouter(); const loginLoading = ref(false); /** * Handle OAuth2 callback */ async function handleOAuthCallback(oauthState: OAuth2State) { let userInfo: null | UserInfo = null; try { loginLoading.value = true; // Exchange code for tokens const tokenResponse = await exchangeCodeForToken(oauthState.code, oauthState.state); // Store tokens accessStore.setAccessToken(tokenResponse.access_token); accessStore.setRefreshToken(tokenResponse.refresh_token); accessStore.setExpiresAt(Date.now() + tokenResponse.expires_in * 1000); // Parse user info from JWT const jwtClaims = parseJwt(tokenResponse.access_token); if (jwtClaims) { userInfo = { userId: jwtClaims.sub, username: jwtClaims.name, realName: jwtClaims.name, email: jwtClaims.email, roles: jwtClaims.role, homePath: preferences.app.defaultHomePath, }; userStore.setUserInfo(userInfo); accessStore.setAccessCodes(jwtClaims.role || []); } // Redirect to home or return URL if (accessStore.loginExpired) { accessStore.setLoginExpired(false); } else { const returnTo = sessionStorage.getItem('oauth_return_to') || preferences.app.defaultHomePath; sessionStorage.removeItem('oauth_return_to'); await router.push(returnTo); } if (userInfo?.realName) { notification.success({ description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.realName}`, duration: 3, message: $t('authentication.loginSuccess'), }); } } catch (error) { console.error('OAuth2 callback error:', error); notification.error({ description: error instanceof Error ? error.message : 'Authentication failed', duration: 5, message: $t('authentication.loginFailed'), }); await router.push(LOGIN_PATH); } finally { loginLoading.value = false; } return { userInfo, }; } /** * Initiate OAuth2 login flow */ async function authLogin( params: any = {}, onSuccess?: () => Promise | void, ) { const returnTo = params.returnTo || router.currentRoute.value.fullPath; redirectToAuth(returnTo); return { userInfo: null }; } /** * Logout with single logout (SLO) */ async function logout(redirect: boolean = true) { const refreshToken = accessStore.refreshToken; try { // Revoke refresh token if (refreshToken) { await logoutApi(refreshToken); } } catch { // Don't fail logout if revocation fails } // Clear local state resetAllStores(); accessStore.setLoginExpired(false); accessStore.setAccessToken(null); accessStore.setRefreshToken(null); accessStore.setExpiresAt(null); // Redirect to auth service logout (SLO) window.location.href = getLogoutUrl(); } async function fetchUserInfo() { // User info is now extracted from JWT in handleOAuthCallback // This can fetch additional user details if needed const userInfo = userStore.getUserInfo; return userInfo; } function $reset() { loginLoading.value = false; } return { $reset, authLogin, fetchUserInfo, handleOAuthCallback, loginLoading, logout, }; }); ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/store/auth.ts git commit -m "feat: update auth store for OAuth2 flow" ``` --- ## Task 7: Update Access Store for Refresh Token **Files:** - Locate and modify access store file (likely in `@vben/stores` package) **Step 1: Check access store location** ```bash find /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben -name "*access*" -type f | grep -i store ``` **Step 2: Update access store to support refresh token** Since the access store is likely in the `@vben/stores` workspace package, we need to add refresh token support. Look for the access store definition and add: ```typescript // Add these new state variables const refreshToken = ref(null); const expiresAt = ref(null); // Add these new functions function setRefreshToken(token: string | null) { refreshToken.value = token; if (token) { localStorage.setItem('oauth_refresh_token', token); } else { localStorage.removeItem('oauth_refresh_token'); } } function setExpiresAt(timestamp: number | null) { expiresAt.value = timestamp; if (timestamp) { localStorage.setItem('oauth_expires_at', timestamp.toString()); } else { localStorage.removeItem('oauth_expires_at'); } } // Initialize from localStorage on store creation setRefreshToken(localStorage.getItem('oauth_refresh_token')); setExpiresAt(localStorage.getItem('oauth_expires_at') ? parseInt(localStorage.getItem('oauth_expires_at')!) : null); // Add to return statement return { // ...existing returns refreshToken, expiresAt, setRefreshToken, setExpiresAt, }; ``` **Step 3: Commit** ```bash cd apps/web-antd git add git commit -m "feat: add refresh token support to access store" ``` --- ## Task 8: Create OAuth Callback Page **Files:** - Create: `apps/web-antd/src/views/_core/authentication/callback.vue` **Step 1: Create OAuth2 callback component** ```vue ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/views/_core/authentication/callback.vue git commit -m "feat: add OAuth2 callback page" ``` --- ## Task 9: Update Login Page **Files:** - Modify: `apps/web-antd/src/views/_core/authentication/login.vue` **Step 1: Update login page to redirect to OAuth2** Replace the entire content of `apps/web-antd/src/views/_core/authentication/login.vue` with: ```vue ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/views/_core/authentication/login.vue git commit -m "feat: update login page for OAuth2 flow" ``` --- ## Task 10: Add OAuth Callback Route **Files:** - Modify: `apps/web-antd/src/router/routes/core.ts` **Step 1: Add OAuth callback route to core routes** Find the route definition file and add this route: ```typescript { path: '/auth/callback', name: 'OAuthCallback', component: () => import('#/views/_core/authentication/callback.vue'), meta: { title: 'OAuth Callback', hideInMenu: true, ignoreAuth: true, }, }, ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/router/routes/core.ts git commit -m "feat: add OAuth2 callback route" ``` --- ## Task 11: Register OAuth2 Client in Auth Service **Files:** - Modify: `src/Fengling.AuthService/Data/SeedData.cs` **Step 1: Add Vben OAuth2 client to seed data** Add this OAuth application to the SeedData.cs file: ```csharp var vbenClient = new OAuthApplication { ClientId = "vben-web", ClientSecret = null, // Public client with PKCE DisplayName = "Vben Admin", RedirectUris = new[] { "http://localhost:5173/auth/callback" }, PostLogoutRedirectUris = new[] { "http://localhost:5173" }, Permissions = new[] { OpenIddictConstants.Permissions.Endpoints.Authorization, OpenIddictConstants.Permissions.Endpoints.Token, OpenIddictConstants.Permissions.Endpoints.Logout, OpenIddictConstants.Permissions.Endpoints.Revocation, OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode, OpenIddictConstants.Permissions.ResponseTypes.Code, OpenIddictConstants.Permissions.Scopes.Profile, OpenIddictConstants.Permissions.Scopes.Email, OpenIddictConstants.Permissions.Scopes.Roles, OpenIddictConstants.Permissions.Prefixes.Scope + "api" } }; await context.OAuthApplications.AddAsync(vbenClient); await context.SaveChangesAsync(); ``` **Step 2: Commit** ```bash cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService git add Data/SeedData.cs git commit -m "feat: register Vben Admin as OAuth2 client" ``` --- ## Task 12: Add Database Migration **Files:** - Generate new migration for auth service **Step 1: Generate migration** ```bash cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService dotnet ef migrations add AddVbenOAuthClient ``` **Step 2: Apply migration** ```bash dotnet ef database update ``` **Step 3: Commit** ```bash git add Migrations/ git commit -m "feat: add migration for Vben OAuth2 client" ``` --- ## Task 13: Test Implementation **Files:** - No files to modify **Step 1: Start auth service** ```bash cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService dotnet run ``` Verify auth service is running on http://localhost:5000 **Step 2: Start Vben app in development mode** ```bash cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben pnpm dev ``` Verify Vben app is running on http://localhost:5173 **Step 3: Test login flow** 1. Navigate to http://localhost:5173 2. Click "使用 Fengling 认证中心登录" 3. Should redirect to auth service login page 4. Login with admin / Admin@123 5. Should redirect back to Vben app 6. Verify user is logged in and can access dashboard **Step 4: Test token refresh** 1. Log in successfully 2. Wait for token to near expiry or manually clear localStorage 3. Make an API request 4. Verify automatic token refresh works **Step 5: Test logout** 1. Log in successfully 2. Click logout 3. Verify redirect to auth service logout 4. Verify tokens are revoked 5. Verify user is logged out **Step 6: Test error handling** 1. Cancel login on auth service page 2. Verify error is displayed on Vben login page 3. Try with invalid credentials 4. Verify error is displayed correctly **Step 7: Document any issues** Note any issues found during testing for fixes. --- ## Task 14: Update API Exports **Files:** - Modify: `apps/web-antd/src/api/core/index.ts` **Step 1: Export OAuth2 functions** Add to the exports: ```typescript export * from './oauth'; ``` **Step 2: Commit** ```bash cd apps/web-antd git add src/api/core/index.ts git commit -m "feat: export OAuth2 service functions" ``` --- ## Task 15: Final Testing and Verification **Files:** - No files to modify **Step 1: Run type checking** ```bash cd apps/web-antd pnpm typecheck ``` Fix any type errors. **Step 2: Run linter** ```bash cd apps/web-antd pnpm lint ``` Fix any linting errors. **Step 3: Build production bundle** ```bash cd apps/web-antd pnpm build ``` Verify build succeeds. **Step 4: Test production build** ```bash pnpm preview ``` Test the flow with production build. **Step 5: Final commit** ```bash cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.Console.Web.Vben git add . git commit -m "feat: complete OAuth2 integration with Fengling.AuthService Implemented OAuth2 Authorization Code flow with PKCE: - OAuth2 client configuration - PKCE code verifier/challenge generation - Token exchange and automatic refresh - Single logout (SLO) support - JWT claim parsing for user info - Complete error handling Features: - Secure authentication with PKCE - Automatic token refresh before expiry - JWT token management with localStorage - User info extracted from JWT claims - Multi-tenant and RBAC support - Full error handling and validation" ``` --- ## Summary This implementation plan transforms Vben Admin from a basic authentication system to a full OAuth2 client integrated with Fengling.AuthService. The plan covers: ✅ OAuth2 client configuration ✅ PKCE security implementation ✅ Authorization flow handling ✅ Token management and automatic refresh ✅ Single logout (SLO) support ✅ JWT claim parsing ✅ Error handling throughout ✅ Full frontend and backend changes ✅ Testing and verification steps The implementation follows OAuth2 best practices with PKCE for public clients, automatic token refresh, and secure token storage.