-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add endpoints to request nonce and message to sign (#10)
* Add Redis configuration * Add get nonce endpoint * Add request message to sign endpoint * Fix README CI badge * Apply suggestion Co-authored-by: Uxío <Uxio0@users.noreply.github.com> * Apply PR suggestions * Fix test --------- Co-authored-by: Uxío <Uxio0@users.noreply.github.com>
- Loading branch information
1 parent
906406b
commit c9d9172
Showing
30 changed files
with
544 additions
and
75 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
REDIS_URL=redis://redis:6379/0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1 @@ | ||
SECRET_KEY=kamehameha | ||
REDIS_URL=redis://localhost:6379/0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
REDIS_URL=redis://localhost:6379/0 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
VERSION = "0.0.0" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
from functools import cache | ||
|
||
from redis import Redis | ||
|
||
from .config import settings | ||
|
||
|
||
@cache | ||
def get_redis() -> Redis: | ||
return Redis.from_url(settings.REDIS_URL) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
""" | ||
Base settings file for FastApi application. | ||
""" | ||
|
||
import os | ||
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict | ||
|
||
|
||
class Settings(BaseSettings): | ||
model_config = SettingsConfigDict( | ||
env_file=os.environ.get("ENV_FILE", ".env"), | ||
env_file_encoding="utf-8", | ||
extra="allow", | ||
case_sensitive=True, | ||
) | ||
REDIS_URL: str = "redis://" | ||
NONCE_TTL_SECONDS: int = 60 * 10 | ||
DEFAULT_SIWE_MESSAGE_STATEMENT: str = ( | ||
"Welcome to Safe! I accept the Terms of Use: https://safe.global/terms." | ||
) | ||
|
||
|
||
settings = Settings() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,55 +1,21 @@ | ||
from typing import Any, Literal | ||
|
||
from fastapi import FastAPI | ||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html | ||
from fastapi.responses import RedirectResponse | ||
from fastapi import APIRouter, FastAPI | ||
from fastapi.staticfiles import StaticFiles | ||
from pydantic import BaseModel | ||
|
||
from .version import VERSION | ||
from . import VERSION | ||
from .routers import about, auth, default | ||
|
||
app = FastAPI( | ||
title="Safe Auth Service", | ||
description="API to grant JWT tokens for using across the Safe Core{API} infrastructure.", | ||
version=VERSION, | ||
docs_url=None, | ||
redoc_url=None, | ||
) | ||
app.mount("/static", StaticFiles(directory="static"), name="static") | ||
|
||
|
||
class About(BaseModel): | ||
version: str | ||
|
||
|
||
@app.get("/docs", include_in_schema=False) | ||
async def swagger_ui_html(): | ||
return get_swagger_ui_html( | ||
openapi_url=str(app.openapi_url), | ||
title=app.title + " - Swagger UI", | ||
swagger_favicon_url="/static/favicon.ico", | ||
) | ||
|
||
|
||
@app.get("/redoc", include_in_schema=False) | ||
async def redoc_html(): | ||
return get_redoc_html( | ||
openapi_url=str(app.openapi_url), | ||
title=app.title + " - ReDoc", | ||
redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js", | ||
redoc_favicon_url="/static/favicon.ico", | ||
) | ||
|
||
|
||
@app.get("/", include_in_schema=False) | ||
async def home() -> RedirectResponse: | ||
return RedirectResponse(url="/docs") | ||
|
||
|
||
@app.get("/v1/about", response_model=About) | ||
async def about() -> Any: | ||
return {"version": VERSION} | ||
|
||
|
||
@app.get("/health") | ||
async def health() -> Literal["OK"]: | ||
return "OK" | ||
# Router configuration | ||
api_v1_router = APIRouter( | ||
prefix="/api/v1", | ||
) | ||
api_v1_router.include_router(about.router) | ||
api_v1_router.include_router(auth.router) | ||
app.include_router(api_v1_router) | ||
app.include_router(default.router) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
from typing import Optional | ||
|
||
from pydantic import AnyUrl, BaseModel, Field, field_validator | ||
|
||
from gnosis.eth.utils import fast_is_checksum_address | ||
|
||
|
||
class About(BaseModel): | ||
version: str | ||
|
||
|
||
class Nonce(BaseModel): | ||
nonce: str = Field(min_length=8, pattern=r"^[A-Za-z0-9]{8,}$") | ||
|
||
|
||
class SiweMessageRequest(BaseModel): | ||
domain: str = Field(pattern="^[^/?#]+$", examples=["domain.com"]) | ||
address: str | ||
chain_id: int | ||
uri: AnyUrl | ||
statement: Optional[str] = Field(default=None) | ||
|
||
@field_validator("address") | ||
def validate_address(cls, value): | ||
if not fast_is_checksum_address(value): | ||
raise ValueError("Invalid Ethereum address") | ||
return value | ||
|
||
|
||
class SiweMessage(BaseModel): | ||
message: str |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
from fastapi import APIRouter | ||
|
||
from .. import VERSION | ||
from ..models import About | ||
|
||
router = APIRouter( | ||
prefix="/about", | ||
tags=["About"], | ||
) | ||
|
||
|
||
@router.get("", response_model=About) | ||
async def about() -> "About": | ||
return About(version=VERSION) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
from fastapi import APIRouter | ||
|
||
from ..models import Nonce, SiweMessage, SiweMessageRequest | ||
from ..services.message_service import create_siwe_message | ||
from ..services.nonce_service import generate_nonce | ||
|
||
router = APIRouter( | ||
prefix="/auth", | ||
tags=["Authentication"], | ||
) | ||
|
||
|
||
@router.get("/nonce", response_model=Nonce) | ||
async def get_nonce() -> "Nonce": | ||
return Nonce(nonce=generate_nonce()) | ||
|
||
|
||
@router.post("/messages", response_model=SiweMessage) | ||
async def request_siwe_message( | ||
siwe_message_request: SiweMessageRequest, | ||
) -> "SiweMessage": | ||
siwe_message = create_siwe_message( | ||
domain=siwe_message_request.domain, | ||
address=siwe_message_request.address, | ||
chain_id=siwe_message_request.chain_id, | ||
uri=str(siwe_message_request.uri), | ||
statement=siwe_message_request.statement, | ||
) | ||
return SiweMessage(message=siwe_message) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
from typing import Literal | ||
|
||
from fastapi import APIRouter | ||
from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html | ||
from fastapi.responses import RedirectResponse | ||
|
||
router = APIRouter() | ||
|
||
|
||
@router.get("/docs", include_in_schema=False) | ||
async def swagger_ui_html(): | ||
return get_swagger_ui_html( | ||
openapi_url="/openapi.json", | ||
title="Safe Auth Service - Swagger UI", | ||
swagger_favicon_url="/static/favicon.ico", | ||
) | ||
|
||
|
||
@router.get("/redoc", include_in_schema=False) | ||
async def redoc_html(): | ||
return get_redoc_html( | ||
openapi_url="/openapi.json", | ||
title="Safe Auth Service - ReDoc", | ||
redoc_js_url="https://unpkg.com/redoc@next/bundles/redoc.standalone.js", | ||
redoc_favicon_url="/static/favicon.ico", | ||
) | ||
|
||
|
||
@router.get("/health", include_in_schema=False) | ||
async def health() -> Literal["OK"]: | ||
return "OK" | ||
|
||
|
||
@router.get("/", include_in_schema=False) | ||
async def home() -> RedirectResponse: | ||
return RedirectResponse(url="/docs") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
from datetime import UTC, datetime, timedelta | ||
|
||
from siwe.siwe import ISO8601Datetime, SiweMessage, VersionEnum | ||
|
||
from gnosis.eth.utils import fast_to_checksum_address | ||
|
||
from ..config import settings | ||
from .nonce_service import generate_nonce | ||
|
||
|
||
def create_siwe_message( | ||
domain: str, address: str, chain_id: int, uri: str, statement=None | ||
) -> str: | ||
""" | ||
Creates a new Sign-in with Ethereum (EIP-4361) message. | ||
:param domain: The domain that is requesting the signing. Its value MUST be an RFC 3986 authority. | ||
:param address: The Ethereum address performing the signing. | ||
:param chain_id: The Chain ID to which the session is bound. | ||
:param uri: An RFC 3986 URI referring to the resource that is the subject of the signing. | ||
:param statement: OPTIONAL. A human-readable assertion to show in the message that the user will sign. | ||
:return: EIP-4361 formatted message, ready for EIP-191 signing. | ||
""" | ||
nonce = generate_nonce() | ||
|
||
message = SiweMessage( | ||
domain=domain, | ||
address=fast_to_checksum_address(address), | ||
statement=statement or settings.DEFAULT_SIWE_MESSAGE_STATEMENT, | ||
uri=uri, | ||
version=VersionEnum.one, | ||
chain_id=chain_id, | ||
nonce=nonce, | ||
issued_at=ISO8601Datetime.from_datetime(datetime.now(UTC)), | ||
valid_until=ISO8601Datetime.from_datetime( | ||
datetime.now(UTC) + timedelta(seconds=settings.NONCE_TTL_SECONDS) | ||
), | ||
) | ||
|
||
return message.prepare_message() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,16 @@ | ||
import siwe | ||
|
||
from ..cache import get_redis | ||
from ..config import settings | ||
|
||
|
||
def generate_nonce() -> str: | ||
""" | ||
Generates a new nonce to be used in the Sign-in with Ethereum process (EIP-4361). | ||
The nonce is cached for future validation. The cache lifetime is configured by NONCE_TTL_SECONDS key. | ||
:return: Alphanumeric random character string of at least 8 characters. | ||
""" | ||
nonce = siwe.generate_nonce() | ||
get_redis().set(nonce, nonce, ex=settings.NONCE_TTL_SECONDS) | ||
return nonce |
This file was deleted.
Oops, something went wrong.
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import unittest | ||
|
||
from fastapi.testclient import TestClient | ||
|
||
from ... import VERSION | ||
from ...main import app | ||
|
||
|
||
class TestRouterAbout(unittest.TestCase): | ||
client: TestClient | ||
|
||
@classmethod | ||
def setUpClass(cls): | ||
cls.client = TestClient(app) | ||
|
||
def test_view_about(self): | ||
response = self.client.get("/api/v1/about") | ||
self.assertEqual(response.status_code, 200) | ||
self.assertEqual(response.json(), {"version": VERSION}) |
Oops, something went wrong.