# SSAFY Verify Quickstart

이 문서는 Next.js App Router 앱에 Verify API를 빠르게 연동하는 기본 예제입니다.
기본 구성은 `인증 화면`과 `서버 token exchange route`입니다.

## Canonical issuer and domain

| 항목 | 값 | 연동 규칙 |
| --- | --- | --- |
| Canonical issuer | `https://verify.myknow.xyz` | 모든 Verify v1 연동의 `iss`, `issuer`, JWKS 기준값입니다. |
| Canonical API/docs base URL | `https://verify.myknow.xyz` | SDK, `/verify/authorize`, `/verify/token`, `/verify/jwks`, public docs 링크를 만들 때 사용합니다. |
| Exact match rule | `callback.iss === "https://verify.myknow.xyz"` | 다르면 token exchange를 시작하지 않고 로컬 오류로 처리합니다. |

문서를 어떤 배포 URL에서 읽었더라도 환경변수와 예제 코드는 `https://verify.myknow.xyz`를 사용하세요.

## 준비물

- 승인된 client_id
- 등록된 redirect_uri: SDK를 로드하는 인증 페이지 URL
- scope: ssafy.verify는 필수. 기수/캠퍼스/지역은 ssafy.affiliation, 이름은 ssafy.name, 이미지는 ssafy.profile_image
- 기존 Mattermost 인증 프로젝트의 계정 매핑이 필요한 경우에만 ssafy.mattermost_id
- jose와 zod 설치

## Scope to claim mapping

| Scope | Required | Claims | Nullable | Usage condition |
| --- | --- | --- | --- | --- |
| `ssafy.verify` | yes | `verified`, `sub`, `auth_time`, `verification_id`, `amr`, `acr` | no | 모든 Verify 연동에 필요합니다. 외부 앱은 최소 인증 결과만 저장하세요. |
| `ssafy.affiliation` | no | `ssafy_cohort`, `ssafy_campus`, `ssafy_region` | yes | 기수, 캠퍼스, 지역 기준으로 혜택이나 권한을 나눌 때만 요청합니다. |
| `ssafy.name` | no | `name` | yes | 사용자 이름 표시나 기존 회원 정보 매칭이 필요할 때만 요청합니다. |
| `ssafy.profile_image` | no | `picture` | yes | 프로필 이미지를 화면에 표시할 때만 요청합니다. |
| `ssafy.role` | no | `ssafy_role`, `ssafy_role_name` | yes | 교육생/운영진 등 역할 기반 기능이 있을 때만 요청합니다. |
| `ssafy.mattermost_id` | no | `ssafy_mattermost_user_id` | yes | 기존 Mattermost 인증 프로젝트의 계정 매핑이 필요할 때만 요청합니다. |

`ssafy.profile_image`는 이름과 별도 scope입니다. 이름만 필요하면 `ssafy.name`만 요청하고, 이미지가 필요할 때만 `ssafy.profile_image`를 추가하세요.

## 완료하면 얻는 결과

- SSAFY 인증하기 페이지
- /api/ssafy/verify-token route
- 앱 DB 또는 session에 저장할 최소 인증 결과

React/Vue 앱과 모바일 앱은 `/docs/platforms`에서 플랫폼별 문서를 먼저 확인하세요.
모바일 앱은 `/docs/platforms/mobile`의 공식 흐름을 따르고, Swift/Kotlin/Flutter 문서는 deep link 설정 메모로만 참고합니다.

## 1. 환경변수

```bash
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
```

client secret은 confidential client의 서버 환경변수에만 둡니다. 브라우저 코드에는 넣지 않습니다.

환경변수 이름 설명:

- NEXT_PUBLIC_SSAFY_VERIFY_CLIENT_ID: 브라우저에서 Hosted SDK를 시작할 때 사용하는 공개 client id
- NEXT_PUBLIC_SSAFY_VERIFY_ISSUER: callback iss를 비교할 SSAFY Verify issuer
- NEXT_PUBLIC_SSAFY_VERIFY_REDIRECT_URI: Developer Portal에 등록된 callback URL
- SSAFY_VERIFY_ISSUER: 서버 route가 /verify/token과 /verify/jwks를 호출할 기준 issuer
- SSAFY_VERIFY_CLIENT_ID: token exchange와 verification_token aud 검증에 사용하는 client id
- SSAFY_VERIFY_REDIRECT_URI: 서버 route가 redirectUri exact match를 검증할 기준값
- SSAFY_VERIFY_CLIENT_SECRET: confidential client에서만 서버 env에 저장하는 secret. 브라우저, URL, 로그에 노출 금지
- ssafy.mattermost_id: Mattermost raw user object의 id를 ssafy_mattermost_user_id claim으로 받는 선택 scope. 기존 MM 인증 계정 매핑에만 사용

## 2. 인증 페이지

```tsx
"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>
  );
}
```

이 페이지 URL을 redirect_uri로 등록합니다. popup callback과 state 비교는 Hosted SDK가 처리합니다.

## Hosted SDK state responsibility

| SDK 단계 | 책임 | 실패 처리 |
| --- | --- | --- |
| `startVerify()` | `state`와 PKCE `code_verifier`를 생성하거나 호출자가 넘긴 값을 사용합니다. | 생성한 `state`와 `codeVerifier`를 호출자에게 반환합니다. |
| `waitForCallback()` | popup callback의 `postMessage` origin과 `state`를 요청 시작 값과 비교합니다. | origin 또는 state가 맞지 않으면 Promise를 reject합니다. |
| `verify({ waitForCallback: true })` | state 검증을 통과한 callback만 resolve하고 `codeVerifier`를 함께 반환합니다. | timeout, popup 실패, state mismatch는 backend exchange로 넘기지 않습니다. |
| Partner backend | SDK가 검증한 state를 다시 받을 필요는 없지만 `code`, `codeVerifier`, `redirectUri`, `iss`는 schema로 검증합니다. | `iss` 또는 `redirectUri`가 등록값과 다르면 `/verify/token`을 호출하지 않습니다. |

Hosted SDK를 쓰지 않는 웹/모바일 직접 구현은 앱이 저장한 `state`와 callback `state`를 직접 비교한 뒤 backend exchange를 시작해야 합니다.

## 3. Token 교환 route

서버 route는 `/verify/token`을 호출하기 전에 request body schema, callback `iss`, `redirectUri` exact match를 먼저 검증합니다.
`/verify/token` 응답도 JSON shape를 확인한 뒤 `verification_token`의 필수 claim을 검증합니다.

```ts
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. 인증 결과 저장

```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를 분리해야 하는 앱만 아래 페이지를 추가합니다.

```tsx
"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>
  );
}
```

## 최종 확인 체크리스트

- Verify token exchange의 grant_type은 verification_code입니다.
- Hosted SDK가 state와 PKCE verifier를 관리합니다.
- 성공 callback에는 code, state, iss만 포함됩니다.
- callback iss가 SSAFY Verify issuer와 다르면 token exchange를 시작하지 않습니다.
- verification_token은 JWKS로 서명 검증하고 iss, aud, exp, sub, client_id, verified, auth_time, amr, acr를 확인합니다.
- token, code, client secret은 URL, 브라우저 저장소, 로그에 저장하지 않습니다.
- Mattermost id가 필요한 기존 MM 인증 프로젝트만 ssafy.mattermost_id를 요청합니다.
- raw user object의 id는 안정 식별자, username은 변경 가능한 보조 표시값으로 취급합니다.
- cohort는 raw user object가 아니라 수집한 Mattermost team context에서 판단합니다.