ec2b666284
Continuous Integration / Pre-commit Linter (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.10) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.11) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.12) (push) Waiting to run
Continuous Integration / Mypy Check (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.10) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.11) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.12) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.13) (push) Waiting to run
Continuous Integration / Unit Tests (Python 3.14) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.10) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.11) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.12) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.13) (push) Waiting to run
Continuous Integration / A2A v0.3 Tests (Python 3.14) (push) Waiting to run
Copybara PR Handler / close-imported-pr (push) Waiting to run
351 lines
11 KiB
Python
351 lines
11 KiB
Python
# Copyright 2026 Google LLC
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
"""
|
|
Weather API OAuth2 Test Server
|
|
|
|
A simple FastAPI server that implements OAuth2 flows for weather API testing:
|
|
- Client Credentials Flow
|
|
- Authorization Code Flow
|
|
|
|
Usage:
|
|
python oauth2_test_server.py
|
|
|
|
Endpoints:
|
|
GET /auth - Authorization endpoint (auth code flow)
|
|
POST /token - Token endpoint (both flows)
|
|
GET /.well-known/openid_configuration - OpenID Connect discovery
|
|
GET /api/weather - Weather API (requires Bearer token)
|
|
"""
|
|
|
|
import secrets
|
|
import time
|
|
from typing import Dict
|
|
from typing import Optional
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi import Form
|
|
from fastapi import HTTPException
|
|
from fastapi import Query
|
|
from fastapi import Request
|
|
from fastapi import status
|
|
from fastapi.responses import HTMLResponse
|
|
from fastapi.responses import RedirectResponse
|
|
from pydantic import BaseModel
|
|
|
|
app = FastAPI(title="Weather API OAuth2 Server", version="1.0.0")
|
|
|
|
# In-memory storage (for testing only)
|
|
clients = {
|
|
"test_client": {
|
|
"client_secret": "test_secret",
|
|
"redirect_uris": [
|
|
"http://localhost:8080/callback",
|
|
"urn:ietf:wg:oauth:2.0:oob",
|
|
],
|
|
"scopes": ["read", "write", "admin"],
|
|
}
|
|
}
|
|
|
|
authorization_codes = {} # code -> {client_id, redirect_uri, scope, expires_at}
|
|
access_tokens = {} # token -> {client_id, scope, expires_at, token_type}
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
token_type: str = "Bearer"
|
|
expires_in: int = 3600
|
|
refresh_token: Optional[str] = None
|
|
scope: Optional[str] = None
|
|
|
|
|
|
@app.get("/.well-known/openid_configuration")
|
|
async def openid_configuration():
|
|
"""OpenID Connect Discovery endpoint."""
|
|
return {
|
|
"issuer": "http://localhost:8080",
|
|
"authorization_endpoint": "http://localhost:8080/auth",
|
|
"token_endpoint": "http://localhost:8080/token",
|
|
"userinfo_endpoint": "http://localhost:8080/userinfo",
|
|
"revocation_endpoint": "http://localhost:8080/revoke",
|
|
"scopes_supported": ["openid", "read", "write", "admin"],
|
|
"response_types_supported": ["code"],
|
|
"grant_types_supported": ["authorization_code", "client_credentials"],
|
|
"token_endpoint_auth_methods_supported": [
|
|
"client_secret_basic",
|
|
"client_secret_post",
|
|
],
|
|
"subject_types_supported": ["public"],
|
|
}
|
|
|
|
|
|
@app.get("/auth")
|
|
async def authorize(
|
|
response_type: str = Query(...),
|
|
client_id: str = Query(...),
|
|
redirect_uri: str = Query(...),
|
|
scope: str = Query(default="read"),
|
|
state: str = Query(default=""),
|
|
):
|
|
"""Authorization endpoint for OAuth2 authorization code flow."""
|
|
|
|
# Validate client
|
|
if client_id not in clients:
|
|
raise HTTPException(status_code=400, detail="Invalid client_id")
|
|
|
|
client = clients[client_id]
|
|
if redirect_uri not in client["redirect_uris"]:
|
|
raise HTTPException(status_code=400, detail="Invalid redirect_uri")
|
|
|
|
if response_type != "code":
|
|
raise HTTPException(status_code=400, detail="Unsupported response_type")
|
|
|
|
# Generate authorization code
|
|
auth_code = secrets.token_urlsafe(32)
|
|
authorization_codes[auth_code] = {
|
|
"client_id": client_id,
|
|
"redirect_uri": redirect_uri,
|
|
"scope": scope,
|
|
"expires_at": time.time() + 600, # 10 minutes
|
|
}
|
|
|
|
# Simulate user consent - in real implementation, this would show a consent form
|
|
params = f"code={auth_code}"
|
|
if state:
|
|
params += f"&state={state}"
|
|
|
|
return RedirectResponse(url=f"{redirect_uri}?{params}")
|
|
|
|
|
|
@app.post("/token")
|
|
async def token_endpoint(
|
|
request: Request,
|
|
grant_type: str = Form(...),
|
|
client_id: str = Form(default=None),
|
|
client_secret: str = Form(default=None),
|
|
code: str = Form(default=None),
|
|
redirect_uri: str = Form(default=None),
|
|
scope: str = Form(default="read"),
|
|
):
|
|
"""Token endpoint for both client credentials and authorization code flows."""
|
|
|
|
# Support both HTTP Basic auth and form-based client authentication
|
|
auth_header = request.headers.get("Authorization")
|
|
|
|
if auth_header and auth_header.startswith("Basic "):
|
|
# HTTP Basic authentication
|
|
import base64
|
|
|
|
try:
|
|
encoded_credentials = auth_header[6:] # Remove "Basic " prefix
|
|
decoded = base64.b64decode(encoded_credentials).decode("utf-8")
|
|
basic_client_id, basic_client_secret = decoded.split(":", 1)
|
|
client_id = client_id or basic_client_id
|
|
client_secret = client_secret or basic_client_secret
|
|
except Exception:
|
|
raise HTTPException(
|
|
status_code=401, detail="Invalid authorization header"
|
|
)
|
|
|
|
if not client_id or not client_secret:
|
|
raise HTTPException(status_code=400, detail="Client credentials required")
|
|
|
|
# Validate client credentials
|
|
if client_id not in clients:
|
|
raise HTTPException(status_code=401, detail="Invalid client")
|
|
|
|
client = clients[client_id]
|
|
if client["client_secret"] != client_secret:
|
|
raise HTTPException(status_code=401, detail="Invalid client credentials")
|
|
|
|
if grant_type == "client_credentials":
|
|
return await handle_client_credentials(client_id, scope)
|
|
elif grant_type == "authorization_code":
|
|
return await handle_authorization_code(client_id, code, redirect_uri, scope)
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Unsupported grant_type")
|
|
|
|
|
|
async def handle_client_credentials(
|
|
client_id: str, scope: str
|
|
) -> TokenResponse:
|
|
"""Handle client credentials flow."""
|
|
|
|
# Generate access token
|
|
access_token = secrets.token_urlsafe(32)
|
|
expires_at = time.time() + 3600 # 1 hour
|
|
|
|
# Store token
|
|
access_tokens[access_token] = {
|
|
"client_id": client_id,
|
|
"scope": scope,
|
|
"expires_at": expires_at,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
token_type="Bearer",
|
|
expires_in=3600,
|
|
scope=scope,
|
|
)
|
|
|
|
|
|
async def handle_authorization_code(
|
|
client_id: str, code: str, redirect_uri: str, scope: str
|
|
) -> TokenResponse:
|
|
"""Handle authorization code flow."""
|
|
|
|
if not code:
|
|
raise HTTPException(status_code=400, detail="Missing authorization code")
|
|
|
|
if code not in authorization_codes:
|
|
raise HTTPException(status_code=400, detail="Invalid authorization code")
|
|
|
|
auth_data = authorization_codes[code]
|
|
|
|
# Validate authorization code
|
|
if time.time() > auth_data["expires_at"]:
|
|
del authorization_codes[code]
|
|
raise HTTPException(status_code=400, detail="Authorization code expired")
|
|
|
|
if auth_data["client_id"] != client_id:
|
|
raise HTTPException(status_code=400, detail="Client mismatch")
|
|
|
|
if redirect_uri and auth_data["redirect_uri"] != redirect_uri:
|
|
raise HTTPException(status_code=400, detail="Redirect URI mismatch")
|
|
|
|
# Generate tokens
|
|
access_token = secrets.token_urlsafe(32)
|
|
refresh_token = secrets.token_urlsafe(32)
|
|
expires_at = time.time() + 3600 # 1 hour
|
|
|
|
# Store token
|
|
access_tokens[access_token] = {
|
|
"client_id": client_id,
|
|
"scope": auth_data["scope"],
|
|
"expires_at": expires_at,
|
|
"token_type": "Bearer",
|
|
}
|
|
|
|
# Clean up authorization code (one-time use)
|
|
del authorization_codes[code]
|
|
|
|
return TokenResponse(
|
|
access_token=access_token,
|
|
token_type="Bearer",
|
|
expires_in=3600,
|
|
refresh_token=refresh_token,
|
|
scope=auth_data["scope"],
|
|
)
|
|
|
|
|
|
@app.get("/api/weather")
|
|
async def get_weather(
|
|
request: Request, city: str = "San Francisco", units: str = "metric"
|
|
):
|
|
"""Weather API endpoint that returns weather data for a city."""
|
|
|
|
# Check authentication
|
|
auth_header = request.headers.get("Authorization")
|
|
if not auth_header or not auth_header.startswith("Bearer "):
|
|
raise HTTPException(
|
|
status_code=401, detail="Missing or invalid authorization header"
|
|
)
|
|
|
|
token = auth_header[7:] # Remove "Bearer " prefix
|
|
|
|
if token not in access_tokens:
|
|
raise HTTPException(status_code=401, detail="Invalid access token")
|
|
|
|
token_data = access_tokens[token]
|
|
|
|
if time.time() > token_data["expires_at"]:
|
|
del access_tokens[token]
|
|
raise HTTPException(status_code=401, detail="Access token expired")
|
|
|
|
# Return weather data (simulated)
|
|
from datetime import datetime
|
|
import random
|
|
|
|
conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear"]
|
|
|
|
weather_data = {
|
|
"city": city,
|
|
"temperature": random.randint(15, 30),
|
|
"condition": random.choice(conditions),
|
|
"humidity": random.randint(40, 80),
|
|
"wind_speed": random.randint(5, 25),
|
|
"timestamp": datetime.now().isoformat(),
|
|
"units": units,
|
|
"api_client": token_data["client_id"],
|
|
}
|
|
|
|
return weather_data
|
|
|
|
|
|
@app.get("/")
|
|
async def root():
|
|
"""Root endpoint with server information."""
|
|
return HTMLResponse("""
|
|
<html>
|
|
<head><title>Weather API OAuth2 Server</title></head>
|
|
<body>
|
|
<h1>Weather API OAuth2 Server</h1>
|
|
<h2>Available Endpoints:</h2>
|
|
<ul>
|
|
<li><strong>GET /auth</strong> - Authorization endpoint</li>
|
|
<li><strong>POST /token</strong> - Token endpoint</li>
|
|
<li><strong>GET /.well-known/openid_configuration</strong> - Discovery</li>
|
|
<li><strong>GET /api/weather</strong> - Weather API (requires Bearer token)</li>
|
|
</ul>
|
|
|
|
<h2>Test Client Credentials:</h2>
|
|
<ul>
|
|
<li><strong>Client ID:</strong> test_client</li>
|
|
<li><strong>Client Secret:</strong> test_secret</li>
|
|
<li><strong>Scopes:</strong> read, write, admin</li>
|
|
</ul>
|
|
|
|
<h2>Example cURL Commands:</h2>
|
|
<h3>Client Credentials Flow:</h3>
|
|
<pre>
|
|
curl -X POST http://localhost:8080/token \\
|
|
-d "grant_type=client_credentials" \\
|
|
-d "client_id=test_client" \\
|
|
-d "client_secret=test_secret" \\
|
|
-d "scope=read write"
|
|
</pre>
|
|
|
|
<h3>Test Weather API:</h3>
|
|
<pre>
|
|
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \\
|
|
"http://localhost:8080/api/weather?city=Tokyo"
|
|
</pre>
|
|
</body>
|
|
</html>
|
|
""")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import uvicorn
|
|
|
|
print("🌤️ Starting Weather API OAuth2 Server...")
|
|
print("📖 Documentation: http://localhost:8080/docs")
|
|
print("🏠 Server Info: http://localhost:8080")
|
|
print(
|
|
'🔧 Test with: curl -H "Authorization: Bearer TOKEN"'
|
|
' "http://localhost:8080/api/weather?city=Tokyo"'
|
|
)
|
|
uvicorn.run(app, host="0.0.0.0", port=8080, log_level="info")
|