From 8e4b863a3fcc04a5541a836211a1f1f5a51dd693 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 22 Oct 2026 21:45:52 +0200 Subject: [PATCH] fix: /fetch, /refresh, /update and /change-password accept the session cookies the jwt guard reads The four routes (and the other signed-in routes through authenticate) read the bearer header, then the token and auth_token cookies, through classes.SessionToken and one cookie-aware guard. A bearer header still wins and no credentials stays 401 with the old bodies. A cookie-carried /refresh answers the rotated token in the body and writes no cookie. --- README.md | 9 +- classes/session_user.go | 19 ++- controllers/api_controller.go | 77 ++++++----- logout_test.go | 19 ++- session_cookie_test.go | 253 ++++++++++++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 47 deletions(-) create mode 100644 session_cookie_test.go diff --git a/README.md b/README.md index 9835e7a..b057e79 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap ## Features - **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore. -- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. +- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A cookie-carried `POST /refresh` answers the rotated token in the body and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so the caller that wrote the cookie stores the new token in it. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes. - **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password. - **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it. - **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. @@ -108,6 +108,8 @@ All routes are mounted under `/_user/api/v1` with the `throttle:user-api` middle | `GET /tokens` | `controllers.APITokenIndex` (`jwt.auth`) | | `DELETE /tokens/{id}` | `controllers.APITokenDestroy` (`jwt.auth`) | +`/fetch`, `/refresh`, `/update`, `/change-password`, `/activate`, `/marketing-consent`, `/avatar`, `/avatar/remove` and the `jwt.auth` routes read the session token from the bearer header, then the `token` and `auth_token` cookies. + ### Host-mounted OAuth identities The host application mounts these constructors on a path it chooses. They are not registered under `/_user/api/v1`. `OAuthIdentitiesOptions.WriteNotFound` is the host's 404 writer so missing, foreign and unknown providers share one body. `OAuthIdentitiesOptions.Providers` is the unlink allow-list; when empty it is read on every request from a published `classes.OAuthProviderSource` (its `KnownOAuthProviders`), else it is `google`, `facebook`, `github` and `microsoft`. Unlinking follows the PHP rule: it succeeds when the user has a self-set password (`has_self_set_password`) or another identity remains, and otherwise answers 409 with the phrase `golem15.user::lang.oauth.last_method_blocked` (body unchanged). The guarded delete runs in a transaction that first locks the user's `users` row, so two concurrent unlinks never remove the last sign-in method. @@ -187,6 +189,7 @@ Packages: - `TouchLastSeen(ctx, db, userID)`. - `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names. - `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error. + - `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none), and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token. - `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned. - `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128. - `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`. @@ -256,7 +259,9 @@ go test ./... # full run, starts Postgres with testcontainers (needs D `TestOffboarding` runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; `TestRefreshSubjectChecks` covers the `/refresh` subject checks; `TestRevokeUserAccess` (in `classes`) covers the revocation writes. -`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused through every carrier on a `jwt.auth` route, at `/fetch` and at `/refresh`, that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401. +`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused on a `jwt.auth` route, at `/fetch` and at `/refresh`, each through every carrier (bearer header, `token` cookie, `auth_token` cookie), that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401. + +`TestSessionRoutesAcceptCookies` checks that a session `jwt.auth` accepts through only the `token` or `auth_token` cookie is served by `GET /fetch`, `POST /refresh` (the rotated token in the body, no cookie written, the old token refused afterwards), `POST /update`, `POST /change-password` (the calling token stays valid) and `POST /marketing-consent`; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on `jwt.auth`, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie. `TestLoginRequiresActivation` pins the activation gate of `POST /login`: under the default and an explicit `require_activation: true` a not-activated account's correct password gets the byte-identical 401 (status, body, `Content-Type` and `Cache-Control`) of a wrong password, stamps no `last_seen`, adds one throttle attempt and suspends the account after `attempt_limit` refusals; `require_activation: false` and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off. diff --git a/classes/session_user.go b/classes/session_user.go index 6f9983e..dbbc728 100644 --- a/classes/session_user.go +++ b/classes/session_user.go @@ -20,6 +20,14 @@ import ( // the Authorization header, in the same order (plugin.go Boot). var sessionCookieNames = []string{"token", "auth_token"} +// SessionCookieNames returns a copy of the cookie names the plugin's "jwt" +// guard (plugin.go Boot) reads after the Authorization header, in order. +// A handler that builds its own guard passes them to bouncer.NewJWTGuard so +// it reads the same token as SessionToken and jwt.auth. +func SessionCookieNames() []string { + return slices.Clone(sessionCookieNames) +} + // RequestUser returns the signed-in user of r for handlers that run without // the jwt.auth middleware and must also serve anonymous requests (an OAuth // provider's authorize page, for example). The token is read and checked @@ -35,7 +43,7 @@ func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*mode if r == nil { return nil, nil } - raw := sessionToken(r) + raw := SessionToken(r) if raw == "" { return nil, nil } @@ -209,9 +217,12 @@ func refreshWindow(app *backpack.App) time.Duration { return time.Duration(mins) * time.Minute } -// sessionToken extracts the session JWT as the "jwt" guard does: the -// Authorization bearer header, then each session cookie in order. -func sessionToken(r *http.Request) string { +// SessionToken returns the session JWT the plugin's "jwt" guard reads from r: +// the Authorization bearer header, then each cookie of SessionCookieNames in +// order, trimmed, empty values skipped. It returns "" when r carries none. +// A controller that builds its own guard passes SessionCookieNames() to +// bouncer.NewJWTGuard, so the guard and SessionToken read the same token. +func SessionToken(r *http.Request) string { if tokens := sessionTokens(r); len(tokens) > 0 { return tokens[0] } diff --git a/controllers/api_controller.go b/controllers/api_controller.go index 2941edd..9fb5d23 100644 --- a/controllers/api_controller.go +++ b/controllers/api_controller.go @@ -186,16 +186,21 @@ func Fetch(app *backpack.App) http.HandlerFunc { } } -// Refresh slides a still-refreshable bearer token of a user who may still -// sign in: deactivated and banned users and tokens issued before the user's -// tokens_valid_after are refused with the token-failure 401 body. +// Refresh slides a still-refreshable session token of a user who may still +// sign in. The token is read as the plugin's jwt guard reads it: the bearer +// header, then the token and auth_token cookies. The rotated token is +// answered in the body only; no cookie is written. The old token is +// blacklisted after golem15.user.jwt.blacklist_grace, so a caller that keeps +// the session in a cookie stores the new token there. Deactivated and banned +// users and tokens issued before the user's tokens_valid_after are refused +// with the token-failure 401 body. func Refresh(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } - raw := bearerToken(r) + raw := classes.SessionToken(r) if raw == "" { writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"}) return @@ -583,24 +588,29 @@ func Update(app *backpack.App) http.HandlerFunc { } // ChangePassword replaces a self-set password and invalidates older tokens. -// The token that made this call stays valid. +// The token is read as the plugin's jwt guard reads it (the bearer header, +// then the token and auth_token cookies), and the token that made this call, +// header or cookie, stays valid. func ChangePassword(app *backpack.App) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { gdb, secret, ok := sessionDeps(w, app) if !ok { return } - raw := bearerToken(r) + raw := classes.SessionToken(r) sub, iat, _, _, err := bouncer.VerifyClaims(raw, secret) if err != nil { writeUnauthorized(w) return } - var bl bouncer.BlacklistStore - if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { - bl = store + principal, err := sessionGuard(app, secret).Authenticate(r) + if err != nil || principal == nil { + writeUnauthorized(w) + return } - if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil { + // The guard and SessionToken read the same token; a differing + // subject fails closed. + if strconv.FormatUint(uint64(principal.ID), 10) != sub { writeUnauthorized(w) return } @@ -877,38 +887,41 @@ func appConfig(app *backpack.App) *compass.Config { return app.Config } +// authenticate resolves the signed-in user of r for the session routes that +// run without jwt.auth: /fetch, /update, /activate, /marketing-consent, +// /avatar and /avatar/remove. It reads the token as the plugin's jwt guard +// does (the bearer header, then the token and auth_token cookies), applies +// the blacklist and the user's tokens_valid_after cutoff, and answers 401 +// otherwise. func authenticate(w http.ResponseWriter, r *http.Request, app *backpack.App) (*models.User, bool) { gdb, secret, ok := sessionDeps(w, app) if !ok { return nil, false } - raw := bearerToken(r) - sub, _, _, _, err := bouncer.VerifyClaims(raw, secret) - if err != nil { - writeUnauthorized(w) - return nil, false - } - id, err := strconv.ParseUint(sub, 10, 64) - if err != nil || id == 0 { - writeUnauthorized(w) - return nil, false - } - var bl bouncer.BlacklistStore - if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { - bl = store - } - if _, err := bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl).Authenticate(r); err != nil { + principal, err := sessionGuard(app, secret).Authenticate(r) + if err != nil || principal == nil || principal.ID == 0 { writeUnauthorized(w) return nil, false } var user models.User - if err := gdb.WithContext(r.Context()).Where("id = ?", id).Take(&user).Error; err != nil { + if err := gdb.WithContext(r.Context()).Where("id = ?", principal.ID).Take(&user).Error; err != nil { writeUnauthorized(w) return nil, false } return &user, true } +// sessionGuard is the plugin's jwt guard as plugin.go Boot registers it: the +// bearer header, then the cookies of classes.SessionCookieNames, checked +// against the published bouncer.BlacklistStore when there is one. +func sessionGuard(app *backpack.App, secret string) bouncer.Guard { + var bl bouncer.BlacklistStore + if store, ok := app.Lookup[bouncer.BlacklistStore](); ok { + bl = store + } + return bouncer.NewJWTGuard(secret, classes.GormUsers{App: app}, bl, classes.SessionCookieNames()...) +} + func sessionDeps(w http.ResponseWriter, app *backpack.App) (*gorm.DB, string, bool) { if app == nil { writeOpaque500(w) @@ -1135,16 +1148,6 @@ func jsonNumber(v any) string { } } -func bearerToken(r *http.Request) string { - h := strings.TrimSpace(r.Header.Get("Authorization")) - token, ok := strings.CutPrefix(h, "Bearer ") - token = strings.TrimSpace(token) - if !ok || token == "" { - return "" - } - return token -} - func requestURL(r *http.Request) string { scheme := "http" if r.TLS != nil { diff --git a/logout_test.go b/logout_test.go index d505531..3d3c517 100644 --- a/logout_test.go +++ b/logout_test.go @@ -24,8 +24,9 @@ const ( // TestLogoutEndsTheSession: POST /logout ends the session however the token // travels (the bearer header, or only the token or auth_token cookie the -// jwt guard also reads), so the next request with the same token or cookie -// is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the +// jwt guard also reads), so the next request with the same token, through +// the header or either cookie, is 401 on the jwt.auth routes, at /fetch and +// at /refresh. It clears the // session cookies with the attributes the token cookie is written with, and // the revocation lasts for the token's refresh window: a logged-out token is // not refreshable after its access lifetime, and an expired but still @@ -224,8 +225,9 @@ func (e *adminEnv) guarded(t *testing.T, token, cookie string) int { return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code } -// assertSessionEnded: token no longer authenticates, through any carrier, -// and cannot be refreshed. +// assertSessionEnded: token no longer authenticates, through any carrier +// (the bearer header, or only the token or auth_token cookie), at jwt.auth +// and /fetch, and cannot be refreshed through any of them. func (e *adminEnv) assertSessionEnded(t *testing.T, token string) { t.Helper() for _, carrier := range []string{"", "token", "auth_token"} { @@ -239,6 +241,15 @@ func (e *adminEnv) assertSessionEnded(t *testing.T, token string) { if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody { t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String()) } + for _, name := range []string{"token", "auth_token"} { + cookie := map[string]string{"Cookie": name + "=" + token} + if rec := e.send(t, http.MethodGet, "/_user/api/v1/fetch", "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized { + t.Fatalf("fetch after logout (cookie %q) = %d", name, rec.Code) + } + if rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", cookie); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody { + t.Fatalf("refresh after logout (cookie %q) = %d %s", name, rec.Code, rec.Body.String()) + } + } } func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig { diff --git a/session_cookie_test.go b/session_cookie_test.go new file mode 100644 index 0000000..7b8b9d8 --- /dev/null +++ b/session_cookie_test.go @@ -0,0 +1,253 @@ +package user + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "git.golem15.com/golem15/sm-user-plugin/models" +) + +const ( + userFetchPath = "/_user/api/v1/fetch" + userUpdatePath = "/_user/api/v1/update" + userChangePasswordPath = "/_user/api/v1/change-password" + userConsentPath = "/_user/api/v1/marketing-consent" + tokenNotFoundBody = `{"error":"Token not found"}` +) + +// sessionCookieNames are the cookies the plugin's jwt guard reads after the +// Authorization header. +var sessionCookieNames = []string{"token", "auth_token"} + +// TestSessionRoutesAcceptCookies: GET /fetch, POST /refresh, POST /update and +// POST /change-password (and the other signed-in routes behind the shared +// authenticate) read the session token as the jwt guard does: the bearer +// header first, then the token and auth_token cookies. A cookie-only session +// that jwt.auth accepts is served; no credentials stays 401 with the old +// bodies; a bearer header wins over a cookie exactly as on jwt.auth; a +// cookie-carried token is refused for the same reasons as a bearer one; and a +// cookie-carried /refresh answers the new token in the body and sets no +// cookie. +func TestSessionRoutesAcceptCookies(t *testing.T) { + e := newAdminEnv(t) + + for _, name := range sessionCookieNames { + t.Run("fetch via the "+name+" cookie", func(t *testing.T) { + token := e.sessionLogin(t, "cookie-fetch-"+name) + if code := e.guarded(t, token, name); code != http.StatusOK { + t.Fatalf("jwt.auth with the %s cookie = %d", name, code) + } + rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("fetch with the %s cookie = %d %s", name, rec.Code, rec.Body.String()) + } + if got, want := fetchedID(t, rec), parseToken(t, token)["sub"]; got != want { + t.Fatalf("fetch served user %q, token names %v", got, want) + } + }) + } + + t.Run("fetch without credentials", func(t *testing.T) { + rec := e.send(t, http.MethodGet, userFetchPath, "", nil, "application/json", nil) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw { + t.Fatalf("fetch without credentials = %d %s", rec.Code, rec.Body.String()) + } + }) + + t.Run("bearer wins", func(t *testing.T) { + a := e.sessionLogin(t, "cookie-bearer-a") + b := e.sessionLogin(t, "cookie-bearer-b") + cookie := map[string]string{"Cookie": "token=" + b} + rec := e.send(t, http.MethodGet, userFetchPath, a, nil, "application/json", cookie) + if rec.Code != http.StatusOK { + t.Fatalf("fetch with bearer A and cookie B = %d %s", rec.Code, rec.Body.String()) + } + if got, want := fetchedID(t, rec), parseToken(t, a)["sub"]; got != want { + t.Fatalf("fetch served user %q, bearer names %v", got, want) + } + rec = e.send(t, http.MethodGet, userFetchPath, "not-a-token", nil, "application/json", cookie) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("fetch with an invalid bearer and a valid cookie = %d", rec.Code) + } + rec = e.send(t, http.MethodGet, userTokensPath, "not-a-token", nil, "application/json", cookie) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("jwt.auth with an invalid bearer and a valid cookie = %d", rec.Code) + } + }) + + t.Run("fetch refuses a cut-off cookie token", func(t *testing.T) { + user := e.seedUser(t, "cookie-cutoff", nil) + e.cutOff(t, user) + token := userAudienceToken(t, userSub(user), "cookie-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour)) + if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("fetch with a cut-off auth_token cookie = %d", rec.Code) + } + }) + + for _, name := range sessionCookieNames { + t.Run("refresh via the "+name+" cookie", func(t *testing.T) { + token := e.sessionLogin(t, "cookie-refresh-"+name) + if code := e.guarded(t, token, name); code != http.StatusOK { + t.Fatalf("jwt.auth with the %s cookie = %d", name, code) + } + rec := e.cookieSend(t, http.MethodPost, userRefreshPath, name, token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("refresh with the %s cookie = %d %s", name, rec.Code, rec.Body.String()) + } + var body struct { + Token string `json:"token"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if body.Token == "" || body.Token == token { + t.Fatalf("refresh answered %s", rec.Body.String()) + } + if cookies := rec.Result().Cookies(); len(cookies) != 0 { + t.Fatalf("refresh set cookies %v", cookies) + } + if code := e.fetchCode(t, body.Token); code != http.StatusOK { + t.Fatalf("fetch with the refreshed token = %d", code) + } + if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("fetch with the refreshed-away token in the %s cookie = %d", name, rec.Code) + } + }) + } + + t.Run("refresh refuses a banned user through the cookie", func(t *testing.T) { + user := e.seedUser(t, "cookie-refresh-banned", nil) + e.seedThrottle(t, user, "", true, nil) + token := userAudienceToken(t, userSub(user), "cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)) + assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil)) + }) + + t.Run("refresh refuses a cut-off token through the cookie", func(t *testing.T) { + user := e.seedUser(t, "cookie-refresh-cutoff", nil) + e.cutOff(t, user) + token := userAudienceToken(t, userSub(user), "cookie-refresh-cutoff-"+adminStamp(), time.Now().Add(-10*time.Second), time.Now().Add(time.Hour)) + assertRefreshRefused(t, e.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil)) + }) + + t.Run("refresh without credentials", func(t *testing.T) { + rec := e.send(t, http.MethodPost, userRefreshPath, "", nil, "application/json", nil) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != tokenNotFoundBody { + t.Fatalf("refresh without credentials = %d %s", rec.Code, rec.Body.String()) + } + }) + + for _, name := range sessionCookieNames { + t.Run("update via the "+name+" cookie", func(t *testing.T) { + user := e.seedUser(t, "cookie-update-"+name, nil) + token := e.login(t, user, "correct-horse") + if code := e.guarded(t, token, name); code != http.StatusOK { + t.Fatalf("jwt.auth with the %s cookie = %d", name, code) + } + rec := e.cookieSend(t, http.MethodPost, userUpdatePath, name, token, map[string]any{ + "name": "Cookie", "surname": "Carrier", "email": user.Email, + }) + if rec.Code != http.StatusOK { + t.Fatalf("update with the %s cookie = %d %s", name, rec.Code, rec.Body.String()) + } + if got := e.reload(t, user.ID).Name; got == nil || *got != "Cookie" { + t.Fatalf("name after update = %v", got) + } + }) + } + + for _, name := range sessionCookieNames { + t.Run("change-password via the "+name+" cookie", func(t *testing.T) { + user := e.seedUser(t, "cookie-password-"+name, func(u *models.User) { u.HasSelfSetPassword = true }) + token := e.login(t, user, "correct-horse") + if code := e.guarded(t, token, name); code != http.StatusOK { + t.Fatalf("jwt.auth with the %s cookie = %d", name, code) + } + rec := e.cookieSend(t, http.MethodPost, userChangePasswordPath, name, token, map[string]any{ + "current_password": "correct-horse", + "password": "cookie-next-pass", + "password_confirmation": "cookie-next-pass", + }) + if rec.Code != http.StatusOK { + t.Fatalf("change-password with the %s cookie = %d %s", name, rec.Code, rec.Body.String()) + } + if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusOK { + t.Fatalf("fetch with the calling token after change-password = %d", rec.Code) + } + if code, _ := e.userLogin(t, user.Email, "cookie-next-pass"); code != http.StatusOK { + t.Fatalf("login with the new password = %d", code) + } + }) + } + + t.Run("marketing-consent via the token cookie", func(t *testing.T) { + token := e.sessionLogin(t, "cookie-consent") + rec := e.cookieSend(t, http.MethodPost, userConsentPath, "token", token, map[string]any{"marketing_consent": true}) + if rec.Code != http.StatusOK { + t.Fatalf("marketing-consent with the token cookie = %d %s", rec.Code, rec.Body.String()) + } + }) + + for _, path := range []string{userUpdatePath, userChangePasswordPath} { + t.Run(path+" without credentials", func(t *testing.T) { + rec := e.send(t, http.MethodPost, path, "", []byte(`{}`), "application/json", nil) + if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw { + t.Fatalf("%s without credentials = %d %s", path, rec.Code, rec.Body.String()) + } + }) + } +} + +// cookieSend sends one request with no bearer header and the session token +// only in the named cookie. body is JSON-encoded unless it is nil. +func (e *adminEnv) cookieSend(t *testing.T, method, path, cookie, token string, body any) *httptest.ResponseRecorder { + t.Helper() + var raw []byte + if body != nil { + var err error + if raw, err = json.Marshal(body); err != nil { + t.Fatal(err) + } + } + return e.send(t, method, path, "", raw, "application/json", map[string]string{"Cookie": cookie + "=" + token}) +} + +// login signs user in through POST /login and returns the session token. +func (e *adminEnv) login(t *testing.T, user *models.User, password string) string { + t.Helper() + code, token := e.userLogin(t, user.Email, password) + if code != http.StatusOK || token == "" { + t.Fatalf("login %s = %d", user.Email, code) + } + return token +} + +// cutOff sets the user's tokens_valid_after to now, truncated to the second. +func (e *adminEnv) cutOff(t *testing.T, user *models.User) { + t.Helper() + if err := e.gdb.Exec(`UPDATE users SET tokens_valid_after = ? WHERE id = ?`, time.Now().Truncate(time.Second), user.ID).Error; err != nil { + t.Fatal(err) + } +} + +func userSub(user *models.User) string { + return strconv.FormatUint(uint64(user.ID), 10) +} + +// fetchedID returns user.id of a /fetch answer as a decimal string. +func fetchedID(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + User struct { + ID json.Number `json:"id"` + } `json:"user"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("fetch body %s: %v", rec.Body.String(), err) + } + return fmt.Sprint(body.User.ID) +}