fix(auth): accept only access tokens as bearer credentials (MFA bypass)
All JWTs (access, MFA setup, MFA challenge, SSO completion) were signed with the same secret and the access-token strategy checked only signature and expiry. The MFA challenge token returned after the password step therefore worked as a full access token: a password alone was enough to use the API, skipping the second factor. Every token now carries a pinned issuer, algorithm (HS256) and a per-purpose audience; the strategy accepts only the access audience and rejects any payload carrying a helper-token purpose. Existing access tokens (no audience) stop working; clients renew them via the refresh cookie. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
fac66882fa
commit
e0bd7b2095
6 files changed
+176
-20
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { PrismaService } from '../src/prisma/prisma.service';
|
||||
import { encryptMfaSecret } from '../src/auth/mfa/mfa-crypto.util';
|
||||
import { generateTotpSecret } from '../src/auth/mfa/totp.util';
|
||||
import { createTestApp } from './utils/create-test-app';
|
||||
|
||||
interface MfaRequiredBody {
|
||||
mfaRequired: true;
|
||||
mfaToken: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* All ApiCore JWTs share one secret. Only an access token may open a
|
||||
* session - the helper tokens (MFA challenge after the password step, MFA
|
||||
* setup, SSO completion) and hand-made tokens without the pinned claims
|
||||
* must be refused everywhere.
|
||||
*/
|
||||
describe('Token confusion (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
let prisma: PrismaService;
|
||||
let userId: string;
|
||||
const email = `token-confusion-${Date.now()}@example.com`;
|
||||
const password = 'SuperSecret123!';
|
||||
|
||||
const me = (token: string) =>
|
||||
request(app.getHttpServer())
|
||||
.get('/api/v1/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
prisma = app.get(PrismaService);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, 12),
|
||||
name: 'MFA user',
|
||||
mfaEnabled: true,
|
||||
mfaSecret: encryptMfaSecret(generateTotpSecret()),
|
||||
role: { connect: { key: 'ADMIN' } },
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('does not accept the MFA challenge token as an access token', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/login')
|
||||
.send({ email, password })
|
||||
.expect(200);
|
||||
const body = response.body as MfaRequiredBody;
|
||||
expect(body.mfaRequired).toBe(true);
|
||||
|
||||
await me(body.mfaToken).expect(401);
|
||||
await request(app.getHttpServer())
|
||||
.get('/api/v1/users')
|
||||
.set('Authorization', `Bearer ${body.mfaToken}`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it('does not accept a token without the pinned audience and issuer', async () => {
|
||||
const legacy = new JwtService().sign(
|
||||
{ sub: userId, email },
|
||||
{ secret: process.env.JWT_ACCESS_SECRET, expiresIn: 60 },
|
||||
);
|
||||
await me(legacy).expect(401);
|
||||
});
|
||||
|
||||
it('does not accept a helper token even with the access audience', async () => {
|
||||
const forged = new JwtService().sign(
|
||||
{ sub: userId, email, purpose: 'mfa-challenge' },
|
||||
{
|
||||
secret: process.env.JWT_ACCESS_SECRET,
|
||||
expiresIn: 60,
|
||||
issuer: 'apicore',
|
||||
audience: 'apicore:access',
|
||||
},
|
||||
);
|
||||
await me(forged).expect(401);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user