Skip to content

Commit 67da535

Browse files
address review: warn once, fail closed without non-null assertions, test opt-in
- Gate the trusted-proxy warning at module scope; authOptions() is rebuilt per request, so the per-instance warning logged on every session lookup. - Replace the env non-null assertions in authorize with a fail-closed guard. - Test that the provider registers only when both TRUSTED_PROXY_AUTH_HEADER and TRUSTED_PROXY_AUTH_EMAIL are set, mirroring the Apple provider test, plus authorize's fail-closed paths and the once-per-process warning.
1 parent 92d8776 commit 67da535

2 files changed

Lines changed: 162 additions & 3 deletions

File tree

‎apps/web/__tests__/unit/auth-options.test.ts‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ const env = vi.hoisted(() => ({
4747
RESEND_API_KEY: undefined,
4848
WORKOS_API_KEY: "workos-secret",
4949
WORKOS_CLIENT_ID: "workos-client",
50+
TRUSTED_PROXY_AUTH_HEADER: undefined as string | undefined,
51+
TRUSTED_PROXY_AUTH_EMAIL: undefined as string | undefined,
52+
TRUSTED_PROXY_AUTH_SECRET: undefined as string | undefined,
5053
}));
5154

5255
vi.mock("@cap/env", () => ({
@@ -105,6 +108,15 @@ function callbacksFor(options: NextAuthOptions) {
105108
return { signIn, jwt };
106109
}
107110

111+
// next-auth keeps a Credentials provider's custom id in `options` until it
112+
// merges providers at runtime, so read it from there when present.
113+
function providerIds(options: NextAuthOptions) {
114+
return options.providers.map(
115+
(provider) =>
116+
(provider.options as { id?: string } | undefined)?.id ?? provider.id,
117+
);
118+
}
119+
108120
describe("authOptions", () => {
109121
beforeEach(() => {
110122
env.APPLE_CLIENT_ID = "so.cap.auth";
@@ -113,6 +125,9 @@ describe("authOptions", () => {
113125
env.WORKOS_API_KEY = "workos-secret";
114126
env.CAP_ALLOWED_SIGNUP_DOMAINS = undefined;
115127
env.CAP_BLOCKED_SIGNUP_DOMAINS = undefined;
128+
env.TRUSTED_PROXY_AUTH_HEADER = undefined;
129+
env.TRUSTED_PROXY_AUTH_EMAIL = undefined;
130+
env.TRUSTED_PROXY_AUTH_SECRET = undefined;
116131
mocks.validate.mockReset().mockResolvedValue(IDENTITY);
117132
mocks.provision.mockReset().mockResolvedValue(undefined);
118133
mocks.adapter.mockClear();
@@ -167,6 +182,141 @@ describe("authOptions", () => {
167182
},
168183
);
169184

185+
it("enables trusted-proxy sign-in when both header and email are configured", () => {
186+
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
187+
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
188+
189+
expect(providerIds(authOptions())).toContain("trusted-proxy");
190+
});
191+
192+
it("does not expose trusted-proxy sign-in by default", () => {
193+
expect(providerIds(authOptions())).not.toContain("trusted-proxy");
194+
});
195+
196+
it.each(["TRUSTED_PROXY_AUTH_HEADER", "TRUSTED_PROXY_AUTH_EMAIL"] as const)(
197+
"does not expose a partially configured trusted-proxy provider when %s is missing",
198+
(key) => {
199+
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
200+
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
201+
env[key] = undefined;
202+
203+
expect(providerIds(authOptions())).not.toContain("trusted-proxy");
204+
},
205+
);
206+
207+
describe("trusted-proxy authorize", () => {
208+
type Authorize = (
209+
credentials: Record<string, string> | undefined,
210+
req: { headers?: Record<string, string> },
211+
) => Promise<unknown>;
212+
213+
function trustedProxyAuthorize() {
214+
const options = authOptions();
215+
const provider = options.providers[
216+
providerIds(options).indexOf("trusted-proxy")
217+
] as { options?: { authorize?: Authorize } } | undefined;
218+
const authorize = provider?.options?.authorize;
219+
if (!authorize) throw new Error("trusted-proxy provider is missing");
220+
return authorize;
221+
}
222+
223+
beforeEach(() => {
224+
env.TRUSTED_PROXY_AUTH_HEADER = "X-OpenHost-Is-Owner";
225+
env.TRUSTED_PROXY_AUTH_EMAIL = "Alex@Company.example";
226+
});
227+
228+
it("signs in the configured user when the proxy sets the header", async () => {
229+
await expect(
230+
trustedProxyAuthorize()(
231+
{},
232+
{
233+
headers: { "x-openhost-is-owner": "true" },
234+
},
235+
),
236+
).resolves.toMatchObject({
237+
id: AUTHENTICATED_USER.id,
238+
email: AUTHENTICATED_USER.email,
239+
});
240+
});
241+
242+
it.each([
243+
["header is absent", {}],
244+
["header is not exactly 'true'", { "x-openhost-is-owner": "1" }],
245+
])("fails closed when the %s", async (_label, headers) => {
246+
await expect(
247+
trustedProxyAuthorize()({}, { headers }),
248+
).resolves.toBeNull();
249+
});
250+
251+
it("fails closed when the configured user does not exist", async () => {
252+
env.TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example";
253+
254+
await expect(
255+
trustedProxyAuthorize()(
256+
{},
257+
{
258+
headers: { "x-openhost-is-owner": "true" },
259+
},
260+
),
261+
).resolves.toBeNull();
262+
});
263+
264+
it("requires the shared secret when one is configured", async () => {
265+
env.TRUSTED_PROXY_AUTH_SECRET = "proxy-secret";
266+
const authorize = trustedProxyAuthorize();
267+
268+
await expect(
269+
authorize({}, { headers: { "x-openhost-is-owner": "true" } }),
270+
).resolves.toBeNull();
271+
await expect(
272+
authorize(
273+
{},
274+
{
275+
headers: {
276+
"x-openhost-is-owner": "true",
277+
"x-trusted-proxy-secret": "wrong",
278+
},
279+
},
280+
),
281+
).resolves.toBeNull();
282+
await expect(
283+
authorize(
284+
{},
285+
{
286+
headers: {
287+
"x-openhost-is-owner": "true",
288+
"x-trusted-proxy-secret": "proxy-secret",
289+
},
290+
},
291+
),
292+
).resolves.toMatchObject({ id: AUTHENTICATED_USER.id });
293+
});
294+
});
295+
296+
// authOptions() is rebuilt on every session lookup, so a per-instance
297+
// warning would log on every authenticated request.
298+
it("warns about trusted-proxy sign-in once per process", async () => {
299+
vi.resetModules();
300+
const { authOptions: freshAuthOptions } = await import(
301+
"@cap/database/auth/auth-options"
302+
);
303+
env.TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner";
304+
env.TRUSTED_PROXY_AUTH_EMAIL = PROFILE.email;
305+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
306+
307+
try {
308+
for (let i = 0; i < 3; i++) void freshAuthOptions().providers;
309+
310+
expect(
311+
warn.mock.calls.filter(([message]) =>
312+
String(message).includes("trusted-proxy sign-in is ENABLED"),
313+
),
314+
).toHaveLength(1);
315+
} finally {
316+
warn.mockRestore();
317+
}
318+
});
319+
170320
// Without an explicit maxAge next-auth falls back to 24 hours, which is far
171321
// too long for a 6-digit code and contradicts what the OTP email tells users.
172322
it("expires email verification codes after the advertised 10 minutes", () => {

‎packages/database/auth/auth-options.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export const maxDuration = 120;
3333

3434
const OTP_CODE_MAX_AGE_SECONDS = 10 * 60;
3535

36+
// authOptions() is rebuilt per request (getServerSession, getCurrentUser), so
37+
// the trusted-proxy warning is gated at module scope to log once per process.
38+
let trustedProxyWarned = false;
39+
3640
export async function decodeSessionToken(
3741
params: JWTDecodeParams,
3842
): Promise<JWT | null> {
@@ -106,9 +110,11 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => {
106110
const oidcClientId = serverEnv().OIDC_CLIENT_ID;
107111
const oidcClientSecret = serverEnv().OIDC_CLIENT_SECRET;
108112
if (
113+
!trustedProxyWarned &&
109114
serverEnv().TRUSTED_PROXY_AUTH_HEADER &&
110115
serverEnv().TRUSTED_PROXY_AUTH_EMAIL
111116
) {
117+
trustedProxyWarned = true;
112118
console.warn(
113119
"[auth] trusted-proxy sign-in is ENABLED. Only run this behind a reverse proxy that strips client-sent copies of the trust header, or a login bypass is possible.",
114120
);
@@ -204,7 +210,9 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => {
204210
// proxy — it would be a login bypass. Fails closed below.
205211
async authorize(_credentials, req) {
206212
const env = serverEnv();
207-
const header = env.TRUSTED_PROXY_AUTH_HEADER!.toLowerCase();
213+
const header = env.TRUSTED_PROXY_AUTH_HEADER?.toLowerCase();
214+
const email = env.TRUSTED_PROXY_AUTH_EMAIL?.toLowerCase();
215+
if (!header || !email) return null;
208216
// The router is the sole authority for this header (it strips
209217
// any client-sent copy), so its presence means the request was
210218
// owner-authenticated. Fail closed on anything else.
@@ -219,11 +227,12 @@ export const authOptions = (ssoContext?: SsoAuthContext): NextAuthOptions => {
219227
req?.headers?.["x-trusted-proxy-secret"] ?? "";
220228
const digest = (s: string) =>
221229
crypto.createHash("sha256").update(s).digest();
222-
if (!crypto.timingSafeEqual(digest(provided), digest(secret)))
230+
if (
231+
!crypto.timingSafeEqual(digest(provided), digest(secret))
232+
)
223233
return null;
224234
}
225235

226-
const email = env.TRUSTED_PROXY_AUTH_EMAIL!.toLowerCase();
227236
const [user] = await db()
228237
.select({
229238
id: users.id,

0 commit comments

Comments
 (0)