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 {
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

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
// 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",
)

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 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.",

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:
• 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
}