Phase 1: accounts and sessions
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s

Implements PLAN.md Phase 1. A client can now register, log in, manage its
devices and set its profile; an operator can create accounts and flip the
runtime switches. There are still no rooms and no real /sync.

userapi: accounts, argon2id passwords in PHC format, devices, access and
refresh tokens stored as SHA-256 hashes, account data and the admin flag.
Its storage interface is storage.UserStore, implemented once in sqlstore
with `?` placeholders; the postgres driver supplies a rebind function and
unique-violation recognition rather than keeping a second copy of thirty
statements. Timestamps in these tables are int64 milliseconds, the unit the
Client-Server API uses on the wire.

Client-Server API: register (user-interactive auth with m.login.dummy and
optional registration tokens), register/available, token validity, login
with m.login.password, logout, logout/all, refresh, account/whoami,
capabilities, devices/* with password-gated deletion, profile/*, global and
per-room account data.

GET /sync is a deliberate placeholder: it answers a valid token with an
empty but well-formed response and refuses a revoked one. Phase 1 has no
rooms, so that is the whole truth, and it is what makes session revocation
observable. Phase 4 replaces it with the syncapi.

Auth middleware resolves a bearer token (header or the legacy query
parameter) to a device and user, answering an expired token with the soft
logout the spec asks for. Rate limiting is a token bucket per endpoint class
keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms.

adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix
token, GET/PUT /federation and /registration driving internal/settings and
reporting a config-pinned switch as a 409, GET/POST /users, GET /stats.

warp create-account [--admin] creates the first account straight in the
database, so a new install with registration off has a way in.

Complement is wired up for the federation=off profile: an image meeting its
contract, an entrypoint that configures Warp entirely from WARP_*
variables, and a runner that builds, clones and executes the target set
(TestRegistration, TestLogin, TestDeviceManagement). It has not been run
yet -- there is no container runtime on the development machine -- so the
three tests' assertions were read from the Complement sources and pinned as
httptest tests instead, and CI gained a job that runs the real thing.

Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern,
meant for the built binary, also matched the directory server/cmd/warp and
silently excluded the entire command package from the repository. The
binary patterns are now anchored to the repository root, and cmd/warp is
committed for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 5 committed 2026-10-15 18:18:37 +02:00
1 parent b4fb60a8ab
commit 6004bb01de
68 files changed
+9729 -66

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
package clientapi
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net/http"
"slices"
"sync"
"time"
"github.com/dariuszjaniak/warp/server/internal/httputil"
)
// User-Interactive Authentication stage types. The spec's full list is long;
// these are the ones Warp implements. CAPTCHA, email and terms stages belong
// to deployments with public sign-up and arrive with the rest of that story.
const (
stageDummy = "m.login.dummy"
stagePassword = "m.login.password"
stageRegistrationToken = "m.login.registration_token"
)
// uiaFlow is one route through the stages. A client may satisfy any flow.
type uiaFlow struct {
Stages []string `json:"stages"`
}
// uiaChallenge is the 401 body of an incomplete User-Interactive
// Authentication: the flows on offer, the session to quote them against, and
// what has been done so far.
//
// When a stage has just been failed it also carries an error envelope, which
// is why errcode and error are part of this type rather than sent as a
// separate Matrix error: the spec puts both in one 401 body so the client
// can show the failure and retry the stage in one step.
type uiaChallenge struct {
Flows []uiaFlow `json:"flows"`
Params map[string]any `json:"params"`
Session string `json:"session"`
Completed []string `json:"completed,omitempty"`
ErrCode string `json:"errcode,omitempty"`
Error string `json:"error,omitempty"`
}
// uiaSessionTTL is how long an unfinished authentication may sit idle.
// Clients complete a flow in seconds; the window only has to be long enough
// for a person to read a prompt and type a password.
const uiaSessionTTL = 15 * time.Minute
// uiaSession tracks one client's progress through a flow set.
type uiaSession struct {
id string
completed []string
expiresAt time.Time
}
// uiaStore holds the in-flight authentication sessions.
//
// Sessions live in memory. They are worthless after a few minutes, they are
// only ever read by the process that wrote them (Warp is one process by
// design), and a restart costs a client exactly one extra round trip —
// whereas a table would need its own schema, its own sweeper and a write on
// every stage.
type uiaStore struct {
ttl time.Duration
now func() time.Time
mu sync.Mutex
sessions map[string]*uiaSession
sweptAt time.Time
}
func newUIAStore(ttl time.Duration, now func() time.Time) *uiaStore {
if now == nil {
now = time.Now
}
return &uiaStore{ttl: ttl, now: now, sessions: map[string]*uiaSession{}}
}
// create starts a session.
func (s *uiaStore) create() (*uiaSession, error) {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return nil, fmt.Errorf("generate auth session: %w", err)
}
session := &uiaSession{
id: base64.RawURLEncoding.EncodeToString(raw),
expiresAt: s.now().Add(s.ttl),
}
s.mu.Lock()
defer s.mu.Unlock()
s.sweep()
s.sessions[session.id] = session
return session, nil
}
// get returns a live session, or nil if it is unknown or expired.
func (s *uiaStore) get(id string) *uiaSession {
if id == "" {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if s.now().After(session.expiresAt) {
delete(s.sessions, id)
return nil
}
// Each copy handed out is independent, so a caller cannot mutate the
// stored progress by accident.
return &uiaSession{id: session.id, completed: slices.Clone(session.completed), expiresAt: session.expiresAt}
}
// complete records a finished stage and returns the session's new progress.
func (s *uiaStore) complete(id, stage string) []string {
s.mu.Lock()
defer s.mu.Unlock()
session, ok := s.sessions[id]
if !ok {
return nil
}
if !slices.Contains(session.completed, stage) {
session.completed = append(session.completed, stage)
}
// Finishing a stage is progress, so the window starts again: a flow
// with several stages must not time out halfway through.
session.expiresAt = s.now().Add(s.ttl)
return slices.Clone(session.completed)
}
// remove forgets a session, which happens once its flow has been acted on.
// Leaving it behind would let a replayed request create a second account or
// delete a second device.
func (s *uiaStore) remove(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, id)
}
// sweep drops expired sessions. It runs under the caller's lock, at most
// once per TTL.
func (s *uiaStore) sweep() {
now := s.now()
if now.Sub(s.sweptAt) < s.ttl {
return
}
s.sweptAt = now
for id, session := range s.sessions {
if now.After(session.expiresAt) {
delete(s.sessions, id)
}
}
}
// uiaRequest is the `auth` dictionary a client sends. The stage-specific
// fields are flattened into it rather than nested, which is how the spec
// defines each stage's payload.
type uiaRequest struct {
Type string `json:"type"`
Session string `json:"session"`
// Token is the m.login.registration_token payload.
Token string `json:"token"`
// Identifier and Password are the m.login.password payload.
Identifier loginIdentifier `json:"identifier"`
Password string `json:"password"`
// User is the deprecated top-level form of Identifier, still sent by
// older clients.
User string `json:"user"`
}
// flowSatisfied reports whether completed covers every stage of any flow.
func flowSatisfied(flows []uiaFlow, completed []string) bool {
for _, flow := range flows {
done := true
for _, stage := range flow.Stages {
if !slices.Contains(completed, stage) {
done = false
break
}
}
if done {
return true
}
}
return false
}
// challenge writes a 401 asking the client to complete a stage.
//
// cause is optional: when a stage was attempted and failed, the client gets
// the failure and the flows in one body, which is what lets it re-prompt
// without starting over.
func (c *ClientAPI) challenge(w http.ResponseWriter, flows []uiaFlow, session *uiaSession, cause *httputil.MatrixError) {
body := uiaChallenge{
Flows: flows,
// Always an object, never null: clients and Complement both expect
// `params` to be present even when no stage needs parameters.
Params: map[string]any{},
Session: session.id,
Completed: session.completed,
}
if cause != nil {
body.ErrCode, body.Error = cause.Code, cause.Message
}
httputil.WriteJSON(w, http.StatusUnauthorized, body)
}
// stageHandler checks one stage of a flow.
//
// The two return values are different kinds of "no": a *MatrixError is a
// stage the client may retry, and is sent back inside the 401 alongside the
// flows; a plain error ends the request. Wrong password is the first kind,
// authenticating as somebody else is the second.
type stageHandler func(stage string, auth *uiaRequest) (*httputil.MatrixError, error)
// beginOrAdvanceUIA drives one request through a flow set.
//
// It returns done=true when the client has satisfied a flow and the caller
// may go ahead; otherwise it has already written the 401 challenge. The
// returned session ID is the one to forget once the operation succeeds.
func (c *ClientAPI) beginOrAdvanceUIA(
w http.ResponseWriter,
flows []uiaFlow,
auth *uiaRequest,
handleStage stageHandler,
) (sessionID string, done bool, err error) {
// No auth at all, or an auth without the session the server handed out,
// starts a new attempt. The spec requires the session to be quoted back,
// so a request that drops it is not treated as a continuation.
if auth == nil || auth.Type == "" || auth.Session == "" {
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session, nil)
return "", false, nil
}
session := c.uia.get(auth.Session)
if session == nil {
// An unknown or expired session: start a fresh one rather than
// failing, so a client that was slow can simply carry on.
session, createErr := c.uia.create()
if createErr != nil {
return "", false, createErr
}
c.challenge(w, flows, session,
httputil.NewError(http.StatusUnauthorized, httputil.ErrCodeUnrecognized,
"The authentication session has expired; start again"))
return "", false, nil
}
if !stageOffered(flows, auth.Type) {
return "", false, httputil.NewError(http.StatusBadRequest, httputil.ErrCodeUnrecognized,
"Unknown authentication type %q", auth.Type)
}
retryable, fatal := handleStage(auth.Type, auth)
if fatal != nil {
return "", false, fatal
}
if retryable != nil {
c.challenge(w, flows, session, retryable)
return "", false, nil
}
session.completed = c.uia.complete(session.id, auth.Type)
if !flowSatisfied(flows, session.completed) {
c.challenge(w, flows, session, nil)
return "", false, nil
}
return session.id, true, nil
}
// stageOffered reports whether stage appears in any of the flows, so that a
// client cannot complete a stage this operation never asked for.
func stageOffered(flows []uiaFlow, stage string) bool {
for _, flow := range flows {
if slices.Contains(flow.Stages, stage) {
return true
}
}
return false
}