package utils import ( "crypto/hmac" "encoding/base64" "encoding/json" "fmt" "strings" "time" ) // Session tokens for the console. // // The same construction as the POS token next door — `base64url(payload). // base64url(hmac-sha256)`, signed with the same key, deliberately not JWT — // and for the same reasons, which `postoken.go` sets out in full. What differs // is who is carrying it and what it is allowed to say. // // ── Why the console needs one at all ──────────────────────────────────────── // // It has never had one. The console keeps its login record in `sessionStorage` // and sends no `Authorization` header, so every `/web` endpoint has been taking // `tenantid` off the query string and believing it. That is the same hole // `middleware/posauth.go` was written to close on the POS surface — its own // header describes a till naming another shop's id in a URL and being trusted — // except that on the web surface nothing has closed it yet. // // ── A browser is not a till ───────────────────────────────────────────────── // // The POS token lasts thirty days because a shop signs a terminal in once and // expects it to keep billing through reboots and dead networks. A browser tab // is the opposite: the console already drops its session when the tab closes, // because `sessionStorage` is per-tab by design. So the expiry here is a // backstop for a tab left open, not the thing that ends the session, and a // working day is the right order of magnitude. // // ── What the claims may say ───────────────────────────────────────────────── // // `Tenantid` is the load-bearing field, as `Locationid` is for POS. It is taken // from the user's own record at sign-in and sealed under the signature, so a // request can no longer name whichever tenant it likes. // // Platform access — Nearle's own staff, who work across every tenant and // legitimately need to — rides on `Superadmin`, and NOT on the tenant being // zero, nor on any role id. // // Both of those shortcuts are wrong, and the console learned it the hard way. // `app_roles` calls roleid 1 "Super admin" and tenant onboarding wrote 1 for // every shop owner, so a role test hands platform access to every merchant on // the system. And a `Tenantid == 0` test promotes any user row whose tenant was // never filled in — a missing field becoming the one session that reads // everything. The real signal is `app_users.issuperadmin`, a column somebody // has to deliberately set. type WebClaims struct { Userid int `json:"uid"` // The tenant this session is pinned to. Every read and write stays inside // it unless Superadmin says otherwise. Tenantid int `json:"tid"` // Nearle staff, from `app_users.issuperadmin`. The only thing that lifts // the tenant pin; see above for the two tests that look equivalent and are // not. Superadmin bool `json:"sa,omitempty"` // The user's home branch, where they have one. Not a restriction on its // own: a tenant admin with six shops reads all six, and the check that // decides which is `LocationAllowed` against the tenant, not this field. Locationid int `json:"lid,omitempty"` Roleid int `json:"rid"` Configid int `json:"cid,omitempty"` Issuedat int64 `json:"iat"` Expiresat int64 `json:"exp"` } // WebTokenTTL is how long a console session stays valid. // // Twelve hours: longer than a shift, shorter than a week. The tab closing is // what normally ends the session, so this only decides how long a tab left open // overnight keeps working — and a person coming back the next morning signing // in again is a reasonable thing to ask, where the same demand of a till // mid-trade is not. const WebTokenTTL = 12 * time.Hour // IsPlatformAccount reports whether these claims may read across tenants. // // One function rather than `claims.Tenantid == 0` written out at each call // site, so the rule can be found, tested, and changed in one place. Every // cross-tenant decision in the middleware goes through it. func (c WebClaims) IsPlatformAccount() bool { return c.Superadmin } // MintWebToken issues a session for a signed-in console user. // // Shares `posTokenSecret` with the POS token: one signing key for the // deployment, one place it can be missing, one error when it is. A second // variable would be a second thing to forget. func MintWebToken(claims WebClaims, now time.Time) (string, time.Time, error) { secret, err := posTokenSecret() if err != nil { return "", time.Time{}, err } expires := now.Add(WebTokenTTL) claims.Issuedat = now.Unix() claims.Expiresat = expires.Unix() payload, err := json.Marshal(claims) if err != nil { return "", time.Time{}, err } encoded := base64.RawURLEncoding.EncodeToString(payload) return webTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil } // webTokenPrefix keeps the two token kinds apart on the wire. // // Without it a POS token and a console token are the same shape signed with the // same key, so one would verify as the other and a cashier's token would parse // into web claims with `Locationid` in the seat `Tenantid` should occupy. The // prefix is checked before the signature and is the reason `ParseWebToken` // cannot accept a till's session. const webTokenPrefix = "w1." // ParseWebToken verifies a console token and returns what it claims. // // The order is the same as the POS parser's and matters for the same reason: // nothing in the payload is trusted — not the expiry, not the tenant — until // the signature has been checked. Reading `exp` from an unverified payload is // taking the caller's word for when their own token runs out. func ParseWebToken(token string, now time.Time) (WebClaims, error) { secret, err := posTokenSecret() if err != nil { return WebClaims{}, err } raw := strings.TrimSpace(token) after, found := strings.CutPrefix(raw, webTokenPrefix) if !found { return WebClaims{}, fmt.Errorf("not a console session token") } encoded, signature, found := strings.Cut(after, ".") if !found || encoded == "" || signature == "" { return WebClaims{}, fmt.Errorf("malformed session token") } // Constant time, so the right signature cannot be learned a byte at a time // from how long the comparison took. if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) { return WebClaims{}, fmt.Errorf("session token signature does not verify") } payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return WebClaims{}, fmt.Errorf("malformed session token") } var claims WebClaims if err := json.Unmarshal(payload, &claims); err != nil { return WebClaims{}, fmt.Errorf("malformed session token") } if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat { return WebClaims{}, fmt.Errorf("session has expired; sign in again") } // A token naming nobody authorises nothing, and must not be mistaken for one // authorising everything. if claims.Userid <= 0 { return WebClaims{}, fmt.Errorf("session token names no user") } // A tenant session must name its tenant. Staff are the only accounts that // may carry none, and they have to say so explicitly. if claims.Tenantid <= 0 && !claims.Superadmin { return WebClaims{}, fmt.Errorf("session token names no tenant") } return claims, nil } // WebTokenConfigured reports whether console sessions can be issued at all. func WebTokenConfigured() bool { return PosTokenConfigured() }