feat(mcp): add local accounts to yt mcp serve #121

Merged
Claude-Run merged 1 commit from feat/YT-67-local-accounts into main 2026-07-17 13:46:44 +02:00
Member

yt mcp serve had no identity of its own: a user was a Hub user of the one configured instance (users.hub_user_id TEXT NOT NULL UNIQUE), and Hub OAuth was the only way to become one. Per-user YouTrack instances cannot exist until a person can be authenticated without a YouTrack instance, so local accounts are the prerequisite for the rest of the epic.

Adds an accounts table alongside users rather than extending it: users means "a Hub user" and is keyed on a non-null Hub id, which cannot represent an account with no Hub identity. Passwords are argon2id (crate defaults, per-hash salt in the PHC string); the TOTP secret is encrypted at rest with the existing Crypto under YOUTRACK_MCP_MASTER_KEY, because a TOTP secret is a credential and must not sit in cleartext beside the password hash.

The routes are GET/POST /signup, GET/POST /login, POST /logout, and GET/POST /totp/enrol / /totp/verify, server-rendered with askama next to the existing pages. GET /login now renders the local sign-in form and keeps the Hub button beside it (the Hub flow and the allowlist are unchanged); once signed in, the same route is the account page. TOTP is RFC 6238 (SHA-1, 6 digits, 30s step, +/-1 step of skew) with single-use recovery codes stored as SHA-256 hashes. Replay is refused by a per-account high-water mark (accounts.totp_last_step): a code is accepted only for a strictly greater step, so a code captured inside its own window cannot be used twice.

Sessions reuse the existing signed cookie rather than inventing a second mechanism. BrowserSession gains account_id (authenticated) and pending_account_id (password verified, second factor still owed), and its Hub fields become optional so a local session carries no Hub state. That is why hub_callback now refuses a session with no state in flight rather than comparing against a default: an empty state would have matched an attacker-supplied empty state.

YOUTRACK_MCP_ADMIN_EMAIL is the first-admin bootstrap for local accounts, the counterpart of the Hub-keyed YOUTRACK_MCP_ADMIN_HUB_LOGIN (which stays for the Hub flow). A banned account cannot sign in, and the status is re-checked on every request, so sessions issued before a ban stop working rather than outliving it.

Login throttling, lockout, and bot protection are NOT here: they are tracked in #YT-65 and are required before this surface faces the internet. Password auth is online-guessable in a way Hub-backed auth was not; docs/deployment.md says so at the top of the new section.

#YT-67

`yt mcp serve` had no identity of its own: a user *was* a Hub user of the one configured instance (`users.hub_user_id TEXT NOT NULL UNIQUE`), and Hub OAuth was the only way to become one. Per-user YouTrack instances cannot exist until a person can be authenticated without a YouTrack instance, so local accounts are the prerequisite for the rest of the epic. Adds an `accounts` table alongside `users` rather than extending it: `users` means "a Hub user" and is keyed on a non-null Hub id, which cannot represent an account with no Hub identity. Passwords are argon2id (crate defaults, per-hash salt in the PHC string); the TOTP secret is encrypted at rest with the existing `Crypto` under `YOUTRACK_MCP_MASTER_KEY`, because a TOTP secret is a credential and must not sit in cleartext beside the password hash. The routes are `GET/POST /signup`, `GET/POST /login`, `POST /logout`, and `GET/POST /totp/enrol` / `/totp/verify`, server-rendered with askama next to the existing pages. `GET /login` now renders the local sign-in form and keeps the Hub button beside it (the Hub flow and the allowlist are unchanged); once signed in, the same route is the account page. TOTP is RFC 6238 (SHA-1, 6 digits, 30s step, +/-1 step of skew) with single-use recovery codes stored as SHA-256 hashes. Replay is refused by a per-account high-water mark (`accounts.totp_last_step`): a code is accepted only for a strictly greater step, so a code captured inside its own window cannot be used twice. Sessions reuse the existing signed cookie rather than inventing a second mechanism. `BrowserSession` gains `account_id` (authenticated) and `pending_account_id` (password verified, second factor still owed), and its Hub fields become optional so a local session carries no Hub state. That is why `hub_callback` now refuses a session with no state in flight rather than comparing against a default: an empty state would have matched an attacker-supplied empty `state`. `YOUTRACK_MCP_ADMIN_EMAIL` is the first-admin bootstrap for local accounts, the counterpart of the Hub-keyed `YOUTRACK_MCP_ADMIN_HUB_LOGIN` (which stays for the Hub flow). A `banned` account cannot sign in, and the status is re-checked on every request, so sessions issued before a ban stop working rather than outliving it. Login throttling, lockout, and bot protection are NOT here: they are tracked in #YT-65 and are required before this surface faces the internet. Password auth is online-guessable in a way Hub-backed auth was not; `docs/deployment.md` says so at the top of the new section. #YT-67
feat(mcp): add local accounts to yt mcp serve
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 1m9s
Create release / Create release from merged PR (pull_request) Has been skipped
96d0f0932c
`yt mcp serve` had no identity of its own: a user *was* a Hub user of the one configured instance (`users.hub_user_id TEXT NOT NULL UNIQUE`), and Hub OAuth was the only way to become one. Per-user YouTrack instances cannot exist until a person can be authenticated without a YouTrack instance, so local accounts are the prerequisite for the rest of the epic.

Adds an `accounts` table alongside `users` rather than extending it: `users` means "a Hub user" and is keyed on a non-null Hub id, which cannot represent an account with no Hub identity. Passwords are argon2id (crate defaults, per-hash salt in the PHC string); the TOTP secret is encrypted at rest with the existing `Crypto` under `YOUTRACK_MCP_MASTER_KEY`, because a TOTP secret is a credential and must not sit in cleartext beside the password hash.

The routes are `GET/POST /signup`, `GET/POST /login`, `POST /logout`, and `GET/POST /totp/enrol` / `/totp/verify`, server-rendered with askama next to the existing pages. `GET /login` now renders the local sign-in form and keeps the Hub button beside it (the Hub flow and the allowlist are unchanged); once signed in, the same route is the account page. TOTP is RFC 6238 (SHA-1, 6 digits, 30s step, +/-1 step of skew) with single-use recovery codes stored as SHA-256 hashes. Replay is refused by a per-account high-water mark (`accounts.totp_last_step`): a code is accepted only for a strictly greater step, so a code captured inside its own window cannot be used twice.

Sessions reuse the existing signed cookie rather than inventing a second mechanism. `BrowserSession` gains `account_id` (authenticated) and `pending_account_id` (password verified, second factor still owed), and its Hub fields become optional so a local session carries no Hub state. That is why `hub_callback` now refuses a session with no state in flight rather than comparing against a default: an empty state would have matched an attacker-supplied empty `state`.

`YOUTRACK_MCP_ADMIN_EMAIL` is the first-admin bootstrap for local accounts, the counterpart of the Hub-keyed `YOUTRACK_MCP_ADMIN_HUB_LOGIN` (which stays for the Hub flow). A `banned` account cannot sign in, and the status is re-checked on every request, so sessions issued before a ban stop working rather than outliving it.

Login throttling, lockout, and bot protection are NOT here: they are tracked in #YT-65 and are required before this surface faces the internet. Password auth is online-guessable in a way Hub-backed auth was not; `docs/deployment.md` says so at the top of the new section.

#YT-67
Claude-Run deleted branch feat/YT-67-local-accounts 2026-07-17 13:46:44 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pandoras-box/youtrack-cli!121
No description provided.