feat(mcp): oauth_start / oauth_complete for social login
Some checks failed
test-and-deploy / test (push) Failing after 10m46s
test-and-deploy / deploy-138 (push) Has been cancelled

Browser OAuth via customer.verae.ai portal (authentik at auth.verae.ai).
oauth_start returns authorize_url+state; oauth_complete polls until xVerae-
access_token. Open without bearer on public MCP; welcome/register copy updated.
This commit is contained in:
George Lambert 2026-10-20 16:40:50 -04:00
commit 2eb9ec72c6
6 changed files with 349 additions and 19 deletions

View file

@ -55,7 +55,7 @@ func TestStreamableHTTP_InitializeListStampLocal(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("tools/list: %v", err) 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{} got := map[string]bool{}
for _, tool := range tools.Tools { for _, tool := range tools.Tools {
got[tool.Name] = true got[tool.Name] = true

243
internal/tools/oauth.go Normal file
View file

@ -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, "")
}

View file

@ -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")
}
}

View file

@ -1,12 +1,13 @@
package tools package tools
// DEC-654 (D): on the public HTTP server (mcp.verae.ai) account and auth are required for use. Every tool except // 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) // welcome, signup, login, oauth_start, and oauth_complete needs the caller's own credential — a VeraeTime JWT
// or a Verae API key — checked at api.veraetime.net — and never falls back to this host's stored service login. // (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. // 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 // Public signup, login, and oauth_* are open (no bearer): they create/authenticate the account and return
// client can set Authorization / bearer_token_file. They never write the host auth.properties on the public server. // access_token so the client can set Authorization / bearer_token_file. They never write the host auth.properties
// welcome is open so new users can read onboarding before signing up. // 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: // 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 // 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. // ErrWriteNeedsKey is returned when the public server is used without a caller account/credential.
var ErrWriteNeedsKey = errors.New( var ErrWriteNeedsKey = errors.New(
"account and auth are required for use. " + "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) " + "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. " + "then send Authorization: Bearer on each tools/call. " +
"Or run this binary with -transport stdio so the laptop host login is used", "Or run this binary with -transport stdio so the laptop host login is used",
) )

View file

@ -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 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).", "On stdio/local transport the token is also stored on this host (auth.properties).",
}, d.login) }, 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{ mcp.AddTool(s, &mcp.Tool{
Name: "session", Name: "session",
Description: "Validate the caller's VeraeTime JWT (Authorization: Bearer). Returns valid, username, role, and expires_at.", Description: "Validate the caller's VeraeTime JWT (Authorization: Bearer). Returns valid, username, role, and expires_at.",

View file

@ -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: This MCP server is how AI clients (Grok, Claude, Cursor, and others) and local tools talk to Verae:
• welcome — onboarding (no account required) • 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) • 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 • verify / hash_report — check a receipt or list history for a digest
• session — confirm your bearer is still valid • 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). Important: the username must be an email address (welcome mail is sent there on signup).
Next step — create or sign in to your account: 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. 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. Already registered? Call tool login with your email and password to receive 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. Save the token in your MCP client, call session to confirm, then stamp or verify. 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.` 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), " + "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). " + "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. " + "Prefer calling tool welcome to show the full onboarding text, then guide signup or login. " +
"Only welcome, signup, and login work without a bearer. " + "Only welcome, signup, login, oauth_start, and oauth_complete 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. " + "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. " + "When VERAE_MCP_EDGE_URL is set, global stamps meter on zap.verae.ai. " +
"Admin create_user is for operators only." "Admin create_user is for operators only."
@ -59,12 +62,13 @@ func (d *Deps) welcome(_ context.Context, _ *mcp.CallToolRequest, _ EmptyArgs) (
Title: "Welcome to Verae", Title: "Welcome to Verae",
Message: welcomeMessage, Message: welcomeMessage,
NextSteps: []string{ NextSteps: []string{
"New user: call tool signup with your email as username and a password → receive access_token (Welcome email is sent)", "Social: oauth_start (accept_tos=true if new) → open authorize_url → oauth_complete → access_token",
"Existing user: call tool login with your email and password → receive 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)", "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", "Call tool session to confirm, then stamp a SHA-256 digest",
}, },
ToolsHint: []string{"welcome", "signup", "login", "session", "stamp", "stamp_batch", "verify", "hash_report"}, ToolsHint: []string{"welcome", "oauth_start", "oauth_complete", "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.", 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 }, nil
} }