-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Add OAuth proxies #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mschfh
wants to merge
13
commits into
master
Choose a base branch
from
mschfh-auth
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 12 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
df2d8f3
feat: Add OAuth proxies
mschfh 62345ed
add test env
mschfh 2631a6d
add bitflyer tests
mschfh bd9b1b3
Update app/api/oauth/uphold.py
mihaiplesa 512462e
fix URL
mschfh 8cb6fa5
tests
mschfh 25047c8
Merge branch 'master' into mschfh-auth
mschfh adc1ee5
feat: Include content-type header in uphold request
mschfh e99a4f8
fix: Sanitize proxy exceptions
mschfh c1bb5cf
Merge branch 'master' into mschfh-auth
mschfh 4d4e4a9
update poetry.lock
mschfh ea8d263
refactor: Consolidate OAuth provider configurations into a base class
mschfh 4f08451
chore: Update Redis image to redis-stack-server version 7.4.0-v6 in d…
mschfh File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 {}, | ||
| 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
|
||||||||||||||||||
| return JSONResponse( | |
| content=response.json() if response.content else {}, | |
| try: | |
| content = response.json() if response.content else {} | |
| except ValueError: | |
| content = {} | |
| return JSONResponse( | |
| content=content, |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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 raiseJSONDecodeErrorif the response body is not valid JSON. This should be handled to avoid unhandled exceptions.Consider wrapping this in a try-except: