Auth.js (구 NextAuth.js)의 `expires_in` 타입 에러 해결방법

Auth.js (구 NextAuth.js) 사용 시 발생하는 `expires_in` 타입 에러를 해결하는 방법을 소개합니다.
Intro

TLDR

제 forked 버전의 oauth4webapi를 사용하시면, `expires_in`이 string 타입이어도 처리해줍니다. forked 버전은 @jacobkim/oauth4webapi에서 확인하실 수 있습니다.

이슈 설명

네이버와 Azure AD B2C 프로바이더를 사용하려 했을때 다음과 같은 에러가 발생했습니다.

[auth][cause]: OperationProcessingError: "response" body "expires_in" property must be a positive number

알고보니 네이버 측에서 OAuth 2.0 스펙을 준수하지 않고, expires_in변수를 number 타입이 아닌, string 으로 반환하고 있었습니다.

Auth.js는 내부적으로 oauth4webapi 이라는 라이브러리를 사용해서 OAuth 2.0 인증을 진행합니다. 하지만 이 라이브러리는 OAuth 2.0 스펙을 엄격히 지키기에, expires_in을 자동으로 number type으로 변환해준다거나 하지 않습니다. 이미 몇번 다른 분들이 expires_in을 자동으로 number 타입으로 변경하는 PR을 올렸지만, 모두 거절당했습니다.

저는 OAuth 2.0 스펙을 철저히 준수하는 oauth4webapi 라이브러리 개발자의 의견을 완전히 존중합니다. 하지만 네이버 측 에서도 사이드 이펙트 때문에 expires_in을 넘버 타입으로 변경할 생각이 없어 보였습니다.

네이버 포럼 스크린샷
네이버 개발자 포럼에 올라온 OAuth 2.0 스펙 관련 문의

해결방안

이 문제를 해결할 수 있는 두가지 방법이 있습니다. 당신에게 가장 적합한 방법을 선택하시면 됩니다.

해결방안 #1 (추천)

만약 제 forked 버전의 oauth4webapi를 사용해도 괜찮다면, 아래 npm 패키지를 사용하시면 됩니다. 제 forked 버전은 `expires_in`이 string 타입이어도 처리하며, forked 레포는 @jacobhjkim/oauth4webapi에서 확인하실 수 있습니다.

NPM의 overrides 기능을 활용하면, oauth4webapi 패키지를 간단하게 덮어쓸 수 있습니다.

// package.json
  ...
            
  "overrides": {
    "oauth4webapi": "npm:@jacobkim/oauth4webapi@^2.10.4"
  }

만약 저처럼 pnpm을 사용하신다면, 아래와 같이 overrides가 아닌 pnpm.overrides를 사용하셔야 합니다. (안그러면 저처럼 한시간동안 pnpm 깃헙 레포에서 이슈들을 검색하고 있을겁니다.)

// package.json
  ...
            
  "pnpm": {
    "overrides": {
      "oauth4webapi": "npm:@jacobkim/oauth4webapi@^2.10.4"
    }
  }

npm은 overrides가 잘 안될 수도 있다고 합니다.

npm CLI는 overrides 기능이 잘 동작하지 않는다는 제보들이 있습니다. 관련 깃헙 이슈들 입니다:

그러니 아직도 npm을 사용하고 계시다면, pnpm을 사용하시는걸 추천합니다. pnpm이 다방면에서 훨씬 좋습니다.


해결방안 #2

만약 당신이 패키지 오버라이딩을 하고 싶지 않으시다거나, 제 fork를 사용하기 께름칙하시다면 다른 해결방안이 있습니다.

여기서 @numman-ali님이 제안하듯, 커스텀 인터셉터를 작성할수도 있습니다. 그리고 특정 provider는 이 인터셉터가 OAuth request를 핸들링 하는거죠.

// app/api/auth/[...nextauth]/route.ts

import { type NextRequest, NextResponse } from "next/server";

import {
  auth,
  GET as AuthGET,
  instagramFetchInterceptor,
  POST as AuthPOST,
} from "@/auth"

const originalFetch = fetch

export async function POST(req: NextRequest) {
  return await AuthPOST(req)
}

export async function GET(req: NextRequest) {
  const url = new URL(req.url)

  if (url.pathname === "/api/auth/callback/naver") {
    const session = await auth()
    if (!session?.user) {
      /* Prevent user creation for instagram access token */
      const signInUrl = new URL("/?modal=sign-in", req.url)
      return NextResponse.redirect(signInUrl)
    }

     /* Intercept the fetch request to patch access_token request to be oauth compliant */
    global.fetch = naverFetchInterceptor(originalFetch)  <- your custom interceptor
    const response = await AuthGET(req)
    global.fetch = originalFetch
    return response
  }

  return await AuthGET(req)
}

개인적으로는 overrides 기능을 사용하는것이, 인터셉터 방식보다 쉽고 덜 머리가 아플것이라고 생각합니다.