{"record":{"id":"fb07679c1269ccf1","repo":"odysseus-dev/odysseus","slug":"api-token-missing-required-scope-required","errorCode":null,"errorMessage":"API token missing required scope: {required}","messagePattern":"API token missing required scope: (.+?)","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"routes/codex_routes.py","lineNumber":91,"sourceCode":"        return result\n    finally:\n        request.state.current_user = orig\n        if orig_api_token is None:\n            try:\n                delattr(request.state, \"api_token\")\n            except AttributeError:\n                pass\n        else:\n            request.state.api_token = orig_api_token\n\n\ndef _scope_owner(request: Request, allowed: set[str]) -> str:\n    \"\"\"Return the data owner if the caller is allowed for this Codex action.\"\"\"\n    if getattr(request.state, \"api_token\", False):\n        scopes = set(getattr(request.state, \"api_token_scopes\", []) or [])\n        if not scopes.intersection(allowed):\n            required = \" or \".join(sorted(allowed))\n            raise HTTPException(403, f\"API token missing required scope: {required}\")\n        owner = getattr(request.state, \"api_token_owner\", None)\n        if not owner:\n            raise HTTPException(403, \"API token has no owner\")\n        return owner\n    return require_user(request)\n\n\ndef _scope_owner_all(request: Request, required: set[str]) -> str:\n    \"\"\"Return owner only when an API token has every required scope.\"\"\"\n    if getattr(request.state, \"api_token\", False):\n        scopes = set(getattr(request.state, \"api_token_scopes\", []) or [])\n        missing = required - scopes\n        if missing:\n            raise HTTPException(403, f\"API token missing required scope: {' and '.join(sorted(missing))}\")\n        owner = getattr(request.state, \"api_token_owner\", None)\n        if not owner:\n            raise HTTPException(403, \"API token has no owner\")\n        return owner","sourceCodeStart":73,"sourceCodeEnd":109,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/codex_routes.py#L73-L109","documentation":"Raised as HTTP 403 by _scope_owner when a request authenticated with an API token lacks every one of the scopes in the route's allowed set (no intersection). API-token callers are authorized purely by scopes, unlike cookie-session callers who go through require_user. The message names the exact scopes that would have been accepted.","triggerScenarios":"Calling a /api/codex/* endpoint (e.g. GET /api/codex/emails) with a token whose scope list does not include any of EMAIL_READ_SCOPES (or the route's specific set); using a read-only token against a write route; typo in a custom token's scope string.","commonSituations":"Token minted with a minimal scope set for a different integration; scopes renamed between app versions so old tokens no longer match; using a token created for the cookbook on email routes.","solutions":["Read the message — it lists the accepted scopes; mint a new API token that includes one of them (via the API token management endpoint).","Inspect the token's current scopes via the token introspection/management endpoint to confirm what it carries.","If scopes were renamed in an upgrade, re-issue the token with the new scope names.","Alternatively call the route with a cookie session instead of the API token."],"exampleFix":"# before\ncurl -H 'Authorization: Bearer $TOKEN' https://host/api/codex/emails  # 403\n\n# after\nTOKEN=$(create_token --scope email:read)\ncurl -H \"Authorization: Bearer $TOKEN\" https://host/api/codex/emails","handlingStrategy":"validation","validationCode":"const resp = await fetch('/api/tokens/introspect', {headers: auth});\nconst {scopes} = await resp.json();\nconst needed = ['email:read']; // from route docs\nif (!needed.some(s => scopes.includes(s))) {\n  throw new Error(`Token lacks scope; has [${scopes}] needs one of [${needed}]`);\n}","typeGuard":"function tokenAllows(tokenScopes: string[], required: string[]): boolean {\n  return required.some(s => tokenScopes.includes(s));\n}","tryCatchPattern":"try { r = await codexListEmails() } catch (e) { if (e.status === 403 && e.detail.startsWith('API token missing required scope')) { await mintTokenWithScopes(parseScopes(e.detail)); retry once } else throw }","preventionTips":["Introspect token scopes at startup of any script/plugin and fail fast with a clear message.","Keep scope constants in one shared place so clients and provisioning agree on names.","Treat 403 scope messages as machine-readable: parse the required list out of the detail text."],"tags":["http-403","authorization","api-token","scopes","codex"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}