헬프 센터에서 로그인하게 하기
헬프 센터의 피드백 보드는 누구나 읽을 수 있습니다. 하지만 글을 올리고, 댓글을 달고, 클릭 한 번으로 투표하려면 방문자가 누구인지 알아야 합니다. 로그인 URL이 없으면 방문자가 로그인하는 방법은 앱에서 토큰을 붙여 보드를 여는 링크뿐입니다(공개 보드에서 방법을 볼 수 있습니다).
로그인 URL을 설정하면 보드 페이지의 헤더와 입력 양식 옆에 로그인 버튼이 나타납니다. 이 버튼은 방문자를 내 사이트의 엔드포인트로 보냅니다. 엔드포인트는 내 앱의 로그인으로 방문자가 누구인지 확인하고, 수명이 짧은 토큰을 붙여 방문자를 원래 페이지로 돌려보냅니다. Mocco는 사용자의 비밀번호를 보지 않으며, 사용자는 Mocco 계정이 없어도 됩니다.
내가 할 일은 엔드포인트 하나를 만들고 그 주소를 Mocco에 설정하는 것입니다. 이 글에서는 엔드포인트가 해야 할 일, Next.js로 만든 전체 예제, 설정 방법을 차례로 설명합니다.
준비할 것
프로젝트의 identity secret. 서버가 토큰에 서명할 때 쓰는 비밀 값입니다. 프로젝트의 Inbox 페이지에서 Set up messenger를 하면 만들어지고, 한 번만 보여 줍니다(Mocco Messenger로 앱 안에서 문의받기 참고). 피드백 보드만 쓰더라도 이 값을 얻으려면 메신저를 한 번 설정해야 합니다.
내 앱에 누가 로그인했는지 읽을 수 있는, 내 사이트의 서버 경로.
로그인 URL을 설정하려면 워크스페이스의 소유자나 관리자여야 합니다.
로그인 흐름
방문자가
https://example.com/help/12?sort=new같은 보드 페이지에서 로그인을 누릅니다.페이지는 탭 전체를 로그인 URL로 보내면서 돌아올 페이지를 함께 넘깁니다.
https://example.com/api/help/enter?return=https%3A%2F%2Fexample.com%2Fhelp%2F12%3Fsort%3Dnew&lang=ko엔드포인트는
return을 확인하고, 방문자가 아직 로그인하지 않았다면 로그인시킨 다음, 프래그먼트에 토큰을 붙여302로 페이지에 돌려보냅니다.https://example.com/help/12?sort=new#token=<jwt>페이지는 토큰을 그 브라우저 탭에 보관하고 새로 고치지 않고 주소에서 지웁니다. 이제 방문자는 글을 올리고, 댓글을 달고, 투표할 수 있습니다.
엔드포인트 규약
요청. Mocco는 방문자를 다음 주소로 보냅니다.
GET <sign-in URL>?return=<URL>[&prompt=none]&lang=<ko|en>로그인 URL에 이미 쿼리가 있으면 그 뒤에 파라미터가 덧붙습니다.
return은 돌아올 페이지의 절대 https 주소입니다. 페이지의 공개 주소이므로, 내 사이트의 경로 아래에서는 내 주소가 됩니다. 페이지의 쿼리는 그대로 두되 토큰은 빼며, 프래그먼트는 없습니다.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를 씁니다. getSignedInUser는 내 앱이 세션을 읽는 방법을 대신하는 이름이니 내 코드로 바꾸세요.
// 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을 찾습니다.
Sign-in URL에 엔드포인트 주소를 입력하고 Save를 누릅니다.
https를 써야 하고, 500자 이하이며, 사용자 이름, 비밀번호,#이 없어야 합니다. 쿼리는 넣을 수 있습니다.Return addresses to allow 아래의 주소들을 엔드포인트에 복사합니다. 헬프 센터 페이지의 주소들로, 활성화된 내 사이트의 경로 각각과 헬프 센터 자체 주소입니다. 나중에 경로를 추가하면 활성화된 뒤에 이 목록에 들어가니, 엔드포인트에도 추가하세요.
이 섹션은 프로젝트에 identity secret이 있는지도 알려 주고, 없으면 메신저 설정으로 가는 링크를 보여 줍니다.
Try it을 누릅니다. 헬프 센터 페이지를
return으로 삼아 로그인 URL을 열어 주므로, 엔드포인트가#token=을 붙여 돌려보내는지 볼 수 있습니다. 페이지는 자기가 요청하지 않은 토큰은 보관하지 않으므로, Try it 뒤에도 페이지에는 로그인이 그대로 보입니다. 로그인 전체 과정을 보려면 그 페이지에서 로그인을 누르세요. Try a foreign address는https://example.com/으로 돌아오라고 요청합니다. 엔드포인트는 이때 고정 페이지로 보내야 합니다.
Clear를 누르면 로그인 URL이 지워집니다. 그러면 보드 페이지에 로그인이 더 이상 나오지 않고, 앱에서 토큰을 붙여 여는 링크는 계속 동작합니다. 프로젝트 멤버는 설정을 볼 수 있지만, 바꿀 수 있는 사람은 소유자와 관리자뿐입니다. 모든 변경은 감사 로그에 남습니다.
저장하면 보드 페이지가 새로 만들어지므로, 새 설정이 바로 반영됩니다.
에이전트나 스크립트에서
MCP로 연결한 에이전트는
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에 어떤 규칙인지 나옵니다.
한국어 보드 페이지
보드 페이지 자체의 문구(헤더, 버튼, 상태, 로그인)는 헬프 센터를 쓴 언어를 따릅니다. 한국어로 쓴 헬프 센터면 한국어로, 그 밖에는 영어로 나옵니다. 글과 그 내용은 쓴 그대로 보입니다.