Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions backend/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,7 @@ func main() {
store := sessions.NewCookieStore(sessionKey)

// Configure secure cookie options
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true, // Prevent JavaScript access
Secure: os.Getenv("ENV") == "production", // HTTPS only in production
SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects)
}
store.Options = sessionCookieOptions()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call-site only. Options values are unchanged. Auth middleware / session save path left as-is on purpose — no third copy of the #462 rewrite.


gob.Register(map[string]interface{}{})

Expand Down
18 changes: 18 additions & 0 deletions backend/session_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package main

import (
"net/http"
"os"

"github.com/gorilla/sessions"
)

func sessionCookieOptions() *sessions.Options {
return &sessions.Options{
Path: "/",
MaxAge: 86400 * 7, // 7 days
HttpOnly: true, // Prevent JavaScript access
Secure: os.Getenv("ENV") == "production", // HTTPS only in production

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No behavior change vs what is already on main: 7-day MaxAge, Secure only when ENV=production, SameSite=Lax.

Pulled into a helper only so we can unit-test those flags (review on #462 asked for session tests). I did not take #462's 30-day lifetime or the X-Forwarded-Proto Secure rewrite — that was the part that got questioned, and main already has working session + AuthMiddleware.

SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects)
}
}
39 changes: 39 additions & 0 deletions backend/session_options_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package main

import (
"net/http"
"testing"

"github.com/stretchr/testify/assert"
)

func TestSessionCookieOptions_Production(t *testing.T) {
t.Setenv("ENV", "production")

opts := sessionCookieOptions()

assert.Equal(t, "/", opts.Path)
assert.Equal(t, 86400*7, opts.MaxAge)
assert.True(t, opts.HttpOnly)
assert.True(t, opts.Secure)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covers the two cookie properties that matter for #417 on HTTPS: Secure=true in production (browsers drop non-Secure cookies on https://taskwarrior-server.ccextractor.org), and MaxAge=7d so a refresh the next day should still be logged in.

The other two tests lock Secure=false when ENV is development or unset, so local HTTP Docker still works.

assert.Equal(t, http.SameSiteLaxMode, opts.SameSite)
}

func TestSessionCookieOptions_NonProduction(t *testing.T) {
t.Setenv("ENV", "development")

opts := sessionCookieOptions()

assert.Equal(t, 86400*7, opts.MaxAge)
assert.True(t, opts.HttpOnly)
assert.False(t, opts.Secure)
}

func TestSessionCookieOptions_UnsetENV(t *testing.T) {
t.Setenv("ENV", "")

opts := sessionCookieOptions()

assert.Equal(t, 86400*7, opts.MaxAge)
assert.False(t, opts.Secure)
}
23 changes: 23 additions & 0 deletions frontend/src/components/LandingPage.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router';
import { url } from '@/components/utils/URLs';
import { About } from './LandingComponents/About/About';
import { FAQ } from './LandingComponents/FAQ/FAQ';
import { Footer } from './LandingComponents/Footer/Footer';
Expand All @@ -9,6 +12,26 @@ import { Contact } from './LandingComponents/Contact/Contact';
import '../App.css';

export const LandingPage = () => {
const navigate = useNavigate();

useEffect(() => {
const redirectIfLoggedIn = async () => {
try {
const response = await fetch(url.backendURL + 'api/user', {
method: 'GET',
credentials: 'include',
});
if (response.ok) {
navigate('/home');

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the user-visible fix for bookmarking / while already logged in.

Same check HomePage already uses (GET api/user + credentials: 'include' so the session cookie is sent). If the session is valid we navigate('/home'). If it is 401/network error we stay on the landing page so first-time / logged-out visitors can still sign in.

I did not add a loading gate — a brief flash of the landing page is possible before redirect. Happy to add a spinner if maintainers want that.

}
} catch (error) {
console.error('Error checking login status:', error);
}
};

redirectIfLoggedIn();
}, [navigate]);

return (
<div className="overflow-x-hidden">
<Navbar />
Expand Down
67 changes: 66 additions & 1 deletion frontend/src/components/__tests__/LandingPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { LandingPage } from '../LandingPage';

const mockedNavigate = jest.fn();
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});

// Mock dependencies
jest.mock('../LandingComponents/Navbar/Navbar', () => ({
Navbar: () => <div>Mocked Navbar</div>,
Expand All @@ -27,7 +32,28 @@ jest.mock('../../components/utils/ScrollToTop', () => ({
ScrollToTop: () => <div>Mocked ScrollToTop</div>,
}));

jest.mock('react-router', () => ({
useNavigate: () => mockedNavigate,
}));

jest.mock('@/components/utils/URLs', () => ({
url: {
backendURL: 'http://mocked-backend-url/',
},
}));

global.fetch = jest.fn(() =>
Promise.resolve({
ok: false,
})
) as jest.Mock;

describe('LandingPage', () => {
afterEach(() => {
jest.clearAllMocks();
consoleErrorSpy.mockClear();
});

it('renders all components correctly', () => {
render(<LandingPage />);

Expand All @@ -40,6 +66,45 @@ describe('LandingPage', () => {
expect(screen.getByText('Mocked Footer')).toBeInTheDocument();
expect(screen.getByText('Mocked ScrollToTop')).toBeInTheDocument();
});

it('redirects to /home when the user is already logged in', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
});

render(<LandingPage />);

await waitFor(() => {
expect(mockedNavigate).toHaveBeenCalledWith('/home');
});
expect(fetch).toHaveBeenCalledWith('http://mocked-backend-url/api/user', {
method: 'GET',
credentials: 'include',
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default fetch mock is ok: false so existing render/snapshot tests still show the landing page.

This case asserts both the redirect and that the request includes credentials (the cookie is what makes production / work after login). The next two tests cover logged-out (ok: false → no navigate) and a thrown fetch (stay on landing, console.error).

});

it('stays on the landing page when the user is not logged in', async () => {
render(<LandingPage />);

await waitFor(() => {
expect(fetch).toHaveBeenCalledWith('http://mocked-backend-url/api/user', {
method: 'GET',
credentials: 'include',
});
});
expect(mockedNavigate).not.toHaveBeenCalled();
});

it('stays on the landing page when the session check fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('network error'));

render(<LandingPage />);

await waitFor(() => {
expect(consoleErrorSpy).toHaveBeenCalled();
});
expect(mockedNavigate).not.toHaveBeenCalled();
});
});

describe('LandingPage Component using Snapshot', () => {
Expand Down
5 changes: 5 additions & 0 deletions production/backend-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ spec:
configMapKeyRef:
key: CONTAINER_ORIGIN
name: backend-env
- name: ENV
valueFrom:
configMapKeyRef:
key: ENV
name: backend-env

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wires the configmap ENV into the backend container the same way the other secrets/config keys are wired. This is the half of #420 that is easy to miss: adding the key to the configmap alone does not set os.Getenv("ENV") in the process.

- name: FRONTEND_ORIGIN_DEV
valueFrom:
configMapKeyRef:
Expand Down
1 change: 1 addition & 0 deletions production/backend-env-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ data:
CLIENT_ID: "YOUR_GOOGLE_CLOUD_AUTH_CLIENT_ID" # Replace this in order to access the frontend
CLIENT_SEC: "YOUR_GOOGLE_CLOUD_AUTH_CLIENT_SECRET" # Replace this in order to access the frontend
CONTAINER_ORIGIN: http://syncserver:8080/
ENV: "production" # Required for secure HTTPS-only cookies

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same leftover as #420. Docker already had ENV=production (production/example.backend.env). Without this key, K8s cookies stay Secure=false and browsers reject them on HTTPS — users then look "logged out" every visit.

Must stay paired with the ENV envFrom wiring in backend-deployment.yaml; a configmap value that is never mounted is a no-op.

FRONTEND_ORIGIN_DEV: http://localhost
PORT: "8000"
REDIRECT_URL_DEV: http://localhost:8000/auth/callback
Expand Down
Loading