You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

26 KiB

OAuth2 模块化功能实施计划

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 实现基于 GitHub 的 OAuth2 登录模块,支持新用户 OAuth 登录和已注册用户账号绑定,采用配置驱动架构设计。

Architecture: 采用策略模式实现 OAuth Provider 接口,核心模块 OAuthManager 统一调度,StateStore 管理 CSRF 令牌防止攻击,集成到现有认证系统。

Tech Stack: Nuxt 4 / Nitro API / TypeScript / SQLite / Drizzle ORM


文件结构

packages/oauth/
├── src/
│   ├── index.ts                    # 模块入口
│   ├── core/
│   │   ├── oauth-manager.ts        # 核心管理器
│   │   ├── oauth-state-store.ts    # State 存储
│   │   └── oauth-error.ts          # 错误类
│   ├── providers/
│   │   ├── base.ts                 # Provider 接口
│   │   ├── github.ts               # GitHub Provider
│   │   └── index.ts                # Provider 导出
│   ├── db/
│   │   ├── schema.ts               # oauth_accounts 表定义
│   │   └── queries.ts              # 数据库操作
│   └── api/
│       ├── authorize.get.ts        # 授权接口
│       ├── callback.get.ts         # 回调接口
│       ├── bind.post.ts            # 绑定接口
│       ├── unbind.delete.ts        # 解绑接口
│       ├── bindings.get.ts         # 绑定列表接口
│       └── status.get.ts           # 绑定状态接口
├── package.json
└── tsconfig.json

Task 1: 创建 oauth 包基础结构

Files:

  • Create: packages/oauth/package.json

  • Create: packages/oauth/tsconfig.json

  • Create: packages/oauth/src/index.ts

  • Step 1: 创建目录结构

mkdir -p packages/oauth/src/{core,providers,db,api}
  • Step 2: 创建 package.json
{
    "name": "oauth",
    "version": "0.1.0",
    "type": "module",
    "exports": {
        ".": "./src/index.ts"
    },
    "dependencies": {
        "zod": "4.3.6"
    },
    "devDependencies": {
        "@types/node": "20.0.0"
    }
}
  • Step 3: 创建 tsconfig.json
{
    "extends": "tsconfig/tsconfig.json"
}
  • Step 4: 创建 src/index.ts
export { OAuthManager } from './core/oauth-manager';
export { OAuthStateStore } from './core/oauth-state-store';
export { OAuthError, OAuthErrorCode } from './core/oauth-error';
export type { OAuthProvider, OAuthUserInfo } from './providers/base';
export type { OAuthBinding, OAuthCallbackResult } from './core/oauth-manager';
  • Step 5: 在根目录 package.json 添加 workspace 依赖

在根目录 package.jsonscripts.postinstall 之前添加:

"install:oauth": "bun add -w oauth",

然后运行 bun install 安装依赖。

  • Step 6: Commit
git add packages/oauth/
git commit -m "feat(oauth): 创建 oauth 包基础结构"

Task 2: 实现 OAuth 错误类

Files:

  • Create: packages/oauth/src/core/oauth-error.ts

  • Step 1: 创建 oauth-error.ts

export const OAuthErrorCodes = {
  PROVIDER_NOT_FOUND: 'OAUTH_PROVIDER_NOT_FOUND',
  STATE_INVALID: 'OAUTH_STATE_INVALID',
  STATE_EXPIRED: 'OAUTH_STATE_EXPIRED',
  TOKEN_EXCHANGE_FAILED: 'OAUTH_TOKEN_EXCHANGE_FAILED',
  USER_INFO_FAILED: 'OAUTH_USER_INFO_FAILED',
  ALREADY_BIND: 'OAUTH_ALREADY_BIND',
  BINDING_USER_MISMATCH: 'OAUTH_BINDING_USER_MISMATCH',
  BINDING_TOKEN_INVALID: 'OAUTH_BINDING_TOKEN_INVALID',
  LAST_BIND: 'OAUTH_LAST_BIND',
} as const;

export type OAuthErrorCode = typeof OAuthErrorCodes[keyof typeof OAuthErrorCodes];

export class OAuthError extends Error {
  constructor(
    public code: OAuthErrorCode,
    message: string,
    public statusCode: number = 400
  ) {
    super(message);
    this.name = 'OAuthError';
  }
}
  • Step 2: Commit
git add packages/oauth/src/core/oauth-error.ts
git commit -m "feat(oauth): 添加 OAuth 错误类"

Task 3: 实现 State 存储管理

Files:

  • Create: packages/oauth/src/core/oauth-state-store.ts

  • Step 1: 创建 oauth-state-store.ts

import { randomUUID } from 'crypto';

export type OAuthState = {
  providerName: string;
  redirectUri: string;
  createdAt: number;
  userId?: number;
  isBinding: boolean;
};

const STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes

export class OAuthStateStore {
  private store = new Map<string, OAuthState>();

  // 定期清理过期 state
  private cleanupInterval: NodeJS.Timeout | null = null;

  constructor() {
    this.startCleanup();
  }

  generate(
    providerName: string,
    redirectUri: string,
    userId?: number
  ): { state: string; oauthState: OAuthState } {
    const state = randomUUID();
    const oauthState: OAuthState = {
      providerName,
      redirectUri,
      createdAt: Date.now(),
      userId,
      isBinding: userId !== undefined,
    };
    this.store.set(state, oauthState);
    return { state, oauthState };
  }

  validate(state: string): OAuthState | null {
    const oauthState = this.store.get(state);
    if (!oauthState) {
      return null;
    }

    if (Date.now() - oauthState.createdAt > STATE_TTL_MS) {
      this.store.delete(state);
      return null;
    }

    return oauthState;
  }

  consume(state: string): OAuthState | null {
    const oauthState = this.validate(state);
    if (oauthState) {
      this.store.delete(state);
    }
    return oauthState;
  }

  private startCleanup() {
    // 每分钟清理一次过期 state
    this.cleanupInterval = setInterval(() => {
      const now = Date.now();
      for (const [state, data] of this.store.entries()) {
        if (now - data.createdAt > STATE_TTL_MS) {
          this.store.delete(state);
        }
      }
    }, 60_000);
  }

  destroy() {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }
  }
}

export const oauthStateStore = new OAuthStateStore();
  • Step 2: Commit
git add packages/oauth/src/core/oauth-state-store.ts
git commit -m "feat(oauth): 实现 State 存储管理"

Task 4: 实现 GitHub Provider

Files:

  • Create: packages/oauth/src/providers/base.ts

  • Create: packages/oauth/src/providers/github.ts

  • Create: packages/oauth/src/providers/index.ts

  • Step 1: 创建 providers/base.ts

export interface OAuthProvider {
  name: string;
  icon: string;
  clientId: string;
  clientSecret: string;
  authorizeUrl: string;
  tokenUrl: string;
  userInfoUrl: string;
  scopes: string[];

  getAuthorizationUrl(code: string, state: string): string;
  mapUserInfo(raw: Record<string, unknown>): OAuthUserInfo;
}

export type OAuthUserInfo = {
  provider: string;
  providerUserId: string;
  username?: string;
  email?: string;
  avatar?: string;
};
  • Step 2: 创建 providers/github.ts
import type { OAuthProvider, OAuthUserInfo } from './base';

export class GitHubProvider implements OAuthProvider {
  name = 'github';
  icon = 'github';
  clientId: string;
  clientSecret: string;
  authorizeUrl = 'https://github.com/login/oauth/authorize';
  tokenUrl = 'https://github.com/login/oauth/access_token';
  userInfoUrl = 'https://api.github.com/user';
  scopes = ['read:user', 'user:email'];

  constructor(config: { clientId: string; clientSecret: string }) {
    this.clientId = config.clientId;
    this.clientSecret = config.clientSecret;
  }

  getAuthorizationUrl(code: string, state: string): string {
    const params = new URLSearchParams({
      client_id: this.clientId,
      redirect_uri: `${process.env.APP_URL}/api/auth/oauth/github/callback`,
      scope: this.scopes.join(' '),
      state,
    });
    return `${this.authorizeUrl}?${params.toString()}`;
  }

  mapUserInfo(raw: Record<string, unknown>): OAuthUserInfo {
    return {
      provider: this.name,
      providerUserId: String(raw.id),
      username: String(raw.login ?? ''),
      email: String(raw.email ?? ''),
      avatar: String(raw.avatar_url ?? ''),
    };
  }
}
  • Step 3: 创建 providers/index.ts
import { GitHubProvider } from './github';

export type { OAuthProvider, OAuthUserInfo } from './base';

export const oauthProviders = {
  github: new GitHubProvider({
    clientId: process.env.GITHUB_CLIENT_ID!,
    clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  }),
};

export function getProvider(name: string): OAuthProvider | undefined {
  return (oauthProviders as Record<string, OAuthProvider>)[name];
}
  • Step 4: Commit
git add packages/oauth/src/providers/
git commit -m "feat(oauth): 实现 GitHub Provider"

Task 5: 实现数据库 Schema 和查询

Files:

  • Create: packages/oauth/src/db/schema.ts

  • Create: packages/oauth/src/db/queries.ts

  • Step 1: 创建 db/schema.ts

import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';

export const oauthAccounts = sqliteTable('oauth_accounts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id').notNull(),
  provider: text('provider').notNull(),
  providerUserId: text('provider_user_id').notNull(),
  username: text('username'),
  email: text('email'),
  avatar: text('avatar'),
  createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
  updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

export type OAuthAccount = typeof oauthAccounts.$inferSelect;
export type NewOAuthAccount = typeof oauthAccounts.$inferInsert;

注意: 需要在 drizzle-pkg 中添加 oauth_accounts 表的定义,因为 drizzle-pkg 管理数据库 schema。

  • Step 2: 创建 db/queries.ts
import { dbGlobal } from 'drizzle-pkg/lib/db';
import { oauthAccounts } from './schema';
import { eq, and } from 'drizzle-orm';
import type { NewOAuthAccount } from './schema';
import type { OAuthUserInfo } from '../providers/base';

export async function createOAuthAccount(data: {
  userId: number;
  provider: string;
  providerUserId: string;
  username?: string;
  email?: string;
  avatar?: string;
}): Promise<OAuthAccount> {
  const now = new Date();
  const [account] = await dbGlobal
    .insert(oauthAccounts)
    .values({
      userId: data.userId,
      provider: data.provider,
      providerUserId: data.providerUserId,
      username: data.username,
      email: data.email,
      avatar: data.avatar,
      createdAt: now,
      updatedAt: now,
    })
    .returning();
  return account;
}

export async function findOAuthAccount(
  provider: string,
  providerUserId: string
): Promise<OAuthAccount | null> {
  const [account] = await dbGlobal
    .select()
    .from(oauthAccounts)
    .where(and(
      eq(oauthAccounts.provider, provider),
      eq(oauthAccounts.providerUserId, providerUserId)
    ));
  return account ?? null;
}

export async function findUserOAuthAccounts(userId: number): Promise<OAuthAccount[]> {
  return dbGlobal
    .select()
    .from(oauthAccounts)
    .where(eq(oauthAccounts.userId, userId));
}

export async function deleteOAuthAccount(
  userId: number,
  provider: string
): Promise<boolean> {
  const result = await dbGlobal
    .delete(oauthAccounts)
    .where(and(
      eq(oauthAccounts.userId, userId),
      eq(oauthAccounts.provider, provider)
    ));
  return result.rowCount > 0;
}
  • Step 3: 在 drizzle-pkg 添加 oauth_accounts 表

需要修改 packages/drizzle-pkg/lib/schema/auth.ts 添加 oauth_accounts 表定义。

sessions 表定义后添加:

export const oauthAccounts = sqliteTable('oauth_accounts', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: integer('user_id').notNull(),
  provider: text('provider').notNull(),
  providerUserId: text('provider_user_id').notNull(),
  username: text('username'),
  email: text('email'),
  avatar: text('avatar'),
  createdAt: integer('created_at').default(sql`(cast((julianday('now') - 2440587.5)*86400000 as integer))`),
  updatedAt: integer('updated_at').default(sql`(cast((julianday('now') - 2440587.5)*86400000 as integer))`),
});

然后生成数据库迁移 SQL。

  • Step 4: Commit
git add packages/oauth/src/db/
git add packages/drizzle-pkg/lib/schema/auth.ts
git commit -m "feat(oauth): 添加 oauth_accounts 表和查询"

Task 6: 实现 OAuthManager 核心类

Files:

  • Create: packages/oauth/src/core/oauth-manager.ts

  • Step 1: 创建 core/oauth-manager.ts

import type { OAuthProvider, OAuthUserInfo } from '../providers/base';
import { getProvider } from '../providers';
import { OAuthStateStore, type OAuthState } from './oauth-state-store';
import { OAuthError, OAuthErrorCodes } from './oauth-error';
import {
  createOAuthAccount,
  findOAuthAccount,
  findUserOAuthAccounts,
  deleteOAuthAccount,
} from '../db/queries';
import { registerUser } from 'auth';
import { createSession } from 'auth';
import { MinimalUser } from 'auth';

export type OAuthBinding = {
  id: number;
  provider: string;
  username: string | null;
  email: string | null;
  avatar: string | null;
  boundAt: Date;
};

export type OAuthCallbackResult = {
  success: boolean;
  isNewUser: boolean;
  user?: MinimalUser;
  requiresBinding: boolean;
  bindingToken?: string;
  sessionId?: string;
  expiresAt?: Date;
};

export class OAuthManager {
  private stateStore: OAuthStateStore;

  constructor() {
    this.stateStore = new OAuthStateStore();
  }

  getAuthorizationUrl(providerName: string, userId?: number): string {
    const provider = getProvider(providerName);
    if (!provider) {
      throw new OAuthError(
        OAuthErrorCodes.PROVIDER_NOT_FOUND,
        `Provider ${providerName} not found`
      );
    }

    const redirectUri = userId
      ? `/profile?bind_success=1`
      : `/auth/login?oauth_success=1`;

    const { state } = this.stateStore.generate(providerName, redirectUri, userId);
    return provider.getAuthorizationUrl('', state);
  }

  async handleCallback(
    providerName: string,
    code: string,
    state: string
  ): Promise<OAuthCallbackResult> {
    const provider = getProvider(providerName);
    if (!provider) {
      throw new OAuthError(
        OAuthErrorCodes.PROVIDER_NOT_FOUND,
        `Provider ${providerName} not found`
      );
    }

    const oauthState = this.stateStore.consume(state);
    if (!oauthState) {
      throw new OAuthError(
        OAuthErrorCodes.STATE_INVALID,
        'Invalid or expired state'
      );
    }

    if (oauthState.providerName !== providerName) {
      throw new OAuthError(
        OAuthErrorCodes.STATE_INVALID,
        'State provider mismatch'
      );
    }

    // 交换 token
    const tokenResponse = await this.exchangeToken(provider, code);
    if (!tokenResponse.access_token) {
      throw new OAuthError(
        OAuthErrorCodes.TOKEN_EXCHANGE_FAILED,
        'Failed to exchange token'
      );
    }

    // 获取用户信息
    const userInfo = await this.getUserInfo(provider, tokenResponse.access_token);
    if (!userInfo) {
      throw new OAuthError(
        OAuthErrorCodes.USER_INFO_FAILED,
        'Failed to get user info'
      );
    }

    // 查找是否已绑定
    const existingAccount = await findOAuthAccount(providerName, userInfo.providerUserId);

    if (oauthState.isBinding) {
      // 绑定模式:关联到已登录用户
      if (existingAccount && existingAccount.userId !== oauthState.userId) {
        throw new OAuthError(
          OAuthErrorCodes.ALREADY_BIND,
          'This OAuth account is already bound to another user'
        );
      }

      await createOAuthAccount({
        userId: oauthState.userId!,
        provider: providerName,
        providerUserId: userInfo.providerUserId,
        username: userInfo.username,
        email: userInfo.email,
        avatar: userInfo.avatar,
      });

      return {
        success: true,
        isNewUser: false,
        requiresBinding: false,
      };
    }

    // 登录模式
    if (existingAccount) {
      // 已存在用户,创建 session
      const { sessionId, expiresAt } = await createSession(existingAccount.userId);
      return {
        success: true,
        isNewUser: false,
        requiresBinding: false,
        sessionId,
        expiresAt,
      };
    }

    // 新用户:自动注册
    const newUser = await registerUser({
      username: userInfo.username || `oauth_${providerName}_${userInfo.providerUserId}`,
      password: '', // OAuth 用户不需要密码
    } as any);

    await createOAuthAccount({
      userId: newUser.id,
      provider: providerName,
      providerUserId: userInfo.providerUserId,
      username: userInfo.username,
      email: userInfo.email,
      avatar: userInfo.avatar,
    });

    const { sessionId, expiresAt } = await createSession(newUser.id);

    return {
      success: true,
      isNewUser: true,
      user: newUser,
      requiresBinding: false,
      sessionId,
      expiresAt,
    };
  }

  async bindAccount(
    userId: number,
    providerName: string,
    userInfo: OAuthUserInfo
  ): Promise<void> {
    const existingAccount = await findOAuthAccount(providerName, userInfo.providerUserId);
    if (existingAccount && existingAccount.userId !== userId) {
      throw new OAuthError(
        OAuthErrorCodes.ALREADY_BIND,
        'This OAuth account is already bound to another user'
      );
    }

    if (existingAccount) {
      return; // 已绑定,跳过
    }

    await createOAuthAccount({
      userId,
      provider: providerName,
      providerUserId: userInfo.providerUserId,
      username: userInfo.username,
      email: userInfo.email,
      avatar: userInfo.avatar,
    });
  }

  async unbindAccount(userId: number, providerName: string): Promise<void> {
    const deleted = await deleteOAuthAccount(userId, providerName);
    if (!deleted) {
      throw new OAuthError(
        OAuthErrorCodes.PROVIDER_NOT_FOUND,
        'OAuth account not found'
      );
    }
  }

  async getUserBindings(userId: number): Promise<OAuthBinding[]> {
    const accounts = await findUserOAuthAccounts(userId);
    return accounts.map((account) => ({
      id: account.id,
      provider: account.provider,
      username: account.username,
      email: account.email,
      avatar: account.avatar,
      boundAt: account.createdAt,
    }));
  }

  private async exchangeToken(
    provider: OAuthProvider,
    code: string
  ): Promise<{ access_token?: string; token_type?: string; scope?: string }> {
    const response = await fetch(provider.tokenUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        Accept: 'application/json',
      },
      body: JSON.stringify({
        client_id: provider.clientId,
        client_secret: provider.clientSecret,
        code,
        redirect_uri: `${process.env.APP_URL}/api/auth/oauth/${provider.name}/callback`,
      }),
    });

    if (!response.ok) {
      throw new OAuthError(
        OAuthErrorCodes.TOKEN_EXCHANGE_FAILED,
        'Failed to exchange token'
      );
    }

    return response.json() as Promise<{
      access_token?: string;
      token_type?: string;
      scope?: string;
    }>;
  }

  private async getUserInfo(
    provider: OAuthProvider,
    accessToken: string
  ): Promise<OAuthUserInfo | null> {
    const response = await fetch(provider.userInfoUrl, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
        Accept: 'application/json',
      },
    });

    if (!response.ok) {
      return null;
    }

    const raw = await response.json() as Record<string, unknown>;
    return provider.mapUserInfo(raw);
  }
}

export const oauthManager = new OAuthManager();
  • Step 2: Commit
git add packages/oauth/src/core/oauth-manager.ts
git commit -m "feat(oauth): 实现 OAuthManager 核心类"

Task 7: 实现 API 接口

Files:

  • Create: packages/oauth/src/api/authorize.get.ts

  • Create: packages/oauth/src/api/callback.get.ts

  • Create: packages/oauth/src/api/bind.post.ts

  • Create: packages/oauth/src/api/unbind.delete.ts

  • Create: packages/oauth/src/api/bindings.get.ts

  • Create: packages/oauth/src/api/status.get.ts

  • Step 1: 创建 authorize.get.ts

import { oauthManager } from 'oauth';
import { createAuthContext } from '#server/service/auth/context';

export default defineWrappedResponseHandler(async (event) => {
  const { getCurrent } = createAuthContext(event);
  const user = await getCurrent();

  const providerName = getRouterParam(event, 'provider');
  if (!providerName) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Provider is required',
    });
  }

  const userId = user?.id;
  const authUrl = oauthManager.getAuthorizationUrl(providerName, userId);

  return sendRedirect(event, authUrl);
});
  • Step 2: 创建 callback.get.ts
import { oauthManager } from 'oauth';
import { setSessionCookie } from '#server/service/auth/cookie';

export default defineWrappedResponseHandler(async (event) => {
  const providerName = getRouterParam(event, 'provider');
  const query = getQuery(event);

  const { code, state } = query as { code?: string; state?: string };

  if (!code || !state) {
    return sendRedirect(event, '/auth/login?oauth_error=missing_params');
  }

  try {
    const result = await oauthManager.handleCallback(providerName!, code, state);

    if (result.sessionId) {
      setSessionCookie(event, result.sessionId);
    }

    const redirectUri = new URL(
      result.isNewUser ? '/auth/login?oauth_success=1' : '/auth/login?oauth_success=1'
    );

    return sendRedirect(event, redirectUri.toString());
  } catch (error) {
    const errorCode = error instanceof OAuthError ? error.code : 'OAUTH_UNKNOWN';
    return sendRedirect(event, `/auth/login?oauth_error=${errorCode}`);
  }
});
  • Step 3: 创建 bindings.get.ts
import { oauthManager } from 'oauth';
import { createAuthContext } from '#server/service/auth/context';

export default defineWrappedResponseHandler(async (event) => {
  const { requireUser } = createAuthContext(event);
  const user = await requireUser();

  const bindings = await oauthManager.getUserBindings(user.id);

  return R.success({ bindings });
});
  • Step 4: 创建 status.get.ts
import { oauthManager } from 'oauth';
import { createAuthContext } from '#server/service/auth/context';

export default defineWrappedResponseHandler(async (event) => {
  const { requireUser } = createAuthContext(event);
  const user = await requireUser();

  const providerName = getRouterParam(event, 'provider');
  const bindings = await oauthManager.getUserBindings(user.id);
  const binding = bindings.find((b) => b.provider === providerName);

  return R.success({
    bound: !!binding,
    binding: binding
      ? {
          id: binding.id,
          username: binding.username,
        }
      : undefined,
  });
});
  • Step 5: 创建 unbind.delete.ts
import { oauthManager } from 'oauth';
import { createAuthContext } from '#server/service/auth/context';

export default defineWrappedResponseHandler(async (event) => {
  const { requireUser } = createAuthContext(event);
  const user = await requireUser();

  const providerName = getRouterParam(event, 'provider');

  await oauthManager.unbindAccount(user.id, providerName!);

  return R.success({ success: true });
});
  • Step 6: 创建 bind.post.ts
import { oauthManager } from 'oauth';
import { createAuthContext } from '#server/service/auth/context';

export default defineWrappedResponseHandler(async (event) => {
  const { requireUser } = createAuthContext(event);
  const user = await requireUser();

  const body = await readBody(event);
  const { bindingToken } = body;

  // bindingToken 在当前设计中暂不使用,回调时已直接绑定
  return R.success({
    success: true,
    binding: {
      provider: 'github',
      username: user.username,
    },
  });
});
  • Step 7: Commit
git add packages/oauth/src/api/
git commit -m "feat(oauth): 实现 API 接口"

Task 8: 前端集成

Files:

  • Modify: pages/auth/login.vue (在现有登录表单中添加 GitHub 登录按钮)

  • Step 1: 添加 GitHub 登录按钮到登录页

在现有登录表单的分割线区域添加:

<div class="divider">
  <span></span>
</div>

<button @click="loginWithGithub" class="btn-github">
  <Icon name="github" /> GitHub 登录
</button>
function loginWithGithub() {
  window.location.href = '/api/auth/oauth/github/authorize';
}

onMounted(() => {
  const url = new URL(window.location.href);
  if (url.searchParams.get('oauth_success') === '1') {
    refreshSession();
    router.push('/');
  }
  if (url.searchParams.get('oauth_error')) {
    const error = url.searchParams.get('oauth_error');
    console.error('OAuth error:', error);
  }
});
  • Step 2: Commit
git add pages/auth/login.vue
git commit -m "feat(oauth): 集成 GitHub 登录按钮到登录页"

Task 9: 配置文档

Files:

  • Create: docs/superpowers/oauth2/FLOW.md

  • Modify: docs/superpowers/oauth2/README.md

  • Step 1: 创建 FLOW.md

补充流程图详解文档,包含:

  • 新用户 OAuth 登录完整时序图

  • 已注册用户绑定流程时序图

  • state 生命周期管理说明

  • 错误处理流程

  • Step 2: Commit

git add docs/superpowers/oauth2/
git commit -m "docs(oauth): 补充流程图文档"

自检清单

完成实现后,对照设计文档检查:

  • 6 个 API 接口全部实现
  • GitHub Provider 配置正确
  • State 存储 + 5min TTL 过期清理
  • oauth_accounts 表创建成功
  • 新用户自动注册 + 创建 session
  • 已登录用户绑定第三方账号
  • 解绑、绑定列表、绑定状态接口
  • 前端登录页集成 GitHub 按钮
  • 错误处理覆盖所有错误码
  • 文档齐全(README、API、PROVIDERS、FLOW)

执行选项

Plan complete and saved to docs/superpowers/plans/2026-05-26-oauth2-implementation-plan.md. Two execution options:

1. Subagent-Driven (recommended) - I dispatch a fresh subagent per task, review between tasks, fast iteration

2. Inline Execution - Execute tasks in this session using executing-plans, batch execution with checkpoints

Which approach?