7 Commits

Author SHA1 Message Date
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
8561b34451 Add mcdsl/sso package for SSO redirect clients
New package providing the client side of the MCIAS SSO authorization
code flow. Web services use this to redirect users to MCIAS for login
and exchange the returned authorization code for a JWT.

- Client type with AuthorizeURL() and ExchangeCode() (TLS 1.3 minimum)
- State cookie helpers (SameSite=Lax for cross-site redirect compat)
- Return-to cookie for preserving the original URL across the redirect
- RedirectToLogin() and HandleCallback() high-level helpers
- Full test suite with mock MCIAS server

Security:
- State is 256-bit random, stored in HttpOnly/Secure/Lax cookie
- Return-to URLs stored client-side only (MCIAS never sees them)
- Login/callback paths excluded from return-to to prevent loops

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 15:26:50 -07:00
818c0c2e14 Add ReadPasswordBytes for crypto use cases
Returns []byte so callers can zeroize the buffer after use.
Refactors internals to share readRaw between both variants.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:32:36 -07:00
b886f9d222 Add terminal package with secure ReadPassword helper
Provides echo-suppressed password prompting via golang.org/x/term for
CLI login commands. Added as a platform standard in engineering-standards.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 11:10:06 -07:00
ebe2079a83 Migrate module path from kyle/ to mc/ org
All import paths updated from git.wntrmute.dev/kyle/mcdsl to
git.wntrmute.dev/mc/mcdsl to match the Gitea organization.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:03:45 -07:00
22 changed files with 639 additions and 35 deletions

View File

@@ -27,7 +27,7 @@ problem, usable independently or together.
### Module Path ### Module Path
``` ```
git.wntrmute.dev/kyle/mcdsl git.wntrmute.dev/mc/mcdsl
``` ```
### Dependencies ### Dependencies
@@ -39,7 +39,7 @@ git.wntrmute.dev/kyle/mcdsl
| `github.com/pelletier/go-toml/v2` | TOML config parsing | | `github.com/pelletier/go-toml/v2` | TOML config parsing |
| `google.golang.org/grpc` | gRPC server | | `google.golang.org/grpc` | gRPC server |
| `github.com/klauspost/compress/zstd` | Zstandard compression for archives | | `github.com/klauspost/compress/zstd` | Zstandard compression for archives |
| `git.wntrmute.dev/kyle/mcias/clients/go` | MCIAS client library | | `git.wntrmute.dev/mc/mcias/clients/go` | MCIAS client library |
All dependencies are already used by existing services. MCDSL adds no new All dependencies are already used by existing services. MCDSL adds no new
dependencies to the platform. dependencies to the platform.

View File

@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
MCDSL (Metacircular Dynamics Standard Library) is the shared Go library for the Metacircular platform. It extracts common patterns from production services into reusable, tested packages. MCDSL is not a deployed service — it is imported by other services. MCDSL (Metacircular Dynamics Standard Library) is the shared Go library for the Metacircular platform. It extracts common patterns from production services into reusable, tested packages. MCDSL is not a deployed service — it is imported by other services.
**Module path:** `git.wntrmute.dev/kyle/mcdsl` **Module path:** `git.wntrmute.dev/mc/mcdsl`
## Build Commands ## Build Commands
@@ -37,6 +37,7 @@ go test ./auth/ -run TestCacheExpiry
| `web` | Session cookies, auth middleware, template rendering for htmx UIs | | `web` | Session cookies, auth middleware, template rendering for htmx UIs |
| `health` | REST and gRPC health check handlers | | `health` | REST and gRPC health check handlers |
| `archive` | tar.zst snapshots with SQLite-aware backup (VACUUM INTO) | | `archive` | tar.zst snapshots with SQLite-aware backup (VACUUM INTO) |
| `terminal` | Secure terminal input (echo-suppressed password prompts) |
## Import Pattern ## Import Pattern
@@ -44,11 +45,11 @@ Services import individual packages with aliased imports:
```go ```go
import ( import (
mcdslauth "git.wntrmute.dev/kyle/mcdsl/auth" mcdslauth "git.wntrmute.dev/mc/mcdsl/auth"
mcdsldb "git.wntrmute.dev/kyle/mcdsl/db" mcdsldb "git.wntrmute.dev/mc/mcdsl/db"
mcdslconfig "git.wntrmute.dev/kyle/mcdsl/config" mcdslconfig "git.wntrmute.dev/mc/mcdsl/config"
"git.wntrmute.dev/kyle/mcdsl/httpserver" "git.wntrmute.dev/mc/mcdsl/httpserver"
"git.wntrmute.dev/kyle/mcdsl/grpcserver" "git.wntrmute.dev/mc/mcdsl/grpcserver"
) )
``` ```
@@ -63,6 +64,7 @@ db --> (modernc.org/sqlite)
grpcserver --> auth, config grpcserver --> auth, config
health --> db health --> db
httpserver --> config httpserver --> config
terminal --> (golang.org/x/term)
web --> auth, csrf web --> auth, csrf
``` ```

View File

@@ -91,11 +91,11 @@ Remaining mcat-specific code:
- `web/` — templates and static assets (unchanged) - `web/` — templates and static assets (unchanged)
Dependencies removed: Dependencies removed:
- `git.wntrmute.dev/kyle/mcias/clients/go` (mcdsl/auth handles MCIAS directly) - `git.wntrmute.dev/mc/mcias/clients/go` (mcdsl/auth handles MCIAS directly)
- `github.com/pelletier/go-toml/v2` (now indirect via mcdsl/config) - `github.com/pelletier/go-toml/v2` (now indirect via mcdsl/config)
Dependencies added: Dependencies added:
- `git.wntrmute.dev/kyle/mcdsl` (local replace directive) - `git.wntrmute.dev/mc/mcdsl` (local replace directive)
Result: vet clean, lint 0 issues, builds successfully. Result: vet clean, lint 0 issues, builds successfully.

View File

@@ -10,7 +10,7 @@ code first).
## Phase 0: Project Setup ## Phase 0: Project Setup
- [ ] Initialize Go module (`git.wntrmute.dev/kyle/mcdsl`) - [ ] Initialize Go module (`git.wntrmute.dev/mc/mcdsl`)
- [ ] Create `.golangci.yaml` (matching platform standard) - [ ] Create `.golangci.yaml` (matching platform standard)
- [ ] Create `Makefile` with standard targets (build, test, vet, lint, all) - [ ] Create `Makefile` with standard targets (build, test, vet, lint, all)
- [ ] Create `.gitignore` - [ ] Create `.gitignore`

View File

@@ -25,7 +25,7 @@ it and provide only their service-specific logic.
## Module Path ## Module Path
``` ```
git.wntrmute.dev/kyle/mcdsl git.wntrmute.dev/mc/mcdsl
``` ```
## Packages ## Packages
@@ -46,10 +46,10 @@ git.wntrmute.dev/kyle/mcdsl
```go ```go
import ( import (
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
"git.wntrmute.dev/kyle/mcdsl/db" "git.wntrmute.dev/mc/mcdsl/db"
"git.wntrmute.dev/kyle/mcdsl/config" "git.wntrmute.dev/mc/mcdsl/config"
"git.wntrmute.dev/kyle/mcdsl/httpserver" "git.wntrmute.dev/mc/mcdsl/httpserver"
) )
// Load config with standard sections + service-specific fields. // Load config with standard sections + service-specific fields.

View File

@@ -20,7 +20,7 @@ import (
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"git.wntrmute.dev/kyle/mcdsl/db" "git.wntrmute.dev/mc/mcdsl/db"
) )
// defaultExcludePatterns are always excluded from snapshots. // defaultExcludePatterns are always excluded from snapshots.

View File

@@ -7,7 +7,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.wntrmute.dev/kyle/mcdsl/db" "git.wntrmute.dev/mc/mcdsl/db"
) )
// setupServiceDir creates a realistic /srv/<service>/ directory. // setupServiceDir creates a realistic /srv/<service>/ directory.

View File

@@ -34,7 +34,7 @@ import (
"github.com/pelletier/go-toml/v2" "github.com/pelletier/go-toml/v2"
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
) )
// Base contains the configuration sections common to all Metacircular // Base contains the configuration sections common to all Metacircular

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

3
go.mod
View File

@@ -1,4 +1,4 @@
module git.wntrmute.dev/kyle/mcdsl module git.wntrmute.dev/mc/mcdsl
go 1.25.7 go 1.25.7
@@ -18,6 +18,7 @@ require (
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.48.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.32.0 // indirect golang.org/x/text v0.32.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/protobuf v1.36.10 // indirect google.golang.org/protobuf v1.36.10 // indirect

2
go.sum
View File

@@ -49,6 +49,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=

View File

@@ -21,7 +21,7 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
) )
// MethodMap classifies gRPC methods for access control. // MethodMap classifies gRPC methods for access control.

View File

@@ -13,7 +13,7 @@ import (
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/grpc/status" "google.golang.org/grpc/status"
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
) )
func mockMCIAS(t *testing.T) *httptest.Server { func mockMCIAS(t *testing.T) *httptest.Server {

View File

@@ -10,7 +10,7 @@ import (
"google.golang.org/grpc" "google.golang.org/grpc"
"git.wntrmute.dev/kyle/mcdsl/db" "git.wntrmute.dev/mc/mcdsl/db"
) )
func TestHandlerHealthy(t *testing.T) { func TestHandlerHealthy(t *testing.T) {

View File

@@ -13,7 +13,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.wntrmute.dev/kyle/mcdsl/config" "git.wntrmute.dev/mc/mcdsl/config"
) )
// Server wraps a chi router and an http.Server with the standard // Server wraps a chi router and an http.Server with the standard

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"time" "time"
"git.wntrmute.dev/kyle/mcdsl/config" "git.wntrmute.dev/mc/mcdsl/config"
) )
func testConfig() config.ServerConfig { func testConfig() config.ServerConfig {

319
sso/sso.go Normal file
View File

@@ -0,0 +1,319 @@
// Package sso provides an SSO redirect client for Metacircular web services.
//
// Services redirect unauthenticated users to MCIAS for login. After
// authentication, MCIAS redirects back with an authorization code that
// the service exchanges for a JWT token. This package handles the
// redirect, state management, and code exchange.
//
// Security design:
// - State cookies use SameSite=Lax (not Strict) because the redirect from
// MCIAS back to the service is a cross-site navigation.
// - State is a 256-bit random value stored in an HttpOnly cookie.
// - Return-to URLs are stored in a separate cookie so MCIAS never sees them.
// - The code exchange is a server-to-server HTTPS call (TLS 1.3 minimum).
package sso
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
stateBytes = 32 // 256 bits
stateCookieAge = 5 * 60 // 5 minutes in seconds
)
// Config holds the SSO client configuration. The values must match the
// SSO client registration in MCIAS config.
type Config struct {
// MciasURL is the base URL of the MCIAS server.
MciasURL string
// ClientID is the registered SSO client identifier.
ClientID string
// RedirectURI is the callback URL that MCIAS redirects to after login.
// Must exactly match the redirect_uri registered in MCIAS config.
RedirectURI string
// CACert is an optional path to a PEM-encoded CA certificate for
// verifying the MCIAS server's TLS certificate.
CACert string
}
// Client handles the SSO redirect flow with MCIAS.
type Client struct {
cfg Config
httpClient *http.Client
}
// New creates an SSO client. TLS 1.3 is required for all HTTPS
// connections to MCIAS.
func New(cfg Config) (*Client, error) {
if cfg.MciasURL == "" {
return nil, fmt.Errorf("sso: mcias_url is required")
}
if cfg.ClientID == "" {
return nil, fmt.Errorf("sso: client_id is required")
}
if cfg.RedirectURI == "" {
return nil, fmt.Errorf("sso: redirect_uri is required")
}
transport := &http.Transport{}
if !strings.HasPrefix(cfg.MciasURL, "http://") {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
}
if cfg.CACert != "" {
pem, err := os.ReadFile(cfg.CACert) //nolint:gosec // CA cert path from operator config
if err != nil {
return nil, fmt.Errorf("sso: read CA cert %s: %w", cfg.CACert, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("sso: no valid certificates in %s", cfg.CACert)
}
tlsCfg.RootCAs = pool
}
transport.TLSClientConfig = tlsCfg
}
return &Client{
cfg: cfg,
httpClient: &http.Client{
Transport: transport,
Timeout: 10 * time.Second,
},
}, nil
}
// AuthorizeURL returns the MCIAS authorize URL with the given state parameter.
func (c *Client) AuthorizeURL(state string) string {
base := strings.TrimRight(c.cfg.MciasURL, "/")
return base + "/sso/authorize?" + url.Values{
"client_id": {c.cfg.ClientID},
"redirect_uri": {c.cfg.RedirectURI},
"state": {state},
}.Encode()
}
// ExchangeCode exchanges an authorization code for a JWT token by calling
// MCIAS POST /v1/sso/token.
func (c *Client) ExchangeCode(ctx context.Context, code string) (token string, expiresAt time.Time, err error) {
reqBody, _ := json.Marshal(map[string]string{
"code": code,
"client_id": c.cfg.ClientID,
"redirect_uri": c.cfg.RedirectURI,
})
base := strings.TrimRight(c.cfg.MciasURL, "/")
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
base+"/v1/sso/token", bytes.NewReader(reqBody))
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: build exchange request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: MCIAS exchange: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", time.Time{}, fmt.Errorf("sso: read exchange response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("sso: exchange failed (HTTP %d): %s", resp.StatusCode, body)
}
var result struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", time.Time{}, fmt.Errorf("sso: decode exchange response: %w", err)
}
exp, parseErr := time.Parse(time.RFC3339, result.ExpiresAt)
if parseErr != nil {
exp = time.Now().Add(1 * time.Hour)
}
return result.Token, exp, nil
}
// GenerateState returns a cryptographically random hex-encoded state string.
func GenerateState() (string, error) {
raw := make([]byte, stateBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("sso: generate state: %w", err)
}
return hex.EncodeToString(raw), nil
}
// StateCookieName returns the cookie name used for SSO state for a given
// service cookie prefix (e.g., "mcr" → "mcr_sso_state").
func StateCookieName(prefix string) string {
return prefix + "_sso_state"
}
// ReturnToCookieName returns the cookie name used for SSO return-to URL
// (e.g., "mcr" → "mcr_sso_return").
func ReturnToCookieName(prefix string) string {
return prefix + "_sso_return"
}
// SetStateCookie stores the SSO state in a short-lived cookie.
//
// Security: SameSite=Lax is required because the redirect from MCIAS back to
// the service is a cross-site top-level navigation. SameSite=Strict cookies
// would not be sent on that redirect.
func SetStateCookie(w http.ResponseWriter, prefix, state string) {
http.SetCookie(w, &http.Cookie{
Name: StateCookieName(prefix),
Value: state,
Path: "/",
MaxAge: stateCookieAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ValidateStateCookie compares the state query parameter against the state
// cookie. If they match, the cookie is cleared and nil is returned.
func ValidateStateCookie(w http.ResponseWriter, r *http.Request, prefix, queryState string) error {
c, err := r.Cookie(StateCookieName(prefix))
if err != nil || c.Value == "" {
return fmt.Errorf("sso: missing state cookie")
}
if c.Value != queryState {
return fmt.Errorf("sso: state mismatch")
}
// Clear the state cookie (single-use).
http.SetCookie(w, &http.Cookie{
Name: StateCookieName(prefix),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
return nil
}
// SetReturnToCookie stores the current request path so the service can
// redirect back to it after SSO login completes.
func SetReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) {
path := r.URL.Path
if path == "" || path == "/login" || strings.HasPrefix(path, "/sso/") {
path = "/"
}
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: path,
Path: "/",
MaxAge: stateCookieAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
}
// ConsumeReturnToCookie reads and clears the return-to cookie, returning
// the path. Returns "/" if the cookie is missing or empty.
func ConsumeReturnToCookie(w http.ResponseWriter, r *http.Request, prefix string) string {
c, err := r.Cookie(ReturnToCookieName(prefix))
path := "/"
if err == nil && c.Value != "" {
path = c.Value
}
// Clear the cookie.
http.SetCookie(w, &http.Cookie{
Name: ReturnToCookieName(prefix),
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
return path
}
// RedirectToLogin generates a state, sets the state and return-to cookies,
// 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 {
state, err := GenerateState()
if err != nil {
return err
}
SetStateCookie(w, cookiePrefix, state)
SetReturnToCookie(w, r, cookiePrefix)
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
}
// HandleCallback validates the state, exchanges the authorization code for
// a JWT, and returns the token and the return-to path. The caller should
// set the session cookie with the returned token.
func HandleCallback(w http.ResponseWriter, r *http.Request, client *Client, cookiePrefix string) (token, returnTo string, err error) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" || state == "" {
return "", "", fmt.Errorf("sso: missing code or state parameter")
}
if err := ValidateStateCookie(w, r, cookiePrefix, state); err != nil {
return "", "", err
}
token, _, err = client.ExchangeCode(r.Context(), code)
if err != nil {
return "", "", err
}
returnTo = ConsumeReturnToCookie(w, r, cookiePrefix)
return token, returnTo, nil
}

225
sso/sso_test.go Normal file
View File

@@ -0,0 +1,225 @@
package sso
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNewValidation(t *testing.T) {
tests := []struct {
name string
cfg Config
wantErr bool
}{
{"valid", Config{MciasURL: "https://mcias.example.com", ClientID: "mcr", RedirectURI: "https://mcr.example.com/cb"}, false},
{"missing url", Config{ClientID: "mcr", RedirectURI: "https://mcr.example.com/cb"}, true},
{"missing client_id", Config{MciasURL: "https://mcias.example.com", RedirectURI: "https://mcr.example.com/cb"}, true},
{"missing redirect_uri", Config{MciasURL: "https://mcias.example.com", ClientID: "mcr"}, true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := New(tc.cfg)
if tc.wantErr && err == nil {
t.Error("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}
func TestAuthorizeURL(t *testing.T) {
c, err := New(Config{
MciasURL: "http://localhost:8443",
ClientID: "mcr",
RedirectURI: "https://mcr.example.com/sso/callback",
})
if err != nil {
t.Fatalf("New: %v", err)
}
u := c.AuthorizeURL("test-state")
if u == "" {
t.Fatal("AuthorizeURL returned empty string")
}
// Should contain all required params.
for _, want := range []string{"client_id=mcr", "state=test-state", "redirect_uri="} {
if !contains(u, want) {
t.Errorf("URL %q missing %q", u, want)
}
}
}
func TestExchangeCode(t *testing.T) {
// Fake MCIAS server.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/sso/token" {
http.Error(w, "not found", http.StatusNotFound)
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Code string `json:"code"`
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req.Code != "valid-code" {
http.Error(w, `{"error":"invalid code"}`, http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{
"token": "jwt-token-here",
"expires_at": "2026-03-30T23:00:00Z",
})
}))
defer srv.Close()
c, err := New(Config{
MciasURL: srv.URL,
ClientID: "mcr",
RedirectURI: "https://mcr.example.com/cb",
})
if err != nil {
t.Fatalf("New: %v", err)
}
// Valid code.
token, _, err := c.ExchangeCode(t.Context(), "valid-code")
if err != nil {
t.Fatalf("ExchangeCode: %v", err)
}
if token != "jwt-token-here" {
t.Errorf("token = %q, want %q", token, "jwt-token-here")
}
// Invalid code.
_, _, err = c.ExchangeCode(t.Context(), "bad-code")
if err == nil {
t.Error("expected error for bad code")
}
}
func TestGenerateState(t *testing.T) {
s1, err := GenerateState()
if err != nil {
t.Fatalf("GenerateState: %v", err)
}
if len(s1) != 64 { // 32 bytes = 64 hex chars
t.Errorf("state length = %d, want 64", len(s1))
}
s2, err := GenerateState()
if err != nil {
t.Fatalf("GenerateState: %v", err)
}
if s1 == s2 {
t.Error("two states should differ")
}
}
func TestStateCookieRoundTrip(t *testing.T) {
state := "test-state-value"
rec := httptest.NewRecorder()
SetStateCookie(rec, "mcr", state)
// Simulate a request with the cookie.
req := httptest.NewRequest(http.MethodGet, "/sso/callback?state="+state, nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
w := httptest.NewRecorder()
if err := ValidateStateCookie(w, req, "mcr", state); err != nil {
t.Fatalf("ValidateStateCookie: %v", err)
}
}
func TestStateCookieMismatch(t *testing.T) {
rec := httptest.NewRecorder()
SetStateCookie(rec, "mcr", "correct-state")
req := httptest.NewRequest(http.MethodGet, "/sso/callback?state=wrong-state", nil)
for _, c := range rec.Result().Cookies() {
req.AddCookie(c)
}
w := httptest.NewRecorder()
if err := ValidateStateCookie(w, req, "mcr", "wrong-state"); err == nil {
t.Error("expected error for state mismatch")
}
}
func TestReturnToCookie(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/repositories/myrepo", nil)
SetReturnToCookie(rec, req, "mcr")
// Read back.
req2 := httptest.NewRequest(http.MethodGet, "/sso/callback", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
w2 := httptest.NewRecorder()
path := ConsumeReturnToCookie(w2, req2, "mcr")
if path != "/repositories/myrepo" {
t.Errorf("return-to = %q, want %q", path, "/repositories/myrepo")
}
}
func TestReturnToDefaultsToRoot(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/sso/callback", nil)
w := httptest.NewRecorder()
path := ConsumeReturnToCookie(w, req, "mcr")
if path != "/" {
t.Errorf("return-to = %q, want %q", path, "/")
}
}
func TestReturnToSkipsLoginPaths(t *testing.T) {
for _, p := range []string{"/login", "/sso/callback", "/sso/redirect"} {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, p, nil)
SetReturnToCookie(rec, req, "mcr")
req2 := httptest.NewRequest(http.MethodGet, "/sso/callback", nil)
for _, c := range rec.Result().Cookies() {
req2.AddCookie(c)
}
w2 := httptest.NewRecorder()
path := ConsumeReturnToCookie(w2, req2, "mcr")
if path != "/" {
t.Errorf("return-to for %s = %q, want %q", p, path, "/")
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

36
terminal/terminal.go Normal file
View File

@@ -0,0 +1,36 @@
// Package terminal provides secure terminal input helpers for CLI tools.
package terminal
import (
"fmt"
"os"
"golang.org/x/term"
)
// ReadPassword prints the given prompt to stderr and reads a password
// from the terminal with echo disabled. It prints a newline after the
// input is complete so the cursor advances normally.
func ReadPassword(prompt string) (string, error) {
b, err := readRaw(prompt)
if err != nil {
return "", err
}
return string(b), nil
}
// ReadPasswordBytes is like ReadPassword but returns a []byte so the
// caller can zeroize the buffer after use.
func ReadPasswordBytes(prompt string) ([]byte, error) {
return readRaw(prompt)
}
func readRaw(prompt string) ([]byte, error) {
fmt.Fprint(os.Stderr, prompt)
b, err := term.ReadPassword(int(os.Stdin.Fd())) //nolint:gosec // fd fits in int
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
return b, nil
}

21
terminal/terminal_test.go Normal file
View File

@@ -0,0 +1,21 @@
package terminal
import (
"testing"
)
func TestReadPasswordNotATTY(t *testing.T) {
// When stdin is not a terminal (e.g. in CI), ReadPassword should
// return an error rather than hanging or panicking.
_, err := ReadPassword("Password: ")
if err == nil {
t.Fatal("expected error when stdin is not a terminal")
}
}
func TestReadPasswordBytesNotATTY(t *testing.T) {
_, err := ReadPasswordBytes("Password: ")
if err == nil {
t.Fatal("expected error when stdin is not a terminal")
}
}

View File

@@ -8,7 +8,7 @@ import (
"io/fs" "io/fs"
"net/http" "net/http"
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
) )
// SetSessionCookie sets a session cookie with the standard Metacircular // SetSessionCookie sets a session cookie with the standard Metacircular

View File

@@ -9,7 +9,7 @@ import (
"testing" "testing"
"testing/fstest" "testing/fstest"
"git.wntrmute.dev/kyle/mcdsl/auth" "git.wntrmute.dev/mc/mcdsl/auth"
) )
func TestSetSessionCookie(t *testing.T) { func TestSetSessionCookie(t *testing.T) {