Files
OwnCord/Server/ws/serve_auth.go
T
J3vb 9c9b8be669 feat(b2-2): protocol epoch and negotiation (slim) (#1438)
* feat(b2-2): declare protocol_epoch in the schema and generate both constants

protocol/schema.json gains protocol_epoch (1). genprotocol emits
ws.ProtocolEpoch and PROTOCOL_EPOCH from it; the contract test pins the Go
constant to the schema so a stale regeneration fails the required check.

* feat(b2-2): check the client's protocol epoch in the auth handshake

The auth payload gains epoch (absent = 0). Outside [minClientEpoch,
ProtocolEpoch] the server answers one auth_error with code
protocol_epoch_unsupported, the client/server/min epochs, and a message
naming which side to update, then closes 1008 like every other handshake
failure. minClientEpoch is 0 for epoch 1 only so alpha.4 clients keep
connecting; the epoch-1 fixtures are unchanged.

* feat(b2-2): send the protocol epoch and offer the update on a refused connect

ws.ts sends epoch: PROTOCOL_EPOCH in the auth frame (contract test extended
on purpose). On auth_error code protocol_epoch_unsupported with a newer
server the dispatcher records the host in ui.store.updateRequiredHost and
main.ts mounts the UpdateNotifier on the connect page, so a refused client
gets the same Update Now banner it would have had on the main page.

* feat(b2-2): withhold client releases newer than the server's protocol epoch

The signed server-update manifest gains protocol_epoch (release.yml reads it
from protocol/schema.json). Updater.ReleaseProtocolEpoch verifies the
manifest and reads it; the client-update endpoint answers 204 when the
release's epoch is newer than ws.ProtocolEpoch or the manifest does not
verify. Releases without a manifest are epoch 0 and advertised as before.
Docs: protocol.md Compatibility section, api.md, deployment.md, protocol
README, CHANGELOG Unreleased.

* docs(b2-2): record the slim B2-2 decision and evidence; fold B2-3/B2-4 into it

* ci: prove the protocol_epoch manifest read on every PR, not only at tag time

* fix(b2-2): offer the update on an already-mounted connect page and keep the credential on a protocol refusal

Codex P1: on a first login or startup auto-login no overlay exists before
auth_ok, so a refusal never re-rendered the connect page and the one-time
read of updateRequiredHost missed it. The connect page now subscribes to
it, and a later refusal replaces the banner.

Codex P2: a refusal on reconnect went through the generic logout and
deleted the stored credential although the token is still valid.
clearAuth gets a protocol_epoch reason; main.ts keeps the credential on it
(the skip-auto-login flag is still set and, being sessionStorage, does not
survive the relaunch the update triggers).
2026-08-29 07:23:06 +02:00

106 lines
4.2 KiB
Go

package ws
import (
"context"
"encoding/json"
"fmt"
"github.com/coder/websocket"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
)
// authenticateConn reads the first WebSocket message and validates the session
// token. Returns the authenticated user and the token hash (for later
// periodic session revalidation).
// resumeHint carries the client-supplied reconnect hints from the auth frame.
// Both fields are UNTRUSTED attacker-controlled input: LastSeq only ever
// narrows what replay will send, and ChannelID is checked against the allowed
// set before it is honoured (see handleReconnect).
type resumeHint struct {
LastSeq uint64
ChannelID int64
}
func authenticateConn(parent context.Context, conn *websocket.Conn, database *db.DB) (*db.User, string, resumeHint, error) {
ctx, cancel := context.WithTimeout(parent, authDeadline)
defer cancel()
_, raw, err := conn.Read(ctx)
if err != nil {
return nil, "", resumeHint{}, err
}
var env envelope
if err := json.Unmarshal(raw, &env); err != nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid message"))
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid JSON: %w", err)
}
if env.Type != MsgTypeAuth {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("first message must be auth"))
return nil, "", resumeHint{}, fmt.Errorf("auth: unexpected type %q", env.Type)
}
var p struct {
Token string `json:"token"`
LastSeq uint64 `json:"last_seq"`
// ActiveChannelID lets a resuming client re-declare the channel it had
// open, so the server can restore its ChannelTopic subscription during
// the handshake instead of leaving it unsubscribed until the
// post-auth_ok channel_focus round trip lands.
ActiveChannelID int64 `json:"active_channel_id"`
// Epoch is the wire epoch the client speaks (docs/protocol.md,
// Compatibility). Absent means 0: clients up to v1.2.0-alpha.4 predate
// the field.
Epoch int `json:"epoch"`
}
if err := json.Unmarshal(env.Payload, &p); err != nil || p.Token == "" {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("missing token"))
return nil, "", resumeHint{}, fmt.Errorf("auth: missing token")
}
if p.Epoch < minClientEpoch || p.Epoch > ProtocolEpoch {
_ = conn.Write(ctx, websocket.MessageText, buildProtocolEpochError(p.Epoch))
return nil, "", resumeHint{}, fmt.Errorf("auth: protocol epoch %d outside [%d, %d]", p.Epoch, minClientEpoch, ProtocolEpoch)
}
hash := auth.HashToken(p.Token)
sess, err := database.GetSessionByTokenHash(ctx, hash)
if err != nil {
// DB outage, not a bad token — send a non-terminal error frame so the
// client's normal backoff/reconnect logic retries instead of treating
// this like a genuinely invalid session (buildAuthError is defined as
// non-recoverable on the wire: the client stops reconnecting and
// clears its stored credentials on that frame).
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
return nil, "", resumeHint{}, fmt.Errorf("auth: session lookup failed: %w", err)
}
if sess == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("invalid token"))
return nil, "", resumeHint{}, fmt.Errorf("auth: invalid session")
}
if auth.IsSessionExpired(sess.ExpiresAt) {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("session expired"))
return nil, "", resumeHint{}, fmt.Errorf("auth: session expired")
}
user, err := database.GetUserByID(ctx, sess.UserID)
if err != nil {
// Same DB-outage-vs-bad-credential distinction as above.
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeInternal, "temporary failure, please retry"))
return nil, "", resumeHint{}, fmt.Errorf("auth: user lookup failed: %w", err)
}
if user == nil {
_ = conn.Write(ctx, websocket.MessageText, buildAuthError("user not found"))
return nil, "", resumeHint{}, fmt.Errorf("auth: user not found")
}
if auth.IsEffectivelyBanned(user) {
_ = conn.Write(ctx, websocket.MessageText, buildErrorMsg(ErrCodeBanned, "you are banned"))
return nil, "", resumeHint{}, fmt.Errorf("auth: banned user %d", user.ID)
}
return user, hash, resumeHint{LastSeq: p.LastSeq, ChannelID: p.ActiveChannelID}, nil
}