Quickstart

Verify API 빠른 시작

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

구현 흐름 지도

세부 코드를 보기 전에 각 파일이 맡는 책임과 보안 경계를 먼저 확인합니다.

Step 01

브라우저 인증 페이지

01

Hosted SDK가 PKCE, popup, state 비교와 callback code 수신을 처리합니다.

Surfaceapp/ssafy/page.tsx

브라우저에는 public client_id와 issuer만 둡니다.

Step 02

서버 token route

02

code와 codeVerifier를 /verify/token으로 교환하고 verification_token을 검증합니다.

Surfaceapp/api/ssafy/verify-token/route.ts

client secret은 confidential client일 때만 서버 환경변수에서 읽습니다.

Step 03

앱 session 저장

03

sub, cohort, campus, verifiedAt 같은 최소 claim만 앱 사용자 레코드에 반영합니다.

Surfaceserver/ssafy-verification.ts

token 원문, code, client secret은 저장하지 않습니다.

Step 04

운영 검증

04

redirect_uri, request_id, error_code, launch readiness를 한 화면에서 확인합니다.

SurfaceDeveloper Console

Local/Preview/Production redirect와 callback iss를 함께 확인합니다.

처음 도입자가 알 것

Developer Portal에서 App을 만들고 User Auth client를 승인받습니다.redirect_uri는 아래 인증 페이지 URL로 등록하세요.

승인된 App과 User Auth client_id

Developer Portal에서 App을 만들고 사용자 인증용 Public PKCE client를 승인받은 값

등록된 redirect_uri

예: https://partner.example.com/ssafy.Local/Preview/Production 실제 URL을 각각 exact match로 등록

scope 선택

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

기존 MM 계정 매핑

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

jose와 zod 설치

verification_token과 request body 검증용 패키지

App과 credential 구분

하나의 서비스 App 아래에 브라우저 인증용 User Auth client와 backend 호출용 Server API credential을 함께 둘 수 있습니다. 브라우저에는 Public PKCE client_id만 사용하고, Server API credential secret은 서버 환경변수에만 저장합니다.

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 문서가 아니라 모바일 공식 흐름 문서를 기준으로 구현합니다.플랫폼별 문서 보기

기본 구현 파일

추가 파일: 인증 페이지, 서버 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
SSAFY_VERIFY_ISSUER=https://verify.myknow.xyz
SSAFY_VERIFY_CLIENT_ID=client_example_public
SSAFY_VERIFY_REDIRECT_PATH=/ssafy
# Confidential client에서만 서버 env로 설정합니다.
SSAFY_VERIFY_CLIENT_SECRET=replace-with-issued-confidential-secret

환경변수 이름 설명

`NEXT_PUBLIC_` 값은 브라우저에 노출될 수 있는 공개 설정입니다. Hosted SDK의 redirectUri는 현재 `window.location.origin`에서 계산하고, client secret은 서버 전용 환경변수로만 설정합니다.

NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID?
브라우저에서 Hosted SDK를 시작할 때 사용하는 client id
NEXT_PUBLIC_SSAFY_VERIFY_ISSUER?
브라우저 callback의 iss 값을 비교할 기준 issuer
SSAFY_VERIFY_ISSUER?
서버 route가 /verify/token과 /verify/jwks를 호출할 기준 issuer
SSAFY_VERIFY_CLIENT_ID?
서버 route가 token exchange와 JWT aud 검증에 사용하는 client id
SSAFY_VERIFY_REDIRECT_PATH?
서버 route가 현재 배포 origin과 합쳐 redirectUri를 검증할 callback path
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 처리
전체 코드 보기123 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;
        message?: string | null;
        phase?: 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 redirectUri = `${window.location.origin}/ssafy`;

    const callbackResult = await window.ssafyVerify.verify({
      clientId: process.env.NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID!,
      redirectUri,
      scopes: ["ssafy.verify", "ssafy.affiliation", "ssafy.name"],
      waitForCallback: true,
    }).then(
      (value) => ({ ok: true as const, value }),
      (error) => ({ ok: false as const, error: error as { error_code?: string; request_id?: string | null } }),
    );

    if (!callbackResult.ok) {
      const sdkError = callbackResult.error;
      setResult({
        ok: false,
        errorCode: sdkError.error_code ?? "VERIFY_POPUP_FAILED",
        requestId: sdkError.request_id ?? null,
      });
      return;
    }
    const callback = callbackResult.value;

    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,
      }),
    }).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 검증
전체 코드 보기151 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 redirectPath = process.env.SSAFY_VERIFY_REDIRECT_PATH ?? "/ssafy";
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 expectedRedirectUri(request: Request) {
  return new URL(redirectPath, request.url).toString();
}

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 !== expectedRedirectUri(request)) {
    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 전용 페이지
전체 코드 보기38 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;
        message?: string | null;
        phase?: 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은 브라우저 코드에 넣지 않음