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
}