Quickstart

Verify API 빠른 시작

기본 연동은 인증 화면과 서버 token exchange route로 구성됩니다. popup callback은 Hosted SDK가 처리합니다.

처음 도입자가 알 것

Developer Portal에서 client를 승인받고, redirect_uri는 아래 인증 페이지 URL로 등록하세요.

승인된 client_id

Developer Portal에서 신청하고 관리자 승인을 받은 값

등록된 redirect_uri

예: https://partner.example.com/ssafy

scope 선택

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

기존 MM 계정 매핑

기존 Mattermost 인증 프로젝트에서 같은 계정을 연결해야 할 때만 ssafy.mattermost_id 추가

jose와 zod 설치

verification_token 검증과 request body 검증에 사용

Canonical issuer: https://verify.myknow.xyz

환경변수와 callback iss 검증값은https://verify.myknow.xyz로 설정합니다. 다른 배포 URL을 issuer로 쓰면 state 검증 이후 token exchange나 JWT 검증이 실패할 수 있습니다.

client secret 위치

confidential client를 쓰는 경우에도 client secret은 서버 환경변수에만 둡니다. 브라우저 컴포넌트에는 절대 넣지 않습니다.

Next.js가 아니라면

React SPA와 Vue/Nuxt는 플랫폼별 웹 문서를 보세요. 모바일 앱은 Swift/Kotlin/Flutter SDK 문서가 아니라 모바일 공식 흐름 문서를 기준으로 구현합니다.플랫폼별 문서 보기

기본 구현 파일

Quickstart에서 새로 추가하는 구현 파일은 인증 페이지와 서버 route입니다. 환경변수 설정은 별도로 준비합니다.

인증 페이지

버튼과 Hosted SDK를 포함하는 사용자 진입 화면

server route

callback code를 token으로 교환하고 검증하는 서버 endpoint

1

환경변수 설정

브라우저가 읽어야 하는 값과 서버 route가 읽어야 하는 값을 분리합니다.

붙일 위치

외부 Next.js 앱의 .env.local

확인 방법

dev server 재시작 후 process.env 값이 비어 있지 않은지 확인

.env.local외부 Next.js 앱 환경변수
NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID=client_example_public
NEXT_PUBLIC_SSAFY_VERIFY_ISSUER=https://verify.myknow.xyz
NEXT_PUBLIC_SSAFY_VERIFY_REDIRECT_URI=https://partner.example.com/ssafy
SSAFY_VERIFY_ISSUER=https://verify.myknow.xyz
SSAFY_VERIFY_CLIENT_ID=client_example_public
SSAFY_VERIFY_REDIRECT_URI=https://partner.example.com/ssafy
# Confidential client에서만 서버 env로 설정합니다.
SSAFY_VERIFY_CLIENT_SECRET=replace-with-issued-confidential-secret

환경변수 이름 설명

`NEXT_PUBLIC_` 값은 브라우저에 노출될 수 있는 공개 설정입니다. client secret은 서버 전용 환경변수로만 설정합니다.

NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID?
브라우저에서 Hosted SDK를 시작할 때 사용하는 client id
NEXT_PUBLIC_SSAFY_VERIFY_ISSUER?
브라우저 callback의 iss 값을 비교할 기준 issuer
NEXT_PUBLIC_SSAFY_VERIFY_REDIRECT_URI?
Hosted SDK가 callback을 받을 등록 redirect URI
SSAFY_VERIFY_ISSUER?
서버 route가 /verify/token과 /verify/jwks를 호출할 기준 issuer
SSAFY_VERIFY_CLIENT_ID?
서버 route가 token exchange와 JWT aud 검증에 사용하는 client id
SSAFY_VERIFY_REDIRECT_URI?
서버 route가 callback redirectUri를 검증할 기준값
SSAFY_VERIFY_CLIENT_SECRET?
confidential client에서만 서버 환경변수로 저장하는 client secret
2

인증 페이지 추가

이 페이지를 redirect_uri로 등록합니다. Hosted SDK가 PKCE, popup, callback 메시지와 state 비교를 처리합니다.

붙일 위치

외부 Next.js 앱의 app/ssafy/page.tsx

확인 방법

버튼을 누르면 popup이 열리고, 인증 후 원래 창으로 결과가 돌아옴

app/ssafy/page.tsx버튼과 자동 popup callback 처리
전체 코드 보기110 lines
"use client";

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

type VerifyResult =
  | { ok: true; verified: boolean; 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;
      }): Promise<{
        code: string | null;
        state: string | null;
        iss: string | null;
        error: string | null;
        error_code: string | null;
        request_id: string | null;
        codeVerifier: string;
      }>;
    };
  }
}

export default function SsafyVerifyPage() {
  const [result, setResult] = useState<VerifyResult | null>(null);
  const expectedIssuer = process.env.NEXT_PUBLIC_SSAFY_VERIFY_ISSUER!;

  async function startVerify() {
    if (!window.ssafyVerify) {
      setResult({ ok: false, errorCode: "SDK_NOT_READY", requestId: null });
      return;
    }

    const callback = await window.ssafyVerify.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) {
      setResult({ ok: false, errorCode: "VERIFY_POPUP_FAILED", requestId: null });
      return;
    }

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

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

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

    if (!response) {
      setResult({ ok: false, errorCode: "VERIFY_NETWORK_FAILED", requestId: null });
      return;
    }

    const responseBody = await response.json().then(
      (value) => value as VerifyResult,
      () => ({ ok: false as const, errorCode: "VERIFY_RESPONSE_INVALID", requestId: null }),
    );
    setResult(responseBody);
  }

  return (
    <main>
      <Script src="https://verify.myknow.xyz/sdk/ssafy-verify.js" strategy="afterInteractive" />
      <h1>SSAFY 구성원 인증</h1>
      <button type="button" onClick={startVerify}>
        SSAFY 인증하기
      </button>
      {result?.ok ? <p role="status">SSAFY 인증이 완료되었습니다.</p> : null}
      {result && !result.ok ? <p role="alert">인증에 실패했습니다. request_id: {result.requestId ?? "없음"}</p> : null}
      {/*
        Local debugging only: inspect result in devtools if needed.
        Do not render pairwise sub, authTime, or raw verification results
        on user-facing screens.
      */}
    </main>
  );
}
3

Token 교환 route 추가

브라우저에서 받은 code와 codeVerifier를 검증한 뒤 서버 route에서 verification_token으로 교환합니다.

붙일 위치

외부 Next.js 앱의 app/api/ssafy/verify-token/route.ts

확인 방법

성공 시 ok: true, verified: true 응답이 내려옴

app/api/ssafy/verify-token/route.ts서버 token exchange와 JWT 검증
전체 코드 보기147 lines
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";
import { NextResponse } from "next/server";
import { z } from "zod";

const issuer = process.env.SSAFY_VERIFY_ISSUER!;
const clientId = process.env.SSAFY_VERIFY_CLIENT_ID!;
const redirectUri = process.env.SSAFY_VERIFY_REDIRECT_URI!;
const expectedAcr = "urn:ssafy:verify:assurance:mattermost-team-dm:v1";
const jwks = createRemoteJWKSet(new URL(`${issuer}/verify/jwks`));

const pkceVerifierPattern = /^[A-Za-z0-9._~-]{43,128}$/;

const callbackBodySchema = z.object({
  code: z.string().min(16).max(256),
  codeVerifier: z.string().regex(pkceVerifierPattern),
  redirectUri: z.string().url().max(2000),
  iss: z.literal(issuer),
}).strict();

const tokenSuccessSchema = z.object({
  verification_token: z.string().min(32).max(8192),
}).passthrough();

const tokenErrorSchema = z.object({
  error: z.object({
    code: z.string().min(1).max(80).optional(),
    request_id: z.string().min(1).max(120).nullable().optional(),
  }).optional(),
}).passthrough();

function publicError(errorCode: string, requestId: string | null, status = 400) {
  return NextResponse.json({ ok: false, errorCode, requestId }, { status });
}

function logSafe(event: string, metadata: Record<string, unknown>) {
  console.warn("ssafy_verify", { event, ...metadata });
}

function readJson(request: Request) {
  return request.json().then(
    (value) => ({ ok: true as const, value }),
    () => ({ ok: false as const }),
  );
}

function readResponseJson(response: Response) {
  return response.json().then(
    (value) => ({ ok: true as const, value }),
    () => ({ ok: false as const }),
  );
}

function validateVerificationClaims(claims: JWTPayload) {
  if (claims.client_id !== clientId) return false;
  if (claims.verified !== true) return false;
  if (typeof claims.sub !== "string" || claims.sub.length === 0) return false;
  if (typeof claims.auth_time !== "number") return false;
  if (!Array.isArray(claims.amr) || !claims.amr.includes("mattermost_dm")) return false;
  if (claims.acr !== expectedAcr) return false;
  return true;
}

export async function POST(request: Request) {
  const rawBody = await readJson(request);
  if (!rawBody.ok) {
    return publicError("INVALID_REQUEST", null, 400);
  }

  const parsedBody = callbackBodySchema.safeParse(rawBody.value);
  if (!parsedBody.success) {
    return publicError("INVALID_REQUEST", null, 400);
  }

  const body = parsedBody.data;
  if (body.redirectUri !== redirectUri) {
    return publicError("REDIRECT_URI_MISMATCH", null, 400);
  }

  const params = new URLSearchParams({
    grant_type: "verification_code",
    client_id: clientId,
    code: body.code,
    code_verifier: body.codeVerifier,
  });

  if (process.env.SSAFY_VERIFY_CLIENT_SECRET) {
    params.set("client_secret", process.env.SSAFY_VERIFY_CLIENT_SECRET);
  }

  const tokenResponse = await fetch(`${issuer}/verify/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded" },
    body: params,
    cache: "no-store",
  }).then((response) => response, () => null);

  if (!tokenResponse) {
    logSafe("token_endpoint_unreachable", { issuer });
    return publicError("VERIFY_TOKEN_FAILED", null, 502);
  }

  const tokenJson = await readResponseJson(tokenResponse);
  if (!tokenJson.ok) {
    logSafe("token_endpoint_non_json", { status: tokenResponse.status });
    return publicError("VERIFY_TOKEN_FAILED", null, 502);
  }

  if (!tokenResponse.ok) {
    const errorPayload = tokenErrorSchema.safeParse(tokenJson.value);
    return NextResponse.json(
      {
        ok: false,
        errorCode: errorPayload.success ? errorPayload.data.error?.code ?? "VERIFY_TOKEN_FAILED" : "VERIFY_TOKEN_FAILED",
        requestId: errorPayload.success ? errorPayload.data.error?.request_id ?? null : null,
      },
      { status: tokenResponse.status },
    );
  }

  const tokenPayload = tokenSuccessSchema.safeParse(tokenJson.value);
  if (!tokenPayload.success) {
    logSafe("token_endpoint_invalid_shape", { status: tokenResponse.status });
    return publicError("VERIFY_TOKEN_FAILED", null, 502);
  }

  const verified = await jwtVerify(tokenPayload.data.verification_token, jwks, {
    issuer,
    audience: clientId,
  }).then(
    (result) => ({ ok: true as const, claims: result.payload }),
    () => ({ ok: false as const }),
  );

  if (!verified.ok || !validateVerificationClaims(verified.claims)) {
    logSafe("verification_token_invalid", { issuer, clientId });
    return publicError("VERIFY_TOKEN_INVALID", null, 401);
  }

  return NextResponse.json({
    ok: true,
    verified: verified.claims.verified === true,
    cohort: verified.claims.ssafy_cohort ?? null,
    campus: verified.claims.ssafy_campus ?? null,
    sub: verified.claims.sub,
    authTime: verified.claims.auth_time,
  });
}
4

인증 결과 저장

앱 DB나 session에는 필요한 최소 claim만 저장합니다. token 원문, code, client secret은 저장하지 않습니다.

붙일 위치

외부 앱의 사용자 저장 로직

확인 방법

사용자 레코드에 ssafyVerified, cohort, campus, verifiedAt만 저장

server/ssafy-verification.ts외부 앱의 사용자 레코드에 최소 인증 결과 저장
type SsafyVerification = {
  sub: string;
  verified: true;
  cohort: string | null;
  campus: string | null;
  authTime: number;
};

export async function saveVerification(userId: string, result: SsafyVerification) {
  await db.partnerUser.update({
    where: { id: userId },
    data: {
      ssafySub: result.sub,
      ssafyVerified: result.verified,
      ssafyCohort: result.cohort,
      ssafyCampus: result.campus,
      ssafyVerifiedAt: new Date(result.authTime * 1000),
    },
  });
}

전용 callback 페이지가 필요한 경우

인증 버튼이 있는 페이지와 redirect_uri를 반드시 분리해야 하는 앱만 추가하세요. 일반적인 도입에서는 필요 없습니다.

app/ssafy/callback/page.tsx선택 사항: popup callback 전용 페이지
전체 코드 보기36 lines
"use client";

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

declare global {
  interface Window {
    ssafyVerify?: {
      handleCallback(options?: { targetOrigin?: string }): {
        code: string | null;
        state: string | null;
        iss: string | null;
        error: string | null;
        error_code: string | null;
        request_id: string | null;
      };
    };
  }
}

export default function SsafyCallbackPage() {
  useEffect(() => {
    if (!window.ssafyVerify) return;
    window.ssafyVerify.handleCallback({
      targetOrigin: window.location.origin,
    });
    window.close();
  }, []);

  return (
    <main>
      <Script src="https://verify.myknow.xyz/sdk/ssafy-verify.js" strategy="beforeInteractive" />
      <p>인증 결과를 전달하는 중입니다.</p>
    </main>
  );
}

서버 route가 내부에서 하는 검증

처음 도입자는 위 token route를 그대로 사용하면 됩니다. 직접 수정할 때도 request body, token response, JWT claim 검증 조건은 유지하세요.

app/api/ssafy/verify-token/route.tsJWT 검증에 필요한 최소 조건
function validateVerificationClaims(claims: JWTPayload) {
  if (claims.client_id !== clientId) return false;
  if (claims.verified !== true) return false;
  if (typeof claims.sub !== "string" || claims.sub.length === 0) return false;
  if (typeof claims.auth_time !== "number") return false;
  if (!Array.isArray(claims.amr) || !claims.amr.includes("mattermost_dm")) return false;
  if (claims.acr !== expectedAcr) return false;
  return true;
}

const verified = await jwtVerify(payload.verification_token, jwks, {
  issuer,
  audience: clientId,
}).then(
  (result) => ({ ok: true as const, claims: result.payload }),
  () => ({ ok: false as const }),
);

if (!verified.ok || !validateVerificationClaims(verified.claims)) {
  return NextResponse.json(
    { ok: false, errorCode: "VERIFY_TOKEN_INVALID", requestId: null },
    { status: 401 },
  );
}

최종 확인 체크리스트

redirect URI

인증 페이지 URL 또는 전용 callback URL과 정확히 일치

grant_type

Verify token exchange는 verification_code 사용

state

Hosted SDK가 callback Promise를 resolve하기 전에 요청 시작 값과 callback 값을 비교

callback iss

SSAFY Verify issuer와 일치할 때만 서버 교환 진행

JWT

verification_token의 iss, aud, exp, sub, client_id, verified, auth_time, amr, acr 검증

secret

client secret은 브라우저 코드에 넣지 않음