Skip to content

Ensuring you check for auth

I have seen plenty of code where auth checks just weren't done. I assume this usually happens when refactoring code, the developer just forgets to put the checks back and code review fails. We can't prevent mistakes, so here's a simple idea for ensuring this does not lead to a catastrophe.

I've been working with FastAPI, so I'll base my example on it. The idea is however not dependent on it.

from fastapi import FastAPI, HTTPException, Request, Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from typing import Optional
from starlette.status import HTTP_401_UNAUTHORIZED

class AuthError(HTTPException): ...

class Auth:
    async def __call__(
        self,
        request: Request,
        http_auth: Optional[HTTPAuthorizationCredentials] = Depends(
            HTTPBearer(auto_error=False)
        ),
    ) -> None:
        if not http_auth:
            raise AuthError(
                status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated"
            )
        # .. do your thing ..
        request.state.auth_checked = True

class SkipAuth:
    async def __call__(self, request: Request) -> None:
        request.state.auth_checked = True

async def ensure_auth(request: Request):
    """
    This is a last-resort check that the requested endpoint actually performs some explicit check for auth.
    If we hit this, something has gone wrong. Writes have already happened, but at least we don't leak data.
    """
    good = False

    try:
        yield
    except AuthError:
        # AuthError implies we tried to check for it.
        good = True
    finally:
        if not (good or getattr(request.state, "auth_checked", False)):
            raise Exception(
                f"Didn't check for auth while handling {request.method} {request.url}"
            )

app = FastAPI(
    dependencies=[
        Depends(ensure_auth),
    ],
)

Now, if we have two simple endpoints:

@app.get("/secret", dependencies=[Depends(Auth(scope="can_read"))])
async def get_secret():
    return "secret"

@app.get("/version", dependencies=[Depends(SkipAuth())])
async def get_version():
    return "v1.0.0"

These work as you'd expect. /secret requires authentication and /version does not. If we remove the dependency from either endpoint, calling it will yield an internal server error. This means the lack of authentication is explicit.