diff --git a/coderd/apikey.go b/coderd/apikey.go index b0cc6a26a4..e78ad5d373 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -582,5 +582,20 @@ func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (* Value: sessionToken, Path: "/", HttpOnly: true, + // MaxAge is set so the browser persists the cookie to disk rather + // than keeping it in memory as a session cookie. Standalone PWAs + // (display: standalone) run in their own browser process, and + // mobile OSes kill that process when the app is swiped away — + // deleting in-memory cookies and forcing an unexpected login. + // + // We use a long static value (1 year) instead of the key's + // LifetimeSeconds because the server refreshes the key's + // ExpiresAt on activity but does not re-set the cookie. Tying + // MaxAge to the key lifetime would cause the cookie to expire + // client-side even when the server-side key is still valid. + // + // Security is not affected: the server validates ExpiresAt on + // every request regardless of the cookie's MaxAge. + MaxAge: int((365 * 24 * time.Hour).Seconds()), }), &newkey, nil } diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 823e2faa6b..14e22d0221 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -394,6 +394,55 @@ func TestSessionExpiry(t *testing.T) { } } +// TestSessionCookieMaxAge verifies that the session cookie is a persistent +// cookie (has MaxAge set) rather than a session cookie. Standalone PWAs +// run in their own browser process and mobile OSes purge in-memory +// (session) cookies when that process is killed, so the cookie must be +// persisted to disk. +func TestSessionCookieMaxAge(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + client := coderdtest.New(t, nil) + + // Create the first user (password-based login). + req := codersdk.CreateFirstUserRequest{ + Email: "testuser@coder.com", + Username: "testuser", + Password: "SomeSecurePassword!", + } + _, err := client.CreateFirstUser(ctx, req) + require.NoError(t, err) + + // Login via the raw HTTP endpoint so we can inspect the Set-Cookie header. + loginURL, err := client.URL.Parse("/api/v2/users/login") + require.NoError(t, err) + + res, err := client.Request(ctx, http.MethodPost, loginURL.String(), codersdk.LoginWithPasswordRequest{ + Email: req.Email, + Password: req.Password, + }) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, http.StatusCreated, res.StatusCode) + + oneYear := int((365 * 24 * time.Hour).Seconds()) + var found bool + for _, cookie := range res.Cookies() { + if cookie.Name == codersdk.SessionTokenCookie { + // MaxAge should be set to a long value so the browser + // persists the cookie to disk. The server handles real + // expiry via the API key's ExpiresAt field. + require.Equal(t, oneYear, cookie.MaxAge, + "Session cookie MaxAge should be set to 1 year for disk persistence") + found = true + } + } + require.True(t, found, "session cookie should be present in login response") +} + func TestAPIKey_OK(t *testing.T) { t.Parallel()