Skip to content

Commit 64a5ddb

Browse files
kaihaaseclaude
andcommitted
fix(better-auth): 2FA cookie handling and test infrastructure
- Fix 2FA sign-in to use native Better Auth handler for cookie handling - When 2FA is required, forward to native handler to ensure session cookie is set - Add returnResponse option to TestHelper for header inspection - Add critical test: validates Set-Cookie header with two_factor token - Update migration guide with 2FA cookie bugfix documentation This commit preserves the current working state before refactoring to use Better Auth hooks for cleaner architecture. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 80827a5 commit 64a5ddb

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+1368
-609
lines changed

migration-guides/11.9.x-to-11.10.x.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,28 @@ Additionally, new endpoints are available for backup codes.
181181

182182
---
183183

184+
## Bugfixes
185+
186+
### 2FA Session Token Cookie Handling
187+
188+
**Fixed Issue:** When signing in with 2FA enabled, the temporary session token was not being set as a cookie. This caused the 2FA verification step (`/iam/two-factor/verify-totp`) to fail with 401 Unauthorized because the browser couldn't authenticate the request.
189+
190+
**What was happening:**
191+
1. User signs in with email/password
192+
2. Server returns `requiresTwoFactor: true` but no cookie was set
193+
3. User enters TOTP code
194+
4. Request fails because there's no session token to identify the 2FA flow
195+
196+
**What's fixed:**
197+
1. User signs in with email/password
198+
2. Server returns `requiresTwoFactor: true` AND sets the temporary session token as a cookie
199+
3. User enters TOTP code
200+
4. Browser automatically sends the session cookie → verification succeeds
201+
202+
**No action required** - this is a bugfix that improves 2FA functionality without requiring any code changes.
203+
204+
---
205+
184206
## Detailed Migration Steps
185207

186208
### Step 1: Update Package

package-lock.json

Lines changed: 66 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@
8080
"dependencies": {
8181
"@apollo/server": "5.2.0",
8282
"@as-integrations/express5": "1.1.2",
83-
"@better-auth/passkey": "1.4.8-beta.4",
83+
"@better-auth/passkey": "^1.4.16",
8484
"@getbrevo/brevo": "3.0.1",
8585
"@nestjs/apollo": "13.2.3",
8686
"@nestjs/common": "11.1.9",
@@ -98,7 +98,7 @@
9898
"@tus/server": "2.3.0",
9999
"apollo-server-core": "3.13.0",
100100
"bcrypt": "6.0.0",
101-
"better-auth": "1.4.8-beta.4",
101+
"better-auth": "^1.4.16",
102102
"class-transformer": "0.5.1",
103103
"class-validator": "0.14.3",
104104
"compression": "1.8.1",
@@ -147,6 +147,7 @@
147147
"@typescript-eslint/parser": "8.50.0",
148148
"@vitest/coverage-v8": "4.0.16",
149149
"@vitest/ui": "4.0.16",
150+
"otpauth": "9.4.1",
150151
"ansi-colors": "4.1.3",
151152
"eslint": "9.39.2",
152153
"eslint-config-prettier": "10.1.8",

src/core.module.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import { EmailService } from './core/common/services/email.service';
1919
import { MailjetService } from './core/common/services/mailjet.service';
2020
import { ModelDocService } from './core/common/services/model-doc.service';
2121
import { TemplateService } from './core/common/services/template.service';
22-
import { BetterAuthUserMapper } from './core/modules/better-auth/better-auth-user.mapper';
23-
import { BetterAuthModule } from './core/modules/better-auth/better-auth.module';
24-
import { BetterAuthService } from './core/modules/better-auth/better-auth.service';
22+
import { CoreBetterAuthUserMapper } from './core/modules/better-auth/core-better-auth-user.mapper';
23+
import { CoreBetterAuthModule } from './core/modules/better-auth/core-better-auth.module';
24+
import { CoreBetterAuthService } from './core/modules/better-auth/core-better-auth.service';
2525
import { ErrorCodeModule } from './core/modules/error-code/error-code.module';
2626
import { CoreHealthCheckModule } from './core/modules/health-check/core-health-check.module';
2727

@@ -86,8 +86,8 @@ export class CoreModule implements NestModule {
8686
*
8787
* **Requirements:**
8888
* - Configure `betterAuth` in your config (enabled by default)
89-
* - Create BetterAuthModule, Resolver, and Controller in your project
90-
* - Inject BetterAuthUserMapper in UserService
89+
* - Create CoreBetterAuthModule, Resolver, and Controller in your project
90+
* - Inject CoreBetterAuthUserMapper in UserService
9191
*
9292
* ### Legacy + IAM Signature (For existing projects)
9393
*
@@ -246,8 +246,8 @@ export class CoreModule implements NestModule {
246246
imports.push(CoreHealthCheckModule);
247247
}
248248

249-
// Add BetterAuthModule based on mode
250-
// IAM-only mode: Always register BetterAuthModule (required for subscription auth)
249+
// Add CoreBetterAuthModule based on mode
250+
// IAM-only mode: Always register CoreBetterAuthModule (required for subscription auth)
251251
// Legacy mode: Only register if autoRegister is explicitly true
252252
// betterAuth can be: boolean | IBetterAuth | undefined
253253
const betterAuthConfig = config.betterAuth;
@@ -258,7 +258,7 @@ export class CoreModule implements NestModule {
258258
if (isBetterAuthEnabled) {
259259
if (isIamOnlyMode || isAutoRegister) {
260260
imports.push(
261-
BetterAuthModule.forRoot({
261+
CoreBetterAuthModule.forRoot({
262262
config: betterAuthConfig === true ? {} : betterAuthConfig || {},
263263
// Pass JWT secrets for backwards compatibility fallback
264264
fallbackSecrets: [config.jwt?.secret, config.jwt?.refresh?.secret],
@@ -289,14 +289,14 @@ export class CoreModule implements NestModule {
289289
/**
290290
* Build GraphQL driver configuration for IAM-only mode
291291
*
292-
* Uses BetterAuthService for subscription authentication via JWT tokens.
292+
* Uses CoreBetterAuthService for subscription authentication via JWT tokens.
293293
* This is the recommended mode for new projects.
294294
*/
295295
private static buildIamOnlyGraphQlDriver(cors: object, options: Partial<IServerOptions>) {
296296
return {
297-
imports: [BetterAuthModule],
298-
inject: [BetterAuthService, BetterAuthUserMapper],
299-
useFactory: async (betterAuthService: BetterAuthService, userMapper: BetterAuthUserMapper) =>
297+
imports: [CoreBetterAuthModule],
298+
inject: [CoreBetterAuthService, CoreBetterAuthUserMapper],
299+
useFactory: async (betterAuthService: CoreBetterAuthService, userMapper: CoreBetterAuthUserMapper) =>
300300
Object.assign(
301301
{
302302
autoSchemaFile: 'schema.gql',

src/core/modules/auth/guards/roles.guard.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Connection, Types } from 'mongoose';
66
import { firstValueFrom, isObservable } from 'rxjs';
77

88
import { RoleEnum } from '../../../common/enums/role.enum';
9-
import { BetterAuthService } from '../../better-auth/better-auth.service';
9+
import { CoreBetterAuthService } from '../../better-auth/core-better-auth.service';
1010
import { ErrorCode } from '../../error-code';
1111
import { AuthGuardStrategy } from '../auth-guard-strategy.enum';
1212
import { ExpiredTokenException } from '../exceptions/expired-token.exception';
@@ -36,7 +36,7 @@ import { AuthGuard } from './auth.guard';
3636
@Injectable()
3737
export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
3838
private readonly logger = new Logger(RolesGuard.name);
39-
private betterAuthService: BetterAuthService | null = null;
39+
private betterAuthService: CoreBetterAuthService | null = null;
4040
private mongoConnection: Connection | null = null;
4141
private servicesResolved = false;
4242

@@ -59,7 +59,7 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
5959
}
6060

6161
try {
62-
this.betterAuthService = this.moduleRef.get(BetterAuthService, { strict: false });
62+
this.betterAuthService = this.moduleRef.get(CoreBetterAuthService, { strict: false });
6363
} catch {
6464
// BetterAuth not available - that's fine, we'll use Legacy JWT only
6565
}
@@ -168,6 +168,47 @@ export class RolesGuard extends AuthGuard(AuthGuardStrategy.JWT) {
168168
token = authHeader.substring(7);
169169
}
170170

171+
// If no token in header, try cookies (for REST endpoints)
172+
if (!token) {
173+
let cookies: Record<string, string> | undefined;
174+
175+
// Try GraphQL context first
176+
try {
177+
const gqlContext = GqlExecutionContext.create(context);
178+
const ctx = gqlContext.getContext();
179+
if (ctx?.req?.cookies) {
180+
cookies = ctx.req.cookies;
181+
}
182+
} catch {
183+
// GraphQL context not available
184+
}
185+
186+
// Fallback to HTTP context
187+
if (!cookies) {
188+
try {
189+
const httpRequest = context.switchToHttp().getRequest();
190+
if (httpRequest?.cookies) {
191+
cookies = httpRequest.cookies;
192+
}
193+
} catch {
194+
// HTTP context not available
195+
}
196+
}
197+
198+
// Extract session token from cookies (try multiple cookie names)
199+
if (cookies) {
200+
// Get the basePath for cookie name (e.g., 'iam' -> 'iam.session_token')
201+
const basePath = this.betterAuthService.getBasePath?.()?.replace(/^\//, '').replace(/\//g, '.') || 'iam';
202+
const basePathCookie = `${basePath}.session_token`;
203+
204+
token =
205+
cookies[basePathCookie] ||
206+
cookies['better-auth.session_token'] ||
207+
cookies['token'] ||
208+
undefined;
209+
}
210+
}
211+
171212
if (!token) {
172213
return null;
173214
}

0 commit comments

Comments
 (0)