pico-server-auth 0.1.0__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.
- pico_server_auth/__init__.py +15 -0
- pico_server_auth/_version.py +1 -0
- pico_server_auth/challenge_store.py +57 -0
- pico_server_auth/config.py +24 -0
- pico_server_auth/controllers.py +129 -0
- pico_server_auth/py.typed +0 -0
- pico_server_auth/token_issuer.py +100 -0
- pico_server_auth/wallet_verifier.py +81 -0
- pico_server_auth-0.1.0.dist-info/METADATA +169 -0
- pico_server_auth-0.1.0.dist-info/RECORD +14 -0
- pico_server_auth-0.1.0.dist-info/WHEEL +5 -0
- pico_server_auth-0.1.0.dist-info/entry_points.txt +2 -0
- pico_server_auth-0.1.0.dist-info/licenses/LICENSE +21 -0
- pico_server_auth-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""pico-server-auth: embeddable auth server module.
|
|
2
|
+
|
|
3
|
+
Auto-discovered by pico-boot via the ``pico_boot.modules`` entry point.
|
|
4
|
+
Provides JWT issuance, wallet challenge/verify login, password login,
|
|
5
|
+
and JWKS endpoint as FastAPI controllers.
|
|
6
|
+
|
|
7
|
+
Compatible with pico-client-auth — tokens issued here are validated
|
|
8
|
+
by pico-client-auth in the same process or across services.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .challenge_store import ChallengeStore as ChallengeStore
|
|
12
|
+
from .challenge_store import InMemoryChallengeStore as InMemoryChallengeStore
|
|
13
|
+
from .config import ServerAuthSettings as ServerAuthSettings
|
|
14
|
+
from .token_issuer import TokenIssuer as TokenIssuer
|
|
15
|
+
from .wallet_verifier import WalletVerifier as WalletVerifier
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import time
|
|
3
|
+
from typing import Protocol
|
|
4
|
+
|
|
5
|
+
from pico_ioc import component
|
|
6
|
+
|
|
7
|
+
from pico_server_auth.config import ServerAuthSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ChallengeStore(Protocol):
|
|
11
|
+
"""Protocol for storing and validating auth challenges.
|
|
12
|
+
|
|
13
|
+
Default implementation is in-memory with TTL expiry.
|
|
14
|
+
Replace with a Redis or DB-backed implementation by registering
|
|
15
|
+
your own @component that implements this protocol.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def create(self, address: str) -> str: ...
|
|
19
|
+
|
|
20
|
+
def validate(self, address: str, nonce: str) -> bool: ...
|
|
21
|
+
|
|
22
|
+
def cleanup(self) -> int: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@component(on_missing_selector=ChallengeStore)
|
|
26
|
+
class InMemoryChallengeStore:
|
|
27
|
+
"""In-memory challenge store with TTL-based expiry.
|
|
28
|
+
|
|
29
|
+
Suitable for single-process deployments. For multi-instance setups,
|
|
30
|
+
register a shared-storage implementation of ChallengeStore.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self, settings: ServerAuthSettings):
|
|
34
|
+
self._ttl = settings.challenge_ttl_seconds
|
|
35
|
+
self._challenges: dict[str, tuple[str, float]] = {}
|
|
36
|
+
|
|
37
|
+
def create(self, address: str) -> str:
|
|
38
|
+
self.cleanup()
|
|
39
|
+
nonce = secrets.token_hex(32)
|
|
40
|
+
self._challenges[address] = (nonce, time.time())
|
|
41
|
+
return nonce
|
|
42
|
+
|
|
43
|
+
def validate(self, address: str, nonce: str) -> bool:
|
|
44
|
+
entry = self._challenges.pop(address, None)
|
|
45
|
+
if entry is None:
|
|
46
|
+
return False
|
|
47
|
+
stored_nonce, created_at = entry
|
|
48
|
+
if time.time() - created_at > self._ttl:
|
|
49
|
+
return False
|
|
50
|
+
return secrets.compare_digest(stored_nonce, nonce)
|
|
51
|
+
|
|
52
|
+
def cleanup(self) -> int:
|
|
53
|
+
now = time.time()
|
|
54
|
+
expired = [k for k, (_, t) in self._challenges.items() if now - t > self._ttl]
|
|
55
|
+
for k in expired:
|
|
56
|
+
del self._challenges[k]
|
|
57
|
+
return len(expired)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
|
|
3
|
+
from pico_ioc import configured
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@configured(target="self", prefix="server_auth", mapping="tree")
|
|
7
|
+
@dataclass
|
|
8
|
+
class ServerAuthSettings:
|
|
9
|
+
"""Configuration for pico-server-auth.
|
|
10
|
+
|
|
11
|
+
When embedded alongside pico-client-auth, set ``issuer`` and ``audience``
|
|
12
|
+
to the same values so tokens issued here are accepted by the client middleware.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
issuer: str = "http://localhost:8100"
|
|
16
|
+
audience: str = "pico"
|
|
17
|
+
algorithm: str = "RS256"
|
|
18
|
+
access_token_expire_minutes: int = 15
|
|
19
|
+
refresh_token_expire_days: int = 7
|
|
20
|
+
challenge_ttl_seconds: int = 60
|
|
21
|
+
auto_create_admin: bool = False
|
|
22
|
+
admin_email: str = "admin@pico.local"
|
|
23
|
+
admin_password: str = "admin"
|
|
24
|
+
supported_wallet_algorithms: list[str] = field(default_factory=lambda: ["ML-DSA-65", "Ed25519", "secp256k1"])
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
from fastapi import HTTPException
|
|
4
|
+
from pico_client_auth import allow_anonymous
|
|
5
|
+
from pico_fastapi import controller, get, post
|
|
6
|
+
|
|
7
|
+
from pico_server_auth.challenge_store import ChallengeStore
|
|
8
|
+
from pico_server_auth.config import ServerAuthSettings
|
|
9
|
+
from pico_server_auth.token_issuer import TokenIssuer
|
|
10
|
+
from pico_server_auth.wallet_verifier import WalletVerifier
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@controller(prefix="/auth", tags=["Auth"])
|
|
14
|
+
class AuthController:
|
|
15
|
+
"""Auth endpoints — compatible with pico-client-auth validation.
|
|
16
|
+
|
|
17
|
+
JWKS endpoint allows pico-client-auth to validate tokens whether
|
|
18
|
+
this module runs in the same process or as a separate service.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
settings: ServerAuthSettings,
|
|
24
|
+
challenges: ChallengeStore,
|
|
25
|
+
verifier: WalletVerifier,
|
|
26
|
+
issuer: TokenIssuer,
|
|
27
|
+
):
|
|
28
|
+
self._settings = settings
|
|
29
|
+
self._challenges = challenges
|
|
30
|
+
self._verifier = verifier
|
|
31
|
+
self._issuer = issuer
|
|
32
|
+
|
|
33
|
+
@allow_anonymous
|
|
34
|
+
@get("/jwks")
|
|
35
|
+
async def jwks(self):
|
|
36
|
+
"""JWKS endpoint — pico-client-auth fetches this to validate tokens."""
|
|
37
|
+
return self._issuer.jwks()
|
|
38
|
+
|
|
39
|
+
@allow_anonymous
|
|
40
|
+
@post("/challenge")
|
|
41
|
+
async def challenge(self, body: dict[str, Any]):
|
|
42
|
+
"""Request a challenge nonce for wallet auth.
|
|
43
|
+
|
|
44
|
+
Body: { "address": "0x..." }
|
|
45
|
+
Returns: { "challenge": "<nonce>", "ttl": 60 }
|
|
46
|
+
"""
|
|
47
|
+
address = body.get("address")
|
|
48
|
+
if not address:
|
|
49
|
+
raise HTTPException(status_code=400, detail="address required")
|
|
50
|
+
nonce = self._challenges.create(str(address))
|
|
51
|
+
return {
|
|
52
|
+
"challenge": nonce,
|
|
53
|
+
"ttl": self._settings.challenge_ttl_seconds,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@allow_anonymous
|
|
57
|
+
@post("/wallet")
|
|
58
|
+
async def wallet_login(self, body: dict[str, Any]):
|
|
59
|
+
"""Verify wallet signature and issue JWT.
|
|
60
|
+
|
|
61
|
+
Body: {
|
|
62
|
+
"address": "0x...",
|
|
63
|
+
"public_key": "<hex>",
|
|
64
|
+
"signature": "<hex>",
|
|
65
|
+
"challenge": "<nonce>",
|
|
66
|
+
"algorithm": "ML-DSA-65" | "Ed25519" | "secp256k1"
|
|
67
|
+
}
|
|
68
|
+
Returns: { "access_token": "...", "refresh_token": "...", "address": "..." }
|
|
69
|
+
"""
|
|
70
|
+
address = str(body.get("address", ""))
|
|
71
|
+
public_key_hex = str(body.get("public_key", ""))
|
|
72
|
+
signature_hex = str(body.get("signature", ""))
|
|
73
|
+
challenge_nonce = str(body.get("challenge", ""))
|
|
74
|
+
algorithm = str(body.get("algorithm", "ML-DSA-65"))
|
|
75
|
+
|
|
76
|
+
if not all([address, public_key_hex, signature_hex, challenge_nonce]):
|
|
77
|
+
raise HTTPException(status_code=400, detail="address, public_key, signature, and challenge required")
|
|
78
|
+
|
|
79
|
+
if not self._challenges.validate(address, challenge_nonce):
|
|
80
|
+
raise HTTPException(status_code=401, detail="invalid or expired challenge")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
public_key = bytes.fromhex(public_key_hex)
|
|
84
|
+
signature = bytes.fromhex(signature_hex)
|
|
85
|
+
message = challenge_nonce.encode("utf-8")
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise HTTPException(status_code=400, detail="invalid hex encoding")
|
|
88
|
+
|
|
89
|
+
if not self._verifier.verify(algorithm, public_key, message, signature):
|
|
90
|
+
raise HTTPException(status_code=401, detail="signature verification failed")
|
|
91
|
+
|
|
92
|
+
access_token = self._issuer.issue_access_token(
|
|
93
|
+
subject=address,
|
|
94
|
+
role="wallet",
|
|
95
|
+
extra_claims={"algorithm": algorithm, "wallet_address": address},
|
|
96
|
+
)
|
|
97
|
+
refresh_token = self._issuer.issue_refresh_token(subject=address)
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
"access_token": access_token,
|
|
101
|
+
"refresh_token": refresh_token,
|
|
102
|
+
"address": address,
|
|
103
|
+
"algorithm": algorithm,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@allow_anonymous
|
|
107
|
+
@post("/login")
|
|
108
|
+
async def password_login(self, body: dict[str, Any]):
|
|
109
|
+
"""Password-based login (for admin bootstrap).
|
|
110
|
+
|
|
111
|
+
Body: { "email": "...", "password": "..." }
|
|
112
|
+
Returns: { "access_token": "...", "refresh_token": "..." }
|
|
113
|
+
"""
|
|
114
|
+
email = str(body.get("email", ""))
|
|
115
|
+
password = str(body.get("password", ""))
|
|
116
|
+
|
|
117
|
+
if not self._settings.auto_create_admin:
|
|
118
|
+
raise HTTPException(status_code=403, detail="password login disabled")
|
|
119
|
+
|
|
120
|
+
if email != self._settings.admin_email or password != self._settings.admin_password:
|
|
121
|
+
raise HTTPException(status_code=401, detail="invalid credentials")
|
|
122
|
+
|
|
123
|
+
access_token = self._issuer.issue_access_token(subject=email, role="admin")
|
|
124
|
+
refresh_token = self._issuer.issue_refresh_token(subject=email)
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"access_token": access_token,
|
|
128
|
+
"refresh_token": refresh_token,
|
|
129
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from cryptography.hazmat.primitives import serialization
|
|
5
|
+
from pico_ioc import component
|
|
6
|
+
|
|
7
|
+
from pico_server_auth.config import ServerAuthSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@component
|
|
11
|
+
class TokenIssuer:
|
|
12
|
+
"""Issues JWT tokens compatible with pico-client-auth validation.
|
|
13
|
+
|
|
14
|
+
Tokens use the same issuer/audience as configured in pico-client-auth,
|
|
15
|
+
so they are accepted transparently whether issued in-process or remotely.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, settings: ServerAuthSettings):
|
|
19
|
+
self._settings = settings
|
|
20
|
+
self._private_key: Any = None
|
|
21
|
+
self._public_key: Any = None
|
|
22
|
+
self._jwk: dict | None = None
|
|
23
|
+
self._kid: str = "pico-server-auth-1"
|
|
24
|
+
self._init_keys()
|
|
25
|
+
|
|
26
|
+
def _init_keys(self) -> None:
|
|
27
|
+
alg = self._settings.algorithm
|
|
28
|
+
if alg == "RS256":
|
|
29
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
30
|
+
|
|
31
|
+
self._private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
32
|
+
self._public_key = self._private_key.public_key()
|
|
33
|
+
elif alg in ("ML-DSA-65", "ML-DSA-87"):
|
|
34
|
+
pass # TODO: post-quantum key generation
|
|
35
|
+
else:
|
|
36
|
+
raise ValueError(f"unsupported token algorithm: {alg}")
|
|
37
|
+
|
|
38
|
+
def issue_access_token(self, subject: str, role: str = "user", extra_claims: dict | None = None) -> str:
|
|
39
|
+
from jose import jwt
|
|
40
|
+
|
|
41
|
+
now = int(time.time())
|
|
42
|
+
payload = {
|
|
43
|
+
"sub": subject,
|
|
44
|
+
"iss": self._settings.issuer,
|
|
45
|
+
"aud": self._settings.audience,
|
|
46
|
+
"iat": now,
|
|
47
|
+
"exp": now + self._settings.access_token_expire_minutes * 60,
|
|
48
|
+
"role": role,
|
|
49
|
+
**(extra_claims or {}),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
pem = self._private_key.private_bytes(
|
|
53
|
+
encoding=serialization.Encoding.PEM,
|
|
54
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
55
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
56
|
+
)
|
|
57
|
+
return jwt.encode(payload, pem, algorithm="RS256", headers={"kid": self._kid})
|
|
58
|
+
|
|
59
|
+
def issue_refresh_token(self, subject: str) -> str:
|
|
60
|
+
from jose import jwt
|
|
61
|
+
|
|
62
|
+
now = int(time.time())
|
|
63
|
+
payload = {
|
|
64
|
+
"sub": subject,
|
|
65
|
+
"iss": self._settings.issuer,
|
|
66
|
+
"aud": self._settings.audience,
|
|
67
|
+
"iat": now,
|
|
68
|
+
"exp": now + self._settings.refresh_token_expire_days * 86400,
|
|
69
|
+
"type": "refresh",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
pem = self._private_key.private_bytes(
|
|
73
|
+
encoding=serialization.Encoding.PEM,
|
|
74
|
+
format=serialization.PrivateFormat.PKCS8,
|
|
75
|
+
encryption_algorithm=serialization.NoEncryption(),
|
|
76
|
+
)
|
|
77
|
+
return jwt.encode(payload, pem, algorithm="RS256", headers={"kid": self._kid})
|
|
78
|
+
|
|
79
|
+
def jwks(self) -> dict:
|
|
80
|
+
"""Returns JWKS for pico-client-auth to validate tokens.
|
|
81
|
+
|
|
82
|
+
When running in the same process, pico-client-auth can fetch
|
|
83
|
+
this from the /auth/jwks endpoint or directly from this component.
|
|
84
|
+
"""
|
|
85
|
+
if self._jwk is None:
|
|
86
|
+
pub_numbers = self._public_key.public_numbers()
|
|
87
|
+
import base64
|
|
88
|
+
|
|
89
|
+
def _b64url(n: int, length: int) -> str:
|
|
90
|
+
return base64.urlsafe_b64encode(n.to_bytes(length, "big")).rstrip(b"=").decode()
|
|
91
|
+
|
|
92
|
+
self._jwk = {
|
|
93
|
+
"kty": "RSA",
|
|
94
|
+
"kid": self._kid,
|
|
95
|
+
"alg": "RS256",
|
|
96
|
+
"use": "sig",
|
|
97
|
+
"n": _b64url(pub_numbers.n, 256),
|
|
98
|
+
"e": _b64url(pub_numbers.e, 3),
|
|
99
|
+
}
|
|
100
|
+
return {"keys": [self._jwk]}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from pico_ioc import component
|
|
2
|
+
|
|
3
|
+
from pico_server_auth.config import ServerAuthSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@component
|
|
7
|
+
class WalletVerifier:
|
|
8
|
+
"""Verifies wallet signatures for challenge-response auth.
|
|
9
|
+
|
|
10
|
+
Supports ML-DSA-65 (FIPS 204), Ed25519, and secp256k1.
|
|
11
|
+
Each backend is loaded on first use to avoid hard dependencies.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, settings: ServerAuthSettings):
|
|
15
|
+
self._supported = set(settings.supported_wallet_algorithms)
|
|
16
|
+
|
|
17
|
+
def verify(self, algorithm: str, public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
18
|
+
if algorithm not in self._supported:
|
|
19
|
+
raise ValueError(f"unsupported wallet algorithm: {algorithm}")
|
|
20
|
+
|
|
21
|
+
if algorithm == "ML-DSA-65":
|
|
22
|
+
return self._verify_mldsa65(public_key, message, signature)
|
|
23
|
+
elif algorithm == "Ed25519":
|
|
24
|
+
return self._verify_ed25519(public_key, message, signature)
|
|
25
|
+
elif algorithm == "secp256k1":
|
|
26
|
+
return self._verify_secp256k1(public_key, message, signature)
|
|
27
|
+
else:
|
|
28
|
+
raise ValueError(f"no verifier for algorithm: {algorithm}")
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def _verify_mldsa65(public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
32
|
+
# Try native SDK backend first
|
|
33
|
+
try:
|
|
34
|
+
from dilithia_sdk.crypto import load_native_crypto_adapter
|
|
35
|
+
|
|
36
|
+
adapter = load_native_crypto_adapter()
|
|
37
|
+
if adapter is not None:
|
|
38
|
+
return adapter.verify_message(
|
|
39
|
+
public_key.hex(),
|
|
40
|
+
message.decode("utf-8", errors="replace"),
|
|
41
|
+
signature.hex(),
|
|
42
|
+
)
|
|
43
|
+
except ImportError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
# Fallback: cryptography lib (when FIPS 204 support is available)
|
|
47
|
+
try:
|
|
48
|
+
from cryptography.hazmat.primitives.asymmetric import mldsa
|
|
49
|
+
|
|
50
|
+
pk = mldsa.MLDSA65PublicKey.from_public_bytes(public_key)
|
|
51
|
+
pk.verify(signature, message)
|
|
52
|
+
return True
|
|
53
|
+
except (ImportError, AttributeError):
|
|
54
|
+
pass
|
|
55
|
+
except Exception:
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
raise RuntimeError("ML-DSA-65 verification requires a compatible crypto backend")
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def _verify_ed25519(public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
62
|
+
try:
|
|
63
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
64
|
+
|
|
65
|
+
pk = Ed25519PublicKey.from_public_bytes(public_key)
|
|
66
|
+
pk.verify(signature, message)
|
|
67
|
+
return True
|
|
68
|
+
except Exception:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _verify_secp256k1(public_key: bytes, message: bytes, signature: bytes) -> bool:
|
|
73
|
+
try:
|
|
74
|
+
from cryptography.hazmat.primitives import hashes
|
|
75
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
76
|
+
|
|
77
|
+
pk = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256K1(), public_key)
|
|
78
|
+
pk.verify(signature, message, ec.ECDSA(hashes.SHA256()))
|
|
79
|
+
return True
|
|
80
|
+
except Exception:
|
|
81
|
+
return False
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pico-server-auth
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embeddable auth server module for the pico ecosystem — JWT issuance, wallet login, JWKS endpoint
|
|
5
|
+
Author-email: David Perez Cabrera <dperezcabrera@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dperezcabrera/pico-server-auth
|
|
8
|
+
Project-URL: Documentation, https://dperezcabrera.github.io/pico-server-auth/
|
|
9
|
+
Project-URL: Repository, https://github.com/dperezcabrera/pico-server-auth
|
|
10
|
+
Project-URL: Changelog, https://github.com/dperezcabrera/pico-server-auth/blob/main/CHANGELOG.md
|
|
11
|
+
Project-URL: Issue Tracker, https://github.com/dperezcabrera/pico-server-auth/issues
|
|
12
|
+
Keywords: auth,jwt,wallet,challenge-response,jwks,ml-dsa-65,ed25519,secp256k1,post-quantum,pico-ioc,pico-boot,dependency-injection
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
23
|
+
Classifier: Operating System :: OS Independent
|
|
24
|
+
Requires-Python: >=3.11
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: pico-ioc[yaml]>=2.2.0
|
|
28
|
+
Requires-Dist: pico-boot>=0.1.0
|
|
29
|
+
Requires-Dist: pico-fastapi>=0.1.0
|
|
30
|
+
Requires-Dist: python-jose[cryptography]>=3.3.0
|
|
31
|
+
Requires-Dist: cryptography>=42.0.0
|
|
32
|
+
Requires-Dist: pydantic>=2.0.0
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == "dev"
|
|
36
|
+
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.9.0; extra == "dev"
|
|
38
|
+
Requires-Dist: httpx; extra == "dev"
|
|
39
|
+
Dynamic: license-file
|
|
40
|
+
|
|
41
|
+
# pico-server-auth
|
|
42
|
+
|
|
43
|
+
[](https://pypi.org/project/pico-server-auth/)
|
|
44
|
+
[](https://deepwiki.com/dperezcabrera/pico-server-auth)
|
|
45
|
+
[](https://opensource.org/licenses/MIT)
|
|
46
|
+

|
|
47
|
+
[](https://codecov.io/gh/dperezcabrera/pico-server-auth)
|
|
48
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-server-auth)
|
|
49
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-server-auth)
|
|
50
|
+
[](https://sonarcloud.io/summary/new_code?id=dperezcabrera_pico-server-auth)
|
|
51
|
+
[](https://pepy.tech/projects/pico-server-auth)
|
|
52
|
+
[](https://dperezcabrera.github.io/pico-server-auth/)
|
|
53
|
+
[](https://dperezcabrera.github.io/pico-learn/)
|
|
54
|
+
|
|
55
|
+
Embeddable auth server module for the [pico-boot](https://github.com/dperezcabrera/pico-boot) ecosystem.
|
|
56
|
+
|
|
57
|
+
Issues JWT tokens, handles wallet challenge-response login, and exposes JWKS — all compatible with [pico-client-auth](https://github.com/dperezcabrera/pico-client-auth) validation.
|
|
58
|
+
|
|
59
|
+
## Two deployment modes
|
|
60
|
+
|
|
61
|
+
**Embedded** — add to any pico-boot app, auth runs in the same process:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
container = init(modules=["myapp", "pico_server_auth"], config=config)
|
|
65
|
+
# /auth/jwks, /auth/challenge, /auth/wallet, /auth/login — all available
|
|
66
|
+
# pico-client-auth validates tokens from the same JWKS
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Standalone** — deploy as a separate auth service (like pico-auth):
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
container = init(modules=["pico_server_auth"], config=config)
|
|
73
|
+
app = container.get(FastAPI)
|
|
74
|
+
# Other services point pico-client-auth JWKS to this service's /auth/jwks
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Endpoints
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
GET /auth/jwks JWKS public keys (pico-client-auth fetches this)
|
|
81
|
+
POST /auth/challenge Request nonce for wallet login
|
|
82
|
+
POST /auth/wallet Verify wallet signature, issue JWT
|
|
83
|
+
POST /auth/login Password login (admin bootstrap)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Wallet login flow
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
Client pico-server-auth
|
|
90
|
+
│ │
|
|
91
|
+
│ POST /auth/challenge │
|
|
92
|
+
│ { address: "0x..." } │
|
|
93
|
+
│───────────────────────────>│
|
|
94
|
+
│ { challenge: "<nonce>" } │
|
|
95
|
+
│<───────────────────────────│
|
|
96
|
+
│ │
|
|
97
|
+
│ sign(nonce) with wallet │
|
|
98
|
+
│ │
|
|
99
|
+
│ POST /auth/wallet │
|
|
100
|
+
│ { address, public_key, │
|
|
101
|
+
│ signature, challenge, │
|
|
102
|
+
│ algorithm: "ML-DSA-65" } │
|
|
103
|
+
│───────────────────────────>│
|
|
104
|
+
│ { access_token, address } │
|
|
105
|
+
│<───────────────────────────│
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Supported wallet algorithms
|
|
109
|
+
|
|
110
|
+
| Algorithm | Type | Library |
|
|
111
|
+
|-----------|------|---------|
|
|
112
|
+
| ML-DSA-65 | Post-quantum lattice (FIPS 204) | `cryptography` |
|
|
113
|
+
| Ed25519 | Edwards curve | `cryptography` |
|
|
114
|
+
| secp256k1 | Elliptic curve (ECDSA) | `cryptography` |
|
|
115
|
+
|
|
116
|
+
## Compatibility with pico-client-auth
|
|
117
|
+
|
|
118
|
+
Tokens issued by pico-server-auth are standard JWT (RS256). pico-client-auth validates them by fetching JWKS from the `/auth/jwks` endpoint.
|
|
119
|
+
|
|
120
|
+
**Same process**: pico-client-auth discovers the JWKS endpoint automatically (same FastAPI app).
|
|
121
|
+
|
|
122
|
+
**Separate processes**: configure pico-client-auth to point to the server:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
auth_client:
|
|
126
|
+
issuer: "http://auth-server:8100"
|
|
127
|
+
audience: "pico"
|
|
128
|
+
# JWKS fetched from http://auth-server:8100/auth/jwks
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Challenge store
|
|
132
|
+
|
|
133
|
+
By default, challenges are stored in memory with TTL expiry. For multi-instance deployments, register a custom `ChallengeStore` component:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
@component
|
|
137
|
+
class RedisChallengeStore:
|
|
138
|
+
async def create(self, address: str) -> str: ...
|
|
139
|
+
async def validate(self, address: str, nonce: str) -> bool: ...
|
|
140
|
+
async def cleanup(self) -> int: ...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The in-memory default is replaced automatically via `on_missing_selector`.
|
|
144
|
+
|
|
145
|
+
## Configuration
|
|
146
|
+
|
|
147
|
+
```yaml
|
|
148
|
+
server_auth:
|
|
149
|
+
issuer: "http://localhost:8100"
|
|
150
|
+
audience: "pico"
|
|
151
|
+
algorithm: "RS256"
|
|
152
|
+
access_token_expire_minutes: 15
|
|
153
|
+
challenge_ttl_seconds: 60
|
|
154
|
+
supported_wallet_algorithms:
|
|
155
|
+
- "ML-DSA-65"
|
|
156
|
+
- "Ed25519"
|
|
157
|
+
- "secp256k1"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Stack
|
|
161
|
+
|
|
162
|
+
- [pico-ioc](https://github.com/dperezcabrera/pico-ioc) — dependency injection
|
|
163
|
+
- [pico-boot](https://github.com/dperezcabrera/pico-boot) — auto-discovery
|
|
164
|
+
- [pico-fastapi](https://github.com/dperezcabrera/pico-fastapi) — controllers
|
|
165
|
+
- [pico-client-auth](https://github.com/dperezcabrera/pico-client-auth) — token validation
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pico_server_auth/__init__.py,sha256=nxqUtnZj5BU61JTqapLnMYcqQ0mgbMyONiFWvakAarE,689
|
|
2
|
+
pico_server_auth/_version.py,sha256=IMjkMO3twhQzluVTo8Z6rE7Eg-9U79_LGKMcsWLKBkY,22
|
|
3
|
+
pico_server_auth/challenge_store.py,sha256=2bCFn82UrRmQDfrtxOUa0jXBxcAhTy_1rVg0oohjTh4,1771
|
|
4
|
+
pico_server_auth/config.py,sha256=5Qw2hijL3bAeWkwJBMQ062GzEp6aehgBz56DGc_tLw4,834
|
|
5
|
+
pico_server_auth/controllers.py,sha256=tRnDSbmfmaAxzezXMC6v4kaEpYDCLGhbKEQLYG_l4CQ,4692
|
|
6
|
+
pico_server_auth/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
pico_server_auth/token_issuer.py,sha256=LdcvUmvnEOorqu232Hukuz3-_KciajwwxNL62IKojuM,3540
|
|
8
|
+
pico_server_auth/wallet_verifier.py,sha256=4Hgp-eynA6AS3-txaZaH9sRFGg533uh9HMIR3R-OtOw,3025
|
|
9
|
+
pico_server_auth-0.1.0.dist-info/licenses/LICENSE,sha256=N1_nOvHTM6BobYnOTNXiQkroDqCEi6EzfGBv8lWtyZ0,1077
|
|
10
|
+
pico_server_auth-0.1.0.dist-info/METADATA,sha256=RQffrhF89tPNKiKdUw_k029AEZMAIfI6cXW38F6NV20,7461
|
|
11
|
+
pico_server_auth-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
12
|
+
pico_server_auth-0.1.0.dist-info/entry_points.txt,sha256=7KkDhxu1ehMGvMXuCalYAZhjQ5VBT8GyMrZQNZAshzE,56
|
|
13
|
+
pico_server_auth-0.1.0.dist-info/top_level.txt,sha256=gSTx--rTRb0UA4YxYvCaraOTIHAuTN8OeSobhj7jgDE,17
|
|
14
|
+
pico_server_auth-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 David Pérez Cabrera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pico_server_auth
|