diff --git a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx index 2da0c0bff4..a567e8ad32 100644 --- a/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx +++ b/platform/flowglad-next/src/app/(merchant)/onboarding/DiscordConciergeLink.tsx @@ -14,9 +14,7 @@ export function DiscordConciergeLink() { const createChannel = trpc.organizations.createDiscordConciergeChannel.useMutation({ onSuccess: (data) => { - // Open Discord invite in new tab (noopener for security) - window.open(data.inviteUrl, '_blank', 'noopener,noreferrer') - setIsLoading(false) + window.location.href = data.oauthUrl }, onError: (error) => { console.error('Failed to create Discord channel:', error) @@ -50,9 +48,7 @@ export function DiscordConciergeLink() { onClick={handleClick} disabled={isLoading} > - {isLoading - ? 'Creating channel...' - : 'Join Concierge Channel'} + {isLoading ? 'Connecting...' : 'Join Concierge Channel'} {hasError ? (

diff --git a/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx new file mode 100644 index 0000000000..9b65e916d4 --- /dev/null +++ b/platform/flowglad-next/src/app/oauth/callback/discord/page.tsx @@ -0,0 +1,135 @@ +import { Result } from 'better-result' +import { redirect } from 'next/navigation' +import { adminTransaction } from '@/db/adminTransaction' +import { authenticatedTransaction } from '@/db/authenticatedTransaction' +import { + selectOrganizationById, + updateOrganization, +} from '@/db/tableMethods/organizationMethods' +import { + addUserToGuild, + exchangeDiscordOAuthCode, + getDiscordChannelUrl, + getDiscordConfig, + getDiscordUserFromToken, + getOrCreateConciergeChannel, + grantChannelAccess, +} from '@/utils/discord' +import { + decodeDiscordOAuthState, + validateAndConsumeDiscordOAuthCsrfToken, +} from '@/utils/discordOAuthState' +import { logger } from '@/utils/logger' +export default async function DiscordOAuthCallbackPage({ + searchParams, +}: { + searchParams: Promise<{ code?: string; state?: string }> +}) { + const { code, state } = await searchParams + + if (!code || !state) { + redirect('/onboarding') + } + + let redirectUrl: string + + try { + const csrfToken = decodeDiscordOAuthState(state) + + const userId = ( + await authenticatedTransaction(async ({ userId }) => + Result.ok(userId) + ) + ).unwrap() + + const validation = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken, + expectedUserId: userId, + }) + + if (!validation) { + throw new Error('CSRF validation failed') + } + + const config = getDiscordConfig() + const tokenResult = await exchangeDiscordOAuthCode({ + code, + config, + }) + + const discordUser = await getDiscordUserFromToken( + tokenResult.access_token + ) + + await addUserToGuild({ + guildId: config.guildId, + discordUserId: discordUser.id, + accessToken: tokenResult.access_token, + config, + }) + + // Fetch org to get existing channel ID (if any) and org name + const organization = ( + await adminTransaction(async ({ transaction }) => { + return selectOrganizationById( + validation.organizationId, + transaction + ) + }) + ).unwrap() + + // Create or reuse existing concierge channel + const { channelId } = await getOrCreateConciergeChannel( + organization.name, + organization.discordConciergeChannelId, + discordUser.id + ) + + // Persist channel ID if it changed + if (channelId !== organization.discordConciergeChannelId) { + ;( + await adminTransaction(async ({ transaction }) => { + await updateOrganization( + { + id: validation.organizationId, + discordConciergeChannelId: channelId, + }, + transaction + ) + return Result.ok(undefined) + }) + ).unwrap() + } + + // Re-read org to get actual stored channel (handles concurrent race) + const updatedOrg = ( + await adminTransaction(async ({ transaction }) => { + return selectOrganizationById( + validation.organizationId, + transaction + ) + }) + ).unwrap() + + const actualChannelId = + updatedOrg.discordConciergeChannelId ?? channelId + + await grantChannelAccess({ + channelId: actualChannelId, + discordUserId: discordUser.id, + config, + }) + + redirectUrl = getDiscordChannelUrl( + config.guildId, + actualChannelId + ) + } catch (error) { + logger.error('Discord OAuth callback failed', { + error: error instanceof Error ? error.message : String(error), + }) + redirectUrl = '/onboarding?error=discord_connection_failed' + } + + redirect(redirectUrl) +} diff --git a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts index 1b7582425d..e29ca0fc54 100644 --- a/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts +++ b/platform/flowglad-next/src/server/mutations/createDiscordConciergeChannel.ts @@ -1,22 +1,25 @@ -import { organizations } from '@db-core/schema/organizations' import { TRPCError } from '@trpc/server' -import { Result } from 'better-result' -import { and, eq, isNull } from 'drizzle-orm' import { z } from 'zod' -import { adminTransaction } from '@/db/adminTransaction' -import { selectOrganizationById } from '@/db/tableMethods/organizationMethods' import { protectedProcedure } from '@/server/trpc' -import { getOrCreateConciergeChannel } from '@/utils/discord' +import { + buildDiscordOAuthUrl, + getDiscordConfig, +} from '@/utils/discord' +import { + createDiscordOAuthCsrfToken, + encodeDiscordOAuthState, +} from '@/utils/discordOAuthState' export const createDiscordConciergeChannelSchema = z.object({}) export const createDiscordConciergeChannel = protectedProcedure .input(createDiscordConciergeChannelSchema) - .output(z.object({ inviteUrl: z.string() })) + .output(z.object({ oauthUrl: z.string() })) .mutation(async ({ ctx }) => { const { organizationId } = ctx + const userId = ctx.user?.id - if (!organizationId) { + if (!organizationId || !userId) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Organization context required', @@ -24,87 +27,19 @@ export const createDiscordConciergeChannel = protectedProcedure } try { - // Fetch fresh organization data to get latest discordConciergeChannelId - const organization = ( - await adminTransaction(async ({ transaction }) => { - return selectOrganizationById(organizationId, transaction) - }) - ).unwrap() - - // Create or get concierge channel (pass existing ID for fast lookup) - const { channelId, inviteUrl } = - await getOrCreateConciergeChannel( - organization.name, - organization.discordConciergeChannelId - ) - - // Persist channel ID using conditional update to prevent race conditions. - // If a concurrent request already set the channel ID, the WHERE clause - // won't match, and we fall back to the winner's channel. - if (channelId !== organization.discordConciergeChannelId) { - const persistResult = await adminTransaction( - async ({ transaction }) => { - const condition = - organization.discordConciergeChannelId === null - ? and( - eq(organizations.id, organizationId), - isNull(organizations.discordConciergeChannelId) - ) - : and( - eq(organizations.id, organizationId), - eq( - organizations.discordConciergeChannelId, - organization.discordConciergeChannelId - ) - ) - - const [updated] = await transaction - .update(organizations) - .set({ - discordConciergeChannelId: channelId, - updatedAt: Date.now(), - }) - .where(condition) - .returning() - - if (!updated) { - // Lost the race — fetch the winner's channel ID - const winnerResult = await selectOrganizationById( - organizationId, - transaction - ) - const winner = winnerResult.unwrap() - return Result.ok({ - raceResolved: true, - winnerChannelId: winner.discordConciergeChannelId, - }) - } - - return Result.ok({ - raceResolved: false, - winnerChannelId: null, - }) - } - ) - - const { raceResolved, winnerChannelId } = - persistResult.unwrap() - - if (raceResolved && winnerChannelId) { - // Return invite for the channel that was persisted first - const winner = await getOrCreateConciergeChannel( - organization.name, - winnerChannelId - ) - return { inviteUrl: winner.inviteUrl } - } - } + const config = getDiscordConfig() + const csrfToken = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + }) + const state = encodeDiscordOAuthState(csrfToken) + const oauthUrl = buildDiscordOAuthUrl({ state, config }) - return { inviteUrl } + return { oauthUrl } } catch (error) { throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', - message: 'Failed to create Discord channel', + message: 'Failed to initiate Discord OAuth', cause: error, }) } diff --git a/platform/flowglad-next/src/utils/discord.ts b/platform/flowglad-next/src/utils/discord.ts index e0c7641825..ca463a5d61 100644 --- a/platform/flowglad-next/src/utils/discord.ts +++ b/platform/flowglad-next/src/utils/discord.ts @@ -1,22 +1,26 @@ import { REST } from '@discordjs/rest' import { type APIChannel, - type APIExtendedInvite, type APIMessage, type APIOverwrite, + type APIUser, ChannelType, + OAuth2Routes, OverwriteType, PermissionFlagsBits, - type RESTPostAPIChannelInviteJSONBody, type RESTPostAPIChannelMessageJSONBody, type RESTPostAPIGuildChannelJSONBody, + type RESTPostOAuth2AccessTokenResult, + type RESTPutAPIChannelPermissionJSONBody, + type RESTPutAPIGuildMemberJSONBody, + RouteBases, Routes, } from 'discord-api-types/v10' +import { z } from 'zod' import { panic } from '@/errors' export interface ConciergeChannelResult { channelId: string - inviteUrl: string } export interface DiscordConfig { @@ -25,6 +29,9 @@ export interface DiscordConfig { conciergeCategoryPrefix: string flowgladTeamRoleId?: string internalBotRoleId?: string + oauthClientId?: string + oauthClientSecret?: string + oauthRedirectUri?: string } const DISCORD_CATEGORY_CHANNEL_LIMIT = 50 @@ -55,12 +62,19 @@ export function getDiscordConfig(): DiscordConfig { panic('DISCORD_GUILD_ID environment variable is required') } + const oauthClientId = process.env.DISCORD_OAUTH_CLIENT_ID + const oauthClientSecret = process.env.DISCORD_OAUTH_CLIENT_SECRET + const oauthRedirectUri = process.env.DISCORD_OAUTH_REDIRECT_URI + return { botToken, guildId, conciergeCategoryPrefix, flowgladTeamRoleId, internalBotRoleId, + oauthClientId, + oauthClientSecret, + oauthRedirectUri, } } @@ -293,13 +307,16 @@ async function createPrivateChannel( */ export function buildWelcomeMessage( orgName: string, - flowgladTeamRoleId?: string + flowgladTeamRoleId?: string, + discordUserId?: string ): string { const teamMention = flowgladTeamRoleId ? `<@&${flowgladTeamRoleId}>` : 'the Flowglad team' - return `@here Welcome to your private concierge channel with ${teamMention}! Ask us any questions about onboarding, we're here to help 🙌 + const greeting = discordUserId ? `<@${discordUserId}>` : '@here' + + return `${greeting} Welcome to your private concierge channel with ${teamMention}! Ask us any questions about onboarding, we're here to help 🙌 **To finish setup, head to :** @@ -318,11 +335,13 @@ async function postWelcomeMessage( rest: REST, channelId: string, orgName: string, - config: DiscordConfig + config: DiscordConfig, + discordUserId?: string ): Promise { const content = buildWelcomeMessage( orgName, - config.flowgladTeamRoleId + config.flowgladTeamRoleId, + discordUserId ) const body: RESTPostAPIChannelMessageJSONBody = { @@ -334,42 +353,6 @@ async function postWelcomeMessage( })) as APIMessage } -/** - * Get channel invites and find a valid one, or create a new invite. - */ -async function getOrCreateInvite( - rest: REST, - channelId: string -): Promise { - // Fetch existing invites - const invites = (await rest.get( - Routes.channelInvites(channelId) - )) as APIExtendedInvite[] - - // Find a valid invite (unlimited uses, not expired) - const existingInvite = invites.find( - (inv) => - inv.max_uses === 0 && - (!inv.expires_at || new Date(inv.expires_at) > new Date()) - ) - - if (existingInvite) { - return `https://discord.gg/${existingInvite.code}` - } - - // Create new invite: 7 days, unlimited uses - const body: RESTPostAPIChannelInviteJSONBody = { - max_age: 604800, // 7 days in seconds - max_uses: 0, // unlimited - unique: false, - } - - const invite = (await rest.post(Routes.channelInvites(channelId), { - body, - })) as APIExtendedInvite - return `https://discord.gg/${invite.code}` -} - /** * Main function: Get or create a concierge channel for an organization. * If existingChannelId is provided, tries to find that channel first. @@ -382,7 +365,8 @@ async function getOrCreateInvite( */ export async function getOrCreateConciergeChannel( orgName: string, - existingChannelId?: string | null + existingChannelId?: string | null, + discordUserId?: string ): Promise { const config = getDiscordConfig() const rest = getRestClient(config.botToken) @@ -409,14 +393,154 @@ export async function getOrCreateConciergeChannel( // Post welcome message for newly created channels if (isNewChannel) { - await postWelcomeMessage(rest, channel.id, orgName, config) + await postWelcomeMessage( + rest, + channel.id, + orgName, + config, + discordUserId + ) } - // Get or create invite - const inviteUrl = await getOrCreateInvite(rest, channel.id) - return { channelId: channel.id, - inviteUrl, } } + +/** + * Build the Discord OAuth2 authorization URL. + * Requires oauthClientId and oauthRedirectUri in config. + */ +export function buildDiscordOAuthUrl(params: { + state: string + config: DiscordConfig +}): string { + const { state, config } = params + if (!config.oauthClientId || !config.oauthRedirectUri) { + panic( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + } + const url = new URL(OAuth2Routes.authorizationURL) + url.searchParams.set('response_type', 'code') + url.searchParams.set('client_id', config.oauthClientId) + url.searchParams.set('scope', 'identify guilds.join') + url.searchParams.set('redirect_uri', config.oauthRedirectUri) + url.searchParams.set('state', state) + url.searchParams.set('prompt', 'consent') + return url.toString() +} + +/** + * Exchange an OAuth2 authorization code for an access token. + * Uses raw fetch (not the bot REST client) because this needs client credentials. + */ +export async function exchangeDiscordOAuthCode(params: { + code: string + config: DiscordConfig +}): Promise { + const { code, config } = params + if ( + !config.oauthClientId || + !config.oauthClientSecret || + !config.oauthRedirectUri + ) { + panic('Discord OAuth credentials are required') + } + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code, + redirect_uri: config.oauthRedirectUri, + client_id: config.oauthClientId, + client_secret: config.oauthClientSecret, + }) + const response = await fetch(OAuth2Routes.tokenURL, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!response.ok) { + const errorText = await response.text() + panic( + `Discord OAuth token exchange failed: ${response.status} ${errorText}` + ) + } + const json = await response.json() + const tokenSchema = z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z.number(), + scope: z.string(), + }) + return tokenSchema.parse(json) as RESTPostOAuth2AccessTokenResult +} + +/** + * Get the Discord user associated with an OAuth2 access token. + * Uses raw fetch with Bearer auth (not the bot REST client). + */ +export async function getDiscordUserFromToken( + accessToken: string +): Promise { + const response = await fetch( + `${RouteBases.api}${Routes.user('@me')}`, + { + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + if (!response.ok) { + panic(`Failed to fetch Discord user: ${response.status}`) + } + return response.json() as Promise +} + +/** + * Add a user to a guild using their OAuth2 access token. + * Returns silently whether the user was newly added (201) or already a member (204). + */ +export async function addUserToGuild(params: { + guildId: string + discordUserId: string + accessToken: string + config: DiscordConfig +}): Promise { + const { guildId, discordUserId, accessToken, config } = params + const rest = getRestClient(config.botToken) + const body: RESTPutAPIGuildMemberJSONBody = { + access_token: accessToken, + } + await rest.put(Routes.guildMember(guildId, discordUserId), { body }) +} + +/** + * Grant a user ViewChannel + SendMessages on a private channel. + */ +export async function grantChannelAccess(params: { + channelId: string + discordUserId: string + config: DiscordConfig +}): Promise { + const { channelId, discordUserId, config } = params + const rest = getRestClient(config.botToken) + const body: RESTPutAPIChannelPermissionJSONBody = { + allow: ( + PermissionFlagsBits.ViewChannel | + PermissionFlagsBits.SendMessages + ).toString(), + deny: '0', + type: OverwriteType.Member, + } + await rest.put(Routes.channelPermission(channelId, discordUserId), { + body, + }) +} + +/** + * Build the Discord channel URL for direct navigation. + */ +export function getDiscordChannelUrl( + guildId: string, + channelId: string +): string { + return `https://discord.com/channels/${guildId}/${channelId}` +} diff --git a/platform/flowglad-next/src/utils/discord.unit.test.ts b/platform/flowglad-next/src/utils/discord.unit.test.ts index c5d1b20044..0a1c6e69e3 100644 --- a/platform/flowglad-next/src/utils/discord.unit.test.ts +++ b/platform/flowglad-next/src/utils/discord.unit.test.ts @@ -1,6 +1,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test' import { + buildDiscordOAuthUrl, buildWelcomeMessage, + type DiscordConfig, + getDiscordChannelUrl, getDiscordConfig, parseCohortNumber, sanitizeChannelName, @@ -210,11 +213,23 @@ describe('discord', () => { }) describe('buildWelcomeMessage', () => { - it('includes @here mention and onboarding link', () => { + it('mentions the user by Discord ID and includes onboarding link', () => { + const message = buildWelcomeMessage( + 'Acme Corp', + undefined, + '987654321' + ) + + expect(message).toContain('<@987654321>') + expect(message).not.toContain('@here') + expect(message).toContain('https://app.flowglad.com/onboarding') + }) + + it('falls back to @here when no Discord user ID is provided', () => { const message = buildWelcomeMessage('Acme Corp') expect(message).toContain('@here') - expect(message).toContain('https://app.flowglad.com/onboarding') + expect(message).not.toContain('<@') }) it('includes all four onboarding steps', () => { @@ -240,4 +255,68 @@ describe('discord', () => { expect(message).not.toContain('<@&') }) }) + + describe('buildDiscordOAuthUrl', () => { + const baseConfig: DiscordConfig = { + botToken: 'test-bot-token', + guildId: 'test-guild-id', + conciergeCategoryPrefix: 'Concierge Cohort', + oauthClientId: 'test-client-id', + oauthClientSecret: 'test-client-secret', + oauthRedirectUri: + 'http://localhost:3000/oauth/callback/discord', + } + + it('builds a valid Discord OAuth2 authorization URL with required params', () => { + const url = buildDiscordOAuthUrl({ + state: 'test-state', + config: baseConfig, + }) + + const parsed = new URL(url) + expect(parsed.origin).toBe('https://discord.com') + expect(parsed.searchParams.get('response_type')).toBe('code') + expect(parsed.searchParams.get('client_id')).toBe( + 'test-client-id' + ) + expect(parsed.searchParams.get('scope')).toBe( + 'identify guilds.join' + ) + expect(parsed.searchParams.get('redirect_uri')).toBe( + 'http://localhost:3000/oauth/callback/discord' + ) + expect(parsed.searchParams.get('state')).toBe('test-state') + expect(parsed.searchParams.get('prompt')).toBe('consent') + }) + + it('throws when oauthClientId is missing', () => { + const config = { ...baseConfig, oauthClientId: undefined } + + expect(() => + buildDiscordOAuthUrl({ state: 'test', config }) + ).toThrow( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + }) + + it('throws when oauthRedirectUri is missing', () => { + const config = { ...baseConfig, oauthRedirectUri: undefined } + + expect(() => + buildDiscordOAuthUrl({ state: 'test', config }) + ).toThrow( + 'DISCORD_OAUTH_CLIENT_ID and DISCORD_OAUTH_REDIRECT_URI are required' + ) + }) + }) + + describe('getDiscordChannelUrl', () => { + it('returns the correct Discord channel URL format', () => { + const url = getDiscordChannelUrl('guild-123', 'channel-456') + + expect(url).toBe( + 'https://discord.com/channels/guild-123/channel-456' + ) + }) + }) }) diff --git a/platform/flowglad-next/src/utils/discordOAuthState.ts b/platform/flowglad-next/src/utils/discordOAuthState.ts new file mode 100644 index 0000000000..476b83f050 --- /dev/null +++ b/platform/flowglad-next/src/utils/discordOAuthState.ts @@ -0,0 +1,175 @@ +import { timingSafeEqual } from 'crypto' +import { z } from 'zod' +import { panic } from '@/errors' +import { generateRandomBytes } from './backendCore' +import { logger } from './logger' +import { RedisKeyNamespace, redis } from './redis' + +/** + * CSRF token length in bytes (32 bytes = 256 bits of entropy) + */ +const CSRF_TOKEN_BYTES = 32 + +/** + * TTL for CSRF tokens in seconds (15 minutes) + */ +const CSRF_TTL_SECONDS = 60 * 15 + +/** + * Schema for CSRF token data stored in Redis + */ +const csrfTokenDataSchema = z.object({ + userId: z.string().min(1), + organizationId: z.string().min(1), + createdAt: z.string().datetime(), +}) + +type CsrfTokenData = z.infer + +/** + * Build Redis key for CSRF token storage + */ +function buildCsrfTokenKey(token: string): string { + return `${RedisKeyNamespace.DiscordOAuthCsrfToken}:${token}` +} + +/** + * Timing-safe string comparison to prevent timing attacks + */ +function safeCompare(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'utf8') + const bufB = Buffer.from(b, 'utf8') + if (bufA.length !== bufB.length) { + return false + } + return timingSafeEqual(bufA, bufB) +} + +/** + * Creates a cryptographically random CSRF token and stores it in Redis + * with the associated user, organization, and channel context. + */ +export async function createDiscordOAuthCsrfToken(params: { + userId: string + organizationId: string +}): Promise { + const { userId, organizationId } = params + + const csrfToken = generateRandomBytes(CSRF_TOKEN_BYTES) + + const tokenData: CsrfTokenData = { + userId, + organizationId, + createdAt: new Date().toISOString(), + } + + const key = buildCsrfTokenKey(csrfToken) + + try { + const redisClient = redis() + await redisClient.set(key, JSON.stringify(tokenData), { + ex: CSRF_TTL_SECONDS, + }) + + logger.info('Discord OAuth CSRF token created', { + userId, + organizationId, + tokenPrefix: csrfToken.substring(0, 4), + }) + + return csrfToken + } catch (error) { + logger.error('Failed to store Discord OAuth CSRF token', { + error: error instanceof Error ? error.message : String(error), + userId, + organizationId, + }) + panic('Unable to initiate Discord OAuth flow') + } +} + +/** + * Validates and consumes a CSRF token. This is a single-use operation — + * the token is atomically retrieved and deleted from Redis. + */ +export async function validateAndConsumeDiscordOAuthCsrfToken(params: { + csrfToken: string + expectedUserId: string +}): Promise<{ organizationId: string } | null> { + const { csrfToken, expectedUserId } = params + const key = buildCsrfTokenKey(csrfToken) + + try { + const redisClient = redis() + + const rawData = await redisClient.getdel(key) + + if (!rawData) { + logger.warn( + 'Discord OAuth CSRF token not found or already consumed', + { + tokenPrefix: csrfToken.substring(0, 4), + expectedUserId, + } + ) + return null + } + + const jsonData = + typeof rawData === 'string' ? JSON.parse(rawData) : rawData + const parseResult = csrfTokenDataSchema.safeParse(jsonData) + + if (!parseResult.success) { + logger.warn('Discord OAuth CSRF token data invalid', { + tokenPrefix: csrfToken.substring(0, 4), + error: parseResult.error.message, + }) + return null + } + + const tokenData = parseResult.data + + if (!safeCompare(tokenData.userId, expectedUserId)) { + logger.warn('Discord OAuth CSRF token user mismatch', { + tokenPrefix: csrfToken.substring(0, 4), + expectedUserId, + }) + return null + } + + logger.info('Discord OAuth CSRF token validated', { + userId: expectedUserId, + organizationId: tokenData.organizationId, + tokenPrefix: csrfToken.substring(0, 4), + }) + + return { + organizationId: tokenData.organizationId, + } + } catch (error) { + logger.error('Error validating Discord OAuth CSRF token', { + error: error instanceof Error ? error.message : String(error), + tokenPrefix: csrfToken.substring(0, 4), + }) + return null + } +} + +/** + * Encodes a CSRF token for use in the OAuth state parameter. + */ +export function encodeDiscordOAuthState(csrfToken: string): string { + return Buffer.from(csrfToken, 'utf8').toString('base64') +} + +/** + * Decodes the OAuth state parameter to extract the CSRF token. + */ +export function decodeDiscordOAuthState(state: string): string { + try { + const decoded = decodeURIComponent(state) + return Buffer.from(decoded, 'base64').toString('utf8') + } catch { + panic('Invalid OAuth state parameter') + } +} diff --git a/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts new file mode 100644 index 0000000000..341549bc41 --- /dev/null +++ b/platform/flowglad-next/src/utils/discordOAuthState.unit.test.ts @@ -0,0 +1,344 @@ +import { beforeEach, describe, expect, it, mock } from 'bun:test' + +/** + * In-memory Redis mock for testing CSRF token storage. + */ +const mockRedisStore: Map = new Map() +let getdelOverride: ((key: string) => unknown) | null = null + +const mockGenerateRandomBytes = mock( + () => 'mock-csrf-token-32-bytes-long-xx' +) + +mock.module('./redis', () => ({ + redis: () => ({ + get: mock((key: string) => mockRedisStore.get(key) || null), + getdel: mock((key: string) => { + if (getdelOverride) { + return getdelOverride(key) + } + const value = mockRedisStore.get(key) || null + mockRedisStore.delete(key) + return value + }), + set: mock( + (key: string, value: string, _options?: { ex?: number }) => { + mockRedisStore.set(key, value) + return 'OK' + } + ), + del: mock((key: string) => { + const existed = mockRedisStore.has(key) + mockRedisStore.delete(key) + return existed ? 1 : 0 + }), + }), + RedisKeyNamespace: { + DiscordOAuthCsrfToken: 'discordOAuthCsrfToken', + }, +})) + +mock.module('./backendCore', () => ({ + generateRandomBytes: mockGenerateRandomBytes, +})) + +mock.module('./logger', () => ({ + logger: { + info: mock(), + warn: mock(), + error: mock(), + }, +})) + +import { + createDiscordOAuthCsrfToken, + decodeDiscordOAuthState, + encodeDiscordOAuthState, + validateAndConsumeDiscordOAuthCsrfToken, +} from './discordOAuthState' + +describe('discordOAuthState', () => { + beforeEach(() => { + mockRedisStore.clear() + getdelOverride = null + mockGenerateRandomBytes.mockReset() + mockGenerateRandomBytes.mockImplementation( + () => 'mock-csrf-token-32-bytes-long-xx' + ) + }) + + describe('encodeDiscordOAuthState', () => { + it('encodes a CSRF token to base64', () => { + const token = 'test-csrf-token' + const encoded = encodeDiscordOAuthState(token) + + expect(encoded).toBe( + Buffer.from(token, 'utf8').toString('base64') + ) + }) + + it('produces valid base64 output', () => { + const token = 'token-with-special+chars/=test' + const encoded = encodeDiscordOAuthState(token) + + expect(encoded).toMatch(/^[A-Za-z0-9+/=]+$/) + }) + }) + + describe('decodeDiscordOAuthState', () => { + it('decodes a base64-encoded state parameter', () => { + const token = 'test-csrf-token' + const encoded = Buffer.from(token, 'utf8').toString('base64') + + const decoded = decodeDiscordOAuthState(encoded) + expect(decoded).toBe(token) + }) + + it('handles URL-encoded state parameters', () => { + const token = 'test-csrf-token' + const encoded = Buffer.from(token, 'utf8').toString('base64') + const urlEncoded = encodeURIComponent(encoded) + + const decoded = decodeDiscordOAuthState(urlEncoded) + expect(decoded).toBe(token) + }) + + it('roundtrips with encodeDiscordOAuthState', () => { + const originalToken = 'my-secret-csrf-token-12345' + const encoded = encodeDiscordOAuthState(originalToken) + const decoded = decodeDiscordOAuthState(encoded) + + expect(decoded).toBe(originalToken) + }) + + it('throws on malformed state parameter', () => { + const invalidState = '%%%invalid%%%' + + expect(() => decodeDiscordOAuthState(invalidState)).toThrow( + 'Invalid OAuth state parameter' + ) + }) + }) + + describe('createDiscordOAuthCsrfToken', () => { + it('creates a token and stores it in Redis with userId and organizationId', async () => { + const userId = 'user-123' + const organizationId = 'org-456' + + const token = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + }) + + expect(token).toBe('mock-csrf-token-32-bytes-long-xx') + + const storedData = mockRedisStore.get( + `discordOAuthCsrfToken:${token}` + ) + + const parsedData = JSON.parse(storedData!) + expect(parsedData.userId).toBe(userId) + expect(parsedData.organizationId).toBe(organizationId) + expect(typeof parsedData.createdAt).toBe('string') + }) + + it('stores createdAt timestamp in ISO format', async () => { + const token = await createDiscordOAuthCsrfToken({ + userId: 'user-123', + organizationId: 'org-456', + }) + + const storedData = mockRedisStore.get( + `discordOAuthCsrfToken:${token}` + ) + const parsedData = JSON.parse(storedData!) + + const parsedDate = new Date(parsedData.createdAt) + expect(parsedDate.toISOString()).toBe(parsedData.createdAt) + }) + }) + + describe('validateAndConsumeDiscordOAuthCsrfToken', () => { + const userId = 'user-123' + const organizationId = 'org-456' + const testToken = 'test-csrf-token' + + beforeEach(() => { + const tokenData = { + userId, + organizationId, + createdAt: new Date().toISOString(), + } + mockRedisStore.set( + `discordOAuthCsrfToken:${testToken}`, + JSON.stringify(tokenData) + ) + }) + + it('validates and returns organizationId for valid token and user', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId }) + }) + + it('deletes the token after validation (single-use)', async () => { + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(true) + + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(false) + }) + + it('returns null for non-existent token', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'non-existent-token', + expectedUserId: userId, + }) + + expect(result).toBeNull() + }) + + it('returns null and deletes token when user ID does not match', async () => { + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: 'different-user', + }) + + expect(result).toBeNull() + expect( + mockRedisStore.has(`discordOAuthCsrfToken:${testToken}`) + ).toBe(false) + }) + + it('returns null for invalid token data format', async () => { + mockRedisStore.set( + `discordOAuthCsrfToken:invalid-token`, + JSON.stringify({ invalid: 'data' }) + ) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'invalid-token', + expectedUserId: userId, + }) + + expect(result).toBeNull() + }) + + it('returns null when token data is missing required fields', async () => { + mockRedisStore.set( + `discordOAuthCsrfToken:incomplete-token`, + JSON.stringify({ + userId: 'user-123', + // missing organizationId and createdAt + }) + ) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'incomplete-token', + expectedUserId: 'user-123', + }) + + expect(result).toBeNull() + }) + + it('cannot reuse the same token twice', async () => { + const firstResult = + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + expect(firstResult).toEqual({ organizationId }) + + const secondResult = + await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: testToken, + expectedUserId: userId, + }) + expect(secondResult).toBeNull() + }) + + it('handles Redis returning object instead of string', async () => { + const tokenData = { + userId, + organizationId, + createdAt: new Date().toISOString(), + } + + // Simulate Redis clients that return parsed objects + getdelOverride = (key: string) => { + if (key === `discordOAuthCsrfToken:object-token`) { + return tokenData + } + return null + } + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: 'object-token', + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId }) + }) + }) + + describe('full OAuth flow integration', () => { + beforeEach(() => { + mockGenerateRandomBytes.mockReturnValue( + 'flow-test-token-abc123' + ) + }) + + it('completes full create-encode-decode-validate flow', async () => { + const userId = 'user-flow-test' + const organizationId = 'org-flow-test' + + const csrfToken = await createDiscordOAuthCsrfToken({ + userId, + organizationId, + }) + + const state = encodeDiscordOAuthState(csrfToken) + + const decodedToken = decodeDiscordOAuthState(state) + expect(decodedToken).toBe(csrfToken) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: decodedToken, + expectedUserId: userId, + }) + + expect(result).toEqual({ organizationId }) + }) + + it('prevents CSRF attack with wrong user', async () => { + const legitimateUserId = 'legitimate-user' + const attackerUserId = 'attacker-user' + const organizationId = 'target-org' + + const csrfToken = await createDiscordOAuthCsrfToken({ + userId: legitimateUserId, + organizationId, + }) + + const state = encodeDiscordOAuthState(csrfToken) + const decodedToken = decodeDiscordOAuthState(state) + + const result = await validateAndConsumeDiscordOAuthCsrfToken({ + csrfToken: decodedToken, + expectedUserId: attackerUserId, + }) + + expect(result).toBeNull() + }) + }) +}) diff --git a/platform/flowglad-next/src/utils/redis.ts b/platform/flowglad-next/src/utils/redis.ts index 4297a1d9df..94a0a1ae35 100644 --- a/platform/flowglad-next/src/utils/redis.ts +++ b/platform/flowglad-next/src/utils/redis.ts @@ -166,6 +166,7 @@ export enum RedisKeyNamespace { Telemetry = 'telemetry', BannerDismissals = 'bannerDismissals', StripeOAuthCsrfToken = 'stripeOAuthCsrfToken', + DiscordOAuthCsrfToken = 'discordOAuthCsrfToken', SubscriptionsByCustomer = 'subscriptionsByCustomer', ItemsBySubscription = 'itemsBySubscription', FeaturesBySubscriptionItem = 'featuresBySubscriptionItem', @@ -209,6 +210,10 @@ const evictionPolicy: Record< max: 10000, // up to 10k concurrent OAuth flows ttl: 60 * 15, // 15 minutes - OAuth flow timeout }, + [RedisKeyNamespace.DiscordOAuthCsrfToken]: { + max: 10000, + ttl: 60 * 15, + }, [RedisKeyNamespace.SubscriptionsByCustomer]: { max: 50000, },