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.
@@ -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,2 @@
1
+ # iglobals-auth
2
+ Python SDK for iGlobals Central Auth.
@@ -0,0 +1,12 @@
1
+ from .client import IGlobalsAuth
2
+ from .errors import ICAError
3
+ from .types import TokenSet, UserInfoClaims
4
+ from .pkce import generate_pkce
5
+
6
+ __all__ = [
7
+ "IGlobalsAuth",
8
+ "ICAError",
9
+ "TokenSet",
10
+ "UserInfoClaims",
11
+ "generate_pkce",
12
+ ]
@@ -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,6 @@
1
+ class ICAError(Exception):
2
+ def __init__(self, error: str, error_description: str, status: int = None):
3
+ super().__init__(f"{error}: {error_description}")
4
+ self.error = error
5
+ self.error_description = error_description
6
+ self.status = status
@@ -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,8 @@
1
+ requests>=2.28.0
2
+ PyJWT[crypto]>=2.8.0
3
+
4
+ [fastapi]
5
+ fastapi>=0.100.0
6
+
7
+ [flask]
8
+ Flask>=2.0.0
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+