diff --git a/internal/server/server_test.go b/internal/server/server_test.go index d5e9758..0bcec4b 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -55,7 +55,7 @@ func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) { if err != nil { t.Fatalf("tools/list: %v", err) } - wantTools := []string{"welcome", "stamp", "verify", "hash_report", "signup", "login", "session", "create_user"} + wantTools := []string{"welcome", "stamp", "verify", "hash_report", "signup", "login", "oauth_start", "oauth_complete", "session", "create_user"} got := map[string]bool{} for _, tool := range tools.Tools { got[tool.Name] = true diff --git a/internal/tools/oauth.go b/internal/tools/oauth.go new file mode 100644 index 0000000..f8e72c6 --- /dev/null +++ b/internal/tools/oauth.go @@ -0,0 +1,243 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Default portal base for OAuth (authentik-brokered via edge). +const defaultOauthPortalURL = "https://customer.verae.ai" + +// OauthStartArgs begins a browser OAuth window flow. +type OauthStartArgs struct { + Provider string `json:"provider,omitempty" jsonschema:"google|github|apple|x (default google); authentik Source slug"` + AcceptTos bool `json:"accept_tos,omitempty" jsonschema:"required true when creating a new account via social login"` +} + +// OauthStartOut is returned by oauth_start. +type OauthStartOut struct { + AuthorizeURL string `json:"authorize_url"` + State string `json:"state"` + ExpiresAt string `json:"expires_at,omitempty"` + Provider string `json:"provider"` + HowToUse string `json:"how_to_use"` +} + +// OauthCompleteArgs finishes OAuth after the browser callback (poll edge result). +type OauthCompleteArgs struct { + State string `json:"state" jsonschema:"state from oauth_start"` + WaitSec int `json:"wait_sec,omitempty" jsonschema:"seconds to poll (default 45, max 120)"` +} + +// OauthCompleteOut is returned when social login succeeds. +type OauthCompleteOut struct { + Username string `json:"username,omitempty"` + CustomerID string `json:"customer_id,omitempty"` + Created bool `json:"created,omitempty"` + AccessToken string `json:"access_token,omitempty"` + HowToUse string `json:"how_to_use,omitempty"` + Status string `json:"status,omitempty"` // pending when still waiting (short poll) +} + +func oauthPortalBase() string { + u := strings.TrimRight(strings.TrimSpace(os.Getenv("VERAE_OAUTH_PORTAL_URL")), "/") + if u == "" { + u = defaultOauthPortalURL + } + return u +} + +func (d *Deps) oauthHTTPClient() *http.Client { + return &http.Client{Timeout: 30 * time.Second} +} + +func (d *Deps) oauthStart(ctx context.Context, _ *mcp.CallToolRequest, in OauthStartArgs) (*mcp.CallToolResult, OauthStartOut, error) { + provider := strings.ToLower(strings.TrimSpace(in.Provider)) + if provider == "" { + provider = "google" + } + switch provider { + case "google", "github", "apple", "x": + default: + return nil, OauthStartOut{}, fmt.Errorf("provider must be google, github, apple, or x") + } + q := url.Values{ + "provider": {provider}, + "popup": {"1"}, + } + if in.AcceptTos { + q.Set("accept_tos", "1") + } + endpoint := oauthPortalBase() + "/portal/api/oauth/start?" + q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, OauthStartOut{}, err + } + req.Header.Set("Accept", "application/json") + res, err := d.oauthHTTPClient().Do(req) + if err != nil { + return nil, OauthStartOut{}, fmt.Errorf("oauth start: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + if res.StatusCode == http.StatusServiceUnavailable { + return nil, OauthStartOut{}, fmt.Errorf("oauth not configured on portal (%s)", strings.TrimSpace(string(body))) + } + if res.StatusCode >= 400 { + return nil, OauthStartOut{}, fmt.Errorf("oauth start HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body))) + } + var raw struct { + AuthorizeURL string `json:"authorize_url"` + State string `json:"state"` + ExpiresAt string `json:"expires_at"` + Provider string `json:"provider"` + Error string `json:"error"` + Detail string `json:"detail"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, OauthStartOut{}, fmt.Errorf("oauth start parse: %w", err) + } + if raw.AuthorizeURL == "" || raw.State == "" { + msg := raw.Error + if raw.Detail != "" { + msg = raw.Detail + } + if msg == "" { + msg = "missing authorize_url/state" + } + return nil, OauthStartOut{}, fmt.Errorf("oauth start: %s", msg) + } + how := "Open authorize_url in a browser window (or system browser). " + + "Complete sign-in at auth.verae.ai. Then call tool oauth_complete with the same state. " + + "For a new account, call oauth_start again with accept_tos=true after accepting Terms on the portal if needed." + return nil, OauthStartOut{ + AuthorizeURL: raw.AuthorizeURL, + State: raw.State, + ExpiresAt: raw.ExpiresAt, + Provider: provider, + HowToUse: how, + }, nil +} + +func (d *Deps) oauthComplete(ctx context.Context, _ *mcp.CallToolRequest, in OauthCompleteArgs) (*mcp.CallToolResult, OauthCompleteOut, error) { + state := strings.TrimSpace(in.State) + if state == "" { + return nil, OauthCompleteOut{}, fmt.Errorf("state is required (from oauth_start)") + } + wait := in.WaitSec + if wait <= 0 { + wait = 45 + } + if wait > 120 { + wait = 120 + } + deadline := time.Now().Add(time.Duration(wait) * time.Second) + var lastPending bool + for { + out, pending, err := d.pollOauthResult(ctx, state) + if err != nil { + return nil, OauthCompleteOut{}, err + } + if !pending { + if !d.Public && out.AccessToken != "" { + _ = d.persistOauthBearer(out.Username, out.AccessToken) + } + out.HowToUse = tokenHowToUse + return nil, out, nil + } + lastPending = true + if time.Now().After(deadline) { + break + } + select { + case <-ctx.Done(): + return nil, OauthCompleteOut{}, ctx.Err() + case <-time.After(2 * time.Second): + } + } + if lastPending { + return nil, OauthCompleteOut{ + Status: "pending", + HowToUse: "Browser sign-in not finished yet. Open authorize_url from oauth_start, then call oauth_complete again with the same state.", + }, nil + } + return nil, OauthCompleteOut{}, fmt.Errorf("oauth complete: unexpected empty result") +} + +func (d *Deps) pollOauthResult(ctx context.Context, state string) (OauthCompleteOut, bool, error) { + endpoint := oauthPortalBase() + "/portal/api/oauth/result?state=" + url.QueryEscape(state) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return OauthCompleteOut{}, false, err + } + req.Header.Set("Accept", "application/json") + res, err := d.oauthHTTPClient().Do(req) + if err != nil { + return OauthCompleteOut{}, false, fmt.Errorf("oauth result: %w", err) + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20)) + switch res.StatusCode { + case http.StatusAccepted: + return OauthCompleteOut{Status: "pending"}, true, nil + case http.StatusNotFound: + return OauthCompleteOut{}, false, fmt.Errorf("unknown_or_expired_state") + } + if res.StatusCode >= 400 { + return OauthCompleteOut{}, false, fmt.Errorf("oauth result HTTP %d: %s", res.StatusCode, strings.TrimSpace(string(body))) + } + var raw struct { + OK bool `json:"ok"` + Token string `json:"token"` + APIKey string `json:"apiKey"` + Created bool `json:"created"` + Email string `json:"email"` + CustomerID string `json:"customerId"` + Error string `json:"error"` + Detail string `json:"detail"` + Status string `json:"status"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return OauthCompleteOut{}, false, fmt.Errorf("oauth result parse: %w", err) + } + if raw.Status == "pending" { + return OauthCompleteOut{Status: "pending"}, true, nil + } + if !raw.OK { + msg := raw.Error + if raw.Detail != "" { + msg = raw.Detail + } + if msg == "" { + msg = "oauth failed" + } + return OauthCompleteOut{}, false, fmt.Errorf("%s", msg) + } + // Prefer long-lived xVerae- portal key for commercial MCP stamps when EDGE_URL is set. + tok := strings.TrimSpace(raw.APIKey) + if tok == "" { + tok = strings.TrimSpace(raw.Token) + } + return OauthCompleteOut{ + Username: raw.Email, + CustomerID: raw.CustomerID, + Created: raw.Created, + AccessToken: tok, + }, false, nil +} + +func (d *Deps) persistOauthBearer(username, token string) error { + if d.Global == nil || token == "" { + return nil + } + return d.persistCallerToken(username, "", token, "") +} diff --git a/internal/tools/oauth_test.go b/internal/tools/oauth_test.go new file mode 100644 index 0000000..af975fe --- /dev/null +++ b/internal/tools/oauth_test.go @@ -0,0 +1,71 @@ +package tools + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestOauthStartAndComplete(t *testing.T) { + var startHits, resultHits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/portal/api/oauth/start": + startHits++ + _ = json.NewEncoder(w).Encode(map[string]any{ + "authorize_url": "https://auth.verae.ai/application/o/authorize/?x=1", + "state": "st-test", + "expires_at": "2099-01-01T00:00:00Z", + "provider": "google", + }) + case r.URL.Path == "/portal/api/oauth/result": + resultHits++ + if resultHits < 2 { + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": "portal-session", + "apiKey": "xVerae-test-key", + "created": true, + "email": "user@example.com", + "customerId": "cust_abc", + }) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + t.Setenv("VERAE_OAUTH_PORTAL_URL", srv.URL) + + d := &Deps{Public: true} + _, start, err := d.oauthStart(context.Background(), nil, OauthStartArgs{Provider: "google", AcceptTos: true}) + if err != nil { + t.Fatal(err) + } + if start.State != "st-test" || start.AuthorizeURL == "" { + t.Fatalf("%+v", start) + } + _, done, err := d.oauthComplete(context.Background(), nil, OauthCompleteArgs{State: start.State, WaitSec: 5}) + if err != nil { + t.Fatal(err) + } + if done.AccessToken != "xVerae-test-key" || done.Username != "user@example.com" || !done.Created { + t.Fatalf("%+v", done) + } + if startHits != 1 || resultHits < 2 { + t.Fatalf("hits start=%d result=%d", startHits, resultHits) + } +} + +func TestOauthStartRejectsBadProvider(t *testing.T) { + d := &Deps{Public: true} + _, _, err := d.oauthStart(context.Background(), nil, OauthStartArgs{Provider: "facebook"}) + if err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/tools/public.go b/internal/tools/public.go index 129dc69..60c833a 100644 --- a/internal/tools/public.go +++ b/internal/tools/public.go @@ -1,12 +1,13 @@ package tools // DEC-654 (D): on the public HTTP server (mcp.verae.ai) account and auth are required for use. Every tool except -// welcome, signup, and login needs the caller's own credential — a VeraeTime JWT (access_token from signup/login) -// or a Verae API key — checked at api.veraetime.net — and never falls back to this host's stored service login. +// welcome, signup, login, oauth_start, and oauth_complete needs the caller's own credential — a VeraeTime JWT +// (access_token from signup/login/oauth) or a Verae API key — checked at api.veraetime.net — and never falls back +// to this host's stored service login. // Tools that need a caller bearer: stamp, stamp_batch, verify, hash_report, session, create_user. -// Public signup and login are open (no bearer): they create/authenticate the account and return access_token so the -// client can set Authorization / bearer_token_file. They never write the host auth.properties on the public server. -// welcome is open so new users can read onboarding before signing up. +// Public signup, login, and oauth_* are open (no bearer): they create/authenticate the account and return +// access_token so the client can set Authorization / bearer_token_file. They never write the host auth.properties +// on the public server. welcome is open so new users can read onboarding before signing up. // // AI clients (Grok, Claude, Cursor) must send Authorization: Bearer on every tools/call after signup/login. Grok Build: // bearer_token_file = "~/.verae/mcp-bearer" # raw JWT only, not auth.properties @@ -26,9 +27,9 @@ import ( // ErrWriteNeedsKey is returned when the public server is used without a caller account/credential. var ErrWriteNeedsKey = errors.New( "account and auth are required for use. " + - "Call tool welcome for onboarding, then signup (new account) or login (existing) to receive a VeraeTime JWT as access_token. " + + "Call tool welcome for onboarding, then signup/login or oauth_start→oauth_complete to receive a VeraeTime JWT or xVerae- key as access_token. " + "Write that token to your client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) " + - "or use a Verae API key from https://zap.verae.ai/portal/, " + + "or use a Verae API key from https://customer.verae.ai/portal/, " + "then send Authorization: Bearer on each tools/call. " + "Or run this binary with -transport stdio so the laptop host login is used", ) diff --git a/internal/tools/register.go b/internal/tools/register.go index 574a829..1c94936 100644 --- a/internal/tools/register.go +++ b/internal/tools/register.go @@ -58,6 +58,17 @@ func Register(s *mcp.Server, d *Deps) { "On the public HTTP server the host credential is never replaced — only the tool result carries the token. " + "On stdio/local transport the token is also stored on this host (auth.properties).", }, d.login) + mcp.AddTool(s, &mcp.Tool{ + Name: "oauth_start", + Description: "Start social login (Google/GitHub/Apple/X) via auth.verae.ai. Returns authorize_url + state. " + + "Open authorize_url in a browser window, then call oauth_complete with the same state. " + + "For a new account set accept_tos=true. No Authorization required.", + }, d.oauthStart) + mcp.AddTool(s, &mcp.Tool{ + Name: "oauth_complete", + Description: "Finish social login after the browser callback. Pass state from oauth_start; polls until done or wait_sec. " + + "Returns access_token (prefer xVerae- portal key for commercial stamps). No Authorization required.", + }, d.oauthComplete) mcp.AddTool(s, &mcp.Tool{ Name: "session", Description: "Validate the caller's VeraeTime JWT (Authorization: Bearer). Returns valid, username, role, and expires_at.", diff --git a/internal/tools/welcome.go b/internal/tools/welcome.go index 33fb3e9..5c91ef6 100644 --- a/internal/tools/welcome.go +++ b/internal/tools/welcome.go @@ -16,19 +16,21 @@ Verae Timestamping proves that a specific digital object existed at a point in t This MCP server is how AI clients (Grok, Claude, Cursor, and others) and local tools talk to Verae: • welcome — onboarding (no account required) -• signup / login — create an account or sign in; you receive an access_token (required next) +• signup / login — create an account or sign in with email+password; you receive an access_token (required next) +• oauth_start / oauth_complete — social login (Google/GitHub/Apple/X) via a browser window at auth.verae.ai • stamp / stamp_batch — commit one or many SHA-256 digests (local ledger or global VeraeTime) • verify / hash_report — check a receipt or list history for a digest • session — confirm your bearer is still valid -Public HTTP (mcp.verae.ai): every tool except welcome, signup, and login needs Authorization: Bearer (VeraeTime JWT or xVerae- API key). There is no host-login fallback. Put access_token in your client's bearer file (Grok: bearer_token_file → ~/.verae/mcp-bearer) or send Authorization on each tools/call. Do not paste tokens into chat. +Public HTTP (mcp.verae.ai): every tool except welcome, signup, login, oauth_start, and oauth_complete needs Authorization: Bearer (VeraeTime JWT or xVerae- API key). There is no host-login fallback. Put access_token in your client's bearer file (Grok: bearer_token_file → ~/.verae/mcp-bearer) or send Authorization on each tools/call. Do not paste tokens into chat. Important: the username must be an email address (welcome mail is sent there on signup). Next step — create or sign in to your account: -1. New here? Call tool signup with your email as username and a password. It creates the account, emails a Welcome to Verae message, and returns access_token. -2. Already registered? Call tool login with your email and password to receive access_token. -3. Save the token in your MCP client, call session to confirm, then stamp or verify. +1. Social: call oauth_start (accept_tos=true for new accounts), open authorize_url in a browser, then oauth_complete with state → access_token. +2. New here with password? Call tool signup with your email as username and a password → access_token (Welcome email is sent). +3. Already registered? Call tool login with your email and password → access_token. +4. Save the token in your MCP client, call session to confirm, then stamp or verify. Global stamps may meter prepaid credits when edge billing is enabled.` @@ -40,8 +42,9 @@ const instructions = "Welcome to Verae Timestamping MCP (mcp.verae.ai). " + "tell them the username must be an email address (a Welcome to Verae email is sent on signup), " + "then ask them to sign in (tool login) or create an account (tool signup). " + "Prefer calling tool welcome to show the full onboarding text, then guide signup or login. " + - "Only welcome, signup, and login work without a bearer. " + - "After signup/login, save access_token to the client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) or Authorization header — never paste tokens into chat. " + + "Only welcome, signup, login, oauth_start, and oauth_complete work without a bearer. " + + "Social login: oauth_start → open authorize_url → oauth_complete. " + + "After signup/login/oauth, save access_token to the client's bearer file (Grok: ~/.verae/mcp-bearer via bearer_token_file) or Authorization header — never paste tokens into chat. " + "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai. " + "Admin create_user is for operators only." @@ -59,12 +62,13 @@ func (d *Deps) welcome(_ context.Context, _ *mcp.CallToolRequest, _ EmptyArgs) ( Title: "Welcome to Verae", Message: welcomeMessage, NextSteps: []string{ - "New user: call tool signup with your email as username and a password → receive access_token (Welcome email is sent)", - "Existing user: call tool login with your email and password → receive access_token", + "Social: oauth_start (accept_tos=true if new) → open authorize_url → oauth_complete → access_token", + "New user (password): signup with email username + password → access_token (Welcome email is sent)", + "Existing user (password): login with email + password → access_token", "Save access_token to your MCP bearer file or Authorization header (do not paste into chat)", "Call tool session to confirm, then stamp a SHA-256 digest", }, - ToolsHint: []string{"welcome", "signup", "login", "session", "stamp", "stamp_batch", "verify", "hash_report"}, - HasAccount: "Account and auth are required for use. The username must be an email address. Do you already have a Verae account? Sign in with login, or create one with signup.", + ToolsHint: []string{"welcome", "oauth_start", "oauth_complete", "signup", "login", "session", "stamp", "stamp_batch", "verify", "hash_report"}, + HasAccount: "Account and auth are required for use. Prefer oauth_start/oauth_complete for Google/GitHub/Apple/X, or signup/login with an email username.", }, nil }