···11+-- Add revocation support to space credentials (M3).
22+-- A NULL revoked_at means active; a timestamp means the credential (and any
33+-- other credential sharing the row) has been revoked and must be rejected.
44+ALTER TABLE happyview_space_credentials ADD COLUMN revoked_at TEXT;
···11+-- Add revocation support to space credentials (M3).
22+-- A NULL revoked_at means active; a timestamp means the credential (and any
33+-- other credential sharing the row) has been revoked and must be rejected.
44+ALTER TABLE happyview_space_credentials ADD COLUMN revoked_at TEXT;
···197197| `sub` | The full `at://` space URI |
198198| `iat` | Issued at (Unix timestamp) |
199199| `exp` | Expiry (Unix timestamp) |
200200-| `jti` | Random nonce for replay protection |
200200+| `jti` | Random nonce making each issued credential unique |
201201+202202+The credential is a bearer token: it is reused for the full 2-hour TTL and is not
203203+bound to a specific client or audience, so any service holding it can read the
204204+space's records. Containment relies on the short TTL and on revocation.
205205+206206+## Revocation
207207+208208+A credential is invalidated before its TTL expires when its holder is removed
209209+from the space (`com.atproto.simplespace.removeMember`): HappyView records the
210210+revocation and rejects the credential on subsequent reads. Because a credential
211211+is an opaque bearer token, revocation is scoped to a member — removing (and, if
212212+needed, re-adding) a member revokes all of that member's outstanding credentials.
201213202214## Using a credential
203215
···357357/// Like `require_auth`, but also accepts a verified space credential as an
358358/// identity source. Use this in space endpoints that support `Bearer
359359/// <space_credential>` in addition to DPoP auth.
360360+/// Whether a verified space credential has been revoked (e.g. its holder was
361361+/// removed from the space). Consulted after signature/exp verification so a
362362+/// leaked or stale credential can be invalidated before its TTL expires (M3).
363363+async fn space_credential_revoked(state: &AppState, token: &str) -> Result<bool, AppError> {
364364+ let token_hash = hex::encode(Sha256::digest(token.as_bytes()));
365365+ db::is_space_credential_revoked(&state.db, state.db_backend, &token_hash).await
366366+}
367367+360368async fn require_auth_or_credential(
361369 state: &AppState,
362370 claims: &XrpcClaims,
···372380 &state.config.plc_url,
373381 )
374382 .await?;
383383+ if space_credential_revoked(state, token).await? {
384384+ return Err(AppError::Auth("space credential has been revoked".into()));
385385+ }
375386 return Ok(verified.sub);
376387 }
377388···450461 .await
451462 {
452463 Ok(claims) if claims.sub == space_uri => {
453453- // External credential grants read access; write is not supported via space credential
454454- if require_write {
464464+ // A revoked credential is treated as invalid — fall through to
465465+ // the local membership check rather than granting access.
466466+ if space_credential_revoked(state, token).await? {
467467+ // fall through
468468+ } else if require_write {
469469+ // External credential grants read access only.
455470 return Err(AppError::Forbidden(
456471 "Write access is required for this action".into(),
457472 ));
473473+ } else {
474474+ return Ok(SpaceAccess::Read);
458475 }
459459- return Ok(SpaceAccess::Read);
460476 }
461477 Ok(_) => {
462478 // Credential is valid but for a different space — fall through
···422422 return Err(AppError::NotFound("Member not found in this space".into()));
423423 }
424424425425+ // Revoke any outstanding space credentials the removed member holds so their
426426+ // cross-service access ends immediately rather than lingering for the
427427+ // credential's 2h TTL (M3).
428428+ db::revoke_space_credentials_for_member(&state.db, state.db_backend, &space.id, &input.did)
429429+ .await?;
430430+425431 Ok(Json(serde_json::json!({ "success": true })))
426432}
427433