토스 로그인 Access-Token 발급 실패 (Client network socket disconnected before secure TLS connection was established)

Google Cloud run을 이용해서, 토스 로그인 서버를 구현하고 있습니다.

mTLS 구성까지 완료하였지만, 아래와 같은 오류가 나타납니다.
토스 측에서 연결을 끊었다고 하는데, 뭐가 문제일까요?

엔드포인트 주소:
https://apps-in-toss-api.toss.im/api-partner/v1/apps-in-toss/user/oauth2/generate-token

오류 로그:

서버측 코드:

const options = {
    cert: fs.readFileSync(mtlsCertPath),
    key: fs.readFileSync(mtlsKeyPath),
    rejectUnauthorized: true,
};

export const getAccessToken = onRequest({
    /*secrets: [mtlsCert, mtlsKey],*/
    cors: true
}, async (req, res) => {
    // 1. 요청 메서드 확인 (GET, POST, PUT, DELETE 등)
    if (req.method !== "POST") {
        res.status(405).send("Method Not Allowed");
        return;
    }

    // 2. 데이터 추출 (header, body) 포워딩
    const urlObj = new URL(`${tossAuthBaseURL}/api-partner/v1/apps-in-toss/user/oauth2/generate-token`);
    const headers = req.headers
    const body = req.body;

    // 3. API 엔드포인트로 전송 (mTLS 인증서 포함)
    const apiReq = https.request(
        {
            ...options,
            hostname: urlObj.hostname,
            port: urlObj.port || 443,
            path: urlObj.pathname + urlObj.search,
            method: 'POST',
            headers: headers,
        },
        (apiRes) => {
            let data = '';
            apiRes.on('data', (chunk) => (data += chunk));
            apiRes.on('end', () => {
                // 4. 결과 반환
                res.header("Content-Type", "application/json");
                res.status(apiRes.statusCode!).send(data);
            });
        }
    );
    apiReq.on('error', (e) => console.error(e));
    if (body) {
        apiReq.write(JSON.stringify(body));
    }
    apiReq.end();
});

해결했습니다..
앱인토스 클라이언트에서 올려다주는 http header를 그대로 API 엔드포인트에 넘겨주다보니 불필요한 header가 넘어가서 문제가 있었습니다.

도움 감사드립니다.

저는 직원은 아니고, 급하다고 하셔서 글 적어 봅니다.
GCP 코드가 익숙해 반갑기도 하구요.
TLSClient 샘플 코드를 거의 그대로 사용해 문제가 없어 보여요.
req.body를 확인해 보셔야 할 것 같아요.
잘 안되면 샘플 코드 그대로 한번 사용해 보세요.

예제 한번 더 올립니다.


import TLSClient from './toss/TLSClient.js';
const response = await client.post(`${AUTH_API_BASE}/generate-token`,
 {authorizationCode, referrer,});
1개의 좋아요

해결 전:

// 3. API 엔드포인트로 전송 (mTLS 인증서 포함)
const apiReq = https.request(
  {
    ...options,
    hostname: urlObj.hostname,
    port: urlObj.port || 443,
    path: urlObj.pathname + urlObj.search,
    method: 'POST',
    headers: headers,
  },

해결 후:

// 3. API 엔드포인트로 전송 (mTLS 인증서 포함)
const apiReq = https.request(
  {
    ...options,
    hostname: urlObj.hostname,
    port: urlObj.port || 443,
    path: urlObj.pathname + urlObj.search,
    method: 'POST',
    headers: {
      'Content-Type': 'application/json; charset=utf-8',
      'Content-Length': Buffer.byteLength(JSON.stringify(body)),
      // ...headers,
    },
  },
1개의 좋아요

해결 방법까지 올려주셔서 감사합니다. ㅋㅋ
cloud run/firebase 용으로 로그인과 구매 인증 처리까지 공개로 공유되면 많이 도움 받을 듯. 이 정도면 서버 사용 무료로도 가능하니까요.

Access-Token 발급 부분만 서버측 코드 공유해드립니다.
다른 인증 관련 플로우들 (AccessToken 발급, AccessToken 재발급, 사용자 정보 조회, 로그인 연결 끊기)은
아래 코드베이스로 구축하시면 될듯 합니다.

여기 토스에서 제공한 예제 코드 많이 참고했습니다. (거의 복붙하다시피 했습니다)

ps. 저는 Firebase Functions를 이용해서 아래 코드가 동작됨을 확인했습니다.

// mTLS 파일 위치 정의
const tossAuthBaseURL = "https://apps-in-toss-api.toss.im";
const mtlsCertName: string = "toss_login_public.crt";
const mtlsKeyName: string = "toss_login_private.key";
const mtlsCertPath = path.resolve(__dirname, '..', 'certs', mtlsCertName);
const mtlsKeyPath = path.resolve(__dirname, '..', 'certs', mtlsKeyName);

// mTLS 인증을 위한 파라미터
const options = {
  cert: fs.readFileSync(mtlsCertPath),
  key: fs.readFileSync(mtlsKeyPath),
  rejectUnauthorized: true,
};

export const getAccessToken = onRequest({
  cors: true
}, async (req, res) => {

  // 1. 요청 메서드 확인  
  if (req.method !== "POST") {
    res.status(405).send("Method Not Allowed");
    return;
  }

  // 2. 데이터 추출
  const urlObj = new URL(`${tossAuthBaseURL}/api-partner/v1/apps-in-toss/user/oauth2/generate-token`);
  const headers = req.headers;
  const body = req.body;

  // 3. API 엔드포인트로 전송 (mTLS 인증서 포함)
  const apiReq = https.request(
    {
      ...options,
      hostname: urlObj.hostname,
      port: urlObj.port || 443,
      path: urlObj.pathname + urlObj.search,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json; charset=utf-8',
        'Content-Length': Buffer.byteLength(JSON.stringify(body)),
        // 클라이언트에게서 온 header에는 불필요한 값들이 있으므로, 필요한 header만 추출해서 쓸 것
        // ...headers,      
      },
    },
    (apiRes) => {
      let data = '';
      apiRes.on('data', (chunk) => (data += chunk));
      apiRes.on('end', () => {
        // 4. 앱인토스 클라이언트로 결과 반환        
        res.header("Content-Type", "application/json");
        res.status(apiRes.statusCode!).send(data);
      });
    }
  );

  // 5. 오류 발생시 앱인토스 클라이언트에게 오류 반환
  apiReq.on('error', (e: any) => {
    const code = typeof e?.code === 'string' ? e.code : undefined;

    res.setHeader("Content-Type", "application/json");
    res.status(502).send({
      ok: false,
      message: e instanceof Error ? e.message : "Request failed",
      code,
    });
  });

  // 6. 앱인토스 클라이언트에서 보낸 body 첨부
  if (body) {
    apiReq.write(JSON.stringify(body));
  }
  apiReq.end();
});
1개의 좋아요