Dentolize · Claude MCP Server (Phase 1) Walkthrough
On this pageBusiness viewTechnical view

Connecting Claude: the OAuth login flow

Business view

Today, if a doctor wants Claude to see their clinic's numbers, they export a report and paste it into a chat. This feature replaces that with a one-time "Connect" step: the doctor clicks Connect in Claude, is sent to the exact Dentolize login screen they already use, logs in exactly as they always do (including two-factor authentication if their clinic requires it), picks which clinic if their login covers more than one, and is shown a plain-English list of what Claude will be able to read — "Inventory, stock levels and supplier spending," for example — before they approve it.

There's no new password to remember and no separate account to manage. The connection uses the same login the doctor already has, so anything their clinic-web account can't see, Claude can't see either. If they leave the clinic or their access is revoked, the connection stops working the moment their login stops working — nothing lingers.

A doctor (or, later, the clinic owner) will be able to disconnect at any time, though that management screen doesn't exist yet — see the Overview for what's deliberately not built yet.

Technical view

Why a separate authorization server, not just an API key

The design (packages/claude-mcp/OAUTH-DESIGN.md) implements OAuth 2.1 with PKCE, because the caller here is Claude itself — a client Dentolize doesn't control and shouldn't have to trust with a long-lived static secret. OAuth lets a doctor grant access without ever handing Claude a password, lets that grant be scoped to exactly one clinic, and lets it be revoked without changing anything the doctor uses day to day.

The two roles, and where they live

  • Authorization server — issues tokens. Lives inside the existing

packages/server API, in src/apis/oauth/. Reuses the same Prisma client, the same checkUserForLogin, the same 2FA and lockout logic clinic-web already has.

  • Resource server — validates tokens and serves data. This is the new

packages/claude-mcp package. It never issues a token, only checks one.

These are deliberately separate processes with separate database logins (see Tenant isolation and the read-only role) — a design decision (A8 in OAUTH-DESIGN.md) specifically to bound the blast radius of a mistake in future tool code.

The three new tables

packages/prisma/schema.prisma:7316-7376:

  • OAuthClient — a registered application (Claude). clientId is either

generated during Dynamic Client Registration or is the client's metadata document URL. Not owned by a user — the client is the application, not a person.

  • OAuthAuthorizationCode — a short-lived (~1 minute), single-use code.

Stored as codeHash, never the raw code. consumedAt makes replay detectable rather than merely blocked.

  • OAuthToken — access and refresh tokens, type distinguishing which.

Stored as tokenHash (SHA-256), never the raw token. Opaque random strings, not JWTs — chosen specifically (decision A2) so revocation means "delete the row" and takes effect immediately, which a self-verifying JWT cannot offer.

Both codes and tokens carry companyId directly on the row. A token names exactly one clinic (decision A3) — never a doctor's whole list of clinics — because "the token names the tenant" is the assumption every isolation guarantee in this PR rests on.

The endpoints (packages/server/src/apis/oauth/router.js)

EndpointWhat it does
GET /.well-known/oauth-authorization-serverRFC 8414 metadata — tells a client where everything else lives.
POST /oauth/registerRFC 7591 dynamic client registration. Requires every redirect_uri to be https:// (router.js:140) — a plain-http redirect target would let an authorization code leak in transit.
GET /oauth/authorizeEntry point. Validates the request against the registered client, stores it under a random flow id in Redis, and redirects to clinic-web's login.
GET /oauth/callbackWhere clinic-web sends the doctor back. Reads the session, shows a clinic chooser if the login covers more than one clinic, then the consent screen.
POST /oauth/consentThe doctor's decision. Issues a code on approval.
POST /oauth/tokenExchanges a code (plus PKCE verifier) or a refresh token for an access token.
POST /oauth/revokeRFC 7009 revocation — the stop button. Always returns 200, even for a token that never existed, so the endpoint can't be used to test whether a guess was a real token.

OAUTH-DESIGN.md documents a constraint discovered mid-build: packages/server/src/index.js:420 sets the session cookie to sameSite: true (Strict in production). A Strict cookie is not sent on a cross-site navigation — so when Claude sends the browser to /oauth/authorize, the doctor's existing clinic-web session cookie does not arrive, even if they're already logged in, in that same browser.

The flow therefore never depends on the session being visible on first landing. router.js:197-214 mints a random flowId and a browserToken, stores them together in Redis, and sets a SameSite=Lax cookie — Lax cookies do survive the return navigation. Only once the browser has come back through /oauth/callback (now same-site) are both the Lax flow cookie and the Strict session cookie visible together.

Per-flow, per-tab isolation

Each authorization attempt gets its own flowId and its own random browserToken, and the flow id travels in the return URL rather than the cookie name (router.js:198-222). This was a real bug during development — BUGS.md §6b: pasting an authorize URL into two tabs and logging in on the first overwrote the second tab's cookie, silently breaking the flow that was already in progress there.

packages/server/src/apis/oauth/consentPage.js builds the consent and clinic-chooser pages as inline HTML strings, not a clinic-web React component. The comment at the top of that file explains why: "this page is the security boundary of the whole flow, and it must not depend on a JavaScript bundle loading correctly before a doctor can see what they are agreeing to." Every dynamic value — client name, clinic name — is HTML-escaped (escapeHtml, line 16) because the client name comes from unauthenticated dynamic registration and is therefore attacker-controlled text rendered on a page carrying the doctor's session cookie.

CSP had to be loosened by exactly one origin, not disabled

Helmet sets form-action 'self' on every response by default. The consent form posts to /oauth/consent (same-origin, fine), but that endpoint's response redirects onward to the client's registered redirect URI — and browsers enforce form-action across the whole redirect chain of a form submission, not just the immediate target. Without a change, clicking "Connect" would appear to do nothing (BUGS.md §6e) — the button works, but Chrome silently blocks the navigation to claude.ai.

allowFormActionTo() (router.js:69-90) sets a per-response CSP header that adds exactly one additional origin — the redirect URI already validated against the registered client — rather than relaxing the policy generally.

Required change to existing, shared login code

clinic-web/src/components/auth/Login.js already used a ?redirect= query parameter to mean "this is a Canny SSO login" (hasRedirect, line 58). The new oauth_return parameter is deliberately a different name (see oauthReturn.js:14-17) so the two flows can't be confused — overloading redirect would have sent OAuth-connecting doctors to Canny instead.

goToOAuthReturn() (clinic-web/src/components/auth/oauthReturn.js:68-90) is the shared guard both login screens call. It:

  1. Reads oauth_return from the URL once, on first read, and remembers

it (getOAuthReturn, line 20-36) — not a micro-optimization. React Router can rewrite the address bar before an effect fires, so a doctor with an existing session would otherwise lose the parameter before the redirect logic ever saw it.

  1. Parses the address with new URL() and compares .origin exactly

against an allow-list of two origins: the web app's own origin, and the API's origin (BACKEND_URL). Two origins, not one, because the OAuth callback lives on the API — a different port locally, a different subdomain in production.

  1. Refuses anything that doesn't match, rather than throwing — the caller

just falls through to whatever it would normally have done.

The exact-origin comparison (rather than a prefix check) closes a real vulnerability found during development (BUGS.md §6d): an earlier version used startsWith(), which https://app.dentolize.com.attacker.example would have passed, since that string literally begins with the real origin.

Both login screens call this guard from two places — once at render time (for a doctor who already has a session) and once inside the submit handler, right before navigating (for a doctor who just logged in). LoginWithCompany.js needs the second call specifically because it calls navigate() in the same tick the login mutation resolves, without waiting for a re-render — a render-time-only guard, which was the first fix attempted, never runs on that path at all (BUGS.md §7, and demonstrated live in the Walkthrough).