package utils import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "os" "strings" "time" ) // Session tokens for the POS terminal. // // A till is not a browser. It signs in once when a shop opens and then bills // for a whole trading day — often on a connection that comes and goes — so the // thing it carries has to survive a reboot, a lost network, and an hour in a // drawer. That rules out a server-side session table (a till that cannot reach // us must still be able to prove who it is when it comes back) and it rules out // a short expiry. // // So: a signed, self-describing token. Everything needed to authorise a request // is inside it, and the signature is what makes it trustworthy. No database // round trip on the hot path, and nothing to replicate between pods. // // Deliberately not JWT. The backend has no JWT dependency today, and the format // buys nothing here — there is exactly one issuer, one audience and one // algorithm, so the header that JWT spends bytes negotiating is a constant. The // `alg` field is also the source of JWT's worst-known footgun (`alg: none`), // and a format with no algorithm field cannot have that bug. // // Wire format is `base64url(payload).base64url(hmac-sha256)`, and the MAC is // taken over the encoded payload rather than the raw JSON so that verification // never has to re-serialise anything to check it. // PosClaims is what a terminal proves about itself on every request. // // Locationid is the load-bearing field. Before this existed the till named its // own store on the wire and was believed, so changing one number in Settings // moved a terminal into another tenant's books. Now the location is decided at // sign-in, from the user's own record, and sealed under the signature. type PosClaims struct { Userid int `json:"uid"` Tenantid int `json:"tid"` Locationid int `json:"lid"` Roleid int `json:"rid"` Configid int `json:"cid"` Terminalid string `json:"trm,omitempty"` Issuedat int64 `json:"iat"` Expiresat int64 `json:"exp"` } // PosTokenTTL is how long a till stays signed in. // // Thirty days rather than hours. A shop signs the terminal in once and expects // it to keep working; forcing a re-login mid-shift would mean a queue of // customers waiting while somebody finds the manager's password. The exposure // that buys is bounded by the token naming a single location — a leaked one // bills into the shop it was already for. const PosTokenTTL = 30 * 24 * time.Hour // posTokenSecret is the signing key. // // Fails loudly rather than falling back to a baked-in default. A hardcoded // development secret has a way of reaching production, and a signing key that // everyone with the source can compute is the same as no signature at all — // anyone could mint a token for any tenant. func posTokenSecret() ([]byte, error) { secret := strings.TrimSpace(os.Getenv("POS_TOKEN_SECRET")) if secret == "" { // Falls back to the key the config file already carries, so a // deployment that set that one does not need a second variable. secret = strings.TrimSpace(os.Getenv("JWT_SECRET_KEY")) } if secret == "" { return nil, fmt.Errorf("POS_TOKEN_SECRET is not set; terminals cannot be issued sessions") } if len(secret) < 16 { return nil, fmt.Errorf("POS_TOKEN_SECRET is too short to sign with; use at least 16 characters") } return []byte(secret), nil } // MintPosToken issues a session for a signed-in terminal. func MintPosToken(claims PosClaims, now time.Time) (string, time.Time, error) { secret, err := posTokenSecret() if err != nil { return "", time.Time{}, err } expires := now.Add(PosTokenTTL) 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 encoded + "." + sign(encoded, secret), expires, nil } // ParsePosToken verifies a token and returns what it claims. // // Order matters: the signature is checked before the payload is trusted for // anything, including expiry. Reading `exp` out of an unverified payload and // acting on it would be taking the attacker's word for when their own token // runs out. func ParsePosToken(token string, now time.Time) (PosClaims, error) { secret, err := posTokenSecret() if err != nil { return PosClaims{}, err } encoded, signature, found := strings.Cut(strings.TrimSpace(token), ".") if !found || encoded == "" || signature == "" { return PosClaims{}, fmt.Errorf("malformed session token") } // Constant time, so a caller cannot learn the right signature one byte at a // time from how long the comparison took. if !hmac.Equal([]byte(signature), []byte(sign(encoded, secret))) { return PosClaims{}, fmt.Errorf("session token signature does not verify") } payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return PosClaims{}, fmt.Errorf("malformed session token") } var claims PosClaims if err := json.Unmarshal(payload, &claims); err != nil { return PosClaims{}, fmt.Errorf("malformed session token") } if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat { return PosClaims{}, fmt.Errorf("session has expired; sign in again") } // A token that verifies but names no outlet would authorise nothing and // must not be mistaken for one that authorises everything. if claims.Locationid <= 0 || claims.Tenantid <= 0 { return PosClaims{}, fmt.Errorf("session token names no outlet") } return claims, nil } func sign(encoded string, secret []byte) string { mac := hmac.New(sha256.New, secret) mac.Write([]byte(encoded)) return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // PosTokenConfigured reports whether sessions can be issued at all. // // Lets the server say "this deployment has no signing key" once at start-up // rather than answering every sign-in with a 500. func PosTokenConfigured() bool { _, err := posTokenSecret() return err == nil }