01.software 문서

Customers

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

Customers

준비

customers를 활성화하고 대상 tenant·환경의 Publishable Key를 받으세요. 운영자에게 enabled hosted clientId, exact normalized HTTPS 앱 origin, callback URI(APP_ORIGIN/api/account/callback) 등록을 요청하세요. issuer는 client별 등록값이 아니라 해당 환경의 플랫폼 host이며 운영자에게 확인합니다. 이 레시피는 postLogoutRedirectUri를 보내므로 logout URI(APP_ORIGIN/account)도 등록합니다. 이 등록은 해당 값을 보낼 때만 필요합니다. wildcard와 loopback host는 허용하지 않습니다. 브라우저 API origin은 별도로 등록합니다. HTTPS 배포 또는 HTTPS 개발 origin을 사용하세요. 프로젝트 루트의 @/* alias가 설정된 Next.js App Router 앱에 복사하고 server-only를 설치하세요(npm install server-only@0.0.1).

예제는 공개 SDK 0.65.1, Next.js 16.3.8 App Router, React 19.2.4, Node 24.15.0 조합입니다. 자신의 앱에 npm install @01.software/sdk@0.65.1로 설치하세요. force-dynamic으로 SDK 호출을 build가 아닌 요청 때 실행합니다. cacheComponents와 static export를 쓰지 않는 구성입니다. 컴파일과 통제된 로컬 transport 검사는 hosted 제공 여부나 원격 tenant admission의 증거가 아닙니다.

# .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://your-hosted-issuer.example
APP_ORIGIN=https://your-app.example

issuer와 API URL은 운영자가 확인한 환경을 사용합니다. 고객 앱에 운영자 PAT, Secret Key, 서명 secret, 내부 exchange secret을 넣지 마세요.

Hosted 로그인 구현

세 파일을 모두 복사하세요. 요청마다 non-persisted SDK client를 만듭니다. callback은 state/verifier 쿠키를 소비하고 state를 검사한 뒤 PKCE로 묶인 code를 한 번 교환하고 me()로 신원을 읽습니다. access/refresh pair는 Secure HttpOnly host 쿠키에 두며 브라우저 storage나 URL에 넣지 않습니다. SDK는 idToken을 검증하지 않으므로 opaque 값으로 취급하세요.

// 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() 결과입니다. access token이 없거나 me()가 401을 반환하면 refresh 쿠키가 없는 경우 로그인 링크를 표시합니다. refresh 쿠키가 남아 있으면 900초 access 쿠키가 만료된 뒤에도 Refresh session과 Sign out을 표시합니다. feature·tenant·네트워크 오류는 익명 성공으로 바꾸지 않고 앱 error boundary로 전파합니다. 원격 오류 본문을 노출하지 않는 Next.js error.tsx 재시도 UI를 추가하세요.

Refresh session을 POST해 client-bound refresh token을 회전합니다. access가 만료돼도 refresh token이 유효하면 갱신할 수 있으며, 성공하면 /account로 돌아옵니다. 세션별 refresh 요청을 직렬화하고 이전 token 재사용이나 token exchange 자동 재시도를 하지 마세요. 이 최소 쿠키 예제는 브라우저당 하나의 로그인 흐름과 한 번의 refresh만 동시에 처리합니다. 두 번째 로그인은 이전 flow 쿠키를 대체합니다. 여러 탭의 동시 회전에는 독립 쿠키 복사본 대신 직렬화된 공유 server session을 사용하세요.

로그아웃은 앱 쿠키를 지우고 hosted family revocation을 요청합니다. SDK success는 hosted가 로그아웃 요청을 수락했다는 뜻입니다. session을 sweep했는지는 앱에 노출되지 않습니다. hosted는 제출한 refresh token을 이 tenant에서 이 client에 발급된 미만료 token으로 인식하면, 이미 회전됐거나 family가 철회됐더라도 sweep합니다. 없거나 만료됐거나 다른 client의 token이면 sweep하지 않으며 앱은 어느 경우인지 알 수 없습니다. Hosted logout 계약에 따라 sweep은 session fence를 기준으로 해당 tenant에서 그 고객의 모든 client session(legacy-direct 포함)에 적용됩니다. fence 이후 발급 family가 유지되는 재시도는 같은 durable revocation intent의 서버 내부 재시도를 뜻하며, 앱의 logout 요청 재전송을 보장하지 않습니다. 쿠키 삭제만으로 서버 철회가 되지는 않습니다. 요청 실패나 refresh 쿠키 누락은 revocation_unconfirmed입니다. 앱에서는 로그아웃됐지만 서버 철회는 미확인입니다. 서버 철회 성공으로 표시하거나 추측한 credential로 재시도하지 마세요. 다시 로그인하거나 계정 지원 절차를 따릅니다.

누락·만료 state 쿠키, state 불일치, callback replay, 잘못된 PKCE verifier, disabled/wrong client, 미등록 origin/redirect/logout URI, 만료·재사용 refresh, 다른 tenant credential을 확인하세요. token·identity·앱 session 응답은 no-store여야 합니다. 예제의 same-origin POST 검사는 쿠키 기반 refresh/logout route를 보호하고 Next.js Server Actions는 자체 origin 검사를 사용합니다. callback query, token, PII, raw error를 access log·analytics·스크린샷·클라이언트 응답에 남기지 마세요.

기존 앱의 direct legacy 호환

이미 direct 인증을 쓰는 기존 앱만 이 경로를 유지합니다. hosted client 부재의 우회책으로 쓰지 마세요. 테스트 입력은 비공개로 전달하며 예제는 고객 데이터 대신 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 }
}

로그인 성공 뒤에는 identity 조회와 두 cleanup을 순서대로 모두 시도합니다. 오류가 하나면 그대로 전파합니다. cleanup도 실패하면 AggregateError.cause와 errors[0]에 원래 identity 오류를 보존하고 이후 항목에 cleanup 오류를 보존합니다. 오류는 trusted server에서만 확인하고 raw error나 credential을 브라우저에 반환하지 마세요.

logoutAsync()는 Publishable Key tenant의 legacy-direct refresh family와 회전된 successor만 철회합니다. 이미 발급된 access JWT는 즉시 철회하지 않습니다. 서버 실패에도 로컬 pair를 지우고 실패를 보고합니다. 동기 logout()은 local-only입니다. 현재 Community Forum starter는 로그아웃에서 앱 HttpOnly 쿠키만 지우며 SDK logout이나 서버 family 철회를 호출하지 않습니다. hosted와 direct logout의 효과는 다릅니다.

콘솔에서 혜택권 처리하기

워크스페이스를 명시적으로 선택한 뒤 고객 상세의 혜택권 패널을 엽니다. 사용 가능한 혜택권부터 최대 10건을 표시합니다.

  • tenant-admin·tenant-editor: 사용 가능한 혜택권의 사용 처리를 누릅니다.
  • tenant-admin: 무효화를 누르고 사유를 선택한 뒤 확인합니다.
  • tenant-viewer: 조회만 가능합니다. super-admin도 워크스페이스를 명시적으로 선택해야 합니다.

확인 전에 고객과 혜택권을 확인하세요. 사용 처리와 무효화는 되돌릴 수 없으며, 정정하려면 새 혜택권을 발급해야 합니다. 응답을 받지 못했다면 같은 확인 창에서 재시도하면 중복 처리 없이 기록된 결과를 확인합니다. 버튼이 보여도 역할이나 워크스페이스가 바뀌면 서버가 거부할 수 있습니다. 거부되면 페이지를 새로고침하세요.

사용 처리는 Ecommerce와 Customers 기능이 모두 필요합니다. Customers를 끄면 고객 상세와 패널이 함께 숨겨집니다. 서버 조회·고객 본인 조회·서버 무효화는 계속 가능합니다. 발급과 직원 앱 처리는 서버 혜택권 채널을 사용합니다.

다음 액션

이 hosted session을 Community와 Events 참여에 사용하세요. 인증과 키, 배포에서 origin·credential·배포 검사를 이어갑니다.

목차