diff --git a/.env b/.env index 5320c50766..32e64b6d44 100644 --- a/.env +++ b/.env @@ -36,8 +36,6 @@ BRIEFING_FEED=http://api POST_SCRAPER_ORIGIN=http://localhost:8000 # YGGDRASIL_ORIGIN=http://localhost:3001 # Optional: override POST_SCRAPER_ORIGIN for link previews YGGDRASIL_SENTIMENT_ORIGIN=http://localhost:3002 -HEIMDALL_ORIGIN=http://localhost:7000 -KRATOS_ORIGIN=http://localhost:7000 BETTER_AUTH_REDIRECT_URL=https://sso.local.fylla.dev/api MAGNI_ORIGIN=http://localhost:9000 MEILI_ORIGIN=http://localhost:7700/ diff --git a/.infra/Pulumi.adhoc.yaml b/.infra/Pulumi.adhoc.yaml index 597e9c0106..046f9292b0 100644 --- a/.infra/Pulumi.adhoc.yaml +++ b/.infra/Pulumi.adhoc.yaml @@ -59,11 +59,9 @@ config: skadiApiOriginV2: http://skadi-boost-api-server.local.svc.cluster.local freyjaOrigin: http://freyja gcloudProject: local - heimdallOrigin: http://heimdall-api jwtAudience: Daily Staging jwtIssuer: Daily API Staging jwtSecret: '|r+.2!!!.Qf_-|63*%.D' - kratosOrigin: http://heimdall-kratos-public logLevel: info meiliIndex: dailydev meiliOrigin: 'http://localhost:7700/' # your local inet address, you can update it in .env file, can be sorted by adding meilisearch to local pulumi stack diff --git a/.infra/Pulumi.prod.yaml b/.infra/Pulumi.prod.yaml index f2b26cbe7b..9f83836f06 100644 --- a/.infra/Pulumi.prod.yaml +++ b/.infra/Pulumi.prod.yaml @@ -72,7 +72,6 @@ config: secure: AAABAJEgS6b8xZv0j06KOawyNMdkTpGmzKs7Ryed5SofiLp3vSTjxhqQuKSVsaHjR5s= growthbookClientKey: secure: AAABAIVkkeJGes/CVRjekXu8GfXXhC9FihKkcb2C4peMA7yS7HbO52uViOrP5uIFmgAQ2A== - heimdallOrigin: http://heimdall-api.heimdall internalFeed: http://feed.feed.svc.cluster.local/api/feed? jwtAudience: secure: AAABABYdafVA0XsTaLOHd1ROWJc6ggvXKA+e+lOjg3jduCMeGQ== @@ -80,7 +79,6 @@ config: secure: AAABAG0AuSVweFAmuLEMRDzVrVKMoXasHIRf0Md6aIyBlqTbvvCWDzU= jwtSecret: secure: AAABAFUcG4T/DgIjrCzR9Ka+aUJ5cMeKCm9jCAvWVdLKqFMj4voiYjva1Lrny+7A67xRYg== - kratosOrigin: http://heimdall-kratos-public.heimdall lofnOrigin: http://lofn-config-api.lofn magniOrigin: http://magni-search-api meiliIndex: diff --git a/__tests__/boot.ts b/__tests__/boot.ts index 95e35d6a72..48ac054597 100644 --- a/__tests__/boot.ts +++ b/__tests__/boot.ts @@ -60,7 +60,7 @@ import { StorageTopic, } from '../src/config'; import nock from 'nock'; -import { addDays, setMilliseconds, subDays } from 'date-fns'; +import { subDays } from 'date-fns'; import setCookieParser from 'set-cookie-parser'; import { postsFixture } from './fixture/post'; import { sourcesFixture } from './fixture/source'; @@ -90,6 +90,7 @@ import { } from '../src/entity/contentPreference/ContentPreferenceOrganization'; import { UserExperienceWork } from '../src/entity/user/experiences/UserExperienceWork'; import { UserExperienceEducation } from '../src/entity/user/experiences/UserExperienceEducation'; +import * as betterAuthModule from '../src/betterAuth'; let app: FastifyInstance; let con: DataSource; @@ -113,11 +114,8 @@ const BASE_BODY = { geo: {}, }; -const BOOT_EXP_WITH_AUTH = { f: 'enc', e: [], a: { authStrategy: 'gbId' } }; - const LOGGED_IN_BODY = { ...BASE_BODY, - exp: BOOT_EXP_WITH_AUTH, alerts: { ...BASE_BODY.alerts, bootPopup: true, @@ -184,7 +182,6 @@ const LOGGED_IN_BODY = { const ANONYMOUS_BODY = { ...BASE_BODY, - exp: BOOT_EXP_WITH_AUTH, settings: SETTINGS_DEFAULT, user: { id: expect.any(String), @@ -238,31 +235,12 @@ beforeEach(async () => { }); const BASE_PATH = '/boot'; -const KRATOS_EXPIRATION = addDays(setMilliseconds(new Date(), 0), 1); -const mockWhoami = (expected: unknown, statusCode = 200) => { - nock(process.env.HEIMDALL_ORIGIN) - .get('/api/whoami') - .reply(statusCode, JSON.stringify(expected), { - 'set-cookie': `ory_kratos_session=new_value; Path=/; Expires=${KRATOS_EXPIRATION.toUTCString()}; Max-Age=86399; HttpOnly; SameSite=Lax`, - }); +const mockLoggedInCookie = async (userId = '1') => { + const accessToken = await signJwt({ userId, roles: [] }, 15 * 60 * 1000); + return `${cookies.auth.key}=${app.signCookie(accessToken.token)}`; }; -const mockLoggedIn = (userId = '1') => - mockWhoami({ - session: { - identity: { traits: { userId } }, - expires_at: KRATOS_EXPIRATION, - }, - verified: true, - }); - -const mockLegacyLoggedIn = (userId = '1') => - mockWhoami({ - identity: { traits: { userId } }, - expires_at: KRATOS_EXPIRATION, - }); - describe('anonymous boot', () => { it('should return defaults', async () => { const res = await request(app.server) @@ -465,10 +443,9 @@ describe('recruiter default theme', () => { const themeKey = generateStorageKey(StorageTopic.Boot, 'theme', '1'); await setRedisObject(themeKey, 'bright'); - mockLoggedIn(); const loggedIn = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(loggedIn.body.settings.theme).toEqual('bright'); }); @@ -478,22 +455,20 @@ describe('recruiter default theme', () => { await setRedisObject(themeKey, 'bright'); await con.getRepository(Settings).save({ userId: '1', theme: 'darcula' }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.settings.theme).toEqual('darcula'); }); }); describe('logged in boot', () => { - it('should boot data when no access token cookie but whoami succeeds', async () => { - mockLoggedIn(); + it('should boot data when jwt cookie is provided', async () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body).toEqual({ ...LOGGED_IN_BODY, @@ -505,13 +480,22 @@ describe('logged in boot', () => { }); }); - it('should boot data when legacy kratos whoami is returned', async () => { - mockLegacyLoggedIn(); + it('should boot data when better auth session cookie is provided', async () => { + jest.spyOn(betterAuthModule, 'getBetterAuth').mockReturnValue({ + api: { + getSession: async () => + ({ + user: { id: '1' }, + }) as unknown, + }, + } as ReturnType); + const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', `${cookies.authSession.key}=session`) .expect(200); + expect(res.body).toEqual({ ...LOGGED_IN_BODY, user: { @@ -526,12 +510,11 @@ describe('logged in boot', () => { const userId = '1'; const requestStart = Date.now(); - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) .set('app', 'extension') .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); await setTimeout(50); @@ -551,11 +534,10 @@ describe('logged in boot', () => { it('should not set lastExtensionUse when app header is not extension', async () => { const userId = '1'; - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); await setTimeout(50); @@ -571,12 +553,11 @@ describe('logged in boot', () => { it('should write lastExtensionUse only once per day for extension app header', async () => { const userId = '1'; - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) .set('app', 'extension') .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); await setTimeout(50); @@ -589,12 +570,11 @@ describe('logged in boot', () => { firstUser.flags.lastExtensionUse as Date, ).getTime(); - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) .set('app', 'extension') .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); await setTimeout(50); @@ -612,7 +592,6 @@ describe('logged in boot', () => { it('should return lastExtensionUse from user flags', async () => { const lastExtensionUse = new Date('2026-01-15T10:20:30.000Z'); - mockLoggedIn(); await con.getRepository(User).update( { id: '1' }, { @@ -625,7 +604,7 @@ describe('logged in boot', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.flags.lastExtensionUse).toEqual( @@ -643,11 +622,10 @@ describe('logged in boot', () => { }, }, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.hasLocationSet).toBe(true); }); @@ -669,11 +647,10 @@ describe('logged in boot', () => { locationId: location.id, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.location).toEqual({ @@ -686,74 +663,28 @@ describe('logged in boot', () => { }); it('should return null location when user has no locationId', async () => { - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.location).toBeNull(); }); - it('should set kratos cookie expiration', async () => { - mockLoggedIn(); - const kratosCookie = 'ory_kratos_session'; - const res = await request(app.server) - .get(BASE_PATH) - .set('User-Agent', TEST_UA) - .set('Cookie', `${kratosCookie}=value;`) - .expect(200); - const cookies = setCookieParser.parse(res, { map: true }); - expect(cookies[kratosCookie].value).toEqual('new_value'); - expect(cookies[kratosCookie].expires).toEqual(KRATOS_EXPIRATION); - }); - it('should set tracking id according to user id', async () => { - mockLoggedIn(); const trackingCookie = 'da2'; + const authCookie = await mockLoggedInCookie(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', `ory_kratos_session=value;${trackingCookie}=t;`) + .set('Cookie', `${authCookie};${trackingCookie}=t;`) .expect(200); const cookies = setCookieParser.parse(res, { map: true }); expect(cookies[trackingCookie].value).toEqual('1'); }); - it('should handle 401 from auth server', async () => { - mockWhoami({}, 401); - const trackingCookie = 'da2'; - const res = await request(app.server) - .get(BASE_PATH) - .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') - .expect(200); - const cookies = setCookieParser.parse(res, { map: true }); - expect(cookies[trackingCookie].value).toBeTruthy(); - expect(cookies[trackingCookie].value).not.toEqual('1'); - expect(res.body).toEqual({ - ...ANONYMOUS_BODY, - }); - }); - - it('should handle user does not exist', async () => { - mockLoggedIn('2'); - const trackingCookie = 'da2'; - const res = await request(app.server) - .get(BASE_PATH) - .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') - .expect(200); - const cookies = setCookieParser.parse(res, { map: true }); - expect(cookies[trackingCookie].value).toBeTruthy(); - expect(cookies[trackingCookie].value).not.toEqual('2'); - expect(res.body).toEqual({ - ...ANONYMOUS_BODY, - }); - }); - - it('should not dispatch whoami when jwt is available', async () => { + it('should boot logged in user when jwt is available', async () => { const accessToken = await signJwt( { userId: '1', @@ -852,10 +783,9 @@ describe('logged in boot', () => { userId: '1', value: 1, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.isTeamMember).toEqual(true); }); @@ -873,10 +803,9 @@ describe('logged in boot', () => { }, defaultFeedId: '1', }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.defaultFeedId).toEqual('1'); }); @@ -891,10 +820,9 @@ describe('logged in boot', () => { ...usersFixture[0], defaultFeedId: '1', }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.defaultFeedId).toBeNull(); }); @@ -902,10 +830,9 @@ describe('logged in boot', () => { describe('subscriptionFlags', () => { describe('provider flag', () => { it('should not return provider when not set on user', async () => { - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.subscriptionFlags.provider).toBeUndefined(); }); @@ -917,10 +844,9 @@ describe('logged in boot', () => { provider: SubscriptionProvider.Paddle, }, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.subscriptionFlags.provider).toEqual( SubscriptionProvider.Paddle, @@ -934,10 +860,9 @@ describe('logged in boot', () => { provider: SubscriptionProvider.AppleStoreKit, }, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.subscriptionFlags.provider).toEqual( SubscriptionProvider.AppleStoreKit, @@ -947,10 +872,9 @@ describe('logged in boot', () => { describe('appAccountToken flag', () => { it('should not return appAccountToken when not set on user', async () => { - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.subscriptionFlags.appAccountToken).toBeUndefined(); }); @@ -962,10 +886,9 @@ describe('logged in boot', () => { appAccountToken: 'b381c50a-b79d-4ec9-9284-973d4d5d767b', }, }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.subscriptionFlags.appAccountToken).toEqual( 'b381c50a-b79d-4ec9-9284-973d4d5d767b', @@ -976,10 +899,9 @@ describe('logged in boot', () => { describe('balance field', () => { it('should return default balance', async () => { - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.balance).toEqual({ amount: 0, @@ -999,10 +921,9 @@ describe('logged in boot', () => { ], }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.balance).toEqual({ amount: 100, @@ -1046,10 +967,9 @@ describe('logged in boot', () => { ]); const userId = '1'; - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); // Wait for the onResponse hook to finish @@ -1076,10 +996,9 @@ describe('logged in boot', () => { it('should not set last activity in redis if user is not part of organization', async () => { const userId = '1'; - mockLoggedIn(userId); await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); const redisKey = generateStorageKey( StorageTopic.Boot, @@ -1104,11 +1023,10 @@ describe('boot marketing cta', () => { it('should return null if the user has no marketing cta', async () => { const userId = '1'; - mockLoggedIn(userId); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); expect(res.body.marketingCta).toBeNull(); @@ -1121,7 +1039,6 @@ describe('boot marketing cta', () => { it('should not check the database if redis value is set to sleeping', async () => { const userId = '1'; - mockLoggedIn(userId); await setRedisObject( generateStorageKey(StorageTopic.Boot, StorageKey.MarketingCta, userId), RedisMagicValues.SLEEPING, @@ -1136,7 +1053,7 @@ describe('boot marketing cta', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); expect(res.body.marketingCta).toBeNull(); @@ -1149,11 +1066,10 @@ describe('boot marketing cta', () => { it('should return null if user has no marketing cta on future ', async () => { const userId = '1'; - mockLoggedIn(userId); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); expect(res.body.marketingCta).toBeNull(); @@ -1166,7 +1082,6 @@ describe('boot marketing cta', () => { it('should return marketing cta for user', async () => { const userId = '1'; - mockLoggedIn(userId); await con.getRepository(MarketingCta).save({ campaignId: 'worlds-best-campaign', @@ -1194,7 +1109,7 @@ describe('boot marketing cta', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); expect(res.body.marketingCta).toMatchObject({ @@ -1224,7 +1139,6 @@ describe('boot marketing cta', () => { it('should not return marketing cta for user if campaign is not active', async () => { const userId = '1'; - mockLoggedIn(userId); await con.getRepository(MarketingCta).save({ campaignId: 'worlds-best-campaign', @@ -1253,7 +1167,7 @@ describe('boot marketing cta', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie(userId)) .expect(200); expect(res.body.marketingCta).toBeNull(); @@ -1268,7 +1182,6 @@ describe('boot marketing cta', () => { describe('boot alerts', () => { it('should return user alerts', async () => { - mockLoggedIn(); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, userId: '1', @@ -1282,7 +1195,7 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual({ ...alerts, @@ -1292,7 +1205,6 @@ describe('boot alerts', () => { }); it('should return banner as true', async () => { - mockLoggedIn(); await setRedisObject(REDIS_BANNER_KEY, '2023-02-06 12:00:00'); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, @@ -1309,13 +1221,12 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual(alerts); }); it('should return banner as false', async () => { - mockLoggedIn(); await setRedisObject(REDIS_BANNER_KEY, '2023-02-05 12:00:00'); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, @@ -1331,13 +1242,12 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual(alerts); }); it('should return banner as false if redis is false', async () => { - mockLoggedIn(); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, userId: '1', @@ -1353,13 +1263,12 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual(alerts); }); it('should return banner as true if redis is empty', async () => { - mockLoggedIn(); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, userId: '1', @@ -1383,7 +1292,7 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual(alerts); expect(await getRedisObject(REDIS_BANNER_KEY)).toEqual( @@ -1392,7 +1301,6 @@ describe('boot alerts', () => { }); it('should return showGenericReferral as true', async () => { - mockLoggedIn(); const data = await con.getRepository(Alerts).save({ ...ALERTS_DEFAULT, userId: '1', @@ -1409,13 +1317,12 @@ describe('boot alerts', () => { delete alerts['userId']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts).toEqual(alerts); }); it('should return true on "flags.showGiftPlus" if user is gift recipient', async () => { - mockLoggedIn(); await con.getRepository(User).update( { id: '1' }, { @@ -1427,7 +1334,7 @@ describe('boot alerts', () => { const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.flags.showPlusGift).toEqual(true); }); @@ -1435,7 +1342,6 @@ describe('boot alerts', () => { describe('boot misc', () => { it('should return user settings', async () => { - mockLoggedIn(); const data = await con.getRepository(Settings).save({ userId: '1', theme: 'bright', @@ -1447,7 +1353,7 @@ describe('boot misc', () => { delete settings['bookmarkSlug']; const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.settings).toEqual({ ...settings, @@ -1463,7 +1369,6 @@ describe('boot misc', () => { }); it('should return unread notifications count', async () => { - mockLoggedIn(); const notifs = await con.getRepository(NotificationV2).save([ notificationV2Fixture, { @@ -1493,13 +1398,12 @@ describe('boot misc', () => { ]); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.notifications).toEqual({ unreadNotificationsCount: 2 }); }); it('should return the user squads', async () => { - mockLoggedIn(); await con.getRepository(SquadSource).save([ { id: 's1', @@ -1568,7 +1472,7 @@ describe('boot misc', () => { ]); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.squads).toEqual([ { @@ -1617,7 +1521,6 @@ describe('boot misc', () => { }); it('should not return squads users blocked from', async () => { - mockLoggedIn(); await con.getRepository(SquadSource).save([ { id: 's1', @@ -1659,7 +1562,7 @@ describe('boot misc', () => { ]); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.squads).toEqual([ { @@ -1680,7 +1583,6 @@ describe('boot misc', () => { }); it('should return the user feeds', async () => { - mockLoggedIn(); const feeds = [ { id: '1', @@ -1717,7 +1619,7 @@ describe('boot misc', () => { await con.getRepository(Feed).save(feeds); const res = await request(app.server) .get(BASE_PATH) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.feeds).toMatchObject([ { @@ -1742,7 +1644,6 @@ describe('boot misc', () => { describe('boot experimentation', () => { it('should return recent experiments from redis', async () => { - mockLoggedIn(); await ioRedisPool.execute((client) => client.hset('exp:1', { e1: `v1:${new Date(2023, 5, 20).getTime()}`, @@ -1752,13 +1653,12 @@ describe('boot experimentation', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.exp.e).toEqual([base64('e1:v1'), base64('e2:v2')]); }); it('should return features as attributes', async () => { - mockLoggedIn(); await con.getRepository(Feature).save([ { userId: '1', @@ -1772,12 +1672,11 @@ describe('boot experimentation', () => { const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.exp.a).toEqual({ search: 1, squad: 1, - authStrategy: 'gbId', }); }); }); @@ -1821,12 +1720,11 @@ describe('companion boot', () => { }); it('should support logged user', async () => { - mockLoggedIn(); const res = await request(app.server) .get(`${BASE_PATH}/companion`) .query({ url: (postsFixture[0] as ArticlePost).url }) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body).toEqual({ ...LOGGED_IN_BODY, @@ -1876,11 +1774,10 @@ describe('boot alerts shouldShowFeedFeedback property', () => { }); it('should be false when the user has seen the survey few days ago', async () => { - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts.shouldShowFeedFeedback).toBeFalsy(); }); @@ -1892,11 +1789,10 @@ describe('boot alerts shouldShowFeedFeedback property', () => { { userId: '1' }, { lastFeedSettingsFeedback: subDays(new Date(), 30) }, ); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.alerts.shouldShowFeedFeedback).toBeTruthy(); }); @@ -2195,11 +2091,10 @@ describe('boot profile completion', () => { experienceLevel: null, }); - mockLoggedIn('pc-empty'); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie('pc-empty')) .expect(200); expect(res.body.user.profileCompletion).toEqual({ @@ -2222,11 +2117,10 @@ describe('boot profile completion', () => { experienceLevel: null, }); - mockLoggedIn('pc-image'); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie('pc-image')) .expect(200); expect(res.body.user.profileCompletion).toEqual({ @@ -2249,11 +2143,10 @@ describe('boot profile completion', () => { }, ); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.profileCompletion).toEqual({ @@ -2290,11 +2183,10 @@ describe('boot profile completion', () => { startedAt: new Date('2016-01-01'), }); - mockLoggedIn(); const res = await request(app.server) .get(BASE_PATH) .set('User-Agent', TEST_UA) - .set('Cookie', 'ory_kratos_session=value;') + .set('Cookie', await mockLoggedInCookie()) .expect(200); expect(res.body.user.profileCompletion).toEqual({ diff --git a/__tests__/routes/betterAuth.ts b/__tests__/routes/betterAuth.ts index 452c389a45..e263657309 100644 --- a/__tests__/routes/betterAuth.ts +++ b/__tests__/routes/betterAuth.ts @@ -6,6 +6,7 @@ import { saveFixtures } from '../helpers'; import { User } from '../../src/entity/user/User'; import { usersFixture } from '../fixture'; import { ioRedisPool } from '../../src/redis'; +import * as betterAuthModule from '../../src/betterAuth'; let app: FastifyInstance; let con: DataSource; @@ -61,5 +62,31 @@ describe('betterAuth routes', () => { modelName: 'ba_account', }); }); + + it('should forward native callback routes to BetterAuth handler', async () => { + const getBetterAuthSpy = jest + .spyOn(betterAuthModule, 'getBetterAuth') + .mockReturnValue({ + handler: async (req: Request) => { + const url = new URL(req.url); + return new Response(`${url.pathname}${url.search}`, { + status: 200, + }); + }, + api: { + getSession: async () => null, + setPassword: async () => ({ status: true }), + }, + } as ReturnType); + + const res = await request(app.server).get( + '/auth/callback/google?state=test&code=abc', + ); + + expect(res.status).toBe(200); + expect(res.text).toBe('/auth/callback/google?state=test&code=abc'); + + getBetterAuthSpy.mockRestore(); + }); }); }); diff --git a/__tests__/users.ts b/__tests__/users.ts index 045127dc80..b6b8eeff54 100644 --- a/__tests__/users.ts +++ b/__tests__/users.ts @@ -217,9 +217,11 @@ jest.mock('../src/cio', () => ({ })); const mockSetPassword = jest.fn(); +const mockBetterAuthHandler = jest.fn(async () => new Response('{}')); jest.mock('../src/betterAuth', () => ({ ...(jest.requireActual('../src/betterAuth') as Record), getBetterAuth: () => ({ + handler: mockBetterAuthHandler, api: { setPassword: mockSetPassword, }, @@ -480,12 +482,6 @@ const additionalKeywords: Partial[] = [ { value: 'javascript', occurrences: 980, status: 'allow' }, ]; -const mockLogout = () => { - nock(process.env.KRATOS_ORIGIN) - .get('/self-service/logout/browser') - .reply(200, {}); -}; - afterAll(() => disposeGraphQLTesting(state)); describe('query userStats', () => { @@ -4960,16 +4956,22 @@ describe('POST /v1/users/logout', () => { const BASE_PATH = '/v1/users/logout'; it('should logout and clear cookies', async () => { - mockLogout(); const res = await authorizeRequest(request(app.server).post(BASE_PATH)) .set('User-Agent', TEST_UA) - .set('Cookie', 'da3=1;da2=1') + .set( + 'Cookie', + 'da3=1;da2=1;dast=1;ory_kratos_session=legacy;ory_kratos_continuity=legacy', + ) .expect(204); const cookies = setCookieParser.parse(res, { map: true }); expect(cookies['da2'].value).toBeTruthy(); expect(cookies['da2'].value).not.toEqual('1'); expect(cookies['da3'].value).toBeFalsy(); + expect(cookies.dast.value).toBeFalsy(); + expect(cookies.ory_kratos_session.value).toBeFalsy(); + expect(cookies.ory_kratos_continuity.value).toBeFalsy(); + expect(mockBetterAuthHandler).toHaveBeenCalledTimes(1); }); }); @@ -4985,7 +4987,6 @@ describe('DELETE /v1/users/me', () => { }); it('should delete user from database', async () => { - mockLogout(); await authorizeRequest(request(app.server).delete(BASE_PATH)).expect(204); const users = await con.getRepository(User).find(); @@ -4996,16 +4997,21 @@ describe('DELETE /v1/users/me', () => { }); it('should clear cookies', async () => { - mockLogout(); const res = await authorizeRequest(request(app.server).delete(BASE_PATH)) .set('User-Agent', TEST_UA) - .set('Cookie', 'da3=1;da2=1') + .set( + 'Cookie', + 'da3=1;da2=1;dast=1;ory_kratos_session=legacy;ory_kratos_continuity=legacy', + ) .expect(204); const cookies = setCookieParser.parse(res, { map: true }); expect(cookies['da2'].value).toBeTruthy(); expect(cookies['da2'].value).not.toEqual('1'); expect(cookies['da3'].value).toBeFalsy(); + expect(cookies.dast.value).toBeFalsy(); + expect(cookies.ory_kratos_session.value).toBeFalsy(); + expect(cookies.ory_kratos_continuity.value).toBeFalsy(); }); it('clears invitedBy from associated features', async () => { @@ -5016,7 +5022,6 @@ describe('DELETE /v1/users/me', () => { invitedById: '1', }); - mockLogout(); await authorizeRequest(request(app.server).delete(BASE_PATH)).expect(204); const feature = await con.getRepository(Feature).findOneBy({ userId: '2' }); @@ -5029,7 +5034,6 @@ describe('DELETE /v1/users/me', () => { campaign: InviteCampaignType.Search, }); - mockLogout(); await authorizeRequest(request(app.server).delete(BASE_PATH)).expect(204); expect(await con.getRepository(Invite).count()).toEqual(0); diff --git a/bin/migrateKratosUsers.ts b/bin/migrateKratosUsers.ts deleted file mode 100644 index 1f572562f7..0000000000 --- a/bin/migrateKratosUsers.ts +++ /dev/null @@ -1,323 +0,0 @@ -import '../src/config'; -import { Pool } from 'pg'; -import createOrGetConnection from '../src/db'; -import { logger as parentLogger } from '../src/logger'; - -const logger = parentLogger.child({ command: 'migrate-kratos-users' }); - -const DEFAULT_LIMIT = 10_000; -const BATCH_SIZE = 2_000; - -type Cursor = { - createdAt: Date; - id: string; -}; - -const INITIAL_CURSOR: Cursor = { - createdAt: new Date(0), - id: '00000000-0000-0000-0000-000000000000', -}; - -const encodeCursor = (cursor: Cursor): string => - Buffer.from(JSON.stringify(cursor)).toString('base64'); - -const decodeCursor = (encoded: string): Cursor => { - const parsed = JSON.parse(Buffer.from(encoded, 'base64').toString('utf-8')); - return { createdAt: new Date(parsed.createdAt), id: parsed.id }; -}; - -type KratosPasswordRow = { - identity_id: string; - user_id: string; - hashed_password: string; - created_at: Date; -}; - -type KratosOidcRawRow = { - identity_id: string; - user_id: string; - provider_subject: string; - created_at: Date; -}; - -const getKratosPool = (): Pool => { - const connectionString = process.env.KRATOS_DATABASE_URL; - if (!connectionString) { - throw new Error( - 'KRATOS_DATABASE_URL is required (e.g. postgres://postgres:12345@localhost:5432/heimdall)', - ); - } - return new Pool({ connectionString, max: 5 }); -}; - -const fetchPasswordIdentities = async ( - kratosPool: Pool, - cursor: Cursor, - fetchSize: number, -): Promise => { - const { rows } = await kratosPool.query( - `SELECT - i.id AS identity_id, - i.traits->>'userId' AS user_id, - ic.config->>'hashed_password' AS hashed_password, - i.created_at - FROM identities i - JOIN identity_credentials ic ON ic.identity_id = i.id - JOIN identity_credential_types ict ON ict.id = ic.identity_credential_type_id - WHERE ict.name = 'password' - AND i.traits->>'userId' IS NOT NULL - AND ic.config->>'hashed_password' IS NOT NULL - AND (i.created_at, i.id) > ($1, $2) - ORDER BY i.created_at, i.id - LIMIT $3`, - [cursor.createdAt, cursor.id, fetchSize], - ); - return rows; -}; - -const fetchOidcIdentities = async ( - kratosPool: Pool, - cursor: Cursor, - fetchSize: number, -): Promise => { - const { rows } = await kratosPool.query( - `SELECT - i.id AS identity_id, - i.traits->>'userId' AS user_id, - ici.identifier AS provider_subject, - i.created_at - FROM identities i - JOIN identity_credentials ic ON ic.identity_id = i.id - JOIN identity_credential_types ict ON ict.id = ic.identity_credential_type_id - JOIN identity_credential_identifiers ici ON ici.identity_credential_id = ic.id - WHERE ict.name = 'oidc' - AND i.traits->>'userId' IS NOT NULL - AND (i.created_at, i.id) > ($1, $2) - ORDER BY i.created_at, i.id - LIMIT $3`, - [cursor.createdAt, cursor.id, fetchSize], - ); - return rows; -}; - -const cursorFromRow = (row: { - created_at: Date; - identity_id: string; -}): Cursor => ({ - createdAt: row.created_at, - id: row.identity_id, -}); - -type MigrateResult = { - count: number; - cursor: Cursor; -}; - -const migratePasswordAccounts = async ( - kratosPool: Pool, - dailyPool: Pool, - initialCursor: Cursor, - limit: number, -): Promise => { - let cursor = initialCursor; - let total = 0; - let processed = 0; - - // eslint-disable-next-line no-constant-condition - while (true) { - const remaining = limit - processed; - if (remaining <= 0) break; - - const fetchSize = Math.min(BATCH_SIZE, remaining); - const fetchStart = Date.now(); - const batch = await fetchPasswordIdentities(kratosPool, cursor, fetchSize); - const fetchMs = Date.now() - fetchStart; - if (batch.length === 0) break; - - processed += batch.length; - - const now = new Date().toISOString(); - const values: unknown[] = []; - const placeholders: string[] = []; - - for (let i = 0; i < batch.length; i++) { - const identity = batch[i]; - const base = i * 4; - placeholders.push( - `($${base + 1}, $${base + 2}, 'credential', $${base + 2}, $${base + 3}, $${base + 4}, $${base + 4})`, - ); - values.push( - `${identity.user_id}-credential`, - identity.user_id, - identity.hashed_password, - now, - ); - } - - const insertStart = Date.now(); - const { rowCount } = await dailyPool.query( - `INSERT INTO ba_account (id, "userId", "providerId", "accountId", password, "createdAt", "updatedAt") - VALUES ${placeholders.join(', ')} - ON CONFLICT ("userId", "providerId") DO NOTHING`, - values, - ); - const insertMs = Date.now() - insertStart; - total += rowCount ?? 0; - - cursor = cursorFromRow(batch[batch.length - 1]); - logger.info( - { - cursor: cursor.createdAt, - batchSize: batch.length, - inserted: rowCount, - total, - fetchMs, - insertMs, - }, - 'Migrated password batch', - ); - - if (batch.length < fetchSize) break; - } - - return { count: total, cursor }; -}; - -const migrateOidcAccounts = async ( - kratosPool: Pool, - dailyPool: Pool, - initialCursor: Cursor, - limit: number, -): Promise => { - let cursor = initialCursor; - let total = 0; - let processed = 0; - - // eslint-disable-next-line no-constant-condition - while (true) { - const remaining = limit - processed; - if (remaining <= 0) break; - - const fetchSize = Math.min(BATCH_SIZE, remaining); - const fetchStart = Date.now(); - const rawBatch = await fetchOidcIdentities(kratosPool, cursor, fetchSize); - const fetchMs = Date.now() - fetchStart; - if (rawBatch.length === 0) break; - - processed += rawBatch.length; - cursor = cursorFromRow(rawBatch[rawBatch.length - 1]); - - const now = new Date().toISOString(); - const values: unknown[] = []; - const placeholders: string[] = []; - let batchCount = 0; - - for (const row of rawBatch) { - const parts = row.provider_subject?.split(':') ?? []; - const provider = parts[0] ?? ''; - const subject = parts.slice(1).join(':'); - if (!provider || !subject) continue; - - const base = batchCount * 5; - placeholders.push( - `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 5})`, - ); - values.push( - `${row.user_id}-${provider}`, - row.user_id, - provider, - subject, - now, - ); - batchCount++; - } - - if (placeholders.length === 0) continue; - - const insertStart = Date.now(); - const { rowCount } = await dailyPool.query( - `INSERT INTO ba_account (id, "userId", "providerId", "accountId", "createdAt", "updatedAt") - VALUES ${placeholders.join(', ')} - ON CONFLICT ("userId", "providerId") DO NOTHING`, - values, - ); - const insertMs = Date.now() - insertStart; - total += rowCount ?? 0; - - logger.info( - { - cursor: cursor.createdAt, - batchSize: rawBatch.length, - inserted: rowCount, - total, - fetchMs, - insertMs, - }, - 'Migrated OIDC batch', - ); - - if (rawBatch.length < fetchSize) break; - } - - return { count: total, cursor }; -}; - -const parseLimit = (): number => { - const limitArg = process.argv.find((arg) => arg.startsWith('--limit=')); - if (!limitArg) return DEFAULT_LIMIT; - const parsed = parseInt(limitArg.split('=')[1], 10); - if (isNaN(parsed) || parsed <= 0) { - throw new Error('--limit must be a positive number'); - } - return parsed; -}; - -(async (): Promise => { - const kratosPool = getKratosPool(); - const con = await createOrGetConnection(); - const dailyPool = (con.driver as unknown as { master: Pool }).master; - const cursorArg = process.argv.find( - (arg) => !arg.startsWith('--') && !arg.includes('/'), - ); - const startCursor = cursorArg ? decodeCursor(cursorArg) : INITIAL_CURSOR; - const limit = parseLimit(); - - try { - const overallStart = Date.now(); - logger.info( - { resumeFrom: startCursor.createdAt, limit }, - 'Starting Kratos to BetterAuth user migration', - ); - - const [password, oidc] = await Promise.all([ - migratePasswordAccounts(kratosPool, dailyPool, startCursor, limit), - migrateOidcAccounts(kratosPool, dailyPool, startCursor, limit), - ]); - logger.info( - { count: password.count }, - 'Password account migration complete', - ); - logger.info({ count: oidc.count }, 'OIDC account migration complete'); - - const endCursor = - password.cursor.createdAt > oidc.cursor.createdAt - ? password.cursor - : oidc.cursor; - const encoded = encodeCursor(endCursor); - - logger.info( - { - passwordCount: password.count, - oidcCount: oidc.count, - cursor: encoded, - totalMs: Date.now() - overallStart, - }, - 'Kratos to BetterAuth migration complete', - ); - console.log(`\nResume cursor: ${encoded}`); - } finally { - await kratosPool.end(); - } - - process.exit(); -})(); diff --git a/src/betterAuth.ts b/src/betterAuth.ts index 8a308f7ef4..8972dac4d8 100644 --- a/src/betterAuth.ts +++ b/src/betterAuth.ts @@ -12,10 +12,9 @@ import { triggerTypedEvent } from './common/typedPubsub'; import { sendEmail, CioTransactionalMessageTemplateId } from './common/mailing'; import { handleRegex } from './common/object'; import { validateAndTransformHandle } from './common/handles'; +import { ONE_DAY_IN_SECONDS } from './common/constants'; import { singleRedisClient } from './redis'; import { User } from './entity/user/User'; -import { fetchOptions } from './http'; -import { retryFetch } from './integrations/retry'; import { cookies, extractRootDomain } from './cookies'; import { getGeo } from './common/geo'; import { getUserCoresRole } from './common/user'; @@ -35,7 +34,12 @@ const getGooglePublicKey = async (kid: string) => { const BETTER_AUTH_SECRET_MIN_LENGTH = 32; const turnstileSecretKey = process.env.TURNSTILE_SECRET_KEY; const googleIosClientId = process.env.GOOGLE_IOS_CLIENT_ID; -const kratosOrigin = process.env.KRATOS_ORIGIN; +const betterAuthSocialProviderEnvVars = { + google: 'GOOGLE_CLIENT_ID', + github: 'GITHUB_CLIENT_ID', + apple: 'APPLE_CLIENT_ID', + facebook: 'FACEBOOK_CLIENT_ID', +} as const; const userExperienceLevels = [ 'LESS_THAN_1_YEAR', 'MORE_THAN_1_YEAR', @@ -47,15 +51,13 @@ const userExperienceLevels = [ ] as const; const userExperienceLevelSchema = z.enum(userExperienceLevels); const signUpEmailPath = '/sign-up/email'; -const signInEmailPath = '/sign-in/email'; - +export type BetterAuthSocialProvider = + keyof typeof betterAuthSocialProviderEnvVars; type BetterAuthHookContext = { path?: string; body?: Record; }; -type KratosVerifyResult = { valid: true; userId: string } | { valid: false }; - const TRACKING_COOKIE_KEY = cookies.tracking.key; const TRACKING_ID_REGEX = /^[0-9A-Za-z]{21}$/; @@ -179,121 +181,6 @@ export const verifyPasswordWithBcryptFallback = async ({ return argon2.verify(hash, password); }; -const hasCredentialAccount = async ( - pool: Pool, - userId: string, -): Promise => { - const { rows } = await pool.query( - `SELECT 1 FROM ba_account WHERE "userId" = $1 AND "providerId" = 'credential' LIMIT 1`, - [userId], - ); - - return rows.length > 0; -}; - -const insertCredentialAccount = async ( - pool: Pool, - userId: string, - hashedPassword: string, -): Promise => { - const now = new Date().toISOString(); - const accountId = `${userId}-credential`; - - await pool.query( - `INSERT INTO ba_account (id, "userId", "providerId", "accountId", "createdAt", "updatedAt", password) - VALUES ($1, $2, 'credential', $3, $4, $4, $5)`, - [accountId, userId, userId, now, hashedPassword], - ); -}; - -const verifyKratosCredentials = async ( - email: string, - password: string, -): Promise => { - if (!kratosOrigin) { - logger.warn( - 'KRATOS_ORIGIN is not set, skipping Kratos credential verification', - ); - return { valid: false }; - } - - try { - const flowRes = await retryFetch( - `${kratosOrigin}/self-service/login/api`, - fetchOptions, - ); - const flow = await flowRes.json(); - const flowId = flow.id; - - const submitRes = await retryFetch( - `${kratosOrigin}/self-service/login?flow=${flowId}`, - { - ...fetchOptions, - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - method: 'password', - identifier: email, - password, - }), - }, - ); - const result = await submitRes.json(); - const traits = result?.session?.identity?.traits; - - if (traits?.userId) { - return { - valid: true, - userId: traits.userId, - }; - } - - logger.warn( - { email }, - 'Kratos credential verification returned no valid session', - ); - return { valid: false }; - } catch (err) { - logger.error( - { err: err instanceof Error ? err.message : String(err) }, - 'Kratos credential verification request failed', - ); - return { valid: false }; - } -}; - -const migrateKratosUserToBetterAuth = async ({ - pool, - userId, - password, -}: { - pool: Pool; - userId: string; - password: string; -}): Promise => { - try { - if (await hasCredentialAccount(pool, userId)) { - return true; - } - - const hashedPassword = await argon2.hash(password, { - type: argon2.argon2id, - }); - - await insertCredentialAccount(pool, userId, hashedPassword); - return true; - } catch (err) { - logger.error( - { - err: err instanceof Error ? err.message : String(err), - userId, - }, - 'Failed to migrate Kratos user to BetterAuth', - ); - return false; - } -}; - export type BetterAuthHandler = { handler: (request: Request) => Promise; api: { @@ -324,15 +211,9 @@ let authInstance: BetterAuthHandler | null = null; const getPool = (): Pool => (AppDataSource.driver as unknown as { master: Pool }).master; -const getSocialRedirectUri = (provider: string): string | undefined => { - const redirectBaseUrl = process.env.BETTER_AUTH_REDIRECT_URL; - - if (!redirectBaseUrl) { - return undefined; - } - - return `${redirectBaseUrl.replace(/\/$/, '')}/callback/${provider}`; -}; +export const betterAuthSocialProviders = Object.keys( + betterAuthSocialProviderEnvVars, +) as BetterAuthSocialProvider[]; const normalizeSignUpUsername = async ( body?: Record, @@ -396,63 +277,6 @@ const prepareSignUpContext = async ({ }; }; -const migrateLegacyCredentialIfNeeded = async ({ - pool, - body, -}: { - pool: Pool; - body?: Record; -}): Promise => { - const email = body?.email; - const password = body?.password; - - if ( - typeof email !== 'string' || - typeof password !== 'string' || - !z.email().safeParse(email).success - ) { - return; - } - - const user = await AppDataSource.getRepository(User) - .createQueryBuilder('user') - .select(['user.id']) - .where('lower(user.email) = lower(:email)', { email }) - .getOne(); - - if (!user || (await hasCredentialAccount(pool, user.id))) { - return; - } - - const kratosResult = await verifyKratosCredentials(email, password); - - if (!kratosResult.valid) { - logger.warn( - { userId: user.id }, - 'Kratos credential verification failed for legacy user migration', - ); - return; - } - - if (kratosResult.userId !== user.id) { - logger.warn( - { userId: user.id, kratosUserId: kratosResult.userId }, - 'Kratos userId mismatch during legacy user migration', - ); - return; - } - - await migrateKratosUserToBetterAuth({ - pool, - userId: user.id, - password, - }); - logger.info( - { userId: user.id }, - 'Successfully migrated Kratos credentials to BetterAuth', - ); -}; - const cookieDomain = process.env.BETTER_AUTH_BASE_URL ? extractRootDomain(new URL(process.env.BETTER_AUTH_BASE_URL).hostname) : undefined; @@ -493,12 +317,6 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { }, }; } - if (hookContext.path === signInEmailPath) { - await migrateLegacyCredentialIfNeeded({ - pool, - body: hookContext.body, - }); - } }), }, advanced: { @@ -571,11 +389,13 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { session: { modelName: 'ba_session', storeSessionInDatabase: true, + expiresIn: 7 * ONE_DAY_IN_SECONDS, + updateAge: ONE_DAY_IN_SECONDS, }, account: { modelName: 'ba_account', accountLinking: { - trustedProviders: ['google', 'github', 'apple', 'facebook'], + trustedProviders: betterAuthSocialProviders, allowDifferentEmails: true, }, }, @@ -727,7 +547,6 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? '', - redirectURI: getSocialRedirectUri('google'), ...(googleIosClientId && { verifyIdToken: async (token: string, nonce?: string) => { try { @@ -765,7 +584,6 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { github: { clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET ?? '', - redirectURI: getSocialRedirectUri('github'), }, }), ...(process.env.APPLE_CLIENT_ID && { @@ -773,14 +591,12 @@ export const getBetterAuthOptions = (pool: Pool): BetterAuthOptions => { clientId: process.env.APPLE_CLIENT_ID, clientSecret: process.env.APPLE_CLIENT_SECRET ?? '', appBundleIdentifier: process.env.APPLE_APP_BUNDLE_ID || undefined, - redirectURI: getSocialRedirectUri('apple'), }, }), ...(process.env.FACEBOOK_CLIENT_ID && { facebook: { clientId: process.env.FACEBOOK_CLIENT_ID, clientSecret: process.env.FACEBOOK_CLIENT_SECRET ?? '', - redirectURI: getSocialRedirectUri('facebook'), }, }), }, diff --git a/src/betterAuthSession.ts b/src/betterAuthSession.ts deleted file mode 100644 index 4db2b635f5..0000000000 --- a/src/betterAuthSession.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { createHmac } from 'crypto'; -import { FastifyReply, FastifyRequest } from 'fastify'; -import { addDays } from 'date-fns'; -import createOrGetConnection from './db'; -import { generateLongId, generateUUID } from './ids'; -import { setCookie } from './cookies'; - -export const createBetterAuthSessionFromKratos = async ({ - req, - res, - userId, -}: { - req: FastifyRequest; - res: FastifyReply; - userId: string; -}): Promise => { - try { - const con = await createOrGetConnection(); - - const sessionId = generateUUID(); - const token = await generateLongId(); - const expiresAt = addDays(new Date(), 7); - - const dailyUser = await con.query( - 'SELECT id FROM public."user" WHERE id = $1 LIMIT 1', - [userId], - ); - if (dailyUser.length === 0) { - req.log.warn('Cannot create BA session: user not found'); - return false; - } - - await con.query( - `INSERT INTO ba_session (id, token, "userId", "expiresAt", "createdAt", "updatedAt", "ipAddress", "userAgent") - VALUES ($1, $2, $3, $4, NOW(), NOW(), $5, $6)`, - [ - sessionId, - token, - userId, - expiresAt, - req.ip, - req.headers['user-agent'] ?? null, - ], - ); - - const secret = process.env.BETTER_AUTH_SECRET; - if (!secret) { - req.log.error('BETTER_AUTH_SECRET is not set, cannot sign session token'); - return false; - } - - const signature = createHmac('sha256', secret) - .update(token) - .digest('base64'); - const signedToken = `${token}.${signature}`; - setCookie(req, res, 'authSession', signedToken); - - return true; - } catch (error) { - req.log.error( - { err: error instanceof Error ? error.message : String(error) }, - 'Failed to create BetterAuth session from Kratos', - ); - return false; - } -}; diff --git a/src/common/typedPubsub.ts b/src/common/typedPubsub.ts index 6ac759f014..0b4a50df42 100644 --- a/src/common/typedPubsub.ts +++ b/src/common/typedPubsub.ts @@ -105,7 +105,6 @@ export type PubSubSchema = { }; 'user-deleted': { id: string; - kratosUser: boolean; email: string; }; 'api.v1.user-created': { diff --git a/src/common/users.ts b/src/common/users.ts index dd0714654a..2daa568ed0 100644 --- a/src/common/users.ts +++ b/src/common/users.ts @@ -594,7 +594,6 @@ export enum LogoutReason { IncomleteOnboarding = 'incomplete onboarding', ManualLogout = 'manual logout', UserDeleted = 'user deleted', - KratosSessionAlreadyAvailable = 'kratos session already available', } export const getAbsoluteDifferenceInDays: typeof differenceInDays = ( diff --git a/src/cookies.ts b/src/cookies.ts index 3a5bb4417b..025f967301 100644 --- a/src/cookies.ts +++ b/src/cookies.ts @@ -1,5 +1,8 @@ import { CookieSerializeOptions } from '@fastify/cookie'; import { FastifyReply, FastifyRequest } from 'fastify'; +import { generateTrackingId } from './ids'; +import { setTrackingId } from './tracking'; +import { counters } from './telemetry'; const env = process.env.NODE_ENV; @@ -56,19 +59,6 @@ export const cookies: { }, key: 'da5', }, - kratos: { - key: 'ory_kratos_session', - opts: { - signed: false, - httpOnly: true, - secure: env === 'production', - sameSite: 'lax', - }, - }, - kratosContinuity: { - key: 'ory_kratos_continuity', - opts: {}, - }, authSession: { key: env === 'production' ? '__Secure-dast' : 'dast', opts: { @@ -79,16 +69,6 @@ export const cookies: { sameSite: 'lax', }, }, - baForce: { - key: 'da_ba', - opts: { - maxAge: 60 * 60 * 24 * 365 * 10, - httpOnly: true, - signed: false, - secure: env === 'production', - sameSite: 'lax', - }, - }, }; export const extractRootDomain = (hostname: string): string => { @@ -153,3 +133,42 @@ export const setCookie = ( } return res.cookie(config.key, value, mergedOpts); }; + +const clearCookieByName = ( + req: FastifyRequest, + res: FastifyReply, + key: string, + opts: Partial = {}, +): FastifyReply => + res.clearCookie(key, { + path: '/', + ...addSubdomainOpts(req, {}), + ...opts, + }); + +export const clearAuthentication = async ( + req: FastifyRequest, + res: FastifyReply, + reason: string, +): Promise => { + req.log.info( + { + reason, + userId: req.userId, + }, + 'clearing authentication', + ); + req.trackingId = await generateTrackingId(req, 'clear authentication'); + req.userId = undefined; + setTrackingId(req, res, req.trackingId); + setCookie(req, res, 'auth', undefined); + setCookie(req, res, 'authSession', undefined); + clearCookieByName(req, res, 'ory_kratos_session', { + httpOnly: true, + sameSite: 'lax', + secure: env === 'production', + }); + clearCookieByName(req, res, 'ory_kratos_continuity'); + + counters?.api?.clearAuthentication?.add(1, { reason }); +}; diff --git a/src/growthbook.ts b/src/growthbook.ts index 531d49931b..30afb2aa07 100644 --- a/src/growthbook.ts +++ b/src/growthbook.ts @@ -123,10 +123,6 @@ export const features = { dailyDigest: new Feature('daily_personalized_digest', { ...digestFeatureBaseConfig, }), - authStrategy: new Feature<'kratos' | 'betterauth'>( - 'auth_strategy', - 'betterauth', - ), }; export class ExperimentAllocationClient { diff --git a/src/kratos.ts b/src/kratos.ts deleted file mode 100644 index 84e14aa3bc..0000000000 --- a/src/kratos.ts +++ /dev/null @@ -1,214 +0,0 @@ -import { Headers, RequestInit } from 'node-fetch'; -import { addDays } from 'date-fns'; -import { fetchOptions } from './http'; -import { FastifyReply, FastifyRequest } from 'fastify'; -import { cookies, setCookie } from './cookies'; -import { setTrackingId } from './tracking'; -import { generateTrackingId } from './ids'; -import { HttpError, retryFetch } from './integrations/retry'; -import { LogoutReason } from './common'; -import { counters } from './telemetry'; -import { callBetterAuth } from './routes/betterAuth'; - -const heimdallOrigin = process.env.HEIMDALL_ORIGIN; -const kratosOrigin = process.env.KRATOS_ORIGIN; - -const addKratosHeaderCookies = (req: FastifyRequest): RequestInit => ({ - headers: { - cookie: req.headers.cookie as string, - forwarded: req.headers.forwarded as string, - }, -}); - -class KratosError extends Error { - statusCode: number; - body: string; - - constructor(statusCode: number, body: string) { - super(`Kratos error: ${statusCode}`); - this.statusCode = statusCode; - this.body = body; - } -} - -const fetchKratos = async ( - req: FastifyRequest, - endpoint: string, - opts: RequestInit = {}, - parseResponse = true, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -): Promise<{ res: any; headers: Headers }> => { - try { - const res = await retryFetch(endpoint, { - ...fetchOptions, - ...addKratosHeaderCookies(req), - ...opts, - }); - return { - res: parseResponse ? await res.json() : null, - headers: res.headers, - }; - } catch (err) { - if (err instanceof HttpError) { - const kratosError = new KratosError(err.statusCode, err.response); - if (err.statusCode >= 500) { - req.log.warn({ err: kratosError }, 'unexpected error from kratos'); - } - if (err.statusCode !== 303 && err.statusCode !== 401) { - req.log.info({ err: kratosError }, 'non-401 error from kratos'); - } - throw err; - } - throw err; - } -}; - -export const clearAuthentication = async ( - req: FastifyRequest, - res: FastifyReply, - reason: string, -): Promise => { - req.log.info( - { - reason, - userId: req.userId, - cookie: req.cookies[cookies.kratos.key], - }, - 'clearing authentication', - ); - req.trackingId = await generateTrackingId(req, 'clear authentication'); - req.userId = undefined; - setTrackingId(req, res, req.trackingId); - setCookie(req, res, 'auth', undefined); - setCookie(req, res, 'kratosContinuity', undefined); - setCookie(req, res, 'kratos', undefined); - setCookie(req, res, 'authSession', undefined); - - counters?.api?.clearAuthentication?.add(1, { reason }); -}; - -const MOCK_USER_ID = process.env.MOCK_USER_ID; - -export type WhoamiResponse = - | { - valid: true; - userId: string; - expires: Date; - cookie?: string; - verified: boolean; - email?: string; - } - | { valid: false }; - -export const dispatchWhoami = async ( - req: FastifyRequest, -): Promise => { - if (MOCK_USER_ID) { - const expires = addDays(new Date(), 1); - - return Promise.resolve({ - valid: true, - userId: MOCK_USER_ID, - expires, - verified: true, - }); - } - - if (heimdallOrigin === 'disabled' || !req.cookies[cookies.kratos.key]) { - return { valid: false }; - } - try { - const { res: whoami, headers } = await fetchKratos( - req, - `${heimdallOrigin}/api/whoami`, - ); - - // To support both legacy and new whoami responses - let session, verified: boolean; - if (whoami.hasOwnProperty('session')) { - session = whoami.session; - verified = whoami.verified; - } else { - session = whoami; - verified = true; - } - - if (session?.identity?.traits?.userId) { - return { - verified, - valid: true, - userId: session.identity.traits.userId, - expires: new Date(session.expires_at), - cookie: headers.get('set-cookie') || undefined, - email: session.identity.traits.email, - }; - } - req.log.info({ whoami }, 'invalid whoami response'); - } catch (originalError) { - const e = originalError as HttpError; - - if (e.statusCode !== 401) { - throw e; - } - } - - return { valid: false }; -}; - -// The app still uses a unified logout endpoint while both Kratos and Better -// Auth sessions can exist. Triggering BA sign-out here keeps `/v1/users/logout` -// as the single logout path during the migration. -const logoutBetterAuth = async ( - req: FastifyRequest, - res: FastifyReply, -): Promise => { - try { - await callBetterAuth({ - req, - reply: res, - path: '/auth/sign-out', - method: 'POST', - }); - } catch (err) { - req.log.warn({ err }, 'error during BetterAuth sign-out'); - } -}; - -export const logout = async ( - req: FastifyRequest, - res: FastifyReply, - isDeletion = false, -): Promise => { - const query = req.query as { reason?: LogoutReason }; - const queryReason = query?.reason as LogoutReason; - const reason = Object.values(LogoutReason).includes(queryReason) - ? queryReason - : LogoutReason.ManualLogout; - - try { - const { res: logoutFlow } = await fetchKratos( - req, - `${kratosOrigin}/self-service/logout/browser`, - ); - if (logoutFlow?.logout_url) { - const logoutParts = logoutFlow.logout_url.split('/self-service/'); - const logoutUrl = `${kratosOrigin}/self-service/${logoutParts[1]}`; - await fetchKratos(req, logoutUrl, { redirect: 'manual' }, false); - } - } catch (originalError) { - const e = originalError as HttpError; - - if (e.statusCode !== 303 && e.statusCode !== 401) { - req.log.warn({ err: e }, 'unexpected error while logging out'); - } - } - - await logoutBetterAuth(req, res); - - await clearAuthentication( - req, - res, - isDeletion ? LogoutReason.UserDeleted : reason, - ); - return res.status(204).send(); -}; diff --git a/src/routes/betterAuth.ts b/src/routes/betterAuth.ts index ea304f2423..a838204b1c 100644 --- a/src/routes/betterAuth.ts +++ b/src/routes/betterAuth.ts @@ -6,7 +6,7 @@ import { getBetterAuth } from '../betterAuth'; const formatError = (err: unknown): string => err instanceof Error ? err.message : String(err); -const betterAuthStateSuffix = '_ba'; +const internalAuthenticationError = 'Internal authentication error'; const toRequestBody = (request: FastifyRequest): string | undefined => { if (request.method === 'GET' || request.method === 'HEAD') { @@ -20,25 +20,6 @@ const toRequestBody = (request: FastifyRequest): string | undefined => { return request.body ? JSON.stringify(request.body) : undefined; }; -// Heimdall fronts a shared OAuth callback URL for both Kratos and Better Auth, -// so we tag Better Auth social flows in `state` and strip the marker before BA -// validates the callback. -const stripBetterAuthStateMarker = (url: URL): URL => { - if (!url.pathname.includes('/auth/callback/')) { - return url; - } - - const state = url.searchParams.get('state'); - - if (!state?.endsWith(betterAuthStateSuffix)) { - return url; - } - - url.searchParams.set('state', state.slice(0, -betterAuthStateSuffix.length)); - - return url; -}; - const forwardHeaders = (reply: FastifyReply, response: Response): void => { response.headers.forEach((value, key) => { if (key.toLowerCase() !== 'set-cookie') { @@ -55,6 +36,34 @@ const forwardHeaders = (reply: FastifyReply, response: Response): void => { } }; +const sendBetterAuthResponse = async ( + reply: FastifyReply, + response: Response, +): Promise => { + reply.status(response.status); + + if (!response.body) { + return reply.send(); + } + + return reply.send(await response.text()); +}; + +const sendBetterAuthError = ( + request: FastifyRequest, + reply: FastifyReply, + error: unknown, + message: string, +): FastifyReply | void => { + request.log.error({ err: formatError(error) }, message); + + if (!reply.sent) { + return reply.status(500).send({ + error: internalAuthenticationError, + }); + } +}; + type CallBetterAuthOptions = { req: FastifyRequest; reply?: FastifyReply; @@ -76,7 +85,7 @@ export const callBetterAuth = async ({ req.headers as Record, ); - const authRequest = new Request(stripBetterAuthStateMarker(url), { + const authRequest = new Request(url, { method: method ?? req.method, headers, ...(body ? { body } : {}), @@ -91,7 +100,37 @@ export const callBetterAuth = async ({ return response; }; +export const logoutBetterAuth = async ( + request: FastifyRequest, + reply: FastifyReply, +): Promise => { + try { + await callBetterAuth({ + req: request, + reply, + path: '/auth/sign-out', + method: 'POST', + }); + } catch (error) { + request.log.warn( + { err: formatError(error) }, + 'error during BetterAuth sign-out', + ); + } +}; + const betterAuthRoute = async (fastify: FastifyInstance): Promise => { + // Apple sends OAuth callbacks as application/x-www-form-urlencoded POSTs. + // Fastify does not parse this content type by default, so collect the raw + // body and let BetterAuth handle it. + fastify.addContentTypeParser( + 'application/x-www-form-urlencoded', + { parseAs: 'string' }, + (_req, body, done) => { + done(null, body); + }, + ); + fastify.route({ method: ['GET', 'POST'], url: '/auth/*', @@ -103,21 +142,14 @@ const betterAuthRoute = async (fastify: FastifyInstance): Promise => { reply, body, }); - reply.status(response.status); - if (!response.body) { - return reply.send(); - } - return reply.send(await response.text()); + return sendBetterAuthResponse(reply, response); } catch (error) { - request.log.error( - { err: formatError(error) }, + return sendBetterAuthError( + request, + reply, + error, 'BetterAuth request failed', ); - if (!reply.sent) { - return reply.status(500).send({ - error: 'Internal authentication error', - }); - } } }, }); diff --git a/src/routes/boot.ts b/src/routes/boot.ts index 9ebc077a46..0ea794f38e 100644 --- a/src/routes/boot.ts +++ b/src/routes/boot.ts @@ -7,7 +7,6 @@ import { import { fromNodeHeaders } from 'better-auth/node'; import createOrGetConnection from '../db'; import { DataSource, Not, QueryRunner } from 'typeorm'; -import { clearAuthentication, dispatchWhoami } from '../kratos'; import { getBetterAuth } from '../betterAuth'; import { generateUUID } from '../ids'; import { generateSessionId, setTrackingId } from '../tracking'; @@ -60,8 +59,7 @@ import { updateFlagsStatement, } from '../common'; import { AccessToken, signJwt } from '../auth'; -import { createBetterAuthSessionFromKratos } from '../betterAuthSession'; -import { cookies, setCookie, setRawCookie } from '../cookies'; +import { clearAuthentication, cookies, setCookie } from '../cookies'; import { parse } from 'graphql/language/parser'; import { execute } from 'graphql/execution/execute'; import { schema } from '../graphql'; @@ -69,7 +67,6 @@ import { Context } from '../Context'; import { SourceMemberRoles } from '../roles'; import { ExperimentAllocationClient, - features as gbFeatures, getEncryptedFeatures, getUserGrowthBookInstance, } from '../growthbook'; @@ -401,23 +398,13 @@ const handleNonExistentUser = async ( req: FastifyRequest, res: FastifyReply, middleware?: BootMiddleware, - authStrategy?: string, ): Promise => { req.log.info( { userId: req.userId }, 'could not find the logged user in the api', ); await clearAuthentication(req, res, 'user not found'); - return anonymousBoot( - con, - req, - res, - middleware, - false, - undefined, - undefined, - authStrategy, - ); + return anonymousBoot(con, req, res, middleware); }; const setAuthCookie = async ( @@ -643,7 +630,6 @@ const loggedInBoot = async ({ refreshToken, middleware, userId, - authStrategy, }: { con: DataSource; req: FastifyRequest; @@ -651,7 +637,6 @@ const loggedInBoot = async ({ refreshToken: boolean; middleware?: BootMiddleware; userId: string; - authStrategy?: string; }): Promise => runInSpan('loggedInBoot', async (span) => { span?.setAttribute(SEMATTRS_DAILY_APPS_USER_ID, userId); @@ -677,7 +662,6 @@ const loggedInBoot = async ({ balance, clickbaitTries, anonymousTheme, - hasBetterAuthAccount, ] = await Promise.all([ visitSection(req, res), getRoles(userId), @@ -704,20 +688,12 @@ const loggedInBoot = async ({ getBalanceBoot({ userId }), getClickbaitTries({ userId }), getAnonymousTheme(userId), - authStrategy && authStrategy !== 'betterauth' - ? con - .query(`SELECT 1 FROM ba_account WHERE "userId" = $1 LIMIT 1`, [ - userId, - ]) - .then((rows) => rows.length > 0) - .catch(() => false) - : Promise.resolve(false), ]); const profileCompletion = calculateProfileCompletion(user, experienceFlags); if (!user) { - return handleNonExistentUser(con, req, res, middleware, authStrategy); + return handleNonExistentUser(con, req, res, middleware); } // Apply anonymous theme (e.g. recruiter light mode) if user has no saved settings @@ -734,10 +710,6 @@ const loggedInBoot = async ({ exp.a.plus = 1; } - if (authStrategy) { - exp.a.authStrategy = hasBetterAuthAccount ? 'betterauth' : authStrategy; - } - span?.setAttribute(SEMATTRS_DAILY_STAFF, isTeamMember); const accessToken = @@ -897,7 +869,6 @@ const anonymousBoot = async ( shouldVerify = false, email?: string, referrer?: string, - authStrategy?: string, ): Promise => { const geo = geoSection(req); @@ -915,10 +886,6 @@ const anonymousBoot = async ( await setAnonymousTheme(req.trackingId, theme); } - if (authStrategy) { - exp.a.authStrategy = authStrategy; - } - return { user: { firstVisit, @@ -953,74 +920,41 @@ export const getBootData = async ( ): Promise => { const referrer = getBootReferrer(req); - const baForceCookie = req.cookies[cookies.baForce.key]; - const trackingIdForGb = req.userId || req.trackingId || ''; - const gb = getUserGrowthBookInstance(trackingIdForGb, { - allocationClient, - }); - const authStrategy = baForceCookie - ? 'betterauth' - : gb.getFeatureValue( - gbFeatures.authStrategy.id, - gbFeatures.authStrategy.defaultValue, - ); - - const setForceBACookie = (data: AnonymousBoot | LoggedInBoot) => { - if (data.exp?.a?.authStrategy === 'betterauth') { - setCookie(req, res, 'baForce', '1'); - } - return data; - }; - const baSessionCookie = req.cookies[cookies.authSession.key]; if (baSessionCookie) { - if (authStrategy === 'betterauth') { - try { - const session = (await getBetterAuth().api.getSession({ - headers: fromNodeHeaders( - req.headers as Record, - ), - })) as BetterAuthSession | null; - - if (session) { - req.userId = session.user.id; - req.trackingId = req.userId; - setTrackingId(req, res, req.trackingId); - const jwtValid = - req.accessToken?.expiresIn && - differenceInMinutes(req.accessToken.expiresIn, new Date()) > 3; - return setForceBACookie( - await loggedInBoot({ - con, - req, - res, - refreshToken: !jwtValid, - middleware, - userId: req.userId, - authStrategy, - }), - ); - } - - req.log.warn('BetterAuth getSession returned null'); - } catch (error) { - req.log.error( - { err: error instanceof Error ? error.message : String(error) }, - 'BetterAuth session validation failed', - ); + try { + const session = (await getBetterAuth().api.getSession({ + headers: fromNodeHeaders( + req.headers as Record, + ), + })) as BetterAuthSession | null; + + if (session) { + req.userId = session.user.id; + req.trackingId = req.userId; + setTrackingId(req, res, req.trackingId); + const jwtValid = + req.accessToken?.expiresIn && + differenceInMinutes(req.accessToken.expiresIn, new Date()) > 3; + return loggedInBoot({ + con, + req, + res, + refreshToken: !jwtValid, + middleware, + userId: req.userId, + }); } - req.log.warn( - { authStrategy }, - 'BetterAuth session cookie present but validation failed', - ); - setCookie(req, res, 'authSession', undefined); - } else { - req.log.warn( - { authStrategy }, - 'BetterAuth session cookie present but auth_strategy is not betterauth', + + req.log.warn('BetterAuth getSession returned null'); + } catch (error) { + req.log.error( + { err: error instanceof Error ? error.message : String(error) }, + 'BetterAuth session validation failed', ); - setCookie(req, res, 'authSession', undefined); } + req.log.warn('BetterAuth session cookie present but validation failed'); + setCookie(req, res, 'authSession', undefined); } if ( @@ -1028,103 +962,17 @@ export const getBootData = async ( req.accessToken?.expiresIn && differenceInMinutes(req.accessToken?.expiresIn, new Date()) > 3 ) { - if (authStrategy === 'betterauth' && !baSessionCookie) { - await createBetterAuthSessionFromKratos({ - req, - res, - userId: req.userId, - }); - } - return setForceBACookie( - await loggedInBoot({ - con, - req, - res, - refreshToken: false, - middleware, - userId: req.userId, - authStrategy, - }), - ); - } - - const whoami = await dispatchWhoami(req); - if (whoami.valid) { - if (whoami.cookie) { - setRawCookie(res, whoami.cookie); - } - if (whoami.verified === false) { - return setForceBACookie( - await anonymousBoot( - con, - req, - res, - middleware, - true, - whoami?.email, - referrer, - authStrategy, - ), - ); - } - if (req.userId !== whoami.userId) { - // Migrate theme from anonymous trackingId to new userId before overwriting - const oldTrackingId = req.trackingId; - if (oldTrackingId && oldTrackingId !== whoami.userId) { - const anonymousTheme = await getAnonymousTheme(oldTrackingId); - if (anonymousTheme) { - await setAnonymousTheme(whoami.userId, anonymousTheme); - } - } - req.userId = whoami.userId; - req.trackingId = req.userId; - setTrackingId(req, res, req.trackingId); - } - if (authStrategy === 'betterauth') { - await createBetterAuthSessionFromKratos({ - req, - res, - userId: whoami.userId, - }); - } - return setForceBACookie( - await loggedInBoot({ - con, - req, - res, - refreshToken: true, - middleware, - userId: req.userId, - authStrategy, - }), - ); - } else if (req.cookies[cookies.kratos.key]) { - await clearAuthentication(req, res, 'invalid cookie'); - return setForceBACookie( - await anonymousBoot( - con, - req, - res, - middleware, - false, - undefined, - referrer, - authStrategy, - ), - ); - } - return setForceBACookie( - await anonymousBoot( + return loggedInBoot({ con, req, res, + refreshToken: false, middleware, - false, - undefined, - referrer, - authStrategy, - ), - ); + userId: req.userId, + }); + } + + return anonymousBoot(con, req, res, middleware, false, undefined, referrer); }; const COMPANION_QUERY = parse(`query Post($url: String) { diff --git a/src/routes/users.ts b/src/routes/users.ts index c1afefcebf..e615e8a183 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -1,9 +1,34 @@ -import { FastifyInstance } from 'fastify'; -import { logout } from '../kratos'; +import { LogoutReason } from '../common'; +import { getShortGenericInviteLink } from '../common'; import { deleteUser } from '../common/user'; +import { clearAuthentication } from '../cookies'; import createOrGetConnection from '../db'; -import { getBootData, LoggedInBoot } from './boot'; -import { getShortGenericInviteLink } from '../common'; +import type { FastifyInstance } from 'fastify'; +import type { FastifyReply } from 'fastify'; +import type { FastifyRequest } from 'fastify'; +import { getBootData } from './boot'; +import { logoutBetterAuth } from './betterAuth'; + +const logout = async ( + req: FastifyRequest, + res: FastifyReply, + isDeletion = false, +): Promise => { + const query = req.query as { reason?: LogoutReason }; + const queryReason = query?.reason as LogoutReason; + const reason = Object.values(LogoutReason).includes(queryReason) + ? queryReason + : LogoutReason.ManualLogout; + + await logoutBetterAuth(req, res); + + await clearAuthentication( + req, + res, + isDeletion ? LogoutReason.UserDeleted : reason, + ); + return res.status(204).send(); +}; export default async function (fastify: FastifyInstance): Promise { const con = await createOrGetConnection(); @@ -11,11 +36,15 @@ export default async function (fastify: FastifyInstance): Promise { // Support legacy moderation platform fastify.get('/me', async (req, res) => { const boot = await getBootData(con, req, res); + const referralLink = req.userId + ? await getShortGenericInviteLink(req.log, req.userId) + : undefined; + return res.send({ ...boot.user, ...boot.visit, - referralLink: await getShortGenericInviteLink(req.log, req.userId!), - accessToken: (boot as LoggedInBoot).accessToken, + referralLink, + accessToken: 'accessToken' in boot ? boot.accessToken : undefined, }); }); diff --git a/src/types.ts b/src/types.ts index fc9aa6b020..8defd21eb7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -16,7 +16,6 @@ declare global { TYPEORM_USERNAME: string; TYPEORM_PASSWORD: string; TYPEORM_DATABASE: string; - HEIMDALL_ORIGIN: string; ENABLE_PRIVATE_ROUTES: string; ACCESS_SECRET: string; ALLOCATION_QUEUE_CONCURRENCY: string; @@ -34,8 +33,6 @@ declare global { GROWTHBOOK_CLIENT_KEY: string; EXPERIMENTATION_KEY: string; COOKIES_KEY: string; - KRATOS_ORIGIN: string; - BETTER_AUTH_REDIRECT_URL?: string; ONESIGNAL_APP_ID: string; ONESIGNAL_API_KEY: string; REDIS_HOST: string; diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 9528d214b3..0fcf404815 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -926,7 +926,6 @@ const onUserChange = async ( if (data.payload.op === 'd') { await triggerTypedEvent(logger, 'user-deleted', { id: data.payload.before!.id, - kratosUser: true, email: data.payload.before!.email, }); }