"지나패스로 로그인"은 표준 OpenID Connect (OAuth 2.0) 그대로예요.
쓰던 OIDC/OAuth2 라이브러리에 발급기관(issuer) 하나만 바꿔 끼우면 바로 붙습니다.
로그인 후 넘어오는 건 안정적인 익명 식별자(sub)와 별명(name)뿐 —
얼굴·생체정보는 절대 넘어오지 않아요.
지나패스는 지나월드의 무자각 얼굴인증 신원 제공자(IdP)예요.
사용자는 얼굴로 자기 자신을 증명하지만, 여러분의 서비스에는 그 얼굴이 오지 않습니다.
대신 표준 OIDC 토큰(id_token)이 넘어오고, 그 안에는 이 사용자를 안정적으로 식별하는
익명 sub가 담겨요. 같은 사람은 여러분 서비스에서 언제나 같은 sub로 돌아옵니다.
https://pass.jina.worldid_token 검증 가능)openid(필수), profile(name 클레임 추가)OpenID Connect를 처음 붙이더라도, 이 문서의 3스텝과 코드 예시만 따라 하면 됩니다.
먼저 지나월드에 클라이언트 등록을 요청하세요. 아래 정보를 전달하면
서버의 OIDC_CLIENTS 목록에 등재됩니다.
| 항목 | 설명 |
|---|---|
client_id | 여러분 서비스 식별자 (예: ticketfarm) |
redirect_uris | 콜백으로 허용할 URL 정확히 — 여기 등록된 것만 리다이렉트 허용(오픈 리다이렉트 방지) |
client_secret (선택) | 서버 사이드 앱이면 발급. SPA·모바일 등 공개 클라이언트면 생략하고 PKCE만 사용 |
name | 동의 화면에 보일 서비스 이름 |
등록 예시(서버 .env의 OIDC_CLIENTS JSON 배열 한 항목):
{
"client_id": "ticketfarm",
"redirect_uris": ["https://ticketfarm.example.com/callback"],
"name": "티켓팜"
// client_secret 없으면 공개 클라이언트 → PKCE 필수
}
/authorize로 리다이렉트
로그인 버튼을 누르면 PKCE code_verifier를 만들어 저장하고,
그 SHA-256 해시(code_challenge)를 붙여 /authorize로 보냅니다.
const ISSUER = 'https://pass.jina.world';
const CLIENT_ID = 'ticketfarm';
const REDIRECT = 'https://ticketfarm.example.com/callback';
// PKCE verifier 생성 → 세션에 보관
const verifier = rand() + rand(); // 충분히 긴 랜덤
sessionStorage.setItem('pkce_verifier', verifier);
const state = rand();
sessionStorage.setItem('oidc_state', state);
const challenge = await sha256b64(verifier); // S256
const u = new URL(ISSUER + '/authorize');
u.search = new URLSearchParams({
response_type: 'code',
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
scope: 'openid profile',
state,
nonce: rand(),
code_challenge: challenge,
code_challenge_method: 'S256',
}).toString();
location.href = u.toString();
code를 /token으로 교환
지나패스가 인증을 마치면 redirect_uri로 code와 state를 돌려줘요.
state를 검증한 뒤, 저장해 둔 code_verifier와 함께 /token에 교환 요청을 보냅니다.
const q = new URL(location.href).searchParams;
if (q.get('state') !== sessionStorage.getItem('oidc_state'))
throw new Error('state 불일치(위조 의심)');
const body = new URLSearchParams({
grant_type: 'authorization_code',
code: q.get('code'),
redirect_uri: REDIRECT,
client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem('pkce_verifier'),
// client_secret 있으면 여기 함께(client_secret_post) — 서버에서만!
});
const r = await fetch(ISSUER + '/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
}).then(r => r.json());
// r.id_token 을 JWKS 공개키로 검증 후 클레임 사용
const claims = verifyIdToken(r.id_token); // sub, name, iss, aud, exp ...
console.log(claims.sub, claims.name);
토큰 응답: { access_token, token_type:"Bearer", expires_in:3600, id_token, scope }.
필요하면 access_token으로 /userinfo를 호출해 sub/name을 다시 받을 수도 있어요.
모든 경로의 기준(issuer)은 https://pass.jina.world 입니다.
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
/.well-known/openid-configuration |
GET | Discovery 문서. 아래 엔드포인트·지원 스펙을 자동 발견. 대부분의 OIDC 라이브러리가 이것만으로 설정 완료 |
/.well-known/jwks.json |
GET | JWKS 공개키(RS256). id_token 서명 검증용. kid로 키 매칭 |
/authorize |
GET | 인가 요청. 사용자를 여기로 리다이렉트 → 인증 후 code 반환. response_type=code만 지원 |
/token |
POST | 토큰 교환. authorization_code 그랜트로 code → id_token+access_token. x-www-form-urlencoded |
/userinfo |
GET | Authorization: Bearer <access_token>로 sub(+profile이면 name) 반환 |
지원 스펙: response_types=["code"], grant_types=["authorization_code"],
subject_types=["public"], id_token_signing_alg=["RS256"],
scopes=["openid","profile"], code_challenge_methods=["S256"],
token_endpoint_auth_methods=["client_secret_post","client_secret_basic","none"].
| 클레임 | 포함 조건 | 의미 |
|---|---|---|
sub | 항상 | 여권 guestId — 이 사용자의 안정적 익명 식별자. 같은 사람은 늘 같은 값 |
name | profile 스코프 + 별명 존재 시 | 사용자 별명(닉네임) |
iss | 항상 | https://pass.jina.world — 검증 시 반드시 대조 |
aud | 항상 | 여러분의 client_id — 검증 시 반드시 대조 |
exp / iat | 항상 | 만료·발급 시각(Unix, 유효기간 1시간) |
auth_time | 항상 | 사용자가 인증한 시각 |
nonce | 요청 시 보냈다면 | 재생공격 방지용. 보낸 값과 일치하는지 확인 |
지나패스가 여러분에게 넘기는 것은 익명 식별자 sub와
(요청 시) 별명 name이 전부입니다.
얼굴 이미지, 얼굴 임베딩, 그 어떤 생체 데이터도 id_token·userinfo 어디에도 포함되지 않아요.
인증은 지나패스 안에서만 일어나고, 여러분에게는 검증된 신원만 도착합니다.
아래는 브라우저(공개 클라이언트) 기준 최소 구현이에요. 실제 동작하는 릴레잉파티(oidc-demo.html)에서 발췌·정리했습니다.
const ISSUER = 'https://pass.jina.world';
const CLIENT_ID = 'ticketfarm';
const REDIRECT = location.origin + location.pathname;
// ── PKCE / 유틸 ─────────────────────────────
const b64url = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const rand = () => { const a = new Uint8Array(32); crypto.getRandomValues(a); return b64url(a.buffer); };
async function sha256b64(s) {
return b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)));
}
function decodeJwtPayload(jwt) {
return JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
}
// ── 1) 로그인 시작 → /authorize ─────────────
async function login() {
const verifier = rand() + rand();
sessionStorage.setItem('pkce_verifier', verifier);
const state = rand();
sessionStorage.setItem('oidc_state', state);
const challenge = await sha256b64(verifier);
const u = new URL(ISSUER + '/authorize');
u.search = new URLSearchParams({
response_type: 'code', client_id: CLIENT_ID, redirect_uri: REDIRECT,
scope: 'openid profile', state, nonce: rand(),
code_challenge: challenge, code_challenge_method: 'S256',
}).toString();
location.href = u.toString();
}
// ── 2) 콜백 처리 → /token 교환 ──────────────
async function handleCallback() {
const q = new URL(location.href).searchParams;
if (q.get('error')) throw new Error(q.get('error'));
const code = q.get('code');
if (!code) return null; // 첫 방문
if (q.get('state') !== sessionStorage.getItem('oidc_state'))
throw new Error('state 불일치');
const body = new URLSearchParams({
grant_type: 'authorization_code', code, redirect_uri: REDIRECT,
client_id: CLIENT_ID, code_verifier: sessionStorage.getItem('pkce_verifier'),
});
const r = await fetch(ISSUER + '/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
}).then(r => r.json());
if (!r.id_token) throw new Error(r.error || 'token 교환 실패');
history.replaceState({}, '', REDIRECT); // URL에서 code 제거
return decodeJwtPayload(r.id_token); // { sub, name, iss, aud, ... }
}
위 decodeJwtPayload는 표시용으로 페이로드만 디코드합니다.
실제 신뢰 결정 전에는 /.well-known/jwks.json의 공개키로 RS256 서명을 검증하고
iss·aud·exp·nonce를 대조하세요.
서버 사이드라면 jose 같은 라이브러리의 jwtVerify를 createRemoteJWKSet(jwks_uri)와 함께 쓰면 자동입니다.
실제로 동작하는 릴레잉파티 데모가 준비돼 있어요. "티켓팜"이라는 가상의 외부 서비스가
지나패스로 로그인하고, 넘어온 sub/name/iss/aud 클레임을 그대로 보여줍니다.
client_secret이 없으므로 PKCE가 필수입니다. 서버 앱도 함께 쓰면 더 안전해요.redirect_uri는 등록한 값과 정확히 일치해야 합니다. 미등록 URI로는 리다이렉트 자체가 거부됩니다(오픈 리다이렉트 방지). 경로·쿼리·트레일링 슬래시까지 동일해야 해요.client_secret은 서버에만 보관하세요. 브라우저·앱 번들에 넣지 마세요. 시크릿이 필요 없는 구조라면 공개 클라이언트 + PKCE로 가는 편이 안전합니다.code는 1회용, TTL 1분입니다. 받은 즉시 /token으로 교환하세요. 재사용·만료된 코드는 invalid_grant로 거부됩니다.state와 nonce를 검증하세요. state는 CSRF, nonce는 토큰 재생을 막습니다.iss·aud·exp·서명까지. iss가 https://pass.jina.world인지, aud가 여러분의 client_id인지 반드시 확인하세요.