diff --git a/docs/mcp.md b/docs/mcp.md index 129efa9..609b5b4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -12,39 +12,70 @@ conversation. ## Connecting it -**Make a token** under **Settings → API tokens**. The page then shows the -connector address to go with it — token and all — which is the thing to copy: +**Add the connector** in Claude with your instance's address followed by `/mcp`, +and nothing else: ``` -https://todo.example.dk/mcp?key=vrd_… +https://todo.example.dk/mcp ``` -**Add the connector** in Claude with that address, and nothing else. The dialog -asks for a name and a URL; leave the OAuth fields empty. +Claude sends you to verdande to sign in — the ordinary sign-in, passkey and +second factor included — and then asks one question: *"Claude wants to connect +to your account"*. Press **Allow** and you are connected. Leave the dialog's +OAuth client fields empty; Claude registers itself. -!!! note "Why the key is in the address" - The dialog has no field for a bearer token — only OAuth client credentials — - so a header cannot be configured from it. The address *is* the credential - here, exactly as it is for the [calendar feed](caldav.md#subscribing-to-a-feed). - Treat it like a password: anyone holding it reaches what you reach. The - server logs request paths without their query strings, so it does not end up - in the log. +The approval screen shows two things: the name the program gave itself, and +**where the access is sent**. Any program can call itself Claude; only Claude is +answered at `claude.ai`. If that line says something you do not recognise, press +**Deny**. !!! warning "It has to be reachable from the internet" Claude connects from Anthropic's side, not from your browser. An instance only reachable on your LAN or over a VPN cannot be used this way. +### With a token in the address instead + +A client that cannot sign in can be given a token in the address. **Make a token** +under **Settings → API tokens**; the page then shows the address to go with it: + +``` +https://todo.example.dk/mcp?key=vrd_… +``` + +Treat that address like a password: anyone holding it reaches what you reach. The +server logs request paths without their query strings, so it does not end up in +the log. + ### With a header instead -Anything that *can* send a header should, and use the API path: +Anything that *can* send a header should: ```bash curl -H "Authorization: Bearer vrd_…" \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ - https://todo.example.dk/api/v1/mcp + https://todo.example.dk/mcp ``` -Both endpoints run the same server and reach the same things. +`/api/v1/mcp` answers the same way. Both run the same server and reach the same +things. + +### How the sign-in works + +verdande is its own OAuth authorization server, for MCP clients only — the shape +the MCP specification asks for: + +- `/.well-known/oauth-protected-resource/mcp` and + `/.well-known/oauth-authorization-server` say where to sign in. An unsigned + request to `/mcp` is answered 401 with a `WWW-Authenticate` header pointing at + the first. +- Clients register themselves at `/oauth/register`. Public clients only: a program + running somewhere else cannot keep a secret, so PKCE (S256) is the proof + instead. A registration nobody approves is removed after a day. +- The code a client gets back is good for one minute and one exchange. +- What it is exchanged for is an ordinary personal API token. No refresh token and + no expiry: it is listed under **Settings → API tokens** with the client's name, + marked *connected with your account*, and connecting the same client again + replaces it rather than adding another. ## What Claude can do @@ -56,6 +87,9 @@ Both endpoints run the same server and reach the same things. | `update_task` | Change a title, priority, date or project. | | `complete_task` | Tick one off. | | `add_comment` | Comment on a task. | +| `search_notes` | Find notes by text. | +| `create_note` | Write a note, as Markdown. | +| `update_note` | Change a note. | `create_task` prefers natural language, so *"add file the VAT return tomorrow at 10am p1 #Accounts"* goes through the same parser the app uses — dates, priorities, @@ -71,8 +105,10 @@ scope to configure. ## What it cannot do Delete projects, manage members, change settings or read another account. The tools -are the six above; there is no general escape hatch. +are the nine above; there is no general escape hatch. !!! note "Revoking it" - **Settings → API tokens**, delete the token. The connector stops working - immediately, and nothing else about your account is affected. + **Settings → API tokens**, delete the token — a connection made by signing in + is listed there under the program's name. The connector stops working + immediately, and asks you to sign in again the next time it is used. Nothing + else about your account is affected. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 332f641..197f089 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3965,6 +3965,61 @@ paths: "422": { $ref: "#/components/responses/Validation" } "502": { $ref: "#/components/responses/Upstream" } + /api/v1/oauth/request: + get: + tags: [integrations] + summary: What the approval page is approving. + description: | + Takes the `/oauth/authorize` query unchanged. Answers with the client's + self-declared name and the host the code will be sent to — the part a + client cannot misstate. Needs a session. + security: + - session: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + client_name: { type: string } + redirect_to: { type: string, example: claude.ai } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/oauth/approve: + post: + tags: [integrations] + summary: Approve or refuse a connection. + description: | + Needs a session — a token cannot approve, or one leaked token could + mint others that outlive its revocation. Answers with the address to + send the browser to: the client's, with `code` or `error=access_denied`. + security: + - session: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [query, approve] + properties: + query: { type: string, description: "The `/oauth/authorize` query string, unchanged." } + approve: { type: boolean } + responses: + "200": + description: Where to go next. + content: + application/json: + schema: + type: object + properties: + redirect_to: { type: string } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + /api/v1/mcp: post: tags: [integrations] @@ -4426,21 +4481,27 @@ paths: /mcp: post: tags: [integrations] - summary: The MCP endpoint, with the token in the query string. + summary: The MCP endpoint at the address a person types into a connector. description: | - The same server as `/api/v1/mcp`, for a client that can only be given a - URL. Claude's custom-connector dialog asks for a name, an address and - OAuth client credentials — there is no field for a bearer token — so the - API path cannot be configured from it. + The same server as `/api/v1/mcp`. Two credentials are accepted: a bearer + token in `Authorization` — what a client holds after signing in with the + account through `/oauth/authorize` — or a token in `?key=`, for a client + that can only be given a URL. - The key is the only credential accepted here. A session cookie is - ignored: this route sits outside the CSRF check, and honouring a cookie - would make it a cross-site request that acts as whoever is signed in. - security: [] + Without either, the 401 carries `WWW-Authenticate: Bearer + resource_metadata="…/.well-known/oauth-protected-resource/mcp"`, which is + how a client finds the sign-in. + + A session cookie is ignored: this route sits outside the CSRF check, and + honouring a cookie would make it a cross-site request that acts as + whoever is signed in. + security: + - bearer: [] + - {} parameters: - name: key in: query - required: true + required: false schema: { type: string, example: vrd_… } description: A personal API token. Treat the whole address as a password. requestBody: @@ -4470,6 +4531,136 @@ paths: "202": { description: A notification. No body. } "401": { $ref: "#/components/responses/Unauthorized" } + /.well-known/oauth-protected-resource: + get: + tags: [integrations] + summary: Protected-resource metadata for the MCP endpoint (RFC 9728). + security: [] + responses: + "200": { $ref: "#/components/responses/ProtectedResource" } + + /.well-known/oauth-protected-resource/mcp: + get: + tags: [integrations] + summary: The same document, at the path RFC 9728 derives from `/mcp`. + security: [] + responses: + "200": { $ref: "#/components/responses/ProtectedResource" } + + /.well-known/oauth-authorization-server: + get: + tags: [integrations] + summary: Authorization-server metadata (RFC 8414). + description: | + The instance is its own authorization server, for MCP clients only. + Authorization code with S256 PKCE, public clients, no refresh tokens. + security: [] + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + issuer: { type: string } + authorization_endpoint: { type: string } + token_endpoint: { type: string } + registration_endpoint: { type: string } + code_challenge_methods_supported: { type: array, items: { type: string } } + + /oauth/register: + post: + tags: [integrations] + summary: Dynamic client registration (RFC 7591). + description: | + Open to anybody, rate limited, public clients only. Redirect addresses + must be https, http on a loopback address (any port), or an app's own + scheme. Metadata the server does not use is ignored. A registration that + is never approved is removed after a day. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [redirect_uris] + properties: + client_name: { type: string } + redirect_uris: { type: array, items: { type: string } } + token_endpoint_auth_method: { type: string, enum: [none] } + responses: + "201": + description: Registered. + content: + application/json: + schema: + type: object + properties: + client_id: { type: string } + client_name: { type: string } + redirect_uris: { type: array, items: { type: string } } + "400": { $ref: "#/components/responses/OAuthError" } + "429": { description: Too many attempts. } + + /oauth/authorize: + get: + tags: [integrations] + summary: Where a client sends the browser to sign in and approve. + description: | + Checks the request and redirects to the app's approval page, `/forbind`, + which signs the person in first if needed. An unknown client or an + unregistered redirect address is refused here with 400, and nothing is + sent anywhere; other problems are sent back to the client as `error=`. + security: [] + parameters: + - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } } + - { name: client_id, in: query, required: true, schema: { type: string } } + - { name: redirect_uri, in: query, schema: { type: string } } + - { name: state, in: query, schema: { type: string } } + - { name: code_challenge, in: query, required: true, schema: { type: string } } + - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } } + responses: + "302": { description: "To the approval page, or back to the client with an error." } + "400": { description: The client or its redirect address cannot be trusted. } + + /oauth/token: + post: + tags: [integrations] + summary: Exchange an authorization code for a token. + description: | + `authorization_code` only. The code is good for one minute and one + attempt. The token is an ordinary personal API token, listed under + Settings → Tokens with the client's name; connecting the same client + again replaces it. + security: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: [grant_type, code, client_id, code_verifier] + properties: + grant_type: { type: string, enum: [authorization_code] } + code: { type: string } + client_id: { type: string } + redirect_uri: { type: string } + code_verifier: { type: string } + responses: + "200": + description: The token. + content: + application/json: + schema: + type: object + properties: + access_token: { type: string, example: vrd_… } + token_type: { type: string, const: Bearer } + "400": { $ref: "#/components/responses/OAuthError" } + "429": { description: Too many attempts. } + /.well-known/caldav: get: tags: [integrations] @@ -4504,6 +4695,24 @@ components: browser's own session is never accepted here. responses: + ProtectedResource: + description: OK + content: + application/json: + schema: + type: object + properties: + resource: { type: string, example: https://todo.example.dk/mcp } + authorization_servers: { type: array, items: { type: string } } + OAuthError: + description: The error shape of RFC 6749 §5.2. + content: + application/json: + schema: + type: object + properties: + error: { type: string, example: invalid_grant } + error_description: { type: string } BadRequest: description: The body could not be read. content: @@ -5382,6 +5591,9 @@ components: created_at: { type: string, format: date-time } last_used_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } + connected: + type: boolean + description: "True for a token an app received by signing in with the account, rather than one made by hand." Notification: type: object diff --git a/internal/httpapi/edition_test.go b/internal/httpapi/edition_test.go index f84679c..a3f0135 100644 --- a/internal/httpapi/edition_test.go +++ b/internal/httpapi/edition_test.go @@ -185,6 +185,10 @@ func TestNotesEditionServesOnlyItsOwnRoutes(t *testing.T) { // er værktøjslisten bagved, der er kortere. "POST /api/v1/mcp", "POST /mcp", + // Og at forbinde med kontoen, som hører til MCP og ikke til opgaverne. + "GET /.well-known/oauth-authorization-server", + "POST /oauth/token", + "POST /api/v1/oauth/approve", } { if !notes[must] { t.Errorf("the notes edition does not serve %s — that is a deletion, not a gate", must) diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go index 7e26122..a0626a0 100644 --- a/internal/httpapi/integrations_test.go +++ b/internal/httpapi/integrations_test.go @@ -1172,17 +1172,16 @@ func TestMailAddressPrefersTheConfiguredSender(t *testing.T) { // namespace, and a client probing it is asking a yes-or-no question. // // Claude asks /.well-known/oauth-authorization-server before connecting to an -// MCP server. It read the shell as "there is an authorization server here" and -// then failed trying to register with it, reporting a broken sign-in service on -// a server that has none and needs none. +// MCP server. Before that was a real route it read the shell as "there is an +// authorization server here" and failed trying to register with it. The OAuth +// paths are answered by oauth_test.go now; everything else must still be a 404. func TestWellKnownDoesNotAnswerWithTheAppShell(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) for _, path := range []string{ - "/.well-known/oauth-authorization-server", - "/.well-known/oauth-protected-resource", "/.well-known/openid-configuration", + "/.well-known/oauth-protected-resource/somewhere-else", "/.well-known/anything-at-all", } { resp, err := ts.client.Get(ts.URL + path) diff --git a/internal/httpapi/mcp_handlers.go b/internal/httpapi/mcp_handlers.go index 6792ae2..e263283 100644 --- a/internal/httpapi/mcp_handlers.go +++ b/internal/httpapi/mcp_handlers.go @@ -138,26 +138,34 @@ func (s *Server) handleMCP(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, response) } -// handleMCPWithKey is the same endpoint for a client that can only be given a -// URL: `POST /mcp?key=vrd_…`. +// handleMCPWithKey is the same endpoint at the address a person types into a +// connector dialog: `POST /mcp`. Two credentials are accepted here. // -// Claude's custom-connector dialog asks for a name, an address and — under -// advanced settings — an OAuth client id and secret. There is no field for a -// bearer token, so /api/v1/mcp cannot be configured from it however correct the -// address is. The calendar feed already carries its credential in the URL for -// the same reason, and the request logger records the path without the query, so -// the key does not end up in the log. +// `Authorization: Bearer vrd_…` is what a client sends after signing in with the +// account (oauth_handlers.go), and what anything holding a token by hand can +// send as well. // -// The key is the *only* thing accepted here. Reading a session cookie instead -// would make this a state-changing POST outside the CSRF check — which is to say -// a cross-site request that acts as whoever is signed in. +// `?key=vrd_…` is for a client that can only be given a URL. Claude's +// custom-connector dialog has no field for a header, so before the sign-in +// existed this was the only way to configure it, and addresses made that way +// still work. The calendar feed carries its credential in the URL for the same +// reason, and the request logger records the path without the query, so the key +// does not end up in the log. +// +// A session cookie is *never* accepted here. That would make this a +// state-changing POST outside the CSRF check — a cross-site request that acts as +// whoever is signed in. A bearer header is safe where a cookie is not: a browser +// will not attach one to a cross-site request by itself. func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) { key := r.URL.Query().Get("key") + if h := r.Header.Get("Authorization"); key == "" && strings.HasPrefix(h, "Bearer ") { + key = strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + } if key == "" || !auth.IsAPIToken(key) { // 401 rather than 404: unlike a task, the existence of this endpoint is - // not a secret — it is written in the documentation. - writeError(w, http.StatusUnauthorized, CodeUnauthorized, - "add ?key= to the URL") + // not a secret — it is written in the documentation. And the header is + // what starts the sign-in: it tells the client where to read how. + s.mcpUnauthorized(w, "sign in, or add ?key= to the URL") return } @@ -167,13 +175,22 @@ func (s *Server) handleMCPWithKey(w http.ResponseWriter, r *http.Request) { s.internal(w, r, "mcp key", err) return } - writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key is not valid") + // Also the answer to a token revoked under Settings, which is why the + // header is here too: the client then asks the person to sign in again. + s.mcpUnauthorized(w, "that key is not valid") return } s.handleMCP(w, r.WithContext(withUser(r.Context(), user))) } +// mcpUnauthorized is the 401 that points a client at the sign-in (RFC 9728 §5.1). +func (s *Server) mcpUnauthorized(w http.ResponseWriter, message string) { + w.Header().Set("WWW-Authenticate", + `Bearer resource_metadata="`+s.protectedResourceMetadataURL()+`"`) + writeError(w, http.StatusUnauthorized, CodeUnauthorized, message) +} + // --- tool implementations ------------------------------------------------------------ func (s *Server) mcpListProjects(ctx context.Context, userID string, args json.RawMessage) (any, error) { diff --git a/internal/httpapi/oauth_handlers.go b/internal/httpapi/oauth_handlers.go new file mode 100644 index 0000000..f05ea5b --- /dev/null +++ b/internal/httpapi/oauth_handlers.go @@ -0,0 +1,558 @@ +package httpapi + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + "unicode" + + "github.com/kristianwind/verdande/internal/store" +) + +// Signing in to MCP with the account, rather than with a token pasted into a URL. +// +// Claude's connector dialog tries OAuth before anything else: it asks +// /.well-known for an authorization server, registers itself, sends the person +// to sign in and approve, and comes back with a code. Until this existed the +// answer was a 404, and the only way in was to make a token under Settings and +// paste it into the address — a secret copied between two programs, which is the +// step people stopped at. +// +// The shape is the smallest one the MCP authorization spec asks for: +// +// - discovery: protected-resource metadata (RFC 9728) pointing at this instance +// as its own authorization server (RFC 8414); +// - dynamic client registration (RFC 7591), public clients only — a program +// running somewhere else cannot keep a secret, so PKCE is the proof instead; +// - the authorization-code grant with S256 PKCE, and nothing else. +// +// What an approval hands out is an ordinary personal API token. That is the +// whole permission model: a token is a person, it is listed under Settings → +// Tokens next to the hand-made ones, and revoking it there disconnects the +// client. No refresh token and no expiry, because there is nothing for one to +// protect that the token page does not already — a client that is cut off is +// answered 401 and asks the person to sign in again. + +const oauthCodeTTL = time.Minute + +// oauthGrant is what an authorization code stands for until it is exchanged. +type oauthGrant struct { + userID string + clientID string + redirectURI string + challenge string + scope string + expires time.Time +} + +// oauthCodes are held in memory, like the socket tickets: good for a minute and +// once. A restart in that minute costs one more click, which is cheaper than a +// table of short-lived secrets. +type oauthCodes struct { + mu sync.Mutex + m map[string]oauthGrant +} + +func newOAuthCodes() *oauthCodes { return &oauthCodes{m: map[string]oauthGrant{}} } + +func (c *oauthCodes) issue(g oauthGrant) (string, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", err + } + code := hex.EncodeToString(raw) + + c.mu.Lock() + defer c.mu.Unlock() + now := time.Now() + for k, v := range c.m { + if now.After(v.expires) { + delete(c.m, k) + } + } + g.expires = now.Add(oauthCodeTTL) + c.m[code] = g + return code, nil +} + +// redeem spends a code. Deleted before it is checked, so a code presented twice +// fails the second time whatever the first one did — a replayed code is the +// thing PKCE and single use are both there to stop. +func (c *oauthCodes) redeem(code string) (oauthGrant, bool) { + c.mu.Lock() + defer c.mu.Unlock() + g, ok := c.m[code] + delete(c.m, code) + if !ok || time.Now().After(g.expires) { + return oauthGrant{}, false + } + return g, true +} + +// --- discovery ----------------------------------------------------------------------- + +// mcpResource is the address the protected resource is known by: the URL a +// person types into the connector dialog. +func (s *Server) mcpResource() string { return s.cfg.BaseURL + "/mcp" } + +func (s *Server) protectedResourceMetadataURL() string { + return s.cfg.BaseURL + "/.well-known/oauth-protected-resource/mcp" +} + +func (s *Server) handleProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) { + openCORS(w) + writeJSON(w, http.StatusOK, map[string]any{ + "resource": s.mcpResource(), + "authorization_servers": []string{s.cfg.BaseURL}, + "bearer_methods_supported": []string{"header"}, + "resource_name": s.cfg.ProductSlug(), + }) +} + +func (s *Server) handleAuthorizationServerMetadata(w http.ResponseWriter, r *http.Request) { + openCORS(w) + base := s.cfg.BaseURL + writeJSON(w, http.StatusOK, map[string]any{ + "issuer": base, + "authorization_endpoint": base + "/oauth/authorize", + "token_endpoint": base + "/oauth/token", + "registration_endpoint": base + "/oauth/register", + "response_types_supported": []string{"code"}, + "grant_types_supported": []string{"authorization_code"}, + "code_challenge_methods_supported": []string{"S256"}, + "token_endpoint_auth_methods_supported": []string{"none"}, + }) +} + +// openCORS lets a browser-based client read these answers. Safe for exactly the +// routes it is on: none of them reads a cookie, so a cross-site caller learns +// nothing and can do nothing it could not do with curl. +func openCORS(w http.ResponseWriter) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") +} + +func handleOpenPreflight(w http.ResponseWriter, r *http.Request) { + openCORS(w) + w.WriteHeader(http.StatusNoContent) +} + +// oauthError is the error shape RFC 6749 §5.2 prescribes for the token and +// registration endpoints — a client library parses `error`, not our `code`. +func oauthError(w http.ResponseWriter, status int, code, description string) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, map[string]string{"error": code, "error_description": description}) +} + +// --- registration -------------------------------------------------------------------- + +type registerRequest struct { + ClientName string `json:"client_name"` + RedirectURIs []string `json:"redirect_uris"` + TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"` +} + +func (s *Server) handleOAuthRegister(w http.ResponseWriter, r *http.Request) { + openCORS(w) + + // Not decodeJSON: a registration request carries whatever metadata the + // client library fills in — logo_uri, grant_types, scope, contacts — and + // RFC 7591 says a server ignores what it does not use. Refusing unknown + // fields here would refuse every real client. + var req registerRequest + if err := decodeLenient(r, &req); err != nil { + oauthError(w, http.StatusBadRequest, "invalid_client_metadata", "the body is not JSON") + return + } + + if len(req.RedirectURIs) == 0 || len(req.RedirectURIs) > 10 { + oauthError(w, http.StatusBadRequest, "invalid_redirect_uri", + "between one and ten redirect_uris are required") + return + } + for _, u := range req.RedirectURIs { + if !validRedirectURI(u) { + oauthError(w, http.StatusBadRequest, "invalid_redirect_uri", + "redirect_uris must be https, http on a loopback address, or an app's own scheme: "+u) + return + } + } + if m := req.TokenEndpointAuthMethod; m != "" && m != "none" { + // Said rather than silently downgraded: a client that believes it holds a + // secret would send one and wonder why it was ignored. + oauthError(w, http.StatusBadRequest, "invalid_client_metadata", + "only public clients are supported; use token_endpoint_auth_method \"none\" with PKCE") + return + } + + name := cleanClientName(req.ClientName) + client, err := s.db.CreateOAuthClient(r.Context(), name, req.RedirectURIs) + if err != nil { + s.internal(w, r, "register oauth client", err) + return + } + + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusCreated, map[string]any{ + "client_id": client.ID, + "client_id_issued_at": client.CreatedAt.Unix(), + "client_name": client.Name, + "redirect_uris": client.RedirectURIs, + "grant_types": []string{"authorization_code"}, + "response_types": []string{"code"}, + "token_endpoint_auth_method": "none", + }) +} + +// cleanClientName makes the self-declared name fit to show. It is shown on the +// approval screen and on the tokens page, so it is trimmed, stripped of control +// characters and capped — and it is never trusted to say who the client is: the +// approval screen also shows where the code will be sent, which a client cannot +// choose to misstate. +func cleanClientName(name string) string { + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, strings.TrimSpace(name)) + if runes := []rune(name); len(runes) > 60 { + name = strings.TrimSpace(string(runes[:60])) + } + if name == "" { + name = "MCP-klient" + } + return name +} + +// validRedirectURI is the rule for where a code may be sent. +// +// https anywhere; plain http only to this machine's loopback (RFC 8252 §7.3, +// where a desktop client listens on a port it picks at run time); and an app's +// own scheme (§7.1), which is how Cursor and its like receive one. The schemes a +// browser would *run* rather than navigate to are refused by name — the approval +// page hands the address to `location`, and `javascript:` there is script +// running on this origin. +func validRedirectURI(raw string) bool { + if len(raw) > 2000 { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Fragment != "" || u.Scheme == "" { + return false + } + switch scheme := strings.ToLower(u.Scheme); scheme { + case "https": + return u.Host != "" + case "http": + return isLoopback(u.Hostname()) + case "javascript", "data", "file", "vbscript", "blob", "about", "ws", "wss", "ftp": + return false + default: + // The scheme's own syntax, RFC 3986 §3.1. url.Parse is lenient about it. + for i, r := range scheme { + if !(r >= 'a' && r <= 'z' || i > 0 && (r >= '0' && r <= '9' || r == '+' || r == '-' || r == '.')) { + return false + } + } + return true + } +} + +func isLoopback(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// redirectAllowed reports whether got is one of the client's registered +// addresses. Exact, except that a loopback address may differ in its port: a +// desktop client registers once and listens wherever the OS lets it next time. +func redirectAllowed(registered []string, got string) bool { + for _, want := range registered { + if want == got { + return true + } + } + g, err := url.Parse(got) + if err != nil || g.Scheme != "http" || !isLoopback(g.Hostname()) { + return false + } + for _, want := range registered { + w, err := url.Parse(want) + if err != nil || w.Scheme != "http" || !isLoopback(w.Hostname()) { + continue + } + if w.Hostname() == g.Hostname() && w.Path == g.Path && w.RawQuery == g.RawQuery { + return true + } + } + return false +} + +// --- authorization ------------------------------------------------------------------- + +// authorizeRequest is a checked /oauth/authorize query. +type authorizeRequest struct { + client *store.OAuthClient + redirectURI string + state string + challenge string + scope string +} + +// errBadClient means the client or its redirect address could not be trusted, +// so the answer must NOT be sent to that address (RFC 6749 §4.1.2.1) — it is +// shown to the person instead. Every other problem goes back to the client. +var errBadClient = errors.New("bad client") + +// checkAuthorize validates a query. It runs twice — when the browser arrives, +// and again when the person presses approve — because the approval page is a +// page in the app, and what it posts back is whatever is in its address bar. +func (s *Server) checkAuthorize(r *http.Request, q url.Values) (*authorizeRequest, string, error) { + client, err := s.db.OAuthClient(r.Context(), q.Get("client_id")) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, "this app is not registered here — remove the connection and add it again", errBadClient + } + return nil, "", err + } + redirect := q.Get("redirect_uri") + if redirect == "" && len(client.RedirectURIs) == 1 { + redirect = client.RedirectURIs[0] + } + if !redirectAllowed(client.RedirectURIs, redirect) { + return nil, "the app asked to be answered at an address it did not register", errBadClient + } + + req := &authorizeRequest{ + client: client, redirectURI: redirect, + state: q.Get("state"), challenge: q.Get("code_challenge"), scope: q.Get("scope"), + } + switch { + case q.Get("response_type") != "code": + return req, "unsupported_response_type", nil + case req.challenge == "" || q.Get("code_challenge_method") != "S256": + // OAuth 2.1 makes PKCE mandatory, and plain PKCE protects nothing that + // S256 does not, so it is S256 or no code. + return req, "invalid_request", nil + } + return req, "", nil +} + +// redirectWith builds the address the browser is sent back to. Callers include +// `iss` (RFC 9207): it says who answered, so a client talking to several servers +// cannot be handed one server's code by another. +func (a *authorizeRequest) redirectWith(params url.Values) string { + if a.state != "" { + params.Set("state", a.state) + } + sep := "?" + if strings.Contains(a.redirectURI, "?") { + sep = "&" + } + return a.redirectURI + sep + params.Encode() +} + +// handleOAuthAuthorize is where the client sends the browser. It checks the +// request and hands over to the app's approval page, which signs the person in +// first if it needs to — the same sign-in screen, passkeys and second factor +// included, rather than a second, lesser one written for OAuth. +func (s *Server) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + req, problem, err := s.checkAuthorize(r, q) + if errors.Is(err, errBadClient) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(s.cfg.ProductSlug() + ": " + problem + ".\n")) + return + } + if err != nil { + s.internal(w, r, "oauth authorize", err) + return + } + if problem != "" { + http.Redirect(w, r, req.redirectWith(url.Values{ + "error": {problem}, "iss": {s.cfg.BaseURL}, + }), http.StatusFound) + return + } + http.Redirect(w, r, "/forbind?"+r.URL.RawQuery, http.StatusFound) +} + +// handleOAuthRequest tells the approval page what it is approving. +func (s *Server) handleOAuthRequest(w http.ResponseWriter, r *http.Request) { + req, problem, err := s.checkAuthorize(r, r.URL.Query()) + if err != nil && !errors.Is(err, errBadClient) { + s.internal(w, r, "oauth request", err) + return + } + if err == nil && problem != "" { + problem = "the app's request is not valid (" + problem + ")" + } + if problem != "" { + writeError(w, http.StatusBadRequest, CodeBadRequest, problem) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "client_name": req.client.Name, + // Where the code goes, which is the part a client cannot lie about. Any + // program can register itself as "Claude"; only Claude is answered at + // claude.ai. + "redirect_to": redirectLabel(req.redirectURI), + }) +} + +func redirectLabel(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return raw + } + if u.Host != "" { + return u.Hostname() + } + return u.Scheme + ":" +} + +type approveRequest struct { + // Query is the approval page's own query string, exactly as the client sent + // it. Posted whole rather than as fields, so this and handleOAuthAuthorize + // read one thing with one function and cannot drift apart. + Query string `json:"query"` + Approve bool `json:"approve"` +} + +// handleOAuthApprove is the person's answer. Behind requireSession: an approval +// mints a token, and a token must not be able to mint another. +func (s *Server) handleOAuthApprove(w http.ResponseWriter, r *http.Request) { + var body approveRequest + if err := decodeJSON(w, r, &body); err != nil { + return + } + q, err := url.ParseQuery(strings.TrimPrefix(body.Query, "?")) + if err != nil { + writeError(w, http.StatusBadRequest, CodeBadRequest, "the request could not be read") + return + } + req, problem, err := s.checkAuthorize(r, q) + if err != nil && !errors.Is(err, errBadClient) { + s.internal(w, r, "oauth approve", err) + return + } + if err == nil && problem != "" { + problem = "the app's request is not valid (" + problem + ")" + } + if problem != "" { + writeError(w, http.StatusBadRequest, CodeBadRequest, problem) + return + } + + if !body.Approve { + writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{ + "error": {"access_denied"}, "iss": {s.cfg.BaseURL}, + })}) + return + } + + code, err := s.oauthCodes.issue(oauthGrant{ + userID: userFrom(r.Context()).ID, clientID: req.client.ID, + redirectURI: req.redirectURI, challenge: req.challenge, scope: req.scope, + }) + if err != nil { + s.internal(w, r, "oauth code", err) + return + } + writeJSON(w, http.StatusOK, map[string]string{"redirect_to": req.redirectWith(url.Values{ + "code": {code}, "iss": {s.cfg.BaseURL}, + })}) +} + +// --- token --------------------------------------------------------------------------- + +func (s *Server) handleOAuthToken(w http.ResponseWriter, r *http.Request) { + openCORS(w) + r.Body = http.MaxBytesReader(w, r.Body, 16<<10) + if err := r.ParseForm(); err != nil { + oauthError(w, http.StatusBadRequest, "invalid_request", "the body must be form-encoded") + return + } + if gt := r.PostForm.Get("grant_type"); gt != "authorization_code" { + oauthError(w, http.StatusBadRequest, "unsupported_grant_type", + "only authorization_code is supported") + return + } + + clientID := r.PostForm.Get("client_id") + if id, _, ok := r.BasicAuth(); ok && clientID == "" { + clientID = id + } + + grant, ok := s.oauthCodes.redeem(r.PostForm.Get("code")) + if !ok { + oauthError(w, http.StatusBadRequest, "invalid_grant", "the code is unknown, used or expired") + return + } + // Each of these is a code presented by somebody other than the one it was + // issued to, or for somewhere else. The code is already spent either way. + if clientID != grant.clientID { + oauthError(w, http.StatusBadRequest, "invalid_grant", "the code was issued to another client") + return + } + if ru := r.PostForm.Get("redirect_uri"); ru != "" && ru != grant.redirectURI { + oauthError(w, http.StatusBadRequest, "invalid_grant", "redirect_uri does not match the authorization") + return + } + if !pkceMatches(r.PostForm.Get("code_verifier"), grant.challenge) { + oauthError(w, http.StatusBadRequest, "invalid_grant", "code_verifier does not match") + return + } + + client, err := s.db.OAuthClient(r.Context(), grant.clientID) + if err != nil { + oauthError(w, http.StatusBadRequest, "invalid_client", "the client is no longer registered") + return + } + token, err := s.db.CreateOAuthToken(r.Context(), grant.userID, client) + if err != nil { + s.internal(w, r, "oauth token", err) + return + } + + w.Header().Set("Cache-Control", "no-store") + out := map[string]string{"access_token": token, "token_type": "Bearer"} + if grant.scope != "" { + out["scope"] = grant.scope + } + writeJSON(w, http.StatusOK, out) +} + +// pkceMatches is RFC 7636 §4.6 for S256: the challenge is the unpadded base64url +// SHA-256 of the verifier. +func pkceMatches(verifier, challenge string) bool { + if len(verifier) < 43 || len(verifier) > 128 { + return false + } + sum := sha256.Sum256([]byte(verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + return subtle.ConstantTimeCompare([]byte(want), []byte(challenge)) == 1 +} + +// decodeLenient reads a JSON body that may carry fields this server does not +// use. Only for the registration endpoint — see the comment there. +func decodeLenient(r *http.Request, dst any) error { + return json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(dst) +} diff --git a/internal/httpapi/oauth_test.go b/internal/httpapi/oauth_test.go new file mode 100644 index 0000000..d78c218 --- /dev/null +++ b/internal/httpapi/oauth_test.go @@ -0,0 +1,398 @@ +package httpapi + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" +) + +// Signing in to MCP with the account: the whole dance Claude performs, from the +// first 401 to a tool call made with the token it was handed. + +const claudeCallback = "https://claude.ai/api/mcp/auth_callback" + +// A verifier of the minimum legal length, and its S256 challenge. +var ( + testVerifier = strings.Repeat("v", 43) + testChallenge = func() string { + sum := sha256.Sum256([]byte(testVerifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) + }() +) + +func postJSON(t *testing.T, target, body string) (*http.Response, map[string]any) { + t.Helper() + resp, err := http.Post(target, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var decoded map[string]any + _ = json.NewDecoder(resp.Body).Decode(&decoded) + return resp, decoded +} + +// registerClient registers the way Claude does, extra metadata included. +func registerClient(t *testing.T, ts *testServer, redirect string) string { + t.Helper() + resp, body := postJSON(t, ts.URL+"/oauth/register", `{ + "client_name": "Claude", + "redirect_uris": ["`+redirect+`"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "token_endpoint_auth_method": "none", + "scope": "claudeai" + }`) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("register: status %d, %v", resp.StatusCode, body) + } + id, _ := body["client_id"].(string) + if id == "" { + t.Fatalf("register: no client_id in %v", body) + } + return id +} + +func authorizeQuery(clientID, redirect string) string { + return url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {redirect}, + "state": {"st4te"}, + "code_challenge": {testChallenge}, + "code_challenge_method": {"S256"}, + "resource": {"http://localhost/mcp"}, + }.Encode() +} + +// approve is the approval page pressing "Tillad", as the signed-in ts.client. +// It returns the code from the address the browser is sent back to. +func approve(t *testing.T, ts *testServer, query string) string { + t.Helper() + resp, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{ + "query": "?" + query, "approve": true, + }) + if resp.StatusCode != http.StatusOK { + t.Fatalf("approve: status %d, %v", resp.StatusCode, body) + } + back, err := url.Parse(body["redirect_to"].(string)) + if err != nil { + t.Fatal(err) + } + if got := back.Query().Get("state"); got != "st4te" { + t.Errorf("state = %q, want it handed back unchanged", got) + } + return back.Query().Get("code") +} + +func exchange(t *testing.T, ts *testServer, form url.Values) (*http.Response, map[string]any) { + t.Helper() + resp, err := http.PostForm(ts.URL+"/oauth/token", form) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var decoded map[string]any + _ = json.NewDecoder(resp.Body).Decode(&decoded) + return resp, decoded +} + +func codeForm(clientID, code string) url.Values { + return url.Values{ + "grant_type": {"authorization_code"}, "code": {code}, "client_id": {clientID}, + "redirect_uri": {claudeCallback}, "code_verifier": {testVerifier}, + } +} + +func mcpInitialize(t *testing.T, ts *testServer, bearer string) *http.Response { + t.Helper() + req, _ := http.NewRequest("POST", ts.URL+"/mcp", + strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`)) + req.Header.Set("Content-Type", "application/json") + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + return resp +} + +func TestMCPSignInWithTheAccount(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + // 1. The connector's first request has no credential, and the answer says + // where to read how to get one. + resp := mcpInitialize(t, ts, "") + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated /mcp: status %d", resp.StatusCode) + } + challenge := resp.Header.Get("WWW-Authenticate") + want := `resource_metadata="http://localhost/.well-known/oauth-protected-resource/mcp"` + if !strings.Contains(challenge, want) { + t.Fatalf("WWW-Authenticate = %q, want it to contain %s", challenge, want) + } + + // 2. Discovery, both documents. + var prm, asm map[string]any + for path, into := range map[string]*map[string]any{ + "/.well-known/oauth-protected-resource/mcp": &prm, + "/.well-known/oauth-authorization-server": &asm, + } { + r, err := http.Get(ts.URL + path) + if err != nil { + t.Fatal(err) + } + json.NewDecoder(r.Body).Decode(into) + r.Body.Close() + if r.StatusCode != http.StatusOK { + t.Fatalf("%s: status %d", path, r.StatusCode) + } + } + if prm["resource"] != "http://localhost/mcp" { + t.Errorf("resource = %v, want the address a person types in", prm["resource"]) + } + if asm["issuer"] != "http://localhost" || asm["registration_endpoint"] != "http://localhost/oauth/register" { + t.Errorf("authorization server metadata = %v", asm) + } + + // 3. Registration, 4. the browser's visit, 5. approval, 6. the exchange. + clientID := registerClient(t, ts, claudeCallback) + query := authorizeQuery(clientID, claudeCallback) + + noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + query) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if loc := r.Header.Get("Location"); r.StatusCode != http.StatusFound || !strings.HasPrefix(loc, "/forbind?") { + t.Fatalf("authorize: status %d, Location %q — want the approval page", r.StatusCode, loc) + } + + // The approval page's question: what is this, and where does it go. + info, body := ts.do(t, "GET", "/api/v1/oauth/request?"+query, nil) + if info.StatusCode != http.StatusOK || body["client_name"] != "Claude" || body["redirect_to"] != "claude.ai" { + t.Fatalf("request info: status %d, %v", info.StatusCode, body) + } + + code := approve(t, ts, query) + tokResp, tok := exchange(t, ts, codeForm(clientID, code)) + if tokResp.StatusCode != http.StatusOK { + t.Fatalf("token: status %d, %v", tokResp.StatusCode, tok) + } + access, _ := tok["access_token"].(string) + if tok["token_type"] != "Bearer" || !strings.HasPrefix(access, "vrd_") { + t.Fatalf("token response = %v", tok) + } + if cc := tokResp.Header.Get("Cache-Control"); cc != "no-store" { + t.Errorf("Cache-Control = %q on a response carrying a token", cc) + } + + // 7. The token works where the person typed the address. + if resp := mcpInitialize(t, ts, access); resp.StatusCode != http.StatusOK { + t.Fatalf("/mcp with the token: status %d", resp.StatusCode) + } + + // 8. And it is on the tokens page, under the client's name, marked as such. + _, list := ts.do(t, "GET", "/api/v1/tokens", nil) + tokens, _ := list["tokens"].([]any) + if len(tokens) != 1 { + t.Fatalf("tokens page lists %d, want 1: %v", len(tokens), list) + } + entry := tokens[0].(map[string]any) + if entry["name"] != "Claude" || entry["connected"] != true { + t.Errorf("token entry = %v", entry) + } + + // 9. Revoked there, the client is told to sign in again. + if resp, _ := ts.do(t, "DELETE", "/api/v1/tokens/"+entry["id"].(string), nil); resp.StatusCode != http.StatusNoContent { + t.Fatalf("revoke: status %d", resp.StatusCode) + } + resp = mcpInitialize(t, ts, access) + if resp.StatusCode != http.StatusUnauthorized || resp.Header.Get("WWW-Authenticate") == "" { + t.Errorf("revoked token: status %d, WWW-Authenticate %q", resp.StatusCode, resp.Header.Get("WWW-Authenticate")) + } +} + +// A code is good once. A second exchange — a replay, or a code that leaked — gets +// nothing, and neither does a wrong verifier, the wrong client or the wrong +// redirect: each is somebody other than the one the code was issued to. +func TestOAuthCodeIsBoundAndSingleUse(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + clientID := registerClient(t, ts, claudeCallback) + other := registerClient(t, ts, claudeCallback) + query := authorizeQuery(clientID, claudeCallback) + + cases := map[string]func(url.Values){ + "wrong verifier": func(f url.Values) { f.Set("code_verifier", strings.Repeat("w", 43)) }, + "no verifier": func(f url.Values) { f.Del("code_verifier") }, + "other client": func(f url.Values) { f.Set("client_id", other) }, + "other redirect": func(f url.Values) { f.Set("redirect_uri", "https://claude.ai/elsewhere") }, + } + for name, spoil := range cases { + code := approve(t, ts, query) + form := codeForm(clientID, code) + spoil(form) + if resp, body := exchange(t, ts, form); resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_grant" { + t.Errorf("%s: status %d, %v — want invalid_grant", name, resp.StatusCode, body) + } + // Spent by the failed attempt, so the rightful owner cannot use it either. + if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest { + t.Errorf("%s: the code survived a failed exchange", name) + } + } + + code := approve(t, ts, query) + if resp, _ := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusOK { + t.Fatalf("first exchange: status %d", resp.StatusCode) + } + if resp, body := exchange(t, ts, codeForm(clientID, code)); resp.StatusCode != http.StatusBadRequest { + t.Errorf("replayed code: status %d, %v", resp.StatusCode, body) + } +} + +// Where a code may be sent is the whole security of the approval: a client +// registers its addresses, and a request for any other is refused without +// sending anything anywhere. +func TestOAuthRedirectRules(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + + for _, bad := range []string{ + "javascript:alert(1)", + "data:text/html,hi", + "http://evil.example/cb", // plain http off this machine + "https://claude.ai/cb#frag", + "not a uri", + } { + resp, body := postJSON(t, ts.URL+"/oauth/register", + `{"client_name":"x","redirect_uris":[`+jsonString(bad)+`]}`) + if resp.StatusCode != http.StatusBadRequest || body["error"] != "invalid_redirect_uri" { + t.Errorf("registering %q: status %d, %v", bad, resp.StatusCode, body) + } + } + + clientID := registerClient(t, ts, claudeCallback) + noFollow := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }} + r, err := noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(clientID, "https://evil.example/cb")) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if r.StatusCode != http.StatusBadRequest || r.Header.Get("Location") != "" { + t.Errorf("unregistered redirect: status %d, Location %q — want a refusal shown here", + r.StatusCode, r.Header.Get("Location")) + } + resp, _ := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{ + "query": authorizeQuery(clientID, "https://evil.example/cb"), "approve": true, + }) + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("approving an unregistered redirect: status %d", resp.StatusCode) + } + + // A desktop client listens on whatever port it is given that day. + loopback := registerClient(t, ts, "http://127.0.0.1/callback") + r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + authorizeQuery(loopback, "http://127.0.0.1:53682/callback")) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if r.StatusCode != http.StatusFound || !strings.HasPrefix(r.Header.Get("Location"), "/forbind?") { + t.Errorf("loopback with a port: status %d, Location %q", r.StatusCode, r.Header.Get("Location")) + } + + // No PKCE is answered to the client — its address is trusted by now. + q, _ := url.ParseQuery(authorizeQuery(clientID, claudeCallback)) + q.Del("code_challenge") + r, err = noFollow.Get(ts.URL + "/oauth/authorize?" + q.Encode()) + if err != nil { + t.Fatal(err) + } + r.Body.Close() + if loc := r.Header.Get("Location"); !strings.HasPrefix(loc, claudeCallback+"?") || !strings.Contains(loc, "error=invalid_request") { + t.Errorf("no PKCE: Location %q", loc) + } +} + +// An approval mints a token, so a token must not be able to approve: otherwise +// one leaked token is a machine for making more that survive its revocation. +func TestOAuthApprovalNeedsASession(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + var userID string + if err := ts.db.QueryRowContext(t.Context(), `SELECT id FROM users LIMIT 1`).Scan(&userID); err != nil { + t.Fatal(err) + } + key := ts.apiToken(t, userID) + clientID := registerClient(t, ts, claudeCallback) + + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/oauth/approve", + strings.NewReader(`{"query":"`+authorizeQuery(clientID, claudeCallback)+`","approve":true}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+key) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusForbidden { + t.Errorf("approval with an API token: status %d, want 403", resp.StatusCode) + } +} + +// "Afvis" sends the client away with access_denied and no code. +func TestOAuthDenial(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + clientID := registerClient(t, ts, claudeCallback) + + _, body := ts.do(t, "POST", "/api/v1/oauth/approve", map[string]any{ + "query": authorizeQuery(clientID, claudeCallback), "approve": false, + }) + back, _ := url.Parse(body["redirect_to"].(string)) + if back.Query().Get("error") != "access_denied" || back.Query().Get("code") != "" { + t.Errorf("denial redirect = %v", body["redirect_to"]) + } +} + +// Connecting the same client again replaces its token rather than adding one. +func TestOAuthReconnectReplacesTheToken(t *testing.T) { + ts := newTestServer(t) + ts.bootstrap(t) + clientID := registerClient(t, ts, claudeCallback) + query := authorizeQuery(clientID, claudeCallback) + + _, first := exchange(t, ts, codeForm(clientID, approve(t, ts, query))) + _, second := exchange(t, ts, codeForm(clientID, approve(t, ts, query))) + + _, list := ts.do(t, "GET", "/api/v1/tokens", nil) + if n := len(list["tokens"].([]any)); n != 1 { + t.Errorf("tokens after reconnecting: %d, want 1", n) + } + if resp := mcpInitialize(t, ts, first["access_token"].(string)); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("the replaced token still works: status %d", resp.StatusCode) + } + if resp := mcpInitialize(t, ts, second["access_token"].(string)); resp.StatusCode != http.StatusOK { + t.Errorf("the new token does not work: status %d", resp.StatusCode) + } +} + +func jsonString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 23c660e..7624811 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -64,6 +64,10 @@ type Server struct { // app_handlers.go. wsTickets *wsTickets + // Authorization codes on their way to becoming an MCP client's token — see + // oauth_handlers.go. + oauthCodes *oauthCodes + // Password guessing is the attack this application is actually exposed to, so // the endpoints that check a secret are limited separately and more tightly // than the rest of the API. @@ -73,6 +77,7 @@ type Server struct { mailLimiter *limiter hookLimiter *limiter resetLimiter *limiter + oauthLimiter *limiter // Second factors are counted against the *account*, not the address. An // address bucket is defeated by anybody with more than one address, which is // the ordinary case — and a six-digit code is only safe while guesses are @@ -115,11 +120,16 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server mailLimiter: newLimiter(60, time.Minute), hookLimiter: newLimiter(60, time.Minute), resetLimiter: newLimiter(5, time.Hour), + // Registration and the token exchange are open to anybody who can reach + // the instance. A real client does each once per connection; thirty an + // hour is a ceiling nobody connecting meets. + oauthLimiter: newLimiter(30, time.Hour), // Tighter than the password bucket, and for a whole hour. Somebody typing // their own code gets it right on the first or second try; ten in an hour is // generous for a person and hopeless for a search through a million. totpLimiter: newLimiter(10, time.Hour), wsTickets: newWSTickets(), + oauthCodes: newOAuthCodes(), } // Before the CSP, deliberately: the policy has to describe the bytes that go // out, and in the notes edition those are the renamed ones. @@ -259,6 +269,22 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server // nothing else. r.Post("/mcp", s.handleMCPWithKey) + // Signing in to MCP with the account: discovery, registration, approval and + // the token exchange. In both editions, like the MCP server itself. None of + // them reads a cookie — the approval does, and it lives in /api/v1 below, + // behind the session and the CSRF check like every other write a browser + // makes. See oauth_handlers.go. + r.Get("/.well-known/oauth-protected-resource", s.handleProtectedResourceMetadata) + r.Get("/.well-known/oauth-protected-resource/mcp", s.handleProtectedResourceMetadata) + r.Get("/.well-known/oauth-authorization-server", s.handleAuthorizationServerMetadata) + r.Get("/oauth/authorize", s.handleOAuthAuthorize) + r.Options("/oauth/register", handleOpenPreflight) + r.Options("/oauth/token", handleOpenPreflight) + r.Method(http.MethodPost, "/oauth/register", + s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthRegister))) + r.Method(http.MethodPost, "/oauth/token", + s.rateLimit(s.oauthLimiter, http.HandlerFunc(s.handleOAuthToken))) + // Google sends the browser here after consent. Outside /api/v1 because it is a // top-level navigation rather than a fetch, but still behind the session — the // tokens it exchanges belong to whoever is signed in. @@ -458,6 +484,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server r.Delete("/{tokenID}", s.handleDeleteAPIToken) }) + // The approval page's two questions — what am I approving, and yes or + // no. Behind requireSession for the reason the tokens are: an approval + // mints a token. + r.Route("/oauth", func(r chi.Router) { + r.Use(s.requireSession) + r.Get("/request", s.handleOAuthRequest) + r.Post("/approve", s.handleOAuthApprove) + }) + r.Route("/ai", func(r chi.Router) { r.Get("/settings", s.handleGetAISettings) r.Put("/settings", s.handleSetAISettings) @@ -805,10 +840,9 @@ func (s *Server) serveWeb(w http.ResponseWriter, r *http.Request) { // // That is not hypothetical. Claude probes // /.well-known/oauth-authorization-server before connecting to an MCP server, - // read 200 and a page of HTML as "there is an authorization server here", and - // then failed trying to register with it — reporting a broken sign-in service - // on a server that has none and needs none. A 404 means "no OAuth here", the - // client stops asking, and the key in the URL is used as intended. + // and before that path was a real route it read 200 and a page of HTML as + // "there is an authorization server here" and failed trying to register with + // it. The OAuth paths are routes now; everything else under here is a 404. if strings.HasPrefix(r.URL.Path, "/.well-known/") { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return diff --git a/internal/httpapi/tokens_handlers.go b/internal/httpapi/tokens_handlers.go index 9436ca4..105a4be 100644 --- a/internal/httpapi/tokens_handlers.go +++ b/internal/httpapi/tokens_handlers.go @@ -23,6 +23,9 @@ type apiTokenJSON struct { CreatedAt string `json:"created_at"` LastUsedAt string `json:"last_used_at,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` + // Connected is true for a token an app received by signing in (OAuth), + // rather than one made on this page. + Connected bool `json:"connected,omitempty"` } // createdTokenJSON is the only response that carries the token itself, and only @@ -45,6 +48,7 @@ func (s *Server) handleListAPITokens(w http.ResponseWriter, r *http.Request) { j := apiTokenJSON{ ID: t.ID, Name: t.Name, Prefix: t.Prefix, CreatedAt: t.CreatedAt.Format(time.RFC3339), + Connected: t.Connected, } if t.LastUsedAt != nil { j.LastUsedAt = t.LastUsedAt.Format(time.RFC3339) diff --git a/internal/store/apitokens.go b/internal/store/apitokens.go index 0133d21..8d62a32 100644 --- a/internal/store/apitokens.go +++ b/internal/store/apitokens.go @@ -17,6 +17,9 @@ type APIToken struct { CreatedAt time.Time LastUsedAt *time.Time ExpiresAt *time.Time + // Connected marks a token an OAuth approval minted rather than one the + // person made by hand — it carries the client's name, not one they chose. + Connected bool } // CreateAPIToken returns the token in plaintext exactly once. Only its hash is @@ -80,7 +83,8 @@ func (db *DB) UserByAPIToken(ctx context.Context, token string) (*User, error) { func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, error) { rows, err := db.QueryContext(ctx, - `SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at + `SELECT id, user_id, name, prefix, created_at, last_used_at, expires_at, + oauth_client_id IS NOT NULL FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC`, userID) if err != nil { return nil, err @@ -92,7 +96,7 @@ func (db *DB) ListAPITokens(ctx context.Context, userID string) ([]APIToken, err var t APIToken var created int64 var lastUsed, expires sql.NullInt64 - if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires); err != nil { + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, &created, &lastUsed, &expires, &t.Connected); err != nil { return nil, err } t.CreatedAt = time.Unix(created, 0).UTC() diff --git a/internal/store/migrations/0038_mcp_med_kontoen.sql b/internal/store/migrations/0038_mcp_med_kontoen.sql new file mode 100644 index 0000000..4cd7c72 --- /dev/null +++ b/internal/store/migrations/0038_mcp_med_kontoen.sql @@ -0,0 +1,29 @@ +-- At forbinde Claude med sin konto i stedet for med et token, man selv har lavet. +-- +-- Claude's forbindelsesdialog prøver OAuth, før den gør noget andet, og indtil nu +-- var svaret "det findes ikke her" — så den eneste vej ind var at lave et token i +-- indstillingerne og sætte det ind i en adresse. Det virker, men det er en +-- hemmelighed, man skal kopiere mellem to programmer, og det er præcis det trin, +-- folk går i stå på. +-- +-- En klient registrerer sig selv (RFC 7591) og får et id. Ingen hemmelighed: en +-- klient, der kører hos en anden, kan ikke holde på en, så det er PKCE, der beviser, +-- at det er den samme, der kommer tilbage med koden. +CREATE TABLE oauth_clients ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + -- JSON-liste. Kun adresser, klienten selv opgav ved registreringen, kan få en + -- kode sendt hen til sig — ellers er "godkend" en knap, der sender adgangen til + -- hvem som helst. + redirect_uris TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +-- Det, en godkendelse udsteder, er et almindeligt API-token. Det står derfor på +-- siden med tokens, kan tilbagekaldes der, og når alle de steder, et token +-- allerede gælder. Kolonnen siger, hvilken klient det hører til, så en ny +-- godkendelse fra den samme klient erstatter den gamle frem for at lægge sig ved +-- siden af. +ALTER TABLE api_tokens ADD COLUMN oauth_client_id TEXT REFERENCES oauth_clients (id) ON DELETE CASCADE; + +CREATE INDEX idx_api_tokens_oauth_client ON api_tokens (oauth_client_id) WHERE oauth_client_id IS NOT NULL; diff --git a/internal/store/oauth.go b/internal/store/oauth.go new file mode 100644 index 0000000..780b2e7 --- /dev/null +++ b/internal/store/oauth.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "time" + + "github.com/kristianwind/verdande/internal/auth" +) + +// OAuthClient is a program that registered itself to connect to an account: in +// practice an MCP client such as Claude. It holds no secret — see migration 0038. +type OAuthClient struct { + ID string + Name string + RedirectURIs []string + CreatedAt time.Time +} + +// unusedClientAge is how long a registration may sit without ever being +// approved before it is cleared. Registration is open to anybody who can reach +// the instance, so without this the table grows with every abandoned attempt. +const unusedClientAge = 24 * time.Hour + +func (db *DB) CreateOAuthClient(ctx context.Context, name string, redirectURIs []string) (*OAuthClient, error) { + uris, err := json.Marshal(redirectURIs) + if err != nil { + return nil, err + } + c := &OAuthClient{ID: NewID(), Name: name, RedirectURIs: redirectURIs, CreatedAt: time.Now().UTC()} + + // Swept on the way in, like the socket tickets: the table is bounded by a + // day of asking, and nothing has to remember to run a job for it. + _, _ = db.ExecContext(ctx, + `DELETE FROM oauth_clients WHERE created_at < ? + AND NOT EXISTS (SELECT 1 FROM api_tokens WHERE oauth_client_id = oauth_clients.id)`, + time.Now().Add(-unusedClientAge).Unix()) + + _, err = db.ExecContext(ctx, + `INSERT INTO oauth_clients (id, name, redirect_uris, created_at) VALUES (?, ?, ?, ?)`, + c.ID, c.Name, string(uris), c.CreatedAt.Unix()) + if err != nil { + return nil, err + } + return c, nil +} + +func (db *DB) OAuthClient(ctx context.Context, id string) (*OAuthClient, error) { + var c OAuthClient + var uris string + var created int64 + err := db.QueryRowContext(ctx, + `SELECT id, name, redirect_uris, created_at FROM oauth_clients WHERE id = ?`, id). + Scan(&c.ID, &c.Name, &uris, &created) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(uris), &c.RedirectURIs); err != nil { + return nil, err + } + c.CreatedAt = time.Unix(created, 0).UTC() + return &c, nil +} + +// CreateOAuthToken mints the API token an approval hands to a client. +// +// Any earlier token this person gave the same client is deleted in the same +// transaction. A client that connects again — after a reinstall, or because +// somebody pressed "connect" twice — then has one entry on the tokens page +// rather than a growing column of identical ones, each still valid. +func (db *DB) CreateOAuthToken(ctx context.Context, userID string, client *OAuthClient) (string, error) { + token, prefix, err := auth.NewAPIToken() + if err != nil { + return "", err + } + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return "", err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `DELETE FROM api_tokens WHERE user_id = ? AND oauth_client_id = ?`, userID, client.ID); err != nil { + return "", err + } + if _, err := tx.ExecContext(ctx, + `INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, oauth_client_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + NewID(), userID, client.Name, auth.HashToken(token), prefix, time.Now().Unix(), client.ID); err != nil { + return "", err + } + return token, tx.Commit() +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 0f42710..f9f4d09 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { createHash } from 'node:crypto'; import { USER } from './user.js'; import { watchForTrouble } from './trouble.js'; @@ -6168,3 +6169,78 @@ test('en titel, der rettes i opgaveruden, tager gentagelse, tid, projekt og alar const { reminders } = await apiCall(page, 'GET', `/tasks/${task.id}/reminders`); expect(reminders.map((r) => r.offset_min)).toEqual([-60]); }); + +/** + * At forbinde Claude med kontoen: hele dansen, som Claude danser den, med den + * eneste del, en Go-prøve ikke kan se — at godkendelsessiden findes, logger én + * ind, når det trænges, og at "Tillad" faktisk sender browseren tilbage med en + * kode, der kan veksles til et token, som /mcp tager imod. + * + * Tilbageadressen er en fremmed vært, som prøven selv svarer for. En rigtig + * claude.ai ville gøre prøven afhængig af nettet og af en side, vi ikke ejer. + */ +test('Claude kan forbinde med kontoen i stedet for med et token', async ({ page, browser }) => { + const callback = 'https://claude.example/api/mcp/auth_callback'; + const registered = await page.request.post('/oauth/register', { + data: { client_name: 'Claude', redirect_uris: [callback], token_endpoint_auth_method: 'none' } + }); + expect(registered.status()).toBe(201); + const { client_id } = await registered.json(); + + // S256 af en fast nøgle. Regnet i Node: en tom side er ikke en sikker + // kontekst, så browserens crypto.subtle findes ikke der. + const verifier = 'v'.repeat(43); + const challenge = createHash('sha256').update(verifier).digest('base64url'); + + const query = new URLSearchParams({ + response_type: 'code', client_id, redirect_uri: callback, state: 'tilstand', + code_challenge: challenge, code_challenge_method: 'S256' + }).toString(); + + // Uden en session: login-skærmen, på godkendelsessidens adresse, så man + // lander tilbage på spørgsmålet bagefter. + // Tom storageState: browser.newContext() i en prøve arver projektets, og + // projektets er en logget-ind konto. + const stranger = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const signedOut = await stranger.newPage(); + await signedOut.goto('/oauth/authorize?' + query); + await expect(signedOut.getByLabel('E-mail')).toBeVisible(); + expect(new URL(signedOut.url()).pathname).toBe('/forbind'); + await stranger.close(); + + let returned = null; + await page.route('https://claude.example/**', (route) => { + returned = new URL(route.request().url()); + return route.fulfill({ status: 200, contentType: 'text/plain', body: 'ok' }); + }); + + await page.goto('/oauth/authorize?' + query); + await expect(page.getByRole('heading', { name: 'Claude vil forbinde til din konto' })).toBeVisible(); + // Hvor koden sendes hen er det, en klient ikke kan lyve om — det skal stå der. + await expect(page.getByText('claude.example', { exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Tillad' }).click(); + await expect.poll(() => returned?.searchParams.get('code') ?? null).not.toBeNull(); + expect(returned.searchParams.get('state')).toBe('tilstand'); + + const exchanged = await page.request.post('/oauth/token', { + form: { + grant_type: 'authorization_code', code: returned.searchParams.get('code'), + client_id, redirect_uri: callback, code_verifier: verifier + } + }); + expect(exchanged.status()).toBe(200); + const { access_token } = await exchanged.json(); + expect(access_token).toMatch(/^vrd_/); + + const mcp = await page.request.post('/mcp', { + headers: { Authorization: 'Bearer ' + access_token }, + data: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} } + }); + expect(mcp.status()).toBe(200); + expect((await mcp.json()).result.protocolVersion).toBeTruthy(); + + // Og den står under Tokens, hvor den kan tilbagekaldes. + await page.goto('/indstillinger/tokens'); + await expect(page.getByText('forbundet med din konto')).toBeVisible(); +}); diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 4076e24..bdf3c03 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -445,6 +445,12 @@ export const api = { post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }), deleteToken: (id) => del(`/tokens/${id}`), + // --- signing an MCP client in with the account (routes/forbind) + // The query goes over unchanged: the server reads it with the same function + // that checked it on the way in, so the two cannot disagree about it. + oauthRequest: (query) => get(`/oauth/request${query}`), + oauthApprove: (query, approve) => post('/oauth/approve', { query, approve }), + // --- live sync, for the app (see stores.svelte.js) wsTicket: () => post('/ws-ticket'), diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index bfafabf..8f0d88d 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -498,6 +498,20 @@ export const da = { 'ai.keyNewHint': 'Gemmes på serveren og vises aldrig igen.', 'tokens.lastUsed': 'sidst brugt {when}', 'tokens.neverUsed': 'aldrig brugt', + 'connect.title': 'Forbind', + 'connect.heading': '{name} vil forbinde til din konto', + 'connect.what': 'Den kan læse og ændre dine opgaver og noter — med præcis dine rettigheder, ikke flere.', + 'connect.whatNotes': 'Den kan læse og ændre dine noter — med præcis dine rettigheder, ikke flere.', + 'tokens.signInHint': 'Til Claude og andre MCP-programmer behøver du ikke et token: tilføj adressen nedenfor som forbindelse, og log ind, når programmet beder om det.', + 'connect.account': 'Konto', + 'connect.sentTo': 'Sendes til', + 'connect.revoke': 'Du kan afbryde forbindelsen når som helst under Indstillinger → Tokens.', + 'connect.allow': 'Tillad', + 'connect.deny': 'Afvis', + 'connect.brokenTitle': 'Forbindelsen kan ikke laves', + 'connect.broken': 'Programmet, der sendte dig hertil, bad om noget, der ikke kan godkendes. Fjern forbindelsen i programmet, og tilføj den igen.', + 'connect.toApp': 'Til appen', + 'tokens.connected': 'forbundet med din konto', 'tokens.expired': 'udløbet {when}', 'tokens.expiresOn': 'udløber {when}', 'tokens.revokeNamed': 'Tilbagekald {name}', diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index 2d56ac3..9d077fa 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -492,6 +492,20 @@ export const en = { 'ai.keyNewHint': 'Stored on the server and never shown again.', 'tokens.lastUsed': 'last used {when}', 'tokens.neverUsed': 'never used', + 'connect.title': 'Connect', + 'connect.heading': '{name} wants to connect to your account', + 'connect.what': 'It can read and change your tasks and notes — with exactly your permissions, no more.', + 'connect.whatNotes': 'It can read and change your notes — with exactly your permissions, no more.', + 'tokens.signInHint': 'Claude and other MCP programs do not need a token: add the address below as a connector, and sign in when it asks.', + 'connect.account': 'Account', + 'connect.sentTo': 'Sent to', + 'connect.revoke': 'You can disconnect it at any time under Settings → Tokens.', + 'connect.allow': 'Allow', + 'connect.deny': 'Deny', + 'connect.brokenTitle': 'This connection cannot be made', + 'connect.broken': 'The program that sent you here asked for something that cannot be approved. Remove the connection in that program and add it again.', + 'connect.toApp': 'To the app', + 'tokens.connected': 'connected with your account', 'tokens.expired': 'expired {when}', 'tokens.expiresOn': 'expires {when}', 'tokens.revokeNamed': 'Revoke {name}', diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 90ac593..081983f 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -52,8 +52,12 @@ * screen. Without this the invite link showed a login form to a person who had * no account yet, and the reset link showed one to a person who had forgotten * the password it was asking for. + * + * `/forbind` is the third: the screen an MCP client sends somebody to. It + * signs them in itself and then asks one question, and the shell around it + * would be a way to wander off before answering it. */ - let standalone = $derived(['/invite', '/reset'].includes($page.url.pathname)); + let standalone = $derived(['/invite', '/reset', '/forbind'].includes($page.url.pathname)); $effect(() => { app.load(); diff --git a/web/src/routes/forbind/+page.svelte b/web/src/routes/forbind/+page.svelte new file mode 100644 index 0000000..4359784 --- /dev/null +++ b/web/src/routes/forbind/+page.svelte @@ -0,0 +1,224 @@ + + +{t('connect.title')} · {app.productSlug} + +{#if !app.user} + +{:else} +
+
+
+ + {app.productSlug} +
+ + {#if problem} +

{t('connect.brokenTitle')}

+

{t('connect.broken')}

+

{problem}

+ {t('connect.toApp')} + {:else if !request} +

…

+ {:else} +

{t('connect.heading', { name: request.client_name })}

+

{t(app.notesOnly ? 'connect.whatNotes' : 'connect.what')}

+ +
+
{t('connect.account')}
+
{app.user.name} · {app.user.email}
+
{t('connect.sentTo')}
+
{request.redirect_to}
+
+ +

{t('connect.revoke')}

+ +
+ + +
+ {/if} +
+
+{/if} + + diff --git a/web/src/routes/indstillinger/tokens/+page.svelte b/web/src/routes/indstillinger/tokens/+page.svelte index f4270e8..ad508fe 100644 --- a/web/src/routes/indstillinger/tokens/+page.svelte +++ b/web/src/routes/indstillinger/tokens/+page.svelte @@ -99,6 +99,10 @@

{t('tokens.hint')}

+ +

{t('tokens.signInHint')}

+

{location.origin}/mcp

{#if fresh} @@ -175,6 +179,7 @@ {token.name} {token.prefix}… + {#if token.connected}· {t('tokens.connected')}{/if} {#if token.last_used_at} · {t('tokens.lastUsed', { when: date(token.last_used_at) })} {:else}