From 1116b9a2ecabe12f6fc0ea3a588f2a52db6c3e91 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Sat, 23 May 2026 13:50:16 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20POST=20/v1/auth/li?= =?UTF-8?q?nk=20=E6=B8=B8=E5=AE=A2=E8=B4=A6=E5=8F=B7=E5=85=B3=E8=81=94?= =?UTF-8?q?=E4=B8=8E=E6=95=B0=E6=8D=AE=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增游客到正式账号的关联接口,支持 Apple Sign In, 采用 server_account_first 合并策略: - 场景 A(新用户):游客行原地升级为 Apple 账号 - 场景 B(老用户):事务内合并答题记录、奖励流水等, 不覆盖老账号的订阅、余额、库存、连续学习 包含幂等迁移追踪(accountMigrations 表)、 Apple identity token 验证(jose + JWKS)、 防竞态的原子迁移槽位抢占, 以及 12 个单元测试覆盖两种场景和各类边界。 --- bun.lock | 3 + package.json | 1 + .../auth/account-link-service.test.ts | 280 +++++++++++++++ .../services/auth/apple-id-token.test.ts | 71 ++++ src/__tests__/setup.ts | 2 + src/db/schema.ts | 26 ++ src/routes/auth.ts | 34 ++ src/services/auth/account-link-service.ts | 330 ++++++++++++++++++ src/services/auth/apple-id-token.ts | 43 +++ src/services/auth/jwt.ts | 4 +- src/types/auth.ts | 18 + src/utils/config.ts | 1 + src/utils/errors.ts | 7 + 13 files changed, 818 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/services/auth/account-link-service.test.ts create mode 100644 src/__tests__/services/auth/apple-id-token.test.ts create mode 100644 src/services/auth/account-link-service.ts create mode 100644 src/services/auth/apple-id-token.ts diff --git a/bun.lock b/bun.lock index 6b479b8..6e92ad1 100644 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "dotenv": "^16.5.0", "drizzle-orm": "^0.44.0", "fastify": "^5.3.0", + "jose": "^6.2.3", "mysql2": "^3.12.0", "pino": "^9.6.0", "uuid": "^11.1.0", @@ -447,6 +448,8 @@ "istanbul-reports": ["istanbul-reports@3.2.0", "https://registry.npmmirror.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="], + "jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "joycon": ["joycon@3.1.1", "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "js-tokens": ["js-tokens@10.0.0", "https://registry.npmmirror.com/js-tokens/-/js-tokens-10.0.0.tgz", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], diff --git a/package.json b/package.json index 1702796..29a1000 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "dotenv": "^16.5.0", "drizzle-orm": "^0.44.0", "fastify": "^5.3.0", + "jose": "^6.2.3", "mysql2": "^3.12.0", "pino": "^9.6.0", "uuid": "^11.1.0", diff --git a/src/__tests__/services/auth/account-link-service.test.ts b/src/__tests__/services/auth/account-link-service.test.ts new file mode 100644 index 0000000..150b288 --- /dev/null +++ b/src/__tests__/services/auth/account-link-service.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { db } from '../../../db/client.js'; +import { linkGuestAccount } from '../../../services/auth/account-link-service.js'; +import { mockSelectQueue as queueSelect } from '../../helpers/db-mock.js'; + +vi.mock('../../../services/auth/apple-id-token.js', () => ({ + verifyAppleIdentityToken: vi.fn().mockResolvedValue({ + appleSub: 'apple-user-123', + email: 'test@example.com', + }), +})); + +const mockApp = { + jwt: { + sign: vi.fn().mockReturnValue('mock-jwt-token'), + }, +} as never; + +const baseGuestUser = { + id: 'guest-001', + authType: 'guest', + authId: 'device-abc', + nickname: null, + avatarUrl: null, + tier: 'free', + xpTotal: 0, + streakDays: 0, + heartsRemaining: 5, + heartsLastRestore: null, + dailyXpGoal: 50, + dailyXpEarned: 0, + dailyXpDate: null, + currentTheme: 'inkTeal', + activeTrackId: 'track-1', + dailyAttemptsLeft: 5, + dailyAttemptsDate: null, + checkInDays: 0, + lastCheckInDate: null, + streakProtectedUntil: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const baseFormalUser = { + ...baseGuestUser, + id: 'formal-001', + authType: 'apple', + authId: 'apple-user-123', + nickname: '老用户', + xpTotal: 500, + streakDays: 10, + activeTrackId: null, +}; + +function mockUpdateChain() { + return { set: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }) } as never; +} + +function mockInsertSuccess() { + return { values: vi.fn().mockResolvedValue(undefined) } as never; +} + +function makeDupError() { + const err = new Error('Duplicate entry') as Error & { code: string }; + err.code = 'ER_DUP_ENTRY'; + return err; +} + +describe('linkGuestAccount', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('Scenario A: upgrades guest in-place when no formal user exists', async () => { + // Select queue: [guest user], [formal user (none)], [re-read upgraded] + queueSelect(vi.mocked(db.select), [ + [baseGuestUser], + [], + [{ ...baseGuestUser, authType: 'apple', authId: 'apple-user-123' }], + ]); + + vi.mocked(db.insert).mockReturnValue(mockInsertSuccess()); + vi.mocked(db.update).mockReturnValue(mockUpdateChain()); + + const result = await linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-001', + app: mockApp, + }); + + expect(result.session.user.id).toBe('guest-001'); + expect(result.migrationSummary.imported).toEqual({}); + expect(result.migrationSummary.conflicts).toEqual([]); + expect(result.bootstrap).toBeNull(); + }); + + it('Scenario B: merges guest data when formal user exists', async () => { + const formalUser = { ...baseFormalUser }; + + // db.transaction mock + const mockTx = { + select: vi.fn(), + update: vi.fn().mockReturnValue(mockUpdateChain()), + execute: vi.fn().mockResolvedValue([{ affectedRows: 1 }]), + }; + + // Set up mockTx.select for merge queries inside transaction + // The merge function uses .select().from().where() chains and awaits them + const txSelectResponses = [ + [{ questionId: 'q1' }, { questionId: 'q2' }], // guest answers + [{ questionId: 'q1' }], // formal answered q1 + [{ activeTrackId: 'track-1' }], // guest activeTrackId + ]; + let txSelectIdx = 0; + mockTx.select.mockImplementation(() => { + const rows = txSelectResponses[txSelectIdx] ?? []; + txSelectIdx++; + // Chainable + thenable: supports .from().where() chaining AND `await` + const chain = { + then: (resolve: (v: unknown) => unknown, reject?: (r: unknown) => unknown) => + Promise.resolve(rows).then(resolve, reject), + } as Record; + const from = vi.fn().mockReturnValue(chain); + const where = vi.fn().mockReturnValue(chain); + const limit = vi.fn().mockResolvedValue(rows); + chain.from = from; + chain.where = where; + chain.limit = limit; + return { from, where, limit }; + }); + + vi.mocked(db.transaction).mockImplementation(async (fn) => fn(mockTx as never) as never); + vi.mocked(db.insert).mockReturnValue(mockInsertSuccess()); + vi.mocked(db.update).mockReturnValue(mockUpdateChain()); + + // Outer selects: [guest user], [formal user] + queueSelect(vi.mocked(db.select), [ + [baseGuestUser], + [formalUser], + ]); + + const result = await linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-002', + app: mockApp, + }); + + expect(result.session.user.id).toBe('formal-001'); + expect(result.migrationSummary.imported.answers).toBe(1); + expect(result.migrationSummary.skipped.duplicateAnswers).toBe(1); + expect(mockTx.execute).toHaveBeenCalled(); + }); + + it('returns idempotent result for duplicate clientMigrationId', async () => { + const formalUser = { ...baseFormalUser }; + const existingMigration = { + id: 'mig-001', + guestUserId: 'guest-001', + formalUserId: 'formal-001', + provider: 'apple' as const, + providerUserId: 'apple-user-123', + clientMigrationId: 'migration-dup', + status: 'completed', + migrationSummary: { policy: 'server_account_first', imported: { answers: 1 }, skipped: {}, conflicts: [] }, + createdAt: new Date(), + completedAt: new Date(), + }; + + // insert().values() throws duplicate key → handleExistingMigration → selects + vi.mocked(db.insert).mockReturnValueOnce({ + values: vi.fn().mockRejectedValue(makeDupError()), + } as never); + + queueSelect(vi.mocked(db.select), [ + [baseGuestUser], // guest user + [existingMigration], // migration found + [formalUser], // formal user for token + ]); + + const result = await linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-dup', + app: mockApp, + }); + + expect(result.session.user.id).toBe('formal-001'); + expect(result.migrationSummary.imported.answers).toBe(1); + }); + + it('throws AUTH_LINK_CONFLICT for non-guest user', async () => { + const nonGuestUser = { ...baseGuestUser, authType: 'apple' as const }; + + queueSelect(vi.mocked(db.select), [[nonGuestUser]]); + + await expect( + linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-003', + app: mockApp, + }), + ).rejects.toThrow('Account is not a guest account'); + }); + + it('throws AUTH_MIGRATION_DUPLICATE when migration is in_progress', async () => { + const inProgressMigration = { + id: 'mig-002', + guestUserId: 'guest-001', + formalUserId: null, + provider: 'apple' as const, + providerUserId: 'apple-user-123', + clientMigrationId: 'migration-in-progress', + status: 'in_progress', + migrationSummary: null, + createdAt: new Date(), + completedAt: null, + }; + + vi.mocked(db.insert).mockReturnValueOnce({ + values: vi.fn().mockRejectedValue(makeDupError()), + } as never); + + queueSelect(vi.mocked(db.select), [ + [baseGuestUser], + [inProgressMigration], + ]); + + await expect( + linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-in-progress', + app: mockApp, + }), + ).rejects.toThrow('Migration already in progress'); + }); + + it('throws VALIDATION_ERROR for unsupported provider', async () => { + queueSelect(vi.mocked(db.select), [[baseGuestUser]]); + + await expect( + linkGuestAccount({ + guestUserId: 'guest-001', + provider: 'google', + credential: { identityToken: 'some-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-004', + app: mockApp, + }), + ).rejects.toThrow('Provider not supported'); + }); + + it('throws when user not found', async () => { + queueSelect(vi.mocked(db.select), [[]]); + + await expect( + linkGuestAccount({ + guestUserId: 'nonexistent', + provider: 'apple', + credential: { identityToken: 'valid-apple-token' }, + mergePolicy: 'server_account_first', + clientMigrationId: 'migration-005', + app: mockApp, + }), + ).rejects.toThrow('User not found'); + }); +}); diff --git a/src/__tests__/services/auth/apple-id-token.test.ts b/src/__tests__/services/auth/apple-id-token.test.ts new file mode 100644 index 0000000..178b429 --- /dev/null +++ b/src/__tests__/services/auth/apple-id-token.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock jose module +vi.mock('jose', () => ({ + createRemoteJWKSet: vi.fn().mockReturnValue('mock-jwks'), + jwtVerify: vi.fn(), +})); + +const { verifyAppleIdentityToken } = await import('../../../services/auth/apple-id-token.js'); +const { jwtVerify } = await import('jose'); + +describe('verifyAppleIdentityToken', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns Apple user info for valid token', async () => { + vi.mocked(jwtVerify).mockResolvedValue({ + payload: { sub: 'apple-sub-123', email: 'user@example.com', iat: Math.floor(Date.now() / 1000) }, + } as never); + + const result = await verifyAppleIdentityToken('valid-identity-token'); + + expect(result).toEqual({ + appleSub: 'apple-sub-123', + email: 'user@example.com', + }); + }); + + it('returns null email when not present', async () => { + vi.mocked(jwtVerify).mockResolvedValue({ + payload: { sub: 'apple-sub-456', iat: Math.floor(Date.now() / 1000) }, + } as never); + + const result = await verifyAppleIdentityToken('valid-identity-token'); + + expect(result).toEqual({ + appleSub: 'apple-sub-456', + email: null, + }); + }); + + it('throws UnauthorizedError when verification fails', async () => { + vi.mocked(jwtVerify).mockRejectedValue(new Error('Invalid signature')); + + await expect(verifyAppleIdentityToken('bad-token')).rejects.toThrow( + 'Apple identity token verification failed', + ); + }); + + it('throws UnauthorizedError when sub is missing', async () => { + vi.mocked(jwtVerify).mockResolvedValue({ + payload: { iat: Math.floor(Date.now() / 1000) }, + } as never); + + await expect(verifyAppleIdentityToken('token-no-sub')).rejects.toThrow( + 'Apple identity token missing sub claim', + ); + }); + + it('rejects tokens issued more than 5 minutes ago', async () => { + const oldIat = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + vi.mocked(jwtVerify).mockResolvedValue({ + payload: { sub: 'apple-sub-old', iat: oldIat }, + } as never); + + await expect(verifyAppleIdentityToken('old-token')).rejects.toThrow( + 'Apple identity token expired', + ); + }); +}); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 9b22ac9..180e35e 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -8,6 +8,7 @@ vi.mock('../utils/config.js', () => ({ ADMIN_TOKEN: 'test-admin-token', HUAWEI_CLIENT_ID: 'test-huawei-id', HUAWEI_CLIENT_SECRET: 'test-huawei-secret', + APPLE_BUNDLE_ID: 'com.duoqi.app', PORT: 3000, NODE_ENV: 'test', LOG_LEVEL: 'error', @@ -33,6 +34,7 @@ vi.mock('../db/client.js', () => { set: vi.fn().mockReturnThis(), delete: vi.fn().mockReturnThis(), execute: vi.fn().mockResolvedValue([]), + transaction: vi.fn(), $dynamic: vi.fn().mockReturnThis(), }; diff --git a/src/db/schema.ts b/src/db/schema.ts index 05a8bed..a4ef037 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -480,6 +480,32 @@ export const adRecoverySessions = mysqlTable('ad_recovery_sessions', { foreignKey({ columns: [table.userId], foreignColumns: [users.id] }), ]); +// ── Account Migrations ───────────────────────────────────────────── + +// 游客账号关联正式账号的迁移记录,用于幂等追踪。 +export const accountMigrations = mysqlTable('account_migrations', { + id: char('id', { length: 36 }).primaryKey(), + guestUserId: char('guest_user_id', { length: 36 }).notNull(), + formalUserId: char('formal_user_id', { length: 36 }), + provider: mysqlEnum('provider', ['apple', 'google', 'phone']).notNull(), + providerUserId: varchar('provider_user_id', { length: 255 }).notNull(), + clientMigrationId: varchar('client_migration_id', { length: 80 }).notNull(), + status: mysqlEnum('status', ['in_progress', 'completed', 'failed']).default('in_progress'), + migrationSummary: json('migration_summary').$type<{ + policy: string; + imported: Record; + skipped: Record; + conflicts: readonly string[]; + }>(), + createdAt: datetime('created_at').default(sql`CURRENT_TIMESTAMP`), + completedAt: datetime('completed_at'), +}, (table) => [ + uniqueIndex('uk_migration_guest_client').on(table.guestUserId, table.clientMigrationId), + uniqueIndex('uk_migration_guest_provider').on(table.guestUserId, table.provider), + foreignKey({ columns: [table.guestUserId], foreignColumns: [users.id] }), + foreignKey({ columns: [table.formalUserId], foreignColumns: [users.id] }), +]); + // ── Admin Audit Log ──────────────────────────────────────────────── // 管理端操作审计日志数据。 diff --git a/src/routes/auth.ts b/src/routes/auth.ts index a0dfc06..bad2b81 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -5,6 +5,7 @@ import { users } from '../db/schema.js'; import { eq } from 'drizzle-orm'; import { findOrCreateGuest, findOrCreateHuawei, refreshJwt } from '../services/auth/jwt.js'; import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.js'; +import { linkGuestAccount } from '../services/auth/account-link-service.js'; import { NotFoundError } from '../utils/errors.js'; const guestLoginSchema = z.object({ @@ -19,6 +20,16 @@ const refreshTokenSchema = z.object({ refreshToken: z.string().min(1), }); +const linkSchema = z.object({ + provider: z.enum(['apple']), + credential: z.object({ + authorizationCode: z.string().optional(), + identityToken: z.string().min(1), + }), + mergePolicy: z.literal('server_account_first'), + clientMigrationId: z.string().min(1), +}); + export async function authRoutes(app: FastifyInstance): Promise { // Auth endpoints: stricter rate limit (10 requests/minute) app.post('/auth/guest', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { @@ -73,6 +84,29 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.send({ success: true, data: result, error: null }); }); + app.post('/auth/link', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => { + const parsed = linkSchema.safeParse(request.body); + if (!parsed.success) { + return reply.status(400).send({ + success: false, + data: null, + error: { code: 'VALIDATION_ERROR', message: parsed.error.issues[0]?.message ?? 'Invalid input' }, + }); + } + + const { userId } = request.user; + const result = await linkGuestAccount({ + guestUserId: userId, + provider: parsed.data.provider, + credential: parsed.data.credential, + mergePolicy: parsed.data.mergePolicy, + clientMigrationId: parsed.data.clientMigrationId, + app, + }); + + return reply.send({ success: true, data: result, error: null }); + }); + app.get('/auth/me', async (request, reply) => { const { userId } = request.user; const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); diff --git a/src/services/auth/account-link-service.ts b/src/services/auth/account-link-service.ts new file mode 100644 index 0000000..0f96c48 --- /dev/null +++ b/src/services/auth/account-link-service.ts @@ -0,0 +1,330 @@ +import type { FastifyInstance } from 'fastify'; +import { eq, and, sql, inArray } from 'drizzle-orm'; +import { db } from '../../db/client.js'; +import { + users, + accountMigrations, + userProgress, +} from '../../db/schema.js'; +import type { AccountLinkResponse, MigrationSummary } from '../../types/auth.js'; +import { signTokens } from './jwt.js'; +import { verifyAppleIdentityToken } from './apple-id-token.js'; +import { + AppError, + ForbiddenError, + ConflictError, + ValidationError, +} from '../../utils/errors.js'; + +// DB users table auth_type enum (excludes 'admin') +type UserAuthType = 'huawei' | 'guest' | 'phone' | 'apple' | 'google'; + +const MYSQL_DUP_ENTRY = 'ER_DUP_ENTRY'; + +interface LinkParams { + guestUserId: string; + provider: string; + credential: { authorizationCode?: string; identityToken?: string }; + mergePolicy: string; + clientMigrationId: string; + app: FastifyInstance; +} + +async function verifyProvider( + provider: string, + credential: { authorizationCode?: string; identityToken?: string }, +): Promise<{ providerUserId: string; email: string | null }> { + if (provider !== 'apple') { + throw new ValidationError('Provider not supported'); + } + + if (!credential.identityToken) { + throw new ValidationError('identityToken is required for Apple login'); + } + + const info = await verifyAppleIdentityToken(credential.identityToken); + return { providerUserId: info.appleSub, email: info.email }; +} + +function emptySummary(): MigrationSummary { + return { + policy: 'server_account_first', + imported: {}, + skipped: {}, + conflicts: [], + }; +} + +function isDuplicateKeyError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error as { code: string }).code === MYSQL_DUP_ENTRY; +} + +export async function linkGuestAccount(params: LinkParams): Promise { + const { guestUserId, provider, credential, clientMigrationId, app } = params; + + // Step 1: Verify guest user + const [guestUser] = await db.select().from(users).where(eq(users.id, guestUserId)).limit(1); + if (!guestUser) { + throw new ForbiddenError('User not found'); + } + if (guestUser.authType !== 'guest') { + throw new ConflictError('Account is not a guest account', 'AUTH_LINK_CONFLICT'); + } + + // Step 2: Verify provider credential + const { providerUserId } = await verifyProvider(provider, credential); + + // Step 3: Idempotency — try to claim migration slot atomically + const migrationId = crypto.randomUUID(); + try { + await db.insert(accountMigrations).values({ + id: migrationId, + guestUserId, + provider: provider as 'apple' | 'google' | 'phone', + providerUserId, + clientMigrationId, + status: 'in_progress', + }); + } catch (error: unknown) { + if (isDuplicateKeyError(error)) { + // Duplicate clientMigrationId or guest+provider — fetch existing + return handleExistingMigration(guestUserId, clientMigrationId, app); + } + throw new AppError('Unable to start account linking', 500, 'AUTH_MERGE_FAILED'); + } + + // Step 4: Find formal account + const [formalUser] = await db + .select() + .from(users) + .where(and(eq(users.authType, provider as UserAuthType), eq(users.authId, providerUserId))) + .limit(1); + + try { + // Scenario A: No existing formal user — upgrade guest in-place + if (!formalUser) { + await db + .update(users) + .set({ authType: provider as UserAuthType, authId: providerUserId }) + .where(eq(users.id, guestUserId)); + + const [upgradedUser] = await db.select().from(users).where(eq(users.id, guestUserId)).limit(1); + if (!upgradedUser) throw new AppError('Failed to upgrade user', 500, 'AUTH_MERGE_FAILED'); + + const tokens = signTokens(app, upgradedUser.id, upgradedUser.authType as UserAuthType, upgradedUser.tier ?? 'free'); + const summary = emptySummary(); + + await completeMigration(migrationId, guestUserId, summary); + + return { + session: { user: buildUserObj(upgradedUser), tokens }, + migrationSummary: summary, + bootstrap: null, + }; + } + + // Scenario B: Existing formal user — merge guest data in transaction + const summary = await db.transaction(async (tx) => { + const result = await mergeGuestToFormal(guestUserId, formalUser.id, tx); + + // Mark guest as consumed + const consumedAuthId = `consumed:${guestUser.authId}`; + await tx + .update(users) + .set({ authId: consumedAuthId }) + .where(eq(users.id, guestUserId)); + + return result; + }); + + await completeMigration(migrationId, formalUser.id, summary); + + const tokens = signTokens(app, formalUser.id, formalUser.authType as UserAuthType, formalUser.tier ?? 'free'); + return { + session: { user: buildUserObj(formalUser), tokens }, + migrationSummary: summary, + bootstrap: null, + }; + } catch (error: unknown) { + // Mark migration as failed so retry creates a new attempt + await db + .update(accountMigrations) + .set({ status: 'failed' }) + .where(eq(accountMigrations.id, migrationId)); + + if (isDuplicateKeyError(error)) { + throw new ConflictError('Account already linked', 'AUTH_LINK_CONFLICT'); + } + throw new AppError('Unable to complete account linking', 500, 'AUTH_MERGE_FAILED'); + } +} + +async function handleExistingMigration( + guestUserId: string, + clientMigrationId: string, + app: FastifyInstance, +): Promise { + const [existing] = await db + .select() + .from(accountMigrations) + .where( + and( + eq(accountMigrations.guestUserId, guestUserId), + eq(accountMigrations.clientMigrationId, clientMigrationId), + ), + ) + .limit(1); + + if (!existing) { + // Duplicate was on guest+provider unique key — already linked + throw new ConflictError('Account already linked to this provider', 'AUTH_LINK_CONFLICT'); + } + + if (existing.status === 'completed' && existing.formalUserId) { + const [formalUser] = await db.select().from(users).where(eq(users.id, existing.formalUserId)).limit(1); + if (formalUser) { + const tokens = signTokens(app, formalUser.id, formalUser.authType as UserAuthType, formalUser.tier ?? 'free'); + return { + session: { user: buildUserObj(formalUser), tokens }, + migrationSummary: (existing.migrationSummary as MigrationSummary | null) ?? emptySummary(), + bootstrap: null, + }; + } + } + + if (existing.status === 'in_progress') { + throw new ConflictError('Migration already in progress', 'AUTH_MIGRATION_DUPLICATE'); + } + + throw new ConflictError('Unable to complete account linking', 'AUTH_MERGE_FAILED'); +} + +async function completeMigration( + migrationId: string, + formalUserId: string, + summary: MigrationSummary, +): Promise { + await db + .update(accountMigrations) + .set({ + formalUserId, + status: 'completed', + migrationSummary: summary, + completedAt: new Date(), + }) + .where(eq(accountMigrations.id, migrationId)); +} + +function buildUserObj(user: typeof users.$inferSelect) { + return { + id: user.id, + nickname: user.nickname ?? null, + avatarUrl: user.avatarUrl ?? null, + tier: user.tier ?? 'free', + xpTotal: user.xpTotal ?? 0, + streakDays: user.streakDays ?? 0, + }; +} + +async function mergeGuestToFormal( + guestId: string, + formalId: string, + queryable: T, +): Promise { + const imported: Record = {}; + const skipped: Record = {}; + + // userProgress: insert guest answers, skip questions formal user already answered + const guestAnswers = await queryable + .select({ questionId: userProgress.questionId }) + .from(userProgress) + .where(eq(userProgress.userId, guestId)); + + if (guestAnswers.length > 0) { + const formalAnswered = new Set( + ( + await queryable + .select({ questionId: userProgress.questionId }) + .from(userProgress) + .where(eq(userProgress.userId, formalId)) + ).map((r) => r.questionId), + ); + + const newAnswers = guestAnswers.filter((a) => !formalAnswered.has(a.questionId)); + const dupAnswers = guestAnswers.length - newAnswers.length; + + if (dupAnswers > 0) { + skipped.duplicateAnswers = dupAnswers; + } + + if (newAnswers.length > 0) { + const newQuestionIds = newAnswers.map((a) => a.questionId); + await queryable + .update(userProgress) + .set({ userId: formalId }) + .where(and(eq(userProgress.userId, guestId), inArray(userProgress.questionId, newQuestionIds))); + imported.answers = newAnswers.length; + } + } + + // userChapterProgress: INSERT IGNORE via raw SQL + const chapterResult = await queryable.execute(sql` + INSERT IGNORE INTO user_chapter_progress (id, user_id, chapter_id, status, best_correct_count, attempts, completed_at) + SELECT UUID(), ${formalId}, chapter_id, status, best_correct_count, attempts, completed_at + FROM user_chapter_progress + WHERE user_id = ${guestId} + `); + const chapterRows = Number(chapterResult[0].affectedRows) || 0; + if (chapterRows > 0) imported.chapterProgress = chapterRows; + + // rewardLedger: INSERT IGNORE + const rewardResult = await queryable.execute(sql` + INSERT IGNORE INTO reward_ledger (id, user_id, source_type, source_id, idempotency_key, status, reward_snapshot, resource_deltas, state_before, state_after, failure_reason, settled_at, created_at, updated_at) + SELECT UUID(), ${formalId}, source_type, source_id, idempotency_key, status, reward_snapshot, resource_deltas, state_before, state_after, failure_reason, settled_at, created_at, updated_at + FROM reward_ledger + WHERE user_id = ${guestId} + `); + const rewardRows = Number(rewardResult[0].affectedRows) || 0; + if (rewardRows > 0) imported.rewardEvents = rewardRows; + + // inventoryTransactions: INSERT IGNORE + const txResult = await queryable.execute(sql` + INSERT IGNORE INTO inventory_transactions (id, user_id, inventory_item_id, item_id, direction, quantity_delta, balance_after, source_type, source_id, idempotency_key, snapshot, created_at) + SELECT UUID(), ${formalId}, inventory_item_id, item_id, direction, quantity_delta, balance_after, source_type, source_id, idempotency_key, snapshot, created_at + FROM inventory_transactions + WHERE user_id = ${guestId} + `); + const txRows = Number(txResult[0].affectedRows) || 0; + if (txRows > 0) imported.inventoryTransactions = txRows; + + // userAchievements: INSERT IGNORE + const achieveResult = await queryable.execute(sql` + INSERT IGNORE INTO user_achievements (id, user_id, achievement_id, unlocked_at) + SELECT UUID(), ${formalId}, achievement_id, unlocked_at + FROM user_achievements + WHERE user_id = ${guestId} + `); + const achieveRows = Number(achieveResult[0].affectedRows) || 0; + if (achieveRows > 0) imported.achievements = achieveRows; + + // questionRatings: INSERT IGNORE + const ratingResult = await queryable.execute(sql` + INSERT IGNORE INTO question_ratings (id, user_id, question_id, rating, created_at) + SELECT UUID(), ${formalId}, question_id, rating, created_at + FROM question_ratings + WHERE user_id = ${guestId} + `); + const ratingRows = Number(ratingResult[0].affectedRows) || 0; + if (ratingRows > 0) imported.questionRatings = ratingRows; + + // activeTrackId: copy only if formal user doesn't have one + const [guestRow] = await queryable.select({ activeTrackId: users.activeTrackId }).from(users).where(eq(users.id, guestId)).limit(1); + if (guestRow?.activeTrackId) { + await queryable + .update(users) + .set({ activeTrackId: guestRow.activeTrackId }) + .where(and(eq(users.id, formalId), sql`active_track_id IS NULL`)); + } + + return { policy: 'server_account_first', imported, skipped, conflicts: [] }; +} diff --git a/src/services/auth/apple-id-token.ts b/src/services/auth/apple-id-token.ts new file mode 100644 index 0000000..4a47e6a --- /dev/null +++ b/src/services/auth/apple-id-token.ts @@ -0,0 +1,43 @@ +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { config } from '../../utils/config.js'; +import { UnauthorizedError } from '../../utils/errors.js'; + +export interface AppleUserInfo { + appleSub: string; + email: string | null; +} + +const APPLE_JWKS = createRemoteJWKSet(new URL('https://appleid.apple.com/auth/keys')); + +export async function verifyAppleIdentityToken(identityToken: string): Promise { + if (!config.APPLE_BUNDLE_ID) { + throw new Error('APPLE_BUNDLE_ID is not configured'); + } + + let payload: Record; + try { + const result = await jwtVerify(identityToken, APPLE_JWKS, { + issuer: 'https://appleid.apple.com', + audience: config.APPLE_BUNDLE_ID, + }); + payload = result.payload as Record; + } catch { + throw new UnauthorizedError('Apple identity token verification failed'); + } + + // Reject tokens issued more than 5 minutes ago (replay protection) + const iat = payload.iat; + if (typeof iat === 'number' && Date.now() / 1000 - iat > 300) { + throw new UnauthorizedError('Apple identity token expired'); + } + + const sub = payload.sub; + if (typeof sub !== 'string' || !sub) { + throw new UnauthorizedError('Apple identity token missing sub claim'); + } + + return { + appleSub: sub, + email: typeof payload.email === 'string' ? payload.email : null, + }; +} diff --git a/src/services/auth/jwt.ts b/src/services/auth/jwt.ts index 20e251d..493ee96 100644 --- a/src/services/auth/jwt.ts +++ b/src/services/auth/jwt.ts @@ -5,14 +5,14 @@ import { users } from '../../db/schema.js'; import { eq, and } from 'drizzle-orm'; import type { JwtPayload, LoginResponse, AuthType } from '../../types/auth.js'; -function signTokens(app: FastifyInstance, userId: string, authType: AuthType, tier: string) { +export function signTokens(app: FastifyInstance, userId: string, authType: AuthType, tier: string) { const payload: JwtPayload = { userId, authType, tier }; const accessToken = app.jwt.sign(payload, { expiresIn: '1h' }); const refreshToken = app.jwt.sign(payload, { expiresIn: '30d' }); return { accessToken, refreshToken }; } -function buildLoginResponse( +export function buildLoginResponse( user: typeof users.$inferSelect, accessToken: string, refreshToken: string, diff --git a/src/types/auth.ts b/src/types/auth.ts index 3b2ff3b..b6f4023 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -30,3 +30,21 @@ export interface AdminLoginResponse { role: string; }; } + +// Account Link + +export interface MigrationSummary { + policy: 'server_account_first'; + imported: Record; + skipped: Record; + conflicts: readonly string[]; +} + +export interface AccountLinkResponse { + session: { + user: LoginResponse['user']; + tokens: { accessToken: string; refreshToken: string }; + }; + migrationSummary: MigrationSummary; + bootstrap: null; +} diff --git a/src/utils/config.ts b/src/utils/config.ts index fd8872a..5e9ef2b 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -15,6 +15,7 @@ const envSchema = z.object({ OSS_REGION: z.string().optional(), HUAWEI_IAP_URL: z.string().optional(), HUAWEI_MERCHANT_ID: z.string().optional(), + APPLE_BUNDLE_ID: z.string().optional(), PORT: z.coerce.number().default(3000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 0f55687..d2361d4 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -39,6 +39,13 @@ export class ValidationError extends AppError { } } +export class ConflictError extends AppError { + constructor(message: string, code = 'CONFLICT') { + super(message, 409, code); + this.name = 'ConflictError'; + } +} + export function errorHandler( error: Error, _request: FastifyRequest,