Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added app/api/oauth/__init__.py
Empty file.
73 changes: 73 additions & 0 deletions app/api/oauth/bitflyer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.datastructures import URL

from app.api.oauth.models import Environment
from app.config import settings

router = APIRouter(prefix="/bitflyer")


@router.get("/{environment}/auth")
async def auth(environment: Environment, request: Request) -> RedirectResponse:
"""
Redirect to Bitflyer OAuth authorization page.
Sets client_id query param.

Example: GET /api/oauth/bitflyer/sandbox/auth
"""
config = settings.oauth.bitflyer
env_config = config.get_env_config(environment.value)

# Build query parameters with OAuth flow params
query_params = dict(request.query_params)
query_params["client_id"] = env_config.client_id

# Construct redirect URL with query parameters
redirect_url = str(
URL(
f"{str(env_config.oauth_url).rstrip('/')}/ex/OAuth/authorize"
).include_query_params(**query_params)
)

return RedirectResponse(url=redirect_url, status_code=302)


@router.post("/{environment}/token")
async def token(environment: Environment, request: Request) -> JSONResponse:
"""
Forward OAuth token exchange request to Bitflyer.
Sets Basic Authorization header and client_id/client_secret in JSON payload.

Example: POST /api/oauth/bitflyer/sandbox/token
"""
config = settings.oauth.bitflyer
env_config = config.get_env_config(environment.value)

url = f"{str(env_config.oauth_url).rstrip('/')}/api/link/v1/token"

body = await request.json()
body["client_id"] = env_config.client_id
body["client_secret"] = env_config.client_secret

async with httpx.AsyncClient() as client:
try:
response = await client.request(
method=request.method,
url=url,
json=body,
auth=(env_config.client_id, env_config.client_secret),
timeout=30.0,
)

return JSONResponse(
content=response.json() if response.content else {},
Comment on lines +64 to +65
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response.json() call can raise JSONDecodeError if the response body is not valid JSON. This should be handled to avoid unhandled exceptions.

Consider wrapping this in a try-except:

try:
    content = response.json() if response.content else {}
except ValueError:
    content = {}
Suggested change
return JSONResponse(
content=response.json() if response.content else {},
try:
content = response.json() if response.content else {}
except ValueError:
content = {}
return JSONResponse(
content=content,

Copilot uses AI. Check for mistakes.
status_code=response.status_code,
)
except httpx.RequestError as e:
raise HTTPException(
status_code=502, detail="Bitflyer request failed"
) from e
except Exception as e:
raise HTTPException(status_code=500, detail="Bitflyer proxy error") from e
64 changes: 64 additions & 0 deletions app/api/oauth/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
from pydantic import BaseModel, HttpUrl


class EnvironmentConfig(BaseModel):
"""Base environment config - all providers have OAuth URL."""

oauth_url: HttpUrl
client_id: str
client_secret: str


class ProviderConfigBase(BaseModel):
"""Base class for OAuth provider configurations."""

def get_env_config(self, environment: str):
"""Get environment-specific config."""
return self.sandbox if environment == "sandbox" else self.production


class GeminiConfig(ProviderConfigBase):
"""Gemini OAuth configuration."""

sandbox: EnvironmentConfig
production: EnvironmentConfig


class BitflyerConfig(ProviderConfigBase):
"""Bitflyer OAuth configuration."""

sandbox: EnvironmentConfig
production: EnvironmentConfig


class UpholdConfig(ProviderConfigBase):
"""Uphold OAuth configuration."""

class Config(EnvironmentConfig):
api_url: HttpUrl

sandbox: Config
production: Config


class ZebpayConfig(ProviderConfigBase):
"""Zebpay OAuth configuration."""

class Config(EnvironmentConfig):
api_url: HttpUrl

sandbox: Config
production: Config


class OAuthConfig(BaseModel):
"""
OAuth provider configuration.
Environment variables use nested structure with OAUTH__ prefix.
Example: OAUTH__GEMINI__SANDBOX__CLIENT_ID
"""

gemini: GeminiConfig
bitflyer: BitflyerConfig
uphold: UpholdConfig
zebpay: ZebpayConfig
74 changes: 74 additions & 0 deletions app/api/oauth/gemini.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import httpx
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.datastructures import URL

from app.api.oauth.models import Environment
from app.config import settings

router = APIRouter(prefix="/gemini")


@router.get("/{environment}/auth")
async def auth(environment: Environment, request: Request) -> RedirectResponse:
"""
Redirect to Gemini OAuth authorization page.
Sets client_id query param.

Example: GET /api/oauth/gemini/production/auth
"""
config = settings.oauth.gemini
env_config = config.get_env_config(environment.value)

# Build query parameters with OAuth flow params
query_params = dict(request.query_params)
query_params["client_id"] = env_config.client_id

# Construct redirect URL with query parameters
redirect_url = str(
URL(f"{str(env_config.oauth_url).rstrip('/')}/auth").include_query_params(
**query_params
)
)

return RedirectResponse(url=redirect_url, status_code=302)


@router.post("/{environment}/token")
async def token(environment: Environment, request: Request) -> JSONResponse:
"""
Forward OAuth token exchange request to Gemini.
Sets client_id and client_secret in the JSON body.

Example: POST /api/oauth/gemini/production/token
"""
config = settings.oauth.gemini
env_config = config.get_env_config(environment.value)

url = f"{str(env_config.oauth_url).rstrip('/')}/auth/token"

# Get original request body and merge with credentials
body_dict = await request.json()
body_dict["client_id"] = env_config.client_id
body_dict["client_secret"] = env_config.client_secret

query_params = dict(request.query_params)

async with httpx.AsyncClient() as client:
try:
response = await client.request(
method=request.method,
url=url,
params=query_params,
json=body_dict,
timeout=30.0,
)

return JSONResponse(
content=response.json() if response.content else {},
Comment on lines +67 to +68
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The response.json() call can raise JSONDecodeError if the response body is not valid JSON. This should be handled to avoid unhandled exceptions.

Consider wrapping this in a try-except:

try:
    content = response.json() if response.content else {}
except ValueError:
    content = {}
Suggested change
return JSONResponse(
content=response.json() if response.content else {},
try:
content = response.json() if response.content else {}
except ValueError:
content = {}
return JSONResponse(
content=content,

Copilot uses AI. Check for mistakes.
status_code=response.status_code,
)
except httpx.RequestError as e:
raise HTTPException(status_code=502, detail="Gemini request failed") from e
except Exception as e:
raise HTTPException(status_code=500, detail="Gemini proxy error") from e
8 changes: 8 additions & 0 deletions app/api/oauth/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class Environment(str, Enum):
"""OAuth environment for sandbox vs production endpoints."""

SANDBOX = "sandbox"
PRODUCTION = "production"
11 changes: 11 additions & 0 deletions app/api/oauth/routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from fastapi import APIRouter

from app.api.oauth import bitflyer, gemini, uphold, zebpay

router = APIRouter(prefix="/api/oauth")

# Include provider-specific routers
router.include_router(gemini.router)
router.include_router(bitflyer.router)
router.include_router(uphold.router)
router.include_router(zebpay.router)
140 changes: 140 additions & 0 deletions app/api/oauth/test_bitflyer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import base64
import json

import httpx
import respx
from fastapi.testclient import TestClient

from app.api.oauth.test_helpers import assert_redirect
from app.main import app

client = TestClient(app)


# ========================================
# Auth Endpoint Tests
# ========================================


def test_bitflyer_auth_redirect():
"""Test Bitflyer sandbox auth endpoint redirects correctly."""
params = {
"scope": "assets create_deposit_id withdraw_to_deposit_id",
"redirect_uri": "rewards://bitflyer/authorization",
"state": "test_state_123",
"response_type": "code",
"code_challenge_method": "S256",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
}

response = client.get(
"/api/oauth/bitflyer/sandbox/auth",
params=params,
follow_redirects=False,
)

assert response.status_code == 302

# Build expected params: sent params + client_id injected
expected_params = params.copy()
expected_params["client_id"] = "test_bitflyer_sandbox_client_id"

# Assert redirect URL matches expected
assert_redirect(
actual_redirect_url=response.headers["location"],
expected_base_url="https://oauth.sandbox.bitflyer.test/ex/OAuth/authorize",
expected_params=expected_params,
)


# ========================================
# Token Endpoint Tests
# ========================================


@respx.mock
def test_bitflyer_token_exchange_success():
"""Test successful token exchange - validates request forwarding and response."""
# Mock Bitflyer token endpoint
route = respx.post("https://oauth.sandbox.bitflyer.test/api/link/v1/token").mock(
return_value=httpx.Response(
200,
json={
"access_token": "bf_access_token",
"token_type": "Bearer",
"expires_in": 7200,
"refresh_token": "bf_refresh_token",
},
)
)

# Make request to our proxy
response = client.post(
"/api/oauth/bitflyer/sandbox/token",
json={
"grant_type": "authorization_code",
"code": "bf_auth_code_123",
"redirect_uri": "rewards://bitflyer/authorization",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
},
)

# Verify response is correct
assert response.status_code == 200
data = response.json()
assert data["access_token"] == "bf_access_token"
assert data["token_type"] == "Bearer"
assert data["expires_in"] == 7200

# Verify request was forwarded to upstream
assert route.called, "Request was not forwarded to Bitflyer"
request = route.calls.last.request

# Verify credentials were injected in request body
body = json.loads(request.content)
assert body["client_id"] == "test_bitflyer_sandbox_client_id", (
"client_id not injected"
)
assert body["client_secret"] == "test_bitflyer_sandbox_secret", (
"client_secret not injected"
)

# Verify Basic Auth header with expected value
expected_auth = base64.b64encode(
b"test_bitflyer_sandbox_client_id:test_bitflyer_sandbox_secret"
).decode("ascii")
expected_header = f"Basic {expected_auth}"
assert request.headers.get("authorization") == expected_header, (
"Basic Auth header not set correctly"
)

# Verify original request parameters were forwarded
assert body["grant_type"] == "authorization_code", "grant_type not forwarded"
assert body["code"] == "bf_auth_code_123", "code not forwarded"
assert body["redirect_uri"] == "rewards://bitflyer/authorization", (
"redirect_uri not forwarded"
)
expected_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
assert body["code_verifier"] == expected_verifier, "code_verifier not forwarded"


@respx.mock
def test_bitflyer_token_exchange_error():
"""Test token exchange with server error."""
# Mock Bitflyer token endpoint with error response
respx.post("https://oauth.sandbox.bitflyer.test/api/link/v1/token").mock(
return_value=httpx.Response(401)
)

# Make request to our proxy
response = client.post(
"/api/oauth/bitflyer/sandbox/token",
json={
"grant_type": "authorization_code",
"code": "test_code",
"redirect_uri": "test_uri",
},
)

# Should forward the error status code
assert response.status_code == 401
Loading