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.
- simple_auth_server-0.1.0/PKG-INFO +7 -0
- simple_auth_server-0.1.0/pyproject.toml +16 -0
- simple_auth_server-0.1.0/setup.cfg +4 -0
- simple_auth_server-0.1.0/src/simple_auth_server/__init__.py +20 -0
- simple_auth_server-0.1.0/src/simple_auth_server/config.py +43 -0
- simple_auth_server-0.1.0/src/simple_auth_server/oauth_google.py +72 -0
- simple_auth_server-0.1.0/src/simple_auth_server/otp.py +171 -0
- simple_auth_server-0.1.0/src/simple_auth_server/redis_client.py +55 -0
- simple_auth_server-0.1.0/src/simple_auth_server/session.py +89 -0
- simple_auth_server-0.1.0/src/simple_auth_server.egg-info/PKG-INFO +7 -0
- simple_auth_server-0.1.0/src/simple_auth_server.egg-info/SOURCES.txt +12 -0
- simple_auth_server-0.1.0/src/simple_auth_server.egg-info/dependency_links.txt +1 -0
- simple_auth_server-0.1.0/src/simple_auth_server.egg-info/requires.txt +2 -0
- simple_auth_server-0.1.0/src/simple_auth_server.egg-info/top_level.txt +1 -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
simple_auth_server
|