# SSAFY Verify > SSAFY 구성원 본인인증을 외부 앱에 연동하기 위한 Verify API. Canonical issuer: https://verify.myknow.xyz Canonical API/docs base URL: https://verify.myknow.xyz Use Verify API for every v1 integration. OIDC login is planned for v2 and is not part of the public v1 quickstart. The recommended basic integration is a verification page plus a server token exchange route. The Hosted SDK creates or receives state, waits for the popup callback message, validates origin and state, and only then resolves the callback Promise. The canonical Hosted SDK URL is https://verify.myknow.xyz/sdk/ssafy-verify.js. No pre-public compatibility alias is supported. Canonical quickstart: - Quickstart: https://verify.myknow.xyz/docs/quickstart - Quickstart Markdown: https://verify.myknow.xyz/docs/quickstart.md - Platform quickstarts: https://verify.myknow.xyz/docs/platforms - React: https://verify.myknow.xyz/docs/platforms/react - Vue: https://verify.myknow.xyz/docs/platforms/vue - Backend token exchange: https://verify.myknow.xyz/docs/platforms/backend - Mobile official scope: https://verify.myknow.xyz/docs/platforms/mobile - Swift iOS deep link notes: https://verify.myknow.xyz/docs/platforms/swift - Kotlin Android deep link notes: https://verify.myknow.xyz/docs/platforms/kotlin - Flutter deep link notes: https://verify.myknow.xyz/docs/platforms/flutter - Full AI context: https://verify.myknow.xyz/llms-full.txt - Verify API: https://verify.myknow.xyz/docs/verify-api - Errors: https://verify.myknow.xyz/docs/errors Important rules: - Use https://verify.myknow.xyz as the only canonical issuer. - Verify token exchange uses grant_type=verification_code. - PKCE S256 and state are required. - Use only https://verify.myknow.xyz/sdk/ssafy-verify.js for Hosted SDK loading. - Hosted SDK verify({ waitForCallback: true }) resolves only after callback state matches the request state. - Partner backend routes must schema-validate code, codeVerifier, redirectUri, and iss before calling /verify/token. - verification_token validation must check iss, aud, exp, sub, client_id, verified, auth_time, amr, and acr. - client secret is server-only and only for confidential clients. - Embedded WebView authorization is Unsupported / Do not use. - callback success contains code, state, iss. - callback iss must match https://verify.myknow.xyz before backend exchange. - /verify/token result.auth_time is an ISO string, while verification_token auth_time is JWT NumericDate seconds. - ssafy.profile_image is separate from ssafy.name and returns picture only when approved and requested. - SSAFY Verify original API errors use error.code and error.request_id; partner app examples convert them to errorCode and requestId for their own frontend. - SSAFY Verify original public errors contain error.code and error.request_id; partner app responses may expose errorCode and requestId. - ssafy.mattermost_id is optional and should be requested only for migration or mapping from an existing Mattermost-authenticated project. - Mattermost raw user.id is the stable provider user id; username is a mutable display/login helper. - Cohort is derived from Mattermost team context, not from the raw user object. - OIDC login is planned for v2; do not use OAuth/OIDC endpoints for new public v1 integrations. ## 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`를 사용하세요. ## Hosted SDK path policy | 항목 | 값 | 연동 규칙 | | --- | --- | --- | | Canonical SDK URL | `https://verify.myknow.xyz/sdk/ssafy-verify.js` | 브라우저, React, Vue 연동은 이 경로만 사용합니다. | | Compatibility alias | none | 외부 사용자가 없는 pre-public rename이므로 legacy alias를 제공하지 않습니다. | | Change tracking | pre-public route rename complete | 내부 기록과 새 문서는 canonical SDK URL만 기준으로 작성합니다. | ## 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를 시작해야 합니다. ## Verify API contract tables ### Endpoints | Endpoint | Method | Caller | Purpose | Response or redirect | | --- | --- | --- | --- | --- | | `/sdk/ssafy-verify.js` | GET | Browser | Hosted SDK 로드 | JavaScript | | `/verify/authorize` | GET | Browser/system browser | 사용자가 SSAFY Verify hosted page에서 인증 | registered `redirect_uri`로 이동 | | `/verify/token` | POST | Partner backend | callback `code`와 PKCE verifier를 `verification_token`으로 교환 | JSON | | `/verify/jwks` | GET | Partner backend | `verification_token` 서명 검증용 JWKS 조회 | JSON Web Key Set | ### `/verify/authorize` query parameters | Name | Required | Type | Example | Notes | | --- | --- | --- | --- | --- | | `client_id` | yes | string | `client_example_public` | 승인된 Verify client id입니다. | | `redirect_uri` | yes | absolute URL | `https://partner.example.com/ssafy` | Developer Portal 등록값과 exact match여야 합니다. | | `scope` | yes | space-separated string | `ssafy.verify ssafy.affiliation` | `ssafy.verify`는 필수입니다. | | `state` | yes | opaque string | `state_placeholder` | SDK 또는 앱이 생성하고 callback에서 비교합니다. | | `code_challenge` | yes | PKCE S256 challenge | `challenge_placeholder` | `code_verifier`의 SHA-256 base64url 값입니다. | | `code_challenge_method` | yes | string literal | `S256` | `plain`은 허용하지 않습니다. | | `nonce` | no | opaque string | `nonce_placeholder` | Verify v1에서는 선택입니다. OIDC v2 nonce와 구분하세요. | ### Success callback fields | Name | Type | Example | Notes | | --- | --- | --- | --- | | `code` | string | `code_from_ssafy_verify` | 1회성 code입니다. 저장하지 말고 backend로 전달합니다. | | `state` | string | `state_placeholder` | 요청 시작 값과 정확히 같아야 합니다. | | `iss` | URL string | `https://verify.myknow.xyz` | canonical issuer와 exact match여야 합니다. | ### Failure callback fields | Name | Type | Example | Notes | | --- | --- | --- | --- | | `error` | string | `access_denied` | OAuth-style public error입니다. | | `error_code` | string | `CONSENT_DENIED` | 안정적인 SSAFY Verify error code입니다. | | `request_id` | string | `req_placeholder` | 운영 문의와 로그 추적용입니다. | | `state` | string | `state_placeholder` | 가능한 경우 원래 state를 유지합니다. | ### `/verify/token` form body | Name | Required | Type | Example | Notes | | --- | --- | --- | --- | --- | | `grant_type` | yes | string literal | `verification_code` | Verify API는 `authorization_code`를 사용하지 않습니다. | | `client_id` | yes | string | `client_example_public` | authorize 요청의 client와 같아야 합니다. | | `code` | yes | string | `code_from_callback` | callback에서 받은 1회성 code입니다. | | `code_verifier` | yes | PKCE verifier | `pkce_verifier_placeholder` | token exchange 직후 폐기합니다. | | `client_secret` | confidential only | string | `server_env_only_placeholder` | confidential client의 partner backend에서만 전송합니다. | ### `/verify/token` success JSON | Field | Type | Example | Notes | | --- | --- | --- | --- | | `verification_token` | JWT string | `jwt_placeholder` | JWKS로 검증하고 원문은 저장하지 않습니다. | | `token_type` | string | `Bearer` | 고정값입니다. | | `expires_in` | number | `300` | seconds 단위입니다. | | `scope` | string | `ssafy.verify ssafy.affiliation` | 실제 승인되어 반영된 scope입니다. | | `result.verification_id` | string | `verification_id_placeholder` | 거래 식별자입니다. | | `result.verified` | boolean | `true` | true일 때만 인증 완료로 처리합니다. | | `result.sub` | string | `pairwise_subject_placeholder` | client별 pairwise subject입니다. | | `result.auth_time` | ISO string | `2026-06-18T00:00:00.000Z` | JSON 응답에서만 ISO string입니다. JWT claim은 NumericDate seconds입니다. | ## 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`를 추가하세요. ### Required `verification_token` claim validation | Claim | Required validation | | --- | --- | | `iss` | equals `https://verify.myknow.xyz` | | `aud` | equals partner `client_id` | | `exp` | not expired | | `sub` | non-empty string | | `client_id` | equals partner `client_id` | | `verified` | exactly `true` | | `auth_time` | NumericDate seconds number | | `amr` | includes `mattermost_dm` | | `acr` | equals `urn:ssafy:verify:assurance:mattermost-team-dm:v1` | ### SSAFY Verify original public error JSON | Field | Type | Example | Notes | | --- | --- | --- | --- | | `ok` | boolean | `false` | 실패 응답입니다. | | `error.code` | string | `LOGIN_CODE_INVALID` | 안정적인 error code입니다. | | `error.message` | string | `인증 코드가 만료되었습니다.` | 사용자에게 보여도 되는 안전한 메시지만 포함합니다. | | `error.request_id` | string | `req_placeholder` | 운영 문의와 로그 추적용입니다. | ### Recommended partner app response JSON | Field | Type | Example | Notes | | --- | --- | --- | --- | | `ok` | boolean | `false` | 파트너 앱 backend가 자기 frontend에 내려주는 실패 응답입니다. | | `errorCode` | string | `LOGIN_CODE_INVALID` | SSAFY Verify 원본 `error.code`를 안전하게 매핑한 값입니다. | | `requestId` | string or null | `req_placeholder` | SSAFY Verify 원본 `error.request_id`를 보존합니다. | SSAFY Verify 원본 API는 `error.code`와 `error.request_id`를 반환합니다. Quickstart의 파트너 앱 route는 자기 frontend 편의를 위해 `errorCode`와 `requestId`로 변환합니다. 두 형식을 혼용하지 말고 경계를 명확히 구분하세요. ## Quickstart # 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(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 (
``` ### Vue composable 추가 File: composables/useSsafyVerify.ts Placement: Vue/Nuxt 앱의 composables/useSsafyVerify.ts Check: verify 호출 시 popup이 열리고 result 값이 갱신됨 Purpose: Vue Verify 흐름 상태 관리 ```ts import { ref } from "vue"; type VerifyResult = | { ok: true; verified: true; sub: string; cohort: string | null; campus: string | null; authTime: number } | { ok: false; errorCode: string; requestId: string | null }; type SsafySdk = { verify(options: { clientId: string; redirectUri: string; scopes: string[]; waitForCallback: true; }): Promise<{ code: string | null; error: string | null; iss: string | null; error_code: string | null; request_id: string | null; codeVerifier: string; }>; }; declare global { interface Window { ssafyVerify?: SsafySdk; } } export function useSsafyVerify() { const status = ref<"idle" | "working" | "verified" | "failed">("idle"); const result = ref(null); async function verify() { const sdk = window.ssafyVerify; const expectedIssuer = import.meta.env.VITE_SSAFY_VERIFY_ISSUER ?? "https://verify.myknow.xyz"; if (!sdk) { result.value = { ok: false, errorCode: "SDK_NOT_READY", requestId: null }; status.value = "failed"; return result.value; } status.value = "working"; const callback = await sdk.verify({ clientId: import.meta.env.VITE_SSAFY_VERIFY_CLIENT_ID, redirectUri: import.meta.env.VITE_SSAFY_VERIFY_REDIRECT_URI, scopes: ["ssafy.verify", "ssafy.affiliation", "ssafy.name"], waitForCallback: true, }).then((value) => value, () => null); if (!callback) { result.value = { ok: false, errorCode: "VERIFY_POPUP_FAILED", requestId: null }; status.value = "failed"; return result.value; } if (callback.error || !callback.code) { result.value = { ok: false, errorCode: callback.error_code ?? "VERIFY_CANCELLED", requestId: callback.request_id, }; status.value = "failed"; return result.value; } if (callback.iss !== expectedIssuer) { result.value = { ok: false, errorCode: "CALLBACK_ISSUER_MISMATCH", requestId: callback.request_id, }; status.value = "failed"; return result.value; } 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: import.meta.env.VITE_SSAFY_VERIFY_REDIRECT_URI, iss: callback.iss, }), }).then((value) => value, () => null); if (!response) { result.value = { ok: false, errorCode: "VERIFY_NETWORK_FAILED", requestId: null }; status.value = "failed"; return result.value; } result.value = await response.json().then( (value) => value as VerifyResult, () => ({ ok: false, errorCode: "VERIFY_RESPONSE_INVALID", requestId: null }), ); status.value = result.value.ok ? "verified" : "failed"; return result.value; } return { status, result, verify }; } ``` ### 버튼 컴포넌트 연결 File: components/SsafyVerifyButton.vue Placement: Vue/Nuxt 앱의 components/SsafyVerifyButton.vue Check: 실패 시 request_id가 표시되고 민감 값은 표시되지 않음 Purpose: 사용자용 인증 버튼 ```vue ``` ## Mobile: 모바일 앱 SSAFY 인증 공식 연동 범위 모바일 공식 지원은 SDK 유지보수가 아니라 hosted verification page, protocol, backend exchange contract입니다. WebView 인증은 지원하지 않습니다. Support: official Flow: - authorize URL: 앱이 state와 PKCE challenge를 만들고 hosted page를 시스템 브라우저로 열기 - hosted page: SSAFY Verify가 동의, Mattermost DM 코드, 인증 완료 화면을 처리 - deep link: 등록된 redirect_uri로 code, state, iss만 앱에 전달 - backend exchange: 앱 서버가 /verify/token으로 교환하고 verification_token 검증 ### 지원 범위 확인 File: mobile-support-policy.yml Placement: 모바일 앱 연동 설계 문서 또는 구현 이슈 Check: WebView와 앱 내 client_secret 사용이 제외되어 있음 Purpose: 공식 지원 범위와 unsupported 항목 ```yaml official_support: - Verify API protocol - SSAFY Verify hosted verification page - /verify/token backend exchange contract - verification_token claim contract best_effort_reference: - platform deep link examples - system browser launch examples unsupported: - embedded WebView authorization - persistent or long-term storage of code, token, code_verifier, or client_secret in the app - mobile app direct /verify/token exchange with client_secret allowed_short_lived_transaction_state: - state in app memory or short-lived encrypted transaction storage - code_verifier in the same transaction state until callback/backend exchange - discard state and code_verifier immediately after backend exchange or timeout ``` ### PKCE와 state 수명 관리 File: mobile-pkce-state.yml Placement: 모바일 앱의 인증 transaction 상태 관리 로직 Check: state 불일치 callback은 backend로 전달되지 않음 Purpose: PKCE S256과 state 보관 규칙 ```yaml create mobile verify transaction: state: generate: cryptographically secure random string, 32+ chars store: app memory or short-lived encrypted transaction state only never_store: persistent storage, analytics, crash logs, or long-term cache ttl: 5 minutes or less compare: callback state must exactly equal stored state code_verifier: generate: PKCE allowed chars, 43-128 chars store: same short-lived transaction state as state never_store: persistent storage, analytics, crash logs, or long-term cache send_to_backend: only after callback state matches discard: after backend exchange finishes code_challenge: method: S256 value: base64url(sha256(code_verifier)) without padding ``` ### authorize와 callback 계약 File: mobile-authorize-contract.txt Placement: 모바일 앱의 인증 시작 로직 Check: callback 성공 필드가 code, state, iss로 제한됨 Purpose: 모바일 앱이 구현할 Verify redirect contract ```txt GET https://verify.myknow.xyz/verify/authorize required query: client_id=client_example_public redirect_uri=https://partner.example.com/mobile/ssafy/callback scope=ssafy.verify ssafy.affiliation ssafy.name state=random_state_from_app code_challenge=pkce_s256_challenge code_challenge_method=S256 success callback: redirect_uri?code=code_from_ssafy_verify&state=random_state_from_app&iss=https%3A%2F%2Fverify.myknow.xyz failure callback: redirect_uri?error=access_denied&error_code=CONSENT_DENIED&request_id=req_placeholder&state=random_state_from_app ``` ### deep link 등록 최소 예시 File: mobile-deep-link-contract.yml Placement: iOS Associated Domains, Android intent-filter, hosting .well-known files Check: 등록된 redirect_uri와 실제 deep link URL이 exact match Purpose: 모바일 callback URL 등록 기준 ```yaml iOS Universal Link: redirect_uri: https://partner.example.com/ios/ssafy/callback associated_domains: applinks:partner.example.com association_file: https://partner.example.com/.well-known/apple-app-site-association Android verified App Link: redirect_uri: https://partner.example.com/android/ssafy/callback intent_filter: scheme=https, host=partner.example.com, pathPrefix=/android/ssafy/callback association_file: https://partner.example.com/.well-known/assetlinks.json Fallback custom scheme: redirect_uri: partnerapp://ssafy/callback use_only_when: Universal Link or App Link cannot be used review: collision risk and exact redirect URI registration ``` ### iOS association file 예시 File: apple-app-site-association Placement: https://partner.example.com/.well-known/apple-app-site-association Check: Content-Type은 application/json 또는 application/pkcs7-mime로 제공 Purpose: iOS Universal Link domain association placeholder ```json { "applinks": { "apps": [], "details": [ { "appIDs": [ "TEAMID.com.example.partner" ], "components": [ { "/": "/ios/ssafy/callback" } ] } ] } } ``` ### Android asset links 예시 File: assetlinks.json Placement: https://partner.example.com/.well-known/assetlinks.json Check: package_name과 인증서 fingerprint가 실제 앱 값과 일치 Purpose: Android App Link domain association placeholder ```json [ { "relation": [ "delegate_permission/common.handle_all_urls" ], "target": { "namespace": "android_app", "package_name": "com.example.partner", "sha256_cert_fingerprints": [ "SHA256_CERT_FINGERPRINT_PLACEHOLDER" ] } } ] ``` ### backend exchange 계약 File: mobile-backend-contract.json Placement: 앱 서버의 /api/ssafy/verify-token 같은 endpoint Check: 앱에는 client_secret과 verification_token 원문이 저장되지 않음 Purpose: 모바일 앱과 backend 사이의 최소 계약 ```json { "mobileApp": { "sendsToOwnBackend": { "code": "code_from_callback", "codeVerifier": "pkce_verifier_from_app_memory", "redirectUri": "https://partner.example.com/mobile/ssafy/callback", "iss": "https://verify.myknow.xyz" } }, "backend": { "exchangesWithSsafyVerify": { "endpoint": "https://verify.myknow.xyz/verify/token", "contentType": "application/x-www-form-urlencoded", "grant_type": "verification_code", "client_id": "client_example_public", "code": "code_from_callback", "code_verifier": "pkce_verifier_from_app_memory" } } } ``` ## Backend: Backend token exchange 공통 구현 모든 웹/모바일 앱은 자기 backend에서 /verify/token 교환과 verification_token 검증을 수행합니다. Support: official Flow: - client request: callback state와 iss를 확인한 뒤 code와 codeVerifier를 backend로 전송 - input validation: backend가 code, codeVerifier, redirectUri, iss를 검증 - token exchange: backend가 /verify/token에 grant_type=verification_code로 교환 - JWT validation: JWKS로 verification_token 서명과 iss, aud, exp, sub, client_id, verified, auth_time, amr, acr를 검증 - session save: 최소 claim만 앱 session 또는 DB에 저장 ### client to backend 요청 계약 File: client-to-backend.schema.json Placement: 외부 앱의 /api/ssafy/verify-token 같은 endpoint Check: iss가 SSAFY Verify issuer와 다르면 즉시 거절 Purpose: client callback 결과를 backend로 전달하는 계약 ```json { "method": "POST", "endpoint": "/api/ssafy/verify-token", "contentType": "application/json", "body": { "code": "code_from_callback", "codeVerifier": "pkce_verifier_from_client", "redirectUri": "https://partner.example.com/ssafy", "iss": "https://verify.myknow.xyz" }, "validation": { "code": "required string", "codeVerifier": "required string", "redirectUri": "must be one of registered callback URLs", "iss": "must equal https://verify.myknow.xyz" } } ``` ### SSAFY Verify token exchange File: backend-to-ssafy-verify.txt Placement: 외부 앱 backend service Check: grant_type은 verification_code이고 response는 no-store로 처리 Purpose: /verify/token form body 계약 ```txt POST https://verify.myknow.xyz/verify/token Content-Type: application/x-www-form-urlencoded Cache-Control: no-store grant_type=verification_code client_id=client_example_public code=code_from_callback code_verifier=pkce_verifier_from_client optional confidential client field: client_secret is read from server env only ``` ### verification_token 검증 File: verification-token-validation.yml Placement: 외부 앱 backend token validation module Check: issuer, audience, expiration, subject, client_id, verified, auth_time, amr, acr가 모두 검증됨 Purpose: JWT 검증 필수 조건 ```yaml verification_token validation: jwks: https://verify.myknow.xyz/verify/jwks required: iss: https://verify.myknow.xyz aud: client_example_public exp: must be in the future sub: required pairwise subject client_id: client_example_public verified: true auth_time: NumericDate seconds amr: contains mattermost_dm acr: urn:ssafy:verify:assurance:mattermost-team-dm:v1 reject_if: signature invalid token expired issuer mismatch audience mismatch verified is not true ``` ### 실패 응답 매핑 File: backend-error-mapping.json Placement: 외부 앱 backend error mapper Check: 사용자 화면에는 안전한 message와 request_id만 표시 Purpose: partner app public error response 예시 ```json { "ssafyVerifyError": { "ok": false, "error": { "code": "PKCE_VERIFICATION_FAILED", "message": "PKCE 검증에 실패했습니다.", "request_id": "req_placeholder" } }, "partnerAppResponse": { "ok": false, "errorCode": "PKCE_VERIFICATION_FAILED", "requestId": "req_placeholder", "message": "인증 요청을 다시 시작해주세요." } } ``` ## Swift: iOS Swift deep link 설정 차이 iOS 문서는 공식 SDK가 아니라 모바일 공식 흐름을 iOS에 맞게 적용하기 위한 짧은 설정 메모입니다. Support: reference Flow: - 공식 흐름: /docs/platforms/mobile의 hosted page + backend exchange 계약을 따름 - iOS 차이: ASWebAuthenticationSession으로 시스템 브라우저 인증을 열기 - callback: Universal Link 또는 custom scheme에서 state를 비교한 뒤 backend로 code 전달 ### iOS 설정 메모 File: ios-deep-link.yml Placement: iOS 앱의 Associated Domains와 인증 시작 로직 Check: WebView가 아니라 ASWebAuthenticationSession으로 열림 Purpose: iOS deep link와 시스템 브라우저 선택 기준 ```yaml platform: iOS recommended_callback: Universal Link example_redirect_uri: https://partner.example.com/ios/ssafy/callback system_browser: ASWebAuthenticationSession fallback_callback: partnerapp://ssafy/callback notes: - Universal Link domain must be controlled by the partner app. - callbackURLScheme is only needed for custom scheme fallback. - SSAFY Verify does not officially maintain Swift SDK code. ``` ## Kotlin: Android Kotlin deep link 설정 차이 Android 문서는 공식 SDK가 아니라 모바일 공식 흐름을 Android에 맞게 적용하기 위한 짧은 설정 메모입니다. Support: reference Flow: - 공식 흐름: /docs/platforms/mobile의 hosted page + backend exchange 계약을 따름 - Android 차이: Chrome Custom Tabs로 시스템 브라우저 인증을 열기 - callback: App Link intent에서 state를 비교한 뒤 backend로 code 전달 ### Android 설정 메모 File: android-deep-link.yml Placement: Android 앱의 intent-filter와 assetlinks.json Check: WebView가 아니라 Chrome Custom Tabs로 열림 Purpose: Android deep link와 시스템 브라우저 선택 기준 ```yaml platform: Android recommended_callback: verified App Link example_redirect_uri: https://partner.example.com/android/ssafy/callback system_browser: Chrome Custom Tabs fallback_callback: partnerapp://ssafy/callback notes: - Configure assetlinks.json for verified App Links. - Handle callback intent and compare state before backend exchange. - SSAFY Verify does not officially maintain Kotlin SDK code. ``` ## Flutter: Flutter deep link 설정 차이 Flutter 문서는 공식 패키지가 아니라 모바일 공식 흐름을 Flutter 앱에 맞게 적용하기 위한 짧은 설정 메모입니다. Support: reference Flow: - 공식 흐름: /docs/platforms/mobile의 hosted page + backend exchange 계약을 따름 - Flutter 차이: 외부 브라우저 실행과 deep link stream 처리는 앱이 선택한 패키지로 구현 - callback: state를 비교한 뒤 backend로 code와 code_verifier 전달 ### Flutter 설정 메모 File: flutter-deep-link.yml Placement: Flutter 앱의 iOS/Android deep link 설정과 인증 시작 로직 Check: WebView가 아니라 외부 브라우저로 열림 Purpose: Flutter deep link와 시스템 브라우저 선택 기준 ```yaml platform: Flutter recommended_callback: Universal Link on iOS and verified App Link on Android example_redirect_uri: https://partner.example.com/flutter/ssafy/callback system_browser: url_launcher externalApplication callback_listener: app_links or equivalent deep link package notes: - Keep platform link configuration in iOS and Android native folders. - Compare state before sending code to backend. - SSAFY Verify does not officially maintain Flutter package code. ``` ## Verify Token Response Mattermost id is omitted unless ssafy.mattermost_id is approved and requested. ```json { "verification_token": "jwt_placeholder", "token_type": "Bearer", "expires_in": 300, "scope": "ssafy.verify ssafy.affiliation ssafy.name", "result": { "verification_id": "verification_id_placeholder", "verified": true, "sub": "pairwise_subject_placeholder", "auth_time": "2026-06-18T00:00:00.000Z" } } ``` ## Error Response ```json { "ok": false, "error": { "code": "LOGIN_CODE_INVALID", "message": "인증 코드가 만료되었습니다.", "request_id": "req_placeholder" } } ```