@@ -44,6 +44,9 @@ const env = vi.hoisted(() => ({
4444 GOOGLE_CLIENT_ID : "google-client" ,
4545 GOOGLE_CLIENT_SECRET : "google-secret" ,
4646 NEXTAUTH_SECRET : "next-auth-secret" ,
47+ OIDC_ISSUER : undefined as string | undefined ,
48+ OIDC_CLIENT_ID : undefined as string | undefined ,
49+ OIDC_CLIENT_SECRET : undefined as string | undefined ,
4750 RESEND_API_KEY : undefined ,
4851 WORKOS_API_KEY : "workos-secret" ,
4952 WORKOS_CLIENT_ID : "workos-client" ,
@@ -123,6 +126,9 @@ describe("authOptions", () => {
123126 env . APPLE_CLIENT_SECRET = "apple-secret" ;
124127 env . WORKOS_CLIENT_ID = "workos-client" ;
125128 env . WORKOS_API_KEY = "workos-secret" ;
129+ env . OIDC_ISSUER = undefined ;
130+ env . OIDC_CLIENT_ID = undefined ;
131+ env . OIDC_CLIENT_SECRET = undefined ;
126132 env . CAP_ALLOWED_SIGNUP_DOMAINS = undefined ;
127133 env . CAP_BLOCKED_SIGNUP_DOMAINS = undefined ;
128134 env . TRUSTED_PROXY_AUTH_HEADER = undefined ;
@@ -182,9 +188,10 @@ describe("authOptions", () => {
182188 } ,
183189 ) ;
184190
185- it ( "enables trusted-proxy sign-in when both header and email are configured" , ( ) => {
191+ it ( "enables trusted-proxy sign-in when header, email and secret are configured" , ( ) => {
186192 env . TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner" ;
187193 env . TRUSTED_PROXY_AUTH_EMAIL = PROFILE . email ;
194+ env . TRUSTED_PROXY_AUTH_SECRET = "proxy-secret" ;
188195
189196 expect ( providerIds ( authOptions ( ) ) ) . toContain ( "trusted-proxy" ) ;
190197 } ) ;
@@ -193,17 +200,66 @@ describe("authOptions", () => {
193200 expect ( providerIds ( authOptions ( ) ) ) . not . toContain ( "trusted-proxy" ) ;
194201 } ) ;
195202
196- it . each ( [ "TRUSTED_PROXY_AUTH_HEADER" , "TRUSTED_PROXY_AUTH_EMAIL" ] as const ) (
203+ it . each ( [
204+ "TRUSTED_PROXY_AUTH_HEADER" ,
205+ "TRUSTED_PROXY_AUTH_EMAIL" ,
206+ "TRUSTED_PROXY_AUTH_SECRET" ,
207+ ] as const ) (
197208 "does not expose a partially configured trusted-proxy provider when %s is missing" ,
198209 ( key ) => {
199210 env . TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner" ;
200211 env . TRUSTED_PROXY_AUTH_EMAIL = PROFILE . email ;
212+ env . TRUSTED_PROXY_AUTH_SECRET = "proxy-secret" ;
201213 env [ key ] = undefined ;
202214
203215 expect ( providerIds ( authOptions ( ) ) ) . not . toContain ( "trusted-proxy" ) ;
204216 } ,
205217 ) ;
206218
219+ it . each ( [ undefined , "" , " " ] ) (
220+ "does not register trusted-proxy with only header/email and an unusable secret (%s)" ,
221+ ( secret ) => {
222+ env . TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner" ;
223+ env . TRUSTED_PROXY_AUTH_EMAIL = PROFILE . email ;
224+ env . TRUSTED_PROXY_AUTH_SECRET = secret ;
225+
226+ expect ( providerIds ( authOptions ( ) ) ) . not . toContain ( "trusted-proxy" ) ;
227+ } ,
228+ ) ;
229+
230+ it . each ( [ undefined , "proxy-secret" ] ) (
231+ "preserves independent OIDC sign-in and its verified-email gate with proxy secret %s" ,
232+ async ( secret ) => {
233+ env . OIDC_ISSUER = "https://idp.example" ;
234+ env . OIDC_CLIENT_ID = "oidc-client" ;
235+ env . OIDC_CLIENT_SECRET = "oidc-secret" ;
236+ env . TRUSTED_PROXY_AUTH_HEADER = "x-openhost-is-owner" ;
237+ env . TRUSTED_PROXY_AUTH_EMAIL = PROFILE . email ;
238+ env . TRUSTED_PROXY_AUTH_SECRET = secret ;
239+ const options = authOptions ( ) ;
240+ expect ( providerIds ( options ) ) . toContain ( "oidc" ) ;
241+ expect ( providerIds ( options ) . includes ( "trusted-proxy" ) ) . toBe ( ! ! secret ) ;
242+ const { signIn } = callbacksFor ( options ) ;
243+ const account : Account = {
244+ provider : "oidc" ,
245+ providerAccountId : "oidc-user" ,
246+ type : "oauth" ,
247+ } ;
248+ for ( const email_verified of [ undefined , false , true ] ) {
249+ const profile = { email : PROFILE . email , email_verified } ;
250+ await expect (
251+ signIn ( {
252+ user : AUTHENTICATED_USER ,
253+ account,
254+ profile,
255+ } ) ,
256+ ) . resolves . toBe (
257+ email_verified === true ? true : "/login?error=OidcEmailUnverified" ,
258+ ) ;
259+ }
260+ } ,
261+ ) ;
262+
207263 describe ( "trusted-proxy authorize" , ( ) => {
208264 type Authorize = (
209265 credentials : Record < string , string > | undefined ,
@@ -223,46 +279,81 @@ describe("authOptions", () => {
223279 beforeEach ( ( ) => {
224280 env . TRUSTED_PROXY_AUTH_HEADER = "X-OpenHost-Is-Owner" ;
225281 env . TRUSTED_PROXY_AUTH_EMAIL = "Alex@Company.example" ;
282+ env . TRUSTED_PROXY_AUTH_SECRET = "proxy-secret" ;
226283 } ) ;
227284
228- it ( "signs in the configured user when the proxy sets the header" , async ( ) => {
285+ const validHeaders = {
286+ "x-openhost-is-owner" : "true" ,
287+ "x-trusted-proxy-secret" : "proxy-secret" ,
288+ } ;
289+
290+ it ( "signs in the configured user when the proxy sets both headers" , async ( ) => {
229291 await expect (
230- trustedProxyAuthorize ( ) (
231- { } ,
232- {
233- headers : { "x-openhost-is-owner" : "true" } ,
234- } ,
235- ) ,
292+ trustedProxyAuthorize ( ) ( { } , { headers : validHeaders } ) ,
236293 ) . resolves . toMatchObject ( {
237294 id : AUTHENTICATED_USER . id ,
238295 email : AUTHENTICATED_USER . email ,
239296 } ) ;
240297 } ) ;
241298
242299 it . each ( [
243- [ "header is absent" , { } ] ,
244- [ "header is not exactly 'true'" , { "x-openhost-is-owner" : "1" } ] ,
300+ [ "header is absent" , { "x-trusted-proxy-secret" : "proxy-secret" } ] ,
301+ [
302+ "header is not exactly 'true'" ,
303+ { ...validHeaders , "x-openhost-is-owner" : "1" } ,
304+ ] ,
245305 ] ) ( "fails closed when the %s" , async ( _label , headers ) => {
246306 await expect (
247307 trustedProxyAuthorize ( ) ( { } , { headers } ) ,
248308 ) . resolves . toBeNull ( ) ;
309+ expect ( mocks . select ) . not . toHaveBeenCalled ( ) ;
249310 } ) ;
250311
251312 it ( "fails closed when the configured user does not exist" , async ( ) => {
252313 env . TRUSTED_PROXY_AUTH_EMAIL = "nobody@company.example" ;
253314
254315 await expect (
255- trustedProxyAuthorize ( ) (
256- { } ,
257- {
258- headers : { "x-openhost-is-owner" : "true" } ,
259- } ,
260- ) ,
316+ trustedProxyAuthorize ( ) ( { } , { headers : validHeaders } ) ,
261317 ) . resolves . toBeNull ( ) ;
318+ expect ( mocks . select ) . toHaveBeenCalledOnce ( ) ;
262319 } ) ;
263320
264- it ( "requires the shared secret when one is configured" , async ( ) => {
265- env . TRUSTED_PROXY_AUTH_SECRET = "proxy-secret" ;
321+ it . each ( [ undefined , "" , " " ] ) (
322+ "fails closed if the configured secret becomes unusable after registration (%s)" ,
323+ async ( secret ) => {
324+ const authorize = trustedProxyAuthorize ( ) ;
325+ env . TRUSTED_PROXY_AUTH_SECRET = secret ;
326+ await expect (
327+ authorize ( { } , { headers : validHeaders } ) ,
328+ ) . resolves . toBeNull ( ) ;
329+ expect ( mocks . select ) . not . toHaveBeenCalled ( ) ;
330+ } ,
331+ ) ;
332+
333+ it . each ( [ undefined , "" , "wrong" , "x" . repeat ( 1024 ) , [ "proxy-secret" ] ] ) (
334+ "rejects missing, empty, incorrect or malformed request secrets (%s)" ,
335+ async ( secret ) => {
336+ await expect (
337+ trustedProxyAuthorize ( ) (
338+ { } ,
339+ {
340+ headers : {
341+ ...validHeaders ,
342+ "x-trusted-proxy-secret" : secret ,
343+ } as Record < string , string > ,
344+ } ,
345+ ) ,
346+ ) . resolves . toBeNull ( ) ;
347+ expect ( mocks . select ) . not . toHaveBeenCalled ( ) ;
348+ } ,
349+ ) ;
350+
351+ it ( "rejects requests without headers" , async ( ) => {
352+ await expect ( trustedProxyAuthorize ( ) ( { } , { } ) ) . resolves . toBeNull ( ) ;
353+ expect ( mocks . select ) . not . toHaveBeenCalled ( ) ;
354+ } ) ;
355+
356+ it ( "always requires the shared secret" , async ( ) => {
266357 const authorize = trustedProxyAuthorize ( ) ;
267358
268359 await expect (
@@ -305,6 +396,9 @@ describe("authOptions", () => {
305396 const warn = vi . spyOn ( console , "warn" ) . mockImplementation ( ( ) => { } ) ;
306397
307398 try {
399+ void freshAuthOptions ( ) . providers ;
400+ expect ( warn ) . not . toHaveBeenCalled ( ) ;
401+ env . TRUSTED_PROXY_AUTH_SECRET = "proxy-secret" ;
308402 for ( let i = 0 ; i < 3 ; i ++ ) void freshAuthOptions ( ) . providers ;
309403
310404 expect (
0 commit comments