simple-auth-server 0.1.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,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-auth-server
3
+ Version: 0.1.0
4
+ Summary: Server-side auth primitives (OTP, sessions, Google OAuth) with Redis
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: redis>=5.0.0
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "simple-auth-server"
3
+ version = "0.1.0"
4
+ description = "Server-side auth primitives (OTP, sessions, Google OAuth) with Redis"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "httpx>=0.27.0",
8
+ "redis>=5.0.0",
9
+ ]
10
+
11
+ [build-system]
12
+ requires = ["setuptools>=69.0.0"]
13
+ build-backend = "setuptools.build_meta"
14
+
15
+ [tool.setuptools.packages.find]
16
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,20 @@
1
+ from .config import SimpleAuthServerConfig, SimpleAuthProvidersConfig, OtpConfig, RedisConfig
2
+ from .otp import OtpError, OtpService
3
+ from .redis_client import create_redis_client, with_key_prefix
4
+ from .session import AuthSessionService
5
+ from .oauth_google import GoogleOAuthService, GoogleOAuthConfig
6
+
7
+ __all__ = [
8
+ "SimpleAuthServerConfig",
9
+ "SimpleAuthProvidersConfig",
10
+ "OtpConfig",
11
+ "RedisConfig",
12
+ "OtpError",
13
+ "OtpService",
14
+ "create_redis_client",
15
+ "with_key_prefix",
16
+ "AuthSessionService",
17
+ "GoogleOAuthService",
18
+ "GoogleOAuthConfig",
19
+ ]
20
+
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal, Optional
5
+
6
+
7
+ Env = Literal["production", "development", "test"]
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class RedisConfig:
12
+ url: Optional[str] = None
13
+ key_prefix: Optional[str] = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class OtpRateLimitConfig:
18
+ window_seconds: int = 60
19
+ max_requests: int = 3
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class OtpConfig:
24
+ code_length: int = 6
25
+ ttl_seconds: int = 300
26
+ max_attempts: int = 5
27
+ rate_limit: OtpRateLimitConfig = OtpRateLimitConfig()
28
+ bypass_code: Optional[str] = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class SimpleAuthProvidersConfig:
33
+ email_otp_enabled: bool = True
34
+ phone_otp_enabled: bool = True
35
+ google_enabled: bool = False
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SimpleAuthServerConfig:
40
+ env: Env
41
+ redis: RedisConfig = RedisConfig()
42
+ otp: OtpConfig = OtpConfig()
43
+ providers: SimpleAuthProvidersConfig = SimpleAuthProvidersConfig()
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Optional
5
+
6
+ import httpx
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class GoogleOAuthConfig:
11
+ client_id: str
12
+ client_secret: str
13
+ redirect_uri: Optional[str] = None
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class GoogleOAuthUserInfo:
18
+ sub: str
19
+ email: str
20
+ email_verified: bool
21
+ first_name: Optional[str]
22
+ last_name: Optional[str]
23
+ raw: dict[str, Any]
24
+
25
+
26
+ class GoogleOAuthService:
27
+ def __init__(self, config: GoogleOAuthConfig, http_client: Optional[httpx.AsyncClient] = None) -> None:
28
+ self._config = config
29
+ self._http = http_client or httpx.AsyncClient(timeout=10.0)
30
+
31
+ async def exchange_auth_code(self, auth_code: str) -> dict[str, Any]:
32
+ token_payload = {
33
+ "code": auth_code,
34
+ "client_id": self._config.client_id,
35
+ "client_secret": self._config.client_secret,
36
+ "grant_type": "authorization_code",
37
+ }
38
+ if self._config.redirect_uri:
39
+ token_payload["redirect_uri"] = self._config.redirect_uri
40
+
41
+ token_resp = await self._http.post("https://oauth2.googleapis.com/token", data=token_payload)
42
+ token_resp.raise_for_status()
43
+ tokens = token_resp.json()
44
+
45
+ access_token = tokens.get("access_token")
46
+ if not access_token:
47
+ raise ValueError("Missing access_token")
48
+
49
+ userinfo_resp = await self._http.get(
50
+ "https://www.googleapis.com/oauth2/v3/userinfo",
51
+ headers={"Authorization": f"Bearer {access_token}"},
52
+ )
53
+ userinfo_resp.raise_for_status()
54
+ user = userinfo_resp.json()
55
+
56
+ return {
57
+ "user": {
58
+ "sub": user.get("sub"),
59
+ "email": user.get("email"),
60
+ "emailVerified": bool(user.get("email_verified", False)),
61
+ "firstName": user.get("given_name"),
62
+ "lastName": user.get("family_name"),
63
+ "raw": user,
64
+ },
65
+ "refreshToken": tokens.get("refresh_token"),
66
+ "accessToken": access_token,
67
+ "idToken": tokens.get("id_token"),
68
+ "expiresIn": tokens.get("expires_in"),
69
+ "tokenType": tokens.get("token_type"),
70
+ "scope": tokens.get("scope"),
71
+ }
72
+
@@ -0,0 +1,171 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import hmac
5
+ import re
6
+ import secrets
7
+ import time
8
+ from typing import Literal, Optional, TypedDict
9
+
10
+ from .config import Env, OtpConfig
11
+ from .redis_client import RedisLike
12
+
13
+
14
+ OtpType = Literal["email", "phone"]
15
+
16
+
17
+ class OtpError(TypedDict):
18
+ code: Literal["RATE_LIMITED", "INVALID_CODE", "EXPIRED", "MAX_ATTEMPTS", "NOT_FOUND"]
19
+ message: str
20
+ retry_after_seconds: Optional[int]
21
+ attempts_remaining: Optional[int]
22
+
23
+
24
+ class _StoredOtp(TypedDict):
25
+ code: str
26
+ attempts: int
27
+ createdAt: int
28
+
29
+
30
+ _EMAIL_PREFIX = "otp:email:"
31
+ _PHONE_PREFIX = "otp:phone:"
32
+ _RATE_PREFIX = "rate:otp:"
33
+
34
+
35
+ def _normalize_email(email: str) -> str:
36
+ return email.strip().lower()
37
+
38
+
39
+ def _normalize_phone(phone: str) -> str:
40
+ # Keep + and digits only
41
+ return re.sub(r"[^\d+]", "", phone)
42
+
43
+
44
+ def _generate_random_code(length: int) -> str:
45
+ # Leading zeros allowed
46
+ length = max(1, int(length))
47
+ max_value = 10**length
48
+ return str(secrets.randbelow(max_value)).zfill(length)
49
+
50
+
51
+ def _bypass_enabled(env: Env, bypass_code: Optional[str]) -> bool:
52
+ return env != "production" and bool(bypass_code)
53
+
54
+
55
+ async def _check_rate_limit(
56
+ redis: RedisLike, key: str, window_seconds: int, max_requests: int
57
+ ) -> tuple[bool, Optional[int]]:
58
+ count = await redis.incr(key)
59
+ if count == 1:
60
+ await redis.expire(key, window_seconds)
61
+
62
+ if count > max_requests:
63
+ ttl = await redis.ttl(key)
64
+ return False, max(ttl, 1)
65
+
66
+ return True, None
67
+
68
+
69
+ class OtpService:
70
+ def __init__(self, redis: RedisLike, env: Env, config: OtpConfig = OtpConfig()) -> None:
71
+ self._redis = redis
72
+ self._env = env
73
+ self._config = config
74
+
75
+ async def generate_email_otp(self, email: str) -> tuple[bool, str | OtpError]:
76
+ return await self._generate("email", _normalize_email(email))
77
+
78
+ async def verify_email_otp(self, email: str, code: str) -> tuple[bool, None | OtpError]:
79
+ return await self._verify("email", _normalize_email(email), code)
80
+
81
+ async def generate_phone_otp(self, phone: str) -> tuple[bool, str | OtpError]:
82
+ return await self._generate("phone", _normalize_phone(phone))
83
+
84
+ async def verify_phone_otp(self, phone: str, code: str) -> tuple[bool, None | OtpError]:
85
+ return await self._verify("phone", _normalize_phone(phone), code)
86
+
87
+ async def _generate(self, otp_type: OtpType, identifier: str) -> tuple[bool, str | OtpError]:
88
+ allowed, retry_after = await _check_rate_limit(
89
+ self._redis,
90
+ f"{_RATE_PREFIX}{otp_type}:{identifier}",
91
+ self._config.rate_limit.window_seconds,
92
+ self._config.rate_limit.max_requests,
93
+ )
94
+
95
+ if not allowed:
96
+ return False, {
97
+ "code": "RATE_LIMITED",
98
+ "message": "Too many OTP requests. Please wait before trying again.",
99
+ "retry_after_seconds": retry_after,
100
+ "attempts_remaining": None,
101
+ }
102
+
103
+ code = (
104
+ self._config.bypass_code
105
+ if _bypass_enabled(self._env, self._config.bypass_code)
106
+ else _generate_random_code(self._config.code_length)
107
+ )
108
+
109
+ stored: _StoredOtp = {"code": code, "attempts": 0, "createdAt": int(time.time() * 1000)}
110
+ key = f"{_EMAIL_PREFIX if otp_type == 'email' else _PHONE_PREFIX}{identifier}"
111
+ await self._redis.setex(key, self._config.ttl_seconds, json.dumps(stored))
112
+ return True, code
113
+
114
+ async def _verify(self, otp_type: OtpType, identifier: str, code: str) -> tuple[bool, None | OtpError]:
115
+ key = f"{_EMAIL_PREFIX if otp_type == 'email' else _PHONE_PREFIX}{identifier}"
116
+ raw = await self._redis.get(key)
117
+ if raw is None:
118
+ return False, {
119
+ "code": "NOT_FOUND",
120
+ "message": "No OTP found. Please request a new code.",
121
+ "retry_after_seconds": None,
122
+ "attempts_remaining": None,
123
+ }
124
+
125
+ try:
126
+ parsed: _StoredOtp = json.loads(raw)
127
+ except Exception:
128
+ await self._redis.delete(key)
129
+ return False, {
130
+ "code": "NOT_FOUND",
131
+ "message": "No OTP found. Please request a new code.",
132
+ "retry_after_seconds": None,
133
+ "attempts_remaining": None,
134
+ }
135
+
136
+ attempts = int(parsed.get("attempts", 0))
137
+ if attempts >= self._config.max_attempts:
138
+ await self._redis.delete(key)
139
+ return False, {
140
+ "code": "MAX_ATTEMPTS",
141
+ "message": "Maximum verification attempts exceeded. Please request a new code.",
142
+ "retry_after_seconds": None,
143
+ "attempts_remaining": None,
144
+ }
145
+
146
+ ttl = await self._redis.ttl(key)
147
+ if ttl <= 0:
148
+ await self._redis.delete(key)
149
+ return False, {
150
+ "code": "NOT_FOUND",
151
+ "message": "No OTP found. Please request a new code.",
152
+ "retry_after_seconds": None,
153
+ "attempts_remaining": None,
154
+ }
155
+
156
+ # Increment attempts on every verification attempt.
157
+ updated = {**parsed, "attempts": attempts + 1}
158
+ await self._redis.setex(key, ttl, json.dumps(updated))
159
+
160
+ stored_code = parsed.get("code", "")
161
+ if not isinstance(stored_code, str) or not hmac.compare_digest(stored_code, code):
162
+ remaining = self._config.max_attempts - updated["attempts"]
163
+ return False, {
164
+ "code": "INVALID_CODE",
165
+ "message": "Invalid code. Please try again.",
166
+ "retry_after_seconds": None,
167
+ "attempts_remaining": remaining,
168
+ }
169
+
170
+ await self._redis.delete(key)
171
+ return True, None
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Awaitable, Callable, Optional, Protocol
5
+
6
+ import redis.asyncio as redis
7
+
8
+
9
+ def get_default_redis_url() -> str:
10
+ return "redis://localhost:6379"
11
+
12
+
13
+ class RedisLike(Protocol):
14
+ async def get(self, key: str) -> Optional[str]: ...
15
+ async def setex(self, key: str, time: int, value: str) -> object: ...
16
+ async def delete(self, key: str) -> int: ...
17
+ async def incr(self, key: str) -> int: ...
18
+ async def expire(self, key: str, time: int) -> bool: ...
19
+ async def ttl(self, key: str) -> int: ...
20
+
21
+
22
+ def create_redis_client(url: Optional[str] = None) -> redis.Redis:
23
+ return redis.from_url(url or get_default_redis_url(), decode_responses=True)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class _KeyPrefixRedis:
28
+ redis: RedisLike
29
+ prefix: str
30
+
31
+ def _k(self, key: str) -> str:
32
+ return f"{self.prefix}{key}"
33
+
34
+ async def get(self, key: str) -> Optional[str]:
35
+ return await self.redis.get(self._k(key))
36
+
37
+ async def setex(self, key: str, time: int, value: str) -> object:
38
+ return await self.redis.setex(self._k(key), time, value)
39
+
40
+ async def delete(self, key: str) -> int:
41
+ return await self.redis.delete(self._k(key))
42
+
43
+ async def incr(self, key: str) -> int:
44
+ return await self.redis.incr(self._k(key))
45
+
46
+ async def expire(self, key: str, time: int) -> bool:
47
+ return await self.redis.expire(self._k(key), time)
48
+
49
+ async def ttl(self, key: str) -> int:
50
+ return await self.redis.ttl(self._k(key))
51
+
52
+
53
+ def with_key_prefix(redis_client: RedisLike, key_prefix: str) -> RedisLike:
54
+ prefix = key_prefix if key_prefix.endswith(":") else f"{key_prefix}:"
55
+ return _KeyPrefixRedis(redis=redis_client, prefix=prefix)
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import time
6
+ from typing import Optional, TypedDict
7
+
8
+ from .redis_client import RedisLike
9
+
10
+
11
+ _SESSION_PREFIX = "auth_session:"
12
+ _DEFAULT_TTL_SECONDS = 24 * 60 * 60
13
+ _SESSION_ID_RE = re.compile(r"^[0-9a-f]{64}$")
14
+
15
+
16
+ class AuthSession(TypedDict):
17
+ email: str
18
+ emailVerified: bool
19
+ phoneNumber: Optional[str]
20
+ phoneVerified: bool
21
+ createdAt: int
22
+ expiresAt: int
23
+
24
+
25
+ def _generate_session_id() -> str:
26
+ # 32 bytes hex
27
+ import secrets
28
+
29
+ return secrets.token_hex(32)
30
+
31
+ def _normalize_session_id(session_id: str) -> str:
32
+ return session_id.strip().lower()
33
+
34
+ def _is_valid_session_id(session_id: str) -> bool:
35
+ return bool(_SESSION_ID_RE.fullmatch(session_id))
36
+
37
+
38
+ class AuthSessionService:
39
+ def __init__(
40
+ self,
41
+ redis: RedisLike,
42
+ session_ttl_seconds: int = _DEFAULT_TTL_SECONDS,
43
+ key_prefix: str = _SESSION_PREFIX,
44
+ ) -> None:
45
+ self._redis = redis
46
+ self._ttl_seconds = session_ttl_seconds
47
+ self._key_prefix = key_prefix
48
+
49
+ async def create_session(self, email: str) -> str:
50
+ session_id = _generate_session_id()
51
+ now_ms = int(time.time() * 1000)
52
+ session: AuthSession = {
53
+ "email": email.strip().lower(),
54
+ "emailVerified": False,
55
+ "phoneNumber": None,
56
+ "phoneVerified": False,
57
+ "createdAt": now_ms,
58
+ "expiresAt": now_ms + self._ttl_seconds * 1000,
59
+ }
60
+ await self._redis.setex(f"{self._key_prefix}{session_id}", self._ttl_seconds, json.dumps(session))
61
+ return session_id
62
+
63
+ async def get_session(self, session_id: str) -> Optional[AuthSession]:
64
+ normalized = _normalize_session_id(session_id)
65
+ if not _is_valid_session_id(normalized):
66
+ return None
67
+
68
+ key = f"{self._key_prefix}{normalized}"
69
+ raw = await self._redis.get(key)
70
+ if raw is None:
71
+ return None
72
+ try:
73
+ parsed: AuthSession = json.loads(raw)
74
+ except Exception:
75
+ await self._redis.delete(key)
76
+ return None
77
+
78
+ if int(time.time() * 1000) > int(parsed.get("expiresAt", 0)):
79
+ await self._redis.delete(key)
80
+ return None
81
+
82
+ return parsed
83
+
84
+ async def delete_session(self, session_id: str) -> None:
85
+ normalized = _normalize_session_id(session_id)
86
+ if not _is_valid_session_id(normalized):
87
+ return
88
+
89
+ await self._redis.delete(f"{self._key_prefix}{normalized}")
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: simple-auth-server
3
+ Version: 0.1.0
4
+ Summary: Server-side auth primitives (OTP, sessions, Google OAuth) with Redis
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: redis>=5.0.0
@@ -0,0 +1,12 @@
1
+ pyproject.toml
2
+ src/simple_auth_server/__init__.py
3
+ src/simple_auth_server/config.py
4
+ src/simple_auth_server/oauth_google.py
5
+ src/simple_auth_server/otp.py
6
+ src/simple_auth_server/redis_client.py
7
+ src/simple_auth_server/session.py
8
+ src/simple_auth_server.egg-info/PKG-INFO
9
+ src/simple_auth_server.egg-info/SOURCES.txt
10
+ src/simple_auth_server.egg-info/dependency_links.txt
11
+ src/simple_auth_server.egg-info/requires.txt
12
+ src/simple_auth_server.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ httpx>=0.27.0
2
+ redis>=5.0.0
@@ -0,0 +1 @@
1
+ simple_auth_server