SSO 연동 가이드

파트너 서비스(예: 핑거푸시)에 로그인한 사용자가 FINGERPUSH.LINK 콘솔에 별도 회원가입 없이 자동으로 연동·로그인할 수 있는 SSO(Single Sign-On) 연동 방법을 안내합니다.

개요지원 방식
JWT 기반 SSO 리다이렉트 연동입니다. 파트너 서버에서 shared_secret으로 서명한 JWT를 생성하고, 브라우저를 FINGERPUSH.LINK SSO 엔드포인트로 리다이렉트하면 자동으로 계정이 연동됩니다.

Step 1. 연동 신청

SSO 연동을 사용하려면 먼저 파트너 등록이 필요합니다. 아래 정보를 준비하신 후 FINGERPUSH.LINK에 회원가입하고 콘솔의 Q&A 문의를 통해 요청해 주세요.

항목필수설명예시
서비스명 ✅ FINGERPUSH.LINK 관리자 화면에 표시될 서비스 이름 ACME Corp
허용 Origins ✅ SSO 요청을 허용할 도메인. 미등록 시 SSO 전체 차단됩니다. 복수 도메인은 콤마(,)로 구분. 로컬 테스트 시 http://localhost:포트도 포함하세요. https://www.acme.com
SSO 로그인 URL ✅ FINGERPUSH.LINK 오류 발생 시 리다이렉트할 파트너 로그인 페이지 URL. https://로 시작해야 합니다. https://www.acme.com/login?redirect=fplink
담당자 이메일 ✅ 발급된 자격증명(client_id, shared_secret)을 수신할 이메일 주소 dev@acme.com

신청이 완료되면 아래 두 가지 정보가 담당자 이메일로 자동 발송됩니다.

중요shared_secret 보관 주의
shared_secret은 서버에서 안전하게 자동 생성되며, 이메일 발송 후에는 원문을 다시 확인할 수 없습니다. 이메일 수신 즉시 환경변수나 보안 저장소에 저장하세요. 분실 시 담당자에게 재발급을 요청하세요.
발급 항목설명
client_id 파트너 식별 키. JWT의 client_id claim에 포함해야 합니다.
shared_secret JWT 서명용 비밀 키 (HMAC-SHA256). 서버가 자동 생성하여 담당자 이메일로 전송됩니다. 절대 외부에 노출하지 마세요.

Step 2. JWT 생성 및 리다이렉트 연동

파트너 서버에서 JWT를 생성하고, 브라우저를 FINGERPUSH.LINK SSO 엔드포인트로 리다이렉트합니다.

JWT 토큰 구조 (파트너 서버에서 생성)

파트너 서버에서 아래 claims를 포함한 JWT를 생성합니다.

claim필수설명예시
sub ✅ 파트너 서비스의 사용자 고유 ID. 이후 계정 연동 키로 사용됨 user_123
email ✅ 사용자 이메일. 기존 FINGERPUSH.LINK 계정과 연동 또는 신규 가입에 사용 user@example.com
jti ✅ JWT 고유 ID. 파트너 서버가 요청마다 새로 생성. 동일 값 재사용 시 Replay Attack으로 간주해 차단됨 550e8400-e29b-41d4-a716-446655440000
exp ✅ 만료 시각 (Unix timestamp). 발급 후 5분 이내 1716700800
name - 사용자 표시 이름. 신규 계정 생성 시 기본값으로 사용 홍길동
client_id - 등록된 파트너 client_id. 포함 시 요청 client_id와 일치 여부 추가 검증 acme_prod

JWT 생성 예시 (서버 사이드)

Java
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.util.Date;
import java.util.UUID;

String jwt = Jwts.builder()
    .setSubject("user_123")                          // 파트너 서비스 사용자 고유 ID
    .claim("email", "user@example.com")
    .claim("name", "홍길동")
    .setId(UUID.randomUUID().toString())             // jti: 매 요청마다 새 UUID 필수
    .setExpiration(new Date(System.currentTimeMillis() + 5 * 60 * 1000))  // 5분
    .signWith(SignatureAlgorithm.HS256, sharedSecret.getBytes())
    .compact();
Node.js
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');

const token = jwt.sign(
    {
        sub: 'user_123',           // 파트너 서비스 사용자 고유 ID
        email: 'user@example.com',
        name: '홍길동',
        jti: uuidv4(),             // 매 요청마다 새 UUID 필수
    },
    sharedSecret,
    { algorithm: 'HS256', expiresIn: '5m' }
);

SSO 엔드포인트

HTTP
GET https://console.fingerpush.link/sso/callback?token={JWT}&client_id={client_id}

구현 예시 (서버 사이드 리다이렉트)

JSP (Java)
// JWT 생성 후 SSO 엔드포인트로 리다이렉트
String redirectUrl = "https://console.fingerpush.link/sso/callback"
    + "?token=" + URLEncoder.encode(token, "UTF-8")
    + "&client_id=" + URLEncoder.encode(clientId, "UTF-8");
response.sendRedirect(redirectUrl);

Step 3. 연동 흐름 이해

FINGERPUSH.LINK는 아래 순서로 처리합니다.

처리 순서
  1. JWT 서명 검증 (shared_secret 기반 HMAC-SHA256)
  2. jti 중복 사용 체크 (Replay Attack 방지)
  3. sub 값으로 기존 연동 계정 조회 → 있으면 바로 로그인
  4. 이메일로 기존 FINGERPUSH.LINK 계정 조회 → 있으면 이메일 인증 후 계정 연동
  5. 신규 사용자: 약관 동의 화면 → 이메일 인증 → 계정 생성
케이스동작
이미 연동된 사용자 바로 로그인 처리 후 콘솔 이동
이메일이 같은 기존 계정 존재 해당 이메일로 인증 링크 발송 → 클릭 후 계정 연동 (보안 이유로 인증 필요)
완전 신규 사용자 약관 동의 화면 → 가입 후 이메일 인증 → 계정 활성화

URL/Campaign 빠른 연동 샘플

아래 샘플은 API 키를 브라우저에 노출하지 않고 서버 세션에 저장해 사용하는 방식입니다. SSO 동의 완료 후 서버가 Service API를 호출하는 구조를 권장합니다.

중요API 키 보안 원칙
API 키는 브라우저(localStorage, JavaScript 변수)에 저장하지 말고 서버 세션에만 저장하세요. 클라이언트는 파트너 서버 엔드포인트만 호출하고, 파트너 서버가 X-API-KEY로 FINGERPUSH.LINK Service API를 호출해야 합니다.
구분금지 패턴권장 패턴
API 키 보관 브라우저 localStorage/세션스토리지/전역 JS 변수 저장 서버 세션 또는 서버 보안 저장소에만 저장
API 호출 경로 브라우저가 Service API를 직접 호출하며 X-API-KEY 포함 브라우저는 파트너 서버만 호출, 파트너 서버가 Service API 호출
보안 사고 영향 브라우저 디버거/XSS/확장프로그램으로 키 유출 위험 키 미노출로 유출면 축소, 서버 측 감사/차단 정책 적용 가능

적용 순서 (복사 전 먼저 확인)

  1. link.jsp를 SSO 진입 URL에 배치하고 redirect_uri, client_id 검증을 설정합니다.
  2. 기존 로그인 성공 로직(예: loginAction.jsp) 마지막에 login-success-hook.jsp 코드를 삽입합니다.
  3. redirect.jsp를 배치하고 JWT 생성 + /sso/callback 리다이렉트를 연결합니다.
  4. URL/Campaign 기능이 필요하면 shorturl-sample.jsp 패턴으로 서버 프록시 엔드포인트를 추가합니다.
  5. 운영 전 shared_secret과 허용 도메인(redirect_uri host) 설정을 재확인합니다.

파일별 역할

파일역할언제 호출됨
link.jsp SSO 파라미터 저장 + 로그인 페이지 진입 제어 사용자가 "핑거푸시 계정으로 로그인" 버튼 클릭 시
login-success-hook.jsp 로그인 성공 후 SSO 요청인지 판별해 redirect.jsp로 전달 파트너 사이트 로그인 성공 직후
redirect.jsp JWT 생성 후 FINGERPUSH.LINK SSO 콜백으로 리다이렉트 이미 로그인 사용자 또는 로그인 직후
shorturl-sample.jsp 세션 API 키로 URL/Campaign Service API 프록시 호출 연동 완료 후 URL 단축/캠페인 생성 시

검증 체크리스트

  • 미로그인 상태에서 SSO 진입 시 로그인 페이지로 이동한다.
  • 로그인 성공 후 /sso/callback으로 자동 이동한다.
  • 잘못된 redirect_uri 또는 client_id 요청이 차단된다.
  • 브라우저 Network 탭에 X-API-KEY가 노출되지 않는다.
  • URL 단축/캠페인 생성 응답이 파트너 서버를 통해 정상 반환된다.

1) URL 단축 샘플 (서버 프록시)

JSP/Controller (Server)
// 세션에서 API 키 조회
String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
    response.setStatus(401);
    out.print("{\"code\":\"NOT_CONNECTED\",\"error\":\"연동이 필요합니다.\"}");
    return;
}

// 파트너 서버 → Service API 호출
// POST /api/v1/service/urls
conn.setRequestProperty("X-API-KEY", apiKey);
conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
String jsonBody = "{\"url\":\"" + escapedUrl + "\"}";
// 응답의 shortUrl/shortCode를 그대로 프론트에 전달
JavaScript (Client)
// 브라우저는 파트너 서버만 호출 (API 키 미노출)
const formData = new URLSearchParams();
formData.append('originalUrl', originalUrl);

const res = await fetch('/samples/sso/fingerpush-shorturl-sample.jsp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: formData.toString()
});
const data = await res.json();

2) Campaign 생성 샘플 (서버 프록시)

JSP/Controller (Server)
// 세션 API 키로 캠페인 생성
String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
if (apiKey == null || apiKey.isBlank()) {
    response.setStatus(401);
    out.print("{\"code\":\"NOT_CONNECTED\"}");
    return;
}

// POST /api/v1/service/campaigns
conn.setRequestProperty("X-API-KEY", apiKey);
String body = "{" +
    "\"name\":\"" + escapedName + "\"," +
    "\"originalUrl\":\"" + escapedUrl + "\"," +
    "\"utmSource\":\"fingerpush\"," +
    "\"utmMedium\":\"push\"" +
"}";
// alias/id를 프론트에 전달해 /c/{alias} URL 표시
JavaScript (Client)
const formData = new URLSearchParams();
formData.append('action', 'campaign');
formData.append('campaignName', campaignName);
formData.append('campaignUrl', campaignUrl);
formData.append('utmSource', 'fingerpush');
formData.append('utmMedium', 'push');

const res = await fetch('/samples/sso/fingerpush-shorturl-sample.jsp', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: formData.toString()
});
const data = await res.json();

JSP 복사본 샘플 (매뉴얼에서 바로 사용)

아래 4개 코드는 파일로 저장 후 바로 사용할 수 있는 최소 동작 버전입니다. 경로/도메인/시크릿 값은 운영 환경에 맞게 교체하세요.

1) SSO 리다이렉트 샘플 (redirect.jsp)

JSP
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="javax.crypto.Mac,javax.crypto.spec.SecretKeySpec,java.util.Base64,java.nio.charset.StandardCharsets,java.time.Instant,java.util.UUID" %>
<%
String fpUserId = "user_123";
String userEmail = "user@example.com";
String userName = "홍길동";
String clientId = "fingerpush_dev";
String sharedSecret = System.getenv("FPLINK_SHARED_SECRET");

if (sharedSecret == null || sharedSecret.isBlank()) {
    response.sendError(500, "sharedSecret 누락");
    return;
}

long now = Instant.now().getEpochSecond();
long exp = now + 300;
String jti = UUID.randomUUID().toString();

String header = Base64.getUrlEncoder().withoutPadding()
    .encodeToString("{\"alg\":\"HS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8));

String payloadJson = "{"
    + "\"jti\":\"" + jti + "\"," 
    + "\"sub\":\"" + fpUserId + "\"," 
    + "\"email\":\"" + userEmail + "\"," 
    + "\"name\":\"" + userName + "\"," 
    + "\"client_id\":\"" + clientId + "\"," 
    + "\"iat\":" + now + ","
    + "\"exp\":" + exp
    + "}";

String payload = Base64.getUrlEncoder().withoutPadding()
    .encodeToString(payloadJson.getBytes(StandardCharsets.UTF_8));

String signingInput = header + "." + payload;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(sharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = Base64.getUrlEncoder().withoutPadding()
    .encodeToString(mac.doFinal(signingInput.getBytes(StandardCharsets.UTF_8)));

String token = signingInput + "." + signature;
String consoleUrl = "https://fpl-stage-console.kissoft.biz";
String redirectUrl = consoleUrl + "/sso/callback?token="
    + java.net.URLEncoder.encode(token, "UTF-8")
    + "&client_id=" + java.net.URLEncoder.encode(clientId, "UTF-8");

response.sendRedirect(redirectUrl);
%>

2) SSO 링크 진입 샘플 (link.jsp)

JSP
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
String redirectUri = request.getParameter("redirect_uri");
String clientId = request.getParameter("client_id");

if (redirectUri == null || redirectUri.isBlank()) {
    response.sendError(400, "redirect_uri 필요");
    return;
}
if (clientId == null || !clientId.matches("[a-zA-Z0-9_]{1,50}")) {
    response.sendError(400, "client_id 형식 오류");
    return;
}

String host = new java.net.URL(redirectUri).getHost();
if (!host.equals("console.fingerpush.link")
        && !host.equals("fpl-stage-console.kissoft.biz")
        && !host.equals("fpl.fingerpush.link")) {
    response.sendError(403, "허용되지 않은 redirect_uri");
    return;
}

session.setAttribute("SSO_REDIRECT_URI", redirectUri);
session.setAttribute("SSO_CLIENT_ID", clientId);

Object loggedInUser = session.getAttribute("LOGIN_MEMBER");
if (loggedInUser != null) {
    request.getRequestDispatcher("/sso/redirect.jsp").forward(request, response);
    return;
}

response.sendRedirect(request.getContextPath() + "/memberNew.jsp?mode=login");
%>

3) 로그인 성공 후크 샘플 (login-success-hook.jsp)

JSP
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%
String ssoRedirectUri = (String) session.getAttribute("SSO_REDIRECT_URI");
if (ssoRedirectUri != null && !ssoRedirectUri.isEmpty()) {
    session.removeAttribute("SSO_REDIRECT_URI");
    session.removeAttribute("SSO_CLIENT_ID");
    request.getRequestDispatcher("/sso/redirect.jsp").forward(request, response);
    return;
}
// SSO 요청이 아니면 기존 로그인 완료 흐름 유지
// response.sendRedirect("/index.jsp");
%>

4) URL/Campaign 통합 샘플 (shorturl-sample.jsp 최소본)

JSP + JavaScript
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" import="java.net.*,java.io.*,java.nio.charset.StandardCharsets" %>
<%
if ("POST".equalsIgnoreCase(request.getMethod())) {
    response.setContentType("application/json; charset=UTF-8");
    String action = request.getParameter("action");

    if ("saveApiKey".equals(action)) {
        String apiKey = request.getParameter("apiKey");
        if (apiKey == null || apiKey.isBlank()) { response.setStatus(400); out.print("{\"error\":\"API 키 누락\"}"); return; }
        session.setAttribute("FPLINK_API_KEY", apiKey.trim());
        out.print("{\"success\":true}");
        return;
    }

    String apiKey = (String) session.getAttribute("FPLINK_API_KEY");
    if (apiKey == null || apiKey.isBlank()) { response.setStatus(401); out.print("{\"code\":\"NOT_CONNECTED\"}"); return; }

    String apiBase = "https://fpl-stage-console.kissoft.biz";
    String apiPath = "campaign".equals(action) ? "/api/v1/service/campaigns" : "/api/v1/service/urls";
    String body = "campaign".equals(action)
        ? ("{\"name\":\"" + request.getParameter("campaignName") + "\",\"originalUrl\":\"" + request.getParameter("campaignUrl") + "\"}")
        : ("{\"url\":\"" + request.getParameter("originalUrl") + "\"}");

    HttpURLConnection conn = (HttpURLConnection) new URL(apiBase + apiPath).openConnection();
    conn.setRequestMethod("POST");
    conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
    conn.setRequestProperty("X-API-KEY", apiKey);
    conn.setDoOutput(true);
    try (OutputStream os = conn.getOutputStream()) { os.write(body.getBytes(StandardCharsets.UTF_8)); }

    InputStream is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
    StringBuilder sb = new StringBuilder();
    if (is != null) {
        try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            String line; while ((line = br.readLine()) != null) sb.append(line);
        }
    }
    response.setStatus(conn.getResponseCode());
    out.print(sb.toString());
    return;
}
%>
<!doctype html>
<html lang="ko"><body>
  <h3>SSO + API키 기반 URL/Campaign 샘플</h3>
  <input id="apiKey" placeholder="동의 후 받은 API 키" />
  <button onclick="saveApiKey()">API 키 저장</button>
  <hr/>
  <input id="url" placeholder="https://example.com" />
  <button onclick="shorten()">URL 단축</button>
  <hr/>
  <input id="campName" placeholder="캠페인명" />
  <input id="campUrl" placeholder="캠페인 URL" />
  <button onclick="campaign()">Campaign 생성</button>
  <pre id="out"></pre>
  <script>
    async function saveApiKey(){
      const fd = new URLSearchParams();
      fd.append('action','saveApiKey');
      fd.append('apiKey', document.getElementById('apiKey').value);
      const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
      document.getElementById('out').textContent = await r.text();
    }
    async function shorten(){
      const fd = new URLSearchParams();
      fd.append('originalUrl', document.getElementById('url').value);
      const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
      document.getElementById('out').textContent = await r.text();
    }
    async function campaign(){
      const fd = new URLSearchParams();
      fd.append('action','campaign');
      fd.append('campaignName', document.getElementById('campName').value);
      fd.append('campaignUrl', document.getElementById('campUrl').value);
      const r = await fetch(location.pathname,{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'},body:fd.toString()});
      document.getElementById('out').textContent = await r.text();
    }
  </script>
</body></html>

보안 주의사항

필수보안 체크리스트
  • JWT는 서버에서만 생성: shared_secret을 클라이언트에 절대 노출하지 마세요.
  • 만료 시간 5분 이내: exp claim을 반드시 설정하세요.
  • jti 고유값: 각 요청마다 새로운 UUID를 사용하세요. 동일 jti 재사용 시 400 오류가 반환됩니다.
  • HTTPS 필수: SSO 엔드포인트는 반드시 HTTPS로 호출하세요.
  • Origin 검증: 파트너 등록 시 입력한 도메인에서만 SSO 요청이 허용됩니다.

오류 코드

오류 메시지원인해결 방법
등록되지 않은 파트너이거나 SSO 설정이 완료되지 않았습니다. client_id가 없거나 shared_secret 미설정 파트너 등록 및 secret 설정 확인
SSO 토큰이 만료되었습니다. exp 초과 서버에서 JWT 재생성 (최대 5분)
이미 사용된 SSO 토큰입니다. jti 재사용 요청마다 새 UUID로 jti 생성
SSO 토큰 서명이 유효하지 않습니다. shared_secret 불일치 발급받은 secret과 동일한 값 사용 확인
JWT client_id가 일치하지 않습니다. JWT 내 client_id claim과 요청 파라미터 불일치 JWT claim과 POST 파라미터를 동일하게 설정
이메일 정보가 없어 회원가입을 진행할 수 없습니다. 신규 사용자인데 email claim 누락 JWT에 이메일 포함 필요

테스트 방법

로컬 개발 환경 테스트

참고로컬 테스트
로컬 환경(localhost)에서도 테스트할 수 있습니다. 파트너 등록 시 allowed_origins에 http://localhost:포트를 포함시켜 요청하세요.

FAQ

Q. 이미 FINGERPUSH.LINK 계정이 있는 사용자는 어떻게 되나요?

JWT의 email과 일치하는 기존 계정이 있으면, 해당 이메일로 계정 연동 인증 메일이 발송됩니다. 사용자가 메일의 링크를 클릭하면 두 계정이 연동되고, 이후부터는 SSO로 바로 로그인됩니다.

Q. shared_secret을 변경하면 기존 사용자에게 영향이 있나요?

이미 연동 완료된 기존 사용자에게는 영향이 없습니다. 변경 후 생성되는 새 JWT부터 새 secret을 사용하면 됩니다.

Q. 사용자가 파트너 서비스 계정을 탈퇴하면 어떻게 되나요?

FINGERPUSH.LINK 계정은 독립적으로 유지됩니다. 필요시 담당자에게 연동 해제를 요청하거나, FINGERPUSH.LINK 콘솔 설정에서 직접 계정을 삭제할 수 있습니다.

Q. 한 사용자가 여러 파트너 서비스와 연동할 수 있나요?

네. 하나의 FINGERPUSH.LINK 계정에 여러 파트너 서비스를 동시에 연동하는 것은 지원되지 않습니다. 파트너 서비스 1개당 FINGERPUSH.LINK 계정 1개가 연결됩니다.