Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ The login response returns:
- `jwt_access_token` / `jwt_refresh_token` — pass `Authorization: Bearer <jwt_access_token>` for normal API auth.

Browser requests to `/auth/nonce` and `/auth/login` are **origin-checked** — they must come from a configured Flexvaults SIWE origin. Non-browser clients (no `Origin` header) are accepted.
Backends that only receive a JWT from hosted auth can exchange that JWT for a private-read token with `POST /auth/jwt/siwe-token`.

### 2. OAuth-style cross-domain (third-party apps)

Expand Down Expand Up @@ -84,6 +85,7 @@ POST /auth/token {grant_type=authorization_code, code, code_verifier, …}
| `GET /funds/locked/total/{token_id}` | required | same |
| `GET /funds/expired` | required | same |
| `GET /history` | required | same |
| `POST /auth/jwt/siwe-token` | required | `Authorization: Bearer …` |
| `POST /auth/jwt/logout`, `GET /auth/jwt/me` | required | `Authorization: Bearer …` |
| Everything else | none (signature-gated where applicable) | — |

Expand Down Expand Up @@ -206,6 +208,7 @@ A `ContractLogicError` from Sapphire on any of these is mapped to `401 Invalid o
| `GET /auth/nonce?address=…` | Single-use SIWE nonce. Browser-origin-checked. Rate-limited. |
| `POST /auth/login` | SIWE login → `siwe_token` + JWT pair. Browser-origin-checked. Rate-limited. |
| `POST /auth/jwt/refresh` | Rotate the refresh token; returns a fresh access/refresh pair. |
| `POST /auth/jwt/siwe-token` (Bearer) | Exchange a JWT access token for an encrypted SIWE token for on-chain private reads. |
| `POST /auth/jwt/logout` (Bearer) | Revoke one or all refresh tokens for the current user. |
| `GET /auth/jwt/jwks.json` | JWKS document for verifying issued JWTs. |
| `GET /auth/jwt/me` (Bearer) | Returns the authenticated address. Useful for client-side identity checks. |
Expand Down
54 changes: 54 additions & 0 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,33 @@
"title": "JWKSResponse",
"type": "object"
},
"JwtSiweTokenResponse": {
"description": "Response from exchanging a JWT access token for a private-read SIWE token.",
"properties": {
"address": {
"description": "Authenticated Ethereum address",
"title": "Address",
"type": "string"
},
"expires_in": {
"description": "SIWE token expiry in seconds",
"title": "Expires In",
"type": "integer"
},
"siwe_token": {
"description": "Encrypted SIWE token for on-chain private reads",
"title": "Siwe Token",
"type": "string"
}
},
"required": [
"siwe_token",
"address",
"expires_in"
],
"title": "JwtSiweTokenResponse",
"type": "object"
},
"LockFundsRequest": {
"description": "Payload for locking user funds for a service.",
"properties": {
Expand Down Expand Up @@ -1906,6 +1933,33 @@
]
}
},
"/v1/accounting/auth/jwt/siwe-token": {
"post": {
"description": "Mint a private-read SIWE token for the authenticated JWT subject.",
"operationId": "exchange_jwt_for_siwe_token_v1_accounting_auth_jwt_siwe_token_post",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JwtSiweTokenResponse"
}
}
},
"description": "Successful Response"
}
},
"security": [
{
"HTTPBearer": []
}
],
"summary": "Exchange Jwt For Siwe Token",
"tags": [
"Accounting"
]
}
},
"/v1/accounting/auth/login": {
"post": {
"description": "Perform SIWE login, mint a Sapphire AuthToken, and issue JWTs.",
Expand Down
23 changes: 22 additions & 1 deletion src/api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
from web3.exceptions import ContractLogicError

from src.auth.auth_token_service import get_auth_token_service
from src.auth.dependencies import get_current_user, get_current_user_optional
from src.auth.dependencies import (
get_current_user,
get_current_user_optional,
get_current_user_without_siwe_token,
)
from src.auth.http import auth_exception, enforce_expected_origin, no_store_headers
from src.auth.jwt_service import get_jwt_service
from src.auth.rate_limiter import get_auth_rate_limiter, request_identity
Expand All @@ -33,6 +37,7 @@
DepositCheckResponse,
ExpiredLocksResponse,
HistoryResponse,
JwtSiweTokenResponse,
LockedFundsResponse,
LockFundsRequest,
LockNonceResponse,
Expand Down Expand Up @@ -825,6 +830,22 @@ class MeResponse(BaseModel):
address: str = Field(..., description="Authenticated Ethereum address")


@router.post("/auth/jwt/siwe-token", response_model=JwtSiweTokenResponse)
async def exchange_jwt_for_siwe_token(
response: Response,
current_user: str = Depends(get_current_user_without_siwe_token),
) -> JwtSiweTokenResponse:
"""Mint a private-read SIWE token for the authenticated JWT subject."""
settings = load_settings()
siwe_token = _mint_private_read_token(current_user)
response.headers.update(no_store_headers())
return JwtSiweTokenResponse(
siwe_token="0x" + siwe_token.hex(),
address=current_user,
expires_in=settings.auth_token_validity_seconds,
Comment thread
uniyalabhishek marked this conversation as resolved.
Outdated
)


@router.post("/auth/jwt/refresh", response_model=RefreshResponse)
async def refresh(payload: RefreshRequest, response: Response) -> RefreshResponse:
"""Rotate a refresh token and issue fresh access and refresh tokens."""
Expand Down
14 changes: 13 additions & 1 deletion src/auth/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from typing import Optional

import jwt
from fastapi import Depends, HTTPException
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer

from src.auth.jwt_service import get_jwt_service
Expand Down Expand Up @@ -62,6 +62,18 @@ def get_current_user(
)


def get_current_user_without_siwe_token(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
) -> str:
if request.headers.get("Authorization") and request.headers.get("X-SIWE-Token"):
raise HTTPException(
status_code=400,
detail="Provide Authorization bearer token only; do not send X-SIWE-Token",
)
return get_current_user(credentials)


def get_current_user_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(_bearer_scheme),
) -> Optional[str]:
Expand Down
8 changes: 8 additions & 0 deletions src/models/accounting.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,14 @@ class SiweLoginResponse(BaseModel):
jwt_refresh_expires_in: int = Field(..., description="Refresh token expiry in seconds")


class JwtSiweTokenResponse(BaseModel):
"""Response from exchanging a JWT access token for a private-read SIWE token."""

siwe_token: str = Field(..., description="Encrypted SIWE token for on-chain private reads")
address: str = Field(..., description="Authenticated Ethereum address")
expires_in: int = Field(..., description="SIWE token expiry in seconds")


class SiweDomainResponse(BaseModel):
"""Response containing the SIWE domain configured in the contract."""

Expand Down
96 changes: 96 additions & 0 deletions test/py/test_redirect_auth_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,102 @@ def test_login_returns_siwe_token_and_jwts(client, monkeypatch):
assert jwt_api.verify_refresh_token(body["jwt_refresh_token"]) == TEST_ADDRESS


def test_jwt_siwe_token_exchange_mints_private_read_token(client, monkeypatch):
minted_token = b"\xde\xad"
mint_private_read_token = MagicMock(return_value=minted_token)
monkeypatch.setattr(routes, "_mint_private_read_token", mint_private_read_token)

access_token = jwt_service.get_jwt_service().create_token(TEST_ADDRESS)
response = client.post(
"/v1/accounting/auth/jwt/siwe-token",
headers={"Authorization": f"Bearer {access_token}"},
)

assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.json() == {
"siwe_token": "0xdead",
"address": TEST_ADDRESS,
"expires_in": 600,
}
mint_private_read_token.assert_called_once_with(TEST_ADDRESS)


def test_jwt_siwe_token_exchange_requires_bearer_jwt(client):
response = client.post("/v1/accounting/auth/jwt/siwe-token")

assert response.status_code == 401
assert response.json()["detail"] == "Missing or invalid Authorization header"


def test_jwt_siwe_token_exchange_rejects_refresh_token(client, monkeypatch):
mint_private_read_token = MagicMock()
monkeypatch.setattr(routes, "_mint_private_read_token", mint_private_read_token)

refresh_token = jwt_service.get_jwt_service().create_refresh_token(TEST_ADDRESS)
response = client.post(
"/v1/accounting/auth/jwt/siwe-token",
headers={"Authorization": f"Bearer {refresh_token}"},
)

assert response.status_code == 401
assert response.json()["detail"] == "Expected access token"
mint_private_read_token.assert_not_called()


def test_jwt_siwe_token_exchange_rejects_mixed_auth_headers(client, monkeypatch):
mint_private_read_token = MagicMock()
monkeypatch.setattr(routes, "_mint_private_read_token", mint_private_read_token)

access_token = jwt_service.get_jwt_service().create_token(TEST_ADDRESS)
response = client.post(
"/v1/accounting/auth/jwt/siwe-token",
headers={
"Authorization": f"Bearer {access_token}",
"X-SIWE-Token": "0x1234",
},
)

assert response.status_code == 400
assert response.json()["detail"] == (
"Provide Authorization bearer token only; do not send X-SIWE-Token"
)
mint_private_read_token.assert_not_called()


def test_jwt_siwe_token_exchange_rejects_mixed_auth_before_jwt_validation(client, monkeypatch):
mint_private_read_token = MagicMock()
monkeypatch.setattr(routes, "_mint_private_read_token", mint_private_read_token)

response = client.post(
"/v1/accounting/auth/jwt/siwe-token",
headers={
"Authorization": "Bearer not-a-jwt",
"X-SIWE-Token": "0x1234",
},
)

assert response.status_code == 400
assert response.json()["detail"] == (
"Provide Authorization bearer token only; do not send X-SIWE-Token"
)
mint_private_read_token.assert_not_called()


def test_jwt_siwe_token_exchange_rejects_siwe_token_without_bearer(client, monkeypatch):
mint_private_read_token = MagicMock()
monkeypatch.setattr(routes, "_mint_private_read_token", mint_private_read_token)

response = client.post(
"/v1/accounting/auth/jwt/siwe-token",
headers={"X-SIWE-Token": "0x1234"},
)

assert response.status_code == 401
assert response.json()["detail"] == "Missing or invalid Authorization header"
mint_private_read_token.assert_not_called()


def test_token_exchange_issues_id_token_with_explicit_client_audience(client, monkeypatch):
verifier = "v" * 43
challenge = _build_pkce_challenge(verifier)
Expand Down
Loading