@@ -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
5255vi . 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+
108120describe ( "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" , ( ) => {
0 commit comments