Skip to content
Draft
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
5 changes: 4 additions & 1 deletion functions/src/common/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
): Promise<number> {
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 {
Expand Down
86 changes: 86 additions & 0 deletions functions/src/session-login.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Response>;
let createSessionCookieSpy: jasmine.Spy;

beforeEach(() => {
req = {
headers: { authorization: 'Bearer fake-id-token' },
cookies: {},
} as unknown as Request;

res = jasmine.createSpyObj<Response>('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);
});
});
4 changes: 2 additions & 2 deletions functions/src/session-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?` +
Expand All @@ -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?` +
Expand Down
146 changes: 145 additions & 1 deletion web/src/app/services/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,60 @@

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';
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<Auth>, 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<Auth> {
return {
onIdTokenChanged,
onAuthStateChanged: jasmine
.createSpy('onAuthStateChanged')
.and.returnValue(() => {}),
};
}

describe('AuthService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
Expand All @@ -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();
});
});
23 changes: 18 additions & 5 deletions web/src/app/services/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export const ROLE_OPTIONS = [
},
];

const SESSION_COOKIE_EXPIRES_AT_KEY = 'sessionCookieExpiresAt';

@Injectable({
providedIn: 'root',
})
Expand Down Expand Up @@ -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$),
Expand All @@ -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',
Expand Down
Loading