DecodeCursor verifies a cursor's HMAC and reverses it into dst. A malformed, tampered, or wrong-secret cursor yields ErrInvalidCursor rather than a generic error so callers branch on a stable sentinel. An empty cursor is treated as "start from the beginning" — dst is left untouched and nil is return
(secrets []string, cursor string, dst any)
| 92 | // with ErrInvalidCursor. Cursors are ephemeral, so a client mid-pagination |
| 93 | // simply restarts the query. |
| 94 | func DecodeCursor(secrets []string, cursor string, dst any) error { |
| 95 | if cursor == "" { |
| 96 | return nil |
| 97 | } |
| 98 | parts := strings.SplitN(cursor, ".", 2) |
| 99 | if len(parts) != 2 { |
| 100 | return ErrInvalidCursor |
| 101 | } |
| 102 | providedSig, err := base64.RawURLEncoding.DecodeString(parts[1]) |
| 103 | if err != nil { |
| 104 | return ErrInvalidCursor |
| 105 | } |
| 106 | matched := false |
| 107 | for _, secret := range secrets { |
| 108 | if hmac.Equal(providedSig, cursorMAC([]byte(secret), []byte(parts[0]))) { |
| 109 | matched = true |
| 110 | break |
| 111 | } |
| 112 | } |
| 113 | if !matched { |
| 114 | return ErrInvalidCursor |
| 115 | } |
| 116 | raw, err := base64.RawURLEncoding.DecodeString(parts[0]) |
| 117 | if err != nil { |
| 118 | return ErrInvalidCursor |
| 119 | } |
| 120 | if err := json.Unmarshal(raw, dst); err != nil { |
| 121 | return ErrInvalidCursor |
| 122 | } |
| 123 | return nil |
| 124 | } |
| 125 | |
| 126 | // cursorMAC computes HMAC-SHA256 of payload under secret. Mirrors |
| 127 | // approvaltoken.signMAC so the two signing paths stay convention-identical. |