Appearance
활용하기
로그인 구현, 회원 조회와 등록, 기존 회원 연결, 로그아웃과 토큰 관리, 앱 연동, ID 토큰 검증 방법을 설명합니다.
상태 확정 · 최종 확인 2026-09-30
로그인
연동 흐름
연동 서비스가 구현할 엔드포인트는 로그인 시작(/auth/membership/login)과 콜백(/auth/membership/callback) 두 가지입니다.
| 번호 | 구현 내용 | 참고 |
|---|---|---|
| 2 | PKCE code_verifier, state, nonce 를 생성하여 연동 서비스 세션에 저장합니다 | 인가 요청 |
| 3 | 인가 URL 을 만들어 302 리다이렉트합니다 | REST API › 인가 코드 요청 |
| 7 | 콜백의 state 를 세션 값과 비교합니다. error=access_denied 이면 로그인 화면으로 안내합니다 | 콜백과 토큰 요청 |
| 8 ~ 9 | 서버에서 토큰을 요청합니다. client_secret 은 요청 본문에 포함합니다 | REST API › 토큰 요청 |
| 10 | ID 토큰을 검증하고 sub 가 발급받은 접두어로 시작하는지 확인합니다 | ID 토큰 검증 |
| 11 ~ 12 | membership_sub 로 회원을 조회합니다. 없으면 기존 회원 연결 또는 회원가입으로 진행합니다 | 회원 조회와 등록 |
라이브러리 설정
검증된 OpenID Connect 라이브러리 사용을 권장합니다. 디스커버리 주소를 설정하면 엔드포인트, 서명키, PKCE, ID 토큰 검증을 라이브러리가 처리합니다. 공통 설정값은 다음과 같습니다.
issuer / discovery : {호스트}/.well-known/openid-configuration
client_id : 발급받은 값
client_secret : 발급받은 값 (서버 환경변수)
scope : openid email
redirect_uri : 등록한 값과 정확히 일치
PKCE : S256 (필수)
client auth : client_secret_post
clock skew : 60초일부 라이브러리는 ID 토큰 검증(서명, iss, aud, exp, nonce)이 옵션으로 비활성화되어 있습니다. 검증이 실제로 수행되는지 확인해야 합니다.
Node.js — openid-client v6
bash
MEMBERSHIP_ISSUER=https://stage-membership.m-box.com
MEMBERSHIP_CLIENT_ID=remit-web
MEMBERSHIP_CLIENT_SECRET=<발급값> # 서버에만 보관ts
// membership.ts — 서버 시작 시 한 번 실행하여 엔드포인트와 서명키 위치를 조회합니다
import * as client from 'openid-client'
export const membership = await client.discovery(
new URL(process.env.MEMBERSHIP_ISSUER!),
process.env.MEMBERSHIP_CLIENT_ID!,
process.env.MEMBERSHIP_CLIENT_SECRET!,
)
// 네 번째 인자를 생략하면 client_secret_post 로 인증합니다. 통합회원은 이 방식만 지원합니다Java / Spring Boot 3 — Spring Security OAuth2 Client
yaml
# application.yml
spring:
security:
oauth2:
client:
registration:
moneybox:
provider: moneybox
client-id: ${MEMBERSHIP_CLIENT_ID}
client-secret: ${MEMBERSHIP_CLIENT_SECRET}
client-authentication-method: client_secret_post
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/moneybox" # 이 값을 등록합니다
scope: openid, email
provider:
moneybox:
issuer-uri: https://membership.m-box.com # 디스커버리를 자동으로 조회합니다java
// PKCE 활성화 — Spring Security 6.x 는 기밀 클라이언트에 PKCE 를 기본 적용하지 않습니다
@Bean
SecurityFilterChain security(HttpSecurity http, ClientRegistrationRepository repo) throws Exception {
var resolver = new DefaultOAuth2AuthorizationRequestResolver(repo, "/oauth2/authorization");
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
http.oauth2Login(o -> o.authorizationEndpoint(a -> a.authorizationRequestResolver(resolver)));
return http.build();
}로그인 성공 후 OidcUser.getSubject() 가 sub, getEmail() 이 email 입니다.
Next.js — Auth.js (NextAuth v5)
ts
// auth.ts
import NextAuth from 'next-auth'
export const { handlers, auth } = NextAuth({
providers: [{
id: 'moneybox',
name: 'MBOX 통합회원',
type: 'oidc',
issuer: process.env.MEMBERSHIP_ISSUER,
clientId: process.env.MEMBERSHIP_CLIENT_ID,
clientSecret: process.env.MEMBERSHIP_CLIENT_SECRET,
authorization: { params: { scope: 'openid email' } },
checks: ['pkce', 'state', 'nonce'],
client: { token_endpoint_auth_method: 'client_secret_post' },
profile: (p) => ({ id: p.sub, email: p.email ?? null }),
}],
callbacks: {
async signIn({ profile }) { /* profile.sub 로 회원 조회 · 등록 */ return true },
},
})
// 콜백 주소 {origin}/api/auth/callback/moneybox 를 redirect_uri 로 등록합니다Python — Authlib
python
oauth.register(
name="moneybox",
server_metadata_url="https://membership.m-box.com/.well-known/openid-configuration",
client_id=os.environ["MEMBERSHIP_CLIENT_ID"],
client_secret=os.environ["MEMBERSHIP_CLIENT_SECRET"],
client_kwargs={"scope": "openid email", "code_challenge_method": "S256",
"token_endpoint_auth_method": "client_secret_post"},
).NET / PHP — Microsoft.AspNetCore.Authentication.OpenIdConnect(Authority 에 호스트, UsePkce = true, ResponseType = "code"), jumbojett/openid-connect-php(setCodeChallengeMethod('S256'), addScope(['email'])).
인가 요청
로그인 버튼이 호출할 엔드포인트를 구현합니다. PKCE code_verifier, state, nonce 를 생성하여 연동 서비스 세션에 저장한 뒤 통합회원 인가 엔드포인트로 리다이렉트합니다. 세 값은 콜백에서 검증합니다.
ts
// GET /auth/membership/login
import * as client from 'openid-client'
import { membership } from './membership'
const REDIRECT_URI = 'https://stage.remit.example/auth/membership/callback' // 등록값과 정확히 일치
export async function startMembershipLogin(session: Session): Promise<string> {
const codeVerifier = client.randomPKCECodeVerifier()
const state = client.randomState()
const nonce = client.randomNonce()
session.membershipLogin = { codeVerifier, state, nonce } // 콜백에서 검증
const url = client.buildAuthorizationUrl(membership, {
redirect_uri: REDIRECT_URI,
scope: 'openid email',
state,
nonce,
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
code_challenge_method: 'S256',
})
return url.href // 302 리다이렉트 대상
}REST API 로 구현하는 경우 REST API › 인가 코드 요청을 참고하시기 바랍니다.
콜백과 토큰 요청
통합회원은 redirect_uri?code=…&state=… 로 리다이렉트합니다. state 를 세션 값과 비교한 뒤 인가 코드로 토큰을 요청합니다. authorizationCodeGrant 는 토큰 요청과 ID 토큰 검증을 함께 수행합니다.
ts
// GET /auth/membership/callback
import * as client from 'openid-client'
import { membership } from './membership'
const SUB_PREFIX = 'rm_' // 발급받은 서비스 접두어
export async function finishMembershipLogin(session: Session, callbackUrl: URL) {
const saved = session.membershipLogin
session.membershipLogin = undefined
if (!saved || callbackUrl.searchParams.get('state') !== saved.state) {
throw new Error('state 불일치') // 인가 요청부터 다시 진행
}
if (callbackUrl.searchParams.get('error') === 'access_denied') {
return { cancelled: true as const } // 사용자가 동의를 거부한 경우
}
const tokens = await client.authorizationCodeGrant(membership, callbackUrl, {
pkceCodeVerifier: saved.codeVerifier,
expectedState: saved.state,
expectedNonce: saved.nonce,
})
const claims = tokens.claims()! // 검증된 ID 토큰 클레임
if (!claims.sub.startsWith(SUB_PREFIX)) throw new Error('sub 접두어 불일치')
return {
cancelled: false as const,
sub: claims.sub, // 회원 식별 값
email: claims.email as string | undefined,
emailVerified: claims.email_verified === true,
idToken: tokens.id_token!, // 로그아웃 시 id_token_hint 로 사용
}
}콜백에서 서비스 접두어로 시작하는 35자 sub 를 수신하면 정상입니다. 같은 인가 코드로 다시 요청하면 invalid_grant 가 반환됩니다.
연동 서비스 세션
토큰을 발급받은 뒤 연동 서비스의 로그인 상태(세션 쿠키 또는 자체 JWT)를 발급합니다. 통합회원 토큰의 용도는 다음과 같습니다.
| 토큰 | 용도 |
|---|---|
id_token | 검증 후 sub · email 을 확인합니다. 로그아웃 시 id_token_hint 로 사용하려면 세션에 보관합니다 |
access_token | 사용자 정보 조회용(10분). 프런트엔드로 전달하지 않습니다 |
refresh_token | offline_access 를 요청한 경우에만 발급됩니다. 서버에만 보관합니다 |
통합회원 토큰은 프런트엔드에 저장하지 않아야 합니다.
화면 표시 방식
| 방식 | 지원 | 설명 |
|---|---|---|
| 전체 페이지 이동 | 기본 | 브라우저를 통합회원 주소로 이동시키고 콜백으로 복귀합니다. 모든 환경에서 동일하게 동작합니다 |
| 팝업 | 권장하지 않음 | 팝업 차단, 모바일 새 탭, 부모 창 통신 등 예외 처리가 필요합니다 |
| iframe | 불가 | 통합회원이 X-Frame-Options: DENY, frame-ancestors 'none' 으로 차단합니다 |
비밀번호를 입력하는 화면을 iframe 에 삽입하면 클릭 가로채기 공격이 가능해지고, 사용자가 주소창으로 통합회원 화면임을 확인할 수 없습니다. 같은 이유로 앱의 WebView 도 사용할 수 없습니다.
전체 페이지 이동 시 이동 전 화면의 입력값은 유지되지 않습니다. 보존할 값과 복귀 위치는 sessionStorage 에 저장해 두었다가 콜백 후 복원합니다. 콜백 화면은 잠시 표시되므로 로딩 표시를 제공합니다.
ts
// 로그인 버튼 → 인가 주소로 이동
sessionStorage.setItem('membership_pkce', JSON.stringify({ codeVerifier, state }))
location.href = authorizeUrl
// 콜백 화면 — code · state 를 백엔드로 전달
const params = new URLSearchParams(location.search)
const saved = JSON.parse(sessionStorage.getItem('membership_pkce') ?? '{}')
sessionStorage.removeItem('membership_pkce')
if (params.get('state') !== saved.state) throw new Error('state 불일치')
await signIn({ code: params.get('code'), codeVerifier: saved.codeVerifier })팝업을 사용하는 경우 다음을 모두 처리해야 합니다.
- 팝업이 차단되면
window.open이null을 반환하므로 전체 페이지 이동으로 전환해야 합니다. window.open은 클릭 처리 함수에서 즉시 호출해야 합니다. PKCE 계산 등 비동기 작업을 먼저 수행하면 브라우저(특히 Safari)가 팝업을 차단합니다.- 팝업이 통합회원 도메인에 있는 동안 부모 창은 교차 출처 제한으로 주소를 확인할 수 없습니다.
- 사용자가 팝업을 닫은 경우를 취소로 처리해야 합니다.
- 모바일 브라우저는 팝업을 새 탭으로 여는 경우가 많습니다.
프런트엔드와 백엔드가 분리된 구조
SPA 와 API 서버가 분리된 구조에서는 인가 요청(PKCE, 인가 URL 생성)을 브라우저가, 토큰 요청을 백엔드가 담당합니다. 브라우저는 code, state, 자신이 생성한 code_verifier 를 백엔드 API 로 전달하고, 백엔드가 client_secret 을 포함하여 토큰을 요청합니다. React 등에서 화면 효과가 두 번 실행되는 경우, 인가 코드는 한 번만 교환할 수 있으므로 요청이 한 번만 발생하도록 처리해야 합니다.
로그인 버튼
| 항목 | 규격 |
|---|---|
| 문구 | MBOX 통합회원으로 로그인. 로그인 · 가입 겸용 버튼은 "MBOX 통합회원으로 시작하기", 별도 가입 버튼은 "MBOX 통합회원으로 가입하기", 전환 안내는 "MBOX 통합회원으로 전환하기" |
| 로고 | 제공된 머니박스 심볼을 문구 왼쪽에 배치합니다 |
| 크기 | 다른 소셜 로그인 버튼과 같은 높이. 최소 높이 44px, 로고 최소 20px |
| 색상 | 심볼 색상은 변경할 수 없습니다. 버튼 배경은 연동 서비스 스타일을 따릅니다 |
| 위치 | 자체 로그인 병행 기간에는 자체 로그인보다 위 또는 같은 열에 배치합니다 |
| 금지 | 문구 변경("통합 로그인" 등), 로고 변형, 다른 소셜 로그인 아이콘과의 합성 |
SSO
통합회원 세션(브라우저 쿠키, 14일)이 유효하면 인가 요청만으로 화면 없이 인가 코드가 발급됩니다. 최초 연결인 경우에만 동의 화면이 표시됩니다. 통합회원 장애 시 신규 로그인은 불가하나 연동 서비스의 기존 세션은 유지되므로, 연동 서비스 세션 유효 기간을 지나치게 짧게 설정하지 않는 것을 권장합니다.
재인증
송금, 비밀번호 변경 등 중요한 작업 전에 사용자를 다시 확인하려면 인가 요청에 prompt=login(또는 max_age=0)을 추가합니다. 통합회원 세션이 있어도 다시 인증하며, 이 경우 ID 토큰에 auth_time 이 포함됩니다. 연동 서비스는 auth_time 으로 인증 시각을 확인합니다.
회원 조회와 등록
회원 조회와 등록은 연동 서비스 DB 에서 수행합니다. 회원 테이블에 membership_sub 컬럼을 추가합니다. 기존 회원의 값은 NULL 이며, sub 는 35자입니다.
sql
ALTER TABLE member ADD COLUMN membership_sub VARCHAR(40) NULL; -- 접두어 확장을 고려하여 40자
CREATE UNIQUE INDEX ux_member_membership_sub ON member (membership_sub);연동 서비스 DB 에서 sub 로 회원 조회
├─ 회원 있음 → 로그인 처리 (연동 서비스 세션 발급). email 이 변경되었으면 갱신
└─ 회원 없음 → 기존 회원 여부 확인 (아래 기존 회원 연결)
→ 신규 사용자면 회원 생성 (membership_sub = sub) → 서비스 가입 절차NOTE
통합회원 회원은 이미 존재합니다
사용자가 MBOX 통합회원으로 로그인하여 돌아왔다면 통합회원 계정은 이미 존재합니다. 연동 서비스에 없는 것은 연동 서비스 회원이므로, 처음 받은 sub 로 연동 서비스가 회원을 생성합니다. 통합회원에 회원 생성을 요청하는 API 는 제공하지 않습니다.
서비스 가입 절차에서는 이름, 연락처, 본인인증, 서비스 약관 등 연동 서비스에 필요한 정보를 수집합니다. 통합회원이 제공하는 정보는 sub 와 이메일뿐입니다.
ts
const r = await finishMembershipLogin(session, callbackUrl)
if (r.cancelled) return redirect('/login?cancelled=1')
const member = await members.findOne({ membershipSub: r.sub })
if (member) {
if (r.email && r.emailVerified && member.email !== r.email) await members.update(member.id, { email: r.email })
session.idToken = r.idToken
return issueSession(member) // 기존 회원 로그인
}
// 회원 없음 — sub 는 서버에서 서명한 단기 토큰에 담아 가입 화면으로 전달합니다
const signupToken = signShortLived({ membershipSub: r.sub, email: r.email ?? null })
return redirect(`/signup?token=${signupToken}`)
// 회원가입 완료 처리
const { membershipSub, email } = verifyShortLived(token)
await members.create({ membershipSub, email, name, phone /* … */ })stage 에서 새로 가입한 계정으로 로그인하면 가입 화면으로 이동하고, 가입 완료 후 membership_sub 가 저장됩니다. 다시 로그인하면 통합회원 화면 없이 해당 회원으로 로그인됩니다.
기존 회원 연결
연동 서비스의 기존 회원은 사용자 본인이 한 번 연결해야 합니다. 연결 방법은 로그인 상태에서의 전환(경로 ①)과 로그아웃 상태에서의 MBOX 통합회원 로그인(경로 ②) 두 가지입니다. 경로 ①을 권장합니다.
경로 ① 로그인 상태에서 전환 (권장)
연동 서비스가 현재 로그인한 회원을 알고 있는 상태에서 sub 를 저장하므로, 이메일 비교가 필요하지 않습니다. 이 콜백은 일반 로그인 콜백과 다른 경로(예: /auth/membership/link)로 구현하여 "현재 회원에 연결"로 처리해야 합니다. 같은 경로를 사용하는 경우 state 에 용도를 포함하여 구분합니다.
전환 요청에는 prompt=create&login_hint=<이메일> 을 사용합니다. login_hint 없이 prompt=create 만 보내면 가입 화면이 표시됩니다.
| 해당 브라우저의 통합회원 세션 | 표시 화면 |
|---|---|
| 없음 | 전환 화면 — "레밋 계정을 MBOX 통합회원으로 전환합니다"(표시 이름 포함), login_hint 로 이메일 입력 완료, 새 비밀번호 설정. 하단에 "이미 MBOX 통합회원이신가요? 로그인" 링크. 이후 이메일 인증 코드, 동의 화면, 콜백 순으로 진행 |
| 있음 | 계정 확인 화면 — "MBOX 통합회원으로 로그인되어 있습니다", 로그인된 계정의 이메일, [이 계정으로 계속] · [다른 계정으로 로그인] · [새로 가입]. 계속하면 최초 연결이면 동의 화면, 이미 연결되어 있으면 화면 없이 콜백. 새로 가입을 선택하면 전환 화면으로 이동 |
세션이 있어도 계정 확인 화면을 표시하는 것은, 공용 기기에 남은 다른 사용자의 통합회원이 현재 회원에 연결되는 것을 막기 위해서입니다.
두 경우 모두 연동 서비스는 콜백에서 받은 sub 를 현재 로그인된 회원에 저장합니다. prompt=create 는 prompt=none 과 함께 사용할 수 없습니다(invalid_request).
경로 ② 로그아웃 상태에서 MBOX 통합회원 로그인
기존 회원이 자체 로그인 대신 MBOX 통합회원으로 로그인하여, sub 로 회원이 조회되지 않는 경우입니다. 연동 서비스는 기존 회원일 수 있는 계정을 안내하고, 해당 계정으로 로그인한 후 연결합니다.
sub 로 회원 없음 — sub 를 서버에 임시 보관 (세션 또는 서명한 단기 토큰, 10분)
├─ email_verified 가 true 이고 email 이 일치하는 회원 있음
│ → 해당 계정 안내 (예: ho***@gmail.com) → 기존 방식으로 로그인
│ → 로그인 성공 시 해당 회원에 membership_sub = sub 저장
└─ 없음 → 연동 서비스 회원가입 화면 (이름 · 생년월일 · 연락처 등 기존 수집 항목)
├─ 입력 정보가 일치하는 회원 있음
│ → "이미 가입된 계정이 있습니다" 안내 → 기존 방식으로 로그인 → 연결
│ (사용자가 본인 계정이 아니라고 선택하면 회원가입 진행)
└─ 없음 → 신규 회원 생성 (membership_sub = sub)정보가 일치하더라도 해당 계정으로 로그인한 후에만 연결해야 합니다.
- 연동 서비스에 저장된 이메일, 이름, 연락처가 사용자가 입력만 한 값이라면, 제3자가 타인의 정보로 계정을 미리 생성해 둘 수 있습니다. 정보 일치만으로 연결하면 실제 사용자가 해당 계정에 연결됩니다.
- 통합회원이 제공하는
email은 통합회원이 확인한 주소이지만(email_verified), 비교 대상인 연동 서비스의 값이 확인되지 않았다면 같은 문제가 발생합니다. - 연동 서비스가 가입 시 이메일을 직접 확인했고(인증 메일 등)
email_verified가true인 경우에 한해 로그인 절차 없이 연결할 수 있습니다.
임시 보관한 sub 는 연동 서비스 서버에서 조회해야 합니다. 브라우저를 통해 전달하면 다른 사용자의 sub 로 변조될 수 있습니다. 로그인한 회원의 membership_sub 가 비어 있는 경우에만 저장하며, 다른 값이 이미 저장되어 있으면 다른 MBOX 통합회원과 연결된 계정임을 안내합니다.
ts
// 콜백 — sub 로 회원을 찾지 못한 경우
const pending = signShortLived({ membershipSub: r.sub, email: r.email ?? null }, '10m')
const byEmail = r.email && r.emailVerified
? await members.findOne({ email: r.email, membershipSub: null })
: null
return byEmail
? redirect(`/link?token=${pending}&hint=${mask(byEmail.email)}`) // 계정 안내 → 기존 방식 로그인
: redirect(`/signup?token=${pending}`) // 가입 화면에서 입력 정보로 다시 조회
// 기존 방식 로그인 성공 직후 — 보관한 sub 를 연결 (비어 있는 경우에만)
const { membershipSub } = verifyShortLived(token)
await members.updateWhere({ id: member.id, membershipSub: null }, { membershipSub })이름, 생년월일, 연락처는 통합회원이 제공하지 않습니다. 연동 서비스의 회원가입 화면에서 수집하여 연동 서비스 DB 와 비교합니다.
이메일과 입력 정보가 모두 다른 기존 회원은 이 흐름으로 조회할 수 없으며 신규 회원으로 생성됩니다. 이러한 회원은 기존 방식으로 로그인한 후 경로 ①로 연결합니다.
전환 기간 정책
기존 회원이 있는 서비스는 일정 기간 자체 로그인과 MBOX 통합회원 로그인을 함께 제공합니다. 정책은 연동 서비스가 결정하며, B 를 권장합니다.
| 정책 | 자체 가입 | 자체 로그인 | 결과 |
|---|---|---|---|
| A | 제공 | 제공 | 연결되지 않은 회원이 계속 증가하여 전환이 완료되지 않습니다 |
| B (권장) | 종료 | 제공(기존 회원용) | 신규 가입은 모두 MBOX 통합회원으로 받습니다. 연결되지 않은 회원 수가 오픈 시점에 고정되고 감소합니다 |
| C | 종료 | 종료 | 연결되지 않은 회원은 최초 로그인 시 경로 ①이 필수입니다. 전환이 가장 빠르나 이탈 위험이 있습니다 |
B 로 시작하여 연결되지 않은 회원이 충분히 줄면 C 로 전환합니다. C 에서는 membership_sub 가 NULL 인 회원이 기존 방식으로 로그인하면 바로 경로 ①을 진행합니다. 연결이 완료된 회원의 자체 로그인 정보(비밀번호 등)는 삭제할 수 있습니다.
가입 버튼을 별도로 두는 경우 인가 요청에 login_hint 없이 prompt=create 를 추가하면 통합회원 가입 화면이 바로 표시됩니다. 이미 계정이 있는 사용자는 해당 화면의 "이미 계정이 있어요 — 로그인"으로 이동할 수 있습니다. 통합회원에 로그인된 브라우저에서는 가입 화면 대신 계정 확인 화면이 먼저 표시됩니다.
금지 사항
email을 회원 식별 값으로 사용할 수 없습니다. 이메일은 변경되거나 확인되지 않은 값일 수 있습니다.- 통합회원
sub를 다른 회사와 대조할 수 없습니다. 서비스마다 다르게 발급되므로 대조할 수 없으며, 대조해서도 안 됩니다. - 회원 명단을 통합회원에 전달하여 계정을 미리 생성할 수 없습니다. 사용자 본인이 가입하고 동의해야 합니다.
로그아웃
| 방식 | 구현 | 결과 |
|---|---|---|
| 연동 서비스 로그아웃 | 연동 서비스 세션 삭제. 리프레시 토큰을 보관한 경우 토큰 폐기 | 통합회원 세션과 다른 서비스의 SSO 는 유지됩니다. 일반적인 로그아웃에 사용합니다 |
| 통합회원 로그아웃 | 브라우저를 /oauth/logout으로 이동(id_token_hint 포함) | 통합회원 세션이 종료되어 모든 서비스에서 다시 로그인해야 합니다 |
통합회원 로그아웃은 "모든 서비스에서 로그아웃" 기능을 제공하는 경우에만 사용합니다. 등록된 post_logout_redirect_uri 와 state 를 함께 전달합니다.
사용자는 확인 화면에서 로그아웃 또는 로그인 유지를 선택합니다. 어느 쪽을 선택하더라도 복귀 주소로 state 와 함께 303 리다이렉트되며, 두 경우를 구분하는 값은 전달되지 않습니다. 따라서 복귀 여부로 통합회원 세션 종료를 판단할 수 없으며, 연동 서비스 세션은 /oauth/logout 으로 이동하기 전에 삭제해야 합니다. 요청한 서비스의 리프레시 토큰은 통합회원이 함께 폐기하므로 별도로 폐기하지 않아도 됩니다.
ts
// POST /logout
import * as client from 'openid-client'
import { membership } from './membership'
export function logout(session: Session): string {
const idToken = session.idToken
session.destroy() // 연동 서비스 세션 종료
const url = client.buildEndSessionUrl(membership, { // {issuer}/oauth/logout?…
...(idToken ? { id_token_hint: idToken } : { client_id: process.env.MEMBERSHIP_CLIENT_ID! }),
post_logout_redirect_uri: 'https://stage.remit.example/', // 등록값과 정확히 일치
})
return url.href // 302 리다이렉트 대상
}로그아웃 후 등록한 주소로 이동하고, 다시 로그인하면 비밀번호를 입력하는지 확인합니다.
토큰 갱신
사용자가 없는 상태에서 서버가 통합회원 API 를 호출해야 하는 경우(사용자 정보 재조회 등)에만 offline_access 를 요청합니다. 로그인에는 필요하지 않습니다.
- 인가 요청에
scope=openid email offline_access와prompt=consent를 함께 전달합니다.prompt=consent가 없으면offline_access는 무시되어 리프레시 토큰이 발급되지 않습니다. 응답의scope로 확인할 수 있습니다. prompt=consent는 이미 동의한 사용자에게도 동의 화면을 매번 표시하여 SSO 가 동작하지 않습니다. 리프레시 토큰이 필요한 진입점(예: 백그라운드 연동 설정 화면)에서만 사용해야 합니다.- 리프레시 토큰은 30일간 유효하며, 사용할 때마다 새 토큰으로 교체됩니다. 기존 토큰은 즉시 무효가 되므로 응답의 새 값을 저장해야 합니다. 두 서버가 같은 토큰으로 동시에 갱신하면 한쪽이
invalid_grant를 받으므로 갱신은 한 곳에서만 수행해야 합니다. - 리프레시 토큰은 서버에만 보관합니다. — REST API › 토큰 갱신
토큰 갱신이 invalid_grant 로 실패하면 리프레시 토큰을 폐기하고 연동 서비스 정책에 따라 회원을 처리합니다. 연결 해제 · 정지는 재로그인 시 같은 sub 로 복구되지만, 탈퇴 후 재가입한 사용자는 새 sub 가 발급되어 다른 회원으로 인식됩니다. — 이해하기 › 계정 상태 변경
앱
iOS · Android 앱은 웹과 다음 세 가지가 다릅니다.
| 원칙 | 이유 |
|---|---|
시스템 브라우저로 통합회원 화면 열기(iOS ASWebAuthenticationSession, Android Chrome Custom Tabs) | 카카오 · Google 은 WebView 로그인을 차단합니다. 시스템 브라우저는 통합회원 세션 쿠키를 공유하여 SSO 가 동작합니다. WebView 는 사용할 수 없습니다 |
| 복귀 주소는 https 딥링크(Universal Link, App Link) | 커스텀 스킴(remit://)은 다른 앱이 등록하여 인가 코드를 가로챌 수 있으며, 등록할 수 없습니다 |
| 토큰 요청은 앱 → 자사 백엔드 → 통합회원 | 앱은 client_secret 을 보관할 수 없습니다 |
PKCE code_verifier 는 앱이 생성하고 보관한 뒤 백엔드로 전달합니다. 딥링크로 인가 코드가 유출되더라도 code_verifier 없이는 토큰을 발급받을 수 없습니다.
iOS (Swift)
swift
let verifier = PKCE.randomVerifier() // 43~128자
let challenge = PKCE.s256(verifier)
var url = URLComponents(string: "https://membership.m-box.com/oauth/authorize")!
url.queryItems = [
.init(name: "client_id", value: "remit-ios"),
.init(name: "redirect_uri", value: "https://www.remit.example/app/cb"),
.init(name: "response_type", value: "code"),
.init(name: "scope", value: "openid email"),
.init(name: "state", value: state), .init(name: "nonce", value: nonce),
.init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256"),
]
let session = ASWebAuthenticationSession(url: url.url!, callback: .https(host: "www.remit.example", path: "/app/cb")) { url, error in
// url 의 code · state 를 verifier 와 함께 자사 백엔드로 전달 → 백엔드가 토큰 요청
}
session.presentationContextProvider = self
session.start()Android (Kotlin, AppAuth)
kotlin
AuthorizationServiceConfiguration.fetchFromIssuer(Uri.parse("https://membership.m-box.com")) { cfg, _ ->
val req = AuthorizationRequest.Builder(cfg!!, "remit-android", ResponseTypeValues.CODE,
Uri.parse("https://www.remit.example/app/cb")) // App Link
.setScope("openid email").setNonce(nonce).setState(state)
.setCodeVerifier(verifier) // AppAuth 가 S256 challenge 생성
.build()
startActivityForResult(AuthorizationService(this).getAuthorizationRequestIntent(req), RC_AUTH)
}
// onActivityResult: AuthorizationResponse.fromIntent(data) 의 authorizationCode 와 verifier 를
// 자사 백엔드로 전달합니다. performTokenRequest 는 시크릿이 필요하므로 사용하지 않습니다앱은 웹과 별도의 클라이언트로 발급받습니다(예: remit-ios, remit-android). 같은 서비스이므로 sub 는 웹과 동일합니다. 딥링크 설정 파일(apple-app-site-association, assetlinks.json)은 연동 서비스 도메인에 배치합니다. 앱에서 카카오 로그인을 선택하면 카카오톡 앱이 아닌 카카오 웹 로그인으로 진행됩니다.
ID 토큰 검증
ID 토큰을 검증 없이 디코드하여 사용하면 위조된 토큰을 허용하게 됩니다. authorizationCodeGrant 와 같이 라이브러리가 토큰 요청과 함께 검증을 수행하면 별도 구현은 필요하지 않습니다. 직접 구현하는 경우 JWT 검증 라이브러리(jose, jjwt, PyJWT 등)로 JWKS 기반 RS256 검증을 수행하며, 라이브러리를 사용할 수 없는 환경에서는 아래 절차를 따릅니다.
1. id_token 을 '.' 로 분리: header.payload.signature (각각 base64url)
2. header.alg 가 "RS256" 인지 확인하고, header.kid 로 JWKS(jwks_uri)에서 키 선택
- 키가 없으면 JWKS 를 다시 조회(키 교체). 그래도 없으면 거부
3. 서명 검증: RSASSA-PKCS1-v1_5 / SHA-256 (RFC 8017 §8.2.2)
- JWKS 의 n, e 는 base64url 로 인코딩된 빅엔디언 정수
- m = signature^e mod n 을 바이트로 변환하여 00 01 FF…FF 00 ‖ DigestInfo ‖ SHA256(header.payload) 와 비교
- DigestInfo(SHA-256) 프리픽스 = 3031300d060960864801650304020105000420 (hex)
4. payload 검증
- iss == 디스커버리의 issuer
- aud == client_id (단일 문자열, azp 없음)
- exp + 60초 > 현재 시각, iat ≤ 현재 시각 + 60초
- nonce == 인가 요청의 nonce (로그인 응답에만 적용, 갱신 응답에는 적용하지 않음)
5. at_hash 검증(선택): BASE64URL( SHA256(access_token) 의 앞 16바이트 ) == at_hash
6. 검증을 통과하면 sub 를 사용