Loading docs/openapi.yaml +5 −1 Changes for docs/openapi.yaml: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -3359,7 +3359,9 @@ paths: the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is spent in the same transaction that stores the session, so a redemption that fails with 500 leaves the link working for another try. x-felis-face: [external] x-felis-tier: public security: [] Loading Loading @@ -3407,6 +3409,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' /api/v1/auth/setup/status: get: Loading internal/api/api_test.go +12 −3 Changes for internal/api/api_test.go: 12 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -94,6 +94,7 @@ type fakeRepo struct { failRevokeOthers error failMarkReauth error failGetSetting error failRedeemSetup error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP Loading Loading @@ -1779,15 +1780,23 @@ func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string, return nil } // ConsumeSetupToken atomically marks a one-time setup token consumed and returns // its user_id, or ErrNotFound when absent, already consumed, or expired. func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) { // RedeemSetupToken spends a setup token and stores s for its user, or ErrNotFound // when the token is absent, already spent, or expired. failRedeemSetup fails the // whole redemption. func (f *fakeRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { if f.failRedeemSetup != nil { return "", f.failRedeemSetup } tok, ok := f.setupTokens[tokenHash] if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) { return "", ErrNotFound } tok.ConsumedAt = now f.setupTokens[tokenHash] = tok s.UserID = tok.UserID if err := f.CreateSession(ctx, s); err != nil { return "", err } return tok.UserID, nil } Loading internal/api/handlers_setup.go +15 −9 Changes for internal/api/handlers_setup.go: 15 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -62,26 +62,32 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) { sum := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(sum[:]) now := a.now() userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now) // A regular felis_session; the lockdown is a product-level restriction the // frontend enforces until email is verified / a passkey is bound. The token and // the session are written together: a new setup link takes `felis setup` on // the node, so a failure here must leave this one working. sessionToken, session, err := a.mintSession(r, "", provenSignIn) if err != nil { writeError(w, r, err) return } userID, err := a.Repo.RedeemSetupToken(r.Context(), tokenHash, a.now(), session) switch { case errors.Is(err, ErrNotFound): // Unknown, already-consumed, or expired — uniform 400 so the token cannot // be used as an oracle. a.authFailure(r, "setup_redeem", "bad_token", nil) writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid", "this setup link is invalid or has already been used")) return } u, err := a.Repo.UserByID(r.Context(), userID) if err != nil { case err != nil: writeError(w, r, err) return } setSessionCookie(w, sessionToken, session.ExpiresAt) // Mint the session — a regular felis_session; the lockdown is a product-level // restriction the frontend enforces until email is verified / a passkey is bound. if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { u, err := a.Repo.UserByID(r.Context(), userID) if err != nil { writeError(w, r, err) return } Loading internal/api/handlers_setup_test.go +53 −0 Changes for internal/api/handlers_setup_test.go: 53 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "testing" "time" ) // TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email Loading Loading @@ -137,6 +141,55 @@ func TestSetupCompletesForNoEmailPlayer(t *testing.T) { } } // Redeeming a setup link signs the owner in once. A link the store does not know // (unknown, spent, expired) is the uniform 400; a store that fails is an outage, // answered as one, so the page does not tell the owner a link that still works // was used up. func TestSetupRedeem(t *testing.T) { const raw = "setup-token-raw" sum := sha256.Sum256([]byte(raw)) hash := hex.EncodeToString(sum[:]) body := `{"token":"` + raw + `"}` setup := func() (*fakeRepo, http.Handler) { repo := newFakeRepo() repo.settings[LocalAuthEnabledKey] = []byte("true") repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"} repo.setupTokens[hash] = fakeSetupToken{TokenHash: hash, UserID: "o1", ExpiresAt: time.Unix(1_700_000_000, 0).Add(10 * time.Minute)} return repo, newTestAPI(repo, newFakeCluster()).ExternalHandler() } t.Run("redeems once", func(t *testing.T) { repo, h := setup() w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String()) } cookies := w.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != sessionCookieName { t.Fatalf("cookies = %v, want one %s", cookies, sessionCookieName) } if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "o1" { t.Fatalf("session for the cookie = %+v (found %v), want one of o1", s, ok) } if w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader); w.Code != http.StatusBadRequest || errCode(w.Body.Bytes()) != "setup_token_invalid" || len(w.Result().Cookies()) != 0 { t.Fatalf("replay: code = %d err = %q cookies = %v, want 400 setup_token_invalid and none", w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) } }) t.Run("store outage", func(t *testing.T) { repo, h := setup() repo.failRedeemSetup = errors.New("connection refused") w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) if w.Code != http.StatusInternalServerError || errCode(w.Body.Bytes()) != "internal" || len(w.Result().Cookies()) != 0 { t.Fatalf("outage: code = %d err = %q cookies = %v, want 500 internal and no cookie", w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) } }) } // errCode returns the error.code of a JSON error body, or "" if body is not one (a // non-failing decodeErr for cases where the response may be a success). func errCode(body []byte) string { Loading internal/api/pgrepo.go +30 −7 Changes for internal/api/pgrepo.go: 30 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -1416,8 +1416,17 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) // CreateSession records a minted session by the sha-256 of its cookie value // (spec §B). Only the hash is stored, mirroring tokens. func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error { return insertSession(ctx, p.db, s) } // sqlExecer is the write half shared by *sql.DB and *sql.Tx. type sqlExecer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } func insertSession(ctx context.Context, db sqlExecer, s NewSession) error { reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()} _, err := p.db.ExecContext(ctx, _, err := db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at) VALUES ($1, $2, $3, $4, $5, $6)`, s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth) Loading Loading @@ -2956,12 +2965,19 @@ func (p *PGRepo) ConsumeOpLoginRequest(ctx context.Context, id string, now time. // ---- setup token redemption (spec §B) ---- // ConsumeSetupToken atomically marks a one-time setup token consumed and returns // its user_id, or ErrNotFound when the token is absent, already consumed, or // expired. The /setup?token=... web flow redeems it for a lockdown session. func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) { // RedeemSetupToken spends a one-time setup token and stores s as a session of the // token's user in one transaction, so a failure leaves the token unspent. It // returns the user id, or ErrNotFound when the token is absent, already spent, or // expired. func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { tx, err := p.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() //nolint:errcheck // no-op after commit var userID string switch err := p.db.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx, `UPDATE setup_tokens SET consumed_at = $2 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2 RETURNING user_id`, Loading @@ -2971,6 +2987,13 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti case err != nil: return "", err } s.UserID = userID if err := insertSession(ctx, tx, s); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return userID, nil } Loading @@ -2978,7 +3001,7 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti // hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind // path uses CompleteOwnerSetup so identity binding, local auth, and this token // commit atomically; this lower-level helper remains for callers that already // established the user. The token is redeemed exactly once by ConsumeSetupToken. // established the user. The token is redeemed exactly once by RedeemSetupToken. func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error { _, err := p.db.ExecContext(ctx, `INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, Loading Loading
docs/openapi.yaml +5 −1 Changes for docs/openapi.yaml: 5 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -3359,7 +3359,9 @@ paths: the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is spent in the same transaction that stores the session, so a redemption that fails with 500 leaves the link working for another try. x-felis-face: [external] x-felis-tier: public security: [] Loading Loading @@ -3407,6 +3409,8 @@ paths: schema: { $ref: '#/components/schemas/Error' } '429': $ref: '#/components/responses/RateLimited' '500': $ref: '#/components/responses/InternalError' /api/v1/auth/setup/status: get: Loading
internal/api/api_test.go +12 −3 Changes for internal/api/api_test.go: 12 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -94,6 +94,7 @@ type fakeRepo struct { failRevokeOthers error failMarkReauth error failGetSetting error failRedeemSetup error // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP Loading Loading @@ -1779,15 +1780,23 @@ func (f *fakeRepo) ApproveOpLogin(_ context.Context, id, approverUserID string, return nil } // ConsumeSetupToken atomically marks a one-time setup token consumed and returns // its user_id, or ErrNotFound when absent, already consumed, or expired. func (f *fakeRepo) ConsumeSetupToken(_ context.Context, tokenHash string, now time.Time) (string, error) { // RedeemSetupToken spends a setup token and stores s for its user, or ErrNotFound // when the token is absent, already spent, or expired. failRedeemSetup fails the // whole redemption. func (f *fakeRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { if f.failRedeemSetup != nil { return "", f.failRedeemSetup } tok, ok := f.setupTokens[tokenHash] if !ok || !tok.ConsumedAt.IsZero() || !tok.ExpiresAt.After(now) { return "", ErrNotFound } tok.ConsumedAt = now f.setupTokens[tokenHash] = tok s.UserID = tok.UserID if err := f.CreateSession(ctx, s); err != nil { return "", err } return tok.UserID, nil } Loading
internal/api/handlers_setup.go +15 −9 Changes for internal/api/handlers_setup.go: 15 added lines, 9 removed lines. Original line number Diff line number Diff line Loading @@ -62,26 +62,32 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) { sum := sha256.Sum256([]byte(token)) tokenHash := hex.EncodeToString(sum[:]) now := a.now() userID, err := a.Repo.ConsumeSetupToken(r.Context(), tokenHash, now) // A regular felis_session; the lockdown is a product-level restriction the // frontend enforces until email is verified / a passkey is bound. The token and // the session are written together: a new setup link takes `felis setup` on // the node, so a failure here must leave this one working. sessionToken, session, err := a.mintSession(r, "", provenSignIn) if err != nil { writeError(w, r, err) return } userID, err := a.Repo.RedeemSetupToken(r.Context(), tokenHash, a.now(), session) switch { case errors.Is(err, ErrNotFound): // Unknown, already-consumed, or expired — uniform 400 so the token cannot // be used as an oracle. a.authFailure(r, "setup_redeem", "bad_token", nil) writeError(w, r, newError(http.StatusBadRequest, "setup_token_invalid", "this setup link is invalid or has already been used")) return } u, err := a.Repo.UserByID(r.Context(), userID) if err != nil { case err != nil: writeError(w, r, err) return } setSessionCookie(w, sessionToken, session.ExpiresAt) // Mint the session — a regular felis_session; the lockdown is a product-level // restriction the frontend enforces until email is verified / a passkey is bound. if err := a.startSession(w, r, u.ID, provenSignIn); err != nil { u, err := a.Repo.UserByID(r.Context(), userID) if err != nil { writeError(w, r, err) return } Loading
internal/api/handlers_setup_test.go +53 −0 Changes for internal/api/handlers_setup_test.go: 53 added lines, 0 removed lines. Original line number Diff line number Diff line package api import ( "crypto/sha256" "encoding/hex" "encoding/json" "errors" "net/http" "testing" "time" ) // TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email Loading Loading @@ -137,6 +141,55 @@ func TestSetupCompletesForNoEmailPlayer(t *testing.T) { } } // Redeeming a setup link signs the owner in once. A link the store does not know // (unknown, spent, expired) is the uniform 400; a store that fails is an outage, // answered as one, so the page does not tell the owner a link that still works // was used up. func TestSetupRedeem(t *testing.T) { const raw = "setup-token-raw" sum := sha256.Sum256([]byte(raw)) hash := hex.EncodeToString(sum[:]) body := `{"token":"` + raw + `"}` setup := func() (*fakeRepo, http.Handler) { repo := newFakeRepo() repo.settings[LocalAuthEnabledKey] = []byte("true") repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"} repo.setupTokens[hash] = fakeSetupToken{TokenHash: hash, UserID: "o1", ExpiresAt: time.Unix(1_700_000_000, 0).Add(10 * time.Minute)} return repo, newTestAPI(repo, newFakeCluster()).ExternalHandler() } t.Run("redeems once", func(t *testing.T) { repo, h := setup() w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("redeem: code = %d, want 200 (%s)", w.Code, w.Body.String()) } cookies := w.Result().Cookies() if len(cookies) != 1 || cookies[0].Name != sessionCookieName { t.Fatalf("cookies = %v, want one %s", cookies, sessionCookieName) } if s, ok := repo.sessions[hashCookie(cookies[0].Value)]; !ok || s.userID != "o1" { t.Fatalf("session for the cookie = %+v (found %v), want one of o1", s, ok) } if w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader); w.Code != http.StatusBadRequest || errCode(w.Body.Bytes()) != "setup_token_invalid" || len(w.Result().Cookies()) != 0 { t.Fatalf("replay: code = %d err = %q cookies = %v, want 400 setup_token_invalid and none", w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) } }) t.Run("store outage", func(t *testing.T) { repo, h := setup() repo.failRedeemSetup = errors.New("connection refused") w := do(h, "POST", "/api/v1/auth/setup/redeem", body, jsonHeader) if w.Code != http.StatusInternalServerError || errCode(w.Body.Bytes()) != "internal" || len(w.Result().Cookies()) != 0 { t.Fatalf("outage: code = %d err = %q cookies = %v, want 500 internal and no cookie", w.Code, errCode(w.Body.Bytes()), w.Result().Cookies()) } }) } // errCode returns the error.code of a JSON error body, or "" if body is not one (a // non-failing decodeErr for cases where the response may be a success). func errCode(body []byte) string { Loading
internal/api/pgrepo.go +30 −7 Changes for internal/api/pgrepo.go: 30 added lines, 7 removed lines. Original line number Diff line number Diff line Loading @@ -1416,8 +1416,17 @@ func (p *PGRepo) InsertOperator(ctx context.Context, id, username, email string) // CreateSession records a minted session by the sha-256 of its cookie value // (spec §B). Only the hash is stored, mirroring tokens. func (p *PGRepo) CreateSession(ctx context.Context, s NewSession) error { return insertSession(ctx, p.db, s) } // sqlExecer is the write half shared by *sql.DB and *sql.Tx. type sqlExecer interface { ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) } func insertSession(ctx context.Context, db sqlExecer, s NewSession) error { reauth := sql.NullTime{Time: s.ReauthAt, Valid: !s.ReauthAt.IsZero()} _, err := p.db.ExecContext(ctx, _, err := db.ExecContext(ctx, `INSERT INTO sessions (token_hash, user_id, expires_at, user_agent, client_ip, reauth_at) VALUES ($1, $2, $3, $4, $5, $6)`, s.TokenHash, s.UserID, s.ExpiresAt, s.UserAgent, s.ClientIP, reauth) Loading Loading @@ -2956,12 +2965,19 @@ func (p *PGRepo) ConsumeOpLoginRequest(ctx context.Context, id string, now time. // ---- setup token redemption (spec §B) ---- // ConsumeSetupToken atomically marks a one-time setup token consumed and returns // its user_id, or ErrNotFound when the token is absent, already consumed, or // expired. The /setup?token=... web flow redeems it for a lockdown session. func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now time.Time) (string, error) { // RedeemSetupToken spends a one-time setup token and stores s as a session of the // token's user in one transaction, so a failure leaves the token unspent. It // returns the user id, or ErrNotFound when the token is absent, already spent, or // expired. func (p *PGRepo) RedeemSetupToken(ctx context.Context, tokenHash string, now time.Time, s NewSession) (string, error) { tx, err := p.db.BeginTx(ctx, nil) if err != nil { return "", err } defer tx.Rollback() //nolint:errcheck // no-op after commit var userID string switch err := p.db.QueryRowContext(ctx, switch err := tx.QueryRowContext(ctx, `UPDATE setup_tokens SET consumed_at = $2 WHERE token_hash = $1 AND consumed_at IS NULL AND expires_at > $2 RETURNING user_id`, Loading @@ -2971,6 +2987,13 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti case err != nil: return "", err } s.UserID = userID if err := insertSession(ctx, tx, s); err != nil { return "", err } if err := tx.Commit(); err != nil { return "", err } return userID, nil } Loading @@ -2978,7 +3001,7 @@ func (p *PGRepo) ConsumeSetupToken(ctx context.Context, tokenHash string, now ti // hash (the raw value rides in the /setup?token=... URL). The setup Owner-bind // path uses CompleteOwnerSetup so identity binding, local auth, and this token // commit atomically; this lower-level helper remains for callers that already // established the user. The token is redeemed exactly once by ConsumeSetupToken. // established the user. The token is redeemed exactly once by RedeemSetupToken. func (p *PGRepo) CreateSetupToken(ctx context.Context, tokenHash, userID string, expiresAt time.Time) error { _, err := p.db.ExecContext(ctx, `INSERT INTO setup_tokens (token_hash, user_id, expires_at) VALUES ($1, $2, $3)`, Loading