package utils import ( "crypto/hmac" "encoding/base64" "encoding/json" "fmt" "strings" "time" ) // The invitation a newly onboarded merchant receives by email. // // ── Why this is a token and not a userid ──────────────────────────────────── // // `setpassword` used to take a bare `userid`, which was safe only because the // caller had to reach it through a sign-in: `applogin` answers 409 with the // userid for an account that has no password, and nothing else hands one out. // // Putting that userid in a link and mailing it changes the threat entirely. // Userids are sequential, so a link is a guessable capability: walk low numbers // and claim any merchant that has been onboarded and not yet set up. The // attacker would own a real business's admin account — the empty-password check // does not help, because an un-set-up account is exactly what they are hunting. // // So the invitation carries a signature instead. The userid is read out of the // payload the server signed, never out of the request, which makes a forged or // edited link fail before anything is looked up. // // ── Why the same secret ───────────────────────────────────────────────────── // // One signing key for the deployment, one place it can be missing, one error // when it is — the same argument `MintWebToken` makes for sharing with the POS // token. The prefix is what keeps the three kinds apart, and it is checked // before the signature so an invitation can never be presented as a session. // InviteClaims is who an invitation is for. // // Deliberately thin. A session carries a role, a branch and a config because // requests are authorised against them; an invitation authorises exactly one // act — setting a first password — and the account it names already holds // everything else. Claims it does not need are claims that cannot be wrong. type InviteClaims struct { Userid int `json:"uid"` // Carried for the audit line, not for the decision. `SetInitialPassword` // re-derives everything it enforces from the account itself. Tenantid int `json:"tid,omitempty"` Issuedat int64 `json:"iat"` Expiresat int64 `json:"exp"` } // InviteTokenTTL is how long an invitation stays usable. // // Seven days: long enough to survive a weekend, a holiday and an email that // went to spam, short enough that a forwarded invitation found in a mailbox // months later is no longer a way into the account. Merchants who miss it get // a fresh one — a resend is cheap and an eternal link is not. const InviteTokenTTL = 7 * 24 * time.Hour // inviteTokenPrefix keeps an invitation from being mistaken for a session. // // Without it the two are the same shape signed with the same key, so an // invitation would verify as a console session — and it names a userid with no // role, no tenant check and a seven-day life. That is a far weaker credential // than a session, and it must not be usable as one. const inviteTokenPrefix = "i1." // MintInviteToken issues the link a new merchant is emailed. func MintInviteToken(claims InviteClaims, now time.Time) (string, time.Time, error) { secret, err := posTokenSecret() if err != nil { return "", time.Time{}, err } if claims.Userid <= 0 { return "", time.Time{}, fmt.Errorf("an invitation must name a user") } expires := now.Add(InviteTokenTTL) 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 inviteTokenPrefix + encoded + "." + sign(encoded, secret), expires, nil } // ParseInviteToken verifies an invitation and returns who it is for. // // Same order as the session parser, for the same reason: nothing in the payload // is trusted — not the expiry, not the user — until the signature has been // checked. Reading `exp` from an unverified payload is taking the caller's word // for when their own link runs out. // Every refusal below is written as a sentence, capital letter and full stop, // against Go's convention for error strings — because these are not read by a // developer. `SetPassword` puts them straight into the `message` a merchant sees // on `/set-password`, and they are the only explanation that screen has. A // lowercase fragment in a red banner reads as something that leaked out of the // machine rather than something anybody meant to say. // // They also all say what to do next, because every one of them is a dead end // otherwise: the person is holding a link that does not work and has no password // to sign in with instead. func ParseInviteToken(token string, now time.Time) (InviteClaims, error) { secret, err := posTokenSecret() if err != nil { return InviteClaims{}, err } raw := strings.TrimSpace(token) after, found := strings.CutPrefix(raw, inviteTokenPrefix) if !found { return InviteClaims{}, fmt.Errorf("This is not an invitation link.") } encoded, signature, found := strings.Cut(after, ".") if !found || encoded == "" || signature == "" { return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.") } // 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 InviteClaims{}, fmt.Errorf("This invitation link is not valid. Ask whoever set you up to send another.") } payload, err := base64.RawURLEncoding.DecodeString(encoded) if err != nil { return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.") } var claims InviteClaims if err := json.Unmarshal(payload, &claims); err != nil { return InviteClaims{}, fmt.Errorf("This invitation link is incomplete. Use the whole link from the email.") } if claims.Expiresat > 0 && now.Unix() >= claims.Expiresat { // Says what to do about it. An expired invitation is the one failure // here somebody can resolve themselves, and "invalid" would send them // to support instead of to whoever onboarded them. return InviteClaims{}, fmt.Errorf("This invitation has expired. Ask whoever set you up to send another.") } if claims.Userid <= 0 { return InviteClaims{}, fmt.Errorf("This invitation names no account. Ask whoever set you up to send another.") } return claims, nil }