a73x

internal/smoke/login.go

Ref:   Size: 4.3 KiB   History

package smoke

import (
	"fmt"
	"net/http"
	"net/http/cookiejar"
	"net/url"
	"time"

	"github.com/a73x/eitri/internal/server/api/client"
)

// sessionCookie is the name of the console session cookie the callback sets.
// Its presence in the jar after the credential POST is the one honest signal
// that sign-in succeeded — a failed password re-renders the login form as a
// plain 200 without ever setting it.
const sessionCookie = "eitri_session"

// loginPAT signs in through the real OIDC code flow — the same doors a browser
// walks — and mints a short-lived PAT for this run. There is no special grant
// for machines (spec §3): CI registers a flat-file user and then drives the
// standard authorization-code flow headlessly against the plain-HTML login
// form, lands an ordinary session, and mints a PAT through the normal route.
func loginPAT(serverURL, email, password string) (string, error) {
	base, err := url.Parse(serverURL)
	if err != nil {
		return "", fmt.Errorf("parse server url %q: %w", serverURL, err)
	}
	jar, err := cookiejar.New(nil)
	if err != nil {
		return "", fmt.Errorf("cookie jar: %w", err)
	}
	// A default (redirect-following) client: GET /auth/login bounces through the
	// issuer's authorize endpoint to the login form, and the credential POST
	// runs issuer -> /auth/callback -> / — we want every hop followed so the
	// session cookie lands in the jar and resp.Request.URL is the form's URL.
	hc := &http.Client{Jar: jar, Timeout: 30 * time.Second}

	// GET /auth/login follows the redirect chain to the issuer's login form.
	// We POST credentials straight back to resp.Request.URL — the authorize
	// URL we landed on, query intact — so no HTML parsing is needed and the
	// form's action attribute is never consulted. (eitri-oidc accepts the
	// credential POST on that same URL by contract, spec §2.1.)
	resp, err := hc.Get(serverURL + "/auth/login")
	if err != nil {
		return "", fmt.Errorf("GET /auth/login: %w", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("sign-in: login form GET returned %d (want 200) at %s", resp.StatusCode, resp.Request.URL)
	}
	formURL := resp.Request.URL.String()

	// POST credentials to the form URL; the redirects run issuer -> /auth/callback
	// -> / and the callback plants the session cookie in the jar on success.
	resp2, err := hc.PostForm(formURL, url.Values{
		"email":    {email},
		"password": {password},
	})
	if err != nil {
		return "", fmt.Errorf("POST credentials: %w", err)
	}
	resp2.Body.Close()

	// A failed sign-in re-renders the login form as a 200 without a session
	// cookie. Detect success by the cookie's presence in the jar, never by the
	// status. The password is never included in the error.
	if cookieByName(jar.Cookies(base), sessionCookie) == nil {
		return "", fmt.Errorf("sign-in failed for %q: no %s cookie after credential POST "+
			"(final status %d at %s) — check the CI user exists in eitri-oidc and the password matches",
			email, sessionCookie, resp2.StatusCode, resp2.Request.URL)
	}

	// Mint the PAT through the shared client, riding the session cookie in the
	// jar (Token left empty so no Bearer header is sent).
	c := &client.Client{BaseURL: serverURL, HTTP: hc}
	tok, err := c.CreateAPIToken("boot-gate", time.Hour)
	if err != nil {
		return "", fmt.Errorf("mint boot-gate PAT: %w", err)
	}
	return tok.Token, nil
}

// proveCredentialChain is the boot-gate's phase-1 proof: it confirms the minted
// PAT resolves to a non-empty tenant via GET /api/v1/me, exercising the whole
// issuer -> login -> session -> PAT-mint chain without touching VMs. It returns
// the tenant handle so the caller can log which tenant the ci user landed in.
func proveCredentialChain(serverURL, token string) (string, error) {
	c := &client.Client{BaseURL: serverURL, Token: token, HTTP: &http.Client{Timeout: 30 * time.Second}}
	me, err := c.Me()
	if err != nil {
		return "", fmt.Errorf("credential-chain proof: Me() with minted PAT: %w", err)
	}
	if me.Tenant == "" {
		return "", fmt.Errorf("credential-chain proof: minted PAT resolved to an empty tenant")
	}
	return me.Tenant, nil
}

// cookieByName returns the named cookie from cookies, or nil if absent.
func cookieByName(cookies []*http.Cookie, name string) *http.Cookie {
	for _, c := range cookies {
		if c.Name == name {
			return c
		}
	}
	return nil
}