nextauthjs / next-auth

Authentication for the Web.
https://authjs.dev
ISC License
24.11k stars 3.34k forks source link

PKCE code_verifier cookie was missing.. on keycloak OIDC login #11641

Open MarkLyck opened 3 weeks ago

MarkLyck commented 3 weeks ago

Environment

System:
    OS: macOS 14.5
    CPU: (10) arm64 Apple M1 Max
    Memory: 857.22 MB / 32.00 GB
    Shell: 5.9 - /bin/zsh
  Binaries:
    Node: 20.12.0 - /usr/local/bin/node
    Yarn: 1.22.17 - /usr/local/bin/yarn
    npm: 10.5.0 - /usr/local/bin/npm
    pnpm: 9.5.0 - ~/Library/pnpm/pnpm
    bun: 1.1.25 - ~/.bun/bin/bun
  Browsers:
    Brave Browser: 119.1.60.118
    Chrome: 126.0.6478.185
    Safari: 17.5
  npmPackages:
    next: 15.0.0-canary.103 => 15.0.0-canary.103
    next-auth: 5.0.0-beta.18 => 5.0.0-beta.18
    react: 19.0.0-rc-187dd6a7-20240806 => 19.0.0-rc-187dd6a7-20240806

Reproduction URL

https://github.com/MarkLyck/keycloak-pkce-error-reproduction

Describe the issue

I have been stuck on this error for 2 weeks, and could really use some help.

I'm using next-auth@5.0.0-beta.18, with multiple Keycloak providers.

The "main" keycloak provider with direct login works fine both on localhost, and when the app is deployed on Vercel. However we also have two keycloak providers with OIDC logins, which only exists in production. When I deploy my app to production, and attempt to login from these two different sites through Keycloak I get the error:

[31m[auth][error][0m InvalidCheck: PKCE code_verifier cookie was missing.. Read more at https://errors.authjs.dev#invalidcheck

Before this error happens, it looks like the PKCECODEVERIFIER is created succesfully:

[90m[auth][debug]:[0m CREATE_PKCECODEVERIFIER {
"value": "ymKd5vWZdJYvahpKJYSVnCNLvhInp1V7KDQ0oKKVBvg",
"maxAge": 900
}

After the users attempts to login the above error happens and they are redirected to /api/auth/error?error=Configuration With a message that says: "There is a problem with the server configuration. Check the server logs for more information".

auth config;

const KEYCLOAK_CLIENT_ID = 'frontend-standard-flow-app'
const providers = flattenedEnvironments.map((environment) => {
  return Keycloak({
    id: environment.provider,
    clientId: KEYCLOAK_CLIENT_ID,
    clientSecret: 'REQUIRED_BY_NEXT_AUTH_BUT_UNUSED',
    issuer: `${environment.keycloakURL}/realms/${environment.keycloakRealm}`,
  })
})

export const { handlers, auth, signIn, signOut } = NextAuth(() => {
  return {
    basePath: '/api/auth',
    trustHost: true,
    secret: process.env.AUTH_SECRET,
    providers: providers,
    debug: true, // process.env.NEXT_AUTH_DEBUG === 'true',
    cookies: {
      pkceCodeVerifier: {
        name: 'next-auth.pkce.code_verifier',
        options: {
          httpOnly: true,
          sameSite: 'none',
          path: '/',
          secure: true,
        },
      },
    },
    callbacks: {
      async session({ session, token }) {
        try {
          if (token) {
            const decodedJWT = parseJwt(token.access_token as string)

            // @ts-expect-error - token.error is added in the jwt callback
            session.error = token.error
            session.user = {
              // @ts-expect-error - token.user is added in the jwt callback
              ...token.user,
              roles: decodedJWT.realm_access?.roles,
            }
            session.token = {
              access_token: token.access_token as string,
              refresh_token: token.refresh_token as string,
              expires_at: token.expires_at as number,
            }
          }

          return session
        } catch (error) {
          console.error('🛑 session callback ERROR:', error)
          return session
        }
      },
      async jwt({ token, account, user, profile, trigger }) {
        try {
          if (trigger === 'update') {
            return await refreshAccessToken(token)
          }

          if (account) {
            const userProfile: User = {
              ...user,
              user_name: profile?.preferred_username as string,
              allowed_company_uids: profile?.allowed_company_uids as string,
              id: token.sub,
            }

            // First login, save the `access_token`, `refresh_token`, and other
            // details into the JWT token.
            // This is returning the enhanced "token"
            return {
              provider: account.provider,
              id_token: account.id_token,
              access_token: account.access_token,
              expires_at: Math.floor(
                Date.now() / 1000 + (account.expires_in as number),
              ),
              refresh_token: account.refresh_token,
              user: userProfile,
            }
          }

          // @ts-expect-error - token.expires_at exists
          if (Date.now() < token.expires_at * 1000) {
            // Subsequent logins, if the `access_token` is still valid, return the JWT
            return token
          }

          // Subsequent logins, if the `access_token` has expired, try to refresh it
          if (typeof token.refresh_token !== 'string') {
            throw new Error('Missing refresh token')
          }

          const decodedRefreshToken = parseJwt(token.refresh_token as string)

          if (decodedRefreshToken.exp < Date.now() / 1000) {
            return { ...token, error: 'REFRESH_TOKEN_EXPIRED' as const }
          }

          try {
            return await refreshAccessToken(token)
          } catch (_err) {
            // The error property is used to force sign-out if the refresh token is invalid
            return { ...token, error: 'REFRESH_ACCESS_TOKEN_ERROR' as const }
          }
        } catch (error) {
          console.error('🛑 jwt callback ERROR:', error)
          // @ts-expect-error - can be any error
          return { ...token, error: error?.message }
        }
      },
    },
    events: {
      async signOut(data: { token: JWT } | Record<string, unknown>) {
        try {
          const APP_CONFIG = await getServerAppConfig()

          const logOutUrl = new URL(
            `${APP_CONFIG.KEYCLOAK_URL}/realms/${APP_CONFIG.KEYCLOAK_REALM}/protocol/openid-connect/logout`,
          )
          // @ts-expect-error - token.id_token is added in the jwt callback
          logOutUrl.searchParams.set('id_token_hint', data.token.id_token)
          await fetch(logOutUrl)
        } catch (error) {
          console.error('🛑 signOut event ERROR:', error)
        }
      },
    },
  }
})

signIn function with idp_hint:

import { cookies } from 'next/headers'

import { signIn } from '@/auth'
import { getServerAppConfig } from '@/common/config/serverConfig'

export async function GET() {
  const APP_CONFIG = await getServerAppConfig()

  const providerOverride = cookies().get('provider_override')?.value
  const { PROVIDER, IDP_HINT } = APP_CONFIG

  await signIn(
    providerOverride ?? PROVIDER,
    undefined,
    { identity_provider: IDP_HINT }
  )

  return <div />
}

How to reproduce

I created a minimal reproduction, but it requires setting up a 3rd party OIDC system to reproduce the issue, so it's no small task. 😞

I'm replacing a client-side keycloak system with next-auth. Everything besides adding next-auth is the exact same, and my 3rd party OIDC login through keycloak works fine in current production, but broken when I deploy next-auth

Expected behavior

User should be logged in and redirect to /

MarkLyck commented 3 weeks ago

I patched the next-auth and @auth/core libraries with some more logs. This time i also deleted all of my browser history and cookies before the test.

Here's the full sequence of logs I gathered leading up to the error:

correct idp hint

🔈 ~ next-auth / signIn / authorizationParams: { kc_idp_hint: 'eas', identity_provider: 'eas', idp_hint: 'eas' }
🔈 ~ @auth / Auth / internalRequest.url: URL {
  href: 'https://sso.yyy.xxx.com/api/auth/signin/yyy-sso-xxx?kc_idp_hint=eas&identity_provider=eas&idp_hint=eas',
  origin: 'https://sso.rogers.colonynetworks.com/',
  protocol: 'https:',
  username: '',
  password: '',
  host: 'sso.yyy.xxx.com',
  hostname: 'sso.yyy.xxx.com',
  port: '',
  pathname: '/api/auth/signin/yyy-sso-xxx',
  search: '?kc_idp_hint=eas&identity_provider=eas&idp_hint=eas',
  searchParams: URLSearchParams { 'kc_idp_hint' => 'eas', 'identity_provider' => 'eas', 'idp_hint' => 'eas' },
  hash: ''
}
🔈 ~ @auth / Auth / internalRequest.url.searchParams: URLSearchParams { 'kc_idp_hint' => 'eas', 'identity_provider' => 'eas', 'idp_hint' => 'eas' }

correct keycloak URL

🔈 ~ @auth / actions / signIn / signInUrl: https://sso.yyy.xxx.com/api/auth/signin

correct provider

🔈 ~ @auth / actions / getAuthorizationUrl / provider: {
  id: 'yyy-sso-xxx',
  name: 'Keycloak',
  type: 'oidc',
  style: { brandColor: '#428bca' },
  clientId: 'frontend-standard-flow-app',
  clientSecret: 'REQUIRED_BY_NEXT_AUTH_BUT_UNUSED',
  issuer: 'https://sso.xxx.com/auth/realms/yyy',
  signinUrl: 'https://sso.yyy.xxx.com/api/auth/signin/yyy-sso-xxx',
  callbackUrl: 'https://sso.yyy.xxx.com/api/auth/callback/yyy-sso-xxx',
  redirectProxyUrl: undefined,
  wellKnown: 'https://sso.xxx.com/auth/realms/yyy/.well-known/openid-configuration',
  authorization: undefined,
  token: undefined,
  checks: [ 'pkce' ],
  userinfo: undefined,
  profile: [Function: re],
  account: [Function: rt]
}
🔈 ~ @auth / actions / getAuthorizationUrl / url: undefined
🔈 ~ @auth / actions / getAuthorizationUrl / url.searchParams: URLSearchParams {}
🔈 ~ @auth / actions / getAuthorizationUrl / redirect_uri: https://sso.yyy.xxx.com/api/auth/callback/yyy-sso-xxx
✅ ~ @auth / actions / getAuthorizationUrl / pkce check is happening: true
🔐 ~ @auth / actions / getAuthorizationUrl / checks.pkce.create {
  debug: true,
  pages: {},
  theme: { colorScheme: 'auto', logo: '', brandColor: '', buttonText: '' },
  basePath: '/api/auth',
  trustHost: true,
  secret: 'brA/qMwPxzJiy13rkpSWtJQ3HIb+bh+yCCl3FH5C8hU=',
  providers: [ all of my providers were listed here ]

seems to create the PKCE code verifier correctly

[90m[auth][debug]:[0m CREATE_PKCECODEVERIFIER {
  "value": "0xVG98aVaKu-F-46k0oqvDBuYlSZIhMSwb3WRZXkVzA",
  "maxAge": 900
}

pkce has a vale in getAuthorizationUrl, but it doesn't match the one generated above?

🔐 ~ @auth / actions / getAuthorizationUrl / pkce value MCROqQtkYxoQoxKNmm61tce9CdyqhDjybhaocyXrwgI

pkce cookie exists in getAuthorizationUrl, but the value is different yet again?

🔐 ~ @auth / actions / getAuthorizationUrl / pkce cookie {
  name: 'next-auth.pkce.code_verifier',
  value: 'eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoienJlMGlVeGZZcnpVSGI1Z3dzUlRONDM3MHR6bk9jVkFQZWt4a3JQMmZWa1ZsVGtZUENWMG4ydE1hVmozcXpRWHhVbl80TERDdjZSeXlVWmV3NEZYRncifQ..ZhAOB0nor1lRcMFbEuTYVQ.seIqoaqyIOk1svcTyvvgVxJvv9gUCT8txRPcDv14Ea7x99hEpBSPmD0seui1m_zl4w52Tr5WhItYq-lvFF62A9dukHLZfyq78BbK9QMWBTaL48mcm_f4feDFPS-oXLRTRam7KnyIeBfhJhFjMubP5h9YtMuGmYvGK1jR8irhf76dxrkWp9aN4nSlwQ1_lDsX.r5IzWQsnDn66qsKlwJx6kdkvQjdunjrn6EsOnZTBXvI',
  options: {
    httpOnly: true,
    sameSite: 'none',
    path: '/',
    secure: true,
    maxAge: 900,
    expires: 2024-08-20T21:23:28.056Z
  }
}

in the code challenge, the value matches one of the previous pkce values

🔐 ~ @auth / actions / getAuthorizationUrl / code_challenge MCROqQtkYxoQoxKNmm61tce9CdyqhDjybhaocyXrwgI
🔈 ~ @auth / actions / getAuthorizationUrl / authParams: URLSearchParams {
  'response_type' => 'code',
  'client_id' => 'frontend-standard-flow-app',
  'redirect_uri' => 'https://sso.yyy.xxx.com/api/auth/callback/yyy-sso-xxx',
  'kc_idp_hint' => 'eas',
  'identity_provider' => 'eas',
  'idp_hint' => 'eas',
  'code_challenge' => 'MCROqQtkYxoQoxKNmm61tce9CdyqhDjybhaocyXrwgI',
  'code_challenge_method' => 'S256' }

authorization url is ready with pkce cookie

[90m[auth][debug]:[0m authorization url is ready {
  "url": "https://sso.xxx.com/auth/realms/yyy/protocol/openid-connect/auth?response_type=code&client_id=frontend-standard-flow-app&redirect_uri=https%3A%2F%2Fsso.yyy.xxx.com%2Fapi%2Fauth%2Fcallback%2Frogers-sso-colonynetworks&kc_idp_hint=eas&identity_provider=eas&idp_hint=eas&code_challenge=MCROqQtkYxoQoxKNmm61tce9CdyqhDjybhaocyXrwgI&code_challenge_method=S256&scope=openid+profile+email",
  "cookies": [
    {
      "name": "next-auth.pkce.code_verifier",
      "value": "eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2Q0JDLUhTNTEyIiwia2lkIjoienJlMGlVeGZZcnpVSGI1Z3dzUlRONDM3MHR6bk9jVkFQZWt4a3JQMmZWa1ZsVGtZUENWMG4ydE1hVmozcXpRWHhVbl80TERDdjZSeXlVWmV3NEZYRncifQ..ZhAOB0nor1lRcMFbEuTYVQ.seIqoaqyIOk1svcTyvvgVxJvv9gUCT8txRPcDv14Ea7x99hEpBSPmD0seui1m_zl4w52Tr5WhItYq-lvFF62A9dukHLZfyq78BbK9QMWBTaL48mcm_f4feDFPS-oXLRTRam7KnyIeBfhJhFjMubP5h9YtMuGmYvGK1jR8irhf76dxrkWp9aN4nSlwQ1_lDsX.r5IzWQsnDn66qsKlwJx6kdkvQjdunjrn6EsOnZTBXvI",
      "options": {
        "httpOnly": true,
        "sameSite": "none",
        "path": "/",
        "secure": true,
        "maxAge": 900,
        "expires": "2024-08-20T21:23:28.056Z"
      }
    }
  ],
  "provider": {
    "id": "yyy-sso-xxx",
    "name": "Keycloak",
    "type": "oidc",
    "style": {
      "brandColor": "#428bca"
    },
    "clientId": "frontend-standard-flow-app",
    "clientSecret": "REQUIRED_BY_NEXT_AUTH_BUT_UNUSED",
    "issuer": "https://sso.xxx.com/auth/realms/yyy",
    "signinUrl": "https://sso.yyy.xxx.com/api/auth/signin/yyy-sso-xxx",
    "callbackUrl": "https://sso.yyy.xxx.com/api/auth/callback/yyy-sso-xxx",
    "wellKnown": "https://sso.xxx.com/auth/realms/rogers/.well-known/openid-configuration",
    "checks": [
      "pkce"
    ]
  }
}

signIn url seems correct

🔈 ~ next-auth / signIn / url: https://sso.yyy.xxx.com/api/auth/signin/yyy-sso-xxx?kc_idp_hint=eas&identity_provider=eas&idp_hint=eas

callbackUrl is the 3rd party oidc login website, which seems right.

🔈 ~ next-auth / signIn / body: URLSearchParams { 'callbackUrl' => 'https://zzz.yyy.com/' }

@auth/core internalRequest.url

🔈 ~ @auth / Auth / internalRequest.url: URL {
  href: 'https://sso.yyy.xxx.com/api/auth/callback/yyy-sso-xxx?session_state=1bb44980-30db-4859-8ea1-e1b0d8b097fb&iss=https%3A%2F%2Fsso.xxx.com%2Fauth%2Frealms%2Fyyy&code=5ef5cc45-d8bc-49d5-899f-5d07dcd3fd07.1bb44980-30db-4859-8ea1-e1b0d8b097fb.445c02ab-27e4-4dde-93ca-31c6b8f3422f',
  origin: 'https://sso.yyy.xxx.com/',
  protocol: 'https:',
  username: '',
  password: '',
  host: 'sso.yyy.xxx.com',
  hostname: 'sso.yyy.xxx.com',
  port: '',
  pathname: '/api/auth/callback/yyy-sso-colonynetworks',
  search: '?session_state=1bb44980-30db-4859-8ea1-e1b0d8b097fb&iss=https%3A%2F%2Fsso.xxx.com%2Fauth%2Frealms%2Fyyy&code=5ef5cc45-d8bc-49d5-899f-5d07dcd3fd07.1bb44980-30db-4859-8ea1-e1b0d8b097fb.445c02ab-27e4-4dde-93ca-31c6b8f3422f',
  searchParams: URLSearchParams {
    'session_state' => '1bb44980-30db-4859-8ea1-e1b0d8b097fb',
    'iss' => 'https://sso.xxx.com/auth/realms/yyy',
    'code' => '5ef5cc45-d8bc-49d5-899f-5d07dcd3fd07.1bb44980-30db-4859-8ea1-e1b0d8b097fb.445c02ab-27e4-4dde-93ca-31c6b8f3422f' },
  hash: ''
}

in @auth/core oauth checks file, the pkce cookies is empty 😞, this is the cause of the error, but the problem is that it shouldn't be empty.

🛡️ ~ @auth / oauth / checks / pkce / cookies: {}

resCookies are also empty

🛡️ ~ @auth / oauth / checks / pkce / resCookies: []

cookie options

🛡️ ~ @auth / oauth / checks / pkce / options.cookies: {
  sessionToken: {
    name: '__Secure-authjs.session-token',
    options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true }
  },
  callbackUrl: {
    name: '__Secure-authjs.callback-url',
    options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true }
  },
  csrfToken: {
    name: '__Host-authjs.csrf-token',
    options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true }
  },
  pkceCodeVerifier: {
    name: 'next-auth.pkce.code_verifier',
    options: {
      httpOnly: true,
      sameSite: 'none',
      path: '/',
      secure: true,
      maxAge: 900
    }
  },
  state: {
    name: '__Secure-authjs.state',
    options: {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      secure: true,
      maxAge: 900
    }
  },
  nonce: {
    name: '__Secure-authjs.nonce',
    options: { httpOnly: true, sameSite: 'lax', path: '/', secure: true }
  },
  webauthnChallenge: {
    name: '__Secure-authjs.challenge',
    options: {
      httpOnly: true,
      sameSite: 'lax',
      path: '/',
      secure: true,
      maxAge: 900
    }
  }
}

the pkce codeVerifier is undefined which is what causes the final error.

🛡️ ~ @auth / oauth / checks / pkce / codeVerifier: undefined
[31m[auth][error][0m InvalidCheck: PKCE code_verifier cookie was missing.. Read more at [https://errors.authjs.dev#invalidcheck](https://errors.authjs.dev/#invalidcheck)
MarkLyck commented 3 weeks ago

Interestingly, and possibly another bug?

If I try the same login in incognito I get a different error:

[31m[auth][error][0m UnknownAction: Unsupported action. Read more at [https://errors.authjs.dev#unknownaction](https://errors.authjs.dev/#unknownaction)

I'm not writing or calling any actions myself besides the signIn function from next-auth. So I'm not sure how/why that's happening. But only happens in incognito mode.

MarkLyck commented 2 weeks ago

Today I attempted to downgrade to next-auth@4 to see if that worked, or at least provided more information.

Downgrading to next-auth@4 did resolve the PKCE code_verifier cookie was missing.. error. However it's still not functional with the oidc login through a 3rd party.

New behavior:

@balazsorban44 Any idea why this happens?

ThangHuuVu commented 2 weeks ago

@MarkLyck in the browser, do you see the cookies being set successfully before the redirection to Keycloak? (Using next-auth@beta)

MarkLyck commented 2 weeks ago

@ThangHuuVu Thank you for the response! And sorry the reply is a bit late, we can only deploy on Tuesdays and Thursdays to test this.

Here are the browser cookies that get set for me with next-auth@beta (v5)

Screenshot 2024-08-27 at 20 30 26

This screenshot is taken from the /api/auth/error?error=Configuration route after the PKCE missing cookie error is shown.

MarkLyck commented 2 weeks ago

@ThangHuuVu @balazsorban44 Hmmm I believe we might have figured out the reason for this.

For the affected clients, we have an HTTP proxy that opens a new HTTP connection to Vercel but still preserving the URL hostname so Vercel knows who it is. This is required due to their custom SSL certificates.

So when next-auth used the VERCEL domain name ENV variable, it's actually using the wrong domain name for the authentication.

Sadly we cannot use the AUTH_URL environment variable either, since we have multi-tenancy and different clients have different domains all pointing to the same Vercel deployment.

Is there any way to dynamically set the auth URL? It would be easy if we could just set it in the config. But I didn't see this in the docs.

I'm going to attempt dynamically setting the redirectProxyUrl to the actual hostname, instead of the domain Vercel thinks the user is on with our next deployment.

MarkLyck commented 2 weeks ago

@ThangHuuVu @balazsorban44 The above attempt failed.

I tried both setting the redirectProxyUrl to the real domain, and setting redirectTo to the https://realdomain.com/api/auth

But the user is still getting redirected back to the Vercel domain after it authenticates with Keycloak 😞

MarkLyck commented 1 week ago

Found a thread with a similar looking issue: https://github.com/nextauthjs/next-auth/issues/10928

I tried implementing the suggest workaround, and that does make next-auth redirect to the correct URL. But the URLs used in the internal requests within next-auth, including getAuthorizationUrl which I think is used for the PKCE code verifier cookie, is still the incorrect VERCEL domain URL.

// In src/app/api/[...nextauth]/route.ts
import { handlers } from "@/auth"
import { NextRequest } from "next/server"

const reqWithTrustedOrigin = (req: NextRequest): NextRequest => {
    if (process.env.AUTH_TRUST_HOST !== 'true') return req
    const proto = req.headers.get('x-forwarded-proto')
    const host = req.headers.get('x-forwarded-host')
    if (!proto || !host) {
        console.warn("Missing x-forwarded-proto or x-forwarded-host headers.")
        return req
    }
    const envOrigin = `${proto}://${host}`
    const { href, origin } = req.nextUrl
    return new NextRequest(href.replace(origin, envOrigin), req)
}

export const GET = (req: NextRequest) => {
    return handlers.GET(reqWithTrustedOrigin(req))
}

export const POST = (req: NextRequest) => {
    return handlers.POST(reqWithTrustedOrigin(req))
}

So while the URLs are not correct in the browser, the PKCE code verifier cookie missing error still remains.

@ThangHuuVu @balazsorban44 any other ideas we can try to get this working with next-auth? I'm pretty sure the issue is that next-auth uses the wrong URL based on the Vercel environment variable, but I don't see any way to dynamically override this for multi tenancy sites.

jack828 commented 1 week ago

I managed to do something similar:

To get the auth redirect working, I removed NEXTAUTH_URL etc and explicitly set a different one, PRODUCTION_URL to the root URL of the "stable" production deployment. This URL is configured as an allowed callback URL in the OIDC provider (cognito, in my case). I set redirectProxyUrl on all deployments (master/preview):

    redirectProxyUrl: process.env.PRODUCTION_URL + '/api/auth',

And then as part of the configuration:

    providers: [
        {
            id: `cognito`,
            name: `cognito`,
            type: 'oidc',
            clientId: COGNITO_CLIENT_ID,
            clientSecret: COGNITO_CLIENT_SECRET,

            issuer: COGNITO_ISSUER,

            authorization: {
                url: `${COGNITO_DOMAIN}/oauth2/authorize`,
                params: {
                    response_type: 'code',
                    client_id: COGNITO_CLIENT_ID,
                                        // NOTE used here
                    redirect_uri: `${process.env.PRODUCTION_URL}/api/auth/callback/cognito`,
                },
            },

            token: {
                url: `${COGNITO_DOMAIN}/oauth2/token`,
            },

            userinfo: {
                url: `${COGNITO_DOMAIN}/oauth2/userInfo`,
            },

This appears to work just fine when using signIn('cognito') in the client - no additional params required.

The key difference from what i understood the documentation to infer was that all deployments need the redirectProxyUrl set, or it is trying to "consume" your login request on the main deployment instead of forwarding it to the callbackUrl provided in the state parameter.

I understand this is not exactly your use case but i do hope it helps.