# v4: Money Forward OAuth 2.0 인증 구현 ## ⚠️ 중요: Business API 사용 이 프로젝트에서는 **Money Forward Business API**를 사용합니다. 개인용 ME API와는 다른 점이 있으므로 주의가 필요합니다. ### Business API vs ME API 차이점 | 구분 | Business API | ME API (개인용) | |------|-------------|----------------| | Base URL | `https://api.biz.moneyforward.com` | `https://moneyforward.com` | | Authorization | `/authorize` (v2 없음) | `/oauth/authorize` | | Token | `/token` (v2 없음) | `/oauth/v2/token` | | Scope 형식 | `mfc/admin/tenant.read` | `accounts transactions` | | 인증 방식 | CLIENT_SECRET_BASIC | CLIENT_SECRET_POST | | State 파라미터 | **필수** | 선택 | ## OAuth 2.0 인증 플로우 복습 Money Forward Business API는 OAuth 2.0 Authorization Code Grant 방식을 사용합니다. ### 전체 플로우 ``` 1. Authorization URL 생성 ↓ 2. 사용자가 브라우저에서 인증 ↓ 3. Authorization Code 받기 ↓ 4. Access Token 발급 ↓ 5. API 호출 (Access Token 사용) ↓ 6. Token 만료 시 Refresh Token으로 갱신 ``` 이번 단계에서는 이 전체 플로우를 TypeScript로 구현합니다. ## OAuth 클라이언트 구현 ### src/moneyforward/auth.ts ```typescript import axios from 'axios'; import fs from 'fs/promises'; import path from 'path'; import { MFTokens } from '../types'; export class MoneyForwardAuth { private clientId: string; private clientSecret: string; private redirectUri: string; private tokenFilePath: string; constructor() { this.clientId = process.env.MONEYFORWARD_CLIENT_ID!; this.clientSecret = process.env.MONEYFORWARD_CLIENT_SECRET!; this.redirectUri = process.env.MONEYFORWARD_REDIRECT_URI!; this.tokenFilePath = path.join(process.cwd(), '.tokens', 'mf-tokens.json'); if (!this.clientId || !this.clientSecret || !this.redirectUri) { throw new Error('Money Forward OAuth 환경 변수가 설정되지 않았습니다'); } } /** * Step 1: Authorization URL 생성 */ getAuthorizationUrl(): string { // ✅ Business API URL (v2 없음!) const baseUrl = 'https://api.biz.moneyforward.com/authorize'; // CSRF 방지용 state 생성 (필수!) const state = Math.random().toString(36).substring(2, 15); const params = new URLSearchParams({ client_id: this.clientId, redirect_uri: this.redirectUri, response_type: 'code', scope: 'mfc/admin/tenant.read', // ✅ Business API scope 형식 state: state, // ✅ 필수 파라미터 }); const authUrl = `${baseUrl}?${params.toString()}`; return authUrl; } /** * Step 2: Authorization Code를 사용하여 Access Token 발급 */ async getAccessToken(code: string): Promise { try { // ✅ CLIENT_SECRET_BASIC: Authorization 헤더에 Base64 인코딩 const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); // ✅ URLSearchParams로 form-urlencoded 형식 생성 // (axios가 JSON으로 보내는 것을 방지) const params = new URLSearchParams(); params.append('redirect_uri', this.redirectUri); params.append('code', code); params.append('grant_type', 'authorization_code'); const response = await axios.post( 'https://api.biz.moneyforward.com/token', // ✅ Business API URL params, // ✅ URLSearchParams 객체 { headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `Basic ${credentials}`, // ✅ Basic 인증 }, } ); const tokens: MFTokens = { access_token: response.data.access_token, refresh_token: response.data.refresh_token, token_type: response.data.token_type, expires_in: response.data.expires_in, expires_at: Date.now() + response.data.expires_in * 1000, }; // 토큰을 파일에 저장 await this.saveTokens(tokens); return tokens; } catch (error) { if (axios.isAxiosError(error)) { throw new Error( `Access Token 발급 실패: ${error.response?.data?.error || error.message}` ); } throw error; } } /** * Step 3: Refresh Token으로 Access Token 갱신 */ async refreshAccessToken(refreshToken: string): Promise { try { const response = await axios.post( 'https://moneyforward.com/oauth/token', { client_id: this.clientId, client_secret: this.clientSecret, refresh_token: refreshToken, grant_type: 'refresh_token', }, { headers: { 'Content-Type': 'application/json', }, } ); const tokens: MFTokens = { access_token: response.data.access_token, refresh_token: response.data.refresh_token || refreshToken, token_type: response.data.token_type, expires_in: response.data.expires_in, expires_at: Date.now() + response.data.expires_in * 1000, }; // 갱신된 토큰 저장 await this.saveTokens(tokens); return tokens; } catch (error) { if (axios.isAxiosError(error)) { throw new Error( `Token 갱신 실패: ${error.response?.data?.error || error.message}` ); } throw error; } } /** * 토큰을 파일에 저장 */ private async saveTokens(tokens: MFTokens): Promise { const tokenDir = path.dirname(this.tokenFilePath); // 디렉토리가 없으면 생성 await fs.mkdir(tokenDir, { recursive: true }); // 토큰 저장 await fs.writeFile(this.tokenFilePath, JSON.stringify(tokens, null, 2)); console.log('✅ 토큰이 저장되었습니다:', this.tokenFilePath); } /** * 저장된 토큰 불러오기 */ async loadTokens(): Promise { try { const data = await fs.readFile(this.tokenFilePath, 'utf-8'); const tokens: MFTokens = JSON.parse(data); // 토큰이 만료되었는지 확인 if (tokens.expires_at < Date.now()) { console.log('⚠️ Access Token이 만료되었습니다. 갱신 중...'); return await this.refreshAccessToken(tokens.refresh_token); } return tokens; } catch (error) { // 파일이 없으면 null 반환 return null; } } /** * 유효한 Access Token 가져오기 (자동 갱신 포함) */ async getValidAccessToken(): Promise { const tokens = await this.loadTokens(); if (!tokens) { throw new Error( '저장된 토큰이 없습니다. 먼저 OAuth 인증을 완료하세요.' ); } return tokens.access_token; } } ``` ## OAuth 인증 서버 구현 사용자가 브라우저에서 인증한 후 Redirect URI로 돌아올 때 Authorization Code를 받기 위한 간단한 HTTP 서버가 필요합니다. ### src/moneyforward/oauth-server.ts ```typescript import http from 'http'; import { URL } from 'url'; import { MoneyForwardAuth } from './auth'; export class OAuthServer { private auth: MoneyForwardAuth; private server: http.Server | null = null; constructor() { this.auth = new MoneyForwardAuth(); } /** * OAuth Callback을 받을 서버 시작 */ async startAuthFlow(): Promise { const authUrl = this.auth.getAuthorizationUrl(); console.log('\n🔐 Money Forward OAuth 인증 시작'); console.log('\n다음 URL을 브라우저에서 열어주세요:'); console.log(`\n${authUrl}\n`); // 서버 시작 await this.startServer(); } private startServer(): Promise { return new Promise((resolve, reject) => { this.server = http.createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost:3000`); // Callback 경로 확인 if (url.pathname === '/callback') { const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); if (error) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`

❌ 인증 실패

에러: ${error}

`); this.stopServer(); return reject(new Error(`OAuth 에러: ${error}`)); } if (!code) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`

❌ Authorization Code가 없습니다

`); this.stopServer(); return reject(new Error('Authorization Code가 없습니다')); } try { // Authorization Code로 Access Token 발급 await this.auth.getAccessToken(code); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`

✅ 인증 성공!

Access Token이 발급되었습니다.

이제 이 창을 닫고 터미널로 돌아가세요.

`); console.log('\n✅ OAuth 인증이 완료되었습니다!'); // 서버 종료 setTimeout(() => { this.stopServer(); resolve(); }, 1000); } catch (error) { res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(`

❌ Token 발급 실패

${error instanceof Error ? error.message : '알 수 없는 오류'}

`); this.stopServer(); reject(error); } } else { // 404 res.writeHead(404); res.end('Not Found'); } }); this.server.listen(3000, () => { console.log('🌐 OAuth Callback 서버가 http://localhost:3000 에서 시작되었습니다'); }); this.server.on('error', (error) => { reject(error); }); }); } private stopServer(): void { if (this.server) { this.server.close(); this.server = null; console.log('🛑 OAuth Callback 서버가 종료되었습니다'); } } } ``` ## CLI 명령어 추가 ### src/cli-auth.ts OAuth 인증만 따로 실행할 수 있도록 CLI 스크립트를 만듭니다. ```typescript import 'dotenv/config'; import { OAuthServer } from './moneyforward/oauth-server'; async function main() { console.log('💰 Money Forward OAuth 인증 CLI'); const oauthServer = new OAuthServer(); try { await oauthServer.startAuthFlow(); console.log('\n✅ 인증이 완료되었습니다!'); process.exit(0); } catch (error) { console.error('\n❌ 인증 실패:', error); process.exit(1); } } main(); ``` ### package.json 스크립트 추가 ```json { "scripts": { "dev": "tsx src/index.ts", "auth": "tsx src/cli-auth.ts", "build": "tsc", "start": "node dist/index.js" } } ``` ## 인증 테스트 ### 1. OAuth 인증 실행 ```bash npm run auth ``` 예상 출력: ``` 💰 Money Forward OAuth 인증 CLI 🔐 Money Forward OAuth 인증 시작 다음 URL을 브라우저에서 열어주세요: https://moneyforward.com/oauth/authorize?client_id=...&redirect_uri=...&response_type=code&scope=accounts+transactions+user_info 🌐 OAuth Callback 서버가 http://localhost:3000 에서 시작되었습니다 ``` ### 2. 브라우저에서 인증 1. 터미널에 표시된 URL을 브라우저에서 엽니다 2. Money Forward에 로그인합니다 3. 권한 승인 화면에서 "허가" 클릭 4. 자동으로 `http://localhost:3000/callback`로 리다이렉트됩니다 ### 3. 인증 완료 브라우저에 "✅ 인증 성공!" 메시지가 표시되고, 터미널에: ``` ✅ 토큰이 저장되었습니다: /path/to/.tokens/mf-tokens.json ✅ OAuth 인증이 완료되었습니다! 🛑 OAuth Callback 서버가 종료되었습니다 ✅ 인증이 완료되었습니다! ``` ### 4. 토큰 파일 확인 `.tokens/mf-tokens.json`: ```json { "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", "refresh_token": "abc123xyz...", "token_type": "Bearer", "expires_in": 7200, "expires_at": 1701234567890 } ``` ## 메인 앱에서 인증 사용 ### src/index.ts 수정 ```typescript import 'dotenv/config'; import { MoneyForwardAuth } from './moneyforward/auth'; async function main() { console.log('🚀 Money Forward + Vertex AI 재무 분석 시작\n'); const auth = new MoneyForwardAuth(); try { // 저장된 토큰 확인 const tokens = await auth.loadTokens(); if (!tokens) { console.log('❌ 인증 토큰이 없습니다'); console.log('💡 먼저 `npm run auth` 명령어로 OAuth 인증을 완료하세요\n'); process.exit(1); } console.log('✅ 인증 토큰 로드 완료'); console.log(`📅 토큰 만료: ${new Date(tokens.expires_at).toLocaleString()}\n`); // Access Token 가져오기 (만료 시 자동 갱신) const accessToken = await auth.getValidAccessToken(); console.log('✅ 유효한 Access Token 확보\n'); // TODO: v5에서 이 토큰으로 API 호출 console.log('다음 단계: Money Forward API 호출 (v5에서 구현)'); } catch (error) { console.error('❌ 에러:', error); process.exit(1); } } main(); ``` ## 🔧 트러블슈팅 구현 과정에서 발생한 주요 문제와 해결 방법입니다. ### 문제 1: "クライアント認証は失敗しました" (클라이언트 인증 실패) **증상**: ``` クライアントが不明か、クライアント認証が含まれていないか、 もしくは認証メソッドがサポートされていないため、 クライアント認証は失敗しました。 ``` **원인**: CLIENT_SECRET_POST 방식으로 전송했으나 Business API는 CLIENT_SECRET_BASIC 필요 **해결**: ```typescript // ❌ 잘못된 방법 (POST body에 포함) await axios.post(url, { client_id: this.clientId, client_secret: this.clientSecret, // ... }); // ✅ 올바른 방법 (Authorization 헤더) const credentials = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString('base64'); await axios.post(url, params, { headers: { 'Authorization': `Basic ${credentials}` } }); ``` ### 문제 2: axios가 JSON으로 전송 **증상**: Content-Type을 `application/x-www-form-urlencoded`로 설정해도 JSON으로 전송됨 **원인**: axios는 객체를 자동으로 JSON으로 변환 **해결**: URLSearchParams 사용 ```typescript // ❌ 객체 사용 - JSON으로 전송됨 await axios.post(url, { code, grant_type }, { headers }); // ✅ URLSearchParams 사용 const params = new URLSearchParams(); params.append('code', code); params.append('grant_type', 'authorization_code'); await axios.post(url, params, { headers }); ``` ### 문제 3: "scopeパラメータが設定されていないか無効です" **증상**: scope 파라미터가 무효하다는 에러 **원인**: Personal API scope 형식 사용 (`accounts transactions`) **해결**: Business API scope 형식 사용 ```typescript // ❌ Personal API scope scope: 'accounts transactions user_info' // ✅ Business API scope scope: 'mfc/admin/tenant.read' ``` ### 문제 4: State 파라미터 누락 **증상**: 인증 화면에서 에러 또는 보안 경고 **원인**: Business API는 state 파라미터 필수 **해결**: ```typescript // CSRF 방지용 random state 생성 const state = Math.random().toString(36).substring(2, 15); params.append('state', state); ``` ### 문제 5: SSH 포트 포워딩 필요 **증상**: 원격 서버에서 실행 시 localhost:3000 callback이 작동하지 않음 **원인**: OAuth callback은 로컬 브라우저에서 발생 **해결**: SSH 포트 포워딩 사용 ```bash # 로컬에서 실행 ssh -L 3000:localhost:3000 user@remote-server.com # 원격 서버에서 auth 서버 실행 pnpm run auth # 로컬 브라우저에서 http://localhost:3000 접속 ``` ## 보안 고려사항 ### 1. 토큰 파일 권한 ```bash # Linux/Mac에서 토큰 파일 권한 제한 chmod 600 .tokens/mf-tokens.json ``` ### 2. 토큰 암호화 (선택) 민감한 환경에서는 토큰을 암호화하여 저장: ```typescript import crypto from 'crypto'; // 암호화 function encrypt(text: string, key: string): string { const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv); let encrypted = cipher.update(text); encrypted = Buffer.concat([encrypted, cipher.final()]); return iv.toString('hex') + ':' + encrypted.toString('hex'); } // 복호화 function decrypt(text: string, key: string): string { const parts = text.split(':'); const iv = Buffer.from(parts[0], 'hex'); const encryptedText = Buffer.from(parts[1], 'hex'); const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(key), iv); let decrypted = decipher.update(encryptedText); decrypted = Buffer.concat([decrypted, decipher.final()]); return decrypted.toString(); } ``` ### 3. 환경별 토큰 관리 프로덕션 환경에서는 파일 저장 대신 데이터베이스 또는 시크릿 매니저 사용: - AWS Secrets Manager - Google Cloud Secret Manager - HashiCorp Vault ## 체크리스트 v4를 완료하기 전에 다음을 확인하세요: - [ ] `MoneyForwardAuth` 클래스 구현 완료 - [ ] `OAuthServer` 클래스 구현 완료 - [ ] `cli-auth.ts` CLI 스크립트 생성 - [ ] `npm run auth` 실행하여 OAuth 인증 완료 - [ ] `.tokens/mf-tokens.json` 파일 생성 확인 - [ ] 토큰 자동 갱신 로직 테스트 - [ ] `src/index.ts`에서 토큰 로드 확인 ## 다음 단계 v5에서는 발급받은 Access Token을 사용하여 Money Forward API에서 실제 거래 데이터를 조회합니다. --- **작성일**: 2025-11-30 **상태**: ✅ 완료 **다음**: v5 - Money Forward API 거래 데이터 조회