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,
},