feat(gateway): recover orphaned Kakao account links
This commit is contained in:
@@ -161,24 +161,58 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async linkKakao(userId, input): Promise<UserRecord> {
|
||||
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||
if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) {
|
||||
throw new Error('Kakao account already linked.');
|
||||
}
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
|
||||
}
|
||||
if (user.email && user.email.toLowerCase() !== normalizedEmail) {
|
||||
usersByEmail.delete(user.email.toLowerCase());
|
||||
}
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = input.oauthId;
|
||||
user.email = input.email.toLowerCase();
|
||||
user.email = normalizedEmail;
|
||||
user.oauthInfo = input.oauthInfo;
|
||||
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
||||
user.kakaoTalkVerifiedUntil = undefined;
|
||||
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
||||
usersByEmail.set(user.email, user);
|
||||
return user;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`);
|
||||
const emailOwner = usersByEmail.get(normalizedEmail);
|
||||
if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) {
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
}
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId || user.email?.toLowerCase() !== normalizedEmail) {
|
||||
continue;
|
||||
}
|
||||
if (user.oauthType === 'KAKAO' && user.oauthId) {
|
||||
usersByOauthId.delete(`KAKAO:${user.oauthId}`);
|
||||
}
|
||||
user.oauthType = 'KAKAO';
|
||||
user.oauthId = input.oauthId;
|
||||
user.oauthInfo = input.oauthInfo;
|
||||
user.kakaoVerifiedAt = input.verifiedAt.toISOString();
|
||||
user.kakaoTalkVerifiedUntil = undefined;
|
||||
usersByOauthId.set(`KAKAO:${input.oauthId}`, user);
|
||||
return user;
|
||||
}
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
},
|
||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface OAuthPendingState {
|
||||
export interface OAuthSession {
|
||||
id: string;
|
||||
mode: OAuthMode;
|
||||
intent?: 'register' | 'link_existing' | 'rejoin';
|
||||
targetUserId?: string;
|
||||
kakaoId: string;
|
||||
email: string;
|
||||
accessToken: string;
|
||||
|
||||
@@ -251,10 +251,32 @@ export const createPostgresUserRepository = (
|
||||
email: input.email.toLowerCase(),
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
},
|
||||
});
|
||||
return mapUser(row);
|
||||
},
|
||||
async relinkKakaoByEmail(userId, input): Promise<UserRecord> {
|
||||
const normalizedEmail = input.email.toLowerCase();
|
||||
const updated = await prisma.appUser.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
email: normalizedEmail,
|
||||
},
|
||||
data: {
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: input.oauthId,
|
||||
oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject,
|
||||
kakaoVerifiedAt: input.verifiedAt,
|
||||
kakaoTalkVerifiedUntil: null,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error('Kakao account recovery ownership changed.');
|
||||
}
|
||||
const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } });
|
||||
return mapUser(row);
|
||||
},
|
||||
async updateRoles(userId: string, roles: string[]): Promise<void> {
|
||||
await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -122,6 +122,15 @@ export interface UserRepository {
|
||||
verifiedAt: Date;
|
||||
}
|
||||
): Promise<UserRecord>;
|
||||
relinkKakaoByEmail(
|
||||
userId: string,
|
||||
input: {
|
||||
oauthId: string;
|
||||
email: string;
|
||||
oauthInfo: UserOAuthInfo;
|
||||
verifiedAt: Date;
|
||||
}
|
||||
): Promise<UserRecord>;
|
||||
updateRoles(userId: string, roles: string[]): Promise<void>;
|
||||
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
|
||||
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
|
||||
|
||||
@@ -35,6 +35,7 @@ const zUsername = z
|
||||
const zPassword = z.string().min(6).max(128);
|
||||
const zProfile = z.string().min(1).max(64);
|
||||
const zOAuthMode = z.enum(['login', 'change_pw', 'verify']);
|
||||
const zKakaoRecoveryAction = z.enum(['link_existing', 'rejoin']);
|
||||
const zBootstrapToken = z.string().min(1);
|
||||
|
||||
const parseDate = (value: string): Date | null => {
|
||||
@@ -252,7 +253,8 @@ export const appRouter = router({
|
||||
const tokenIssuedAt = new Date();
|
||||
|
||||
const signupResult = await ctx.kakaoClient.signup(token.accessToken);
|
||||
if (!signupResult.id && signupResult.msg !== 'already registered') {
|
||||
const alreadyRegisteredWithKakao = !signupResult.id && signupResult.msg === 'already registered';
|
||||
if (!signupResult.id && !alreadyRegisteredWithKakao) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '카카오 앱 연결에 실패했습니다.',
|
||||
@@ -270,12 +272,6 @@ export const appRouter = router({
|
||||
ctx.users.findByOauthId('KAKAO', profile.kakaoId),
|
||||
ctx.users.findByEmail(profile.email),
|
||||
]);
|
||||
if (existingByEmail && existingByEmail.id !== existingById?.id) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.',
|
||||
});
|
||||
}
|
||||
|
||||
if (pending.mode === 'verify') {
|
||||
if (!pending.userId) {
|
||||
@@ -416,8 +412,15 @@ export const appRouter = router({
|
||||
}
|
||||
|
||||
const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt);
|
||||
const recoveryIntent = existingByEmail
|
||||
? ('link_existing' as const)
|
||||
: alreadyRegisteredWithKakao
|
||||
? ('rejoin' as const)
|
||||
: ('register' as const);
|
||||
const stored = await ctx.oauthSessions.createSession({
|
||||
mode: pending.mode,
|
||||
intent: recoveryIntent,
|
||||
targetUserId: existingByEmail?.id,
|
||||
kakaoId: profile.kakaoId,
|
||||
email: profile.email,
|
||||
accessToken: token.accessToken,
|
||||
@@ -427,12 +430,133 @@ export const appRouter = router({
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (recoveryIntent !== 'register') {
|
||||
return {
|
||||
status: 'account_recovery' as const,
|
||||
action: recoveryIntent,
|
||||
oauthSessionId: stored.id,
|
||||
email: stored.email,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'join' as const,
|
||||
oauthSessionId: stored.id,
|
||||
email: stored.email,
|
||||
};
|
||||
}),
|
||||
kakaoResolveAccount: procedure
|
||||
.input(
|
||||
z.object({
|
||||
oauthSessionId: z.string().min(1),
|
||||
action: zKakaoRecoveryAction,
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId);
|
||||
if (!oauthSession) {
|
||||
throw new TRPCError({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: '카카오 계정 복구 세션이 만료되었습니다.',
|
||||
});
|
||||
}
|
||||
if (oauthSession.intent !== input.action) {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: '카카오 계정 복구 선택이 올바르지 않습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (input.action === 'rejoin') {
|
||||
const [oauthOwner, emailOwner] = await Promise.all([
|
||||
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
|
||||
ctx.users.findByEmail(oauthSession.email),
|
||||
]);
|
||||
if (oauthOwner || emailOwner) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '연결할 기존 계정이 확인되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
const registrationSession = await ctx.oauthSessions.createSession({
|
||||
mode: oauthSession.mode,
|
||||
intent: 'register',
|
||||
kakaoId: oauthSession.kakaoId,
|
||||
email: oauthSession.email,
|
||||
accessToken: oauthSession.accessToken,
|
||||
refreshToken: oauthSession.refreshToken,
|
||||
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
|
||||
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return {
|
||||
status: 'join' as const,
|
||||
oauthSessionId: registrationSession.id,
|
||||
email: registrationSession.email,
|
||||
};
|
||||
}
|
||||
|
||||
if (!oauthSession.targetUserId) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '연결할 기존 계정을 찾지 못했습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
const [targetUser, emailOwner, oauthOwner] = await Promise.all([
|
||||
ctx.users.findById(oauthSession.targetUserId),
|
||||
ctx.users.findByEmail(oauthSession.email),
|
||||
ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId),
|
||||
]);
|
||||
if (!targetUser || emailOwner?.id !== targetUser.id) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message:
|
||||
'보존된 이메일의 계정 정보가 변경되었습니다. 카카오 로그인을 처음부터 다시 진행해 주세요.',
|
||||
});
|
||||
}
|
||||
if (oauthOwner && oauthOwner.id !== targetUser.id) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '이미 다른 계정에 연결된 카카오 계정입니다.',
|
||||
});
|
||||
}
|
||||
if (targetUser.deleteAfter) {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '탈퇴 처리 중인 계정에는 카카오 계정을 다시 연결할 수 없습니다.',
|
||||
});
|
||||
}
|
||||
if (isLoginBanned(targetUser.sanctions)) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Account login is blocked.',
|
||||
});
|
||||
}
|
||||
|
||||
const oauthInfo: UserOAuthInfo = {
|
||||
accessToken: oauthSession.accessToken,
|
||||
refreshToken: oauthSession.refreshToken,
|
||||
accessTokenValidUntil: oauthSession.accessTokenValidUntil,
|
||||
refreshTokenValidUntil: oauthSession.refreshTokenValidUntil,
|
||||
};
|
||||
let linked: UserRecord;
|
||||
try {
|
||||
linked = await ctx.users.relinkKakaoByEmail(targetUser.id, {
|
||||
oauthId: oauthSession.kakaoId,
|
||||
email: oauthSession.email,
|
||||
oauthInfo,
|
||||
verifiedAt: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: 'CONFLICT',
|
||||
message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.',
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked');
|
||||
return finishKakaoLogin(ctx, linked, oauthSession.accessToken, 'login');
|
||||
}),
|
||||
register: procedure
|
||||
.input(
|
||||
z.object({
|
||||
@@ -460,6 +584,12 @@ export const appRouter = router({
|
||||
message: 'OAuth 세션이 만료되었습니다.',
|
||||
});
|
||||
}
|
||||
if (oauthSession.intent && oauthSession.intent !== 'register') {
|
||||
throw new TRPCError({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: '카카오 계정 복구 여부를 먼저 선택해 주세요.',
|
||||
});
|
||||
}
|
||||
const existing = await ctx.users.findByUsername(input.username);
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -24,6 +24,7 @@ const buildCaller = (
|
||||
profileListError?: Error;
|
||||
kakaoId?: string;
|
||||
kakaoEmail?: string;
|
||||
kakaoSignupAlreadyRegistered?: boolean;
|
||||
allowKakaoRefresh?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
@@ -69,7 +70,8 @@ const buildCaller = (
|
||||
accessTokenExpiresIn: 3600,
|
||||
};
|
||||
},
|
||||
signup: async () => ({ id: '1' }),
|
||||
signup: async () =>
|
||||
options.kakaoSignupAlreadyRegistered ? { msg: 'already registered' as const } : { id: kakaoProfile.id },
|
||||
getMe: async () => ({
|
||||
id: kakaoProfile.id,
|
||||
kakaoAccount: {
|
||||
@@ -472,9 +474,9 @@ describe('gateway auth flow', () => {
|
||||
expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message');
|
||||
});
|
||||
|
||||
it('rejects a new Kakao identity when its verified email is already registered', async () => {
|
||||
const { caller, users, kakaoProfile } = buildCaller();
|
||||
await users.createUser({
|
||||
it('asks before relinking a new Kakao identity to the permanently retained email owner', async () => {
|
||||
const { caller, users, kakaoProfile, sentTalkMessages, flushPublisher } = buildCaller();
|
||||
const emailOwner = await users.createUser({
|
||||
username: 'email-owner',
|
||||
password: 'owner-password',
|
||||
oauth: {
|
||||
@@ -484,12 +486,100 @@ describe('gateway auth flow', () => {
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000));
|
||||
kakaoProfile.id = 'different-kakao-id';
|
||||
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({
|
||||
code: 'CONFLICT',
|
||||
message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'),
|
||||
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
|
||||
expect(recovery).toMatchObject({
|
||||
status: 'account_recovery',
|
||||
action: 'link_existing',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
|
||||
|
||||
const linked = await caller.auth.kakaoResolveAccount({
|
||||
oauthSessionId: recovery.oauthSessionId,
|
||||
action: 'link_existing',
|
||||
});
|
||||
expect(linked.status).toBe('otp');
|
||||
expect(sentTalkMessages).toHaveLength(1);
|
||||
expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull();
|
||||
expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({
|
||||
id: emailOwner.id,
|
||||
username: 'email-owner',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked');
|
||||
});
|
||||
|
||||
it('asks for rejoin confirmation when Kakao is already registered but no retained email owner exists', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller({ kakaoSignupAlreadyRegistered: true });
|
||||
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
expect(recovery).toMatchObject({
|
||||
status: 'account_recovery',
|
||||
action: 'rejoin',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
if (recovery.status !== 'account_recovery') throw new Error('Expected rejoin choice.');
|
||||
|
||||
const confirmed = await caller.auth.kakaoResolveAccount({
|
||||
oauthSessionId: recovery.oauthSessionId,
|
||||
action: 'rejoin',
|
||||
});
|
||||
expect(confirmed).toMatchObject({ status: 'join', email: 'tester@example.com' });
|
||||
if (confirmed.status !== 'join') throw new Error('Expected registration session.');
|
||||
|
||||
const registered = await caller.auth.register({
|
||||
oauthSessionId: confirmed.oauthSessionId,
|
||||
username: 'rejoined-user',
|
||||
credential: sealPassword('rejoined-password'),
|
||||
displayName: '재가입사용자',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
});
|
||||
expect(registered.status).toBe('otp');
|
||||
expect(await users.findByUsername('rejoined-user')).toMatchObject({
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: '1',
|
||||
email: 'tester@example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let the registration mutation bypass the recovery confirmation', async () => {
|
||||
const { caller, users, sealPassword } = buildCaller();
|
||||
await users.createUser({
|
||||
username: 'retained-owner',
|
||||
password: 'owner-password',
|
||||
oauth: {
|
||||
type: 'KAKAO',
|
||||
id: 'former-kakao-id',
|
||||
email: 'tester@example.com',
|
||||
info: {},
|
||||
},
|
||||
});
|
||||
|
||||
const start = await caller.auth.kakaoStart({ mode: 'login' });
|
||||
const recovery = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state });
|
||||
if (recovery.status !== 'account_recovery') throw new Error('Expected account recovery choice.');
|
||||
|
||||
await expect(
|
||||
caller.auth.register({
|
||||
oauthSessionId: recovery.oauthSessionId,
|
||||
username: 'bypass-user',
|
||||
credential: sealPassword('bypass-password'),
|
||||
displayName: '우회사용자',
|
||||
termsAgreed: true,
|
||||
privacyAgreed: true,
|
||||
thirdPartyUse: false,
|
||||
})
|
||||
).rejects.toMatchObject({
|
||||
code: 'PRECONDITION_FAILED',
|
||||
message: expect.stringContaining('복구 여부를 먼저 선택'),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra';
|
||||
|
||||
import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js';
|
||||
|
||||
const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL;
|
||||
const integration = describe.skipIf(!databaseUrl);
|
||||
const userId = '3dd08c49-279e-41ad-a12b-51b914cc51c8';
|
||||
|
||||
const assertDedicatedSchema = (): void => {
|
||||
const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA;
|
||||
const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null;
|
||||
if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) {
|
||||
throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.');
|
||||
}
|
||||
};
|
||||
|
||||
integration('Kakao account relink PostgreSQL boundary', () => {
|
||||
let db: GatewayPrismaClient;
|
||||
let closeDb: (() => Promise<void>) | undefined;
|
||||
let initialized = false;
|
||||
|
||||
beforeAll(async () => {
|
||||
assertDedicatedSchema();
|
||||
const connector = createGatewayPostgresConnector({ url: databaseUrl! });
|
||||
await connector.connect();
|
||||
db = connector.prisma;
|
||||
initialized = true;
|
||||
closeDb = () => connector.disconnect();
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
await db.appUser.create({
|
||||
data: {
|
||||
id: userId,
|
||||
loginId: 'kakao-relink-integration',
|
||||
displayName: '카카오 재연결 통합',
|
||||
passwordHash: 'not-used',
|
||||
passwordSalt: 'not-used',
|
||||
roles: ['user'],
|
||||
sanctions: {},
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: 'former-kakao-id',
|
||||
email: 'retained@example.test',
|
||||
kakaoVerifiedAt: new Date('2026-08-01T00:00:00.000Z'),
|
||||
kakaoTalkVerifiedUntil: new Date('2026-08-20T00:00:00.000Z'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (initialized) {
|
||||
await db.appUser.deleteMany({ where: { id: userId } });
|
||||
}
|
||||
await closeDb?.();
|
||||
});
|
||||
|
||||
it('relinks the new provider identity while preserving email ownership and resetting the old talk proof', async () => {
|
||||
const users = createPostgresUserRepository(db);
|
||||
const linked = await users.relinkKakaoByEmail(userId, {
|
||||
oauthId: 'replacement-kakao-id',
|
||||
email: 'retained@example.test',
|
||||
oauthInfo: {
|
||||
accessToken: 'replacement-access-token',
|
||||
accessTokenValidUntil: '2026-08-08T12:00:00.000Z',
|
||||
},
|
||||
verifiedAt: new Date('2026-08-08T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
expect(linked).toMatchObject({
|
||||
id: userId,
|
||||
oauthType: 'KAKAO',
|
||||
oauthId: 'replacement-kakao-id',
|
||||
email: 'retained@example.test',
|
||||
kakaoTalkVerifiedUntil: undefined,
|
||||
});
|
||||
await expect(users.findByOauthId('KAKAO', 'former-kakao-id')).resolves.toBeNull();
|
||||
await expect(users.findByOauthId('KAKAO', 'replacement-kakao-id')).resolves.toMatchObject({ id: userId });
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,13 @@ import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js';
|
||||
|
||||
const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL;
|
||||
|
||||
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => {
|
||||
describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => {
|
||||
const prefix = `gateway-oauth-test:${randomUUID()}`;
|
||||
const client = createClient({ url: redisUrl });
|
||||
const store = new RedisOAuthSessionStore(client, prefix, 300);
|
||||
const userIds = new Set<string>();
|
||||
const challengeIds = new Set<string>();
|
||||
const sessionIds = new Set<string>();
|
||||
|
||||
beforeAll(async () => {
|
||||
await client.connect();
|
||||
@@ -22,6 +23,7 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
|
||||
const keys = [
|
||||
...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`),
|
||||
...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`),
|
||||
...[...sessionIds].map((id) => `${prefix}:oauth-session:${id}`),
|
||||
];
|
||||
if (keys.length > 0) {
|
||||
await client.del(keys);
|
||||
@@ -43,6 +45,31 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () =>
|
||||
return challenge;
|
||||
};
|
||||
|
||||
it('preserves and consumes a retained-email recovery target once', async () => {
|
||||
const targetUserId = randomUUID();
|
||||
const session = await store.createSession({
|
||||
mode: 'login',
|
||||
intent: 'link_existing',
|
||||
targetUserId,
|
||||
kakaoId: 'replacement-kakao-id',
|
||||
email: 'retained@example.test',
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(),
|
||||
refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
sessionIds.add(session.id);
|
||||
|
||||
await expect(store.consumeSession(session.id)).resolves.toMatchObject({
|
||||
id: session.id,
|
||||
intent: 'link_existing',
|
||||
targetUserId,
|
||||
email: 'retained@example.test',
|
||||
});
|
||||
await expect(store.consumeSession(session.id)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('atomically consumes a successful code once', async () => {
|
||||
const challenge = await createChallenge();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user