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 금지
적용 흐름
SDK 로드
인증 버튼이 있는 페이지에서 Hosted SDK를 afterInteractive로 로드
popup 시작
hook에서 ssafyVerify.verify를 호출하고 PKCE verifier를 유지
서버 교환
callback code와 verifier를 앱 서버 route로 전달
세션 저장
서버가 verification_token 검증 후 최소 인증 결과만 저장
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 };
}인증 버튼 연결
일반 사용자 화면에는 버튼, 진행 상태, 안전한 실패 안내만 노출합니다.
붙일 위치
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 인증 계정 매핑 목적일 때만 요청하고 저장