Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7df876c4a | ||
|
|
4a2aa81725 |
+54
-18
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user