React 단일 페이지 앱에 로그인 추가하기

이 퀵스타트는 Vite로 빌드한 React 단일 페이지 앱에 @keenauth/js 브라우저 SDK를 사용하여 KeenAuth 로그인을 추가합니다. 앱은 리디렉션으로 사용자를 로그인시키고, DPoP에 바인딩된 액세스 토큰으로 보호된 API를 호출하며, 사용자를 로그아웃시킵니다. 또한 오프라인에서 테스트하는 방법도 다룹니다. SDK에 대한 설명은 JavaScript 브라우저 SDK를 참조하십시오.

단일 페이지 앱에서는 토큰이 브라우저에 저장됩니다. SDK는 토큰을 메모리에만 보관하고 추출할 수 없는 키에 DPoP로 바인딩하므로, 탈취된 토큰은 해당 키 없이는 사용할 수 없습니다. 앱에 서버가 있다면 Next.js 퀵스타트에서 설명하는 방식이 더 안전합니다. 이 방식은 모든 토큰을 서버에 보관합니다(BFF, backend for frontend).

이 앱에는 다음 경로가 있습니다:

경로 역할
/ 로그인 버튼 또는 사용자 정보, API를 호출하는 버튼, 로그아웃 버튼
/callback 리디렉션 URI입니다. SDK가 여기서 로그인을 완료하고 앱은 사용자가 시작한 페이지로 돌아갑니다

시작하기 전에

1. 설치

npm create vite@latest my-app -- --template react-ts
cd my-app
npm install @keenauth/js

SDK는 아직 공개되지 않았습니다. 공개되기 전까지 예제는 package.json에서 SDK 소스를 가리킵니다. 예제는 vite@8.3.4, @vitejs/plugin-react@6.1.2, react@19.3.1, react-dom@19.3.1 버전을 고정합니다.

2. 구성

앱은 구성을 환경 변수에서만 읽습니다. Vite가 로드하는 .env.local에 설정하고, 이 파일은 절대 커밋하지 마십시오. Vite는 모든 VITE_ 변수를 브라우저가 내려받는 JavaScript에 포함하므로, 여기에 시크릿을 넣지 마십시오. 이 앱에는 시크릿이 없습니다.

변수 필수 여부 의미
VITE_KEENAUTH_ISSUER 예 환경의 발급자
VITE_KEENAUTH_CLIENT_ID 예 단일 페이지 애플리케이션의 client_id
VITE_KEENAUTH_API_AUDIENCE 예 KeenAuth에 등록된 API의 식별자
VITE_API_URL 아니요 API의 기본 URL입니다. 비워 두면 앱 자체의 출처를 사용합니다
API_PROXY_TARGET 아니요 개발 전용: 개발 서버가 /api를 전달하는 대상; 기본값 http://localhost:8080

loadConfig는 이 값들을 읽고 검사합니다. 값이 없으면 변수 이름을 밝힌 오류와 함께 앱을 중단합니다:

export function loadConfig(env: Readonly<Record<string, unknown>>): AppConfig {
  const need = (name: string): string => {
    const v = env[name];
    if (typeof v !== "string" || v.trim() === "") throw new Error(`${name} is required (see .env.example)`);
    return v.trim();
  };

3. SDK 클라이언트 생성

export function createAuth(config: AppConfig, origin: string, inject: Injectables = {}): KeenAuth {
  return new KeenAuth({
    issuer: config.issuer,
    clientId: config.clientId,
    redirectUri: `${origin}${CALLBACK_PATH}`,
    postLogoutRedirectUri: `${origin}/`,
    // The access token is for this API: its aud is the API identifier (RFC 8707).
    resource: config.apiAudience,
    // messages:read is a scope of that API; the example API requires it on /api/messages.
    scope: "openid profile email offline_access messages:read",
    ...inject,
  });
}

resource는 API의 식별자입니다(RFC 8707). SDK는 이를 인가 요청, 코드 교환 및 모든 갱신 요청에 포함하며, 액세스 토큰의 대상은 여러분의 API가 됩니다. 이 값이 없으면 액세스 토큰은 여러분의 API용이 아니므로 API가 이를 거부합니다. SDK는 클라이언트를 생성할 때 모든 옵션을 검사하며, 잘못된 issuer 또는 resource는 코드 config_invalid의 KeenAuthError를 발생시킵니다.

페이지마다 클라이언트 하나를 생성하고 프로바이더에 전달하십시오:

const config = loadConfig(import.meta.env);
const auth = createAuth(config, window.location.origin);

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <AuthProvider auth={auth}>
      <App apiUrl={config.apiUrl} />
    </AuthProvider>
  </StrictMode>,
);

4. 로그인 및 콜백 처리

로그인 버튼은 리디렉션 로그인을 시작합니다. PKCE(S256), state, nonce를 사용하는 인가 코드 흐름입니다. appState는 돌아갈 경로입니다.

        <button onClick={() => auth.signInRedirect({ appState: window.location.pathname })}>Sign in</button>

KeenAuth가 /callback으로 다시 리디렉션하면 프로바이더가 로그인을 완료합니다. SDK는 state, iss와 ID 토큰을 검사하고, DPoP 증명과 함께 코드를 교환합니다. 인가 응답은 한 번만 사용할 수 있으며, React의 StrictMode는 개발 환경에서 effect를 두 번 실행하므로 completeSignIn은 첫 번째 결과를 기억합니다:

export function completeSignIn(auth: KeenAuth, href: string): Promise<string | null> {
  const url = new URL(href);
  if (url.pathname !== CALLBACK_PATH || !(url.searchParams.has("code") || url.searchParams.has("error"))) {
    return Promise.resolve(null);
  }
  let done = completions.get(auth);
  if (!done) {
    done = auth.handleRedirectCallback(href).then((r) => safeReturnPath(r.appState));
    completions.set(auth, done);
  }
  return done;
}

그다음 프로바이더는 주소 표시줄에서 code와 state를 제거합니다. 반환 경로로는 앱 내부 경로만 사용하므로, 조작된 appState로 사용자를 다른 사이트로 보낼 수 없습니다:

export function safeReturnPath(path: string | undefined): string {
  return path !== undefined && /^\/(?![/\\])/.test(path) ? path : "/";
}

운영 환경에서도 /callback에 대해 index.html을 제공하십시오(Vite 개발 서버는 이미 이렇게 동작합니다).

5. API 호출

authorizedFetch는 토큰에 바인딩된 새 증명과 함께 Authorization: DPoP <token>을 보내며, 토큰이 만료되기 직전에 갱신합니다:

export async function fetchMessages(auth: KeenAuth, apiUrl: string): Promise<unknown> {
  const res = await auth.authorizedFetch(`${apiUrl}/api/messages`, { headers: { Accept: "application/json" } });
  if (!res.ok) throw new Error(`The API answered ${res.status}`);
  return res.json();
}

개발 환경에서는 Vite 개발 서버가 /api를 API로 전달하므로, 브라우저는 앱 자체의 출처에서 호출하며 API에 CORS가 필요하지 않습니다:

      proxy: { "/api": env.API_PROXY_TARGET || "http://localhost:8080" },

API는 DPoP 증명을 브라우저가 호출한 URL과 대조하여 검사합니다. 이 프록시 뒤에서 Express 퀵스타트의 API를 실행한다면 PUBLIC_BASE_URL을 http://localhost:5173으로 설정하십시오. 운영 환경에서는 API를 같은 출처에서 제공하거나 앱의 출처에 대해 API의 CORS를 활성화하고, VITE_API_URL을 설정하십시오.

6. 로그아웃

      <button onClick={() => auth.signOut()}>Sign out</button>

signOut은 토큰을 삭제하고, 갱신 토큰을 폐기하며, 브라우저를 환경의 로그아웃 엔드포인트로 보내고, 이 엔드포인트가 브라우저를 /로 되돌립니다.

7. 앱 실행

Express 퀵스타트의 API를 PUBLIC_BASE_URL=http://localhost:5173과 API 식별자로 설정한 KEENAUTH_AUDIENCE로 시작한 다음 앱을 실행하십시오:

cp .env.example .env.local   # then fill it in
npm run dev

http://localhost:5173을 여십시오. Sign in을 선택하고, KeenAuth에서 로그인한 다음 Call the API를 선택하십시오.

SDK는 토큰을 메모리에만 보관합니다. 페이지를 새로 고치면 앱에서 사용자는 로그아웃 상태가 되며 다시 로그인해야 합니다. KeenAuth 세션이 있으면 보통 입력 창 없이 리디렉션만으로 로그인됩니다.

8. 네트워크 없이 테스트

테스트에는 네트워크와 KeenAuth 계정이 필요하지 않습니다. 가짜 fetch가 인가 서버와 API 역할을 하며, ID 토큰은 테스트용으로 만든 키로 서명됩니다. Node가 TypeScript 테스트를 직접 실행합니다.

  assert.deepEqual(await fetchMessages(s.auth, ""), { messages: ["hello"] });
  const call = s.seen.find((c) => c.url === `${ORIGIN}/api/messages`)!;
  assert.equal(call.headers.get("Authorization"), "DPoP access-token-1");
npm test
npm run typecheck
npm run deps    # pinned versions, lockfile sources and licences
npm run build

테스트는 다음을 확인합니다: 구성이 검증되는지, SDK가 잘못된 issuer 또는 resource를 거부하는지, 로그인이 PKCE S256과 API를 resource로 보내는지, 콜백이 한 번만 완료되고 코드와 함께 resource를 보내는지, 위조된 state가 토큰 요청 전에 거부되는지, 로그인 후에 앱 내부 경로만 사용되는지, 토큰이 저장소에 기록되지 않는지, 그리고 API 호출에 DPoP에 바인딩된 토큰과 그 증명이 포함되는지.

실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.

다음 단계