Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,21 @@

## [Unreleased]

### Phase 8 — Device Authorization Grant backend (AI-050a) (2026-06-16)

Backend for the OAuth 2.0 **Device Authorization Grant (RFC 8628)** so the headless MCP CLI (AI-050b) can obtain a per-user TextStack JWT without a browser redirect. This is the BACKEND slice; the consent page lives in `apps/web` (built concurrently by the frontend agent).

- **New `DeviceAuthorization` entity + migration** (`AddDeviceAuthorization`). Mirrors the `PasswordResetToken` precedent: the long `device_code` secret is stored **SHA256-hex hashed**, never plain. Columns: `device_code_hash` (unique, maxlen 128), `user_code` (maxlen 16), `user_id` (nullable until approved, FK `OnDelete(SetNull)`), `status` (pending|approved|denied|expired, maxlen 16), `expires_at`, `interval_seconds` (default 5), `created_at`, `consumed_at`. Indexes: unique on `device_code_hash`, **filtered** `user_code WHERE status='pending'`, and `expires_at`. Exposed on `IAppDbContext` + `AppDbContext`.
- **3 RFC-8628 endpoints** under `/auth/device` (`DeviceAuthEndpoints.cs`), snake_case fields/errors per §3.2/§3.5, plus a `deny`:
- `POST /auth/device/code` (public, rate-limited `device-code` 5/min/IP) → `{ device_code, user_code, verification_uri: "<App:BaseUrl>/device", verification_uri_complete, expires_in: 600, interval: 5 }`. Public base from config key **`App:BaseUrl`** (default `https://textstack.app`).
- `POST /auth/device/token` (public, `device-token` 12/min/IP) — body `{ grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code }`. Approved → `200 { access_token, refresh_token, token_type:"Bearer", user }`; otherwise `400 { error: "authorization_pending" | "expired_token" | "access_denied" }`. Unknown device_code → `expired_token` (no enumeration).
- `POST /auth/device/approve` (**authed** via cookie session, `device-approve` 10/min/IP) — body `{ user_code }` → `200 { status:"ok" }` or `400 { error: "invalid_user_code" | "expired_user_code" | "user_code_already_used" }`. `POST /auth/device/deny` included (sets status=denied).
- **Reuses existing JWT issuance** — `RedeemDeviceCodeAsync` mints tokens via the SAME `GenerateAccessToken` + `CreateRefreshTokenAsync` as the login path (no re-implemented JWT). **`TimeProvider`** injected into `AuthService` so device expiry is testable with a fake clock (`TimeProvider.System` registered in DI). Pure helpers extracted to `DeviceCodes` (user_code gen/normalize, SHA256 hash, secure token) for unit testing.
- **Security**: device_code hashed at rest; **single-use** (redeem flips status off pending + sets `consumed_at`, so a second poll → `expired_token`); approval requires an authenticated session; short **10-min TTL**; per-IP rate limits. `user_code` = Crockford base32 minus ambiguous chars (no I/O/0/1), 8 chars grouped `XXXX-XXXX` — its entropy is deliberately NOT load-bearing.
- **Frontend contract** (api method added in `apps/web/src/api/auth.ts`, page owned by frontend agent): `approveDevice(userCode)` → `POST /auth/device/approve`, body `{ user_code }`, `credentials:'include'`. Companion `denyDevice(userCode)` → `POST /auth/device/deny` (same body/credentials/error-union); the consent page's **Deny/Cancel now calls the backend** (when authed + code present) before showing the denied state, so the pending row is rejected and the CLI poll transitions to `access_denied` instead of looping on `authorization_pending`.
- **QA fix (P2#1) — approve/deny scoped to the live pending row.** `ApproveDeviceAsync`/`DenyDeviceAsync` now look up `UserCode == normalized && Status == Pending` (was an unscoped `FirstOrDefault` with no status/ordering). Because the `user_code` index is FILTERED on `status='pending'` (not unique), the same 8-char code legitimately recurs across history; the old unscoped lookup could return a stale terminal row and wrongly yield `AlreadyUsed`/`Expired` for an approvable live flow. Lazy-expiry preserved (a past-deadline pending row still reports `Expired`); stale terminal rows of the same code are now invisible to approve/deny.
- **Tests**: 9 unit tests (`DeviceCodesTests` — user_code shape/alphabet/entropy, normalization, deterministic SHA256, secure-token uniqueness) + 10 integration tests (`DeviceAuthEndpointTests` — RFC fields, authorization_pending, anon-approve 401, approve→poll tokens, single-use, unknown-code expired_token; hashed-storage + expired-seed + **same-code-scoping approve/deny** guarded by `TEST_DB_CONNECTION`). The same-code-scoping cases seed an OLDER terminal row sharing the live row's `user_code` and prove approve/deny still bind the live pending row (poll → tokens / `access_denied`). The CLI provider is AI-050b.

### Phase 8 — MCP read-tool surface (AI-048a) (2026-06-16)

Second Phase 8 slice (2a/7): appends **5 READ tools** to the AI-047 `McpToolCatalog`, adds an interim auth-token provider for the user-scoped ones, and lifts AI-047's inline error handling into a shared wrapper. The bridge stays thin/stateless — each tool is still just a descriptor + a handler that does validation + mapping over the existing public API. The `save_highlight` WRITE tool is **deferred to AI-048b** (after OAuth, AI-050).
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { TermsPage } from './pages/TermsPage'
import { DmcaPage } from './pages/DmcaPage'
import { ContactPage } from './pages/ContactPage'
import { ResetPasswordPage } from './pages/ResetPasswordPage'
import { DeviceVerifyPage } from './pages/DeviceVerifyPage'
import { SitemapPage } from './pages/SitemapPage'
import { NotFoundPage } from './pages/NotFoundPage'
// User-only / heavy routes — lazy so they ship in separate chunks.
Expand Down Expand Up @@ -130,6 +131,9 @@ function AppRoutes() {
return (
<Routes>
<Route path="/" element={<RootRedirect />} />
{/* Device Authorization Grant consent (AI-050a) — MCP CLI points here at a
fixed, language-less URL (/device?code=XXXX-XXXX), so mount top-level. */}
<Route path="/device" element={<DeviceVerifyPage />} />
{/* Redirect legacy URLs without language prefix */}
<Route path="/books/*" element={<LegacyRedirect />} />
<Route path="/authors/*" element={<LegacyRedirect />} />
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,29 @@ export async function getCurrentUser(): Promise<AuthResponse> {
return authFetch<AuthResponse>('/auth/me')
}

// Device Authorization Grant (RFC 8628, AI-050a) — consent page approves a
// CLI's user_code from the authenticated browser session. authFetch sends
// credentials:'include', so the session cookie authenticates the approval.
export type DeviceApproveError =
| 'invalid_user_code'
| 'expired_user_code'
| 'user_code_already_used'
| 'invalid_request'

export async function approveDevice(userCode: string): Promise<void> {
await authFetch<void>('/auth/device/approve', {
method: 'POST',
body: JSON.stringify({ user_code: userCode }),
})
}

export async function denyDevice(userCode: string): Promise<void> {
await authFetch<void>('/auth/device/deny', {
method: 'POST',
body: JSON.stringify({ user_code: userCode }),
})
}

// Profile API
export interface UpdateProfilePayload {
name?: string | null
Expand Down
25 changes: 25 additions & 0 deletions apps/web/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,31 @@
"auth": {
"progressSavedToast": "Your reading progress and saved words were kept."
},
"deviceVerify": {
"title": "Connect a device",
"codeLabel": "Enter the code shown by your MCP client",
"codePlaceholder": "XXXX-XXXX",
"continue": "Continue",
"signInTitle": "Sign in to continue",
"signInText": "Sign in to your TextStack account to approve this connection.",
"signInBtn": "Sign in",
"consentTitle": "Approve connection",
"consentLead": "TextStack MCP wants to connect to your library as {{email}}.",
"consentScope": "It will be able to read your highlights, vocabulary, and ask questions about your books — and (soon) save highlights — the same access as this website.",
"consentWarning": "Only approve this if you started a login from your own MCP client.",
"approve": "Approve",
"cancel": "Cancel",
"submitting": "Approving...",
"deniedTitle": "Request canceled",
"deniedText": "No access was granted. You can close this page.",
"successTitle": "✓ Connected",
"successText": "Return to your MCP client or terminal — you can close this page now.",
"errorInvalidCode": "That code wasn't found — check it and try again.",
"errorExpired": "This code expired — start a new login from your MCP client.",
"errorAlreadyUsed": "This code was already used.",
"errorGeneric": "Something went wrong. Please try again.",
"errorNotSignedIn": "Please sign in first."
},
"common": {
"loading": "Loading...",
"noBooksYet": "No books available yet.",
Expand Down
181 changes: 181 additions & 0 deletions apps/web/src/pages/DeviceVerifyPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { useState, FormEvent, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import { approveDevice, denyDevice, DeviceApproveError } from '../api/auth'
import { useAuth } from '../context/AuthContext'
import { useTranslation } from '../hooks/useTranslation'

/**
* OAuth 2.0 Device Authorization Grant (RFC 8628) consent gate (AI-050a).
* The MCP CLI prints a user_code and points the user at /device(?code=XXXX-XXXX).
* A signed-in TextStack user confirms the code and approves the CLI's access.
* Mounted at top-level (no /:lang prefix) so the CLI's stable URL works.
*/

/** Strip everything but [A-Z0-9], then format as XXXX-XXXX for display. */
function formatUserCode(raw: string): string {
const clean = raw.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8)
if (clean.length <= 4) return clean
return `${clean.slice(0, 4)}-${clean.slice(4)}`
}

/** What we send to the backend: trimmed, uppercased; backend normalizes further. */
function normalizeForSubmit(display: string): string {
return display.trim().toUpperCase()
}

/** Map the thrown auth error (status + message=code) to an i18n key. */
function errorKey(err: unknown): string {
const e = err as { status?: number; message?: string }
if (e?.status === 401) return 'deviceVerify.errorNotSignedIn'
const code = e?.message as DeviceApproveError | undefined
switch (code) {
case 'invalid_user_code':
return 'deviceVerify.errorInvalidCode'
case 'expired_user_code':
return 'deviceVerify.errorExpired'
case 'user_code_already_used':
return 'deviceVerify.errorAlreadyUsed'
default:
return 'deviceVerify.errorGeneric'
}
}

export function DeviceVerifyPage() {
const [params] = useSearchParams()
const { t } = useTranslation()
const { isAuthenticated, user, openAuthModal } = useAuth()

const initialCode = useMemo(() => formatUserCode(params.get('code') || ''), [params])
const [code, setCode] = useState(initialCode)
const [submitting, setSubmitting] = useState(false)
const [errorMsg, setErrorMsg] = useState('')
const [success, setSuccess] = useState(false)
const [denied, setDenied] = useState(false)

const handleApprove = async (e: FormEvent) => {
e.preventDefault()
setErrorMsg('')
const submitCode = normalizeForSubmit(code)
if (!submitCode) {
setErrorMsg(t('deviceVerify.errorInvalidCode'))
return
}
setSubmitting(true)
try {
await approveDevice(submitCode)
setSuccess(true)
} catch (err) {
setErrorMsg(t(errorKey(err)))
} finally {
setSubmitting(false)
}
}

// Deny/Cancel: when authenticated with a code present, actually deny the
// pending row server-side so the CLI stops polling and gets access_denied.
// Otherwise (not signed in or no code) there's nothing to deny — local cancel.
const handleDeny = async () => {
setErrorMsg('')
const submitCode = normalizeForSubmit(code)
if (!isAuthenticated || !submitCode) {
setDenied(true)
return
}
setSubmitting(true)
try {
await denyDevice(submitCode)
setDenied(true)
} catch (err) {
setErrorMsg(t(errorKey(err)))
} finally {
setSubmitting(false)
}
}

// --- Success ---------------------------------------------------------------
if (success) {
return (
<div className="auth-page">
<div className="auth-page__card">
<h2 className="auth-modal__title">{t('deviceVerify.successTitle')}</h2>
<p className="auth-modal__text">{t('deviceVerify.successText')}</p>
</div>
</div>
)
}

// --- Denied ----------------------------------------------------------------
if (denied) {
return (
<div className="auth-page">
<div className="auth-page__card">
<h2 className="auth-modal__title">{t('deviceVerify.deniedTitle')}</h2>
<p className="auth-modal__text">{t('deviceVerify.deniedText')}</p>
</div>
</div>
)
}

// --- Not signed in: gate behind login -------------------------------------
// The AuthModal stays on this same URL (incl. ?code=), so after sign-in the
// page re-renders into the consent state with the code preserved.
if (!isAuthenticated) {
return (
<div className="auth-page">
<div className="auth-page__card">
<h2 className="auth-modal__title">{t('deviceVerify.signInTitle')}</h2>
<p className="auth-modal__text">{t('deviceVerify.signInText')}</p>
<button className="auth-modal__btn" onClick={openAuthModal}>
{t('deviceVerify.signInBtn')}
</button>
</div>
</div>
)
}

// --- Signed in: confirm code + consent ------------------------------------
return (
<div className="auth-page">
<div className="auth-page__card">
<h2 className="auth-modal__title">{t('deviceVerify.consentTitle')}</h2>
<p className="auth-modal__text">
{t('deviceVerify.consentLead', { email: user?.email || '' })}
</p>
<p className="auth-modal__text">{t('deviceVerify.consentScope')}</p>
<p className="auth-modal__text">
<strong>{t('deviceVerify.consentWarning')}</strong>
</p>
<form onSubmit={handleApprove}>
<label className="auth-modal__text" htmlFor="device-code">
{t('deviceVerify.codeLabel')}
</label>
<input
id="device-code"
type="text"
className="auth-modal__input"
placeholder={t('deviceVerify.codePlaceholder')}
value={code}
onChange={(e) => setCode(formatUserCode(e.target.value))}
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
autoFocus={!code}
/>
{errorMsg && <p className="auth-modal__error">{errorMsg}</p>}
<button className="auth-modal__btn" type="submit" disabled={submitting || !code}>
{submitting ? t('deviceVerify.submitting') : t('deviceVerify.approve')}
</button>
<button
type="button"
className="auth-modal__btn"
onClick={handleDeny}
disabled={submitting}
style={{ background: 'transparent', color: 'var(--color-text-secondary)' }}
>
{t('deviceVerify.cancel')}
</button>
</form>
</div>
</div>
)
}
Loading
Loading