fix: degrade auth profile on backend outage
This commit is contained in:
@@ -15,6 +15,84 @@ import { hasSupabaseServerEnv } from "@/lib/supabase/server";
|
|||||||
|
|
||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
|
|
||||||
|
type VerifiedBearerIdentity = {
|
||||||
|
email: string | null;
|
||||||
|
userId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractBearerToken(headerValue: string | null) {
|
||||||
|
if (!headerValue) return "";
|
||||||
|
const parts = headerValue.trim().split(/\s+/);
|
||||||
|
if (parts.length === 2 && parts[0].toLowerCase() === "bearer") {
|
||||||
|
return parts[1];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getVerifiedBearerIdentity(
|
||||||
|
req: NextRequest,
|
||||||
|
): Promise<VerifiedBearerIdentity | null> {
|
||||||
|
const token = extractBearerToken(req.headers.get("authorization"));
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
||||||
|
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
|
||||||
|
if (!supabaseUrl || !anonKey) return null;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), 4000);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${supabaseUrl.replace(/\/+$/, "")}/auth/v1/user`, {
|
||||||
|
cache: "no-store",
|
||||||
|
headers: {
|
||||||
|
apikey: anonKey,
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const user = await res.json();
|
||||||
|
const userId = String(user?.id || "").trim();
|
||||||
|
if (!userId) return null;
|
||||||
|
return {
|
||||||
|
email: String(user?.email || "").trim() || null,
|
||||||
|
userId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function degradedAuthProfileResponse({
|
||||||
|
email,
|
||||||
|
reason,
|
||||||
|
response,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
email: string | null;
|
||||||
|
reason: string;
|
||||||
|
response: NextResponse | null;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
const degraded = NextResponse.json({
|
||||||
|
authenticated: true,
|
||||||
|
user_id: userId,
|
||||||
|
email,
|
||||||
|
subscription_active: null,
|
||||||
|
subscription_plan_code: null,
|
||||||
|
subscription_expires_at: null,
|
||||||
|
subscription_total_expires_at: null,
|
||||||
|
subscription_queued_days: 0,
|
||||||
|
subscription_queued_count: 0,
|
||||||
|
points: 0,
|
||||||
|
degraded_auth_profile: true,
|
||||||
|
degraded_reason: reason,
|
||||||
|
});
|
||||||
|
return applyAuthResponseCookies(degraded, response);
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const requestHost =
|
const requestHost =
|
||||||
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
req.headers.get("x-forwarded-host") || req.headers.get("host") || req.nextUrl.host;
|
||||||
@@ -63,23 +141,23 @@ export async function GET(req: NextRequest) {
|
|||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
}
|
}
|
||||||
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
|
if ((res.status === 401 || res.status === 403) && auth.authUserId) {
|
||||||
const response = NextResponse.json({
|
return degradedAuthProfileResponse({
|
||||||
authenticated: true,
|
|
||||||
user_id: auth.authUserId,
|
|
||||||
email: auth.authEmail || null,
|
email: auth.authEmail || null,
|
||||||
subscription_active: null,
|
reason: `backend_${res.status}`,
|
||||||
subscription_plan_code: null,
|
response: auth.response,
|
||||||
subscription_expires_at: null,
|
userId: auth.authUserId,
|
||||||
subscription_total_expires_at: null,
|
|
||||||
subscription_queued_days: 0,
|
|
||||||
subscription_queued_count: 0,
|
|
||||||
points: 0,
|
|
||||||
degraded_auth_profile: true,
|
|
||||||
degraded_reason: `backend_${res.status}`,
|
|
||||||
});
|
});
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
|
||||||
}
|
}
|
||||||
if (res.status === 401 || res.status === 403) {
|
if (res.status === 401 || res.status === 403) {
|
||||||
|
const bearerIdentity = await getVerifiedBearerIdentity(req);
|
||||||
|
if (bearerIdentity) {
|
||||||
|
return degradedAuthProfileResponse({
|
||||||
|
email: bearerIdentity.email,
|
||||||
|
reason: `backend_${res.status}`,
|
||||||
|
response: auth.response,
|
||||||
|
userId: bearerIdentity.userId,
|
||||||
|
});
|
||||||
|
}
|
||||||
const response = NextResponse.json({
|
const response = NextResponse.json({
|
||||||
authenticated: false,
|
authenticated: false,
|
||||||
subscription_active: false,
|
subscription_active: false,
|
||||||
@@ -90,21 +168,21 @@ export async function GET(req: NextRequest) {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const raw = await res.text();
|
const raw = await res.text();
|
||||||
if (auth.authUserId) {
|
if (auth.authUserId) {
|
||||||
const response = NextResponse.json({
|
return degradedAuthProfileResponse({
|
||||||
authenticated: true,
|
|
||||||
user_id: auth.authUserId,
|
|
||||||
email: auth.authEmail || null,
|
email: auth.authEmail || null,
|
||||||
subscription_active: null,
|
reason: `backend_${res.status}`,
|
||||||
subscription_plan_code: null,
|
response: auth.response,
|
||||||
subscription_expires_at: null,
|
userId: auth.authUserId,
|
||||||
subscription_total_expires_at: null,
|
});
|
||||||
subscription_queued_days: 0,
|
}
|
||||||
subscription_queued_count: 0,
|
const bearerIdentity = await getVerifiedBearerIdentity(req);
|
||||||
points: 0,
|
if (bearerIdentity) {
|
||||||
degraded_auth_profile: true,
|
return degradedAuthProfileResponse({
|
||||||
degraded_reason: `backend_${res.status}`,
|
email: bearerIdentity.email,
|
||||||
|
reason: `backend_${res.status}`,
|
||||||
|
response: auth.response,
|
||||||
|
userId: bearerIdentity.userId,
|
||||||
});
|
});
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
|
||||||
}
|
}
|
||||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
@@ -114,21 +192,21 @@ export async function GET(req: NextRequest) {
|
|||||||
return applyAuthResponseCookies(response, auth.response);
|
return applyAuthResponseCookies(response, auth.response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (auth?.authUserId) {
|
if (auth?.authUserId) {
|
||||||
const response = NextResponse.json({
|
return degradedAuthProfileResponse({
|
||||||
authenticated: true,
|
|
||||||
user_id: auth.authUserId,
|
|
||||||
email: auth.authEmail || null,
|
email: auth.authEmail || null,
|
||||||
subscription_active: null,
|
reason: String(error),
|
||||||
subscription_plan_code: null,
|
response: auth.response,
|
||||||
subscription_expires_at: null,
|
userId: auth.authUserId,
|
||||||
subscription_total_expires_at: null,
|
});
|
||||||
subscription_queued_days: 0,
|
}
|
||||||
subscription_queued_count: 0,
|
const bearerIdentity = await getVerifiedBearerIdentity(req);
|
||||||
points: 0,
|
if (bearerIdentity) {
|
||||||
degraded_auth_profile: true,
|
return degradedAuthProfileResponse({
|
||||||
degraded_reason: String(error),
|
email: bearerIdentity.email,
|
||||||
|
reason: String(error),
|
||||||
|
response: auth?.response || null,
|
||||||
|
userId: bearerIdentity.userId,
|
||||||
});
|
});
|
||||||
return applyAuthResponseCookies(response, auth.response);
|
|
||||||
}
|
}
|
||||||
return buildProxyExceptionResponse(error, {
|
return buildProxyExceptionResponse(error, {
|
||||||
publicMessage: "Failed to fetch auth profile",
|
publicMessage: "Failed to fetch auth profile",
|
||||||
|
|||||||
@@ -132,6 +132,22 @@ export function runTests() {
|
|||||||
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
|
middlewareSource.includes("unauthorizedSupabaseSessionResponse()"),
|
||||||
"middleware must reject no-cookie protected API requests without calling Supabase auth",
|
"middleware must reject no-cookie protected API requests without calling Supabase auth",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const authMeRouteSource = fs.readFileSync(
|
||||||
|
path.join(projectRoot, "app", "api", "auth", "me", "route.ts"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
authMeRouteSource.includes("/auth/v1/user") &&
|
||||||
|
authMeRouteSource.includes("getVerifiedBearerIdentity") &&
|
||||||
|
authMeRouteSource.includes("degraded_auth_profile: true"),
|
||||||
|
"/api/auth/me must verify bearer tokens directly and return a degraded authenticated profile when the backend auth profile is transiently unavailable",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
authMeRouteSource.indexOf("const bearerIdentity = await getVerifiedBearerIdentity(req)") <
|
||||||
|
authMeRouteSource.indexOf("return buildProxyExceptionResponse(error"),
|
||||||
|
"/api/auth/me must try bearer identity fallback before returning a proxy exception",
|
||||||
|
);
|
||||||
for (const route of [
|
for (const route of [
|
||||||
"app/api/ops/analytics/funnel/route.ts",
|
"app/api/ops/analytics/funnel/route.ts",
|
||||||
"app/api/ops/config/route.ts",
|
"app/api/ops/config/route.ts",
|
||||||
|
|||||||
@@ -255,7 +255,8 @@ export function runTests() {
|
|||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
|
authMeRouteSource.includes("if ((res.status === 401 || res.status === 403) && auth.authUserId)") &&
|
||||||
authMeRouteSource.includes("degraded_reason: `backend_${res.status}`") &&
|
authMeRouteSource.includes("degradedAuthProfileResponse") &&
|
||||||
|
authMeRouteSource.includes("reason: `backend_${res.status}`") &&
|
||||||
authMeRouteSource.includes("subscription_active: null"),
|
authMeRouteSource.includes("subscription_active: null"),
|
||||||
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall",
|
"auth profile proxy must preserve authenticated identity with unknown subscription on backend 401/403 instead of forcing a false paywall",
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user