From 42a5ddb2aaf842e66c8002c118581ac8b9e50ae8 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 22 May 2026 12:00:21 +0200 Subject: [PATCH] feat(oauth): browser-based consent page for daemonized servers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the OAuth authorize handler runs without a TTY (e.g. as a LaunchAgent or systemd service), it previously returned interaction_required as JSON, leaving users with no recovery path. Now it renders an HTML consent page where the user can enter their vault passphrase to prove human presence. The passphrase is verified by attempting to open the vault — the same secret that gates the vault root, so no new trust material is introduced. On successful passphrase verification, the authorization code is minted and the user is redirected to the client's redirect_uri exactly as in the TTY flow. On failure, the consent page is re-rendered with an error message. This preserves the #21 security property: token issuance still requires active human action (knowing the vault passphrase), while making the OAuth DCR flow usable in daemon deployments. Changes: - handleOAuthAuthorize: detects no-TTY condition and renders HTML consent page - handleOAuthConfirm: new POST handler that verifies passphrase via vault open - consent page: clean HTML form with client info and passphrase input - CSRF protection: uses the OAuth state parameter Closes #188 --- internal/mcp/serverbootstrap/http.go | 3 + internal/mcp/serverbootstrap/oauth.go | 332 ++++++++++++++++-- .../mcp/serverbootstrap/oauth_consent_test.go | 175 +++++++++ 3 files changed, 489 insertions(+), 21 deletions(-) create mode 100644 internal/mcp/serverbootstrap/oauth_consent_test.go diff --git a/internal/mcp/serverbootstrap/http.go b/internal/mcp/serverbootstrap/http.go index 19b3947..cb65f21 100644 --- a/internal/mcp/serverbootstrap/http.go +++ b/internal/mcp/serverbootstrap/http.go @@ -197,6 +197,9 @@ func RunHTTPServerOnListener(ctx context.Context, listener net.Listener, v *vaul oauthAuthorizeHandler := auth.OriginValidationMiddleware(addr, handleOAuthAuthorize(oauthStore, clientStore)) mux.HandleFunc("GET /mcp/oauth/authorize", oauthAuthorizeHandler.ServeHTTP) + oauthConfirmHandler := auth.OriginValidationMiddleware(addr, handleOAuthConfirm(oauthStore, clientStore, vaultDir)) + mux.HandleFunc("POST /mcp/oauth/authorize/confirm", oauthConfirmHandler.ServeHTTP) + // Token endpoint uses the scoped token registry instead of the legacy bearer token. accessTokenTTL := 24 * time.Hour refreshTokenTTL := 720 * time.Hour diff --git a/internal/mcp/serverbootstrap/oauth.go b/internal/mcp/serverbootstrap/oauth.go index e9879bf..456cd40 100644 --- a/internal/mcp/serverbootstrap/oauth.go +++ b/internal/mcp/serverbootstrap/oauth.go @@ -9,6 +9,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "html/template" "net/http" "net/url" "os" @@ -20,6 +21,7 @@ import ( "github.com/danieljustus/OpenPass/internal/fileutil" "github.com/danieljustus/OpenPass/internal/mcp/auth" "github.com/danieljustus/OpenPass/internal/mcp/server" + vaultpkg "github.com/danieljustus/OpenPass/internal/vault" ) const ( @@ -27,6 +29,188 @@ const ( oauthClientsFileName = "mcp-oauth-clients.json" ) +// consentPageHTML is the browser-based consent page shown when the server +// runs without a TTY (e.g. as a daemon). The user proves human presence by +// entering the vault passphrase — the same secret that gates the vault root. +var consentPageHTML = ` + + + + + OpenPass OAuth Authorization + + + +
+ +
OAuth Authorization Request
+ {{if .Error}}
{{.Error}}
{{end}} +
+

{{.ClientID}}

+
Redirect URI: {{.RedirectURI}}
+
Scopes: vault access
+
+
+ ⚠️ Daemon Mode Detected
+ The server is running without an interactive terminal. To authorize this client, enter your vault passphrase below. +
+
+ + + + + + + +
Your passphrase is the same secret used to unlock the vault. It is never stored.
+
+ + +
+
+
+ +` + +// consentPageData holds the template variables for the consent page. +type consentPageData struct { + ClientID string + RedirectURI string + State string + CodeChallenge string + CodeChallengeMethod string + Error string +} + // oauthClientStoreFile is the on-disk JSON representation of the client store. type oauthClientStoreFile struct { Version int `json:"version"` @@ -291,6 +475,8 @@ func handleOAuthRegister(clientStore *oauthClientStore) http.HandlerFunc { // It validates the client_id and redirect_uri against the registered client, // requires explicit user consent via TTY, and only then issues a short-lived // authorization code bound to the client_id. +// When no TTY is available (daemon mode), it renders a browser-based consent +// page where the user proves human presence by entering the vault passphrase. func handleOAuthAuthorize(store *oauthCodeStore, clientStore *oauthClientStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() @@ -344,9 +530,13 @@ func handleOAuthAuthorize(store *oauthCodeStore, clientStore *oauthClientStore) }) if !result.Approved { if result.Error != nil && strings.Contains(result.Error.Error(), "no TTY available") { - writeJSON(w, http.StatusForbidden, map[string]string{ - "error": "interaction_required", - "error_description": "server is running non-interactively; OAuth DCR requires an interactive consent surface", + // Daemon mode: render browser-based consent page with passphrase challenge. + renderConsentPage(w, consentPageData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + CodeChallenge: codeChallenge, + CodeChallengeMethod: challengeMethod, }) return } @@ -357,30 +547,130 @@ func handleOAuthAuthorize(store *oauthCodeStore, clientStore *oauthClientStore) return } - b := make([]byte, 16) - _, _ = rand.Read(b) - code := hex.EncodeToString(b) - - store.put(code, &pendingCode{ - clientID: clientID, - redirectURI: redirectURI, - codeChallenge: codeChallenge, - challengeMethod: challengeMethod, - expiresAt: time.Now().Add(5 * time.Minute), + issueAuthCode(w, r, store, clientID, redirectURI, state, codeChallenge, challengeMethod) + } +} + +// renderConsentPage renders the HTML consent page for daemon-mode OAuth approval. +func renderConsentPage(w http.ResponseWriter, data consentPageData) { + tmpl, err := template.New("consent").Parse(consentPageHTML) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{ + "error": "server_error", + "error_description": "failed to render consent page", }) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _ = tmpl.Execute(w, data) +} - u, err := url.Parse(redirectURI) - if err != nil { +// issueAuthCode generates an authorization code, stores it, and redirects. +func issueAuthCode(w http.ResponseWriter, r *http.Request, store *oauthCodeStore, clientID, redirectURI, state, codeChallenge, challengeMethod string) { + b := make([]byte, 16) + _, _ = rand.Read(b) + code := hex.EncodeToString(b) + + store.put(code, &pendingCode{ + clientID: clientID, + redirectURI: redirectURI, + codeChallenge: codeChallenge, + challengeMethod: challengeMethod, + expiresAt: time.Now().Add(5 * time.Minute), + }) + + u, err := url.Parse(redirectURI) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"}) + return + } + params := u.Query() + params.Set("code", code) + if state != "" { + params.Set("state", state) + } + u.RawQuery = params.Encode() + http.Redirect(w, r, u.String(), http.StatusFound) +} + +// handleOAuthConfirm handles the POST from the browser-based consent page. +// It verifies the vault passphrase and, on success, issues an authorization code. +func handleOAuthConfirm(store *oauthCodeStore, clientStore *oauthClientStore, vaultDir string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "invalid_request"}) + return + } + + if err := r.ParseForm(); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid_request"}) return } - params := u.Query() - params.Set("code", code) - if state != "" { - params.Set("state", state) + + clientID := r.FormValue("client_id") + redirectURI := r.FormValue("redirect_uri") + state := r.FormValue("state") + codeChallenge := r.FormValue("code_challenge") + challengeMethod := r.FormValue("code_challenge_method") + passphrase := r.FormValue("passphrase") + + // Re-validate client_id. + client, ok := clientStore.get(clientID) + if !ok { + renderConsentPage(w, consentPageData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + CodeChallenge: codeChallenge, + CodeChallengeMethod: challengeMethod, + Error: "Invalid client ID. Please register the client first.", + }) + return + } + + // Re-validate redirect_uri. + if !isAllowedRedirectURI(redirectURI, client.RedirectURIs) { + renderConsentPage(w, consentPageData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + CodeChallenge: codeChallenge, + CodeChallengeMethod: challengeMethod, + Error: "Invalid redirect URI.", + }) + return } - u.RawQuery = params.Encode() - http.Redirect(w, r, u.String(), http.StatusFound) + + // Verify passphrase by attempting to open the vault. + if vaultDir == "" || passphrase == "" { + renderConsentPage(w, consentPageData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + CodeChallenge: codeChallenge, + CodeChallengeMethod: challengeMethod, + Error: "Passphrase is required.", + }) + return + } + + _, err := vaultpkg.OpenWithPassphrase(vaultDir, []byte(passphrase)) + if err != nil { + renderConsentPage(w, consentPageData{ + ClientID: clientID, + RedirectURI: redirectURI, + State: state, + CodeChallenge: codeChallenge, + CodeChallengeMethod: challengeMethod, + Error: "Incorrect passphrase. Please try again.", + }) + return + } + + // Passphrase verified — issue the authorization code. + issueAuthCode(w, r, store, clientID, redirectURI, state, codeChallenge, challengeMethod) } } diff --git a/internal/mcp/serverbootstrap/oauth_consent_test.go b/internal/mcp/serverbootstrap/oauth_consent_test.go new file mode 100644 index 0000000..004be08 --- /dev/null +++ b/internal/mcp/serverbootstrap/oauth_consent_test.go @@ -0,0 +1,175 @@ +package serverbootstrap + +import ( + "net/http" + "net/url" + "strings" + "testing" +) + +func TestRenderConsentPage(t *testing.T) { + w := &mockResponseWriter{header: http.Header{}} + data := consentPageData{ + ClientID: "test-client", + RedirectURI: "http://localhost:3000/callback", + State: "test-state", + CodeChallenge: "test-challenge", + CodeChallengeMethod: "S256", + } + + renderConsentPage(w, data) + + if w.status != http.StatusOK { + t.Fatalf("status = %d, want %d", w.status, http.StatusOK) + } + + contentType := w.header.Get("Content-Type") + if !strings.Contains(contentType, "text/html") { + t.Errorf("Content-Type = %q, want text/html", contentType) + } + + body := w.body.String() + if !strings.Contains(body, "") { + t.Error("expected HTML response, got non-HTML") + } + if !strings.Contains(body, "test-client") { + t.Error("expected client ID in HTML") + } + if !strings.Contains(body, "http://localhost:3000/callback") { + t.Error("expected redirect URI in HTML") + } + if !strings.Contains(body, `name="state" value="test-state"`) { + t.Error("expected state in hidden form field") + } + if !strings.Contains(body, "passphrase") { + t.Error("expected passphrase input field") + } +} + +func TestRenderConsentPage_WithError(t *testing.T) { + w := &mockResponseWriter{header: http.Header{}} + data := consentPageData{ + ClientID: "test-client", + RedirectURI: "http://localhost:3000/callback", + State: "test-state", + CodeChallenge: "test-challenge", + CodeChallengeMethod: "S256", + Error: "Incorrect passphrase", + } + + renderConsentPage(w, data) + + body := w.body.String() + if !strings.Contains(body, "Incorrect passphrase") { + t.Errorf("expected error message in HTML, got: %s", body) + } +} + +func TestOAuthConfirm_InvalidPassphraseShowsError(t *testing.T) { + clientStore := newOAuthClientStore() + clientStore.put(®isteredClient{ + ClientID: "test-client", + RedirectURIs: []string{"http://localhost:3000/callback"}, + }) + + store := newOAuthCodeStore() + handler := handleOAuthConfirm(store, clientStore, t.TempDir()) + + form := url.Values{ + "client_id": {"test-client"}, + "redirect_uri": {"http://localhost:3000/callback"}, + "code_challenge": {"test-challenge"}, + "code_challenge_method": {"S256"}, + "state": {"test-state"}, + "passphrase": {"wrong-passphrase"}, + } + req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := &mockResponseWriter{header: http.Header{}} + + handler.ServeHTTP(w, req) + + if w.status != http.StatusOK { + t.Fatalf("status = %d, want %d", w.status, http.StatusOK) + } + + body := w.body.String() + if !strings.Contains(body, "Incorrect passphrase") { + t.Errorf("expected error message about incorrect passphrase, got: %s", body) + } +} + +func TestOAuthConfirm_MissingPassphraseShowsError(t *testing.T) { + clientStore := newOAuthClientStore() + clientStore.put(®isteredClient{ + ClientID: "test-client", + RedirectURIs: []string{"http://localhost:3000/callback"}, + }) + + store := newOAuthCodeStore() + handler := handleOAuthConfirm(store, clientStore, t.TempDir()) + + form := url.Values{ + "client_id": {"test-client"}, + "redirect_uri": {"http://localhost:3000/callback"}, + "code_challenge": {"test-challenge"}, + "code_challenge_method": {"S256"}, + "state": {"test-state"}, + } + req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := &mockResponseWriter{header: http.Header{}} + + handler.ServeHTTP(w, req) + + if w.status != http.StatusOK { + t.Fatalf("status = %d, want %d", w.status, http.StatusOK) + } + + body := w.body.String() + if !strings.Contains(body, "Passphrase is required") { + t.Errorf("expected error message about missing passphrase, got: %s", body) + } +} + +func TestOAuthConfirm_InvalidClientShowsError(t *testing.T) { + store := newOAuthCodeStore() + handler := handleOAuthConfirm(store, newOAuthClientStore(), "") + + form := url.Values{ + "client_id": {"unknown-client"}, + "redirect_uri": {"http://localhost:3000/callback"}, + "code_challenge": {"test-challenge"}, + "code_challenge_method": {"S256"}, + "state": {"test-state"}, + "passphrase": {"some-pass"}, + } + req, _ := http.NewRequest(http.MethodPost, "/", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + w := &mockResponseWriter{header: http.Header{}} + + handler.ServeHTTP(w, req) + + if w.status != http.StatusOK { + t.Fatalf("status = %d, want %d", w.status, http.StatusOK) + } + + body := w.body.String() + if !strings.Contains(body, "Invalid client ID") { + t.Errorf("expected error message about invalid client, got: %s", body) + } +} + +func TestOAuthConfirm_GetNotAllowed(t *testing.T) { + store := newOAuthCodeStore() + handler := handleOAuthConfirm(store, newOAuthClientStore(), "") + + req, _ := http.NewRequest(http.MethodGet, "/", nil) + w := &mockResponseWriter{header: http.Header{}} + + handler.ServeHTTP(w, req) + + if w.status != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want %d", w.status, http.StatusMethodNotAllowed) + } +}