import { existsSync, readFileSync } from 'node:fs' import { createServer } from 'node:http' import { randomUUID } from 'node:crypto' import { URL, URLSearchParams } from 'node:url' const DEFAULT_SCOPE = 'https://www.googleapis.com/auth/adsense.readonly' const DEFAULT_REDIRECT_PORT = 43082 const OAUTH_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth' const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token' const ADSENSE_ACCOUNTS_URL = 'https://adsense.googleapis.com/v2/accounts' /** * env 파일의 단순 KEY=VALUE 값을 현재 프로세스 환경 변수에 반영한다. * @param {string} filePath - env 파일 경로 * @returns {void} */ const loadEnvFile = (filePath) => { if (!filePath || !existsSync(filePath)) { return } const lines = readFileSync(filePath, 'utf8').split(/\r?\n/) for (const line of lines) { const trimmedLine = line.trim() if (!trimmedLine || trimmedLine.startsWith('#')) { continue } const separatorIndex = trimmedLine.indexOf('=') if (separatorIndex < 1) { continue } const key = trimmedLine.slice(0, separatorIndex).trim() let value = trimmedLine.slice(separatorIndex + 1).trim() if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1) } if (!process.env[key]) { process.env[key] = value } } } /** * credentials.json에서 OAuth 클라이언트 정보를 읽는다. * @param {string} filePath - credentials.json 파일 경로 * @returns {{ clientId: string, clientSecret: string }} OAuth 클라이언트 정보 */ const readCredentialsFile = (filePath) => { const credentials = JSON.parse(readFileSync(filePath, 'utf8')) const client = credentials.web || credentials.installed || credentials return { clientId: client.client_id || '', clientSecret: client.client_secret || '' } } /** * 환경 변수와 credentials 파일에서 OAuth 클라이언트 정보를 가져온다. * @returns {{ clientId: string, clientSecret: string }} OAuth 클라이언트 정보 */ const resolveOAuthClient = () => { const credentialsPath = process.env.GOOGLE_ADSENSE_CREDENTIALS_PATH || '' if (credentialsPath) { return readCredentialsFile(credentialsPath) } return { clientId: process.env.GOOGLE_ADSENSE_CLIENT_ID || '', clientSecret: process.env.GOOGLE_ADSENSE_CLIENT_SECRET || '' } } /** * 한 번만 쓰는 로컬 OAuth 콜백 서버를 열고 인증 코드를 기다린다. * @param {number} port - 로컬 콜백 포트 * @param {string} state - CSRF 방지용 state 값 * @returns {Promise<{ code: string, redirectUri: string }>} 인증 코드와 리다이렉트 URI */ const waitForAuthorizationCode = (port, state) => { const redirectUri = `http://127.0.0.1:${port}/oauth2callback` return new Promise((resolve, reject) => { const server = createServer((request, response) => { const requestUrl = new URL(request.url || '/', redirectUri) if (requestUrl.pathname !== '/oauth2callback') { response.writeHead(404) response.end('Not found') return } const receivedState = requestUrl.searchParams.get('state') || '' const code = requestUrl.searchParams.get('code') || '' const error = requestUrl.searchParams.get('error') || '' if (error) { response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) response.end(`AdSense OAuth 승인 실패: ${error}`) server.close() reject(new Error(`AdSense OAuth 승인 실패: ${error}`)) return } if (!code || receivedState !== state) { response.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) response.end('AdSense OAuth 승인 응답이 올바르지 않습니다.') server.close() reject(new Error('AdSense OAuth 승인 응답이 올바르지 않습니다.')) return } response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) response.end('

AdSense OAuth 승인이 완료되었습니다. 터미널로 돌아가세요.

') server.close() resolve({ code, redirectUri }) }) server.once('error', reject) server.listen(port, '127.0.0.1') }) } /** * 인증 코드를 access token과 refresh token으로 교환한다. * @param {Object} options - 토큰 교환 옵션 * @param {string} options.clientId - OAuth 클라이언트 ID * @param {string} options.clientSecret - OAuth 클라이언트 시크릿 * @param {string} options.code - 승인 코드 * @param {string} options.redirectUri - 리다이렉트 URI * @returns {Promise<{ access_token?: string, refresh_token?: string }>} OAuth 토큰 응답 */ const exchangeAuthorizationCode = async ({ clientId, clientSecret, code, redirectUri }) => { const response = await fetch(OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: clientId, client_secret: clientSecret, code, grant_type: 'authorization_code', redirect_uri: redirectUri }) }) const data = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(`토큰 교환 실패: ${data.error_description || data.error || response.status}`) } return data } /** * access token으로 접근 가능한 AdSense 계정 목록을 조회한다. * @param {string} accessToken - OAuth access token * @returns {Promise>} 계정 목록 */ const fetchAdsenseAccounts = async (accessToken) => { const response = await fetch(ADSENSE_ACCOUNTS_URL, { headers: { Authorization: `Bearer ${accessToken}` } }) const data = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(`AdSense 계정 조회 실패: ${data.error?.message || response.status}`) } return Array.isArray(data.accounts) ? data.accounts : [] } /** * OAuth 승인 URL을 생성한다. * @param {Object} options - 승인 URL 옵션 * @param {string} options.clientId - OAuth 클라이언트 ID * @param {string} options.redirectUri - 리다이렉트 URI * @param {string} options.scope - OAuth scope * @param {string} options.state - CSRF 방지용 state 값 * @returns {string} 승인 URL */ const createAuthorizationUrl = ({ clientId, redirectUri, scope, state }) => { const authorizationUrl = new URL(OAUTH_AUTHORIZE_URL) authorizationUrl.search = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope, access_type: 'offline', prompt: 'consent', include_granted_scopes: 'true', state }).toString() return authorizationUrl.toString() } loadEnvFile(process.env.ENV_FILE || '.env.development') const { clientId, clientSecret } = resolveOAuthClient() const redirectPort = Number(process.env.GOOGLE_ADSENSE_OAUTH_PORT || DEFAULT_REDIRECT_PORT) const redirectUri = `http://127.0.0.1:${redirectPort}/oauth2callback` const scope = process.env.GOOGLE_ADSENSE_SCOPE || DEFAULT_SCOPE const state = randomUUID() if (!clientId || !clientSecret) { process.stderr.write( [ 'AdSense OAuth 클라이언트 정보가 없습니다.', 'GOOGLE_ADSENSE_CLIENT_ID / GOOGLE_ADSENSE_CLIENT_SECRET을 env에 넣거나', 'GOOGLE_ADSENSE_CREDENTIALS_PATH에 credentials.json 경로를 지정하세요.' ].join('\n') + '\n' ) process.exit(1) } process.stdout.write( [ 'AdSense refresh token 발급을 시작합니다.', `Google Cloud OAuth 클라이언트의 승인된 리다이렉트 URI에 아래 주소가 필요합니다:`, redirectUri, '', '브라우저에서 아래 URL을 열고 AdSense 계정으로 승인하세요:', createAuthorizationUrl({ clientId, redirectUri, scope, state }), '' ].join('\n') ) const { code } = await waitForAuthorizationCode(redirectPort, state) const tokenData = await exchangeAuthorizationCode({ clientId, clientSecret, code, redirectUri }) if (!tokenData.refresh_token) { process.stderr.write( [ 'refresh token이 응답에 없습니다.', '이미 같은 앱에 권한을 부여한 적이 있으면 Google 계정 권한에서 앱 접근을 해제한 뒤 다시 실행하세요.' ].join('\n') + '\n' ) process.exit(1) } const accounts = tokenData.access_token ? await fetchAdsenseAccounts(tokenData.access_token) : [] process.stdout.write('\n.env.production에 아래 값을 추가하세요.\n\n') process.stdout.write(`GOOGLE_ADSENSE_REFRESH_TOKEN=${tokenData.refresh_token}\n`) if (accounts.length > 0) { process.stdout.write(`GOOGLE_ADSENSE_ACCOUNT_NAME=${accounts[0].name}\n`) if (accounts.length > 1) { process.stdout.write('\n접근 가능한 AdSense 계정 목록:\n') for (const account of accounts) { process.stdout.write(`- ${account.name}${account.displayName ? ` (${account.displayName})` : ''}\n`) } } } else { process.stdout.write('GOOGLE_ADSENSE_ACCOUNT_NAME=accounts/pub-게시자ID\n') process.stdout.write('\n접근 가능한 AdSense 계정을 찾지 못했습니다. 게시자 ID 앞의 ca-를 빼고 accounts/를 붙여 입력하세요.\n') }