Skip to content
Merged
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
58 changes: 57 additions & 1 deletion src/features/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { UnauthorizedException } from '@nestjs/common';
import {
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
Expand Down Expand Up @@ -38,6 +42,7 @@ describe('AuthService', () => {
rotateRefreshSession: jest.fn(),
revokeRefreshSession: jest.fn(),
findAccountForMe: jest.fn(),
findAccountForJwt: jest.fn(),
createRefreshSession: jest.fn(),
} as unknown as jest.Mocked<AuthRepository>;

Expand Down Expand Up @@ -509,4 +514,55 @@ describe('AuthService', () => {
expect(returnToCookie![1]).toBe('http://localhost:3000');
});
});

describe('issueDevAccessToken', () => {
beforeEach(() => {
mockConfig.get.mockImplementation((key: string) => {
if (key === 'JWT_ACCESS_EXPIRES_SECONDS') return '900';
return undefined;
});
mockJwt.sign.mockReturnValue('signed-access-token');
});

it('정상 발급: 활성 USER 계정이면 access token + 만료 정보를 반환한다', async () => {
mockRepo.findAccountForJwt.mockResolvedValue({
id: BigInt(1),
status: 'ACTIVE',
account_type: 'USER',
});

const result = await service.issueDevAccessToken(BigInt(1));

expect(result).toEqual({
accessToken: 'signed-access-token',
tokenType: 'Bearer',
expiresInSeconds: 900,
});
expect(mockJwt.sign).toHaveBeenCalledWith(
expect.objectContaining({ sub: '1', typ: 'access' }),
);
});

it('존재하지 않는 accountId면 NotFoundException', async () => {
mockRepo.findAccountForJwt.mockResolvedValue(null);

await expect(service.issueDevAccessToken(BigInt(999))).rejects.toThrow(
NotFoundException,
);
expect(mockJwt.sign).not.toHaveBeenCalled();
});

it('비활성(SUSPENDED) 계정이면 ForbiddenException', async () => {
mockRepo.findAccountForJwt.mockResolvedValue({
id: BigInt(2),
status: 'SUSPENDED',
account_type: 'USER',
});

await expect(service.issueDevAccessToken(BigInt(2))).rejects.toThrow(
ForbiddenException,
);
expect(mockJwt.sign).not.toHaveBeenCalled();
});
});
});
32 changes: 32 additions & 0 deletions src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
Expand Down Expand Up @@ -204,6 +205,37 @@ export class AuthService {
return { accessToken };
}

/**
* 개발 환경 한정: accountId 만으로 access token을 즉시 발급한다.
*
* 운영자/FE가 OIDC 흐름을 거치지 않고 시드 데이터의 accountId 로 곧장
* GraphQL API를 시험하기 위함. production 환경에서는 controller 입구에서
* 차단된다.
*
* @param accountId 발급 대상 account id
* @returns 발급된 access token + 만료(초)
*/
async issueDevAccessToken(accountId: bigint): Promise<{
accessToken: string;
tokenType: 'Bearer';
expiresInSeconds: number;
}> {
const account = await this.repo.findAccountForJwt(accountId);
if (!account) {
throw new NotFoundException('Account not found.');
}
if (account.status !== 'ACTIVE') {
throw new ForbiddenException('Account is not active.');
}

const accessToken = this.signAccessToken(accountId);
Comment on lines +227 to +231
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject non-USER accounts when issuing dev token

issueDevAccessToken only verifies account.status and then signs an access token, so /auth/dev/issue-token will also mint tokens for active SELLER/ADMIN IDs if provided. Because JwtBearerStrategy.validate propagates the real account_type into req.user and seller authorization relies on that type, this allows bypassing the seller credential flow in non-production environments by guessing a seller accountId. If this endpoint is intended for FE mypage testing, it should explicitly enforce account.account_type === USER before signing.

Useful? React with 👍 / 👎.

return {
accessToken,
tokenType: 'Bearer',
expiresInSeconds: this.getAccessExpiresSeconds(),
};
}

/**
* 판매자 refresh 재발급
*
Expand Down
60 changes: 59 additions & 1 deletion src/features/auth/controllers/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import type { Request, Response } from 'express';

Expand Down Expand Up @@ -29,6 +29,7 @@ describe('AuthController', () => {
refreshSeller: jest.fn(),
logoutSeller: jest.fn(),
changeSellerPassword: jest.fn(),
issueDevAccessToken: jest.fn(),
} as unknown as jest.Mocked<AuthService>;

const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -208,4 +209,61 @@ describe('AuthController', () => {
),
).rejects.toThrow(BadRequestException);
});

describe('devIssueToken', () => {
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;

afterEach(() => {
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
});

it('NODE_ENV=production이면 ForbiddenException', async () => {
process.env.NODE_ENV = 'production';
const res = mockRes();

await expect(
controller.devIssueToken({ accountId: '1' }, res),
).rejects.toThrow(ForbiddenException);
expect(auth.issueDevAccessToken).not.toHaveBeenCalled();
});

it('accountId 문자열이 누락되면 BadRequestException', async () => {
process.env.NODE_ENV = 'development';
const res = mockRes();

await expect(
controller.devIssueToken({} as unknown as { accountId: string }, res),
).rejects.toThrow(BadRequestException);
});

it('accountId가 BigInt로 파싱 불가하면 BadRequestException', async () => {
process.env.NODE_ENV = 'development';
const res = mockRes();

await expect(
controller.devIssueToken({ accountId: 'not-a-number' }, res),
).rejects.toThrow(BadRequestException);
});

it('정상 발급: service 위임 + 200 응답', async () => {
process.env.NODE_ENV = 'development';
const res = mockRes();

auth.issueDevAccessToken.mockResolvedValue({
accessToken: 't',
tokenType: 'Bearer',
expiresInSeconds: 900,
});

await controller.devIssueToken({ accountId: '5' }, res);

expect(auth.issueDevAccessToken).toHaveBeenCalledWith(BigInt(5));
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({
accessToken: 't',
tokenType: 'Bearer',
expiresInSeconds: 900,
});
});
});
});
61 changes: 61 additions & 0 deletions src/features/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Param,
Post,
Expand Down Expand Up @@ -266,6 +267,54 @@ export class AuthController {
res.status(204).send();
}

/**
* Dev 전용 access token 발급 (개발 환경 한정)
*
* POST /auth/dev/issue-token
*
* - NODE_ENV=production 인 경우 ForbiddenException
* - body: { accountId: string }
* - 응답: { accessToken, tokenType, expiresInSeconds }
*
* 시드(yarn prisma:seed) 데이터의 accountId 로 곧장 GraphQL Playground에서
* 마이페이지 API를 시험해 볼 수 있도록 OIDC 흐름을 우회한다.
*/
@ApiOperation({
summary: '[DEV ONLY] Access token 즉시 발급',
description:
'개발 환경에서 OIDC 흐름 없이 accountId로 access token을 발급한다. NODE_ENV=production 에서는 차단된다.',
})
@ApiOkResponse({
description: 'Dev access token',
schema: {
type: 'object',
properties: {
accessToken: { type: 'string' },
tokenType: { type: 'string', example: 'Bearer' },
expiresInSeconds: { type: 'number', example: 900 },
},
required: ['accessToken', 'tokenType', 'expiresInSeconds'],
},
})
@Post('dev/issue-token')
async devIssueToken(
@Body() body: DevIssueTokenBody,
@Res() res: Response,
): Promise<void> {
if (process.env.NODE_ENV === 'production') {
throw new ForbiddenException(
'/auth/dev/issue-token은 개발 환경에서만 사용 가능합니다.',
);
}
if (!body || typeof body.accountId !== 'string') {
throw new BadRequestException('accountId(string)가 필요합니다.');
}

const accountId = parseAccountIdString(body.accountId);
const result = await this.auth.issueDevAccessToken(accountId);
res.status(200).json(result);
}

/**
* 판매자 비밀번호 변경
*
Expand Down Expand Up @@ -313,10 +362,22 @@ interface SellerChangePasswordBody {
newPassword: string;
}

interface DevIssueTokenBody {
accountId: string;
}

function parseAccountId(user: JwtUser): bigint {
try {
return BigInt(user.accountId);
} catch {
throw new BadRequestException('Invalid account id.');
}
}

function parseAccountIdString(raw: string): bigint {
try {
return BigInt(raw);
} catch {
throw new BadRequestException('Invalid account id.');
}
}
Loading