Platform Guide

React / Next.js SSAFY 인증 연동

Hosted JS SDK를 로드하고 hook 하나로 popup Verify 흐름을 시작합니다. 서버 route에서만 token exchange와 JWT 검증을 수행합니다.

이 문서가 맞는 경우

플랫폼별 권장 흐름을 먼저 고정한 뒤, code 교환은 모두 앱 서버에서 처리합니다.

Best for
React SPA, Next.js App Router, Vite React
Strategy
Hosted SDK popup + backend token exchange
Callback
등록된 redirect_uri 페이지가 SDK를 로드하면 popup callback이 자동 전달됩니다.
Support
공식 지원: protocol, hosted page, backend exchange contract

준비물

아래 값이 준비되면 공식 지원 흐름에 맞춰 backend token exchange를 연결할 수 있습니다.

issuer

모든 플랫폼에서 canonical issuer/API base URL은 https://verify.myknow.xyz 입니다

client_id

Developer Portal에서 승인된 public 또는 confidential client id

redirect_uri

client 설정에 exact match로 등록된 callback URL 또는 deep link

scope

ssafy.verify는 필수. 기수/캠퍼스/지역은 ssafy.affiliation, 이름은 ssafy.name, 이미지는 ssafy.profile_image 추가

Mattermost id

기존 Mattermost 인증 프로젝트의 계정 매핑이 필요한 경우에만 ssafy.mattermost_id 추가

backend endpoint

앱 서버에서 code와 code_verifier를 받아 /verify/token으로 교환

client secret 금지

브라우저와 모바일 앱에는 client_secret을 넣지 않습니다. confidential client를 쓰는 경우에도 앱 서버 환경변수에만 둡니다.

적용 흐름

SDK 로드

인증 버튼이 있는 페이지에서 Hosted SDK를 afterInteractive로 로드

popup 시작

hook에서 ssafyVerify.verify를 호출하고 PKCE verifier를 유지

서버 교환

callback code와 verifier를 앱 서버 route로 전달

세션 저장

서버가 verification_token 검증 후 최소 인증 결과만 저장

1

React hook 추가

Hosted SDK를 감싼 hook입니다. client secret 없이 popup, PKCE, callback 결과 처리를 담당합니다.

붙일 위치

React/Next.js 앱의 src/lib/use-ssafy-verify.tsx

확인 방법

SDK 로드 전에는 SDK_NOT_READY, 로드 후에는 popup이 열림

src/lib/use-ssafy-verify.tsx브라우저 Verify 흐름을 시작하는 React hook
전체 코드 보기115 lines
"use client";

import Script from "next/script";
import { useState } from "react";

type VerifyResult =
  | { ok: true; verified: true; sub: string; cohort: string | null; campus: string | null; authTime: number }
  | { ok: false; errorCode: string; requestId: string | null };

declare global {
  interface Window {
    ssafyVerify?: {
      verify(options: {
        clientId: string;
        redirectUri: string;
        scopes: string[];
        waitForCallback: true;
        timeoutMs?: number;
      }): Promise<{
        code: string | null;
        state: string | null;
        iss: string | null;
        error: string | null;
        error_code: string | null;
        request_id: string | null;
        codeVerifier: string;
      }>;
    };
  }
}

export function SsafySdkScript() {
  return <Script src="https://verify.myknow.xyz/sdk/ssafy-verify.js" strategy="afterInteractive" />;
}

export function useSsafyVerify() {
  const [status, setStatus] = useState<"idle" | "working" | "verified" | "failed">("idle");
  const [result, setResult] = useState<VerifyResult | null>(null);

  async function verify() {
    const sdk = window.ssafyVerify;
    const expectedIssuer = process.env.NEXT_PUBLIC_SSAFY_VERIFY_ISSUER ?? "https://verify.myknow.xyz";
    if (!sdk) {
      const nextResult = { ok: false as const, errorCode: "SDK_NOT_READY", requestId: null };
      setStatus("failed");
      setResult(nextResult);
      return nextResult;
    }

    setStatus("working");
    const callback = await sdk.verify({
      clientId: process.env.NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID!,
      redirectUri: process.env.NEXT_PUBLIC_SSAFY_VERIFY_REDIRECT_URI!,
      scopes: ["ssafy.verify", "ssafy.affiliation", "ssafy.name"],
      waitForCallback: true,
    }).then((value) => value, () => null);

    if (!callback) {
      const nextResult = { ok: false as const, errorCode: "VERIFY_POPUP_FAILED", requestId: null };
      setStatus("failed");
      setResult(nextResult);
      return nextResult;
    }

    if (callback.error || !callback.code) {
      const nextResult = {
        ok: false as const,
        errorCode: callback.error_code ?? "VERIFY_CANCELLED",
        requestId: callback.request_id,
      };
      setStatus("failed");
      setResult(nextResult);
      return nextResult;
    }

    if (callback.iss !== expectedIssuer) {
      const nextResult = {
        ok: false as const,
        errorCode: "CALLBACK_ISSUER_MISMATCH",
        requestId: callback.request_id,
      };
      setStatus("failed");
      setResult(nextResult);
      return nextResult;
    }

    const response = await fetch("/api/ssafy/verify-token", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        code: callback.code,
        codeVerifier: callback.codeVerifier,
        redirectUri: process.env.NEXT_PUBLIC_SSAFY_VERIFY_REDIRECT_URI!,
        iss: callback.iss,
      }),
    }).then((value) => value, () => null);

    if (!response) {
      const nextResult = { ok: false as const, errorCode: "VERIFY_NETWORK_FAILED", requestId: null };
      setStatus("failed");
      setResult(nextResult);
      return nextResult;
    }

    const nextResult = await response.json().then(
      (value) => value as VerifyResult,
      () => ({ ok: false as const, errorCode: "VERIFY_RESPONSE_INVALID", requestId: null }),
    );
    setStatus(nextResult.ok ? "verified" : "failed");
    setResult(nextResult);
    return nextResult;
  }

  return { status, result, verify };
}
2

인증 버튼 연결

일반 사용자 화면에는 버튼, 진행 상태, 안전한 실패 안내만 노출합니다.

붙일 위치

React/Next.js 앱의 인증 페이지 또는 마이페이지 컴포넌트

확인 방법

성공 시 SSAFY 인증 완료 문구가 표시됨

components/ssafy-verify-button.tsx사용자가 누르는 최소 UI
"use client";

import { SsafySdkScript, useSsafyVerify } from "@/lib/use-ssafy-verify";

export default function SsafyVerifyButton() {
  const { status, result, verify } = useSsafyVerify();

  return (
    <section>
      <SsafySdkScript />
      <button type="button" disabled={status === "working"} onClick={verify}>
        {status === "working" ? "인증 진행 중" : "SSAFY 인증하기"}
      </button>
      {result?.ok ? <p>SSAFY 인증이 완료되었습니다.</p> : null}
      {result && !result.ok ? <p>인증에 실패했습니다. request_id: {result.requestId}</p> : null}
    </section>
  );
}

공통 서버 token exchange 계약

플랫폼이 무엇이든 최종 교환과 verification_token 검증은 앱 서버에서 수행합니다.

Client request
앱 또는 브라우저가 자기 backend로 보내는 JSON
Backend action
backend가 /verify/token에 grant_type=verification_code로 교환
Backend response
앱 세션에 저장해도 되는 최소 인증 결과만 반환
client-to-backend.json앱이 자기 서버에 보내는 body
{
  "code": "code_from_callback",
  "codeVerifier": "pkce_verifier_from_client",
  "redirectUri": "https://partner.example.com/ssafy",
  "iss": "https://verify.myknow.xyz"
}

Client request fields

code?
callback으로 받은 1회성 Verify code
codeVerifier?
PKCE 검증에 필요한 원본 verifier
redirectUri?
외부 앱에 등록된 callback URL
iss?
callback 응답의 issuer
backend-success.json앱 서버가 클라이언트에 돌려주는 최소 결과
{
  "ok": true,
  "verified": true,
  "sub": "pairwise_subject_placeholder",
  "cohort": "15",
  "campus": "서울 캠퍼스",
  "authTime": 1781740800
}

Backend response fields

ok?
외부 앱 backend 처리 성공 여부
verified?
SSAFY 구성원 인증 완료 여부
sub?
client별 pairwise subject
cohort?
SSAFY 기수
campus?
SSAFY 캠퍼스
authTime?
JWT auth_time NumericDate seconds

보안 체크리스트

공식 지원 범위

protocol, hosted page, backend token exchange만 공식 지원

secret 위치

client_secret은 confidential client의 서버 환경변수에만 저장

state 검증

authorize 시작 시 만든 state와 callback state를 반드시 비교

PKCE 보관

code_verifier는 callback 완료 후 서버 교환까지만 짧게 보관

token 검증

verification_token은 서버에서 iss, aud, exp, sub, client_id, verified, auth_time, amr, acr를 검증

저장 최소화

앱 DB에는 verified, sub, cohort, campus, verifiedAt 정도만 저장

Mattermost id

ssafy.mattermost_id는 기존 MM 인증 계정 매핑 목적일 때만 요청하고 저장