{"record":{"id":"ad0fd99eabdd052e","repo":"odysseus-dev/odysseus","slug":"api-token-owner-mismatch","errorCode":null,"errorMessage":"API token owner mismatch","messagePattern":"API token owner mismatch","errorType":"http","errorClass":"HTTPException","httpStatus":403,"severity":"error","filePath":"routes/codex_routes.py","lineNumber":343,"sourceCode":"        ]\n        if cc:\n            lines.append(f\"Cc: {cc}\")\n        if bcc:\n            lines.append(f\"Bcc: {bcc}\")\n        lines.append(f\"Subject: {subject}\")\n        if in_reply_to:\n            lines.append(f\"In-Reply-To: {in_reply_to}\")\n        if references:\n            lines.append(f\"References: {references}\")\n        lines.extend([\"---\", body_text])\n        return \"\\n\".join(lines).rstrip() + \"\\n\"\n\n    @router.post(\"/emails/draft-document\")\n    async def codex_email_draft_document(request: Request, body: dict[str, Any] = Body(default_factory=dict)):\n        owner = _scope_owner(request, EMAIL_DRAFT_SCOPES)\n        docs_owner = _scope_owner_all(request, DOCS_WRITE_SCOPES)\n        if docs_owner != owner:\n            raise HTTPException(403, \"API token owner mismatch\")\n        if documents_create_endpoint is None:\n            raise HTTPException(503, \"Documents integration is not available\")\n        from routes.document_routes import DocumentCreate\n\n        subject = str(body.get(\"subject\") or \"Email draft\").strip() or \"Email draft\"\n        title = str(body.get(\"title\") or subject).strip() or \"Email draft\"\n        req = DocumentCreate(\n            session_id=body.get(\"session_id\"),\n            title=title,\n            language=\"email\",\n            content=_email_draft_document_content(body),\n        )\n        result = await _as_owner(request, owner, documents_create_endpoint, request, req)\n        if isinstance(result, dict):\n            result = dict(result)\n            result[\"draft_type\"] = \"document\"\n            result[\"send_required_confirmation\"] = True\n        return result","sourceCodeStart":325,"sourceCodeEnd":361,"githubUrl":"https://github.com/odysseus-dev/odysseus/blob/f9235ebbf13f693a6fd29ce70b097f6ec83705bf/routes/codex_routes.py#L325-L361","documentation":"Raised as HTTP 403 by POST /api/codex/emails/draft-document when the owner resolved from email-draft scopes differs from the owner resolved under the documents-write scopes. Both scope checks must pass for the SAME principal; this fires when an API token's email authorization and documents authorization map to different owners, which the combined route forbids to prevent cross-owner data leaks.","triggerScenarios":"An API token whose api_token_owner differs from the owner the docs-scope check yields — typically only possible with misprovisioned tokens or middleware that resolves owners inconsistently between scope sets; also reachable if one check falls back to a cookie user while the other uses the token identity.","commonSituations":"Mixing auth: request carries both a valid API token and a cookie session of a different user, so one path authenticates as the token owner and the other as the cookie user; tokens minted against a shared/organization owner while docs require the personal owner.","solutions":["Send only one credential: drop the cookie session when calling with an API token (or vice versa).","Re-issue the API token under the same user that owns the documents workspace.","Verify request.state.api_token_owner and the docs owner resolve to the same string via token introspection before calling combined routes."],"exampleFix":"# before\ncurl -b cookies.txt -H 'Authorization: Bearer $OTHER_USER_TOKEN' -X POST .../emails/draft-document  # 403\n\n# after\ncurl -H \"Authorization: Bearer $TOKEN\" -X POST .../emails/draft-document  # no cookie jar","handlingStrategy":"validation","validationCode":"const t = await introspectToken(activeTokenId);\nconst docsOwner = await getDocsOwner(); // from documents API session\nif (t.owner && t.owner !== docsOwner) {\n  await reissueTokenUnderOwner(docsOwner);\n}","typeGuard":"function sameOwner(a?: string|null, b?: string|null): boolean {\n  return !!a && !!b && a === b;\n}","tryCatchPattern":"try { r = await draftDocument() } catch (e) { if (e.status === 403 && e.detail === 'API token owner mismatch') { clearCookies(); instruct single-credential auth } else throw }","preventionTips":["Send exactly one credential per request: API token XOR cookie session.","Never mix a shared/organizational token with a personal cookie session on combined routes.","Assert token owner equals the active workspace owner in client startup checks."],"tags":["http-403","authorization","owner-mismatch","api-token","codex"],"backgroundTag":null,"analyzedSha":"f9235ebbf13f693a6fd29ce70b097f6ec83705bf","analyzedAt":"2026-08-14T21:47:48.359Z","schemaVersion":2},"datasetVersion":"2026-08-15T17:31:12.345Z"}