Quickstart
Verify API 빠른 시작
기본 연동은 인증 화면과 서버 token exchange route로 구성됩니다.popup callback은 Hosted SDK가 처리합니다.
구현 흐름 지도
세부 코드를 보기 전에 각 파일이 맡는 책임과 보안 경계를 먼저 확인합니다.
Step 01
브라우저 인증 페이지
01Hosted SDK가 PKCE, popup, state 비교와 callback code 수신을 처리합니다.
app/ssafy/page.tsx브라우저에는 public client_id와 issuer만 둡니다.
Step 02
서버 token route
02code와 codeVerifier를 /verify/token으로 교환하고 verification_token을 검증합니다.
app/api/ssafy/verify-token/route.tsclient secret은 confidential client일 때만 서버 환경변수에서 읽습니다.
Step 03
앱 session 저장
03sub, cohort, campus, verifiedAt 같은 최소 claim만 앱 사용자 레코드에 반영합니다.
server/ssafy-verification.tstoken 원문, code, client secret은 저장하지 않습니다.
Step 04
운영 검증
04redirect_uri, request_id, error_code, launch readiness를 한 화면에서 확인합니다.
Developer ConsoleLocal/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 구분
Canonical issuer: https://verify.myknow.xyz
iss 검증값은https://verify.myknow.xyz로 설정합니다. 다른 배포 URL을 issuer로 쓰면 state 검증 이후 token exchange나 JWT 검증이 실패할 수 있습니다.client secret 위치
Next.js가 아니라면
기본 구현 파일
추가 파일: 인증 페이지, 서버 route.환경변수는 별도 준비.
인증 페이지
버튼과 Hosted SDK를 포함하는 사용자 진입 화면
server route
callback code token 교환 및 검증 서버 endpoint
환경변수 설정
브라우저가 읽어야 하는 값과 서버 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
인증 페이지 추가
이 페이지를 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>
);
}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,
});
}인증 결과 저장
앱 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은 브라우저 코드에 넣지 않음