Skip to content

활용하기 ​

로그인 구현, 회원 조회와 등록, 기존 회원 연결, 로그아웃과 토큰 관리, 앱 연동, ID 토큰 검증 방법을 설명합니다.

상태 확정 · 최종 확인 2026-09-30


로그인 ​

연동 흐름 ​

연동 서비스가 구현할 엔드포인트는 로그인 시작(/auth/membership/login)과 콜백(/auth/membership/callback) 두 가지입니다.

번호구현 내용참고
2PKCE code_verifier, state, nonce 를 생성하여 연동 서비스 세션에 저장합니다인가 요청
3인가 URL 을 만들어 302 리다이렉트합니다REST API › 인가 코드 요청
7콜백의 state 를 세션 값과 비교합니다. error=access_denied 이면 로그인 화면으로 안내합니다콜백과 토큰 요청
8 ~ 9서버에서 토큰을 요청합니다. client_secret 은 요청 본문에 포함합니다REST API › 토큰 요청
10ID 토큰을 검증하고 sub 가 발급받은 접두어로 시작하는지 확인합니다ID 토큰 검증
11 ~ 12membership_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_tokenoffline_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 를 사용