feat: account connect

This commit is contained in:
Krispire Admin
2026-06-02 22:54:41 +05:30
parent cf3a49458a
commit 3f6b7b6636
8 changed files with 429 additions and 22 deletions
+28 -16
View File
@@ -19,11 +19,16 @@ import (
// `Next` is the post-login redirect path on the frontend. We sign it (along
// with Nonce + IssuedAt) under HMAC so a tampered ?next= can't redirect the
// user to an arbitrary URL after sign-in.
//
// `LinkUserID` is set only by the /link/start endpoint — it pins the OAuth
// callback to the user who initiated the link. An empty value means the
// callback should run the normal sign-in / sign-up flow instead.
type statePayload struct {
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
Next string `json:"u"` // safe path, validated by handler
IssuedAt int64 `json:"t"` // unix seconds
Provider string `json:"p"` // "google" | "facebook"
Nonce string `json:"n"` // 16 random bytes, base64-urlencoded
Next string `json:"u"` // safe path, validated by handler
IssuedAt int64 `json:"t"` // unix seconds
Provider string `json:"p"` // "google" | "facebook"
LinkUserID string `json:"l,omitempty"` // user id to link the provider to, empty for sign-in flow
}
// stateMaxAge bounds how long a /start may be in flight. Ten minutes is
@@ -34,16 +39,20 @@ const stateMaxAge = 10 * time.Minute
// EncodeState signs and packs a fresh state value bound to the given provider
// and post-login redirect path. The returned string is what goes into both
// the `state` query param and the cookie.
func EncodeState(secret []byte, provider, next string) (string, error) {
//
// `linkUserID` pins the callback to a specific user (link-to-current-user
// flow). Pass "" for the normal sign-in flow.
func EncodeState(secret []byte, provider, next, linkUserID string) (string, error) {
nonceB := make([]byte, 16)
if _, err := rand.Read(nonceB); err != nil {
return "", fmt.Errorf("state nonce: %w", err)
}
p := statePayload{
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
Next: next,
IssuedAt: time.Now().Unix(),
Provider: provider,
Nonce: base64.RawURLEncoding.EncodeToString(nonceB),
Next: next,
IssuedAt: time.Now().Unix(),
Provider: provider,
LinkUserID: linkUserID,
}
body, err := json.Marshal(p)
if err != nil {
@@ -59,30 +68,33 @@ func EncodeState(secret []byte, provider, next string) (string, error) {
// DecodeState verifies the signature, checks the freshness window, and
// returns the embedded payload. Any failure surfaces as ErrInvalidState so
// the handler maps it to a single error path.
func DecodeState(secret []byte, raw string) (provider, next string, err error) {
//
// `linkUserID` is empty for sign-in flows and set for link-to-current-user
// flows — callers should branch on it.
func DecodeState(secret []byte, raw string) (provider, next, linkUserID string, err error) {
parts := strings.SplitN(raw, ".", 2)
if len(parts) != 2 {
return "", "", ErrInvalidState
return "", "", "", ErrInvalidState
}
bodyEnc, sigEnc := parts[0], parts[1]
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(bodyEnc))
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(sigEnc)) {
return "", "", ErrInvalidState
return "", "", "", ErrInvalidState
}
body, err := base64.RawURLEncoding.DecodeString(bodyEnc)
if err != nil {
return "", "", ErrInvalidState
return "", "", "", ErrInvalidState
}
var p statePayload
if err := json.Unmarshal(body, &p); err != nil {
return "", "", ErrInvalidState
return "", "", "", ErrInvalidState
}
if time.Since(time.Unix(p.IssuedAt, 0)) > stateMaxAge {
return "", "", ErrInvalidState
return "", "", "", ErrInvalidState
}
return p.Provider, p.Next, nil
return p.Provider, p.Next, p.LinkUserID, nil
}
// ErrInvalidState is returned whenever signature or freshness checks fail.