01.software 문서

Customers

앱에서 hosted 로그인, 현재 고객 readback과 로그아웃을 완료합니다.

고객 이메일은 소문자로 저장됩니다. API로 저장할 때는 앞뒤 공백을 제거하며, Admin Panel에서는 앞뒤 공백이 있으면 폼 검증에서 거부됩니다. 보관된 고객을 포함해 워크스페이스 안에서 이메일 하나는 고객 한 명에게만 연결됩니다. 대소문자만 바꿔 같은 이메일로 새 계정을 만들 수 없습니다.

고객이 탈퇴해도 작성한 게시물과 댓글은 남습니다. 계정 개인정보 삭제 시 복사된 작성자 이름은 [삭제된 사용자]로 바뀌고 핸들과 아바타는 지워집니다. 앱의 개인정보 처리방침에 글의 보존과 작성자 정보 삭제 방식을 안내하는 것을 권장합니다.

계정 핸들

선택적 계정 핸들은 표시 이름과 구분해 사용하세요. 프로필 읽기와 고객 프로필 수정의 핸들 문자열/null 형태는 유지됩니다. 핸들을 설정해도 기존 프로필 공개 여부는 유지되며, 핸들 로그인은 제공하지 않습니다.

작업규칙
고객이 핸들을 변경하거나 비우기최근 14일 동안 두 번까지 가능합니다.
고객이 이전 핸들 되찾기14일 동안 원래 고객만 되찾을 수 있습니다.
계정 삭제현재 핸들은 최소 14일간 누구에게도 재할당되지 않으며, 만료는 UTC 일 경계로 올림합니다.

충돌 응답은 소유자나 예약 사유를 알려주지 않습니다. 고객 본인과 권한을 확인한 워크스페이스 관리자만 핸들을 변경할 수 있습니다. 편집자와 Secret Key는 변경할 수 없습니다. Admin Panel 프로필 핸들 입력은 읽기 전용이며, 관리자 변경은 API로 제공합니다. 관리자 핸들 변경은 by-ID API만 허용하며 일괄 변경은 400으로 거부합니다. 정지·보관 고객의 핸들은 비우기만 가능합니다.

준비

customers를 켜고 대상 워크스페이스와 환경의 Publishable Key를 받으세요. 운영자에게 다음 호스티드 클라이언트 등록을 요청합니다.

  • 활성화된 clientId
  • 정확히 정규화된 HTTPS 앱 origin
  • callback URI APP_ORIGIN/api/account/callback
  • 로그아웃 URI APP_ORIGIN/account. 이 예제가 postLogoutRedirectUri를 보내므로 필요하며, 보내지 않으면 등록하지 않아도 됩니다.

와일드카드와 loopback 호스트는 받지 않습니다. 브라우저 API origin은 따로 등록합니다. HTTPS 배포나 HTTPS 개발 origin을 사용하세요.

issuer는 https://01.software입니다. Live와 Sandbox 워크스페이스가 같은 issuer를 쓰며, 워크스페이스는 Publishable Key와 clientId로 구분됩니다. issuer는 클라이언트마다 등록하는 값이 아니고, 앱을 로컬에서 개발할 때도 바꾸지 않습니다.

프로젝트 디렉터리를 @/* 별칭의 기준으로 둔 Next.js App Router 앱에 복사하고 server-only를 설치하세요(npm install server-only@0.0.1). 예제는 요청마다 실행되며, 확인한 버전과 구성은 빌드 옵션에 있습니다.

# .env.local (configure the same values in the deployed app)
NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY=pk01_...
SOFTWARE_API_URL=https://your-console-origin.example
CUSTOMER_OAUTH_CLIENT_ID=your-registered-client-id
CUSTOMER_OAUTH_ISSUER=https://01.software
APP_ORIGIN=https://your-app.example

API URL은 운영자가 확인한 값이어야 합니다. 운영자 PAT, Secret Key, 서명 비밀값, 내부 교환 비밀값을 이 고객 앱에 넣지 마세요.

호스티드 로그인 구현

세 파일을 모두 복사하세요. 요청마다 저장하지 않는 SDK 클라이언트를 새로 만듭니다. callback은 state와 verifier 쿠키를 꺼내 쓰고, state를 확인하고, PKCE에 묶인 code를 한 번만 교환한 뒤 me()로 사용자를 읽습니다. 액세스 토큰과 리프레시 토큰은 Secure HttpOnly 호스트 쿠키에만 두며, 브라우저 저장소나 URL에 넣지 않습니다. SDK는 idToken을 검증하지 않으므로 내용을 해석하지 마세요.

// lib/customer.ts
import 'server-only'
import { createClient } from '@01.software/sdk'
import { cookies } from 'next/headers'

export const accessCookie = '__Host-customer-access'
export const refreshCookie = '__Host-customer-refresh'
export const cookieOptions = {
  httpOnly: true,
  secure: true,
  sameSite: 'lax' as const,
  path: '/',
}

export function customerClient(token?: string) {
  return createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: {
      persist: false,
      token,
      oauth: {
        clientId: process.env.CUSTOMER_OAUTH_CLIENT_ID!,
        issuer: process.env.CUSTOMER_OAUTH_ISSUER!,
      },
    },
  })
}

export async function currentCustomer() {
  const jar = await cookies()
  const client = customerClient(jar.get(accessCookie)?.value)
  const customer = await client.customer.auth.me()
  return { client, customer, hadToken: Boolean(jar.get(accessCookie)?.value) }
}
// app/api/account/[action]/route.ts
import { cookies } from 'next/headers'
import {
  accessCookie,
  refreshCookie,
  cookieOptions,
  customerClient,
} from '@/lib/customer'

export const dynamic = 'force-dynamic'
export const runtime = 'nodejs'
type Context = { params: Promise<{ action: string }> }
const stateCookie = '__Host-customer-state'
const verifierCookie = '__Host-customer-verifier'

function appOrigin() {
  const url = new URL(process.env.APP_ORIGIN!)
  if (url.protocol !== 'https:' || url.origin !== process.env.APP_ORIGIN) {
    throw new Error('APP_ORIGIN must be an exact HTTPS origin')
  }
  return url.origin
}

async function handle(request: Request, context: Context) {
  const { action } = await context.params
  const jar = await cookies()
  const origin = appOrigin()
  const reply = (body: object, status = 200) =>
    Response.json(body, { status, headers: { 'Cache-Control': 'no-store' } })
  const clearSession = () => {
    for (const name of [accessCookie, refreshCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const clearFlow = () => {
    for (const name of [stateCookie, verifierCookie]) {
      jar.set(name, '', { ...cookieOptions, maxAge: 0 })
    }
  }
  const redirect = (path: string) =>
    new Response(null, {
      status: 303,
      headers: { Location: origin + path, 'Cache-Control': 'no-store' },
    })
  const client = customerClient(jar.get(accessCookie)?.value)
  const oauth = client.customer.oauth!
  const redirectUri = origin + '/api/account/callback'
  if (request.method === 'POST' && request.headers.get('origin') !== origin) {
    return reply({ error: 'origin_not_allowed' }, 403)
  }
  try {
    if (request.method === 'GET' && action === 'login') {
      const flow = await oauth.createAuthorizationUrl({ redirectUri })
      jar.set(stateCookie, flow.state, { ...cookieOptions, maxAge: 600 })
      jar.set(verifierCookie, flow.codeVerifier, {
        ...cookieOptions,
        maxAge: 600,
      })
      return new Response(null, {
        status: 303,
        headers: {
          Location: flow.authorizationUrl,
          'Cache-Control': 'no-store',
        },
      })
    }
    if (request.method === 'GET' && action === 'callback') {
      const expectedState = jar.get(stateCookie)?.value
      const codeVerifier = jar.get(verifierCookie)?.value
      clearFlow()
      if (!expectedState || !codeVerifier)
        return reply({ error: 'flow_expired' }, 400)
      const { code } = oauth.parseCallback(request.url, { expectedState })
      const tokens = await oauth.exchangeCode({
        code,
        codeVerifier,
        redirectUri,
      })
      // Read identity from me(); the SDK does not verify idToken.
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'logout') {
      const refreshToken = jar.get(refreshCookie)?.value
      clearSession()
      clearFlow()
      if (!refreshToken) return reply({ error: 'revocation_unconfirmed' }, 409)
      await oauth.logout({
        refreshToken,
        postLogoutRedirectUri: origin + '/account',
      })
      return redirect('/account')
    }
    if (request.method === 'POST' && action === 'refresh') {
      const refreshToken = jar.get(refreshCookie)?.value
      if (!refreshToken) return reply({ error: 'sign_in_required' }, 401)
      const tokens = await oauth.refresh({ refreshToken })
      const customer = await customerClient(
        tokens.accessToken,
      ).customer.auth.me()
      if (!customer) {
        clearSession()
        return reply({ error: 'session_invalid' }, 401)
      }
      jar.set(accessCookie, tokens.accessToken, {
        ...cookieOptions,
        maxAge: tokens.expiresIn,
      })
      jar.set(refreshCookie, tokens.refreshToken, {
        ...cookieOptions,
        maxAge: 60 * 60 * 24 * 30,
      })
      return redirect('/account')
    }
    return reply({ error: 'not_found' }, 404)
  } catch {
    // Do not return tokens, callback query strings, or raw upstream errors.
    if (action === 'callback' || action === 'refresh') clearSession()
    return reply(
      {
        error:
          action === 'logout'
            ? 'revocation_unconfirmed'
            : 'authentication_failed',
      },
      502,
    )
  }
}

export const GET = handle
export const POST = handle
// app/account/page.tsx
import { cookies } from 'next/headers'
import { currentCustomer, refreshCookie } from '@/lib/customer'

export const dynamic = 'force-dynamic'

export default async function AccountPage() {
  const { customer } = await currentCustomer()
  const canRefresh = Boolean((await cookies()).get(refreshCookie)?.value)
  if (!customer && !canRefresh) return <a href="/api/account/login">Sign in</a>
  return (
    <main>
      <h1>
        {customer ? `Welcome, ${customer.name}` : 'Session needs refresh'}
      </h1>
      <form method="post" action="/api/account/refresh">
        <button>Refresh session</button>
      </form>
      <form method="post" action="/api/account/logout">
        <button>Sign out</button>
      </form>
    </main>
  )
}

정상·빈 결과·실패

/account를 열고 등록한 issuer에서 로그인한 뒤 /account로 돌아오세요. 표시되는 이름은 me()에서 가져옵니다.

상황화면
액세스 토큰이 없거나 me()가 401을 반환하고, 리프레시 쿠키도 없음Sign in 링크를 보여 줍니다.
리프레시 쿠키가 남아 있음(900초 액세스 쿠키가 만료된 뒤 포함)Refresh session과 Sign out 버튼을 계속 제공합니다.
기능·워크스페이스·네트워크 오류앱의 오류 경계로 전달합니다. 로그아웃 상태처럼 보여 주지 마세요.

평소 쓰는 Next.js error.tsx 재시도 화면을 추가하되, 외부 오류 본문은 드러내지 마세요.

세션 새로고침. POST로 Refresh session을 실행하면 클라이언트에 묶인 리프레시 토큰이 교체되며, 리프레시 토큰이 유효하면 액세스 토큰이 만료된 뒤에도 됩니다. 성공하면 /account로 돌아갑니다. 새로고침 요청은 세션마다 직렬화하고, 이전 토큰을 다시 보내거나 토큰 교환을 자동으로 재시도하지 마세요. 이 쿠키 예제는 브라우저마다 로그인 흐름 하나와 새로고침 하나만 동시에 지원하며, 두 번째 로그인을 시작하면 흐름 쿠키가 바뀝니다. 여러 탭에서 새로고침하려면 쿠키를 따로 복사하지 말고 직렬화된 공유 서버 세션을 쓰세요.

로그아웃. 로그아웃은 앱 쿠키를 지우고 호스티드 서비스에 세션 family 철회를 요청합니다.

  • SDK의 success는 호스티드 서비스가 요청을 수락했다는 뜻입니다. 실제로 세션을 정리했는지는 앱에 보이지 않습니다.
  • 보낸 리프레시 토큰이 만료되지 않았고 이 워크스페이스의 이 클라이언트에 발급된 것으로 인식되면 정리합니다. 토큰이 이미 교체됐거나 family가 이미 철회됐어도 마찬가지입니다. 토큰이 없거나 만료됐거나 다른 클라이언트의 것이면 정리하지 않으며, 앱은 이 경우들을 구분할 수 없습니다.
  • 정리는 워크스페이스 안의 모든 클라이언트에 걸친 그 고객의 세션을 대상으로 하며 legacy-direct 세션도 포함합니다. Customers SDK: Hosted 세션 메서드를 참고하세요.
  • 쿠키를 지우는 것만으로는 철회되지 않습니다. 요청이 실패하거나 리프레시 쿠키가 없으면 응답은 revocation_unconfirmed입니다. 앱에서는 로그아웃됐지만 서버 철회는 확인되지 않은 상태입니다. 서버 철회가 성공했다고 표시하거나 추측한 자격 증명으로 재시도하지 마세요. 다시 로그인하게 하거나 계정 지원 절차를 안내하세요.

없거나 만료된 state 쿠키, 일치하지 않는 state, callback 재전송, 잘못된 PKCE verifier, 꺼졌거나 잘못된 클라이언트, 등록하지 않은 origin·redirect·로그아웃 URI, 만료됐거나 재사용된 리프레시 토큰, 다른 워크스페이스의 자격 증명을 시험하세요. 토큰, 사용자 정보, 앱 세션 응답은 no-store여야 합니다. 이 예제의 same-origin POST 검사는 쿠키를 쓰는 새로고침·로그아웃 경로를 보호하며, Next.js Server Action은 origin을 스스로 검사합니다. callback 쿼리 문자열, 토큰, 개인정보, 원본 오류는 접근 로그, 분석 도구, 스크린숏, 클라이언트 응답에 남기지 마세요.

Sandbox에서 테스트

Sandbox 워크스페이스는 고객 인증 메일을 그 Sandbox 멤버 본인 주소로만 보냅니다. 이메일 코드를 시험하려면 현재 이메일을 인증한 멤버 주소로 테스트 고객을 만드세요.

로그인 방법Sandbox에서
비밀번호테스트할 수 있습니다.
Google, Kakao플랫폼 공용 개발 앱이 구성된 제공자를 클라이언트에서 켜면 테스트할 수 있습니다.
이메일 코드멤버 본인 주소의 고객이면 테스트할 수 있습니다. 메일은 플랫폼 발신자와 [Sandbox] 제목으로 갑니다. 다른 주소로는 코드가 전달되지 않으며, 시작 요청의 응답은 같습니다.

비밀번호 재설정의 Sandbox 제한은 기존 로컬 비밀번호 재설정 완료를 참고하세요.

기존 앱의 direct legacy 호환

이미 direct 로그인을 쓰는 기존 앱에만 이 경로를 유지하세요. 호스티드 클라이언트가 없다고 우회 수단으로 쓰지 마세요. 시험 입력은 비공개로 넣으며, 예제는 고객 데이터 대신 boolean만 반환합니다.

// Existing direct-auth applications only; call from a trusted server.
import { createClient } from '@01.software/sdk'

export async function legacySession(email: string, password: string) {
  const client = createClient({
    publishableKey: process.env.NEXT_PUBLIC_SOFTWARE_PUBLISHABLE_KEY!,
    apiUrl: process.env.SOFTWARE_API_URL,
    customer: { persist: false },
  })
  await client.customer.auth.login({ email, password })
  const errors: unknown[] = []
  let signedIn = false
  try {
    signedIn = (await client.customer.auth.me()) !== null
  } catch (error) {
    errors.push(error)
  }
  try {
    await client.customer.auth.logoutAsync()
  } catch (error) {
    errors.push(error)
  }
  try {
    client.customer.auth.logout()
  } catch (error) {
    errors.push(error)
  }
  if (errors.length === 1) throw errors[0]
  if (errors.length > 1) {
    throw new AggregateError(errors, 'Customer session and cleanup failed', {
      cause: errors[0],
    })
  }
  return { signedIn, revocationConfirmed: true }
}

로그인에 성공하면 사용자 조회와 두 정리 작업을 차례로 실행합니다. 오류가 하나면 그대로 다시 던집니다. 정리도 실패하면 AggregateError.cause와 errors[0]에 원래의 사용자 조회 오류가, 이후 항목에 정리 실패가 남습니다. 이 오류는 신뢰할 수 있는 서버에서만 확인하고, 원본 오류나 자격 증명을 브라우저로 돌려주지 마세요.

logoutAsync()는 Publishable Key가 속한 워크스페이스의 legacy-direct refresh family와 교체로 생긴 후속 토큰만 철회합니다. 이미 발급한 액세스 JWT를 즉시 철회하지는 않습니다. 서버 호출이 실패해도 로컬 토큰 쌍은 지우고 실패를 알립니다. 동기 logout()은 로컬 상태만 지웁니다.

호스티드 로그아웃과 direct 로그아웃은 효과가 다릅니다. Community Forum 스타터의 로그아웃 흐름은 배포 가이드에서 확인하세요.

콘솔에서 고객 인증 상태 확인

고객 상세의 인증 상태 패널은 읽기 전용입니다. 비밀번호 없이 수동 등록한 고객에게는 비밀번호 없음이 표시됩니다. 이 안내는 비밀번호의 설정 여부만 알려줍니다.

콘솔에서 교환·이용 쿠폰 처리하기

워크스페이스를 명시적으로 선택하고 고객 상세를 연 뒤 교환·이용 쿠폰을 찾으세요. 교환·이용 쿠폰은 최대 10개까지, 사용 가능한 것부터 표시됩니다.

  • tenant-admin, tenant-editor: 사용 가능한 쿠폰에서 사용 처리를 선택합니다.
  • tenant-admin: 무효화를 선택하고 사유를 고른 뒤 확인합니다.
  • tenant-viewer: 보기만 할 수 있습니다. super-admin도 워크스페이스를 명시적으로 선택해야 합니다.

확인하기 전에 고객과 쿠폰을 다시 보세요. 사용 처리와 무효화는 되돌릴 수 없으며, 바로잡으려면 쿠폰을 새로 발급해야 합니다. 응답이 유실되면 같은 확인 화면에서 다시 시도하세요. 기록된 결과를 가져올 뿐 명령을 두 번 적용하지 않습니다. 역할이나 워크스페이스가 바뀌면 버튼이 보여도 명령이 거부될 수 있으니, 거부되면 페이지를 새로고침하세요.

사용 처리에는 Ecommerce와 Customers가 모두 필요합니다. Customers를 끄면 고객 상세와 이 패널이 숨겨지지만, 서버 조회, 고객 본인 조회, 서버 무효화는 계속 쓸 수 있습니다. 발급용 앱과 직원용 앱은 서버 쿠폰 발급·사용 경로를 사용합니다.

선택적 핸들 비밀번호 로그인

워크스페이스 관리자는 Console의 설정 → 워크스페이스 접근 → 고객 로그인에서 아이디 로그인을 켤 수 있습니다. 서버 설정은 customerAuth.handleLoginEnabled입니다. 기본값은 꺼짐입니다. 별도 비공개 로그인 ID가 아닌 현재 계정 핸들을 쓰며, 핸들 migration과 서버 배포가 끝난 뒤 활성화합니다.

기존 이메일·비밀번호 요청은 유지됩니다. 활성화된 워크스페이스에서는 직접 client.customer.auth.login({ handle, password })에 이메일 대신 핸들 하나를 보냅니다. 둘 다 보내거나 둘 다 없으면 검증 오류입니다. CustomerLoginData의 기존 이메일 인터페이스는 유지되고 CustomerPasswordLoginData가 핸들 분기를 추가합니다. 저장된 이메일과 로컬 비밀번호가 있는 계정만 핸들 로그인을 사용할 수 있습니다. 설정을 꺼도 이메일 로그인, 공개 핸들, 기존 세션은 유지됩니다.

신규 스토어는 hosted OAuth를 권장합니다. 설정을 켠 워크스페이스의 hosted 화면에서 이메일 또는 아이디로 로그인을 선택하면 이메일 또는 현재 핸들과 비밀번호를 입력할 수 있습니다. 비밀번호 없는 이메일 코드와 비밀번호 재설정은 이메일을 계속 사용합니다. 변경하거나 지운 이전 핸들은 로그인 별칭으로 남지 않습니다. 이메일과 핸들에는 같은 로그인 오류·잠금 규칙이 적용됩니다.

다음 액션

이 호스티드 세션은 Community와 Events 참여에 사용하세요. origin, 자격 증명, 배포 점검은 인증과 키와 배포를 참고하세요.

목차