4 Commits

Author SHA1 Message Date
Kyle Isom
519f8f8879 sso: add PublicURL for browser authorize (split from backend MciasURL)
Lets services point the browser SSO authorize redirect at the public
MCIAS hostname while keeping the server-to-server code exchange on the
internal/Tailnet address (efficient, edge-independent). PublicURL is
optional and falls back to MciasURL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:03:27 -07:00
4b54e50a0d Make database chmod best-effort for rootless podman
os.Chmod(path, 0600) fails inside rootless podman containers because
fchmod is denied in the user namespace. This was fatal — the database
wouldn't open, crashing the service.

Changed to best-effort: log nothing on failure, database functions
correctly without the permission tightening. The file is already
protected by the container's volume mount and the host filesystem
permissions.

Root cause of the 2026-04-03 incident recovery failure — MCR and
Metacrypt couldn't start until their databases were deleted and
recreated.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:32:52 -07:00
453c52584c Fix SSO cookies not stored on Firefox 302 redirects
Firefox does not reliably store Set-Cookie headers on 302 responses
that redirect to a different origin. Change RedirectToLogin to return
a 200 with an HTML meta-refresh instead, ensuring cookies are stored
before navigation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 23:13:37 -07:00
bcab16f2bf Fix SSO return-to redirect loop
SetReturnToCookie stored /sso/redirect as the return-to path,
causing a redirect loop after successful SSO login: the callback
would redirect back to /sso/redirect instead of /. Filter all
/sso/* paths, not just /sso/callback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 14:54:55 -07:00
3 changed files with 46 additions and 14 deletions

View File

@@ -65,11 +65,11 @@ func Open(path string) (*sql.DB, error) {
// connection to serialize all access and eliminate busy errors. // connection to serialize all access and eliminate busy errors.
database.SetMaxOpenConns(1) database.SetMaxOpenConns(1)
// Ensure permissions are correct even if the file already existed. // Best-effort permissions tightening. This may fail inside rootless
if err := os.Chmod(path, 0600); err != nil { // podman containers where fchmod is denied in the user namespace.
_ = database.Close() // The database still functions correctly without it.
return nil, fmt.Errorf("db: chmod %s: %w", path, err) // See: log/2026-04-03-uid-incident.md
} _ = os.Chmod(path, 0600)
return database, nil return database, nil
} }
@@ -168,9 +168,7 @@ func Snapshot(database *sql.DB, destPath string) error {
return fmt.Errorf("db: snapshot: %w", err) return fmt.Errorf("db: snapshot: %w", err)
} }
if err := os.Chmod(destPath, 0600); err != nil { _ = os.Chmod(destPath, 0600) // best-effort; may fail in rootless containers
return fmt.Errorf("db: chmod snapshot %s: %w", destPath, err)
}
return nil return nil
} }

View File

@@ -22,6 +22,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@@ -38,9 +39,18 @@ const (
// Config holds the SSO client configuration. The values must match the // Config holds the SSO client configuration. The values must match the
// SSO client registration in MCIAS config. // SSO client registration in MCIAS config.
type Config struct { type Config struct {
// MciasURL is the base URL of the MCIAS server. // MciasURL is the base URL of the MCIAS server used for the
// server-to-server authorization-code exchange. This is typically the
// internal/Tailnet address so the exchange does not depend on the public
// edge.
MciasURL string MciasURL string
// PublicURL, when set, is the browser-facing MCIAS base URL used to build
// the authorize redirect. It must be resolvable and reachable by end-user
// browsers (e.g. the public hostname). When empty, MciasURL is used for
// both, which only works when MciasURL is itself browser-reachable.
PublicURL string
// ClientID is the registered SSO client identifier. // ClientID is the registered SSO client identifier.
ClientID string ClientID string
@@ -103,9 +113,19 @@ func New(cfg Config) (*Client, error) {
}, nil }, nil
} }
// AuthorizeURL returns the MCIAS authorize URL with the given state parameter. // authorizeBase returns the browser-facing MCIAS base URL: PublicURL when
// configured, otherwise MciasURL.
func (c *Client) authorizeBase() string {
if c.cfg.PublicURL != "" {
return c.cfg.PublicURL
}
return c.cfg.MciasURL
}
// AuthorizeURL returns the MCIAS authorize URL (browser-facing) with the
// given state parameter.
func (c *Client) AuthorizeURL(state string) string { func (c *Client) AuthorizeURL(state string) string {
base := strings.TrimRight(c.cfg.MciasURL, "/") base := strings.TrimRight(c.authorizeBase(), "/")
return base + "/sso/authorize?" + url.Values{ return base + "/sso/authorize?" + url.Values{
"client_id": {c.cfg.ClientID}, "client_id": {c.cfg.ClientID},
"redirect_uri": {c.cfg.RedirectURI}, "redirect_uri": {c.cfg.RedirectURI},
@@ -229,7 +249,7 @@ func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, querySt
// redirect back to it after SSO login completes. // redirect back to it after SSO login completes.
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) { func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
path := r.URL.Path path := r.URL.Path
if path == "" || path == "/login" || path == "/sso/callback" { if path == "" || path == "/login" || strings.HasPrefix(path, "/sso/") {
path = "/" path = "/"
} }
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
@@ -268,6 +288,12 @@ func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string
// RedirectToLogin generates a state, sets the state and return-to cookies, // RedirectToLogin generates a state, sets the state and return-to cookies,
// and redirects the user to the MCIAS authorize URL. // and redirects the user to the MCIAS authorize URL.
//
// The redirect is performed via a 200 response with an HTML meta-refresh
// instead of a 302. Some browsers (notably Firefox) do not reliably store
// Set-Cookie headers on 302 responses that redirect to a different origin,
// even when the origins are same-site. Using a 200 response ensures the
// cookies are stored before the browser navigates away.
func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error { func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) error {
state, err := GenerateState() state, err := GenerateState()
if err != nil { if err != nil {
@@ -276,7 +302,15 @@ func RedirectToLogin(w http.ResponseWriter, r *http.Request, client *Client, coo
SetStateCookie(w, cookiePrefix, state) SetStateCookie(w, cookiePrefix, state)
SetReturnToCookie(w, r, cookiePrefix) SetReturnToCookie(w, r, cookiePrefix)
http.Redirect(w, r, client.AuthorizeURL(state), http.StatusFound)
authorizeURL := client.AuthorizeURL(state)
escaped := html.EscapeString(authorizeURL)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, `<!DOCTYPE html>
<html><head><meta http-equiv="refresh" content="0;url=%s"></head>
<body><p>Redirecting to <a href="%s">MCIAS</a>...</p></body></html>`,
escaped, escaped)
return nil return nil
} }

View File

@@ -193,7 +193,7 @@ func TestReturnToDefaultsToRoot(t *testing.T) {
} }
func TestReturnToSkipsLoginPaths(t *testing.T) { func TestReturnToSkipsLoginPaths(t *testing.T) {
for _, p := range []string{"/login", "/sso/callback"} { for _, p := range []string{"/login", "/sso/callback", "/sso/redirect"} {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, p, nil) req := httptest.NewRequest(http.MethodGet, p, nil)
SetReturnToCookie(rec, req, "mcr") SetReturnToCookie(rec, req, "mcr")