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

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

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

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

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

## 시작하기 전에

- KeenAuth 환경. 발급자(issuer)는 `https://your-environment.eu.keenauth.app`와 같은 형식입니다.
- 환경에 단일 페이지 애플리케이션([애플리케이션 생성](/docs/api/management/applications/create))을 `app_type` `spa`, `token_endpoint_auth_method` `none`으로 만드십시오(퍼블릭 클라이언트는 시크릿이 없습니다). `development` 환경에서는 다음을 등록하십시오:
  - 리디렉션 URI `http://localhost/callback` (루프백 리디렉션 URI는 포트 없이 등록되며 모든 포트와 일치합니다);
  - 로그아웃 후 리디렉션 URI `http://localhost/`;
  - 허용된 웹 출처 `http://localhost:5173`, 브라우저가 토큰 엔드포인트를 호출할 수 있도록 하기 위함입니다.
- 환경에 `messages:read` 스코프를 가진 API를 등록하십시오([API 생성](/docs/api/management/apis/create)). 예를 들어 `https://api.example.com`과 같은 `identifier`가 해당 API 액세스 토큰의 대상(audience)입니다. [Express 퀵스타트](/docs/quickstarts/express)에서는 이러한 토큰을 허용하는 API를 만듭니다.
- Node.js 22.18 이상.

## 1. 설치

```sh
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`는 이 값들을 읽고 검사합니다. 값이 없으면 변수 이름을 밝힌 오류와 함께 앱을 중단합니다:

```ts
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 클라이언트 생성

```ts
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`를 발생시킵니다.

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

```tsx
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`는 돌아갈 경로입니다.

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

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

```ts
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`로 사용자를 다른 사이트로 보낼 수 없습니다:

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

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

## 5. API 호출

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

```ts
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가 필요하지 않습니다:

```ts
      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. 로그아웃

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

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

## 7. 앱 실행

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

```sh
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 테스트를 직접 실행합니다.

```ts
  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");
```

```sh
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 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.

## 다음 단계

- [JavaScript 브라우저 SDK](/docs/sdk/js): 모든 옵션, 토큰 보관 위치 및 현재 제한 사항.
- [React SDK](/docs/sdk/react): 내장 로그인, 가입 및 계정 컴포넌트.
- [Express API 보호하기](/docs/quickstarts/express): 이 앱이 호출하는 API.
