iglobals-auth 1.0.0__tar.gz
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.
- iglobals_auth-1.0.0/PKG-INFO +16 -0
- iglobals_auth-1.0.0/README.md +2 -0
- iglobals_auth-1.0.0/iglobals_auth/__init__.py +12 -0
- iglobals_auth-1.0.0/iglobals_auth/client.py +103 -0
- iglobals_auth-1.0.0/iglobals_auth/errors.py +6 -0
- iglobals_auth-1.0.0/iglobals_auth/fastapi.py +31 -0
- iglobals_auth-1.0.0/iglobals_auth/flask.py +28 -0
- iglobals_auth-1.0.0/iglobals_auth/jwks.py +24 -0
- iglobals_auth-1.0.0/iglobals_auth/pkce.py +12 -0
- iglobals_auth-1.0.0/iglobals_auth/types.py +28 -0
- iglobals_auth-1.0.0/iglobals_auth.egg-info/PKG-INFO +16 -0
- iglobals_auth-1.0.0/iglobals_auth.egg-info/SOURCES.txt +15 -0
- iglobals_auth-1.0.0/iglobals_auth.egg-info/dependency_links.txt +1 -0
- iglobals_auth-1.0.0/iglobals_auth.egg-info/requires.txt +8 -0
- iglobals_auth-1.0.0/iglobals_auth.egg-info/top_level.txt +1 -0
- iglobals_auth-1.0.0/pyproject.toml +24 -0
- iglobals_auth-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iglobals-auth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: iGlobals Central Auth SDK for Python
|
|
5
|
+
Author-email: iGlobals <engineering@iglobals.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.28.0
|
|
9
|
+
Requires-Dist: PyJWT[crypto]>=2.8.0
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
|
|
12
|
+
Provides-Extra: flask
|
|
13
|
+
Requires-Dist: Flask>=2.0.0; extra == "flask"
|
|
14
|
+
|
|
15
|
+
# iglobals-auth
|
|
16
|
+
Python SDK for iGlobals Central Auth.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from urllib.parse import urlencode
|
|
3
|
+
from typing import List, Optional
|
|
4
|
+
|
|
5
|
+
from .types import TokenSet, UserInfoClaims
|
|
6
|
+
from .errors import ICAError
|
|
7
|
+
from .pkce import generate_pkce
|
|
8
|
+
from .jwks import JWKSService
|
|
9
|
+
|
|
10
|
+
class IGlobalsAuth:
|
|
11
|
+
def __init__(self, client_id: str, redirect_uri: str, base_url: str, client_secret: Optional[str] = None, scopes: Optional[List[str]] = None):
|
|
12
|
+
if not client_id:
|
|
13
|
+
raise ValueError('client_id is required')
|
|
14
|
+
if not redirect_uri:
|
|
15
|
+
raise ValueError('redirect_uri is required')
|
|
16
|
+
if not base_url:
|
|
17
|
+
raise ValueError('base_url is required')
|
|
18
|
+
|
|
19
|
+
self.client_id = client_id
|
|
20
|
+
self.client_secret = client_secret
|
|
21
|
+
self.redirect_uri = redirect_uri
|
|
22
|
+
self.base_url = base_url.rstrip('/')
|
|
23
|
+
self.scopes = scopes or ['openid', 'profile', 'email']
|
|
24
|
+
|
|
25
|
+
self.jwks_service = JWKSService(f"{self.base_url}/api/oauth/jwks")
|
|
26
|
+
|
|
27
|
+
def get_authorization_url(self, state: str, code_challenge: str) -> str:
|
|
28
|
+
params = {
|
|
29
|
+
'client_id': self.client_id,
|
|
30
|
+
'redirect_uri': self.redirect_uri,
|
|
31
|
+
'response_type': 'code',
|
|
32
|
+
'scope': ' '.join(self.scopes),
|
|
33
|
+
'state': state,
|
|
34
|
+
'code_challenge': code_challenge,
|
|
35
|
+
'code_challenge_method': 'S256',
|
|
36
|
+
}
|
|
37
|
+
return f"{self.base_url}/api/oauth/authorize?{urlencode(params)}"
|
|
38
|
+
|
|
39
|
+
def generate_pkce(self) -> dict:
|
|
40
|
+
return generate_pkce()
|
|
41
|
+
|
|
42
|
+
def _request_token(self, payload: dict) -> TokenSet:
|
|
43
|
+
payload['client_id'] = self.client_id
|
|
44
|
+
if self.client_secret:
|
|
45
|
+
payload['client_secret'] = self.client_secret
|
|
46
|
+
|
|
47
|
+
res = requests.post(f"{self.base_url}/api/oauth/token", json=payload)
|
|
48
|
+
data = res.json()
|
|
49
|
+
|
|
50
|
+
if not res.ok:
|
|
51
|
+
raise ICAError(data.get('error', 'request_failed'), data.get('error_description', 'Failed to fetch token'), res.status_code)
|
|
52
|
+
|
|
53
|
+
return TokenSet(
|
|
54
|
+
access_token=data['access_token'],
|
|
55
|
+
token_type=data['token_type'],
|
|
56
|
+
expires_in=data['expires_in'],
|
|
57
|
+
refresh_token=data['refresh_token'],
|
|
58
|
+
scope=data['scope'],
|
|
59
|
+
id_token=data.get('id_token')
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def exchange_code(self, code: str, code_verifier: str) -> TokenSet:
|
|
63
|
+
return self._request_token({
|
|
64
|
+
'grant_type': 'authorization_code',
|
|
65
|
+
'code': code,
|
|
66
|
+
'redirect_uri': self.redirect_uri,
|
|
67
|
+
'code_verifier': code_verifier,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
def refresh_access_token(self, refresh_token: str) -> TokenSet:
|
|
71
|
+
return self._request_token({
|
|
72
|
+
'grant_type': 'refresh_token',
|
|
73
|
+
'refresh_token': refresh_token,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
def get_user_info(self, access_token: str) -> UserInfoClaims:
|
|
77
|
+
res = requests.get(
|
|
78
|
+
f"{self.base_url}/api/oauth/userinfo",
|
|
79
|
+
headers={"Authorization": f"Bearer {access_token}"}
|
|
80
|
+
)
|
|
81
|
+
data = res.json()
|
|
82
|
+
if not res.ok:
|
|
83
|
+
raise ICAError(data.get('error', 'request_failed'), data.get('error_description', 'Failed to fetch userinfo'), res.status_code)
|
|
84
|
+
|
|
85
|
+
return UserInfoClaims(**data)
|
|
86
|
+
|
|
87
|
+
def verify_token(self, jwt_token: str) -> dict:
|
|
88
|
+
return self.jwks_service.verify_token(jwt_token, self.client_id, self.base_url)
|
|
89
|
+
|
|
90
|
+
def revoke_token(self, refresh_token: str) -> bool:
|
|
91
|
+
payload = {
|
|
92
|
+
'token': refresh_token,
|
|
93
|
+
'client_id': self.client_id
|
|
94
|
+
}
|
|
95
|
+
if self.client_secret:
|
|
96
|
+
payload['client_secret'] = self.client_secret
|
|
97
|
+
|
|
98
|
+
res = requests.post(f"{self.base_url}/api/oauth/revoke", json=payload)
|
|
99
|
+
data = res.json()
|
|
100
|
+
if not res.ok:
|
|
101
|
+
raise ICAError(data.get('error', 'request_failed'), data.get('error_description', 'Failed to revoke token'), res.status_code)
|
|
102
|
+
|
|
103
|
+
return data.get('revoked', False)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from typing import Callable
|
|
2
|
+
from .client import IGlobalsAuth
|
|
3
|
+
from .errors import ICAError
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from fastapi import Request, HTTPException, status
|
|
7
|
+
except ImportError:
|
|
8
|
+
pass # Allow import if FastAPI is not installed, fail at runtime if used
|
|
9
|
+
|
|
10
|
+
def require_auth(ica: IGlobalsAuth) -> Callable:
|
|
11
|
+
async def auth_dependency(request: Request) -> dict:
|
|
12
|
+
auth_header = request.headers.get("Authorization")
|
|
13
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
14
|
+
raise HTTPException(
|
|
15
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
16
|
+
detail="Bearer token missing or malformed.",
|
|
17
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
token = auth_header.split(" ")[1]
|
|
21
|
+
try:
|
|
22
|
+
payload = ica.verify_token(token)
|
|
23
|
+
return payload
|
|
24
|
+
except ICAError as e:
|
|
25
|
+
raise HTTPException(
|
|
26
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
27
|
+
detail=str(e),
|
|
28
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return auth_dependency
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from functools import wraps
|
|
2
|
+
from typing import Callable
|
|
3
|
+
from .client import IGlobalsAuth
|
|
4
|
+
from .errors import ICAError
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from flask import request, jsonify, g
|
|
8
|
+
except ImportError:
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
def auth_required(ica: IGlobalsAuth) -> Callable:
|
|
12
|
+
def decorator(f):
|
|
13
|
+
@wraps(f)
|
|
14
|
+
def decorated_function(*args, **kwargs):
|
|
15
|
+
auth_header = request.headers.get("Authorization")
|
|
16
|
+
if not auth_header or not auth_header.startswith("Bearer "):
|
|
17
|
+
return jsonify({"error": "unauthorized", "error_description": "Bearer token missing or malformed.", "status": 401}), 401
|
|
18
|
+
|
|
19
|
+
token = auth_header.split(" ")[1]
|
|
20
|
+
try:
|
|
21
|
+
payload = ica.verify_token(token)
|
|
22
|
+
g.ica_user = payload
|
|
23
|
+
except ICAError as e:
|
|
24
|
+
return jsonify({"error": "unauthorized", "error_description": str(e), "status": 401}), 401
|
|
25
|
+
|
|
26
|
+
return f(*args, **kwargs)
|
|
27
|
+
return decorated_function
|
|
28
|
+
return decorator
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import jwt
|
|
2
|
+
from typing import Dict, Any
|
|
3
|
+
from jwt import PyJWKClient
|
|
4
|
+
|
|
5
|
+
from .errors import ICAError
|
|
6
|
+
|
|
7
|
+
class JWKSService:
|
|
8
|
+
def __init__(self, jwks_uri: str):
|
|
9
|
+
# PyJWKClient provides caching out of the box
|
|
10
|
+
self.jwks_client = PyJWKClient(jwks_uri, cache_keys=True)
|
|
11
|
+
|
|
12
|
+
def verify_token(self, token: str, expected_aud: str, expected_iss: str) -> Dict[str, Any]:
|
|
13
|
+
try:
|
|
14
|
+
signing_key = self.jwks_client.get_signing_key_from_jwt(token)
|
|
15
|
+
payload = jwt.decode(
|
|
16
|
+
token,
|
|
17
|
+
signing_key.key,
|
|
18
|
+
algorithms=["RS256"],
|
|
19
|
+
audience=expected_aud,
|
|
20
|
+
issuer=expected_iss,
|
|
21
|
+
)
|
|
22
|
+
return payload
|
|
23
|
+
except jwt.PyJWTError as e:
|
|
24
|
+
raise ICAError('invalid_token', str(e), 401)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import hashlib
|
|
3
|
+
import base64
|
|
4
|
+
|
|
5
|
+
def generate_pkce() -> dict:
|
|
6
|
+
code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode('utf-8').rstrip('=')
|
|
7
|
+
digest = hashlib.sha256(code_verifier.encode('utf-8')).digest()
|
|
8
|
+
code_challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
|
|
9
|
+
return {
|
|
10
|
+
"code_verifier": code_verifier,
|
|
11
|
+
"code_challenge": code_challenge
|
|
12
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional, TypedDict, Any
|
|
3
|
+
|
|
4
|
+
@dataclass
|
|
5
|
+
class TokenSet:
|
|
6
|
+
access_token: str
|
|
7
|
+
token_type: str
|
|
8
|
+
expires_in: int
|
|
9
|
+
refresh_token: str
|
|
10
|
+
scope: str
|
|
11
|
+
id_token: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
class AddressClaims(TypedDict, total=False):
|
|
14
|
+
street_address: str
|
|
15
|
+
locality: str
|
|
16
|
+
region: str
|
|
17
|
+
postal_code: str
|
|
18
|
+
country: str
|
|
19
|
+
|
|
20
|
+
class UserInfoClaims(TypedDict, total=False):
|
|
21
|
+
sub: str
|
|
22
|
+
given_name: str
|
|
23
|
+
family_name: str
|
|
24
|
+
email: str
|
|
25
|
+
email_verified: bool
|
|
26
|
+
phone_number: str
|
|
27
|
+
phone_number_verified: bool
|
|
28
|
+
address: AddressClaims
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iglobals-auth
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: iGlobals Central Auth SDK for Python
|
|
5
|
+
Author-email: iGlobals <engineering@iglobals.com>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.28.0
|
|
9
|
+
Requires-Dist: PyJWT[crypto]>=2.8.0
|
|
10
|
+
Provides-Extra: fastapi
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0; extra == "fastapi"
|
|
12
|
+
Provides-Extra: flask
|
|
13
|
+
Requires-Dist: Flask>=2.0.0; extra == "flask"
|
|
14
|
+
|
|
15
|
+
# iglobals-auth
|
|
16
|
+
Python SDK for iGlobals Central Auth.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
iglobals_auth/__init__.py
|
|
4
|
+
iglobals_auth/client.py
|
|
5
|
+
iglobals_auth/errors.py
|
|
6
|
+
iglobals_auth/fastapi.py
|
|
7
|
+
iglobals_auth/flask.py
|
|
8
|
+
iglobals_auth/jwks.py
|
|
9
|
+
iglobals_auth/pkce.py
|
|
10
|
+
iglobals_auth/types.py
|
|
11
|
+
iglobals_auth.egg-info/PKG-INFO
|
|
12
|
+
iglobals_auth.egg-info/SOURCES.txt
|
|
13
|
+
iglobals_auth.egg-info/dependency_links.txt
|
|
14
|
+
iglobals_auth.egg-info/requires.txt
|
|
15
|
+
iglobals_auth.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
iglobals_auth
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "iglobals-auth"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "iGlobals Central Auth SDK for Python"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "iGlobals", email = "engineering@iglobals.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.9"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.28.0",
|
|
12
|
+
"PyJWT[crypto]>=2.8.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
fastapi = ["fastapi>=0.100.0"]
|
|
17
|
+
flask = ["Flask>=2.0.0"]
|
|
18
|
+
|
|
19
|
+
[build-system]
|
|
20
|
+
requires = ["setuptools>=61.0"]
|
|
21
|
+
build-backend = "setuptools.build_meta"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools]
|
|
24
|
+
packages = ["iglobals_auth"]
|