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) } }