Files
sm-user-plugin/backend_link_auth_time_smoke_test.go
Jakub Zych 32f92591d6 fix: account link proof by auth_time, code in the URL fragment and POST show
- BackendLinkConfirm reads the session's auth_time (bouncer.VerifyAuthTime);
  a token without it, or a refreshed old session, answers 409 stale_session
- POST /_user/api/v1/backend-link/show takes the code in the body; GET
  /backend-link/{code} stays, deprecated; both answer Cache-Control no-store
- My account answers <frontend_url><page_path>#code=<code>; new key
  golem15.user.backend_links.page_path (default /link-admin)
- TestBackendLink now crafts auth_time tokens; TestBackendLinkAuthTimeSmoke
2026-10-23 16:38:55 +02:00

104 lines
3.8 KiB
Go

package user
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"testing"
"time"
"git.golem15.com/golem15/summercms/modules/bouncer"
"github.com/golang-jwt/jwt/v5"
)
// craftSignInToken is craftToken with an auth_time claim: a session token
// issued at iat for a sign-in at authTime.
func craftSignInToken(t *testing.T, sub, jti string, iat, authTime time.Time) string {
t.Helper()
claims := jwt.MapClaims{
"sub": sub,
"iss": "http://example.com/_user/api/v1/login",
"iat": iat.Unix(),
"nbf": iat.Unix(),
"exp": time.Now().Add(time.Hour).Unix(),
"jti": jti,
"prv": prvHash,
bouncer.ClaimAuthTime: authTime.Unix(),
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
if err != nil {
t.Fatal(err)
}
return signed
}
// TestBackendLinkAuthTimeSmoke: the account link proof is the sign-in time.
// A session signed in before the request and refreshed after it is refused
// with stale_session, a sign-in after the request confirms, POST show
// answers the administrator for the code in the body, and My account's URL
// carries the code in the fragment, never in a query string. Skips under
// -short.
func TestBackendLinkAuthTimeSmoke(t *testing.T) {
env := newAdminEnv(t)
user := env.seedUser(t, "auth-time", nil)
sub := strconv.FormatUint(uint64(user.ID), 10)
admin := env.admin(t)
login := env.adminLogin(t, adminID(t, admin))
// 1. The user signed in an hour ago.
old := craftSignInToken(t, sub, "auth-time-old-"+adminStamp(), time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
// 2. The administrator starts a link; 6. its URL holds the code in the
// fragment only.
var started struct {
URL string `json:"url"`
}
adminInto(t, env.expect(t, http.StatusOK, http.MethodPost, adminAPI("/account/contributions/frontend_account/actions/link"), admin, map[string]any{}), &started)
if !strings.Contains(started.URL, "/link-admin#code=") || strings.Contains(started.URL, "?") {
t.Fatalf("link URL = %q", started.URL)
}
m := linkURL.FindStringSubmatch(started.URL)
if m == nil {
t.Fatalf("link URL = %q", started.URL)
}
code := m[1]
// 5. POST show answers the administrator for the code in the body.
rec := env.expect(t, http.StatusOK, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": code})
var shown struct {
Data struct {
Admin map[string]any `json:"admin"`
} `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &shown); err != nil || shown.Data.Admin["login"] != login {
t.Fatalf("POST show = %s (%v)", rec.Body.String(), err)
}
if got := rec.Header().Get("Cache-Control"); got != "no-store, private" {
t.Fatalf("POST show Cache-Control = %q", got)
}
env.expect(t, http.StatusNotFound, http.MethodPost, "/_user/api/v1/backend-link/show", "", map[string]string{"code": strings.Repeat("0", 64)})
// 3. Refreshing the old session after the request does not make it a
// fresh sign-in.
rec = env.refresh(t, old)
var refreshed struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &refreshed); rec.Code != http.StatusOK || err != nil || refreshed.Token == "" {
t.Fatalf("refresh = %d %s", rec.Code, rec.Body.String())
}
if status, body := env.confirmLink(t, refreshed.Token, code); status != http.StatusConflict || !strings.Contains(body, `"stale_session"`) {
t.Fatalf("refreshed confirm = %d %s", status, body)
}
// 4. A sign-in after the request confirms.
status, fresh := env.userLogin(t, user.Email, "correct-horse")
if status != http.StatusOK || fresh == "" {
t.Fatalf("login = %d", status)
}
if status, body := env.confirmLink(t, fresh, code); status != http.StatusOK || !strings.Contains(body, `"login":"`+login+`"`) {
t.Fatalf("fresh confirm = %d %s", status, body)
}
}