feat: 实现 POST /v1/auth/link 游客账号关联与数据合并
新增游客到正式账号的关联接口,支持 Apple Sign In, 采用 server_account_first 合并策略: - 场景 A(新用户):游客行原地升级为 Apple 账号 - 场景 B(老用户):事务内合并答题记录、奖励流水等, 不覆盖老账号的订阅、余额、库存、连续学习 包含幂等迁移追踪(accountMigrations 表)、 Apple identity token 验证(jose + JWKS)、 防竞态的原子迁移槽位抢占, 以及 12 个单元测试覆盖两种场景和各类边界。
This commit is contained in:
parent
7dcaf65585
commit
1116b9a2ec
3
bun.lock
3
bun.lock
@ -13,6 +13,7 @@
|
|||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"fastify": "^5.3.0",
|
"fastify": "^5.3.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"uuid": "^11.1.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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
"fastify": "^5.3.0",
|
"fastify": "^5.3.0",
|
||||||
|
"jose": "^6.2.3",
|
||||||
"mysql2": "^3.12.0",
|
"mysql2": "^3.12.0",
|
||||||
"pino": "^9.6.0",
|
"pino": "^9.6.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^11.1.0",
|
||||||
|
|||||||
280
src/__tests__/services/auth/account-link-service.test.ts
Normal file
280
src/__tests__/services/auth/account-link-service.test.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
71
src/__tests__/services/auth/apple-id-token.test.ts
Normal file
71
src/__tests__/services/auth/apple-id-token.test.ts
Normal file
@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,6 +8,7 @@ vi.mock('../utils/config.js', () => ({
|
|||||||
ADMIN_TOKEN: 'test-admin-token',
|
ADMIN_TOKEN: 'test-admin-token',
|
||||||
HUAWEI_CLIENT_ID: 'test-huawei-id',
|
HUAWEI_CLIENT_ID: 'test-huawei-id',
|
||||||
HUAWEI_CLIENT_SECRET: 'test-huawei-secret',
|
HUAWEI_CLIENT_SECRET: 'test-huawei-secret',
|
||||||
|
APPLE_BUNDLE_ID: 'com.duoqi.app',
|
||||||
PORT: 3000,
|
PORT: 3000,
|
||||||
NODE_ENV: 'test',
|
NODE_ENV: 'test',
|
||||||
LOG_LEVEL: 'error',
|
LOG_LEVEL: 'error',
|
||||||
@ -33,6 +34,7 @@ vi.mock('../db/client.js', () => {
|
|||||||
set: vi.fn().mockReturnThis(),
|
set: vi.fn().mockReturnThis(),
|
||||||
delete: vi.fn().mockReturnThis(),
|
delete: vi.fn().mockReturnThis(),
|
||||||
execute: vi.fn().mockResolvedValue([]),
|
execute: vi.fn().mockResolvedValue([]),
|
||||||
|
transaction: vi.fn(),
|
||||||
$dynamic: vi.fn().mockReturnThis(),
|
$dynamic: vi.fn().mockReturnThis(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -480,6 +480,32 @@ export const adRecoverySessions = mysqlTable('ad_recovery_sessions', {
|
|||||||
foreignKey({ columns: [table.userId], foreignColumns: [users.id] }),
|
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<string, number>;
|
||||||
|
skipped: Record<string, number>;
|
||||||
|
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 ────────────────────────────────────────────────
|
// ── Admin Audit Log ────────────────────────────────────────────────
|
||||||
|
|
||||||
// 管理端操作审计日志数据。
|
// 管理端操作审计日志数据。
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { users } from '../db/schema.js';
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from 'drizzle-orm';
|
||||||
import { findOrCreateGuest, findOrCreateHuawei, refreshJwt } from '../services/auth/jwt.js';
|
import { findOrCreateGuest, findOrCreateHuawei, refreshJwt } from '../services/auth/jwt.js';
|
||||||
import { verifyHuaweiToken } from '../services/auth/huawei-id-kit.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';
|
import { NotFoundError } from '../utils/errors.js';
|
||||||
|
|
||||||
const guestLoginSchema = z.object({
|
const guestLoginSchema = z.object({
|
||||||
@ -19,6 +20,16 @@ const refreshTokenSchema = z.object({
|
|||||||
refreshToken: z.string().min(1),
|
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<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
// Auth endpoints: stricter rate limit (10 requests/minute)
|
// Auth endpoints: stricter rate limit (10 requests/minute)
|
||||||
app.post('/auth/guest', { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }, async (request, reply) => {
|
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<void> {
|
|||||||
return reply.send({ success: true, data: result, error: null });
|
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) => {
|
app.get('/auth/me', async (request, reply) => {
|
||||||
const { userId } = request.user;
|
const { userId } = request.user;
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
|||||||
330
src/services/auth/account-link-service.ts
Normal file
330
src/services/auth/account-link-service.ts
Normal file
@ -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<AccountLinkResponse> {
|
||||||
|
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<AccountLinkResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<T extends { select: typeof db.select; update: typeof db.update; execute: typeof db.execute }>(
|
||||||
|
guestId: string,
|
||||||
|
formalId: string,
|
||||||
|
queryable: T,
|
||||||
|
): Promise<MigrationSummary> {
|
||||||
|
const imported: Record<string, number> = {};
|
||||||
|
const skipped: Record<string, number> = {};
|
||||||
|
|
||||||
|
// 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: [] };
|
||||||
|
}
|
||||||
43
src/services/auth/apple-id-token.ts
Normal file
43
src/services/auth/apple-id-token.ts
Normal file
@ -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<AppleUserInfo> {
|
||||||
|
if (!config.APPLE_BUNDLE_ID) {
|
||||||
|
throw new Error('APPLE_BUNDLE_ID is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
let payload: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
const result = await jwtVerify(identityToken, APPLE_JWKS, {
|
||||||
|
issuer: 'https://appleid.apple.com',
|
||||||
|
audience: config.APPLE_BUNDLE_ID,
|
||||||
|
});
|
||||||
|
payload = result.payload as Record<string, unknown>;
|
||||||
|
} 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -5,14 +5,14 @@ import { users } from '../../db/schema.js';
|
|||||||
import { eq, and } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import type { JwtPayload, LoginResponse, AuthType } from '../../types/auth.js';
|
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 payload: JwtPayload = { userId, authType, tier };
|
||||||
const accessToken = app.jwt.sign(payload, { expiresIn: '1h' });
|
const accessToken = app.jwt.sign(payload, { expiresIn: '1h' });
|
||||||
const refreshToken = app.jwt.sign(payload, { expiresIn: '30d' });
|
const refreshToken = app.jwt.sign(payload, { expiresIn: '30d' });
|
||||||
return { accessToken, refreshToken };
|
return { accessToken, refreshToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildLoginResponse(
|
export function buildLoginResponse(
|
||||||
user: typeof users.$inferSelect,
|
user: typeof users.$inferSelect,
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
refreshToken: string,
|
refreshToken: string,
|
||||||
|
|||||||
@ -30,3 +30,21 @@ export interface AdminLoginResponse {
|
|||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account Link
|
||||||
|
|
||||||
|
export interface MigrationSummary {
|
||||||
|
policy: 'server_account_first';
|
||||||
|
imported: Record<string, number>;
|
||||||
|
skipped: Record<string, number>;
|
||||||
|
conflicts: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountLinkResponse {
|
||||||
|
session: {
|
||||||
|
user: LoginResponse['user'];
|
||||||
|
tokens: { accessToken: string; refreshToken: string };
|
||||||
|
};
|
||||||
|
migrationSummary: MigrationSummary;
|
||||||
|
bootstrap: null;
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ const envSchema = z.object({
|
|||||||
OSS_REGION: z.string().optional(),
|
OSS_REGION: z.string().optional(),
|
||||||
HUAWEI_IAP_URL: z.string().optional(),
|
HUAWEI_IAP_URL: z.string().optional(),
|
||||||
HUAWEI_MERCHANT_ID: z.string().optional(),
|
HUAWEI_MERCHANT_ID: z.string().optional(),
|
||||||
|
APPLE_BUNDLE_ID: z.string().optional(),
|
||||||
PORT: z.coerce.number().default(3000),
|
PORT: z.coerce.number().default(3000),
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||||
|
|||||||
@ -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(
|
export function errorHandler(
|
||||||
error: Error,
|
error: Error,
|
||||||
_request: FastifyRequest,
|
_request: FastifyRequest,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user