diff --git a/app/indieauth.py b/app/indieauth.py index d7fa6bb..90ce658 100644 --- a/app/indieauth.py +++ b/app/indieauth.py @@ -13,6 +13,7 @@ from fastapi.responses import JSONResponse from loguru import logger from pydantic import BaseModel from sqlalchemy import select +from sqlalchemy.orm import joinedload from app import config from app import models @@ -115,7 +116,7 @@ async def indieauth_authorization_endpoint( "url": registered_client.client_uri, } else: - client = await indieauth.get_client_id_data(client_id) + client = await indieauth.get_client_id_data(client_id) # type: ignore return await templates.render_template( db_session, @@ -321,8 +322,10 @@ async def _check_access_token( ) -> tuple[bool, models.IndieAuthAccessToken | None]: access_token_info = ( await db_session.scalars( - select(models.IndieAuthAccessToken).where( - models.IndieAuthAccessToken.access_token == token + select(models.IndieAuthAccessToken) + .where(models.IndieAuthAccessToken.access_token == token) + .options( + joinedload(models.IndieAuthAccessToken.indieauth_authorization_request) ) ) ).one_or_none() @@ -345,6 +348,7 @@ async def _check_access_token( @dataclass(frozen=True) class AccessTokenInfo: scopes: list[str] + client_id: str | None async def verify_access_token( @@ -371,9 +375,57 @@ async def verify_access_token( return AccessTokenInfo( scopes=access_token.scope.split(), + client_id=( + access_token.indieauth_authorization_request.client_id + if access_token.indieauth_authorization_request + else None + ), ) +async def check_access_token( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> AccessTokenInfo | None: + token = request.headers.get("Authorization", "").removeprefix("Bearer ") + if not token: + return None + + is_token_valid, access_token = await _check_access_token(db_session, token) + if not is_token_valid: + return None + + if not access_token or not access_token.scope: + raise ValueError("Should never happen") + + access_token_info = AccessTokenInfo( + scopes=access_token.scope.split(), + client_id=( + access_token.indieauth_authorization_request.client_id + if access_token.indieauth_authorization_request + else None + ), + ) + + logger.info( + "Authenticated with access token from client_id=" + f"{access_token_info.client_id} scopes={access_token.scope}" + ) + + return access_token_info + + +async def enforce_access_token( + request: Request, + db_session: AsyncSession = Depends(get_db_session), +) -> AccessTokenInfo: + maybe_access_token_info = await check_access_token(request, db_session) + if not maybe_access_token_info: + raise HTTPException(status_code=401, detail="access token required") + + return maybe_access_token_info + + @router.post("/revoke_token") async def indieauth_revocation_endpoint( request: Request, diff --git a/app/main.py b/app/main.py index 587cc13..b9aeabc 100644 --- a/app/main.py +++ b/app/main.py @@ -464,7 +464,12 @@ async def followers( _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse | templates.TemplateResponse: if is_activitypub_requested(request): - if config.HIDES_FOLLOWERS: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + if config.HIDES_FOLLOWERS and not maybe_access_token_info: return ActivityPubResponse( await _empty_followx_collection( db_session=db_session, @@ -523,7 +528,12 @@ async def following( _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse | templates.TemplateResponse: if is_activitypub_requested(request): - if config.HIDES_FOLLOWING: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + if config.HIDES_FOLLOWING and not maybe_access_token_info: return ActivityPubResponse( await _empty_followx_collection( db_session=db_session, @@ -579,22 +589,34 @@ async def following( @app.get("/outbox") async def outbox( + request: Request, db_session: AsyncSession = Depends(get_db_session), _: httpsig.HTTPSigInfo = Depends(httpsig.httpsig_checker), ) -> ActivityPubResponse: + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + + # Default restrictions unless the request is authenticated with an access token + restricted_where = [ + models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, + models.OutboxObject.ap_type.in_(["Create", "Note", "Article", "Announce"]), + ] + # By design, we only show the last 20 public activities in the oubox outbox_objects = ( await db_session.scalars( select(models.OutboxObject) .where( - models.OutboxObject.visibility == ap.VisibilityEnum.PUBLIC, models.OutboxObject.is_deleted.is_(False), - models.OutboxObject.ap_type.in_(["Create", "Announce"]), + *([] if maybe_access_token_info else restricted_where), ) .order_by(models.OutboxObject.ap_published_at.desc()) .limit(20) ) ).all() + return ActivityPubResponse( { "@context": ap.AS_EXTENDED_CTX, @@ -646,6 +668,14 @@ async def _check_outbox_object_acl( if templates.is_current_user_admin(request): return None + maybe_access_token_info = await indieauth.check_access_token( + request, + db_session, + ) + if maybe_access_token_info: + # TODO: check scopes + return None + if ap_object.visibility in [ ap.VisibilityEnum.PUBLIC, ap.VisibilityEnum.UNLISTED, diff --git a/app/models.py b/app/models.py index 56c0d41..393538c 100644 --- a/app/models.py +++ b/app/models.py @@ -465,6 +465,10 @@ class IndieAuthAccessToken(Base): indieauth_authorization_request_id = Column( Integer, ForeignKey("indieauth_authorization_request.id"), nullable=True ) + indieauth_authorization_request = relationship( + IndieAuthAuthorizationRequest, + uselist=False, + ) access_token = Column(String, nullable=False, unique=True, index=True) expires_in = Column(Integer, nullable=False)