Skip to content
Merged
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: 8 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,14 @@ Every later request validates this cookie in the DAL, so most requests need no p

Next.js 16 uses `src/proxy.ts`, not `middleware.ts`. The proxy checks for the auth cookie on protected paths and redirects when it is missing, which is a fast UX guard. The real security boundary is `verifySession()` in `src/lib/dal.ts`, which verifies the HMAC and expiry, and `requireAdmin()` gates admin-only features. See the [Auth Patterns ADR](decisions/auth-patterns.md).

### Member data and the portal

Member identity comes from the co-op's member portal over HTTP, not from our database. Contact reads (`getmembercontacts`) authenticate with the `MEMBER_API_SECRET`; the roster read (`listmembers`) sends the member's portal token. Both go through `src/lib/api/portal-api.ts`.

The portal is a single dependency, so the integration is defensive. Each call carries an 8-second `AbortSignal.timeout`, so a hung portal fails fast instead of holding a serverless function open. The portal answers `200` even on failure (an `INVALID_KEY` sentinel for a bad secret, a PHP notice for a missing token), so the client treats those shapes as errors and logs a truncated raw snippet rather than failing opaquely.

We memoize reads per request with React `cache()`, so one request makes at most one portal call per distinct member read. We do not cache member data across requests. It is PII (name, email, phone), a Redis copy would be a new exposure surface for little gain at this roster size, and a stale entry could show wrong contact info.

## Data Model

The Prisma schema defines the app's tables. Referral PII and SMS/email PII are encrypted at the service layer with AES-256-GCM, alongside HMAC blind-index columns for lookup without decryption.
Expand Down
3 changes: 3 additions & 0 deletions docs/decisions/auth-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ Use a single token-based session for both user types, validated in two steps. Th

On a valid response the callback mints the app's own session cookie, `prfc_auth`, and stores the raw portal token in a second cookie (`prfc_portal_token`) for the member-roster read.

The callback answers with a **303 (See Other)** redirect to `/home`, not the framework default of 307. The login POST arrives cross-site from the portal, and `prfc_auth` is `SameSite=Lax`. A 307 preserves the method, so the browser re-POSTs to `/home`, and a Lax cookie is not sent on a cross-site POST navigation, so the just-set session is dropped and the proxy bounces the user to `/unauthorized`. A 303 makes the browser GET `/home` instead, and Lax cookies ride along on top-level GET navigations, so the session holds. Keep the explicit 303.

**Session cookie format:**

```
Expand Down Expand Up @@ -56,3 +58,4 @@ ownerid|isAdmin|timestamp|hmac_signature

- Login depends on the portal's `validatetoken` endpoint being reachable and reporting the admin flag
- `PRFC_PORTAL_SECRET` must be coordinated with PRFC infrastructure, since the same secret signs the session cookie and the public referral `cs` parameter
- Sessions last one hour with no refresh. The portal issues one-hour tokens and has no refresh endpoint, and `listmembers` needs that same token, so a usable session cannot outlive the portal token without portal changes. Both cookies expire together, so `verifySession()` redirects to sign-in at the hour, and `handleActionError` treats a missing portal token (`"Member portal session required"`) the same way. Extending sessions would require a refresh endpoint on the portal side.
15 changes: 12 additions & 3 deletions src/actions/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,21 @@ export async function updateUserPreferencesAction(input: {
try {
const session = await verifySession();
const validated = UpdatePreferencesSchema.parse(input);

let consentPhone: string | undefined;
if (env.SMS_ENABLED && validated.notifySmsDefault === true) {
const profile = await getMemberProfile(session.ownerid, session.isAdmin);
if (!profile.phone || profile.phone.trim() === "") {
return { success: false, error: "Add a phone number to your member account before enabling text messages." };
}
consentPhone = profile.phone;
}

const updated = await updateUserPreferences(session.ownerid, validated);

if (env.SMS_ENABLED) {
if (validated.notifySmsDefault === true) {
const profile = await getMemberProfile(session.ownerid, session.isAdmin);
await grantSmsConsent(session.ownerid, profile.phone);
if (validated.notifySmsDefault === true && consentPhone) {
await grantSmsConsent(session.ownerid, consentPhone);
} else if (validated.notifySmsDefault === false) {
await revokeSmsConsent(session.ownerid, "web_settings_toggle", null);
}
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,28 @@ export async function POST(req: NextRequest) {
try {
getSecret();
} catch {
return NextResponse.redirect(new URL("/home", req.url));
return NextResponse.redirect(new URL("/home", req.url), 303);
}

const formData = await req.formData();
const parsed = AuthCallbackSchema.safeParse({ token: formData.get("token") });

if (!parsed.success) {
console.warn("[AUTH_CALLBACK] missing or malformed token", ip);
return NextResponse.redirect(new URL("/home", req.url));
return NextResponse.redirect(new URL("/home", req.url), 303);
}

const { token } = parsed.data;

const session = await validatePortalToken(token);
if (!session) {
console.warn("[AUTH_CALLBACK] invalid or expired token", ip);
return NextResponse.redirect(new URL("/home", req.url));
return NextResponse.redirect(new URL("/home", req.url), 303);
}

console.info("[AUTH_CALLBACK] login success", session.ownerid, ip);

const response = NextResponse.redirect(new URL("/home", req.url));
const response = NextResponse.redirect(new URL("/home", req.url), 303);
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: { code: "FORBIDDEN", message: "Invalid origin" } }, { status: 403 });
}

const response = NextResponse.redirect(new URL("/", req.url));
const response = NextResponse.redirect(new URL("/", req.url), 303);
response.cookies.delete(AUTH_COOKIE);
response.cookies.delete(PORTAL_TOKEN_COOKIE);
return response;
Expand Down
13 changes: 7 additions & 6 deletions src/lib/api/member-api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "server-only";
import { cache } from "react";
import { env } from "@/env";
import { AppError } from "@/utils/errors";
import { fetchListMembers, fetchMemberContacts, getPortalToken } from "@/lib/api/portal-api";
Expand Down Expand Up @@ -54,23 +55,23 @@ export async function getMemberDetails(memberIds: number[]): Promise<Member[]> {
return getRealMemberDetails(memberIds);
}

export async function getAllActiveMemberIds(): Promise<number[]> {
export const getAllActiveMemberIds = cache(async (): Promise<number[]> => {
if (env.USE_MOCK_MEMBER_API) {
return getMockAllActiveMemberIds();
}
return getRealAllActiveMemberIds();
}
});

export async function getAllMembers(): Promise<MemberSummary[]> {
export const getAllMembers = cache(async (): Promise<MemberSummary[]> => {
if (env.USE_MOCK_MEMBER_API) {
return getMockAllMembers();
}
return getRealAllMembers();
}
});

export async function getMemberById(id: number): Promise<Member | null> {
export const getMemberById = cache(async (id: number): Promise<Member | null> => {
if (env.USE_MOCK_MEMBER_API) {
return getMockMemberById(id);
}
return getRealMemberById(id);
}
});
57 changes: 47 additions & 10 deletions src/lib/api/portal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,42 +17,79 @@ export async function getPortalToken(): Promise<string | null> {
return cookieStore.get(PORTAL_TOKEN_COOKIE)?.value ?? null;
}

const PORTAL_TIMEOUT_MS = 8000;

async function postForm(task: string, body: Record<string, string>): Promise<string> {
const res = await fetch(`${env.PRFC_PORTAL_API_URL}?task=${task}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
});
let res: Response;
try {
res = await fetch(`${env.PRFC_PORTAL_API_URL}?task=${task}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(body).toString(),
signal: AbortSignal.timeout(PORTAL_TIMEOUT_MS),
});
} catch (error) {
const name = error instanceof Error ? error.name : "";
if (name === "TimeoutError" || name === "AbortError") {
throw new AppError("INTERNAL_ERROR", `Member portal ${task} timed out after ${PORTAL_TIMEOUT_MS}ms`);
}
throw new AppError("INTERNAL_ERROR", `Member portal ${task} request failed`);
}

if (!res.ok) {
throw new AppError("INTERNAL_ERROR", `Member portal ${task} responded ${res.status}`);
}

return res.text();
const text = await res.text();
assertNoPortalError(text, task);
return text;
}

function rawSnippet(raw: string): string {
return raw.replace(/\s+/g, " ").trim().slice(0, 200);
}

function assertNoPortalError(raw: string, task: string): void {
if (raw.includes("INVALID_KEY")) {
throw new AppError("INTERNAL_ERROR", `Member portal ${task} rejected the request (invalid secret or token)`, {
raw: rawSnippet(raw),
});
}
if (/Undefined index|<b>\s*(Notice|Warning|Fatal error)\s*<\/b>/i.test(raw)) {
throw new AppError("INTERNAL_ERROR", `Member portal ${task} returned a server error`, { raw: rawSnippet(raw) });
}
}

function extractObject(raw: string): unknown {
const stripped = raw.replace(/<[^>]+>/g, "");
const start = stripped.indexOf("{");
const end = stripped.lastIndexOf("}");
if (start === -1 || end === -1) {
throw new AppError("INTERNAL_ERROR", "Member portal returned no JSON object");
throw new AppError("INTERNAL_ERROR", "Member portal returned no JSON object", { raw: rawSnippet(raw) });
}
try {
return JSON.parse(stripped.slice(start, end + 1));
} catch {
throw new AppError("INTERNAL_ERROR", "Member portal returned an unparseable JSON object", { raw: rawSnippet(raw) });
}
return JSON.parse(stripped.slice(start, end + 1));
}

function extractArray(raw: string): unknown {
const start = raw.indexOf("[");
const end = raw.lastIndexOf("]");
if (start === -1 || end === -1) {
throw new AppError("INTERNAL_ERROR", "Member portal returned no JSON array");
throw new AppError("INTERNAL_ERROR", "Member portal returned no JSON array", { raw: rawSnippet(raw) });
}
const body = raw
.slice(start, end + 1)
.replace(/<[^>]+>/g, "")
.replace(/}\s*{/g, "},{")
.replace(/,\s*]/g, "]");
return JSON.parse(body);
try {
return JSON.parse(body);
} catch {
throw new AppError("INTERNAL_ERROR", "Member portal returned an unparseable JSON array", { raw: rawSnippet(raw) });
}
}

export async function validatePortalToken(token: string): Promise<Session | null> {
Expand Down
7 changes: 1 addition & 6 deletions src/services/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,8 +189,7 @@ export function wrapInEmailTemplate(bodyHtml: string, footerHtml: string): strin
</table>`;
}

const BATCH_SIZE = 10;
const BATCH_DELAY_MS = 1000;
const BATCH_SIZE = 25;

export type { EmailRecipient as Recipient } from "@/types/message";

Expand Down Expand Up @@ -280,10 +279,6 @@ export async function sendGroupEmails(
}
break;
}

if (i + BATCH_SIZE < validRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}

return { sent, failed, suppressed: suppressed.length, results: recipientResults };
Expand Down
5 changes: 4 additions & 1 deletion src/services/sms-consent.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "server-only";
import prisma from "@/lib/db";
import { transformError } from "@/utils/errors";
import { AppError, transformError } from "@/utils/errors";
import { encrypt, decrypt, blindIndex } from "@/lib/encryption";

import type { SmsConsentRecord } from "@/types/settings";
Expand Down Expand Up @@ -31,6 +31,9 @@ export async function getMemberSmsConsent(memberId: number): Promise<SmsConsentR
}

export async function grantSmsConsent(memberId: number, phone: string): Promise<void> {
if (!phone || phone.trim() === "") {
throw new AppError("VALIDATION_ERROR", "A phone number is required to record SMS consent");
}
try {
const existing = await prisma.smsConsent.findFirst({
where: { memberId, revokedAt: null },
Expand Down
8 changes: 7 additions & 1 deletion src/utils/auth-redirect.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
const SESSION_EXPIRED_ERRORS = new Set([
"Authentication required",
"Invalid or expired token",
"Member portal session required",
]);

export function handleActionError(
error: string | undefined,
fallback: string = "An unexpected error occurred",
): string {
if (error === "Authentication required" || error === "Invalid or expired token") {
if (error !== undefined && SESSION_EXPIRED_ERRORS.has(error)) {
window.location.href = "/unauthorized";
return "";
}
Expand Down
24 changes: 20 additions & 4 deletions test/auth/auth-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ describe("POST /api/auth/callback", () => {
const cookie = response.cookies.get(AUTH_COOKIE);
const portalCookie = response.cookies.get("prfc_portal_token");

expect(response.status).toBe(307);
expect(response.status).toBe(303);
expect(cookie).toBeDefined();
expect(validateToken(cookie!.value, getSecret())).toEqual({ ownerid: 100001, isAdmin: true });
expect(cookie!.httpOnly).toBe(true);
Expand All @@ -206,6 +206,22 @@ describe("POST /api/auth/callback", () => {
expect(portalCookie?.value).toBe(portalToken);
});

it("uses 303 See Other on success so the cross-site portal POST lands on a GET and the SameSite=lax cookie is sent", async () => {
mockValidatePortalToken.mockResolvedValueOnce({ ownerid: 100001, isAdmin: true });
const formData = new FormData();
formData.set("token", "portal-base64-token");

const request = new NextRequest("http://localhost:3000/api/auth/callback", {
method: "POST",
body: formData,
});

const response = await callbackPOST(request);

expect(response.status).toBe(303);
expect(new URL(response.headers.get("location")!).pathname).toBe("/home");
});

it("redirects without cookie for invalid token", async () => {
const formData = new FormData();
formData.set("token", "invalid|token|data|badhash!");
Expand All @@ -218,7 +234,7 @@ describe("POST /api/auth/callback", () => {
const response = await callbackPOST(request);
const cookie = response.cookies.get(AUTH_COOKIE);

expect(response.status).toBe(307);
expect(response.status).toBe(303);
expect(cookie).toBeUndefined();
});

Expand All @@ -233,7 +249,7 @@ describe("POST /api/auth/callback", () => {
const response = await callbackPOST(request);
const cookie = response.cookies.get(AUTH_COOKIE);

expect(response.status).toBe(307);
expect(response.status).toBe(303);
expect(cookie).toBeUndefined();
});

Expand Down Expand Up @@ -369,7 +385,7 @@ describe("POST /api/auth/logout", () => {
const response = await logoutPOST(request);
const setCookies = response.headers.getSetCookie();

expect(response.status).toBe(307);
expect(response.status).toBe(303);
expect(setCookies.some((c) => c.includes(AUTH_COOKIE) && c.includes("Expires=Thu, 01 Jan 1970"))).toBe(true);
});

Expand Down
Loading
Loading