gabriel / musehub public
auth.py python
280 lines 9.6 KB
Raw
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