apikey-gateway 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- apikey_gateway/__init__.py +48 -0
- apikey_gateway/decorators.py +65 -0
- apikey_gateway/gateway.py +245 -0
- apikey_gateway/helpers.py +71 -0
- apikey_gateway/middleware.py +124 -0
- apikey_gateway-1.1.1.dist-info/METADATA +145 -0
- apikey_gateway-1.1.1.dist-info/RECORD +9 -0
- apikey_gateway-1.1.1.dist-info/WHEEL +5 -0
- apikey_gateway-1.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""API Key Gateway - Professional API key validation library"""
|
|
2
|
+
|
|
3
|
+
# Public API exports
|
|
4
|
+
from .gateway import APIKeyGateway, gateway
|
|
5
|
+
from .decorators import apikey_login
|
|
6
|
+
from .helpers import (
|
|
7
|
+
add_apikey_gateway_middleware,
|
|
8
|
+
retry_on_exception,
|
|
9
|
+
fetch_valid_public_keys,
|
|
10
|
+
compute_public_key,
|
|
11
|
+
validate_api_key,
|
|
12
|
+
CACHE_TTL_SECONDS,
|
|
13
|
+
API_KEY_CACHE,
|
|
14
|
+
PH
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Middleware-related exports
|
|
18
|
+
try:
|
|
19
|
+
from .middleware import APIKeyGatewayMiddleware, FASTAPI_AVAILABLE
|
|
20
|
+
__all__ = [
|
|
21
|
+
'APIKeyGateway',
|
|
22
|
+
'gateway',
|
|
23
|
+
'apikey_login',
|
|
24
|
+
'add_apikey_gateway_middleware',
|
|
25
|
+
'APIKeyGatewayMiddleware',
|
|
26
|
+
'FASTAPI_AVAILABLE',
|
|
27
|
+
'retry_on_exception',
|
|
28
|
+
'fetch_valid_public_keys',
|
|
29
|
+
'compute_public_key',
|
|
30
|
+
'validate_api_key',
|
|
31
|
+
'CACHE_TTL_SECONDS',
|
|
32
|
+
'API_KEY_CACHE',
|
|
33
|
+
'PH'
|
|
34
|
+
]
|
|
35
|
+
except ImportError:
|
|
36
|
+
__all__ = [
|
|
37
|
+
'APIKeyGateway',
|
|
38
|
+
'gateway',
|
|
39
|
+
'apikey_login',
|
|
40
|
+
'add_apikey_gateway_middleware',
|
|
41
|
+
'retry_on_exception',
|
|
42
|
+
'fetch_valid_public_keys',
|
|
43
|
+
'compute_public_key',
|
|
44
|
+
'validate_api_key',
|
|
45
|
+
'CACHE_TTL_SECONDS',
|
|
46
|
+
'API_KEY_CACHE',
|
|
47
|
+
'PH'
|
|
48
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Decorators for API key validation"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def apikey_login(func=None, *, public_keys_url=None, service=None, verbose=True):
|
|
9
|
+
"""
|
|
10
|
+
Decorator that adds API key validation to a function.
|
|
11
|
+
|
|
12
|
+
:param func: The function to decorate (required if using as decorator without params)
|
|
13
|
+
:param public_keys_url: Optional URL to fetch valid public keys from (defaults to the official URL)
|
|
14
|
+
:param service: The service name to validate the API key against (required)
|
|
15
|
+
:param verbose: Whether to print verbose messages (default: True)
|
|
16
|
+
"""
|
|
17
|
+
# Check if service parameter is provided
|
|
18
|
+
if func is None:
|
|
19
|
+
# If we're in the parameterized decorator mode, we'll check service later
|
|
20
|
+
pass
|
|
21
|
+
elif service is None:
|
|
22
|
+
raise ValueError("service parameter is required for apikey_login decorator")
|
|
23
|
+
|
|
24
|
+
# Handle both decorator with and without parameters
|
|
25
|
+
if func is None:
|
|
26
|
+
# Return a new decorator with the provided parameters
|
|
27
|
+
return lambda f: apikey_login(f, public_keys_url=public_keys_url, service=service, verbose=verbose)
|
|
28
|
+
|
|
29
|
+
def wrapper(*args, **kwargs):
|
|
30
|
+
# Create argument parser
|
|
31
|
+
parser = argparse.ArgumentParser(add_help=False) # Don't add help to avoid conflict
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
'-a', '--apikey',
|
|
34
|
+
required=False,
|
|
35
|
+
help='Your API secret key (fallbacks to AKGATEWAY_API_KEY env var)'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Save original argv to restore later
|
|
39
|
+
original_argv = sys.argv.copy()
|
|
40
|
+
|
|
41
|
+
# Parse only API key arguments
|
|
42
|
+
args_parsed, remaining_args = parser.parse_known_args()
|
|
43
|
+
|
|
44
|
+
# Get API key from command line or environment variable
|
|
45
|
+
secret_key = args_parsed.apikey or os.environ.get('AKGATEWAY_API_KEY')
|
|
46
|
+
|
|
47
|
+
# If no API key provided, raise an error
|
|
48
|
+
if not secret_key:
|
|
49
|
+
parser.error('API key is required. Use -a/--apikey or set AKGATEWAY_API_KEY environment variable')
|
|
50
|
+
|
|
51
|
+
# Replace sys.argv with remaining args (excluding API key) for the wrapped function
|
|
52
|
+
sys.argv = [sys.argv[0]] + remaining_args
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Use the gateway singleton to validate the API key
|
|
56
|
+
from .gateway import gateway
|
|
57
|
+
gateway.validate_api_key(secret_key, service, public_keys_url=public_keys_url, verbose=verbose)
|
|
58
|
+
|
|
59
|
+
# If validation passes, call the decorated function
|
|
60
|
+
return func(*args, **kwargs)
|
|
61
|
+
finally:
|
|
62
|
+
# Always restore original argv
|
|
63
|
+
sys.argv = original_argv
|
|
64
|
+
|
|
65
|
+
return wrapper
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Core API key gateway logic for validation and caching"""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import urllib.request
|
|
5
|
+
import urllib.error
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from argon2 import PasswordHasher
|
|
11
|
+
from argon2.exceptions import HashingError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIKeyGateway:
|
|
15
|
+
"""Core API key gateway logic for validation and caching"""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self.PH = PasswordHasher(
|
|
19
|
+
memory_cost=65536,
|
|
20
|
+
time_cost=3,
|
|
21
|
+
parallelism=4,
|
|
22
|
+
hash_len=32,
|
|
23
|
+
salt_len=16
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Cache to store valid API key-service pairs
|
|
27
|
+
# Format: {(secret_key, service): expiration_time}
|
|
28
|
+
self.API_KEY_CACHE = {}
|
|
29
|
+
|
|
30
|
+
# Cache TTL: 1 day (86400 seconds)
|
|
31
|
+
self.CACHE_TTL_SECONDS = 86400
|
|
32
|
+
|
|
33
|
+
# Default URL using JSONBin.io
|
|
34
|
+
self.DEFAULT_URL = "https://api.jsonbin.io/v3/b/691ec6a543b1c97be9b8ea6d"
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def retry_on_exception(exceptions, max_retries=3, delay=0.5, backoff=1):
|
|
38
|
+
"""
|
|
39
|
+
Decorator that retries a function on specific exceptions.
|
|
40
|
+
"""
|
|
41
|
+
def decorator(func):
|
|
42
|
+
@wraps(func)
|
|
43
|
+
def wrapper(*args, **kwargs):
|
|
44
|
+
verbose = kwargs.get('verbose', True)
|
|
45
|
+
func_kwargs = {k: v for k, v in kwargs.items() if k != 'verbose'}
|
|
46
|
+
retries = 0
|
|
47
|
+
while retries <= max_retries:
|
|
48
|
+
try:
|
|
49
|
+
return func(*args, **func_kwargs)
|
|
50
|
+
except exceptions as e:
|
|
51
|
+
retries += 1
|
|
52
|
+
if retries > max_retries:
|
|
53
|
+
if verbose:
|
|
54
|
+
print(f" All {max_retries} retry attempts failed. Exception: {e}")
|
|
55
|
+
raise
|
|
56
|
+
wait_time = delay * (backoff ** (retries - 1))
|
|
57
|
+
if verbose:
|
|
58
|
+
print(f" Retry {retries}/{max_retries} in {wait_time:.2f} seconds due to: {type(e).__name__}: {e}")
|
|
59
|
+
time.sleep(wait_time)
|
|
60
|
+
return None # This line should never be reached
|
|
61
|
+
return wrapper
|
|
62
|
+
return decorator
|
|
63
|
+
|
|
64
|
+
@retry_on_exception(
|
|
65
|
+
urllib.error.URLError,
|
|
66
|
+
max_retries=3,
|
|
67
|
+
delay=0.5,
|
|
68
|
+
backoff=1 # constant delay, not exponential
|
|
69
|
+
)
|
|
70
|
+
def fetch_valid_public_keys(self, url, verbose=True, jsonbin_api_key=None):
|
|
71
|
+
"""Fetch the list of valid public keys from the specified URL."""
|
|
72
|
+
try:
|
|
73
|
+
# Set proper headers for JSONBin.io API
|
|
74
|
+
headers = {
|
|
75
|
+
'accept': 'application/json',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Add JSONBin API key if available (parameter takes precedence over env var)
|
|
79
|
+
jsonbin_api_key = jsonbin_api_key or os.environ.get('JSONBIN_API_KEY')
|
|
80
|
+
if jsonbin_api_key:
|
|
81
|
+
headers['X-Access-Key'] = jsonbin_api_key
|
|
82
|
+
|
|
83
|
+
# Create a request object with the headers
|
|
84
|
+
req = urllib.request.Request(url, headers=headers)
|
|
85
|
+
|
|
86
|
+
# Set timeout to avoid hanging (10 seconds)
|
|
87
|
+
with urllib.request.urlopen(req, timeout=10) as response:
|
|
88
|
+
data = json.loads(response.read().decode('utf-8'))
|
|
89
|
+
|
|
90
|
+
# JSONBin API wraps the data in a 'record' field
|
|
91
|
+
if isinstance(data, dict) and 'record' in data:
|
|
92
|
+
data = data['record']
|
|
93
|
+
all_keys = []
|
|
94
|
+
|
|
95
|
+
# Expected structure: {service: {key_id: public_key, ...}}
|
|
96
|
+
for service_name, service_data in data.items():
|
|
97
|
+
if isinstance(service_data, dict):
|
|
98
|
+
# Service has multiple API keys
|
|
99
|
+
for key_id, public_key in service_data.items():
|
|
100
|
+
all_keys.append({
|
|
101
|
+
"service": service_name,
|
|
102
|
+
"public_key": public_key
|
|
103
|
+
})
|
|
104
|
+
else:
|
|
105
|
+
# Service has a single API key
|
|
106
|
+
all_keys.append({
|
|
107
|
+
"service": service_name,
|
|
108
|
+
"public_key": service_data
|
|
109
|
+
})
|
|
110
|
+
return all_keys
|
|
111
|
+
raise RuntimeError(f"Unexpected response structure from JSONBin API: {data}")
|
|
112
|
+
except urllib.error.HTTPError as e:
|
|
113
|
+
if e.code == 401:
|
|
114
|
+
# 401 Unauthorized typically means missing or invalid API key
|
|
115
|
+
jsonbin_api_key = os.environ.get('JSONBIN_API_KEY')
|
|
116
|
+
if not jsonbin_api_key:
|
|
117
|
+
raise RuntimeError("Failed to fetch valid public keys from jsonbin.io: JSONBIN_API_KEY environment variable is missing")
|
|
118
|
+
else:
|
|
119
|
+
raise RuntimeError(f"Failed to fetch valid public keys from jsonbin.io: Invalid API key provided (HTTP 401). Check your JSONBIN_API_KEY environment variable.")
|
|
120
|
+
raise RuntimeError(f"Failed to fetch valid public keys from jsonbin.io: HTTP {e.code} error - {e.reason}")
|
|
121
|
+
except urllib.error.URLError:
|
|
122
|
+
# Re-raise URLError so the retry decorator can handle it
|
|
123
|
+
raise
|
|
124
|
+
except Exception as e:
|
|
125
|
+
# Wrap all other exceptions in RuntimeError
|
|
126
|
+
raise RuntimeError(f"Failed to fetch valid public keys: {e}")
|
|
127
|
+
|
|
128
|
+
def compute_public_key(self, secret_key):
|
|
129
|
+
"""Compute the public key from the secret key using argon2id."""
|
|
130
|
+
try:
|
|
131
|
+
return self.PH.hash(secret_key)
|
|
132
|
+
except HashingError as e:
|
|
133
|
+
raise RuntimeError(f"Failed to compute public key: {e}")
|
|
134
|
+
|
|
135
|
+
def validate_api_key(self, secret_key, service, public_keys_url=None, verbose=True, jsonbin_api_key=None):
|
|
136
|
+
"""Validate an API key for a specific service"""
|
|
137
|
+
# Fast fail validations
|
|
138
|
+
if not secret_key:
|
|
139
|
+
raise PermissionError("API key validation failed: API key cannot be empty")
|
|
140
|
+
|
|
141
|
+
# Check if secret key has correct length
|
|
142
|
+
expected_length = 43 # Based on test secret key: LVYYllZk6xLIRoGVfGK9A78Dl1ehJbZOpRo6JoF2LAM
|
|
143
|
+
if len(secret_key) != expected_length:
|
|
144
|
+
raise PermissionError(f"API key validation failed: API key must be {expected_length} characters long")
|
|
145
|
+
|
|
146
|
+
# Check cache first
|
|
147
|
+
cache_key = (secret_key, service)
|
|
148
|
+
current_time = time.time()
|
|
149
|
+
|
|
150
|
+
if cache_key in self.API_KEY_CACHE:
|
|
151
|
+
expiration_time = self.API_KEY_CACHE[cache_key]
|
|
152
|
+
if current_time < expiration_time:
|
|
153
|
+
# Cache hit: key-service pair is still valid
|
|
154
|
+
if verbose:
|
|
155
|
+
print(f"✓ API key validation passed (cached until {time.ctime(expiration_time)})")
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
# Cache miss or expired: proceed with full validation
|
|
159
|
+
if verbose:
|
|
160
|
+
print(f"⟳ Checking API key validity for service '{service}'...")
|
|
161
|
+
|
|
162
|
+
# Fetch valid public keys
|
|
163
|
+
url = public_keys_url or self.DEFAULT_URL
|
|
164
|
+
if verbose:
|
|
165
|
+
print(" Checking public keys from remote server...")
|
|
166
|
+
|
|
167
|
+
all_valid_keys = self.fetch_valid_public_keys(url, verbose=verbose, jsonbin_api_key=jsonbin_api_key)
|
|
168
|
+
|
|
169
|
+
# Filter keys by the specified service
|
|
170
|
+
valid_public_keys = [key['public_key'] for key in all_valid_keys if key['service'] == service]
|
|
171
|
+
|
|
172
|
+
# Check if service exists
|
|
173
|
+
if not valid_public_keys:
|
|
174
|
+
# Check if the service name exists in the fetched keys
|
|
175
|
+
available_services = set(key['service'] for key in all_valid_keys)
|
|
176
|
+
if service not in available_services:
|
|
177
|
+
# Find similar service names to suggest
|
|
178
|
+
def levenshtein_distance(s1, s2):
|
|
179
|
+
"""Compute the Levenshtein distance between two strings"""
|
|
180
|
+
if len(s1) < len(s2):
|
|
181
|
+
return levenshtein_distance(s2, s1)
|
|
182
|
+
if len(s2) == 0:
|
|
183
|
+
return len(s1)
|
|
184
|
+
|
|
185
|
+
previous_row = range(len(s2) + 1)
|
|
186
|
+
for i, c1 in enumerate(s1):
|
|
187
|
+
current_row = [i + 1]
|
|
188
|
+
for j, c2 in enumerate(s2):
|
|
189
|
+
insertions = previous_row[j + 1] + 1
|
|
190
|
+
deletions = current_row[j] + 1
|
|
191
|
+
substitutions = previous_row[j] + (0 if c1 == c2 else 1)
|
|
192
|
+
current_row.append(min(insertions, deletions, substitutions))
|
|
193
|
+
previous_row = current_row
|
|
194
|
+
return previous_row[-1]
|
|
195
|
+
|
|
196
|
+
# Suggest services with Levenshtein distance <= 2
|
|
197
|
+
similar_services = sorted(
|
|
198
|
+
[s for s in available_services if levenshtein_distance(service.lower(), s.lower()) <= 2],
|
|
199
|
+
key=lambda s: levenshtein_distance(service.lower(), s.lower())
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if similar_services:
|
|
203
|
+
raise PermissionError(
|
|
204
|
+
f"API key validation failed: Service '{service}' does not exist. Did you mean: {', '.join(similar_services)}?"
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
raise PermissionError(
|
|
208
|
+
f"API key validation failed: Service '{service}' does not exist."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Compute public key from secret key
|
|
212
|
+
try:
|
|
213
|
+
computed_public_key = self.compute_public_key(secret_key)
|
|
214
|
+
except RuntimeError as e:
|
|
215
|
+
raise RuntimeError(f"API key validation failed: {e}")
|
|
216
|
+
|
|
217
|
+
# Check if any of the valid public keys match the secret key
|
|
218
|
+
valid = False
|
|
219
|
+
for public_key in valid_public_keys:
|
|
220
|
+
try:
|
|
221
|
+
if self.PH.verify(public_key, secret_key):
|
|
222
|
+
valid = True
|
|
223
|
+
break
|
|
224
|
+
except Exception:
|
|
225
|
+
# If verification fails for any reason, try next one
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if not valid:
|
|
229
|
+
raise PermissionError(
|
|
230
|
+
"API key validation failed: API key is not correct for the specified service. Please apply for a valid API key from yanru@cyanru.com"
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Update cache with valid key-service pair
|
|
234
|
+
self.API_KEY_CACHE[cache_key] = current_time + self.CACHE_TTL_SECONDS
|
|
235
|
+
expiration_time = self.API_KEY_CACHE[cache_key]
|
|
236
|
+
|
|
237
|
+
if verbose:
|
|
238
|
+
print(f"✓ API key validation passed for service '{service}'")
|
|
239
|
+
print(f" Caching result until {time.ctime(expiration_time)}")
|
|
240
|
+
|
|
241
|
+
return True
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# Create singleton instance
|
|
245
|
+
gateway = APIKeyGateway()
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Helper functions for the API key gateway"""
|
|
2
|
+
|
|
3
|
+
from .gateway import gateway
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Expose some constants and helper functions for backward compatibility
|
|
7
|
+
CACHE_TTL_SECONDS = gateway.CACHE_TTL_SECONDS
|
|
8
|
+
API_KEY_CACHE = gateway.API_KEY_CACHE
|
|
9
|
+
PH = gateway.PH
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def retry_on_exception(exceptions, max_retries=3, delay=0.5, backoff=1):
|
|
13
|
+
"""
|
|
14
|
+
Decorator that retries a function on specific exceptions.
|
|
15
|
+
|
|
16
|
+
:param exceptions: Tuple of exceptions to retry on
|
|
17
|
+
:param max_retries: Maximum number of retries (default: 3)
|
|
18
|
+
:param delay: Initial delay between retries in seconds (default: 0.5)
|
|
19
|
+
:param backoff: Factor by which delay increases each retry (default: 1 for constant delay)
|
|
20
|
+
"""
|
|
21
|
+
from .gateway import APIKeyGateway
|
|
22
|
+
return APIKeyGateway.retry_on_exception(exceptions, max_retries, delay, backoff)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def fetch_valid_public_keys(url, verbose=True):
|
|
26
|
+
"""Fetch the list of valid public keys from the specified URL."""
|
|
27
|
+
return gateway.fetch_valid_public_keys(url, verbose=verbose)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def compute_public_key(secret_key):
|
|
31
|
+
"""Compute the public key from the secret key using argon2id."""
|
|
32
|
+
return gateway.compute_public_key(secret_key)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_api_key(secret_key, service, public_keys_url=None, verbose=True, jsonbin_api_key=None):
|
|
36
|
+
"""Validate an API key for a specific service"""
|
|
37
|
+
return gateway.validate_api_key(secret_key, service, public_keys_url=public_keys_url, verbose=verbose, jsonbin_api_key=jsonbin_api_key)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def add_apikey_gateway_middleware(
|
|
41
|
+
app,
|
|
42
|
+
service: str,
|
|
43
|
+
public_keys_url: str | None = None,
|
|
44
|
+
api_key_headers: list[str] | None = None,
|
|
45
|
+
jsonbin_api_key_header: str | None = None,
|
|
46
|
+
verbose: bool = True
|
|
47
|
+
):
|
|
48
|
+
"""
|
|
49
|
+
Convenience function to add APIKeyGatewayMiddleware to a FastAPI app.
|
|
50
|
+
|
|
51
|
+
:param app: FastAPI app instance
|
|
52
|
+
:param service: Service name to validate against
|
|
53
|
+
:param public_keys_url: Custom URL for public keys
|
|
54
|
+
:param api_key_headers: List of headers to check for API key
|
|
55
|
+
:param jsonbin_api_key_header: Header name to check for JSONBin API key (default: X-JSONBIN-API-KEY)
|
|
56
|
+
:param verbose: Verbose logging
|
|
57
|
+
"""
|
|
58
|
+
from .middleware import APIKeyGatewayMiddleware, FASTAPI_AVAILABLE
|
|
59
|
+
|
|
60
|
+
if not FASTAPI_AVAILABLE:
|
|
61
|
+
raise RuntimeError("FastAPI is not installed. Please install it with 'pip install fastapi'.")
|
|
62
|
+
|
|
63
|
+
# Add middleware directly to the app
|
|
64
|
+
app.add_middleware(
|
|
65
|
+
APIKeyGatewayMiddleware,
|
|
66
|
+
service=service,
|
|
67
|
+
public_keys_url=public_keys_url,
|
|
68
|
+
api_key_headers=api_key_headers,
|
|
69
|
+
jsonbin_api_key_header=jsonbin_api_key_header,
|
|
70
|
+
verbose=verbose
|
|
71
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""FastAPI middleware for API key gateway validation"""
|
|
2
|
+
|
|
3
|
+
# Try to import FastAPI types for middleware (optional dependency)
|
|
4
|
+
try:
|
|
5
|
+
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
|
6
|
+
from starlette.requests import Request
|
|
7
|
+
from starlette.responses import Response, JSONResponse
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
FASTAPI_AVAILABLE = True
|
|
10
|
+
except ImportError:
|
|
11
|
+
# If FastAPI is not installed, middleware-related functionality will be disabled
|
|
12
|
+
FASTAPI_AVAILABLE = False
|
|
13
|
+
# Create mock classes for type hinting
|
|
14
|
+
class Request:
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class Response:
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
class JSONResponse:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
class BaseHTTPMiddleware:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
class RequestResponseEndpoint:
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
class FastAPI:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class APIKeyGatewayMiddleware(BaseHTTPMiddleware):
|
|
34
|
+
"""
|
|
35
|
+
FastAPI middleware for API key gateway validation.
|
|
36
|
+
|
|
37
|
+
Best practice:
|
|
38
|
+
- Validates API keys for all incoming requests to protected routes
|
|
39
|
+
- Extracts API key from configurable headers (default: X-API-Key, Authorization Bearer)
|
|
40
|
+
- Supports service-specific validation
|
|
41
|
+
- Handles proper error responses with appropriate status codes
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
app: FastAPI,
|
|
47
|
+
service: str,
|
|
48
|
+
public_keys_url: str | None = None,
|
|
49
|
+
api_key_headers: list[str] | None = None,
|
|
50
|
+
jsonbin_api_key_header: str | None = None,
|
|
51
|
+
verbose: bool = True
|
|
52
|
+
):
|
|
53
|
+
"""
|
|
54
|
+
Initialize the API key gateway middleware.
|
|
55
|
+
|
|
56
|
+
:param app: FastAPI app instance
|
|
57
|
+
:param service: Service name to validate against (required)
|
|
58
|
+
:param public_keys_url: Custom URL for public keys (defaults to gateway's DEFAULT_URL)
|
|
59
|
+
:param api_key_headers: List of headers to check for API key (default: X-AKGATEWAY-API-KEY, Authorization Bearer)
|
|
60
|
+
:param jsonbin_api_key_header: Header name to check for JSONBin API key (default: X-JSONBIN-API-KEY)
|
|
61
|
+
:param verbose: Verbose logging
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(app)
|
|
64
|
+
from .gateway import gateway
|
|
65
|
+
self.service = service
|
|
66
|
+
self.public_keys_url = public_keys_url
|
|
67
|
+
self.api_key_headers = api_key_headers or [
|
|
68
|
+
"x-akgateway-api-key",
|
|
69
|
+
"authorization" # for Bearer token format
|
|
70
|
+
]
|
|
71
|
+
self.jsonbin_api_key_header = jsonbin_api_key_header or "x-jsonbin-api-key"
|
|
72
|
+
self.verbose = verbose
|
|
73
|
+
self.gateway_instance = gateway
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
|
77
|
+
"""
|
|
78
|
+
Middleware dispatch method that handles API key validation.
|
|
79
|
+
"""
|
|
80
|
+
# Extract API key from configured headers
|
|
81
|
+
headers = request.headers
|
|
82
|
+
|
|
83
|
+
# Extract secret API key
|
|
84
|
+
secret_key = None
|
|
85
|
+
for header_name in self.api_key_headers:
|
|
86
|
+
header_value = headers.get(header_name)
|
|
87
|
+
if header_value:
|
|
88
|
+
if header_name.lower() == "authorization":
|
|
89
|
+
# Handle Bearer token format
|
|
90
|
+
if header_value.lower().startswith("bearer "):
|
|
91
|
+
secret_key = header_value[7:].strip()
|
|
92
|
+
break
|
|
93
|
+
else:
|
|
94
|
+
# Handle direct header value
|
|
95
|
+
secret_key = header_value.strip()
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
# Extract JSONBin API key from header if present
|
|
99
|
+
jsonbin_api_key = headers.get(self.jsonbin_api_key_header)
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Validate the API key using the gateway instance
|
|
103
|
+
if secret_key:
|
|
104
|
+
self.gateway_instance.validate_api_key(
|
|
105
|
+
secret_key=secret_key,
|
|
106
|
+
service=self.service,
|
|
107
|
+
public_keys_url=self.public_keys_url,
|
|
108
|
+
verbose=self.verbose,
|
|
109
|
+
jsonbin_api_key=jsonbin_api_key
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
# No API key found in headers
|
|
113
|
+
raise PermissionError("API key validation failed: No API key found in request headers")
|
|
114
|
+
|
|
115
|
+
# If validation passes, call the next endpoint
|
|
116
|
+
response = await call_next(request)
|
|
117
|
+
return response
|
|
118
|
+
|
|
119
|
+
except (PermissionError, RuntimeError) as e:
|
|
120
|
+
# Return appropriate error response
|
|
121
|
+
return JSONResponse(
|
|
122
|
+
status_code=401 if isinstance(e, PermissionError) else 500,
|
|
123
|
+
content={"error": str(e)}
|
|
124
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: apikey-gateway
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Summary: A Python library for API key validation
|
|
5
|
+
Project-URL: Homepage, https://github.com/yanrucheng/apikey-gateway
|
|
6
|
+
Project-URL: Bug Tracker, https://github.com/yanrucheng/apikey-gateway/issues
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Operating System :: OS Independent
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: argon2-cffi>=25.1.0
|
|
13
|
+
Requires-Dist: pytest>=8.3.5
|
|
14
|
+
|
|
15
|
+
# API Key Gateway
|
|
16
|
+
|
|
17
|
+
A Python library that provides an `@apikey_login` decorator to validate API keys with service-aware authentication.
|
|
18
|
+
|
|
19
|
+
## Features
|
|
20
|
+
|
|
21
|
+
- **CLI Decorator**: Automatically adds `--apikey/-a` CLI parameter to decorated functions
|
|
22
|
+
- **FastAPI Middleware**: Built-in support for FastAPI applications
|
|
23
|
+
- **Strong Security**: Validates API keys using argon2id hashing algorithm
|
|
24
|
+
- **Service-Aware**: Keys are scoped to specific services
|
|
25
|
+
- **Remote Key Management**: Fetches valid public keys from a remote JSON endpoint
|
|
26
|
+
- **Caching**: Caches valid API key-service pairs for improved performance
|
|
27
|
+
- **Retry Mechanism**: Automatically retries fetching keys on network failures
|
|
28
|
+
- **Flexible Configuration**: Supports custom API key URLs and authentication headers
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv install apikey-gateway
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
The library supports two usage modes: **CLI Decorator** and **FastAPI Middleware**.
|
|
39
|
+
|
|
40
|
+
### CLI Decorator
|
|
41
|
+
The `apikey_login` decorator automatically adds `--apikey/-a` CLI parameter to decorated functions.
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from apikey_gateway import apikey_login
|
|
45
|
+
|
|
46
|
+
@apikey_login(service="media-match")
|
|
47
|
+
def media_app():
|
|
48
|
+
print("API key validated for media-match service!")
|
|
49
|
+
# Your media application logic here
|
|
50
|
+
|
|
51
|
+
@apikey_login(service="analytics")
|
|
52
|
+
def analytics_app():
|
|
53
|
+
print("API key validated for analytics service!")
|
|
54
|
+
# Your analytics application logic here
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
media_app() # or analytics_app()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run with:
|
|
61
|
+
```bash
|
|
62
|
+
python app.py --apikey your-secret-key
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### FastAPI Middleware
|
|
66
|
+
The library provides built-in FastAPI middleware for validating API keys on all requests.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from fastapi import FastAPI
|
|
70
|
+
from apikey_gateway import add_apikey_gateway_middleware
|
|
71
|
+
|
|
72
|
+
# Create your FastAPI app
|
|
73
|
+
app = FastAPI(title="My API")
|
|
74
|
+
|
|
75
|
+
# Add API key validation middleware
|
|
76
|
+
add_apikey_gateway_middleware(
|
|
77
|
+
app=app,
|
|
78
|
+
service="my-service",
|
|
79
|
+
verbose=True
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# All endpoints below will require API key validation
|
|
83
|
+
@app.get("/protected/resource")
|
|
84
|
+
def protected_resource():
|
|
85
|
+
return {"message": "Access granted to protected resource"}
|
|
86
|
+
|
|
87
|
+
# To run: uvicorn app:app --reload
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Test the FastAPI endpoint:
|
|
91
|
+
```bash
|
|
92
|
+
# With X-AKGATEWAY-API-KEY header (default)
|
|
93
|
+
curl -H "X-AKGATEWAY-API-KEY: your-secret-key" http://localhost:8000/protected/resource
|
|
94
|
+
|
|
95
|
+
# With Authorization Bearer header
|
|
96
|
+
curl -H "Authorization: Bearer your-secret-key" http://localhost:8000/protected/resource
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Middleware Features:
|
|
100
|
+
- Automatically checks `X-AKGATEWAY-API-KEY` and `Authorization Bearer` headers
|
|
101
|
+
- Supports custom API key headers
|
|
102
|
+
- Can be configured with a custom public keys URL
|
|
103
|
+
- Supports `X-JSONBIN-API-KEY` header for JSONBin API key authentication
|
|
104
|
+
- Allows customization of both API key header and JSONBin API key header names
|
|
105
|
+
|
|
106
|
+
## How It Works
|
|
107
|
+
|
|
108
|
+
### For CLI Applications
|
|
109
|
+
1. The application specifies the `service` name when using the `@apikey_login` decorator
|
|
110
|
+
2. The user provides a secret API key via the `--apikey/-a` CLI parameter
|
|
111
|
+
3. Followed by the same validation steps as FastAPI applications...
|
|
112
|
+
|
|
113
|
+
### For FastAPI Applications
|
|
114
|
+
1. The application adds the middleware with a specific `service` name
|
|
115
|
+
2. The client provides a secret API key via `X-AKGATEWAY-API-KEY` or `Authorization Bearer` header
|
|
116
|
+
3. Followed by the same validation steps as CLI applications...
|
|
117
|
+
|
|
118
|
+
### Common Validation Steps
|
|
119
|
+
3. The library computes an argon2id hash (public key) from the secret key
|
|
120
|
+
4. It fetches the list of valid public keys from `https://api.jsonbin.io/v3/b/691ec6a543b1c97be9b8ea6d`
|
|
121
|
+
5. Valid keys are filtered to only those belonging to the specified service
|
|
122
|
+
6. If the computed public key matches any valid service-specific public key, access is granted
|
|
123
|
+
|
|
124
|
+
## JSON Format
|
|
125
|
+
|
|
126
|
+
The remote JSON follows a service-aware structure where keys are organized by service name:
|
|
127
|
+
|
|
128
|
+
### Service-Aware Structure
|
|
129
|
+
```json
|
|
130
|
+
{
|
|
131
|
+
"service1": {
|
|
132
|
+
"key_id_1": "argon2id_hash_here",
|
|
133
|
+
"key_id_2": "another_hash_here"
|
|
134
|
+
},
|
|
135
|
+
"service2": "single_key_hash_here"
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- Top-level keys are service names
|
|
140
|
+
- Each service can have either multiple keys (as a dictionary) or a single key (as a string)
|
|
141
|
+
- The library automatically handles both formats when fetching keys
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
apikey_gateway/__init__.py,sha256=wik-nyj1clngVzzmK244hzvZqzAyx8oTmB932byH8aU,1207
|
|
2
|
+
apikey_gateway/decorators.py,sha256=7K8khH0t6NOAWNvaVVtOlCWSJBXe8ygK4p7pd8bSYvs,2555
|
|
3
|
+
apikey_gateway/gateway.py,sha256=lNz5Wua3_Jn-kNqBWiMvNxR3Dkta1XPrgSPMzFmRbwg,10533
|
|
4
|
+
apikey_gateway/helpers.py,sha256=91Snz_LZsCOOgpPcQdhZDd9-ZJanYXtKPDtgoME-jdc,2616
|
|
5
|
+
apikey_gateway/middleware.py,sha256=ym-zNMukNkx9kNulYroMCe7F9Dna6BzpxL7gKu5wZt8,4456
|
|
6
|
+
apikey_gateway-1.1.1.dist-info/METADATA,sha256=uuU93HScsmFy0IyGPOw8nzNEnQhZ_STZMVhf49F_f8Q,4709
|
|
7
|
+
apikey_gateway-1.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
apikey_gateway-1.1.1.dist-info/top_level.txt,sha256=nwreq0PaHh4WpaMOluQ3V5iA1LP8y9GliflzObT7G1I,15
|
|
9
|
+
apikey_gateway-1.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
apikey_gateway
|