UNPKG

@point3/logto-module

Version:

포인트3 내부 logto Authentication 모듈입니다

251 lines (228 loc) 8.96 kB
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); * */ @Global() @Injectable() 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; // 토큰 만료 시간(초) };