fengling-console-web/docs/plans/2026-02-06-oauth2-implementation-plan.md
Sam fa93d71725 feat: 添加OAuth2认证配置和实现
添加OAuth2认证相关配置文件和服务实现,包括环境变量配置、PKCE流程支持、token管理等功能。主要变更:
- 新增OAuth2配置文件
- 实现OAuth2服务层
- 更新请求拦截器支持token自动刷新
- 修改认证API和store以支持OAuth2流程
2026-02-07 17:47:11 +08:00

32 KiB

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

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

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

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<string> {
  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<TokenResponse> {
  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<TokenResponse> {
  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

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:

# 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:

# 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

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:

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

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:

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<void>('/health');
}

Step 2: Commit

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:

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> | 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

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

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:

// Add these new state variables
const refreshToken = ref<string | null>(null);
const expiresAt = ref<number | null>(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

cd apps/web-antd
git add <access-store-file>
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

<script lang="ts" setup>
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';

import { useAuthStore } from '#/store';

defineOptions({ name: 'OAuthCallback' });

const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();

onMounted(async () => {
  const { code, state, error, error_description } = route.query as {
    code?: string;
    state?: string;
    error?: string;
    error_description?: string;
  };

  // Handle OAuth2 errors
  if (error) {
    console.error('OAuth2 error:', error, error_description);
    await router.push({
      path: '/login',
      query: {
        error,
        error_description,
      },
    });
    return;
  }

  // Handle successful authorization
  if (!code || !state) {
    console.error('Missing code or state in callback');
    await router.push({
      path: '/login',
      query: {
        error: 'invalid_request',
        error_description: 'Missing authorization code or state',
      },
    });
    return;
  }

  // Process the callback
  await authStore.handleOAuthCallback({ code, state });
});
</script>

<template>
  <div class="flex h-screen items-center justify-center">
    <div class="text-center">
      <div class="mb-4">
        <div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent motion-reduce:animate-[spin_1.5s_linear_infinite]"></div>
      </div>
      <p class="text-gray-600">正在完成登录...</p>
    </div>
  </div>
</template>

Step 2: Commit

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:

<script lang="ts" setup>
import { useAuthStore } from '#/store';

defineOptions({ name: 'Login' });

const authStore = useAuthStore();
</script>

<template>
  <div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
    <div class="w-full max-w-md space-y-8">
      <div class="text-center">
        <h2 class="mt-6 text-3xl font-bold text-gray-900">
          登录系统
        </h2>
        <p class="mt-2 text-sm text-gray-600">
          使用 Fengling 认证中心账号登录
        </p>
      </div>

      <div class="mt-8 space-y-4">
        <button
          type="button"
          class="group relative flex w-full justify-center rounded-md bg-blue-600 px-3 py-3 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
          :disabled="authStore.loginLoading"
          @click="authStore.authLogin()"
        >
          <svg v-if="authStore.loginLoading" class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          <svg v-else class="-ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
          </svg>
          {{ authStore.loginLoading ? '正在跳转...' : '使用 Fengling 认证中心登录' }}
        </button>

        <div class="mt-4 text-center text-sm text-gray-500">
          <p>默认账号: admin / Admin@123</p>
          <p>测试账号: testuser / Test@123</p>
        </div>
      </div>

      <!-- OAuth Error Display -->
      <div v-if="$route.query.error" class="mt-6 rounded-md bg-red-50 p-4">
        <div class="flex">
          <div class="flex-shrink-0">
            <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
              <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
            </svg>
          </div>
          <div class="ml-3">
            <h3 class="text-sm font-medium text-red-800">登录失败</h3>
            <div class="mt-2 text-sm text-red-700">
              <p>{{ $route.query.error_description || '未知错误' }}</p>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

Step 2: Commit

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:

{
  path: '/auth/callback',
  name: 'OAuthCallback',
  component: () => import('#/views/_core/authentication/callback.vue'),
  meta: {
    title: 'OAuth Callback',
    hideInMenu: true,
    ignoreAuth: true,
  },
},

Step 2: Commit

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:

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

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

cd /Users/movingsam/Fengling.Refactory.Buiding/src/Fengling.AuthService
dotnet ef migrations add AddVbenOAuthClient

Step 2: Apply migration

dotnet ef database update

Step 3: Commit

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

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

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:

export * from './oauth';

Step 2: Commit

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

cd apps/web-antd
pnpm typecheck

Fix any type errors.

Step 2: Run linter

cd apps/web-antd
pnpm lint

Fix any linting errors.

Step 3: Build production bundle

cd apps/web-antd
pnpm build

Verify build succeeds.

Step 4: Test production build

pnpm preview

Test the flow with production build.

Step 5: Final commit

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.