Shell 플레이어 연동 가이드

데스크톱 보안 실행 환경(StarPlayer Shell / StarPlayerApp)을 학습 사이트에서 호출하는 방법입니다. URL scheme 두 가지를 다룹니다.

대상 · LMS/서비스 백엔드 개발자

두 가지 연동 방식

스킴하는 일언제
starplayer://콘텐츠 다운로드 · VOD/라이브 재생을 앱에 직접 지시강의를 내려받거나 원격 매니페스트를 앱에서 바로 재생
starplayerapp://웹 재생 페이지를 보안 셸 안에서 연다 (세션 핸드셰이크)기존 웹 플레이어 화면을 그대로 보안 환경에서 감싸 재생

starplayer:// 딥링크(다운로드/재생/라이브)의 파라미터는 Shell 제품 페이지재생 토큰 연동 가이드를 참고하세요. 이 문서는 starplayerapp:// 보안 셸 웹 재생을 중심으로 설명합니다.

starplayerapp:// — 보안 셸 웹 재생 핸드셰이크

이미 있는 웹 재생 페이지를 보안 셸 안에서 열어, 변조 방지·프로세스 감시·Device Binding을 적용합니다. 페이지가 링크만 만들면 되고, 실제 감싸기는 앱이 처리합니다. 아무나 임의 URL을 여는 것을 막기 위해 1회용 토큰 + HMAC 서명으로 검증합니다.

1
페이지 → 백엔드

POST /api/app-launch-token { play_url, user_id } — 30초 1회용 토큰 발급(IP 바인딩), starplayerapp:// launchUrl 반환

2
페이지 → 앱 실행

location.href = launchUrl (starplayerapp://play?token=&url=&authUrl=…)

3
앱 → 백엔드 검증

앱이 authUrl 로 POST — 헤더 X-App-Signature: HMAC-SHA256(APP_SECRET, token). 서명·만료·IP 검증 후 세션 쿠키 발급

4
보안 셸에서 재생

앱이 받은 세션 쿠키로 play_url(웹 재생 페이지)을 보안 셸 안에서 연다

반드시. HMAC 서명 키(APP_SECRET)는 앱 빌드와 같은 값이어야 하고 서버에만 둡니다. 실행 토큰은 30초·1회용·IP 바인딩이라 재사용·탈취를 막습니다.
주의 — 이 두 엔드포인트는 StarPlayer API가 아닙니다. /api/app-launch-token/api/app-launch-verify고객사가 자사 백엔드에 직접 구현하는 주소입니다(아래 예제). 경로 이름은 예시일 뿐 자유롭게 정해도 되며, drm.starplayer.net 같은 StarPlayer 서버로 호출하는 것이 아닙니다. 앱은 ①에서 내려준 authUrl(= 고객사 자사 /api/app-launch-verify 주소)로 ②를 호출합니다.

① 실행 토큰 발급 — POST /api/app-launch-token (고객사 자사 백엔드)

# 요청 (브라우저 → 고객사 자사 백엔드. StarPlayer 서버 아님)
POST /api/app-launch-token          # ← 자사 도메인의 엔드포인트 (경로 이름은 자유)
Content-Type: application/json

{ "play_url": "https://lms.example.com/player/watch/12345", "user_id": "student001" }

# 응답 (200)
{
  "token": "9f1c...-uuid",
  "expiresIn": 30,
  "launchUrl": "starplayerapp://play?token=9f1c...&url=https%3A%2F%2F...%2Fwatch%2F12345&authUrl=https%3A%2F%2F...%2Fapi%2Fapp-launch-verify&referer=https%3A%2F%2Flms.example.com"
}
파라미터설명
play_url필수. 보안 셸 안에서 열 웹 재생 페이지의 절대 URL
user_id사용자 식별자. 검증 성공 시 세션 사용자로 실린다

launchUrlauthUrl 은 앱이 ②에서 부를 검증 주소(/api/app-launch-verify)입니다. 서버가 자동으로 채웁니다.

② 앱 검증 — POST /api/app-launch-verify (고객사 자사 백엔드)

네이티브 앱이 ①에서 받은 authUrl(= 고객사 자사 /api/app-launch-verify)로 부릅니다(브라우저가 아니므로 Origin 검사 대상이 아님). 앱이 APP_SECRET 으로 서명한 값을 고객사 백엔드가 대조합니다.

# 요청 (앱 → 고객사 자사 백엔드. authUrl 로 지정된 주소)
POST /api/app-launch-verify
Content-Type: application/json
X-App-Signature: <hex(HMAC-SHA256(APP_SECRET, token))>    ← 앱이 서명

{ "token": "9f1c...-uuid" }

# 응답 (200) — 앱이 이 쿠키로 play_url 을 보안 셸에서 연다
{
  "user": { "id": "student001", "name": null },
  "url": "https://lms.example.com/player/watch/12345",
  "auth": {
    "type": "cookie",
    "cookies": [{
      "name": "STAR_SESSION", "value": "sess_...",
      "domain": "lms.example.com", "path": "/",
      "secure": true, "httpOnly": true, "sameSite": "lax", "expire": 86400
    }]
  }
}
검증 항목. ① 서명(X-App-Signature)이 APP_SECRET 으로 맞는지 ② 토큰이 존재하고 만료 전인지(30초) ③ 발급 때와 IP가 같은지. 통과하면 토큰을 즉시 폐기(1회용)하고 세션 쿠키를 내려줍니다. 구버전 앱은 token:timestamp 를 서명하고 X-App-Timestamp 를 함께 보냅니다 — 둘 다 받도록 하세요.

서버측 구현

Classic ASP(VBScript)는 HMAC-SHA256 내장이 없어 별도 컴포넌트가 필요합니다 — .NET 환경이면 위 ASP.NET(C#) 예제를 권장합니다.

// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다.
const crypto = require('crypto');
const APP_SECRET = process.env.APP_SECRET;
const TTL = 30;                          // 초 — 1회용 토큰 수명
const launchTokens = new Map();          // token → { userId, url, ip, expiresAt }

// ① 실행 토큰 발급
app.post('/api/app-launch-token', (req, res) => {
  const { play_url, user_id } = req.body;
  if (!play_url) return res.status(400).json({ error: 'missing_play_url' });

  const token = crypto.randomUUID();
  launchTokens.set(token, { userId: user_id || 'demo', url: play_url,
                            ip: req.ip, expiresAt: Date.now() + TTL * 1000 });

  const q = new URLSearchParams({
    token, url: play_url,
    authUrl: `${req.protocol}://${req.get('host')}/api/app-launch-verify`,
    referer: new URL(play_url).origin,
  });
  res.json({ token, expiresIn: TTL, launchUrl: `starplayerapp://play?${q}` });
});

// ② 앱 서명 검증 (Origin 검사 제외 — 네이티브 앱 호출)
app.post('/api/app-launch-verify', (req, res) => {
  const token = req.body.token;
  const sig   = req.headers['x-app-signature'];
  const ts    = req.headers['x-app-timestamp'];        // 구버전 호환
  const target = (ts != null) ? `${token}:${ts}` : token;
  const expected = crypto.createHmac('sha256', APP_SECRET).update(target).digest('hex');

  if (!sig || !crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)))
    return res.status(403).json({ error: '서명이 유효하지 않습니다.' });

  const sess = launchTokens.get(token);
  if (!sess)                       return res.status(401).json({ error: '유효하지 않은 토큰' });
  if (Date.now() > sess.expiresAt) return res.status(401).json({ error: '만료된 토큰' });
  if (sess.ip !== req.ip)          return res.status(401).json({ error: 'IP 불일치' });
  launchTokens.delete(token);      // 1회용

  const host = new URL(sess.url).hostname;
  res.json({
    user: { id: sess.userId, name: null },
    url: sess.url,
    auth: { type: 'cookie', cookies: [{
      name: 'STAR_SESSION', value: 'sess_' + crypto.randomUUID(),
      domain: host, path: '/', secure: true, httpOnly: true, sameSite: 'lax', expire: 86400,
    }]},
  });
});
<?php
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다.
const TTL = 30;
$APP_SECRET = getenv('APP_SECRET');

// 토큰 저장소는 예시로 파일/DB/redis 등 서버 공유 저장소를 쓴다 (아래는 개념용)
function store_put($token, $data) { /* redis/DB 에 TTL 로 저장 */ }
function store_take($token)       { /* 조회 후 삭제(1회용) 하여 반환, 없으면 null */ }

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$in   = json_decode(file_get_contents('php://input'), true) ?: [];

// ① 실행 토큰 발급
if ($path === '/api/app-launch-token') {
  header('Content-Type: application/json');
  if (empty($in['play_url'])) { http_response_code(400); echo '{"error":"missing_play_url"}'; exit; }

  $token = bin2hex(random_bytes(16));
  store_put($token, [
    'userId' => $in['user_id'] ?? 'demo',
    'url'    => $in['play_url'],
    'ip'     => $_SERVER['REMOTE_ADDR'],
    'exp'    => time() + TTL,
  ]);
  $proto   = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
  $authUrl = $proto . '://' . $_SERVER['HTTP_HOST'] . '/api/app-launch-verify';
  $q = http_build_query([
    'token'   => $token,
    'url'     => $in['play_url'],
    'authUrl' => $authUrl,
    'referer' => parse_url($in['play_url'], PHP_URL_SCHEME) . '://' . parse_url($in['play_url'], PHP_URL_HOST),
  ]);
  echo json_encode(['token' => $token, 'expiresIn' => TTL, 'launchUrl' => "starplayerapp://play?$q"]);
  exit;
}

// ② 앱 서명 검증
if ($path === '/api/app-launch-verify') {
  header('Content-Type: application/json');
  $token = $in['token'] ?? '';
  $sig   = $_SERVER['HTTP_X_APP_SIGNATURE'] ?? '';
  $ts    = $_SERVER['HTTP_X_APP_TIMESTAMP'] ?? null;      // 구버전 호환
  $target   = ($ts !== null) ? "$token:$ts" : $token;
  $expected = hash_hmac('sha256', $target, $APP_SECRET);

  if (!$sig || !hash_equals($expected, $sig)) { http_response_code(403); echo '{"error":"bad_signature"}'; exit; }

  $sess = store_take($token);
  if (!$sess)                       { http_response_code(401); echo '{"error":"invalid_token"}'; exit; }
  if (time() > $sess['exp'])        { http_response_code(401); echo '{"error":"expired"}'; exit; }
  if ($sess['ip'] !== $_SERVER['REMOTE_ADDR']) { http_response_code(401); echo '{"error":"ip_mismatch"}'; exit; }

  $host = parse_url($sess['url'], PHP_URL_HOST);
  echo json_encode([
    'user' => ['id' => $sess['userId'], 'name' => null],
    'url'  => $sess['url'],
    'auth' => ['type' => 'cookie', 'cookies' => [[
      'name' => 'STAR_SESSION', 'value' => 'sess_' . bin2hex(random_bytes(12)),
      'domain' => $host, 'path' => '/', 'secure' => true, 'httpOnly' => true,
      'sameSite' => 'lax', 'expire' => 86400,
    ]]],
  ]);
  exit;
}
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다.
const int TTL = 30;
static readonly string AppSecret = Environment.GetEnvironmentVariable("APP_SECRET");
// 다중 인스턴스면 분산 캐시(Redis 등)로 교체
static readonly ConcurrentDictionary<string, (string userId, string url, string ip, long exp)> Tokens = new();

// ① 실행 토큰 발급
app.MapPost("/api/app-launch-token", (HttpContext ctx, LaunchReq req) => {
    if (string.IsNullOrEmpty(req.play_url)) return Results.BadRequest(new { error = "missing_play_url" });
    var token = Guid.NewGuid().ToString();
    var ip = ctx.Connection.RemoteIpAddress?.ToString();
    Tokens[token] = (req.user_id ?? "demo", req.play_url, ip, DateTimeOffset.UtcNow.ToUnixTimeSeconds() + TTL);
    var origin  = new Uri(req.play_url).GetLeftPart(UriPartial.Authority);
    var authUrl = $"{ctx.Request.Scheme}://{ctx.Request.Host}/api/app-launch-verify";
    var q = $"token={Uri.EscapeDataString(token)}&url={Uri.EscapeDataString(req.play_url)}" +
            $"&authUrl={Uri.EscapeDataString(authUrl)}&referer={Uri.EscapeDataString(origin)}";
    return Results.Ok(new { token, expiresIn = TTL, launchUrl = $"starplayerapp://play?{q}" });
});

// ② 앱 서명 검증
app.MapPost("/api/app-launch-verify", (HttpContext ctx, VerifyReq body) => {
    var sig = ctx.Request.Headers["X-App-Signature"].ToString();
    var ts  = ctx.Request.Headers["X-App-Timestamp"].ToString();     // 구버전 호환
    var target = string.IsNullOrEmpty(ts) ? body.token : $"{body.token}:{ts}";
    using var h = new HMACSHA256(Encoding.UTF8.GetBytes(AppSecret));
    var expected = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(target))).ToLower();
    if (string.IsNullOrEmpty(sig) || !CryptographicOperations.FixedTimeEquals(
            Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(sig)))
        return Results.StatusCode(403);
    if (!Tokens.TryRemove(body.token, out var s))            return Results.StatusCode(401);  // 1회용
    if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > s.exp)   return Results.StatusCode(401);
    if (s.ip != ctx.Connection.RemoteIpAddress?.ToString()) return Results.StatusCode(401);
    var host = new Uri(s.url).Host;
    return Results.Ok(new {
        user = new { id = s.userId, name = (string)null }, url = s.url,
        auth = new { type = "cookie", cookies = new[] { new {
            name = "STAR_SESSION", value = "sess_" + Guid.NewGuid(), domain = host,
            path = "/", secure = true, httpOnly = true, sameSite = "lax", expire = 86400 } } },
    });
});
record LaunchReq(string play_url, string user_id);
record VerifyReq(string token);
# APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다.
import os, time, uuid, hmac, hashlib
from urllib.parse import urlencode, urlparse
from flask import Flask, request, jsonify

app = Flask(__name__)
APP_SECRET = os.environ["APP_SECRET"].encode()
TTL = 30
tokens = {}   # token -> {userId,url,ip,exp}  (다중 인스턴스면 redis 등 공유 저장소)

# ① 실행 토큰 발급
@app.post("/api/app-launch-token")
def launch_token():
    body = request.get_json(silent=True) or {}
    if not body.get("play_url"):
        return jsonify(error="missing_play_url"), 400
    token = uuid.uuid4().hex
    tokens[token] = {"userId": body.get("user_id", "demo"), "url": body["play_url"],
                     "ip": request.remote_addr, "exp": time.time() + TTL}
    u = urlparse(body["play_url"])
    q = urlencode({"token": token, "url": body["play_url"],
                   "authUrl": f"{request.scheme}://{request.host}/api/app-launch-verify",
                   "referer": f"{u.scheme}://{u.netloc}"})
    return jsonify(token=token, expiresIn=TTL, launchUrl=f"starplayerapp://play?{q}")

# ② 앱 서명 검증
@app.post("/api/app-launch-verify")
def launch_verify():
    body = request.get_json(silent=True) or {}
    token = body.get("token", "")
    sig = request.headers.get("X-App-Signature", "")
    ts  = request.headers.get("X-App-Timestamp")        # 구버전 호환
    target = f"{token}:{ts}" if ts is not None else token
    expected = hmac.new(APP_SECRET, target.encode(), hashlib.sha256).hexdigest()
    if not sig or not hmac.compare_digest(expected, sig):
        return jsonify(error="bad_signature"), 403
    sess = tokens.pop(token, None)   # 1회용
    if not sess:                          return jsonify(error="invalid_token"), 401
    if time.time() > sess["exp"]:         return jsonify(error="expired"), 401
    if sess["ip"] != request.remote_addr: return jsonify(error="ip_mismatch"), 401
    host = urlparse(sess["url"]).hostname
    return jsonify(user={"id": sess["userId"], "name": None}, url=sess["url"],
                   auth={"type": "cookie", "cookies": [{"name": "STAR_SESSION",
                        "value": "sess_" + uuid.uuid4().hex, "domain": host, "path": "/",
                        "secure": True, "httpOnly": True, "sameSite": "lax", "expire": 86400}]})
// APP_SECRET 는 앱 빌드와 같은 값. 서버에만 둔다.
static final int TTL = 30;
static final String APP_SECRET = System.getenv("APP_SECRET");
// 다중 인스턴스면 Redis 등 공유 저장소로 교체
static final Map<String, String[]> TOKENS = new ConcurrentHashMap<>();  // token -> [userId,url,ip,exp]

// ① 실행 토큰 발급
@PostMapping("/api/app-launch-token")
public Map<String,Object> token(@RequestBody Map<String,String> req, HttpServletRequest http) {
    String url = req.get("play_url");
    if (url == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "missing_play_url");
    String token = UUID.randomUUID().toString();
    TOKENS.put(token, new String[]{ req.getOrDefault("user_id", "demo"), url,
        http.getRemoteAddr(), String.valueOf(Instant.now().getEpochSecond() + TTL) });
    URI u = URI.create(url);
    String q = "token=" + enc(token) + "&url=" + enc(url)
        + "&authUrl=" + enc(http.getScheme() + "://" + http.getHeader("host") + "/api/app-launch-verify")
        + "&referer=" + enc(u.getScheme() + "://" + u.getAuthority());
    return Map.of("token", token, "expiresIn", TTL, "launchUrl", "starplayerapp://play?" + q);
}

// ② 앱 서명 검증
@PostMapping("/api/app-launch-verify")
public Map<String,Object> verify(@RequestBody Map<String,String> body,
        @RequestHeader(value="X-App-Signature", required=false) String sig,
        @RequestHeader(value="X-App-Timestamp", required=false) String ts,   // 구버전 호환
        HttpServletRequest http) throws Exception {
    String token = body.getOrDefault("token", "");
    String target = (ts != null) ? token + ":" + ts : token;
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(APP_SECRET.getBytes(), "HmacSHA256"));
    String expected = HexFormat.of().formatHex(mac.doFinal(target.getBytes()));
    if (sig == null || !MessageDigest.isEqual(expected.getBytes(), sig.getBytes()))
        throw new ResponseStatusException(HttpStatus.FORBIDDEN);
    String[] s = TOKENS.remove(token);   // 1회용
    if (s == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
    if (Instant.now().getEpochSecond() > Long.parseLong(s[3])) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
    if (!s[2].equals(http.getRemoteAddr())) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED);
    String host = URI.create(s[1]).getHost();
    return Map.of("user", Map.of("id", s[0]), "url", s[1],
        "auth", Map.of("type", "cookie", "cookies", List.of(Map.of(
            "name", "STAR_SESSION", "value", "sess_" + UUID.randomUUID(), "domain", host,
            "path", "/", "secure", true, "httpOnly", true, "sameSite", "lax", "expire", 86400))));
}
static String enc(String v) { return URLEncoder.encode(v, StandardCharsets.UTF_8); }

브라우저측 — 앱 호출

토큰을 받아 launchUrl 로 앱을 엽니다. 앱이 없으면 아무 일도 안 일어나므로 설치 안내로 폴백합니다.

async function openInShell(playUrl, userId) {
  // ① 백엔드에서 1회용 실행 토큰 발급
  const r = await fetch('/api/app-launch-token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ play_url: playUrl, user_id: userId }),
  });
  const data = await r.json();         // { token, expiresIn, launchUrl }

  // ② 앱 실행 — 1.5초 내 화면 전환 없으면 미설치로 보고 설치 안내
  let hidden = false;
  const onHide = () => (hidden = true);
  document.addEventListener('visibilitychange', onHide, { once: true });
  window.addEventListener('pagehide', onHide, { once: true });
  setTimeout(() => {
    if (!hidden && document.visibilityState === 'visible'
        && confirm('StarPlayer 앱이 설치되어 있지 않은 것 같습니다. 설치 페이지로 이동할까요?')) {
      location.href = 'https://shell.starplayer.net';   // 설치 안내
    }
  }, 1500);
  location.href = data.launchUrl;      // starplayerapp://play?…
}

자주 겪는 문제

증상원인해결
검증에서 "서명이 유효하지 않습니다"APP_SECRET 이 앱 빌드와 다름앱과 서버의 시크릿을 같은 값으로 맞춘다
"만료된 토큰"발급 후 30초 초과링크 생성 직후 바로 앱을 호출한다
"IP 불일치"토큰 발급·검증의 클라이언트 IP가 다름프록시 뒤라면 X-Forwarded-For 를 신뢰하도록 설정
토큰 저장소가 비어 재검증 실패인스턴스별 메모리 저장다중 인스턴스면 Redis 등 공유 저장소를 쓴다
링크를 눌러도 앱이 안 열림앱 미설치설치 안내(shell.starplayer.net)로 폴백

적용 전 체크리스트

APP_SECRET앱 빌드와 동일하고 서버에만 있다
☐ 실행 토큰은 30초·1회용·IP 바인딩으로 관리한다
☐ 토큰 저장소가 다중 인스턴스에서 공유된다 (Redis/DB)
☐ 서명 검증은 tokentoken:timestamp(구버전)를 모두 받는다
play_url 은 절대 URL, 세션 쿠키 도메인은 그 호스트에 맞춘다
☐ 앱 미설치 시 설치 안내가 뜬다