@point3/logto-module
Version:
포인트3 내부 logto Authentication 모듈입니다
251 lines (228 loc) • 8.96 kB
text/typescript
import { Injectable, Global, LoggerService } from "@nestjs/common";
import axios, { AxiosResponse } from "axios";
import { GrantType, LogtoConfig, LogtoOAuthConfig } from "./config";
import { axiosAdapter, p3Values } from "point3-common-tool";
import {
TokenRevocationFailedError,
AuthorizationCodeTokenFetchError,
SignInUriGenerationError,
SignOutUriGenerationError,
PersonalAccessTokenFetchError,
} from "../errors";
import { LogtoLoggerServiceToken, LogtoOAuthRESTTemplate } from "./types";
const Gulid = p3Values.Gulid;
/** DI 토큰 */
export const OAuthClientToken = "OAuthClient";
/**
* OAuthClient
*
* Logto OAuth 인증을 위한 클라이언트 서비스입니다.
* 로그인/로그아웃 URI 생성, 토큰 발급 및 해지 등 OAuth 인증 플로우의 핵심 기능을 제공합니다.
* NestJS DI 환경에서 사용되며, Logto와의 통합 인증 처리를 담당합니다.
*
* 주요 역할:
* - 로그인/로그아웃 URI 생성
* - 인증 코드로 액세스 토큰 및 ID 토큰 발급
* - 토큰 해지(로그아웃)
* - Logto OAuth 관련 예외 및 로깅 처리
*
* 사용 예시:
* const client = new OAuthClient(...);
* const { uri, state } = client.getSignInURI(SignInType.Admin);
* const tokens = await client.fetchTokenByAuthorizationCode(code);
* await client.revokeToken(tokens.accessToken);
*
*/
export class OAuthClient {
/** Logto 설정 정보 */
private logtoConfig: LogtoConfig;
/** OAuth REST 템플릿 */
private logtoRestTemplate: axiosAdapter.RESTTemplate;
/** 상태값 prefix (CSRF 방지용) */
static readonly prefix: string = "signin";
/**
* 생성자
* @param config OAuth 설정
* @param logger 로거 서비스
*/
constructor(
private readonly config: LogtoOAuthConfig,
private readonly logger: LoggerService
) {
// Logto 설정 초기화
this.logtoConfig = {
endpoint: config.endpoint,
appId: config.clientId,
appSecret: config.clientSecret,
resources: config.resources,
scopes: config.scopes,
prompt: config.prompt,
redirectUri: config.redirectUri,
grantType: GrantType.AuthorizationCode,
};
// REST 템플릿 및 Basic Auth 설정
this.logtoRestTemplate = new LogtoOAuthRESTTemplate(
logger,
this.logtoConfig.endpoint
);
this.logtoRestTemplate.setBasic(
this.logtoConfig.appId!,
this.logtoConfig.appSecret!
);
}
/**
* 로그인 URI 생성
* @param signInType 로그인 타입 (Admin | Dashboard)
* @returns { uri, state } 로그인 URI와 상태값
*/
public getSignInURI(
signInType: SignInType
): { uri: string; state: string } {
try {
let uri: URL;
// 대시보드 로그인일 경우 별도 URI, 실패시 기본 URI로 폴백
if (signInType === SignInType.Dashboard) {
if (this.config.dashboardSignInUri) {
uri = new URL(`${this.config.dashboardSignInUri}/auth`);
} else {
this.logger.warn(
"대시보드 로그인 URI 설정을 찾을 수 없어 기본 URI를 사용합니다.",
this.constructor.name
);
uri = new URL(`${this.config.signInUri}/auth`);
}
} else {
uri = new URL(`${this.config.signInUri}/auth`);
}
// 상태값 생성 (CSRF 방지)
const state = Gulid.create(OAuthClient.prefix);
// OAuth 필수 파라미터 설정
uri.searchParams.set("redirect_uri", this.logtoConfig.redirectUri!);
uri.searchParams.set("response_type", "code");
uri.searchParams.set("scope", this.logtoConfig.scopes!.join(" "));
uri.searchParams.set("prompt", this.logtoConfig.prompt!);
uri.searchParams.set("client_id", this.logtoConfig.appId!);
uri.searchParams.set("resource", this.logtoConfig.resources!.join(" "));
uri.searchParams.set("state", state.toString());
return { uri: uri.toString(), state: state.toString() };
} catch (error) {
throw new SignInUriGenerationError(signInType);
}
}
/**
* 로그아웃 URI 생성
* @returns 로그아웃 URI
*/
public async getSignOutURI(): Promise<string> {
try {
const uri = new URL(`${this.config.signInUri}/session/end`);
// 로그아웃 후 리다이렉트 URI 및 클라이언트 ID 설정
uri.searchParams.set("redirect_uri", this.logtoConfig.redirectUri!);
uri.searchParams.set("client_id", this.logtoConfig.appId!);
return uri.toString();
} catch (error) {
throw new SignOutUriGenerationError();
}
}
/**
* 인증 코드로 액세스 토큰 및 ID 토큰 발급
* @param code OAuth 인증 코드
* @returns { accessToken, idToken } 액세스 토큰과 ID 토큰
*/
public async fetchTokenByAuthorizationCode(
code: string
): Promise<{ accessToken: string; idToken: string }> {
try {
// 토큰 요청 파라미터 설정
const parameters = new URLSearchParams();
parameters.set("code", code);
parameters.set("grant_type", this.logtoConfig.grantType);
parameters.set("redirect_uri", this.logtoConfig.redirectUri!);
parameters.set("resource", this.logtoConfig.resources!.join(" "));
parameters.set("scope", this.logtoConfig.scopes!.join(" "));
// 토큰 엔드포인트 호출
const response = await this.logtoRestTemplate.post<TokenResponse>(
`${this.logtoConfig.endpoint}/token`,
parameters.toString()
);
return {
accessToken: response.data.access_token,
idToken: response.data.id_token,
};
} catch (error) {
throw new AuthorizationCodeTokenFetchError(code);
}
}
/**
* PAT 토큰을 이용해 AccessToken 발급
* @param pat Personal Access Token
* @returns { accessToken } 액세스 토큰
*/
public async fetchAccessTokenByPAT(pat: string): Promise<{ accessToken: string }> {
try {
const parameters = new URLSearchParams();
parameters.set("client_id", this.logtoConfig.appId!);
parameters.set("grant_type", 'urn:ietf:params:oauth:grant-type:token-exchange');
parameters.set("resource", this.logtoConfig.resources!.join(" "));
parameters.set("scope", this.logtoConfig.scopes!.join(" "));
parameters.set("subject_token", pat);
parameters.set("subject_token_type", 'urn:logto:token-type:personal_access_token');
const response = await this.logtoRestTemplate.post<TokenResponse>(
`${this.logtoConfig.endpoint}/token`,
parameters.toString(),
{
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
}
);
return {
accessToken: response.data.access_token,
};
} catch (error) {
this.logger.error(`PAT를 이용한 AccessToken 발급 실패: ${error.message}`, error.stack, this.constructor.name);
throw new PersonalAccessTokenFetchError();
}
}
/**
* 토큰 해지
* @param token 해지할 토큰
*/
public async revokeToken(token: string): Promise<void> {
try {
const response: AxiosResponse = await axios.post(
`${this.logtoConfig.endpoint}/token/revoke`,
new URLSearchParams({
token: token,
client_id: this.logtoConfig.appId!,
}).toString(),
{
headers: { "Content-Type": "application/x-www-form-urlencoded" },
}
);
if (response.status === 200) return;
throw new TokenRevocationFailedError();
} catch (error) {
throw new TokenRevocationFailedError();
}
}
}
/**
* 로그인 타입 열거형
* - Admin: 관리자 로그인
* - Dashboard: 대시보드 로그인
*/
export enum SignInType {
Admin = "admin",
Dashboard = "dashboard",
}
/**
* 토큰 응답 타입
*/
type TokenResponse = {
access_token: string; // 액세스 토큰
refresh_token?: string; // 리프레시 토큰 (선택)
id_token: string; // ID 토큰
scope: string; // 부여된 스코프
expires_in: number; // 토큰 만료 시간(초)
};