구매자 인증
로그인 화면은 스토어프론트가 그리고, 계정과 인증은 sayren이 처리하기
구매자 계정은 sayren이 보관합니다. 스토어프론트는 로그인·가입 화면만 그리고 @sayren/storefront-sdk/auth로 결과를 받습니다.
어느 방식으로 로그인해도 같은 구매자 토큰이 나오고, 이 토큰으로 /me/*·장바구니·주문 API를 호출합니다.
준비
- Storefront SDK 설치와 상점 코드
- 소셜 로그인을 쓰면 설정 › 로그인에 공급자 키와 스토어프론트 도메인 등록(소유자만)
import { createStorefrontAuth } from "@sayren/storefront-sdk/auth";
const auth = createStorefrontAuth({ baseUrl: "https://api.sayren.io/storefront/v1", storeCode: "{상점 코드}" });| 메서드 | 하는 일 | 결과 |
|---|---|---|
anonymous() | 비회원 세션 발급 | { cartToken, expiresAt } |
session({ email, password, cartToken? }) | 이메일·비밀번호 로그인 | 토큰 쌍 |
signUp({ email, password, name, phone?, agreements, cartToken? }) | 가입과 동시에 로그인 | 토큰 쌍 |
idp(provider, { redirectUri, agreements? }) | 소셜 로그인 시작(카카오·네이버·구글) | { url, state, codeVerifier } |
idpCallback({ code, state, error?, expectedState, codeVerifier, cartToken? }) | 소셜 로그인 마무리 | 토큰 쌍 |
identities(accessToken) | 로그인 수단 조회 | { hasPassword, hasExternalAuth, identities } |
linkIdp(accessToken, provider, { redirectUri }) | 소셜 계정 연결 시작 | { url, state, codeVerifier } |
linkIdpCallback(accessToken, provider, { code, state, error?, expectedState, codeVerifier }) | 연결 마무리 | identities()와 같음 |
unlinkIdp(accessToken, provider) | 연결 해제 | identities()와 같음 |
refresh(refreshToken) | 토큰 갱신 | 새 토큰 쌍 |
signOut(accessToken) · withdraw(accessToken, { password? }) | 로그아웃 · 탈퇴 | 없음 |
토큰 쌍은 { accessToken, refreshToken, expiresIn }입니다. 액세스 토큰은 30분, 리프레시 토큰은 14일입니다.
토큰은 서버에 보관하기
로그인은 스토어프론트 서버(라우트 action, 서버 함수)에서 부르고 토큰은 HttpOnly 쿠키에 두십시오. 브라우저 스크립트가 읽지 못해 스크립트 삽입으로 새지 않습니다.
const tokens = await auth.session({ email, password, cartToken });
const expiresAt = Date.now() + tokens.expiresIn * 1000;
// { accessToken, refreshToken, expiresAt }를 HttpOnly 쿠키에 저장- 요청마다
expiresAt을 보고 만료가 가까우면refresh()로 새 쌍을 받아 쿠키를 바꿉니다. 리프레시 토큰은 한 번 쓰면 폐기됩니다. - 여러 요청이 같은 토큰으로 동시에 갱신해도 됩니다. 30초 안의 3번까지는 각각 새 쌍을 줍니다.
refresh()가401 INVALID_REFRESH_TOKEN이면 쿠키를 지우고 로그아웃 상태로 보여 주십시오.
로그인 방법 확인
셀러가 설정 › 로그인(소유자만)에서 켠 방법만 동작합니다. 상점 공개 설정의 loginMethods를 읽어 버튼을 그립니다.
const store = await sdk.store.get();
store.loginMethods; // 예: ["password", "kakao", "naver"]password가 없으면 이메일·비밀번호 가입·로그인이 403 PASSWORD_LOGIN_DISABLED입니다.
소셜 로그인
공급자 키는 셀러가 설정 › 로그인에 등록합니다. 스토어프론트는 키 없이 두 번만 호출합니다.
- 로그인 버튼 action에서
idp()로 공급자 로그인 주소를 받습니다.state·codeVerifier는 HttpOnly 쿠키 같은 서버 세션에 보관하고 구매자를url로 보냅니다. - 구매자가
redirectUri로 돌아오면 쿼리의code·state와 보관한 값으로idpCallback()을 불러 토큰 쌍을 받습니다.
// 1. 로그인 버튼 action
const started = await auth.idp("kakao", { redirectUri: "https://shop.example.com/auth/callback" });
// started.state·codeVerifier를 쿠키에 보관하고 started.url로 redirect
// 2. /auth/callback loader
const url = new URL(request.url);
const tokens = await auth.idpCallback({
code: url.searchParams.get("code"),
state: url.searchParams.get("state"),
error: url.searchParams.get("error"),
expectedState: saved.state,
codeVerifier: saved.codeVerifier,
cartToken, // 비회원 장바구니를 합치려면
});redirectUri는 설정 › 로그인의 스토어프론트 도메인 안이어야 합니다.- 구매자가 취소하면
?error=access_denied로 돌아오고idpCallback()이IdpCallbackError(reason)를 던집니다.code는 1분 안에 한 번만 씁니다. - 처음 보는 공급자 계정이면 새 구매자가 생깁니다. 같은 이메일의 구매자와 합치지 않고, 그때와 공급자가 이메일을 주지 않을 때는
email: null입니다.
소셜 계정 연결
로그인한 구매자가 소셜 계정을 더 붙입니다. 흐름은 소셜 로그인과 같고 액세스 토큰을 함께 넘기는 점만 다릅니다.
// 1. 연결 버튼 action — state·codeVerifier와 "연결 중" 표시를 쿠키에 보관하고 started.url로 redirect
const started = await auth.linkIdp(accessToken, "kakao", { redirectUri: "https://shop.example.com/auth/callback" });
// 2. /auth/callback — 연결 흐름이면 linkIdpCallback()
const identities = await auth.linkIdpCallback(accessToken, "kakao", {
code: url.searchParams.get("code"),
state: url.searchParams.get("state"),
error: url.searchParams.get("error"),
expectedState: saved.state,
codeVerifier: saved.codeVerifier,
});흐름을 시작한 구매자만 마무리할 수 있습니다(아니면 400 INVALID_GRANT). 마지막 로그인 수단은 unlinkIdp()로 끊을 수 없습니다.
MCP 템플릿의 /account 화면에 이 흐름이 들어 있습니다.
자체 회원 연동
회원 시스템이 이미 있으면 로그인은 직접 하고, 서버에서 sayren 구매자 토큰만 받습니다(구매자 위임).
- Sayren 계정 › 액세스 토큰에서 토큰을 만듭니다. 권한에
customer:rw를 넣고 구매자 위임을 켜고, 상점 범위에 이 상점을 넣습니다. 만든 사람의 역할에customer:rw가 있어야 합니다(소유자·관리자). - 토큰과 상점 id를 서버 환경변수에 둡니다. 토큰은 만든 직후 한 번만 보입니다.
- 사용자가 로그인하면 서버에서
delegateMember()로 구매자 토큰을 받습니다.
import { delegateMember } from "@sayren/store-sdk/server";
const { accessToken, memberId, expiresIn } = await delegateMember({
baseUrl: "https://api.sayren.io",
accessToken: process.env.SAYREN_ACCESS_TOKEN!,
storeId: process.env.SAYREN_STORE_ID!,
uid: user.id, // 내 회원 시스템의 사용자 id
email: user.email, // 처음 만들 때만 씀
name: user.name,
authenticatedAt: session.loggedInAt, // Date — 사용자가 실제로 로그인한 시각
});REST로는 POST /v1/customers/delegations(Authorization: Bearer <액세스 토큰>, X-Store-Id)입니다. 본문은 uid·email·name·authenticatedAt이고
응답 data는 { accessToken, expiresIn, memberId }입니다(인증과 계정 토큰).
- 처음 보는
uid면 구매자를 만들고, 같은uid는 이메일이 바뀌어도 같은 구매자입니다. 이메일이 다른 구매자 것이면 이메일 없이 만듭니다. - 토큰은 30분이고 리프레시 토큰이 없어 만료되면 같은 호출로 다시 받습니다. 계정 토큰을 쓰므로 서버에서만 부릅니다.
authenticatedAt이 없으면 비밀번호 없는 구매자의 탈퇴가403 REAUTH_REQUIRED입니다. 재발급할 때도 처음 로그인한 시각을 넘기십시오.- 실패하면
MemberDelegationError를 던집니다.error가 API 오류 코드입니다.
| 코드 | 상황 |
|---|---|
403 DELEGATION_NOT_ALLOWED | 액세스 토큰이나 Sayren 상점 플랫폼·CLI 로그인이 아닌 토큰(다른 앱의 로그인 토큰, AI 에이전트) |
403 INSUFFICIENT_SCOPE | 토큰에 member:delegate(구매자 위임)가 없음 |
403 INSUFFICIENT_ROLE | 역할에 customer:rw가 없음 |
403 STORE_NOT_GRANTED | 토큰의 상점 범위 밖의 상점 |
uid 없이 email만 보내면 이메일로 구매자를 찾습니다. 두 방식은 같은 구매자로 이어지지 않으니 처음부터 하나로 정하십시오.
0.27.0부터 delegateMemberByUid()·delegateMemberByEmail()(Client ID·Client Secret, token-exchange)은 없어졌습니다. delegateMember()로
바꾸고 앱 대신 액세스 토큰을 쓰십시오.
비회원
로그인하지 않은 구매자는 비회원 세션을 씁니다. 장바구니와 비회원 주문만 쌓이고 /me/*에는 쓸 수 없습니다.
const guest = await auth.anonymous();
// guest.cartToken을 쿠키에 보관하고 클라이언트의 cartToken 옵션으로 보낸다- 따로 부르지 않아도 됩니다. 장바구니 API를 토큰 없이 부르면 세션을 발급해
X-Cart-Token헤더로 돌려줍니다(onCartToken으로 받음). - 마지막 활동 후 30일이 지나면 만료되고 장바구니도 지워집니다. 쓸 때마다 기한이 늘어납니다.
- 만료됐거나 모르는 토큰이면 새 세션이 옵니다. 담은 적 없는 세션은 하루 뒤 지워집니다.
로그인하면 장바구니 합치기
session()·signUp()·idpCallback()에 비회원 cartToken을 보내면 회원 장바구니로 합칩니다. 같은 상품·옵션은 수량을 더합니다.
합친 뒤 비회원 세션이 닫히므로 비회원 토큰 쿠키를 지우고, 이후 장바구니는 액세스 토큰으로 조회합니다.
동의와 재동의
가입 때 받은 약관·개인정보·마케팅 동의는 버전과 함께 이력으로 쌓이고 지워지지 않습니다. 셀러가 약관 버전을 올리면
이전 버전에 동의한 구매자에게 재동의를 받습니다. 조회·변경은 createStorefrontClient의 member입니다(구매자 토큰 필요).
const consents = await client.member.consents();
// {
// terms: { kind: "TERMS", agreed: true, agreedVersion: "1", currentVersion: "2", currentDocumentUrl: "https://…", reconsentRequired: true, updatedAt },
// privacy: { … }, marketing: { … },
// reconsentRequired: true,
// }
await client.member.updateConsents({ terms: true }); // 개정 약관에 다시 동의
await client.member.updateConsents({ marketing: false }); // 마케팅 철회- 재동의 필요 여부는
GET /me의reconsentRequired로도 봅니다. 로그인 응답에는 없으니 화면을 열 때 조회하고,currentDocumentUrl을 링크로 보여 주십시오. - 필수 동의(
terms·privacy)는true만 보냅니다. 이미 현재 버전이면 이력을 더 남기지 않고, 재동의 전에도 이용은 막히지 않습니다. - 회원당 분당 10번입니다. 넘으면
429 TOO_MANY_REQUESTS(error.details.retryAfterSeconds)입니다.PATCH /me의marketingAgreed도 같은 이력에 남습니다.
소셜 가입의 동의
소셜 가입은 공급자 화면을 거치므로 가입 화면에서 받은 동의를 idp()를 시작할 때 보냅니다. 그 로그인으로 가입이 될 때만 이력에 남고,
기존 회원의 로그인이면 무시합니다.
await auth.idp("kakao", { redirectUri: `${origin}/auth/callback`, agreements: { terms: true, privacy: true, marketing: false } });terms와 privacy는 함께 true여야 합니다. 보내지 않아도 가입은 되지만 reconsentRequired가 true가 되니 첫 진입에서 updateConsents()로 받으십시오.
탈퇴
로그인한 구매자가 본인 확인 뒤 계정을 지웁니다. 되돌릴 수 없으니 화면에서 한 번 더 확인받으십시오.
await auth.withdraw(accessToken, { password });
// 성공하면 토큰 쿠키를 지운다- 계정·배송지·위시리스트·장바구니·적립금·동의 이력이 지워지고 토큰은 모두 무효가 됩니다. 주문·클레임·리뷰·문의 기록은 상점이 보관합니다.
- 진행 중 주문·클레임·결제가 있으면
409 WITHDRAWAL_BLOCKED이고error.details의inProgressOrderItems·openClaims·pendingPayments로 안내합니다. - 비밀번호를 5번 연속 틀리면 10분 잠깁니다.
- 비밀번호 없는 소셜 가입 구매자는
password를 빼고 부릅니다. 로그인 10분 안의 토큰이어야 하고, 갱신한 토큰이면403 REAUTH_REQUIRED입니다.
에러
실패는 ApiError로 올라오고 error.code로 나눕니다.
| 상태 | 코드 | 뜻 |
|---|---|---|
| 401 | INVALID_CREDENTIALS | 이메일·비밀번호 불일치 |
| 423 | ACCOUNT_LOCKED | 5번 연속 실패로 10분 잠김 |
| 409 | EMAIL_ALREADY_EXISTS | 이미 가입한 이메일 |
| 401 | INVALID_REFRESH_TOKEN | 리프레시 토큰 만료·폐기. 다시 로그인 |
| 403 | PASSWORD_MISMATCH | 탈퇴 본인 확인 비밀번호 불일치 |
| 409 | WITHDRAWAL_BLOCKED | 진행 중 주문·클레임·결제로 탈퇴 불가 |
| 403 | PASSWORD_LOGIN_DISABLED | 이메일·비밀번호 로그인 꺼짐 |
| 403 | REAUTH_REQUIRED | 최근 로그인 필요(소셜 전용 구매자 탈퇴) |
| 400 | INVALID_REDIRECT_URI | 스토어프론트 도메인 밖의 redirectUri |
| 400 | INVALID_GRANT | 만료·재사용·PKCE 불일치 소셜 code |
| 404 | LOGIN_PROVIDER_DISABLED | 켜지 않은 소셜 공급자 |
| 409 | IDENTITY_IN_USE | 다른 구매자에 연결된 소셜 계정 |
| 409 | PROVIDER_ALREADY_LINKED | 같은 공급자의 다른 계정이 이미 연결됨 |
| 409 | LAST_LOGIN_METHOD | 마지막 로그인 수단 해제 불가 |