Files
njts-accounting-core/backend/app/modules/egov/egov_api.py
2026-05-20 17:45:09 +09:00

236 lines
8.4 KiB
Python

"""
e-Gov 電子申請 API v2 クライアント
OAuth2 + PKCE フローによる認証と電子送達ファイルのダウンロード
"""
import hashlib
import base64
import secrets
import os
import json
import time
from pathlib import Path
from typing import Optional
import httpx
# ── エンドポイント定数 ─────────────────────────────────────────
# EGOV_ENV=sandbox で検証環境、省略または production で本番環境を使用
_ENV = os.environ.get("EGOV_ENV", "production").lower()
if _ENV == "sandbox":
AUTH_BASE = "https://account2.sbx.e-gov.go.jp/auth"
API_BASE = "https://api2.sbx.e-gov.go.jp/shinsei/v2"
else:
AUTH_BASE = "https://account.e-gov.go.jp/auth"
API_BASE = "https://api.e-gov.go.jp/shinsei/v2"
AUTH_URL = f"{AUTH_BASE}/auth"
TOKEN_URL = f"{AUTH_BASE}/token"
LOGOUT_URL = f"{AUTH_BASE}/logout"
EGOV_DATA_DIR = Path("/app/egov_data")
TOKENS_PATH = EGOV_DATA_DIR / "tokens.json"
CONFIG_PATH = EGOV_DATA_DIR / "api_config.json"
# ── 資格情報 ──────────────────────────────────────────────────
def load_api_config() -> dict:
"""API設定を config ファイルから読み込む(環境変数でも上書き可)"""
cfg = {}
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
except Exception:
pass
# 環境変数が優先
client_id = os.environ.get("EGOV_SOFTWARE_ID") or cfg.get("software_id", "")
client_secret = os.environ.get("EGOV_API_KEY") or cfg.get("api_key", "")
redirect_uri = os.environ.get("EGOV_REDIRECT_URI") or cfg.get("redirect_uri",
"http://192.168.0.61:18000/egov.html")
return {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
}
def save_api_config(software_id: str, api_key: str,
redirect_uri: str = "http://192.168.0.61:18000/egov.html"):
"""API設定をファイルに保存"""
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump({"software_id": software_id,
"api_key": api_key,
"redirect_uri": redirect_uri}, f)
# ── トークン永続化 ────────────────────────────────────────────
def load_tokens() -> dict:
if TOKENS_PATH.exists():
try:
with open(TOKENS_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
def save_tokens(tokens: dict):
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKENS_PATH, "w") as f:
json.dump(tokens, f)
def delete_tokens():
if TOKENS_PATH.exists():
TOKENS_PATH.unlink()
# ── PKCE ─────────────────────────────────────────────────────
def generate_pkce() -> tuple[str, str]:
"""code_verifier と code_challenge (S256) を生成"""
verifier = secrets.token_urlsafe(48) # 64文字 (43-128の範囲内)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
# ── OAuth2 フロー ─────────────────────────────────────────────
def build_auth_url(client_id: str, redirect_uri: str,
challenge: str, state: str) -> str:
"""OAuth2 認可URL を生成"""
from urllib.parse import urlencode, quote
params = {
"client_id": client_id,
"response_type": "code",
"scope": "openid offline_access",
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
# quote_via=quote を使うことでスペースを + ではなく %20 にエンコードする
return f"{AUTH_URL}?" + urlencode(params, quote_via=quote)
async def exchange_code(code: str, client_id: str, client_secret: str,
redirect_uri: str, verifier: str) -> dict:
"""認可コード → アクセストークン・リフレッシュトークン取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def refresh_tokens_request(refresh_token: str, client_id: str,
client_secret: str) -> dict:
"""リフレッシュトークンでアクセストークン再取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def do_logout(refresh_token: str, client_id: str, client_secret: str):
"""ログアウト(トークン無効化)"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
try:
await client.post(
LOGOUT_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={"refresh_token": refresh_token},
timeout=15,
)
except Exception:
pass
# ── 電子送達 API ──────────────────────────────────────────────
async def get_post_list(access_token: str, date_from: str, date_to: str,
limit: int = 50) -> dict:
"""
電子送達一覧取得
GET /post/lists?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD&limit=N&offset=1
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/lists",
headers={"Authorization": f"Bearer {access_token}"},
params={"date_from": date_from, "date_to": date_to,
"limit": limit, "offset": 1},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def get_post_file(access_token: str, post_id: str) -> dict:
"""
電子送達取得(通知文書ファイルを base64 で返す)
GET /post/{post_id}
Returns: {notice_data_name, file_data (base64), file_name_list}
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/{post_id}",
headers={"Authorization": f"Bearer {access_token}"},
timeout=60,
)
resp.raise_for_status()
return resp.json()
async def mark_post_done(access_token: str, post_id: str) -> dict:
"""
電子送達取得完了登録
POST /post {"post_id": "..."}
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{API_BASE}/post",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={"post_id": post_id},
timeout=30,
)
resp.raise_for_status()
return resp.json()