diff --git a/.gitignore b/.gitignore index a9487213..9d2dfa1c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies -/node_modules +node_modules /.pnp .pnp.js @@ -35,4 +35,4 @@ cypress/videos next-env.d.ts .vercel -.vscode/settings.json +.vscode/ diff --git a/cypress/fixtures/feed_datasets_mdb-2947.json b/cypress/fixtures/feed_datasets_mdb-2947.json new file mode 100644 index 00000000..12684689 --- /dev/null +++ b/cypress/fixtures/feed_datasets_mdb-2947.json @@ -0,0 +1,29 @@ +[ + { + "id": "mdb-2947-202410010000", + "feed_id": "mdb-2947", + "hosted_url": "https://files.example.com/mdb-2947/mdb-2947-202410010000.zip", + "note": null, + "downloaded_at": "2024-10-01T00:00:15Z", + "hash": "mockhash2947", + "bounding_box": { + "minimum_latitude": 40.0, + "maximum_latitude": 41.0, + "minimum_longitude": -75.0, + "maximum_longitude": -74.0 + }, + "validation_report": { + "validated_at": "2024-10-01T00:10:00Z", + "features": ["Transfers", "Shapes"], + "validator_version": "5.0.2-SNAPSHOT", + "total_error": 0, + "total_warning": 10, + "total_info": 0, + "unique_error_count": 0, + "unique_warning_count": 2, + "unique_info_count": 0, + "url_json": "https://files.example.com/mdb-2947/report.json", + "url_html": "https://files.example.com/mdb-2947/report.html" + } + } +] \ No newline at end of file diff --git a/cypress/fixtures/feed_mdb-2947.json b/cypress/fixtures/feed_mdb-2947.json new file mode 100644 index 00000000..2236a916 --- /dev/null +++ b/cypress/fixtures/feed_mdb-2947.json @@ -0,0 +1,21 @@ +{ + "id": "mdb-2947", + "data_type": "gtfs", + "status": "active", + "created_at": "2024-10-01T00:00:00Z", + "external_ids": [ + { "external_id": "2947", "source": "mdb" } + ], + "provider": "Example Transit Agency", + "feed_name": "Example City Transit", + "note": "Mock fixture for local dev", + "feed_contact_email": "", + "source_info": { + "producer_url": "https://example.com/gtfs.zip", + "authentication_type": 0, + "authentication_info_url": "", + "api_key_parameter_name": "", + "license_url": "" + }, + "redirects": [] +} \ No newline at end of file diff --git a/cypress/fixtures/gtfs_feed_mdb-2947.json b/cypress/fixtures/gtfs_feed_mdb-2947.json new file mode 100644 index 00000000..ecbdd30e --- /dev/null +++ b/cypress/fixtures/gtfs_feed_mdb-2947.json @@ -0,0 +1,53 @@ +{ + "id": "mdb-2947", + "data_type": "gtfs", + "status": "active", + "created_at": "2024-10-01T00:00:00Z", + "external_ids": [ + { "external_id": "2947", "source": "mdb" } + ], + "provider": "Example Transit Agency", + "feed_name": "Example City Transit", + "note": "Mock fixture for local dev", + "feed_contact_email": "", + "source_info": { + "producer_url": "https://example.com/gtfs.zip", + "authentication_type": 0, + "authentication_info_url": "", + "api_key_parameter_name": "", + "license_url": "" + }, + "redirects": [], + "locations": [ + { + "country_code": "US", + "subdivision_name": "Example State", + "municipality": "Example City" + } + ], + "latest_dataset": { + "id": "mdb-2947-202410010000", + "hosted_url": "https://files.example.com/mdb-2947/mdb-2947-202410010000.zip", + "bounding_box": { + "minimum_latitude": 40.0, + "maximum_latitude": 41.0, + "minimum_longitude": -75.0, + "maximum_longitude": -74.0 + }, + "downloaded_at": "2024-10-01T00:00:15Z", + "hash": "mockhash2947", + "validation_report": { + "validated_at": "2024-10-01T00:10:00Z", + "features": ["Transfers", "Shapes"], + "validator_version": "5.0.2-SNAPSHOT", + "total_error": 0, + "total_warning": 10, + "total_info": 0, + "unique_error_count": 0, + "unique_warning_count": 2, + "unique_info_count": 0, + "url_json": "https://files.example.com/mdb-2947/report.json", + "url_html": "https://files.example.com/mdb-2947/report.html" + } + } +} \ No newline at end of file diff --git a/docs/Authentication.md b/docs/Authentication.md new file mode 100644 index 00000000..1df94572 --- /dev/null +++ b/docs/Authentication.md @@ -0,0 +1,151 @@ +# Authentication Architecture (SSR + IAP via GCIP) + +This document explains how server-side authentication works in the Mobility Database Web app when calling the Mobility Feed API behind Google Cloud IAP with Identity Platform (GCIP). It also covers local development without Firebase access. + +## Overview +- Server components/actions call the Mobility Feed API using a server‑minted GCIP ID token. +- The client never forwards its Firebase ID token to the server; all API calls use server credentials only. +- A short‑lived HTTP‑only session cookie (`md_session`) stores a server‑signed JWT with the current user's identity (including guest/anonymous flag). +- Firebase Admin is initialized once via a centralized helper and used for both Remote Config and token minting. +- Mock mode (MSW) enables local development without hitting the real API or Firebase. + +## Server‑Side Token Flow (IAP + Identity Platform) +1. Firebase Admin creates a **custom token** for a service UID (synthetic user identity). +2. The custom token is exchanged with **Identity Toolkit** (`accounts:signInWithCustomToken`) to obtain a **GCIP ID token**. +3. The GCIP ID token is added as `Authorization: Bearer ` for calls to the IAP‑protected Mobility Feed API. +4. Tokens are cached server‑side and refreshed a few minutes before expiry. + +Key code paths: +- `src/lib/firebase-admin.ts`: centralized Admin initialization (`getFirebaseAdminApp()`), backed by `ensureAdminInitialized()`. +- `src/app/utils/auth-server.ts`: token functions `getGcipIdToken()` and `getSSRAccessToken()` (the canonical token provider for SSR calls) and helpers for reading the session cookie. +- `src/app/services/feeds/index.ts`: OpenAPI client; injects `Authorization` and user‑context headers using the token returned by `getSSRAccessToken()`. + +## Session Cookie & SSR User Identity + +To let server components know "who" the current user is (including guests) without ever trusting client tokens directly, the web app uses a short‑lived, server‑signed session JWT stored in the `md_session` HTTP‑only cookie. + +Flow: +1. The user signs in on the client with Firebase Auth (email/password, provider, or anonymous). +2. After any successful login, a Redux saga calls `setUserCookieSession()` from [src/app/services/session-service.ts](src/app/services/session-service.ts). +3. `setUserCookieSession()` reads the current Firebase ID token from the client SDK and POSTs it to `/api/session`. +4. The `/api/session` `POST` handler in [src/app/api/session/route.ts](src/app/api/session/route.ts): + - Verifies the ID token with Firebase Admin. + - Derives an `isGuest` flag from the sign‑in provider (`anonymous` → guest). + - Issues a short‑lived session JWT (1 hour) signed with `NEXT_SESSION_JWT_SECRET` containing: + - `uid`, optional `email`, `isGuest`, `iat`, and `exp`. + - Sets the `md_session` cookie (HTTP‑only, `sameSite=lax`, `secure` in production). + +On the server side: +- [src/app/utils/session-jwt.ts](src/app/utils/session-jwt.ts) defines the `SessionPayload` type and helpers to sign/verify the JWT used in `md_session`. +- [src/app/utils/auth-server.ts](src/app/utils/auth-server.ts) exposes: + - `getCurrentUserFromCookie()` to decode the session cookie into `SessionPayload` for SSR. + - `getUserContextJwtFromCookie()` to obtain the raw, verified session JWT for forwarding to the backend. + +The GCIP ID token used for IAP remains a server‑minted token that does not depend on the client token; the session cookie is only used for identifying the current end‑user (including guests) and for per‑user attribution. + +## Firebase Admin Initialization +Centralized in `getFirebaseAdminApp()`: +- Reuse existing Admin app if already initialized (matching `NEXT_PUBLIC_FIREBASE_PROJECT_ID`). +- Prefer **inline service account JSON**: `GOOGLE_SA_JSON` (or `FIREBASE_SERVICE_ACCOUNT_JSON` if enabled in your environment). +- Or load **from file path**: `GOOGLE_SA_JSON_PATH` (or `GOOGLE_APPLICATION_CREDENTIALS`). +- Fail fast if no credentials are provided to avoid unpredictable ADC/metadata lookups in serverless/dev environments. + +Required service account fields: `project_id`, `client_email`, `private_key`. + +## Environment Variables +Server‑side credentials and config (server‑only): +- `GOOGLE_SA_JSON`: Inline service account JSON string. +- `GOOGLE_SA_JSON_PATH`: Absolute/relative path to service account JSON file. +- `NEXT_PUBLIC_FIREBASE_PROJECT_ID`: Project ID; used to match Admin apps and as fallback when JSON lacks `project_id`. +- `NEXT_SESSION_JWT_SECRET`: Secret used to sign and verify the `md_session` session JWT on the web side. + +GCIP / Identity Toolkit: +- `GCIP_API_KEY` (or `FIREBASE_API_KEY` or `NEXT_PUBLIC_FIREBASE_API_KEY`): API key for `accounts:signInWithCustomToken`. +- `GCIP_TENANT_ID` (optional): Tenant ID when using multi‑tenant Identity Platform. +- `GCIP_SERVICE_UID` (optional): Synthetic UID for the server caller (default: `iap-service-caller`). + +Mock/dev: +- `NEXT_PUBLIC_API_MOCKING=enabled`: Enables MSW mock service worker in the browser. +- `LOCAL_DEV_NO_ADMIN=1` (optional): Bypass Admin initialization for Remote Config/token code paths during local experimentation. + +> Note: On the Mobility Feed API (Python) side, a matching secret (e.g. `S2S_JWT_SECRET`) is used to validate the user‑context JWT forwarded from the web app. + +## Remote Config +- Server‑side code fetches Firebase Remote Config via Admin SDK. +- In mock mode, Remote Config returns defaults to avoid Admin calls. +- Entry points: `src/lib/remote-config.server.ts` and the `Providers` wrapper in `src/app/providers.tsx`. + +## Mock Mode (No Real API or Firebase) +Use **MSW** (Mock Service Worker) so mocks behave like a real API without changing app logic. + +Setup: +1. Initialize the worker (one‑time): + ```bash + npx msw init public/ + ``` +2. Start dev in mock mode: + ```bash + NEXT_PUBLIC_API_MOCKING=enabled yarn start:dev:mock + ``` +3. Mock handlers live in `src/mocks/handlers.ts`. + - The browser worker starts from `src/mocks/browser.ts` via `src/app/providers.tsx` when mock mode is enabled. + +## Usage in Code +- SSR/API calls: Always obtain `accessToken = await getSSRAccessToken()` in server components/actions, then pass to `openapi-fetch` client. +- Do **not** forward client tokens to the server. +- Keep all credentials server‑only and never expose service account JSON to client code. + +### End‑User Context Propagation to the Mobility Feed API + +In addition to the GCIP ID token for IAP, SSR API calls also propagate a compact, server‑signed user‑context JWT so the backend can attribute requests to an end‑user without trusting any client tokens: + +- The `md_session` cookie's JWT is reused as this user‑context token. +- On the server, [src/app/utils/auth-server.ts](src/app/utils/auth-server.ts) reads the cookie via `getUserContextJwtFromCookie()`. +- [src/app/services/api-auth-middleware.ts](src/app/services/api-auth-middleware.ts) provides `generateAuthMiddlewareWithToken(accessToken, userContextJwt?)`, which: + - Sets `Authorization: Bearer ` for IAP. + - When `userContextJwt` is present, also sets `x-mdb-user-context: `. +- All server‑side feeds service functions in [src/app/services/feeds/index.ts](src/app/services/feeds/index.ts) accept an optional `userContextJwt` and pass it into this middleware. + +On the Mobility Feed API side (see [mobility-feed-api/api/src/middleware/request_context.py](mobility-feed-api/api/src/middleware/request_context.py)): +- The `RequestContext` middleware reads `x-mdb-user-context`. +- It verifies the HS256 signature using a shared secret and decodes the payload. +- It populates `user_id`, `user_email`, and an `is_guest` flag for auditing and per‑user behavior. + +## Security Considerations +- **No client token pass‑through**: Prevents elevation of privilege and token replay. +- **Server‑only credentials**: Service account material must never be sent to the client. +- **End‑user attribution without client tokens**: The backend receives only a server‑signed, minimal user‑context JWT via `x-mdb-user-context`, never the raw client Firebase ID token. +- **Guest users**: Anonymous sign‑ins are explicitly flagged via `isGuest` in the session JWT so both the web app and backend can distinguish guest from authenticated accounts. + +## Troubleshooting +Common issues and fixes: +- "Firebase app already exists": Multiple Admin initializations. Use centralized `getFirebaseAdminApp()` and reuse existing apps. +- "Invalid GCIP ID token": IAP configured with Identity Platform requires **GCIP tokens**, not Google OIDC tokens. +- Metadata errors (ENOTFOUND): ADC fallback on local/dev without credentials. Provide explicit service account JSON/path and avoid metadata server. +- Missing fields: Ensure service account JSON contains `project_id`, `client_email`, `private_key`. +- MSW not intercepting: Confirm `NEXT_PUBLIC_API_MOCKING=enabled`, `public/mockServiceWorker.js` exists, and the worker starts in `providers.tsx`. + +## Quick Start (Local Dev) +1. Provide service account (either inline or file path): + - Inline (recommended for Vercel server env): + - Set `GOOGLE_SA_JSON` to a single‑line JSON string. + - File path: + - Set `GOOGLE_SA_JSON_PATH` to the JSON file path. +2. Start dev: + ```bash + yarn start:dev + ``` +3. Optional mock mode: + ```bash + npx msw init public/ + NEXT_PUBLIC_API_MOCKING=enabled yarn start:dev:mock + ``` + +## Deployment (Vercel) +- Set server‑only env vars in Vercel: + - `GOOGLE_SA_JSON`, `GCIP_API_KEY`, and optionally `GCIP_TENANT_ID`, `GCIP_SERVICE_UID`. +- Ensure `NEXT_PUBLIC_FIREBASE_PROJECT_ID` matches your Firebase project. +- Use Node runtime for server components/actions (default in Next.js 16 App Router). + +--- +For questions or improvements, see `src/lib/firebase-admin.ts`, `src/app/utils/auth-server.ts`, and `src/mocks/handlers.ts` for practical references. diff --git a/src/app/actions/auth-actions.ts b/src/app/actions/auth-actions.ts deleted file mode 100644 index 7b569801..00000000 --- a/src/app/actions/auth-actions.ts +++ /dev/null @@ -1,27 +0,0 @@ -'use server'; - -import { cookies } from 'next/headers'; - -export async function setAuthTokenAction(token: string | null): Promise { - const cookieStore = await cookies(); - - if (token != null && token.length > 0) { - // We assume the toke is valid -> could add extra layer of security but will make an extra call to Firebase - // If bad token is provided, it will be rejected by external API - cookieStore.set('firebase_token', token, { - path: '/', - maxAge: 3600, - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - }); - } else { - cookieStore.set('firebase_token', '', { - path: '/', - maxAge: 0, - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - }); - } -} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts new file mode 100644 index 00000000..c7cc109b --- /dev/null +++ b/src/app/api/session/route.ts @@ -0,0 +1,97 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { getAuth } from 'firebase-admin/auth'; +import { getFirebaseAdminApp } from '../../../lib/firebase-admin'; +import { signSessionToken, verifySessionToken } from '../../utils/session-jwt'; + +const COOKIE_NAME = 'md_session'; + +function isProduction(): boolean { + return process.env.NODE_ENV === 'production'; +} + +export async function POST(req: NextRequest): Promise { + try { + const body = (await req.json()) as { idToken?: string }; + const idToken = body?.idToken; + + if (idToken == null || typeof idToken !== 'string') { + return NextResponse.json({ error: 'Missing idToken' }, { status: 400 }); + } + + const app = getFirebaseAdminApp(); + const decoded = await getAuth(app).verifyIdToken(idToken); + + // Log the user id server-side for auditing/debugging + console.info('Session established for user', { + uid: decoded.uid, + provider: decoded.firebase?.sign_in_provider, + }); + + const response = NextResponse.json({ status: 'ok' }); + + // Create a short-lived server-signed session token that only contains + // the user id and expiry, not the Firebase ID token itself. + const sessionMaxAgeSec = 60 * 60; // 1 hour + const isGuest = decoded.firebase?.sign_in_provider === 'anonymous'; + const sessionToken = signSessionToken( + decoded.uid, + decoded.email ?? undefined, + { + ttlSeconds: sessionMaxAgeSec, + isGuest, + }, + ); + console.info(decoded); + response.cookies.set({ + name: COOKIE_NAME, + value: sessionToken, + httpOnly: true, + secure: isProduction(), + sameSite: 'lax', + path: '/', + maxAge: sessionMaxAgeSec, + }); + + return response; + } catch (error) { + console.error('Error establishing session', error); + return NextResponse.json( + { error: 'Invalid or expired token' }, + { status: 401 }, + ); + } +} + +export async function GET(req: NextRequest): Promise { + try { + const cookie = req.cookies.get(COOKIE_NAME)?.value; + if (cookie == null) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + + const session = verifySessionToken(cookie); + if (session == null) { + return NextResponse.json({ authenticated: false }, { status: 200 }); + } + + return NextResponse.json( + { + authenticated: true, + uid: session.uid, + isGuest: session.isGuest ?? false, + }, + { status: 200 }, + ); + } catch (error) { + console.error('Error validating session', error); + return NextResponse.json({ authenticated: false }, { status: 200 }); + } +} + +export async function DELETE(req: NextRequest): Promise { + // Clear the session cookie so that subsequent requests have no session. + const response = NextResponse.json({ status: 'logged_out' }); + // Use the built-in delete helper to ensure the cookie is removed. + response.cookies.delete(COOKIE_NAME); + return response; +} diff --git a/src/app/components/AuthTokenSync.tsx b/src/app/components/AuthTokenSync.tsx deleted file mode 100644 index 1489a167..00000000 --- a/src/app/components/AuthTokenSync.tsx +++ /dev/null @@ -1,26 +0,0 @@ -'use client'; - -import { useEffect, type ReactElement } from 'react'; -import { app } from '../../firebase'; -import { setAuthTokenAction } from '../actions/auth-actions'; - -export default function AuthTokenSync(): ReactElement | null { - useEffect(() => { - // Sets the auth token in a httpOnly cookie for server side requests - // Uses Next.js const cookieStore = await cookies(); for cookie management - const unsubscribe = app.auth().onIdTokenChanged(async (user) => { - if (user != null) { - const token = await user.getIdToken(); - await setAuthTokenAction(token); - } else { - await setAuthTokenAction(null); - } - }); - - return () => { - unsubscribe(); - }; - }, []); - - return null; -} diff --git a/src/app/feeds/[feedDataType]/[feedId]/page.tsx b/src/app/feeds/[feedDataType]/[feedId]/page.tsx index d7774e13..146e37c8 100644 --- a/src/app/feeds/[feedDataType]/[feedId]/page.tsx +++ b/src/app/feeds/[feedDataType]/[feedId]/page.tsx @@ -12,7 +12,10 @@ import { } from '../../../services/feeds'; import { notFound } from 'next/navigation'; import type { Metadata, ResolvingMetadata } from 'next'; -import { getSSRAccessToken } from '../../../utils/auth-server'; +import { + getSSRAccessToken, + getUserContextJwtFromCookie, +} from '../../../utils/auth-server'; import { type GTFSFeedType, type GTFSRTFeedType, @@ -32,15 +35,16 @@ interface Props { const fetchFeedData = cache( async (feedDataType: string, feedId: string, accessToken: string) => { try { + const userContextJwt = await getUserContextJwtFromCookie(); let feed; if (feedDataType === 'gtfs') { - feed = await getGtfsFeed(feedId, accessToken); + feed = await getGtfsFeed(feedId, accessToken, userContextJwt); } else if (feedDataType === 'gtfs_rt') { - feed = await getGtfsRtFeed(feedId, accessToken); + feed = await getGtfsRtFeed(feedId, accessToken, userContextJwt); } else if (feedDataType === 'gbfs') { - feed = await getGbfsFeed(feedId, accessToken); + feed = await getGbfsFeed(feedId, accessToken, userContextJwt); } else { - feed = await getFeed(feedId, accessToken); + feed = await getFeed(feedId, accessToken, userContextJwt); } return feed; } catch (e) { @@ -52,9 +56,15 @@ const fetchFeedData = cache( const fetchInitialDatasets = cache( async (feedId: string, accessToken: string) => { try { - const datasets = await getGtfsFeedDatasets(feedId, accessToken, { - limit: 10, - }); + const userContextJwt = await getUserContextJwtFromCookie(); + const datasets = await getGtfsFeedDatasets( + feedId, + accessToken, + { + limit: 10, + }, + userContextJwt, + ); return datasets; } catch (e) { return []; @@ -65,9 +75,10 @@ const fetchInitialDatasets = cache( const fetchRelatedFeeds = cache( async (feedReferences: string[], accessToken: string) => { try { + const userContextJwt = await getUserContextJwtFromCookie(); const feedPromises = feedReferences.map( async (feedId) => - await getFeed(feedId, accessToken).catch((e) => { + await getFeed(feedId, accessToken, userContextJwt).catch((e) => { return undefined; }), ); @@ -218,12 +229,14 @@ export default async function FeedPage({ accessToken, ); + const userContextJwt = await getUserContextJwtFromCookie(); const associatedGtfsRtFeedsArrays = await Promise.all( gtfsFeeds.map( async (gtfsFeed) => await getGtfsFeedAssociatedGtfsRtFeeds( gtfsFeed?.id ?? '', accessToken, + userContextJwt, ), ), ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f780ffad..b3cf8808 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -9,8 +9,6 @@ import { type RemoteConfigValues } from './interface/RemoteConfig'; import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; -import AuthTokenSync from './components/AuthTokenSync'; - interface ProvidersProps { children: React.ReactNode; remoteConfig: RemoteConfigValues; @@ -21,9 +19,20 @@ export function Providers({ children, remoteConfig, }: ProvidersProps): React.ReactElement { + // Start MSW in mock mode to intercept API calls client-side + React.useEffect(() => { + if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') { + // Lazy-load the worker to avoid bundling in prod + import('../mocks/browser') + .then(async ({ worker }) => await worker.start()) + .catch((err) => { + console.warn('MSW mock worker failed to start:', err); + }); + } + }, []); + return ( - {children} diff --git a/src/app/services/api-auth-middleware.ts b/src/app/services/api-auth-middleware.ts new file mode 100644 index 00000000..d83ba77b --- /dev/null +++ b/src/app/services/api-auth-middleware.ts @@ -0,0 +1,42 @@ +import type { Middleware } from 'openapi-fetch'; + +/** + * Internal header used to propagate end-user context from the web app + * to backend services. The value is expected to be a compact JWT that + * the backend (Python) can verify and decode. + */ +export const USER_CONTEXT_HEADER = 'x-mdb-user-context'; + +/** + * Builds an OpenAPI client middleware that attaches both the GCIP/IAP + * access token and, optionally, a user-context JWT header to outgoing + * requests. + * + * This helper lives outside the generated API client so that future + * OpenAPI-generated services can all share the same auth wiring. + * + * - `accessToken` is the IAP/GCIP token used for Authorization. + * - `userContextJwt`, when provided, is a server-signed JWT carrying + * minimal user identity (uid/email/isGuest, etc.) that the Python + * service can decode. + */ +export const generateAuthMiddlewareWithToken = ( + accessToken: string, + userContextJwt?: string, +): Middleware => { + return { + async onRequest(req) { + // Always attach the bearer token for IAP/GCIP. + req.headers.set('Authorization', `Bearer ${accessToken}`); + + // When available (typically in server-side code), also attach a + // compact user-context JWT so the backend can attribute calls to + // an end-user without relying on IAP to forward custom claims. + if (userContextJwt != null && userContextJwt !== '') { + req.headers.set(USER_CONTEXT_HEADER, userContextJwt); + } + + return req; + }, + }; +}; diff --git a/src/app/services/feeds/index.ts b/src/app/services/feeds/index.ts index 7e4eb384..acfb115c 100644 --- a/src/app/services/feeds/index.ts +++ b/src/app/services/feeds/index.ts @@ -3,6 +3,7 @@ import type { paths } from './types'; import { type AllFeedsParams, type AllFeedType } from './utils'; import { type GtfsRoute } from '../../types'; import { getFeedFilesBaseUrl } from '../../utils/config'; +import { generateAuthMiddlewareWithToken } from '../api-auth-middleware'; const client = createClient({ baseUrl: String(process.env.NEXT_PUBLIC_FEED_API_BASE_URL), @@ -33,15 +34,17 @@ const throwOnError: Middleware = { client.use(throwOnError); -const generateAuthMiddlewareWithToken = (accessToken: string): Middleware => { - return { - async onRequest(req) { - // add Authorization header to every request - req.headers.set('Authorization', `Bearer ${accessToken}`); - return req; - }, - }; -}; +async function withAuthMiddleware( + authMiddleware: Middleware, + fn: () => Promise, +): Promise { + client.use(authMiddleware); + try { + return await fn(); + } finally { + client.eject(authMiddleware); + } +} export const getFeeds = async (): Promise< | paths['/v1/feeds']['get']['responses'][200]['content']['application/json'] @@ -61,24 +64,21 @@ export const getFeeds = async (): Promise< export const getFeed = async ( feedId: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/feeds/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/feeds/{id}', { params: { path: { id: feedId } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/feeds/{id}', { + params: { path: { id: feedId } }, }); + return response.data; + }); }; export const getGtfsFeeds = async (): Promise< @@ -114,141 +114,119 @@ export const getGtfsRtFeeds = async (): Promise< export const getGtfsFeed = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/gtfs_feeds/{id}', { params: { path: { id } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gtfs_feeds/{id}', { + params: { path: { id } }, }); + return response.data as AllFeedType; + }); }; export const getGtfsRtFeed = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/gtfs_rt_feeds/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/gtfs_rt_feeds/{id}', { params: { path: { id } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gtfs_rt_feeds/{id}', { + params: { path: { id } }, }); + return response.data; + }); }; export const getGbfsFeed = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/gbfs_feeds/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/gbfs_feeds/{id}', { params: { path: { id } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gbfs_feeds/{id}', { + params: { path: { id } }, }); + return response.data; + }); }; export const getGtfsFeedAssociatedGtfsRtFeeds = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/gtfs_feeds/{id}/gtfs_rt_feeds']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/gtfs_feeds/{id}/gtfs_rt_feeds', { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gtfs_feeds/{id}/gtfs_rt_feeds', { params: { path: { id } }, - }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); }); + return response.data; + }); }; export const getGtfsFeedDatasets = async ( id: string, accessToken: string, queryParams?: paths['/v1/gtfs_feeds/{id}/datasets']['get']['parameters']['query'], + userContextJwt?: string, ): Promise< | paths['/v1/gtfs_feeds/{id}/datasets']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/gtfs_feeds/{id}/datasets', { + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/gtfs_feeds/{id}/datasets', { params: { query: queryParams, path: { id } }, - }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); }); + return response.data; + }); }; export const getDatasetGtfs = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/datasets/gtfs/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/datasets/gtfs/{id}', { params: { path: { id } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/datasets/gtfs/{id}', { + params: { path: { id } }, }); + return response.data; + }); }; export const getMetadata = async (): Promise< @@ -269,47 +247,39 @@ export const getMetadata = async (): Promise< export const searchFeeds = async ( params: AllFeedsParams, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/search']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/search', { params }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); - }); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/search', { params }); + return response.data; + }); }; export const getLicense = async ( id: string, accessToken: string, + userContextJwt?: string, ): Promise< | paths['/v1/licenses/{id}']['get']['responses'][200]['content']['application/json'] | undefined > => { - const authMiddleware = generateAuthMiddlewareWithToken(accessToken); - client.use(authMiddleware); - return await client - .GET('/v1/licenses/{id}', { params: { path: { id } } }) - .then((response) => { - const data = response.data; - return data; - }) - .catch(function (error) { - throw error; - }) - .finally(() => { - client.eject(authMiddleware); + const authMiddleware = generateAuthMiddlewareWithToken( + accessToken, + userContextJwt, + ); + return await withAuthMiddleware(authMiddleware, async () => { + const response = await client.GET('/v1/licenses/{id}', { + params: { path: { id } }, }); + return response.data; + }); }; /** diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts new file mode 100644 index 00000000..5f8298a9 --- /dev/null +++ b/src/app/services/session-service.ts @@ -0,0 +1,37 @@ +import { app } from '../../firebase'; + +/** + * After Firebase login on the client, call this to establish + * a server-side session via the /api/session endpoint. + */ +export const setUserCookieSession = async (): Promise => { + // Ensure this only runs in the browser + if (typeof window === 'undefined') { + return; + } + + const user = app.auth().currentUser; + if (user == null) { + return; + } + + const idToken = await user.getIdToken(); + await fetch('/api/session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ idToken }), + }); +}; + +/** + * Clear the server-side session cookie on logout. + */ +export const clearUserCookieSession = async (): Promise => { + if (typeof window === 'undefined') { + return; + } + + await fetch('/api/session', { + method: 'DELETE', + }); +}; diff --git a/src/app/store/saga/auth-saga.ts b/src/app/store/saga/auth-saga.ts index 8859ce76..deb9ba13 100644 --- a/src/app/store/saga/auth-saga.ts +++ b/src/app/store/saga/auth-saga.ts @@ -39,6 +39,10 @@ import { retrieveUserInformation, sendEmailVerification, } from '../../services'; +import { + setUserCookieSession, + clearUserCookieSession, +} from '../../services/session-service'; import { type AdditionalUserInfo, type UserCredential, @@ -90,6 +94,9 @@ function* logoutSaga({ try { navigateTo(redirectScreen); yield app.auth().signOut(); + // Clear the HTTP-only md_session cookie on logout so that + // server-side requests immediately see the user as logged out. + yield call(clearUserCookieSession); yield put(logoutSuccess()); if (propagate) { broadcastMessage(LOGOUT_CHANNEL); @@ -262,6 +269,11 @@ function* anonymousLoginSaga(): Generator { } } +function* sessionCookieAfterLoginSaga(): Generator { + // Establish server-side HTTP-only session cookie after any loginSuccess. + yield call(setUserCookieSession); +} + export function* watchAuth(): Generator { yield takeLatest(USER_PROFILE_LOGIN, emailLoginSaga); yield takeLatest(USER_PROFILE_LOGOUT, logoutSaga); @@ -274,4 +286,6 @@ export function* watchAuth(): Generator { yield takeLatest(USER_PROFILE_CHANGE_PASSWORD, changePasswordSaga); yield takeLatest(USER_PROFILE_RESET_PASSWORD, resetPasswordSaga); yield takeLatest(USER_PROFILE_ANONYMOUS_LOGIN, anonymousLoginSaga); + // When loginSuccess is dispatched (any login flow), set the session cookie. + yield takeLatest(loginSuccess.type, sessionCookieAfterLoginSaga); } diff --git a/src/app/utils/auth-server.ts b/src/app/utils/auth-server.ts index c29225ce..1ec4b051 100644 --- a/src/app/utils/auth-server.ts +++ b/src/app/utils/auth-server.ts @@ -1,45 +1,220 @@ +import 'server-only'; import { cookies } from 'next/headers'; -import { app } from '../../firebase'; -import firebase from 'firebase/compat/app'; -import 'firebase/compat/auth'; -// TODO: for anonymous auth use fireabse admin +import { getAuth } from 'firebase-admin/auth'; +import { getEnvConfig, nonEmpty } from './config'; +import { getFirebaseAdminApp } from '../../lib/firebase-admin'; +import { verifySessionToken, type SessionPayload } from './session-jwt'; + +interface CachedToken { + token: string; + expiresAt: number; // epoch ms +} + +// Per-cache-key token cache. The cache key is typically the user uid, +// or a fallback key like 'service' when no user is associated. +const cachedByKey = new Map(); +function now(): number { + return Date.now(); +} + +async function exchangeCustomTokenForIdToken( + customToken: string, +): Promise<{ idToken: string; expiresInSec?: number }> { + // Accept multiple env var names for dev/prod convenience + const apiKey = + nonEmpty(getEnvConfig('GCIP_API_KEY')) ?? + nonEmpty(getEnvConfig('FIREBASE_API_KEY')) ?? + nonEmpty(getEnvConfig('NEXT_PUBLIC_FIREBASE_API_KEY')); + if (apiKey == undefined) { + throw new Error('GCIP/Firebase API key is not set'); + } + + const url = `https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${apiKey}`; + const body: Record = { + token: customToken, + returnSecureToken: true, + }; + + const tenantId = + nonEmpty(getEnvConfig('GCIP_TENANT_ID')) ?? + nonEmpty(getEnvConfig('NEXT_PUBLIC_GCIP_TENANT_ID')); + if (tenantId != undefined) { + body.tenantId = tenantId; + } + + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + cache: 'no-store', + }); + if (!resp.ok) { + const text = await resp.text(); + throw new Error( + `GCIP signInWithCustomToken failed: ${resp.status} ${text}`, + ); + } + const data = (await resp.json()) as { + idToken: string; + expiresIn?: string; // seconds as string + }; + const expiresInSec = + data.expiresIn != null ? Number(data.expiresIn) : undefined; + return { idToken: data.idToken, expiresInSec }; +} /** - * Retrieves the Firebase access token from the 'firebase_token' cookie. - * If the cookie is missing, performs a server-side anonymous login to generate a token. - * This ensures SSR pages can access the API even for direct, unauthenticated visits. + * Returns a GCIP ID token suitable for calling an IAP-protected API configured with Identity Platform. + * + * If a user uid is provided, the uid is embedded as a custom claim in the + * underlying Firebase custom token, and the resulting GCIP ID token is cached + * per-user. This ensures that user-specific tokens are not shared across + * different users. + * + * When no uid is provided, a shared "service" token is used and cached + * under a common key. + */ +export async function getGcipIdToken( + userInfo: SessionPayload | undefined, +): Promise { + // Dev/mock bypass: allow local runs without Firebase Admin/service accounts + const isMock = + getEnvConfig('NEXT_PUBLIC_API_MOCKING') === 'enabled' || + getEnvConfig('LOCAL_DEV_NO_ADMIN') === '1'; + if (isMock) { + return 'dev-mock-token'; + } + const cacheKey = userInfo?.uid ?? 'service'; + + // Use cached token if still valid for at least 60 seconds + const cached = cachedByKey.get(cacheKey); + if (cached != undefined && cached.expiresAt - now() > 60_000) { + return cached.token; + } + + // Ensure Admin app is initialized centrally + const adminApp = getFirebaseAdminApp(); + const serviceUid = + nonEmpty(getEnvConfig('GCIP_SERVICE_UID')) ?? + nonEmpty(getEnvConfig('NEXT_GCIP_SERVICE_UID')) ?? + 'iap-service-caller'; + const customClaims: Record = { service: true }; + if (userInfo?.uid != undefined) { + // Attach the end-user session information as metadata so downstream + // services can attribute calls without changing API signatures. + customClaims.userUid = userInfo.uid; + customClaims.email = userInfo.email; + customClaims.sessionIat = userInfo.iat; + customClaims.sessionExp = userInfo.exp; + customClaims.isGuest = userInfo.isGuest === true; + } + const customToken = await getAuth(adminApp).createCustomToken( + serviceUid, + customClaims, + ); + const { idToken, expiresInSec } = + await exchangeCustomTokenForIdToken(customToken); + // Default TTL ~ 55 minutes if expiresIn not present + const ttlMs = (expiresInSec ?? 3600) * 1000; + const safetyMs = 300_000; // refresh 5 minutes early + const entry: CachedToken = { + token: idToken, + expiresAt: now() + ttlMs - safetyMs, + }; + cachedByKey.set(cacheKey, entry); + return idToken; +} + +/** + * Returns a GCIP ID token suitable for IAP-protected API calls. + * This avoids trusting client tokens and keeps credentials server-side only. */ export async function getSSRAccessToken(): Promise { - const cookieStore = await cookies(); - const tokenCookie = cookieStore.get('firebase_token'); + // If a user session exists, embed the user uid as a custom claim so + // downstream services can attribute the call. Otherwise, fall back to a + // shared service token. + let userInfo: SessionPayload | undefined; + try { + userInfo = await getCurrentUserFromCookie(); + } catch { + console.warn('No cookie found'); + } + return await getGcipIdToken(userInfo); +} - if (tokenCookie?.value != null && tokenCookie.value.length > 0) { +/** + * Reads the HTTP-only session cookie set by /api/session, verifies the + * server-signed session JWT, and returns basic user info. + * + * Server-only: do not import this helper from client components. + */ +export async function getCurrentUserFromCookie(): Promise< + SessionPayload | undefined +> { + try { + // In newer Next.js versions, cookies() can be async and must be awaited. + let cookieStore; try { - // Basic JWT decoding to check expiry - const token = tokenCookie.value; - const payloadBase64 = token.split('.')[1]; - const payload = JSON.parse( - Buffer.from(payloadBase64, 'base64').toString(), - ); - const now = Math.floor(Date.now() / 1000); - - if (payload.exp != null && payload.exp > now) { - return token; - } - } catch (error) {} - } - - // Fallback: Server-side Anonymous Login - // We use NONE persistence to verify we don't store this session in any shared environment storage + cookieStore = await cookies(); + } catch { + cookieStore = undefined; + } + + if (cookieStore == undefined || typeof cookieStore.get !== 'function') { + return undefined; + } + + const token = cookieStore.get('md_session')?.value; + if (token == null) { + return undefined; + } + + const session = verifySessionToken(token); + if (session == null) { + return undefined; + } + + return session; + } catch (error) { + // Swallow errors and treat as unauthenticated; callers already handle + // the undefined case and attach a service-level token instead. + return undefined; + } +} + +/** + * Returns the raw session JWT from the md_session cookie, but only if it + * verifies successfully. This is intended for forwarding to backend services + * via a header (e.g. x-mdb-user-context) so they can decode user identity + * without directly accessing browser cookies. + */ +export async function getUserContextJwtFromCookie(): Promise< + string | undefined +> { try { - const auth = app.auth(); - await auth.setPersistence(firebase.auth.Auth.Persistence.NONE); - const userCredential = await auth.signInAnonymously(); - if (userCredential.user != null) { - const token = await userCredential.user.getIdToken(); - return token; + let cookieStore; + try { + cookieStore = await cookies(); + } catch { + cookieStore = undefined; + } + + if (cookieStore == undefined || typeof cookieStore.get !== 'function') { + return undefined; + } + + const token = cookieStore.get('md_session')?.value; + if (token == null) { + return undefined; } - } catch (error) {} - return ''; + const verified = verifySessionToken(token); + if (verified == null) { + return undefined; + } + + return token; + } catch { + return undefined; + } } diff --git a/src/app/utils/config.ts b/src/app/utils/config.ts index d419a3d2..6e33c526 100644 --- a/src/app/utils/config.ts +++ b/src/app/utils/config.ts @@ -38,3 +38,19 @@ export const getFeedFilesBaseUrl = (): string => { } return `https://${prefix}files.mobilitydatabase.org`; }; + +/** + * Treat empty or whitespace-only strings as "not set". + * + * @param value Raw string. + * @returns The trimmed string when non-empty; otherwise undefined. + */ +export const nonEmpty = ( + value: string | undefined | null, +): string | undefined => { + if (value == undefined) { + return undefined; + } + const trimmed = value.trim(); + return trimmed === '' ? undefined : trimmed; +}; diff --git a/src/app/utils/session-jwt.ts b/src/app/utils/session-jwt.ts new file mode 100644 index 00000000..5904984e --- /dev/null +++ b/src/app/utils/session-jwt.ts @@ -0,0 +1,150 @@ +import crypto from 'node:crypto'; +import { getEnvConfig, nonEmpty } from './config'; + +/** + * Session JWT payload shape used internally when signing and verifying tokens. + * + * - uid: stable Firebase user identifier + * - email: optional email when available + * - isGuest: true when the user is an anonymous/guest session + * - iat/exp: issued-at and expiry timestamps (seconds since epoch) + */ +export interface SessionPayload { + uid: string; + email?: string; + isGuest?: boolean; + iat: number; + exp: number; +} + +/** + * Encode a Buffer or string to a URL-safe Base64 string (base64url) with padding removed. + * + * This follows the common "base64url" format used by JWTs: + * - '+' => '-' + * - '/' => '_' + * - '=' padding removed + * + * @param input - The input Buffer or string to encode. + * @returns The URL-safe Base64-encoded string without padding. + */ +function base64url(input: Buffer | string): string { + return Buffer.from(input) + .toString('base64') + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); +} + +/** + * Default time-to-live (TTL) for session JWTs, in seconds. + * + * Value: 60 * 60 (1 hour) + */ +const DEFAULT_TTL_SECONDS = 60 * 60; // 1 hour + +/** + * Read the JWT secret from the environment. + * + * The secret is required to be present in the SESSION_JWT_SECRET environment variable + * and must be at least 32 characters long to ensure sufficient HMAC key strength. + * + * @returns The raw secret string. + * @throws {Error} If SESSION_JWT_SECRET is not set or is shorter than 32 characters. + * + * @internal + */ +function getSecret(): string { + const secret = nonEmpty(getEnvConfig('NEXT_SESSION_JWT_SECRET')); + if (secret == null || secret.length < 32) { + throw new Error( + 'SESSION_JWT_SECRET must be set and at least 32 characters long', + ); + } + return secret; +} + +/** + * Create and sign a session JWT for a given user ID (uid). + * + * The token uses HS256 (HMAC-SHA256) with a base64url-encoded header and payload: + * - Header: { alg: "HS256", typ: "JWT" } + * - Payload: { uid, email?, isGuest?, iat, exp } + * + * The issued-at time (iat) and expiration time (exp) are expressed as UNIX timestamps + * (seconds since epoch). If ttlSeconds is omitted, DEFAULT_TTL_SECONDS (1 hour) is used. + * + * @param uid - The unique identifier for the user (required). + * @param email - Optional email address to include in the token payload. + * @param options - Optional settings: TTL (seconds) and guest flag. + * @returns A compact JWT string in the format: "..". + * @throws {Error} If the signing secret is missing or invalid. + */ +export function signSessionToken( + uid: string, + email?: string, + options?: { ttlSeconds?: number; isGuest?: boolean }, +): string { + const now = Math.floor(Date.now() / 1000); + const exp = now + (options?.ttlSeconds ?? DEFAULT_TTL_SECONDS); + + const header = { alg: 'HS256', typ: 'JWT' }; + const payload: SessionPayload = { + uid, + email, + isGuest: options?.isGuest, + iat: now, + exp, + }; + + const encodedHeader = base64url(JSON.stringify(header)); + const encodedPayload = base64url(JSON.stringify(payload)); + const data = `${encodedHeader}.${encodedPayload}`; + + const secret = getSecret(); + const signature = crypto.createHmac('sha256', secret).update(data).digest(); + + return `${data}.${base64url(signature)}`; +} + +/** + * Verify a session JWT previously created by signSessionToken. + * + * Verification steps: + * 1. Ensure the token has three dot-separated parts. + * 2. Recompute the HMAC-SHA256 signature using the same secret and compare. + * 3. Decode and parse the payload, validating required fields (uid and exp). + * 4. Ensure the token has not expired (payload.exp > current time). + * + * On success, returns the minimal payload { uid, email? }. On any failure (malformed token, + * signature mismatch, missing/invalid claims, expired token, or runtime error), returns undefined. + * + * @param token - The compact JWT string to verify. + * @returns The session identity { uid, email? } if verification succeeds, otherwise undefined. + */ +export function verifySessionToken(token: string): SessionPayload | undefined { + try { + const parts = token.split('.'); + if (parts.length !== 3) return undefined; + const [encodedHeader, encodedPayload, signature] = parts; + const data = `${encodedHeader}.${encodedPayload}`; + + const secret = getSecret(); + const expectedSig = base64url( + crypto.createHmac('sha256', secret).update(data).digest(), + ); + if (signature !== expectedSig) return undefined; + + const json = Buffer.from(encodedPayload, 'base64').toString('utf8'); + const payload = JSON.parse(json) as Partial; + if (typeof payload.uid !== 'string') return undefined; + if (typeof payload.exp !== 'number') return undefined; + + const now = Math.floor(Date.now() / 1000); + if (payload.exp <= now) return undefined; + + return payload as SessionPayload; + } catch { + return undefined; + } +} diff --git a/src/lib/firebase-admin.ts b/src/lib/firebase-admin.ts index cc78554d..07eeee33 100644 --- a/src/lib/firebase-admin.ts +++ b/src/lib/firebase-admin.ts @@ -1,54 +1,79 @@ import { initializeApp, getApps, cert, type App } from 'firebase-admin/app'; - -/** - * Server-only Firebase Admin SDK initialization. - * Uses Application Default Credentials (ADC) which works automatically on Cloud Run. - * For local development, you can either: - * 1. Run `gcloud auth application-default login` - * 2. Set GOOGLE_APPLICATION_CREDENTIALS env var to a service account JSON path - */ +import fs from 'node:fs'; +import path from 'node:path'; +import { getEnvConfig, nonEmpty } from '../app/utils/config'; let adminApp: App | undefined; -export function getFirebaseAdminApp(): App { - if (adminApp != undefined) { - return adminApp; - } - +/** + * ensureAdminInitialized + * Creates or reuses a singleton Firebase Admin App. + * + * Selection order: + * 1) Reuse an existing Admin app from `getApps()`, preferring the one whose + * `options.projectId` matches `NEXT_PUBLIC_FIREBASE_PROJECT_ID`; falls back + * to the first app if no match. + * 2) If `GOOGLE_SA_JSON` is set (server-only inline JSON), parse and initialize + * with `cert(serviceAccount)`. + * 3) If `GOOGLE_SA_JSON_PATH` is set, read and parse the JSON file and + * initialize with `cert(serviceAccount)`. + * 4) If none of the above are provided, throws an error to avoid implicit ADC + * behavior (metadata server lookups) in serverless environments. + * + * Environment variables accessed: + * - NEXT_PUBLIC_FIREBASE_PROJECT_ID: Used to match existing apps and as fallback + * when the service account JSON lacks `project_id`. + * - GOOGLE_SA_JSON: Server-only inline service account JSON string + * (must include `project_id`, `client_email`, and `private_key`). + * - GOOGLE_SA_JSON_PATH: Path to a service account JSON file containing the + * same required fields. + * + * Notes: + * - Uses `getEnvConfig` and `nonEmpty` to read configuration consistently. + * - Keep credentials server-only; do not expose inline JSON to client code. + */ +function ensureAdminInitialized(): App { + // Reuse already initialized app const existingApps = getApps(); + const projectId = getEnvConfig('NEXT_PUBLIC_FIREBASE_PROJECT_ID'); if (existingApps.length > 0) { - adminApp = existingApps.find( - (existingApp) => - existingApp.options.projectId === - process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, - ); - if (adminApp != undefined) { - return adminApp; - } + const matchedApp = + existingApps.find((app) => app.options?.projectId === projectId) ?? + existingApps[0]; + return matchedApp; } - // Check if we have explicit credentials via environment variable - const serviceAccountJson = process.env.FIREBASE_SERVICE_ACCOUNT_JSON; - - if (serviceAccountJson != null && serviceAccountJson.length > 0) { - try { - const serviceAccount = JSON.parse(serviceAccountJson); - adminApp = initializeApp({ - credential: cert(serviceAccount), - projectId: serviceAccount.project_id, - }); - } catch (error) { - console.error('Failed to parse FIREBASE_SERVICE_ACCOUNT_JSON:', error); - // Fall through to ADC - } + // Prefer inline service account JSON (server-only) + const inlineJson = nonEmpty(getEnvConfig('GOOGLE_SA_JSON')); + if (inlineJson != undefined) { + const serviceAccount = JSON.parse(inlineJson); + return initializeApp({ + credential: cert(serviceAccount), + projectId: serviceAccount.project_id, + }); } - if (adminApp == undefined) { - // Use Application Default Credentials (works on Cloud Run automatically) - adminApp = initializeApp({ - projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, + // Or load from file path + const filePath = nonEmpty(getEnvConfig('GOOGLE_SA_JSON_PATH')); + if (filePath != undefined) { + const raw = fs.readFileSync(path.resolve(filePath), 'utf8'); + const serviceAccount = JSON.parse(raw); + return initializeApp({ + credential: cert(serviceAccount), + projectId: serviceAccount.project_id ?? projectId, }); } + // No credentials provided: fail fast instead of attempting metadata server + throw new Error( + 'Missing server-side credentials. Set GOOGLE_SA_JSON (inline), or GOOGLE_SA_JSON_PATH(file path).', + ); +} + +export function getFirebaseAdminApp(): App { + if (adminApp != undefined) { + return adminApp; + } + adminApp = ensureAdminInitialized(); return adminApp; } diff --git a/src/lib/remote-config.server.ts b/src/lib/remote-config.server.ts index d170446f..fa79af3a 100644 --- a/src/lib/remote-config.server.ts +++ b/src/lib/remote-config.server.ts @@ -3,6 +3,7 @@ import 'server-only'; import { cache } from 'react'; import { getRemoteConfig } from 'firebase-admin/remote-config'; import { getFirebaseAdminApp } from './firebase-admin'; +import { getEnvConfig } from '../app/utils/config'; import { defaultRemoteConfigValues, type RemoteConfigValues, @@ -51,6 +52,13 @@ function parseConfigValue( * Returns the template parameters merged with defaults. */ async function fetchRemoteConfigFromFirebase(): Promise { + // Dev/mock bypass: return defaults without touching Admin SDK + const isMock = + getEnvConfig('NEXT_PUBLIC_API_MOCKING') === 'enabled' || + getEnvConfig('LOCAL_DEV_NO_ADMIN') === '1'; + if (isMock) { + return defaultRemoteConfigValues; + } const app = getFirebaseAdminApp(); const remoteConfigAdmin = getRemoteConfig(app); @@ -95,6 +103,13 @@ async function fetchRemoteConfigFromFirebase(): Promise { */ export const getRemoteConfigValues = cache( async (): Promise => { + // Dev/mock bypass: use defaults immediately + const isMock = + getEnvConfig('NEXT_PUBLIC_API_MOCKING') === 'enabled' || + getEnvConfig('LOCAL_DEV_NO_ADMIN') === '1'; + if (isMock) { + return defaultRemoteConfigValues; + } const now = Date.now(); const cacheAge = (now - cacheTimestamp) / 1000; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts new file mode 100644 index 00000000..245f7605 --- /dev/null +++ b/src/mocks/browser.ts @@ -0,0 +1,5 @@ +import { setupWorker } from 'msw/browser'; +import { handlers } from './handlers'; + +// MSW browser worker to intercept client-side fetch/XHR +export const worker = setupWorker(...handlers); diff --git a/src/mocks/data/gtfs_feed_mdb-2947.json b/src/mocks/data/gtfs_feed_mdb-2947.json new file mode 100644 index 00000000..131d294b --- /dev/null +++ b/src/mocks/data/gtfs_feed_mdb-2947.json @@ -0,0 +1,14 @@ +{ + "id": "mdb-2947", + "data_type": "gtfs", + "provider": "Example Transit Agency", + "feed_name": "Example City Transit", + "locations": [ + { + "country_code": "US", + "subdivision_name": "Example State", + "municipality": "Example City", + "country": "United States" + } + ] +} \ No newline at end of file diff --git a/src/mocks/data/gtfs_feed_test-516.json b/src/mocks/data/gtfs_feed_test-516.json new file mode 100644 index 00000000..c96deb6d --- /dev/null +++ b/src/mocks/data/gtfs_feed_test-516.json @@ -0,0 +1,14 @@ +{ + "id": "test-516", + "data_type": "gtfs", + "provider": "Metropolitan Transit Authority (MTA)", + "feed_name": "NYC Subway", + "locations": [ + { + "country_code": "US", + "subdivision_name": "New York", + "municipality": "New York City", + "country": "United States" + } + ] +} \ No newline at end of file diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index e1cc895e..80f87426 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -4,8 +4,37 @@ import { http, HttpResponse } from 'msw'; import feedJson from '../../cypress/fixtures/feed_test-516.json'; import gtfsFeedJson from '../../cypress/fixtures/gtfs_feed_test-516.json'; import datasetsFeedJson from '../../cypress/fixtures/feed_datasets_test-516.json'; +import feed2947 from '../../cypress/fixtures/feed_mdb-2947.json'; +import gtfsFeed2947 from '../../cypress/fixtures/gtfs_feed_mdb-2947.json'; +import datasets2947 from '../../cypress/fixtures/feed_datasets_mdb-2947.json'; export const handlers = [ + // Mock search endpoint: return a list including test-516 and mdb-2947 + http.get(`*/v1/search`, () => { + return HttpResponse.json({ + total: 2, + results: [ + { + id: 'test-516', + data_type: 'gtfs', + feed_name: gtfsFeedJson.feed_name, + provider: gtfsFeedJson.provider, + official: false, + locations: gtfsFeedJson.locations ?? [], + entity_types: [], + }, + { + id: 'mdb-2947', + data_type: 'gtfs', + feed_name: gtfsFeed2947.feed_name, + provider: gtfsFeed2947.provider, + official: false, + locations: gtfsFeed2947.locations ?? [], + entity_types: [], + }, + ], + }); + }), // Mock GET /v1/feeds/{id} - basic feed info http.get(`*/v1/feeds/test-516`, () => { return HttpResponse.json(feedJson); @@ -30,4 +59,18 @@ export const handlers = [ http.get(/.*test-516.*routes\.json$/, () => { return HttpResponse.json([]); }), + + // --- mdb-2947 --- + http.get(`*/v1/feeds/mdb-2947`, () => { + return HttpResponse.json(feed2947); + }), + http.get(`*/v1/gtfs_feeds/mdb-2947`, () => { + return HttpResponse.json(gtfsFeed2947); + }), + http.get(`*/v1/gtfs_feeds/mdb-2947/datasets`, () => { + return HttpResponse.json(datasets2947); + }), + http.get(`*/v1/gtfs_feeds/mdb-2947/gtfs_rt_feeds`, () => { + return HttpResponse.json([]); + }), ];