# 헬프 센터에서 로그인하게 하기

헬프 센터의 피드백 보드는 누구나 읽을 수 있습니다. 하지만 글을 올리고, 댓글을 달고, 클릭 한 번으로 투표하려면 방문자가 누구인지 알아야 합니다. 로그인 URL이 없으면 방문자가 로그인하는 방법은 앱에서 토큰을 붙여 보드를 여는 링크뿐입니다([공개 보드](https://www.mocco.club/docs/ko/articles/zp45zj-public-board)에서 방법을 볼 수 있습니다).

**로그인 URL**을 설정하면 보드 페이지의 헤더와 입력 양식 옆에 **로그인** 버튼이 나타납니다. 이 버튼은 방문자를 내 사이트의 엔드포인트로 보냅니다. 엔드포인트는 내 앱의 로그인으로 방문자가 누구인지 확인하고, 수명이 짧은 토큰을 붙여 방문자를 원래 페이지로 돌려보냅니다. Mocco는 사용자의 비밀번호를 보지 않으며, 사용자는 Mocco 계정이 없어도 됩니다.

내가 할 일은 엔드포인트 하나를 만들고 그 주소를 Mocco에 설정하는 것입니다. 이 글에서는 엔드포인트가 해야 할 일, Next.js로 만든 전체 예제, 설정 방법을 차례로 설명합니다.

## 준비할 것

- 프로젝트의 **identity secret**. 서버가 토큰에 서명할 때 쓰는 비밀 값입니다. 프로젝트의 **Inbox** 페이지에서 **Set up messenger**를 하면 만들어지고, 한 번만 보여 줍니다([Mocco Messenger로 앱 안에서 문의받기](https://www.mocco.club/docs/ko/articles/ky2kfv-contact-us) 참고). 피드백 보드만 쓰더라도 이 값을 얻으려면 메신저를 한 번 설정해야 합니다.
- 내 앱에 누가 로그인했는지 읽을 수 있는, 내 사이트의 서버 경로.
- 로그인 URL을 설정하려면 워크스페이스의 소유자나 관리자여야 합니다.

## 로그인 흐름

1. 방문자가 `https://example.com/help/12?sort=new` 같은 보드 페이지에서 **로그인**을 누릅니다.
2. 페이지는 탭 전체를 로그인 URL로 보내면서 돌아올 페이지를 함께 넘깁니다.
   `https://example.com/api/help/enter?return=https%3A%2F%2Fexample.com%2Fhelp%2F12%3Fsort%3Dnew&lang=ko`
3. 엔드포인트는 `return`을 확인하고, 방문자가 아직 로그인하지 않았다면 로그인시킨 다음, 프래그먼트에 토큰을 붙여 `302`로 페이지에 돌려보냅니다. `https://example.com/help/12?sort=new#token=<jwt>`
4. 페이지는 토큰을 그 브라우저 탭에 보관하고 새로 고치지 않고 주소에서 지웁니다. 이제 방문자는 글을 올리고, 댓글을 달고, 투표할 수 있습니다.

## 엔드포인트 규약

**요청.** Mocco는 방문자를 다음 주소로 보냅니다.

```text
GET <sign-in URL>?return=<URL>[&prompt=none]&lang=<ko|en>
```

로그인 URL에 이미 쿼리가 있으면 그 뒤에 파라미터가 덧붙습니다.

- `return`은 돌아올 페이지의 절대 https 주소입니다. 페이지의 공개 주소이므로, [내 사이트의 경로](https://www.mocco.club/docs/ko/articles/a2t8mn-own-site) 아래에서는 내 주소가 됩니다. 페이지의 쿼리는 그대로 두되 토큰은 빼며, 프래그먼트는 없습니다.
- `prompt=none`은 "로그인 화면을 보여 주지 말라"는 뜻입니다. 아래의 조용히 확인하기에서 설명합니다.
- `lang`은 페이지의 언어(`ko` 또는 `en`)로, 엔드포인트가 자체 화면을 보여 줄 때 씁니다.

**먼저 `return`을 확인하세요.** 엔드포인트는 방문자를 반드시 내 헬프 센터 페이지로만 돌려보내야 합니다. `return`을 파싱해서 다음을 모두 만족할 때만 받아들입니다.

- `https`를 씁니다.
- 출처(origin)가 콘솔에 나오는 **돌아올 주소** 중 하나와 같고, 경로가 그 주소의 경로와 같거나 `/` 경계에서 그 아래에 있습니다. 경로가 없는 주소는 그 출처 전체를 허용합니다.

예를 들어 `https://example.com/help`를 허용했다면 `https://example.com/help`와 `https://example.com/help/12`는 통과하지만, `https://example.com/helpers`, `//evil.example`, `https://evil.example/help`는 통과하지 않습니다. 통과하지 못한 `return`으로는 절대 보내지 말고, 헬프 홈처럼 내가 정한 고정 페이지로 보내세요. 통과했다면 받은 문자열 그대로가 아니라 파싱한 주소(JavaScript의 `URL.href`)로 리디렉션합니다. 이 확인이 없으면 엔드포인트를 링크한 아무 사이트에나 사용자의 토큰을 넘겨주게 됩니다.

**로그인한 경우.** 프래그먼트에 토큰을 넣어 `return`으로 `302` 응답합니다. `#token=<jwt>`이며, 원래 있던 프래그먼트는 이것으로 바꿉니다.

**로그인하지 않은 경우.**

- `prompt=none`이 있으면 곧바로 `return#signin=none`으로 `302` 응답합니다. 로그인 화면은 보여 주지 않습니다.
- 없으면 내 앱의 로그인을 진행하고, 끝나면 같은 요청으로 돌아와 위와 같이 응답합니다. 사용자가 로그인을 그만두면 `#signin=none`으로 응답합니다.

**토큰.** 프로젝트의 identity secret으로 서명한 HS256 JWT입니다.

| 클레임 | 값 |
|---|---|
| `sub` | 사용자 ID(문자열). `email:`로 시작할 수 없습니다. |
| `exp` | 만료 시각. 지금부터 최대 한 시간까지입니다. 방문자가 페이지에 있는 동안만 쓰이므로 10분이면 충분합니다. |
| `iat` | 서명한 시각. |
| `name` | 선택. 사용자 이름, 1~200자. |
| `email` | 선택. 사용자 이메일 주소, 최대 320자. |

피드백 보드는 `sub`만 씁니다. `name`과 `email`은 Messenger만 읽어서, 누가 문의했는지 팀이 볼 수 있게 합니다. Mocco가 쓸 수 없는 `name`이나 `email`은 거부하지 않고 빼고 처리합니다. `sub`나 `exp`가 없는 토큰, `exp`가 한 시간보다 먼 토큰, 다른 비밀 값으로 서명한 토큰은 거부됩니다.

## 토큰을 프래그먼트에 싣는 이유

주소에서 `#` 뒤의 부분은 브라우저 밖으로 나가지 않습니다. 내 프록시에도, Mocco에도, 다른 어떤 서버에도 전달되지 않고, 다음 요청의 `Referer` 헤더에도 들어가지 않습니다. 그래서 토큰이 누구의 요청 로그에도 남지 않습니다. 페이지는 주소에서 토큰을 읽은 뒤 바로 지웁니다.

페이지는 같은 탭이 최근 10분 안에 **로그인**이나 조용히 확인하기로 로그인 URL에 다녀왔을 때만 `#token=`을 보관합니다. 다른 곳에서 온 링크에 붙은 토큰은 버리므로, 누구도 방문자를 다른 사람의 계정으로 로그인시킬 수 없습니다.

앱에서 이미 `?token=`으로 만들고 있는 링크도 계속 동작합니다.

## 조용히 확인하기 (`prompt=none`)

보드에서 한 번 로그인하면, 브라우저는 비밀 값 없이 로그인한 적이 있다는 사실만 기억합니다. 나중에 새 탭으로 다시 오거나, 페이지에 있는 동안 토큰이 만료되면, 페이지는 `prompt=none`을 붙여 로그인 URL로 한 번 이동합니다.

- 아직 내 앱에 로그인되어 있으면 엔드포인트가 새 토큰과 함께 돌려보내고, 방문자는 아무것도 하지 않아도 로그인됩니다. 글이나 댓글을 쓰는 중에 토큰이 만료됐다면 쓰던 내용은 남아 있습니다.
- 로그인되어 있지 않으면 엔드포인트가 `#signin=none`으로 응답합니다. 페이지는 로그인했던 기록을 지우고 **로그인**을 보여 줍니다.

이 시도는 탭마다 5분에 한 번까지만 하므로, 엔드포인트가 잘못 동작해도 무한히 반복되지 않습니다. 방문자는 잠깐 거쳐 갈 뿐이니 `prompt=none`에는 빠르게, 화면 없이 응답하세요.

보드 페이지의 **로그아웃**은 그 브라우저에서 토큰만 지웁니다. 내 앱에서 로그아웃시키지는 않습니다.

## Next.js 예제

이 엔드포인트는 `pages/api/help/enter.ts`에 있으므로 URL은 `https://example.com/api/help/enter`입니다. 내 사이트가 Mocco로 전달하는 경로(`/help`, `/docs` 등) 아래에 두면 요청이 내 코드가 아니라 Mocco로 가므로 그 아래에 두지 마세요.

토큰 서명에는 [`jose`](https://github.com/panva/jose)를 씁니다. `getSignedInUser`는 내 앱이 세션을 읽는 방법을 대신하는 이름이니 내 코드로 바꾸세요.

```ts
// pages/api/help/enter.ts
import { SignJWT } from 'jose';

import { getSignedInUser } from '@/lib/auth';

import type { NextApiRequest, NextApiResponse } from 'next';

// Copied from Help center → Signing in in Mocco's console.
const RETURN_ADDRESSES = ['https://example.com/help', 'https://orbit.help.mocco.club'];
// Where anyone with a return address you don't allow ends up.
const FALLBACK = 'https://example.com/help';
// Your app's own sign-in page. It sends the person back to `next` afterwards.
const SIGN_IN_PAGE = '/login';
const TOKEN_SECONDS = 10 * 60;

/** `raw` as parsed when it is a page under one of the return addresses, else null. */
function allowedReturn(raw: string | string[] | undefined): URL | null {
  if (typeof raw !== 'string') {
    return null;
  }
  let target: URL;
  try {
    target = new URL(raw);
  } catch {
    return null;
  }
  if (target.protocol !== 'https:' || target.username !== '' || target.password !== '') {
    return null;
  }
  if (target.pathname.startsWith('//')) {
    return null;
  }
  const isAllowed = RETURN_ADDRESSES.some(address => {
    const base = new URL(address);
    const path = base.pathname.replace(/\/+$/, '');
    return (
      target.origin === base.origin &&
      (path === '' || target.pathname === path || target.pathname.startsWith(`${path}/`))
    );
  });
  return isAllowed ? target : null;
}

export default async function enter(req: NextApiRequest, res: NextApiResponse) {
  res.setHeader('Cache-Control', 'no-store');
  const secret = process.env.MOCCO_IDENTITY_SECRET;
  if (secret === undefined || secret === '') {
    res.status(500).end('MOCCO_IDENTITY_SECRET is not set');
    return;
  }
  const back = allowedReturn(req.query.return);
  if (back === null) {
    res.redirect(302, FALLBACK);
    return;
  }

  const user = await getSignedInUser(req);
  if (user === null) {
    if (req.query.prompt === 'none') {
      back.hash = 'signin=none';
      res.redirect(302, back.href);
      return;
    }
    // Sign in first, then come back to this same request.
    res.redirect(302, `${SIGN_IN_PAGE}?next=${encodeURIComponent(req.url ?? '/api/help/enter')}`);
    return;
  }

  const now = Math.floor(Date.now() / 1000);
  const token = await new SignJWT({ name: user.name ?? undefined, email: user.email ?? undefined })
    .setProtectedHeader({ alg: 'HS256' })
    .setSubject(String(user.id))
    .setIssuedAt(now)
    .setExpirationTime(now + TOKEN_SECONDS)
    .sign(new TextEncoder().encode(secret));

  back.hash = `token=${token}`;
  res.redirect(302, back.href);
}
```

`back.href`는 파싱한 주소이므로, 허용된 것처럼 보이기만 하는 `return`은 빠져나갈 수 없습니다. `back.hash`를 설정하면 원래 있던 프래그먼트는 바뀝니다. 비밀 값이 없으면 아무도 확인할 수 없는 토큰에 서명하는 대신 `500`으로 응답합니다.

`SIGN_IN_PAGE?next=`로 보내는 리디렉션은 내 로그인 페이지가 `next`를 확인해 내 사이트의 경로로만 보낼 때만 안전합니다.

로그인해도 아무 일도 일어나지 않는 것 같다면, 토큰을 메신저 설정에서 받은 identity secret으로 서명했는지 확인하세요. 페이지는 Mocco가 거부한 토큰을 버립니다.

로그인 페이지에 자체 언어 설정이 있다면 `req.query.lang`을 넘겨주세요. 한국어 페이지에서 온 방문자가 한국어로 로그인할 수 있습니다.

## identity secret은 서버에만 두기

identity secret은 내 모든 사용자의 신원을 Mocco에 증명하는 서명에 쓰입니다. 이 값을 가진 사람은 헬프 센터 페이지와 Messenger에서 어떤 사용자로든 로그인할 수 있습니다.

- `MOCCO_IDENTITY_SECRET`처럼 서버에서만 읽는 환경 변수에 두세요. Next.js에서는 절대 `NEXT_PUBLIC_` 접두사를 붙이지 말고, API 경로나 서버 코드에서만 읽으세요.
- 앱, 웹 페이지, 저장소에 넣지 마세요. 커밋하는 env 파일에서는 비워 두고, 서버를 배포하는 곳에서 설정하세요.
- Mocco는 이 값을 API나 에이전트에 절대 돌려주지 않습니다. 유출됐다면 **Inbox** 페이지에서 교체하세요. 이전 값으로 만든 토큰과 메신저 서명은 즉시 동작하지 않게 됩니다.

## 콘솔에서 로그인 URL 설정하기

프로젝트의 **Help center** 페이지에서 **Signing in**을 찾습니다.

1. **Sign-in URL**에 엔드포인트 주소를 입력하고 **Save**를 누릅니다. `https`를 써야 하고, 500자 이하이며, 사용자 이름, 비밀번호, `#`이 없어야 합니다. 쿼리는 넣을 수 있습니다.
2. **Return addresses to allow** 아래의 주소들을 엔드포인트에 복사합니다. 헬프 센터 페이지의 주소들로, 활성화된 [내 사이트의 경로](https://www.mocco.club/docs/ko/articles/a2t8mn-own-site) 각각과 헬프 센터 자체 주소입니다. 나중에 경로를 추가하면 활성화된 뒤에 이 목록에 들어가니, 엔드포인트에도 추가하세요.
3. 이 섹션은 프로젝트에 identity secret이 있는지도 알려 주고, 없으면 메신저 설정으로 가는 링크를 보여 줍니다.
4. **Try it**을 누릅니다. 헬프 센터 페이지를 `return`으로 삼아 로그인 URL을 열어 주므로, 엔드포인트가 `#token=`을 붙여 돌려보내는지 볼 수 있습니다. 페이지는 자기가 요청하지 않은 토큰은 보관하지 않으므로, **Try it** 뒤에도 페이지에는 **로그인**이 그대로 보입니다. 로그인 전체 과정을 보려면 그 페이지에서 **로그인**을 누르세요. **Try a foreign address**는 `https://example.com/`으로 돌아오라고 요청합니다. 엔드포인트는 이때 고정 페이지로 보내야 합니다.

**Clear**를 누르면 로그인 URL이 지워집니다. 그러면 보드 페이지에 **로그인**이 더 이상 나오지 않고, 앱에서 토큰을 붙여 여는 링크는 계속 동작합니다. 프로젝트 멤버는 설정을 볼 수 있지만, 바꿀 수 있는 사람은 소유자와 관리자뿐입니다. 모든 변경은 [감사 로그](https://www.mocco.club/docs/ko/articles/2yiscf-audit-log)에 남습니다.

저장하면 보드 페이지가 새로 만들어지므로, 새 설정이 바로 반영됩니다.

## 에이전트나 스크립트에서

- [MCP](https://www.mocco.club/docs/ko/articles/frhj3w-connect)로 연결한 에이전트는 `mocco_help_site_get`(`signInUrl`, `returnPrefixes`)으로 로그인 URL과 돌아올 주소를 읽고, `mocco_help_site_sign_in_url_set`으로 바꿉니다. 변경은 먼저 클라이언트에서 확인하며, 소유자나 관리자만 할 수 있습니다.
- `help:write` 권한이 있는 비밀 API 키로 `GET /v1/help/sign-in`을 호출하면 `{ signInUrl, returnPrefixes }`가 돌아오고, `PUT /v1/help/sign-in`에 `{ "signInUrl": "https://example.com/api/help/enter" }`를 보내면 설정됩니다(`null`이면 지웁니다). 규칙에 맞지 않는 URL은 `400`으로 거부되며, `detail`에 어떤 규칙인지 나옵니다.

## 한국어 보드 페이지

보드 페이지 자체의 문구(헤더, 버튼, 상태, **로그인**)는 헬프 센터를 쓴 언어를 따릅니다. 한국어로 쓴 헬프 센터면 한국어로, 그 밖에는 영어로 나옵니다. 글과 그 내용은 쓴 그대로 보입니다.
