auth.py
python
sha256:5601f81903b6c70ddd11bd88a5a257ee6dfd38aa3b85b19746c100c030657f1e
chore: update smoke_muse.sh comment to reference rc9
Sonnet 4.6
minor
⚠ breaking
21 days ago
| 1 | """Public-key challenge-response authentication endpoints. |
| 2 | |
| 3 | All endpoints are unauthenticated — they ARE the authentication mechanism. |
| 4 | Agent provisioning lives at /api/identities/agent (identities.py). |
| 5 | |
| 6 | Routes |
| 7 | ------ |
| 8 | POST /api/auth/challenge |
| 9 | Given a fingerprint + algorithm, returns a nonce to sign. |
| 10 | Rate-limited: 20/minute per IP. |
| 11 | |
| 12 | POST /api/auth/verify |
| 13 | Submits the signed nonce. Creates identity on first use. |
| 14 | Returns identity metadata (no token — use MSign for all requests). |
| 15 | Rate-limited: 20/minute per IP. |
| 16 | |
| 17 | POST /api/auth/keys |
| 18 | Add a new key to an existing identity (key rotation). |
| 19 | Requires ``Authorization: MSign`` from an existing key (proves account ownership). |
| 20 | The body carries a challenge-response proof for the new key (proves key ownership). |
| 21 | |
| 22 | GET /api/auth/keys/{handle} |
| 23 | List registered public keys for an identity (owner only). |
| 24 | |
| 25 | DELETE /api/auth/keys/{handle}/{key_id} |
| 26 | Revoke a specific key (owner only). |
| 27 | """ |
| 28 | |
| 29 | import json |
| 30 | import logging |
| 31 | |
| 32 | from fastapi import APIRouter, Depends, HTTPException, Request, status |
| 33 | from fastapi.responses import Response |
| 34 | from sqlalchemy import select |
| 35 | from sqlalchemy.ext.asyncio import AsyncSession |
| 36 | |
| 37 | from musehub.auth.dependencies import require_valid_token, TokenClaims |
| 38 | from musehub.db.database import get_db as get_session |
| 39 | from musehub.db.musehub_auth_models import MusehubAuthKey |
| 40 | from musehub.db.musehub_identity_models import MusehubIdentity |
| 41 | from musehub.models.musehub_auth import ChallengeRequest, ChallengeResponse, RotateKeyRequest, VerifyRequest |
| 42 | from musehub.auth.failure_limiter import check_failure_limit, record_failure, record_success |
| 43 | from musehub.rate_limits import limiter, AUTH_LIMIT |
| 44 | from musehub.services.musehub_auth import ( |
| 45 | AuthError, |
| 46 | CHALLENGE_TTL_SECONDS, |
| 47 | add_key_for_identity, |
| 48 | create_challenge, |
| 49 | get_keys_for_identity, |
| 50 | revoke_key, |
| 51 | verify_and_authenticate, |
| 52 | ) |
| 53 | |
| 54 | logger = logging.getLogger(__name__) |
| 55 | |
| 56 | router = APIRouter(tags=["auth"]) |
| 57 | |
| 58 | # --------------------------------------------------------------------------- |
| 59 | # Step 1: Issue challenge |
| 60 | # --------------------------------------------------------------------------- |
| 61 | |
| 62 | @router.post( |
| 63 | "/api/auth/challenge", |
| 64 | summary="Request a public-key authentication challenge", |
| 65 | status_code=status.HTTP_200_OK, |
| 66 | ) |
| 67 | @limiter.limit(AUTH_LIMIT) |
| 68 | async def challenge( |
| 69 | request: Request, |
| 70 | body: ChallengeRequest, |
| 71 | session: AsyncSession = Depends(get_session), |
| 72 | ) -> Response: |
| 73 | """Return a nonce for the given public key fingerprint. |
| 74 | |
| 75 | The client must: |
| 76 | 1. Sign ``bytes.fromhex(challenge_token)`` with its private key. |
| 77 | 2. Submit the result to ``POST /api/auth/verify``. |
| 78 | |
| 79 | ``is_new_key`` is ``true`` when the fingerprint is not yet registered — |
| 80 | the client must then include ``handle`` in the verify call. |
| 81 | |
| 82 | Rate-limited to ``{limit}`` per IP. |
| 83 | """.format(limit=AUTH_LIMIT) |
| 84 | key_row: MusehubAuthKey | None = ( |
| 85 | await session.execute( |
| 86 | select(MusehubAuthKey).where(MusehubAuthKey.fingerprint == body.fingerprint) |
| 87 | ) |
| 88 | ).scalar_one_or_none() |
| 89 | |
| 90 | is_new_key = key_row is None |
| 91 | nonce_hex = await create_challenge(session, body.fingerprint, body.algorithm) |
| 92 | |
| 93 | resp = ChallengeResponse( |
| 94 | challenge_token=nonce_hex, |
| 95 | is_new_key=is_new_key, |
| 96 | expires_in=CHALLENGE_TTL_SECONDS, |
| 97 | algorithm=body.algorithm, |
| 98 | ) |
| 99 | return Response( |
| 100 | content=resp.model_dump_json(), |
| 101 | media_type="application/json", |
| 102 | ) |
| 103 | |
| 104 | # --------------------------------------------------------------------------- |
| 105 | # Step 2: Verify and authenticate |
| 106 | # --------------------------------------------------------------------------- |
| 107 | |
| 108 | @router.post( |
| 109 | "/api/auth/verify", |
| 110 | summary="Verify a signed challenge and register/confirm your key", |
| 111 | status_code=status.HTTP_200_OK, |
| 112 | ) |
| 113 | @limiter.limit(AUTH_LIMIT) |
| 114 | async def verify( |
| 115 | request: Request, |
| 116 | body: VerifyRequest, |
| 117 | session: AsyncSession = Depends(get_session), |
| 118 | ) -> Response: |
| 119 | """Complete authentication and confirm key registration. |
| 120 | |
| 121 | On the first call for a given public key (``is_new_key`` was ``true``): |
| 122 | - ``handle`` is required, globally unique, and normalized to lowercase. |
| 123 | - A new identity is created automatically. |
| 124 | |
| 125 | On subsequent calls (login confirmation): |
| 126 | - ``handle`` is ignored. |
| 127 | - The existing identity is returned. |
| 128 | |
| 129 | No token is returned. Use MSign (``Authorization: MSign handle="..." ts=... sig="..."``) |
| 130 | to authenticate all subsequent requests. |
| 131 | |
| 132 | Rate-limited to ``{limit}`` per IP. |
| 133 | """.format(limit=AUTH_LIMIT) |
| 134 | ip = request.client.host if request.client else "unknown" |
| 135 | check_failure_limit(ip) |
| 136 | |
| 137 | try: |
| 138 | result = await verify_and_authenticate( |
| 139 | session=session, |
| 140 | challenge_token=body.challenge_token, |
| 141 | public_key_b64=body.public_key_b64, |
| 142 | signature_b64=body.signature_b64, |
| 143 | handle=body.handle, |
| 144 | display_name=body.display_name, |
| 145 | label=body.label, |
| 146 | identity_type=body.identity_type, |
| 147 | ) |
| 148 | except AuthError as exc: |
| 149 | record_failure(ip) |
| 150 | raise HTTPException(status_code=exc.status_code, detail=exc.detail) |
| 151 | |
| 152 | record_success(ip) |
| 153 | |
| 154 | return Response( |
| 155 | content=result.model_dump_json(), |
| 156 | media_type="application/json", |
| 157 | ) |
| 158 | |
| 159 | # --------------------------------------------------------------------------- |
| 160 | # Key rotation — add a new key to an existing identity |
| 161 | # --------------------------------------------------------------------------- |
| 162 | |
| 163 | @router.post( |
| 164 | "/api/auth/keys", |
| 165 | summary="Add a new key to an existing identity (key rotation)", |
| 166 | status_code=status.HTTP_201_CREATED, |
| 167 | ) |
| 168 | async def add_key( |
| 169 | body: RotateKeyRequest, |
| 170 | claims: TokenClaims = Depends(require_valid_token), |
| 171 | session: AsyncSession = Depends(get_session), |
| 172 | ) -> Response: |
| 173 | """Add a new public key to the caller's existing identity. |
| 174 | |
| 175 | This is the correct endpoint for key rotation. Two proofs are required: |
| 176 | |
| 177 | 1. **``Authorization: MSign``** header signed with an **existing** key |
| 178 | (``require_valid_token`` verifies this and extracts ``claims.identity_id``). |
| 179 | 2. **Challenge-response** for the **new** key in the request body — the |
| 180 | ``challenge_token`` must have been issued for the new key's fingerprint |
| 181 | (``POST /api/auth/challenge``) and the ``signature_b64`` must be the |
| 182 | new private key's signature over ``bytes.fromhex(challenge_token)``. |
| 183 | |
| 184 | Both proofs together guarantee: |
| 185 | - The caller controls the existing account (MSign proof). |
| 186 | - The caller controls the new private key (challenge-response proof). |
| 187 | """ |
| 188 | try: |
| 189 | key_resp = await add_key_for_identity( |
| 190 | session=session, |
| 191 | identity_id=claims.identity_id, |
| 192 | challenge_token=body.challenge_token, |
| 193 | public_key_b64=body.public_key_b64, |
| 194 | signature_b64=body.signature_b64, |
| 195 | label=body.label, |
| 196 | ) |
| 197 | except AuthError as exc: |
| 198 | raise HTTPException(status_code=exc.status_code, detail=exc.detail) |
| 199 | |
| 200 | logger.info( |
| 201 | "🔑 Key rotation: identity_id=%s new_fp=%s", |
| 202 | claims.identity_id, |
| 203 | key_resp.fingerprint[:16], |
| 204 | ) |
| 205 | return Response( |
| 206 | content=key_resp.model_dump_json(), |
| 207 | media_type="application/json", |
| 208 | status_code=status.HTTP_201_CREATED, |
| 209 | ) |
| 210 | |
| 211 | # --------------------------------------------------------------------------- |
| 212 | # Key management (owner only) |
| 213 | # --------------------------------------------------------------------------- |
| 214 | |
| 215 | @router.get( |
| 216 | "/api/auth/keys/{handle}", |
| 217 | summary="List registered public keys for an identity", |
| 218 | ) |
| 219 | async def list_keys( |
| 220 | handle: str, |
| 221 | claims: TokenClaims = Depends(require_valid_token), |
| 222 | session: AsyncSession = Depends(get_session), |
| 223 | ) -> Response: |
| 224 | """Return public summaries of all keys registered to ``handle``. |
| 225 | |
| 226 | Requires the caller to be the owner of the handle. |
| 227 | Never returns raw key material — only the fingerprint, algorithm, and label. |
| 228 | """ |
| 229 | if claims.handle != handle: |
| 230 | raise HTTPException(status_code=403, detail="Forbidden") |
| 231 | |
| 232 | identity: MusehubIdentity | None = ( |
| 233 | await session.execute( |
| 234 | select(MusehubIdentity).where(MusehubIdentity.handle == handle) |
| 235 | ) |
| 236 | ).scalar_one_or_none() |
| 237 | |
| 238 | if identity is None: |
| 239 | raise HTTPException(status_code=404, detail=f"Identity '{handle}' not found") |
| 240 | |
| 241 | keys = await get_keys_for_identity(session, identity.identity_id) |
| 242 | return Response( |
| 243 | content=json.dumps({"keys": [k.model_dump() for k in keys]}), |
| 244 | media_type="application/json", |
| 245 | ) |
| 246 | |
| 247 | @router.delete( |
| 248 | "/api/auth/keys/{handle}/{key_id}", |
| 249 | summary="Revoke a registered public key", |
| 250 | status_code=status.HTTP_204_NO_CONTENT, |
| 251 | ) |
| 252 | async def delete_key( |
| 253 | handle: str, |
| 254 | key_id: str, |
| 255 | claims: TokenClaims = Depends(require_valid_token), |
| 256 | session: AsyncSession = Depends(get_session), |
| 257 | ) -> Response: |
| 258 | """Revoke a specific key by its ``key_id``. |
| 259 | |
| 260 | Revoking your only key does not delete your identity, but you will need |
| 261 | to re-register with a new key. Requires the caller to own the handle. |
| 262 | """ |
| 263 | if claims.handle != handle: |
| 264 | raise HTTPException(status_code=403, detail="Forbidden") |
| 265 | |
| 266 | identity: MusehubIdentity | None = ( |
| 267 | await session.execute( |
| 268 | select(MusehubIdentity).where(MusehubIdentity.handle == handle) |
| 269 | ) |
| 270 | ).scalar_one_or_none() |
| 271 | |
| 272 | if identity is None: |
| 273 | raise HTTPException(status_code=404, detail=f"Identity '{handle}' not found") |
| 274 | |
| 275 | deleted = await revoke_key(session, identity.identity_id, key_id) |
| 276 | if not deleted: |
| 277 | raise HTTPException(status_code=404, detail="Key not found") |
| 278 | |
| 279 | logger.info("🔑 Key revoked: key_id=%s handle=%s", key_id, handle) |
| 280 | return Response(status_code=status.HTTP_204_NO_CONTENT) |
File History
2 commits
sha256:5601f81903b6c70ddd11bd88a5a257ee6dfd38aa3b85b19746c100c030657f1e
chore: update smoke_muse.sh comment to reference rc9
Sonnet 4.6
minor
⚠
21 days ago
sha256:39e9c4e6f2134da0732e6983268a218178973936f8d7ca03c91f2b5ad42133c8
fix: use read_object_bytes in blob viewer; add zstd magic d…
Sonnet 4.6
patch
21 days ago