diff --git a/functions/src/common/auth.ts b/functions/src/common/auth.ts index 65a7804cb..2acdc0590 100644 --- a/functions/src/common/auth.ts +++ b/functions/src/common/auth.ts @@ -65,19 +65,22 @@ export async function getDecodedIdToken( /** * Generates and sets a session cookie for the current user into the provided response. + * Returns the absolute expiry timestamp (ms since epoch) of the created cookie. */ export async function setSessionCookie( req: Request, res: Response -): Promise { +): Promise { const token = getAuthBearer(req); const expiresIn = 60 * 60 * 24 * 5 * 1000; // 5 days const cookie = await getAuth().createSessionCookie(token!, { expiresIn }); + const expiresAt = Date.now() + expiresIn; res.cookie(SESSION_COOKIE_NAME, cookie, { maxAge: expiresIn, httpOnly: true, secure: true, }); + return expiresAt; } function isEmulatorIdToken(user: DecodedIdToken): boolean { diff --git a/functions/src/session-login.spec.ts b/functions/src/session-login.spec.ts new file mode 100644 index 000000000..f56f05ea0 --- /dev/null +++ b/functions/src/session-login.spec.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2026 The Ground Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as adminAuth from 'firebase-admin/auth'; +import type { Auth } from 'firebase-admin/auth'; +import { Request } from 'firebase-functions/v2/https'; +import type { Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { sessionLoginHandler } from './session-login'; + +const FIVE_DAYS_MS = 60 * 60 * 24 * 5 * 1000; + +describe('sessionLoginHandler', () => { + let req: Request; + let res: jasmine.SpyObj; + let createSessionCookieSpy: jasmine.Spy; + + beforeEach(() => { + req = { + headers: { authorization: 'Bearer fake-id-token' }, + cookies: {}, + } as unknown as Request; + + res = jasmine.createSpyObj('response', [ + 'setHeader', + 'cookie', + 'json', + 'status', + 'send', + ]); + res.status.and.returnValue(res); + + createSessionCookieSpy = jasmine + .createSpy('createSessionCookie') + .and.resolveTo('fake-session-cookie'); + spyOn(adminAuth, 'getAuth').and.returnValue({ + createSessionCookie: createSessionCookieSpy, + } as unknown as Auth); + }); + + it('returns expiresAt approximately 5 days in the future', async () => { + const before = Date.now(); + await sessionLoginHandler(req, res); + const after = Date.now(); + + expect(res.json).toHaveBeenCalledOnceWith( + jasmine.objectContaining({ expiresAt: jasmine.any(Number) }) + ); + const { expiresAt } = (res.json as jasmine.Spy).calls.mostRecent().args[0]; + expect(expiresAt).toBeGreaterThanOrEqual(before + FIVE_DAYS_MS); + expect(expiresAt).toBeLessThanOrEqual(after + FIVE_DAYS_MS); + }); + + it('sets httpOnly secure session cookie', async () => { + await sessionLoginHandler(req, res); + + // Cast needed: Express's `cookie` overloads cause TS to resolve it as a + // 2-param function, but `setSessionCookie` calls the 3-param overload. + expect(res.cookie as jasmine.Spy).toHaveBeenCalledOnceWith( + '__session', + 'fake-session-cookie', + jasmine.objectContaining({ httpOnly: true, secure: true }) + ); + }); + + it('returns 401 on auth error', async () => { + createSessionCookieSpy.and.rejectWith(new Error('invalid token')); + + await sessionLoginHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(StatusCodes.UNAUTHORIZED); + }); +}); diff --git a/functions/src/session-login.ts b/functions/src/session-login.ts index 3d111d5e8..3f348ac6b 100644 --- a/functions/src/session-login.ts +++ b/functions/src/session-login.ts @@ -28,8 +28,8 @@ export async function sessionLoginHandler(req: Request, res: Response) { // Required for CDN: // https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session/44935288#44935288 res.setHeader('Cache-Control', 'private'); - await setSessionCookie(req, res); - res.end('OK'); + const expiresAt = await setSessionCookie(req, res); + res.json({ expiresAt }); } catch (err) { logger.error(err); res.status(StatusCodes.UNAUTHORIZED).send('Authorization error'); diff --git a/web/src/app/components/shared/job-list-item/job-list-item.component.ts b/web/src/app/components/shared/job-list-item/job-list-item.component.ts index f96435f54..57167ff91 100644 --- a/web/src/app/components/shared/job-list-item/job-list-item.component.ts +++ b/web/src/app/components/shared/job-list-item/job-list-item.component.ts @@ -125,7 +125,6 @@ export class JobListItemComponent implements OnInit { } async onDownloadCsvClick() { - // TODO(#1160): This can be optimized to only create a cookie when missing or expired. await this.authService.createSessionCookie(); window.open( `${environment.cloudFunctionsUrl}/exportCsv?` + @@ -135,7 +134,6 @@ export class JobListItemComponent implements OnInit { } async onDownloadGeoJsonClick() { - // TODO(#1160): This can be optimized to only create a cookie when missing or expired. await this.authService.createSessionCookie(); window.open( `${environment.cloudFunctionsUrl}/exportGeojson?` + diff --git a/web/src/app/services/auth/auth.service.spec.ts b/web/src/app/services/auth/auth.service.spec.ts index d0da3144b..6fb304538 100644 --- a/web/src/app/services/auth/auth.service.spec.ts +++ b/web/src/app/services/auth/auth.service.spec.ts @@ -16,7 +16,12 @@ import { TestBed } from '@angular/core/testing'; import { initializeApp, provideFirebaseApp } from '@angular/fire/app'; -import { getAuth, provideAuth } from '@angular/fire/auth'; +import { + Auth, + User as FirebaseUser, + getAuth, + provideAuth, +} from '@angular/fire/auth'; import { Firestore } from '@angular/fire/firestore'; import { Functions } from '@angular/fire/functions'; import { Router } from '@angular/router'; @@ -24,9 +29,47 @@ import { of } from 'rxjs'; import { AuthService } from 'app/services/auth/auth.service'; import { DataStoreService } from 'app/services/data-store/data-store.service'; +import { User } from 'app/models/user.model'; +import { environment } from 'environments/environment'; import { HttpClientService } from '../http-client/http-client.service'; +const SESSION_COOKIE_EXPIRES_AT_KEY = 'sessionCookieExpiresAt'; +const FIVE_DAYS_MS = 60 * 60 * 24 * 5 * 1000; + +/** Providers shared by all describe blocks that use a mock Auth. */ +function mockAuthProviders(mockAuth: Partial, postWithAuth: jasmine.Spy) { + return [ + { provide: Auth, useValue: mockAuth }, + { provide: Firestore, useValue: {} }, + { provide: Functions, useValue: {} }, + { + provide: DataStoreService, + useValue: { + user$: () => + of({ + id: 'user-1', + email: 'test@test.com', + displayName: 'Test User', + isAuthenticated: true, + } as User), + }, + }, + { provide: Router, useValue: { events: of() } }, + { provide: HttpClientService, useValue: { postWithAuth } }, + ]; +} + +/** Returns a minimal Auth mock where onAuthStateChanged is a no-op. */ +function createMockAuth(onIdTokenChanged: jasmine.Spy): Partial { + return { + onIdTokenChanged, + onAuthStateChanged: jasmine + .createSpy('onAuthStateChanged') + .and.returnValue(() => {}), + }; +} + describe('AuthService', () => { beforeEach(() => { TestBed.configureTestingModule({ @@ -50,3 +93,104 @@ describe('AuthService', () => { expect(service).toBeTruthy(); }); }); + +describe('AuthService createSessionCookie()', () => { + let service: AuthService; + let postWithAuthSpy: jasmine.Spy; + const futureExpiry = Date.now() + FIVE_DAYS_MS; + + beforeEach(() => { + localStorage.clear(); + + postWithAuthSpy = jasmine + .createSpy('postWithAuth') + .and.resolveTo({ expiresAt: futureExpiry }); + + const mockAuth = createMockAuth( + jasmine.createSpy('onIdTokenChanged').and.returnValue(() => {}) + ); + + TestBed.configureTestingModule({ + providers: mockAuthProviders(mockAuth, postWithAuthSpy), + }); + + service = TestBed.inject(AuthService); + }); + + afterEach(() => localStorage.clear()); + + it('calls sessionLogin and persists expiresAt in localStorage on first call', async () => { + await service.createSessionCookie(); + + expect(postWithAuthSpy).toHaveBeenCalledOnceWith( + `${environment.cloudFunctionsUrl}/sessionLogin`, + {} + ); + expect(localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY)).toBe( + String(futureExpiry) + ); + }); + + it('skips sessionLogin when a valid cookie already exists in localStorage', async () => { + localStorage.setItem(SESSION_COOKIE_EXPIRES_AT_KEY, String(futureExpiry)); + + await service.createSessionCookie(); + + expect(postWithAuthSpy).not.toHaveBeenCalled(); + }); + + it('calls sessionLogin again when the stored cookie has expired', async () => { + localStorage.setItem(SESSION_COOKIE_EXPIRES_AT_KEY, String(Date.now() - 1)); + + await service.createSessionCookie(); + + expect(postWithAuthSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('AuthService session cookie invalidation', () => { + let service: AuthService; + let capturedIdTokenCallback: (user: FirebaseUser | null) => void; + + beforeEach(() => { + localStorage.setItem( + SESSION_COOKIE_EXPIRES_AT_KEY, + String(Date.now() + FIVE_DAYS_MS) + ); + + const onIdTokenChangedSpy = jasmine + .createSpy('onIdTokenChanged') + .and.callFake((cb: (user: FirebaseUser | null) => void) => { + capturedIdTokenCallback = cb; + return () => {}; + }); + + const mockAuth = createMockAuth(onIdTokenChangedSpy); + + TestBed.configureTestingModule({ + providers: mockAuthProviders(mockAuth, jasmine.createSpy('postWithAuth')), + }); + + service = TestBed.inject(AuthService); + }); + + afterEach(() => localStorage.clear()); + + it('clears localStorage entry when user signs out', () => { + expect(localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY)).not.toBeNull(); + + capturedIdTokenCallback!(null); + + expect(localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY)).toBeNull(); + }); + + it('preserves localStorage entry on token refresh for the same user', () => { + // Spy on callProfileRefresh to prevent it from calling httpsCallable with + // the mock Functions instance, which would fail with a missing _url error. + spyOn(service, 'callProfileRefresh').and.resolveTo(); + + capturedIdTokenCallback!({ uid: 'user-1' } as FirebaseUser); + + expect(localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY)).not.toBeNull(); + }); +}); diff --git a/web/src/app/services/auth/auth.service.ts b/web/src/app/services/auth/auth.service.ts index 8d296937a..da2a96ad6 100644 --- a/web/src/app/services/auth/auth.service.ts +++ b/web/src/app/services/auth/auth.service.ts @@ -63,6 +63,8 @@ export const ROLE_OPTIONS = [ }, ]; +const SESSION_COOKIE_EXPIRES_AT_KEY = 'sessionCookieExpiresAt'; + @Injectable({ providedIn: 'root', }) @@ -90,7 +92,10 @@ export class AuthService { // import { user } from '@angular/fire/auth'; --> corresponds to onIdTokenChanged. // But I didn't import 'user' in the top block, I imported 'User as FirebaseUser'. // Let's just use the strict SDK method in constructor. - this.auth.onIdTokenChanged(user => this.tokenChanged$.next(user)); + this.auth.onIdTokenChanged(user => { + if (!user) localStorage.removeItem(SESSION_COOKIE_EXPIRES_AT_KEY); + this.tokenChanged$.next(user); + }); this.user$ = authState(this.auth).pipe( mergeWith(this.tokenChanged$), @@ -107,14 +112,22 @@ export class AuthService { * backend functions which require session auth. Namely, this is necessary for functions which * download arbitrarily large files (namely, "/exportCsv"). These functions can only be invoked via * HTTP GET, since browser do not allow streaming directly to disk via POST or other methods. + * + * Skips the server call if a valid session cookie was already created in this browser. The expiry + * timestamp is persisted in localStorage (sourced from the server response) so the check survives + * page refreshes. The entry is cleared when the user signs out (tracked via onIdTokenChanged). */ async createSessionCookie() { + const stored = localStorage.getItem(SESSION_COOKIE_EXPIRES_AT_KEY); + if (stored !== null && Date.now() < parseInt(stored, 10)) { + return; + } try { // TODO(#1159): Refactor access to Cloud Functions into new service. - await this.httpClientService.postWithAuth( - `${environment.cloudFunctionsUrl}/sessionLogin`, - {} - ); + const { expiresAt } = await this.httpClientService.postWithAuth<{ + expiresAt: number; + }>(`${environment.cloudFunctionsUrl}/sessionLogin`, {}); + localStorage.setItem(SESSION_COOKIE_EXPIRES_AT_KEY, String(expiresAt)); } catch (err) { console.error( 'Session login failed. Some features may be unavailable',