Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,42 @@ Legend: `→` flow, `↔` bidirectional, `[]` storage, `{}` transform
| useUser | Browser→`/auth/profile`→[session]→User |
| Stateful session | [cookie:sid]↔[external store:SessionData] |

**CRITICAL:** Routes are `/auth/*` not `/api/auth/*`. Middleware required.

See [EXAMPLES.md](EXAMPLES.md) for detailed flow implementations.

---

## Key Patterns

### Middleware Requirement

**MANDATORY:** Create `middleware.ts` at workspace root:

```ts
import { auth0 } from "./lib/auth0";
export async function middleware(req) {
const authRes = await auth0.middleware(req);

// Auth routes handled by SDK
if (req.nextUrl.pathname.startsWith("/auth")) {
return authRes;
}

// Custom logic: check session (MUST pass req)
const session = await auth0.getSession(req);
if (!session) {
return NextResponse.redirect(new URL("/auth/login", req.url));
}

return authRes;
}
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
```

Without middleware, `/auth/*` routes return 404.
**CRITICAL:** Pass `req` to `getSession(req)` in middleware context.

### Public API Surface

| Class/Function | Location | Purpose |
Expand Down Expand Up @@ -203,6 +233,10 @@ E2E runs separately, requires credentials.

## Anti-Patterns

- ❌ Missing `middleware.ts` (causes 404 on `/auth/*`)
- ❌ Using `/api/auth/*` routes (correct: `/auth/*`)
- ❌ `getSession()` without request in middleware (causes "cookies called outside request scope")
- ❌ Setting `AUTH0_BASE_URL` env var (correct: `APP_BASE_URL`)
- ❌ Importing from internal paths (`src/server/auth-client.ts`)
- ❌ Using `instanceof` for error handling (use `error.code`)
- ❌ Storing sensitive data in client-accessible session fields
Expand Down
7 changes: 7 additions & 0 deletions examples/mfa/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": ["next/core-web-vitals", "next/typescript"],
"rules": {
"@typescript-eslint/no-explicit-any": "off",
"react-hooks/exhaustive-deps": "off"
}
}
36 changes: 36 additions & 0 deletions examples/mfa/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
187 changes: 187 additions & 0 deletions examples/mfa/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# MFA Testing Example

A comprehensive Next.js application demonstrating Auth0 MFA (Multi-Factor Authentication) step-up flows using `@auth0/nextjs-auth0`.

## Features

- **Step-up MFA**: MFA triggered only when accessing protected resources (not at login)
- **Multiple Authenticator Types**: OTP/TOTP, SMS, Email
- **Factor Management**: Enroll, list, and delete authenticators
- **Verbose Logging**: Real-time log viewer for debugging
- **Error Handling**: Graceful recovery from invalid codes, expired tokens
- **Token Caching**: Automatic caching to avoid repeated MFA prompts

## User Journeys

### Act 1: First-Time User
1. Login without MFA → Dashboard
2. Access protected API → MFA enrollment required
3. Enroll OTP authenticator → Scan QR code
4. Verify enrollment → Enter OTP code
5. Access granted → Protected data displayed

### Act 2: Returning User
6. Login again → Dashboard (no MFA at login!)
7. Access protected API → MFA challenge
8. Verify quickly → Enter OTP
9. Subsequent calls → Cached token (no MFA)

### Act 3: Factor Management
10. Enroll second factor → SMS
11. View all factors → Manage screen
12. Delete factor → Confirmation

### Act 4: Error Handling
13. Invalid OTP → Retry
14. Token expiration → Auto-recovery

## Setup

1. **Install dependencies**:
```bash
pnpm install
```

2. **Configure Auth0**:
- Create Regular Web Application
- Enable MFA authenticators (OTP, SMS, Email)
- Deploy MFA step-up action for `resource-server-1`
- Enable tenant flag: `mfa_list_with_challenge_type`

3. **Environment variables** (`.env.local`):
```bash
AUTH0_DOMAIN=your-tenant.auth0.com
AUTH0_CLIENT_ID=your-client-id
AUTH0_CLIENT_SECRET=your-client-secret
AUTH0_ISSUER_BASE_URL=https://your-tenant.auth0.com
AUTH0_SECRET=random-32-char-secret
APP_BASE_URL=http://localhost:3000
AUTH0_AUDIENCE=resource-server-1
```
```bash
cp .env.example .env.local
# Edit .env.local with your Auth0 credentials
```

4. **Run development server**:
```bash
pnpm dev
```

5. **Open browser**: http://localhost:3000

## Configuration Notes

### Step-up MFA Pattern

For step-up MFA, **do not** include `audience` in SDK initialization:

```typescript
// ✅ Correct - Step-up MFA
export const auth0 = new Auth0Client();
// MFA triggered on-demand via getAccessToken({ audience })

// ❌ Wrong - Triggers MFA at login (Universal Login flow)
export const auth0 = new Auth0Client({
authorizationParameters: { audience: 'resource-server-1' }
});
```

MFA is triggered when requesting protected audience:

```typescript
// This triggers the MFA step-up flow
const token = await auth0.getAccessToken({
audience: 'resource-server-1'
});
```

The SDK internally uses `refresh_token` grant to request the new audience, which activates the MFA action.

## Architecture

```
app/
├── layout.tsx # Root layout
├── page.tsx # Home (logged out)
├── dashboard/
│ └── page.tsx # User dashboard
├── mfa/
│ ├── enroll/
│ │ ├── page.tsx # Enrollment router
│ │ ├── otp/page.tsx # OTP enrollment
│ │ ├── sms/page.tsx # SMS enrollment
│ │ └── email/page.tsx # Email enrollment
│ ├── challenge/
│ │ └── page.tsx # Challenge + Verify
│ └── manage/
│ └── page.tsx # Factor management
└── api/
└── protected/
└── route.ts # Protected API endpoint

components/
├── mfa/
│ ├── authenticator-list.tsx # Factor picker
│ ├── qr-code-display.tsx # QR code renderer
│ ├── recovery-codes.tsx # Recovery codes display
│ ├── otp-input.tsx # 6-digit OTP input
│ └── error-display.tsx # Error banners
├── log-viewer.tsx # Real-time logs
├── user-info.tsx # User details panel
└── protected-data.tsx # Protected content display

lib/
├── auth0.ts # Auth0Client config
├── types.ts # MFA types
└── mfa-logger.ts # Verbose logging
```

## Logging

The app includes comprehensive verbose logging for debugging:

- All MFA operations logged with `[MFA]` prefix
- Token details (length, expiry, audience)
- Error details (code, description, recovery)
- Real-time log viewer component (collapsible panel)

Enable verbose logs in components by importing:
```typescript
import { mfaLog } from '@/lib/mfa-logger';

mfaLog.info('User selected factor type:', factorType);
mfaLog.error('Verification failed:', error);
```

## Demo Script

Perfect for executive presentations showcasing the MFA flow:

1. **Setup**: Clean user account, no MFA enrolled
2. **Act 1** (5 min): First-time enrollment flow
3. **Act 2** (2 min): Returning user fast path
4. **Act 3** (2 min): Factor management (optional)
5. **Act 4** (1 min): Error handling (optional)

**Total**: 10 minutes for full demo

## Troubleshooting

### MFA Triggered at Login
- Remove `audience` from SDK init in `lib/auth0.ts`
- Ensure action only triggers on `refresh_token` grant

### No MFA Required Error
- Verify action is deployed and active
- Check action targets correct audience (`resource-server-1`)
- Confirm tenant flag `mfa_list_with_challenge_type` is enabled

### Invalid Token
- Check token encryption/decryption
- Verify token TTL (default 5 minutes)
- Ensure MFA token passed correctly between flows

## License

MIT
65 changes: 65 additions & 0 deletions examples/mfa/app/api/mfa/authenticators/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth0 } from '@/lib/auth0';

export async function GET(request: NextRequest) {
try {
// Read mfaToken from query param (dashboard passes via URL)
const mfaToken = request.nextUrl.searchParams.get('mfa_token');

if (!mfaToken) {
return NextResponse.json(
{ error: 'missing_mfa_token', error_description: 'MFA token required as ?mfa_token query parameter' },
{ status: 400 }
);
}

// List all enrolled authenticators for the user
const authenticators = await auth0.mfa.getAuthenticators({ mfaToken });

return NextResponse.json(authenticators);
} catch (error: any) {

return NextResponse.json(
{
error: error.code || 'server_error',
error_description: error.message || 'Failed to list authenticators',
},
{ status: error.status || 500 }
);
}
}

export async function DELETE(request: NextRequest) {
try {
const body = await request.json();
const { authenticatorId, mfaToken } = body;

if (!authenticatorId) {
return NextResponse.json(
{ error: 'missing_authenticator_id', error_description: 'Authenticator ID is required' },
{ status: 400 }
);
}

if (!mfaToken) {
return NextResponse.json(
{ error: 'missing_mfa_token', error_description: 'MFA token is required' },
{ status: 400 }
);
}

// Delete authenticator
await auth0.mfa.deleteAuthenticator({ mfaToken, authenticatorId });

return new NextResponse(null, { status: 204 });
} catch (error: any) {

return NextResponse.json(
{
error: error.code || 'delete_failed',
error_description: error.message || 'Failed to delete authenticator',
},
{ status: error.status || 500 }
);
}
}
41 changes: 41 additions & 0 deletions examples/mfa/app/api/mfa/challenge/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth0 } from '@/lib/auth0';

export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { mfaToken, challengeType, authenticatorId } = body;

if (!mfaToken) {
return NextResponse.json(
{ error: 'missing_mfa_token', error_description: 'MFA token is required' },
{ status: 400 }
);
}

if (!challengeType) {
return NextResponse.json(
{ error: 'missing_challenge_type', error_description: 'Challenge type is required' },
{ status: 400 }
);
}

// Create MFA challenge
const challengeData = await auth0.mfa.challenge({
mfaToken,
challengeType,
authenticatorId,
});

return NextResponse.json(challengeData);
} catch (error: any) {

return NextResponse.json(
{
error: error.code || error.error || 'challenge_failed',
error_description: error.message || error.error_description || 'Challenge creation failed',
},
{ status: error.status || 400 }
);
}
}
Loading