Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions backend/api/deps.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from typing import Any, AsyncGenerator, Optional
from urllib.parse import urlparse

import jwt
from fastapi import Depends, HTTPException, Request, WebSocket, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from pydantic import ValidationError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
Expand Down Expand Up @@ -178,7 +178,7 @@ async def get_authenticated_token_details(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token does not have the required scope",
)
except (JWTError, ValidationError):
except (jwt.PyJWTError, ValidationError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
Expand Down
24 changes: 14 additions & 10 deletions backend/api/services/oauth_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ def _build_access_token(
token_version=user.token_version,
extra_claims={
"client_id": client_id,
# Custom claim (not "aud": python-jose rejects tokens carrying
# "aud" unless every decode call passes an audience option).
# Custom claim (not "aud": PyJWT rejects tokens carrying "aud"
# unless every decode call passes an audience option).
"res": mcp_resource_url(),
# Lets the MCP endpoint attribute a request to its consent grant
# so Connected Apps can show when the grant was actually used.
Expand Down Expand Up @@ -383,10 +383,15 @@ async def exchange_authorization_code(
if not code_verifier or not _verify_pkce(record.code_challenge, code_verifier):
raise OAuthError("invalid_grant", "PKCE verification failed.")

record.used_at = utc_now()
user = await _load_user(db, record.user_id)

grant_id = uuid.uuid4().hex
# Mint before spending the code: if signing fails, the client can retry
# with the same code instead of having it refused as a replay.
access_token, expires_in = _build_access_token(
user, client_id=client_id, scope=record.scope, grant_id=grant_id
)

record.used_at = utc_now()
refresh_token = await _issue_refresh_token(
db,
RefreshGrant(
Expand All @@ -400,9 +405,6 @@ async def exchange_authorization_code(
client.last_used_at = utc_now()
await db.commit()

access_token, expires_in = _build_access_token(
user, client_id=client_id, scope=record.scope, grant_id=grant_id
)
return {
"access_token": access_token,
"token_type": "Bearer",
Expand Down Expand Up @@ -445,6 +447,11 @@ async def refresh_access_token(
raise OAuthError("invalid_grant", "Refresh token has expired.")

user = await _load_user(db, record.user_id)
# Mint before rotating: if signing fails, the presented refresh token
# stays valid, so a retry is not mistaken for reuse of a rotated token.
access_token, expires_in = _build_access_token(
user, client_id=client_id, scope=record.scope, grant_id=record.grant_id
)

record.revoked_at = utc_now()
record.last_used_at = utc_now()
Expand All @@ -461,9 +468,6 @@ async def refresh_access_token(
client.last_used_at = utc_now()
await db.commit()

access_token, expires_in = _build_access_token(
user, client_id=client_id, scope=record.scope, grant_id=record.grant_id
)
return {
"access_token": access_token,
"token_type": "Bearer",
Expand Down
14 changes: 14 additions & 0 deletions backend/api/v1/endpoints/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from backend.api.deps import get_current_user, get_db
from backend.api.services import oauth_service
from backend.api.services.oauth_service import OAuthError
from backend.core.security import SigningKeyUnavailableError
from backend.models.user import User
from backend.utils.config_manager import is_mcp_enabled
from backend.utils.rate_limit import enforce_rate_limit
Expand Down Expand Up @@ -271,6 +272,19 @@ async def token_endpoint(
)
except OAuthError as exc:
return _oauth_error_response(exc)
except SigningKeyUnavailableError as exc:
# Nothing was spent or rotated, so the client can retry the same
# grant once the keyring is fixed. The message names the key file,
# so it goes to the log, not to the client.
logger.error("Could not issue an OAuth access token: %s", exc)
return _oauth_error_response(
OAuthError(
"server_error",
"The server could not load its JWT signing key, so no access "
"token can be issued. Try again later.",
500,
)
)

return JSONResponse(content=payload, headers={"Cache-Control": "no-store"})

Expand Down
137 changes: 112 additions & 25 deletions backend/core/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
from threading import Lock
from typing import Any, Optional, Union

import jwt
from argon2 import PasswordHasher
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
from jose import jwt

from backend.utils.path_manager import path_manager
from backend.utils.time import utc_now
Expand Down Expand Up @@ -55,26 +55,87 @@ def _new_kid() -> str:
return f"k_{secrets.token_hex(4)}"


class SigningKeyUnavailableError(RuntimeError):
"""The JWT keyring cannot be loaded, so no token can be signed or verified.

The message names the file at fault and how to fix it, for the api log.
"""


def _read_keyring_file() -> Optional[dict[str, Any]]:
keyring_file = _keyring_path()
if not keyring_file.exists():
return None
try:
data = json.loads(keyring_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RuntimeError(
f"Unable to load JWT keyring at {keyring_file}: {exc}"
) from exc
if not keyring_file.exists():
return None
content = keyring_file.read_text(encoding="utf-8")
except OSError as exc:
raise _key_file_access_error(keyring_file, exc) from exc
except UnicodeDecodeError as exc:
raise _malformed_key_file_error(keyring_file, str(exc)) from exc
if not content.strip():
# A write cut short, for example by a full disk, leaves the file blank.
raise _empty_signing_key_error(keyring_file)
try:
data = json.loads(content)
except json.JSONDecodeError as exc:
raise _malformed_key_file_error(keyring_file, str(exc)) from exc
if (
not isinstance(data, dict)
or not isinstance(data.get("keys"), dict)
or not isinstance(data.get("active"), str)
or data["active"] not in data["keys"]
):
raise RuntimeError(f"JWT keyring at {keyring_file} is malformed.")
raise _malformed_key_file_error(keyring_file, "no usable active key entry")
if not all(isinstance(key, str) for key in data["keys"].values()):
raise _malformed_key_file_error(keyring_file, "a key that is not a string")
return data


def _replace_key_file_remedy(key_file: Path) -> str:
return (
f"Delete {key_file} and restart Nojoin to generate a new key (everyone "
"signed in will have to sign in again), or set the SECRET_KEY "
"environment variable."
)


def _empty_signing_key_error(key_file: Path) -> SigningKeyUnavailableError:
# PyJWT refuses an empty HMAC key, so an empty key would fail every
# sign-in with a bare 500. Detecting it at load names the file instead.
return SigningKeyUnavailableError(
f"The JWT signing key in {key_file} is empty, so no token can be signed "
f"or verified. {_replace_key_file_remedy(key_file)}"
)


def _malformed_key_file_error(
key_file: Path, reason: str
) -> SigningKeyUnavailableError:
return SigningKeyUnavailableError(
f"The JWT key file {key_file} is malformed ({reason}), so no token can "
f"be signed or verified. {_replace_key_file_remedy(key_file)}"
)


def _key_file_access_error(
key_file: Path, exc: Union[OSError, UnicodeDecodeError]
) -> SigningKeyUnavailableError:
if isinstance(exc, IsADirectoryError):
return SigningKeyUnavailableError(
f"The JWT key file {key_file} is a directory, so no token can be "
"signed or verified. A Docker bind mount of a host file that does "
"not exist creates a directory like this. Remove the directory and "
"any bind mount of that file (the key lives in the data directory), "
"then restart Nojoin to generate a new key (everyone signed in will "
"have to sign in again), or set the SECRET_KEY environment variable."
)
return SigningKeyUnavailableError(
f"Unable to read or write the JWT key file {key_file} ({exc}), so no "
f"token can be signed or verified. Make sure the api can read and write "
f"{key_file.parent}, or set the SECRET_KEY environment variable."
)


def _write_keyring_file(data: dict[str, Any]) -> None:
keyring_file = _keyring_path()
keyring_file.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -91,12 +152,25 @@ def _write_keyring_file(data: dict[str, Any]) -> None:
)


def _read_legacy_key(legacy_file: Path) -> Optional[str]:
try:
if not legacy_file.exists():
return None
return legacy_file.read_text(encoding="utf-8").strip()
except OSError as exc:
raise _key_file_access_error(legacy_file, exc) from exc
except UnicodeDecodeError as exc:
raise _malformed_key_file_error(legacy_file, str(exc)) from exc


def _bootstrap_keyring() -> dict[str, Any]:
legacy_file = _legacy_key_path()
_migrate_legacy_secret_file(legacy_file)

if legacy_file.exists():
legacy_value = legacy_file.read_text(encoding="utf-8").strip()
legacy_value = _read_legacy_key(legacy_file)
if legacy_value is not None:
if not legacy_value:
raise _empty_signing_key_error(legacy_file)
data = {
"active": DEFAULT_LEGACY_KID,
"keys": {DEFAULT_LEGACY_KID: legacy_value},
Expand Down Expand Up @@ -133,10 +207,18 @@ def _load_keyring() -> dict[str, Any]:
return {"active": "env", "keys": {"env": env_key}}

with _keyring_lock:
existing = _read_keyring_file()
if existing is not None:
return existing
return _bootstrap_keyring()
keyring = _read_keyring_file()
if keyring is None:
try:
keyring = _bootstrap_keyring()
except (OSError, UnicodeDecodeError) as exc:
# Writing the new keyring, or migrating a desktop-era key file.
raise _key_file_access_error(_keyring_path(), exc) from exc
# Checked here rather than in _read_keyring_file so that
# rotate_signing_key() can still replace an empty active entry.
if not keyring["keys"][keyring["active"]]:
raise _empty_signing_key_error(_keyring_path())
return keyring


def get_signing_keyring() -> dict[str, Any]:
Expand Down Expand Up @@ -312,19 +394,24 @@ def create_access_token(
def decode_access_token(token: str) -> dict[str, Any]:
"""Decode and verify a JWT using the keyring entry indicated by its ``kid``.

Raises :class:`jose.JWTError` if the token cannot be verified with any
known key.
Raises :class:`jwt.PyJWTError` if the token is malformed or cannot be
verified with any known key. Catch that base class: besides
:class:`jwt.InvalidTokenError`, ``jwt.decode`` raises
:class:`jwt.InvalidKeyError` (not an ``InvalidTokenError``) when the
stored key for the token's ``kid`` is unusable as an HMAC secret. An
unloadable keyring also raises ``InvalidTokenError``, so every token
is refused until the keyring is fixed.
"""
# PyJWT parses the header strictly: a malformed token or a non-string
# ``kid`` raises InvalidTokenError here, before any key lookup.
kid = jwt.get_unverified_header(token).get("kid")
try:
unverified_header = jwt.get_unverified_header(token)
except Exception: # pragma: no cover - defensive: jose raises subclass of Exception # noqa: BLE001
unverified_header = {}
kid = unverified_header.get("kid") if isinstance(unverified_header, dict) else None
signing_key = get_signing_key_for_kid(kid)
signing_key = get_signing_key_for_kid(kid)
except SigningKeyUnavailableError as exc:
# The api logs the cause at startup and on each sign-in attempt.
raise jwt.InvalidTokenError("JWT keyring is unavailable") from exc
if signing_key is None:
from jose import JWTError

raise JWTError("Unknown signing key id")
raise jwt.InvalidTokenError("Unknown signing key id")
return jwt.decode(token, signing_key, algorithms=[ALGORITHM])


Expand Down
41 changes: 41 additions & 0 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ def _build_redirect_url(self, request: Request) -> str:

# Import models to register them with SQLModel
from backend.core.db import async_session_maker
from backend.core.security import SigningKeyUnavailableError, get_signing_keyring
from backend.models.user import User
from backend.seed_demo import seed_demo_data
from backend.services.model_preparation import enqueue_model_preparation
Expand Down Expand Up @@ -253,8 +254,45 @@ def log_recordings_storage_warnings(*, logger_instance: logging.Logger) -> None:
)


SIGNING_KEY_UNAVAILABLE_DETAIL = (
"Sign-in is unavailable because the server could not load its JWT signing "
"key. The api log names the file to fix."
)


def log_signing_keyring_status(*, logger_instance: logging.Logger) -> None:
"""Load the JWT keyring at boot, so an unusable one is reported at once.

Without this the first evidence is a failed sign-in. The API still starts,
as with the recordings storage probe: sign-in and token verification fail
closed until the keyring is fixed, and the message names the file.
"""
try:
get_signing_keyring()
except SigningKeyUnavailableError as e:
logger_instance.error("%s Sign-in is unavailable until this is fixed.", e)
except Exception:
# Boundary: like the storage probe, this check must not block startup.
logger_instance.exception(
"The JWT keyring check failed to run, so sign-in may be unavailable."
)


async def signing_key_unavailable_handler(
request: Request, exc: Exception
) -> JSONResponse:
# 500, not 503: the frontend reads 502-504 as "backend unreachable", while
# here the backend answered and the sign-in page shows the detail.
logger.error("Could not issue a token: %s", exc)
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"detail": SIGNING_KEY_UNAVAILABLE_DETAIL},
)


@asynccontextmanager
async def lifespan(app: FastAPI):
log_signing_keyring_status(logger_instance=logger)
run_migrations()
await ensure_owner_exists()
await log_first_run_setup_pointer()
Expand Down Expand Up @@ -324,6 +362,9 @@ def create_app(*, app_lifespan=lifespan) -> FastAPI:
app.add_middleware(TrustedHostMiddleware, allowed_hosts=get_trusted_host_list())
app.add_middleware(NormaliseMcpMountPathMiddleware)

app.add_exception_handler(
SigningKeyUnavailableError, signing_key_unavailable_handler
)
app.include_router(api_router, prefix="/api/v1")
if is_mcp_enabled():
# OAuth discovery documents must live at the server root (RFC 8414 /
Expand Down
Loading
Loading