Compare commits

..
Author SHA1 Message Date
Kristian Wind f578dee45f Merge pull request #110 from kristianwind/mcp-oauth
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Failing after 5m48s
CI / Docker image builds (push) Successful in 3m34s
Release / Build and publish image (push) Failing after 8s
CI / Go (push) Failing after 27m13s
MCP: forbind med kontoen i stedet for med et token
2026-10-31 23:02:06 +01:00
kristianwindandClaude Opus 5.5 e7df876c4a feat(mcp): forbind med kontoen i stedet for med et token
Claude's forbindelsesdialog prøver OAuth først. Indtil nu var svaret 404,
og eneste vej ind var at lave et token og sætte det ind i en adresse. Nu
er instansen sin egen autorisationsserver for MCP-klienter:

- /.well-known/oauth-protected-resource(/mcp) og
  /.well-known/oauth-authorization-server peger på login'et; en 401 fra
  /mcp bærer WWW-Authenticate med resource_metadata
- /oauth/register (RFC 7591), kun offentlige klienter; registreringer,
  ingen godkender, ryddes efter et døgn
- /oauth/authorize sender til /forbind, som logger ind med det almindelige
  login og spørger "Claude vil forbinde til din konto" — og viser, hvor
  adgangen sendes hen, fordi navnet er klientens eget
- /oauth/token: authorization code + S256 PKCE, koden gælder ét minut og
  én gang; ud kommer et almindeligt API-token, listet under Tokens med
  klientens navn, og en ny forbindelse fra samme klient erstatter det
- /mcp tager nu også Authorization: Bearer; ?key= virker stadig

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:49:48 +01:00
Kristian Wind 4a2aa81725 Merge pull request #109 from kristianwind/quickadd-rettelser
CI / OpenAPI is valid (push) Successful in 15s
CI / End-to-end (push) Failing after 5m39s
CI / Docker image builds (push) Successful in 3m12s
CI / Go (push) Failing after 26m34s
fix(hurtig tilføjelse): fire ting, der blev læst forkert eller tabt i stilhed
2026-10-31 00:51:06 +01:00
19 changed files with 1792 additions and 55 deletions
+54 -18
View File
@@ -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.
+222 -10
View File
@@ -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
+4
View File
@@ -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)
+4 -5
View File
@@ -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)
+32 -15
View File
@@ -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=<api token> 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=<api token> 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) {
+558
View File
@@ -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)
}
+398
View File
@@ -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)
}
+38 -4
View File
@@ -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
+4
View File
@@ -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)
+6 -2
View File
@@ -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()
@@ -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;
+99
View File
@@ -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()
}
+76
View File
@@ -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();
});
+6
View File
@@ -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'),
+14
View File
@@ -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}',
+14
View File
@@ -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}',
+5 -1
View File
@@ -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();
+224
View File
@@ -0,0 +1,224 @@
<script>
/**
* The approval screen an MCP client sends somebody to: "Claude wants to
* connect to your account".
*
* The server's /oauth/authorize checks the request and hands the browser
* here with the query unchanged. Signing in, if it is needed, is the ordinary
* sign-in screen — passkeys and second factor included — and this page takes
* over once there is somebody to ask.
*
* Two things are shown, and only one of them can be trusted. The name is
* whatever the client called itself when it registered: any program can call
* itself Claude. Where the browser goes afterwards is the address the client
* registered, and that is what decides who receives the access — so it is
* shown, and shown as plainly as the name.
*/
import SignIn from '$lib/components/SignIn.svelte';
import { api, humanMessage } from '$lib/api.js';
import { app } from '$lib/stores.svelte.js';
import { t } from '$lib/i18n.svelte.js';
const query = typeof location === 'undefined' ? '' : location.search;
/** @type {{ client_name: string, redirect_to: string } | null} */
let request = $state(null);
let problem = $state('');
let busy = $state(false);
$effect(() => {
if (!app.user || request || problem) return;
api
.oauthRequest(query)
.then((r) => (request = r))
.catch((e) => (problem = e.message || humanMessage(e)));
});
async function answer(approve) {
busy = true;
try {
const { redirect_to } = await api.oauthApprove(query, approve);
// The server only ever answers with an address the client registered,
// and refuses the schemes a browser would run. Checked again here
// because this line is what hands it to the browser.
const scheme = new URL(redirect_to).protocol;
if (['javascript:', 'data:', 'vbscript:', 'blob:'].includes(scheme)) throw new Error('bad redirect');
location.assign(redirect_to);
} catch (e) {
busy = false;
app.toast(humanMessage(e));
}
}
</script>
<svelte:head><title>{t('connect.title')} · {app.productSlug}</title></svelte:head>
{#if !app.user}
<SignIn />
{:else}
<div class="screen">
<div class="card">
<div class="brand">
<span class="rune" aria-hidden="true">ᚹ</span>
<span class="slug">{app.productSlug}</span>
</div>
{#if problem}
<h1>{t('connect.brokenTitle')}</h1>
<p class="lede">{t('connect.broken')}</p>
<p class="detail mono">{problem}</p>
<a class="link" href="/">{t('connect.toApp')}</a>
{:else if !request}
<p class="lede">…</p>
{:else}
<h1>{t('connect.heading', { name: request.client_name })}</h1>
<p class="lede">{t(app.notesOnly ? 'connect.whatNotes' : 'connect.what')}</p>
<dl>
<dt>{t('connect.account')}</dt>
<dd>{app.user.name} · {app.user.email}</dd>
<dt>{t('connect.sentTo')}</dt>
<dd class="mono">{request.redirect_to}</dd>
</dl>
<p class="hint">{t('connect.revoke')}</p>
<div class="actions">
<button class="allow" disabled={busy} onclick={() => answer(true)}>{t('connect.allow')}</button>
<button class="deny" disabled={busy} onclick={() => answer(false)}>{t('connect.deny')}</button>
</div>
{/if}
</div>
</div>
{/if}
<style>
.screen {
min-height: 100dvh;
display: grid;
place-items: center;
padding: var(--s4);
background: var(--ground);
}
.card {
width: 100%;
max-width: 380px;
display: flex;
flex-direction: column;
gap: var(--s4);
}
.brand {
display: flex;
align-items: center;
gap: var(--s3);
justify-content: center;
}
.rune {
font-size: var(--text-2xl);
color: var(--accent);
line-height: 1;
}
.slug {
font-size: var(--text-lg);
font-weight: 560;
}
h1 {
margin: 0;
font-size: var(--text-xl);
font-weight: 560;
letter-spacing: -0.02em;
text-align: center;
text-wrap: balance;
}
.lede {
margin: 0;
text-align: center;
color: var(--ink-muted);
font-size: var(--text-sm);
}
dl {
margin: 0;
padding: var(--s3) var(--s4);
display: grid;
grid-template-columns: auto 1fr;
gap: var(--s2) var(--s4);
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
font-size: var(--text-sm);
}
dt {
color: var(--ink-faint);
}
dd {
margin: 0;
min-width: 0;
overflow-wrap: anywhere;
color: var(--ink);
}
.mono {
font-family: var(--font-mono);
}
.hint,
.detail {
margin: 0;
font-size: var(--text-xs);
color: var(--ink-faint);
text-align: center;
overflow-wrap: anywhere;
}
.actions {
display: flex;
flex-direction: column;
gap: var(--s2);
}
.allow {
padding: var(--s3);
background: var(--accent);
color: var(--accent-ink);
border-radius: var(--radius);
font-weight: 550;
transition: background var(--fast) var(--ease);
}
.allow:hover:not(:disabled) {
background: var(--accent-hover);
}
.deny {
padding: var(--s2) var(--s4);
border: 1px solid var(--line-strong);
border-radius: var(--radius);
font-size: var(--text-sm);
color: var(--ink);
}
button:disabled {
opacity: 0.6;
cursor: default;
}
.link {
color: var(--ink-faint);
font-size: var(--text-sm);
text-align: center;
text-decoration: none;
}
.link:hover {
color: var(--ink);
}
</style>
@@ -99,6 +99,10 @@
<p class="hint">
{t('tokens.hint')}
</p>
<!-- The way most people want: no token at all. The address is the same
for everybody on this instance, so it can be shown without a secret. -->
<p class="hint">{t('tokens.signInHint')}</p>
<p class="mono value">{location.origin}/mcp</p>
</header>
{#if fresh}
@@ -175,6 +179,7 @@
<span class="token-name">{token.name}</span>
<span class="meta mono">
{token.prefix}…
{#if token.connected}· {t('tokens.connected')}{/if}
{#if token.last_used_at}
· {t('tokens.lastUsed', { when: date(token.last_used_at) })}
{:else}