
# React Router 앱에 로그인 추가하기

이 빠른 시작에서는 프레임워크 모드의 React Router 8 앱에 표준 OpenID Connect 클라이언트 `openid-client`를 사용해 KeenAuth 로그인을 추가합니다. 로더와 액션은 서버에서 실행되므로 앱이 OAuth 클라이언트 역할을 합니다. 모든 토큰은 서버 측 세션에 보관되고, 브라우저는 HTML과 서명된 세션 ID 쿠키만 받습니다. 로그인 경로를 추가하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출하고, 로그아웃하고, 오프라인에서 앱을 테스트하게 됩니다.

서버가 없는 단일 페이지 앱이라면 [React 단일 페이지 앱에 로그인 추가하기](/docs/quickstarts/react)를 참고하세요.

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

| 경로 | 역할 |
|---|---|
| `GET /` | 공개 홈 페이지: 로그인 버튼, 또는 사용자 이름과 로그아웃 버튼 |
| `GET /dashboard` | 세션이 필요합니다. 사용자와 API가 반환하는 메시지를 표시합니다 |
| `POST /login` | 로그인을 시작합니다. 이 앱의 페이지에서만 요청할 수 있습니다 |
| `GET /auth/callback` | 응답을 확인하고, 코드를 교환하고, 세션을 시작합니다 |
| `POST /logout` | 여기와 KeenAuth에서 세션을 종료합니다. 이 앱의 페이지에서만 요청할 수 있습니다 |

## 시작하기 전에

- KeenAuth 환경. 발급자 주소는 `https://your-environment.eu.keenauth.app` 형식입니다.
- 환경에 등록된 웹 애플리케이션([애플리케이션 만들기](/docs/api/management/applications/create))과 클라이언트 비밀 값(`client_secret_basic`). `development` 환경에서는 리디렉션 URI `http://localhost/auth/callback`와 로그아웃 후 리디렉션 URI `http://localhost/`를 등록하세요. 루프백 URI는 포트 없이 등록되며 `5173`처럼 모든 포트와 일치합니다.
- 환경에 등록된 API([API 만들기](/docs/api/management/apis/create))와 `messages:read` 범위. API의 `identifier`(예: `https://api.example.com`)는 이 API 액세스 토큰의 대상(audience)입니다. [Express 빠른 시작](/docs/quickstarts/express)에서 이런 토큰을 받는 API를 만듭니다.
- Node.js 22.22 이상과 프레임워크 모드의 React Router 앱(`npx create-react-router@8.5.0`).

## 1. 설치

```sh
npm install --save-exact openid-client@6.8.8
```

`openid-client`는 OpenID Connect 신뢰 당사자(relying party)를 구현합니다. 디스커버리, PKCE를 사용하는 인가 코드 흐름, RFC 9207 `iss` 검사, ID 토큰 검사를 담당합니다. 예제는 모든 패키지를 정확한 버전으로 고정하고 `package-lock.json`에서 `npm ci --ignore-scripts` 옵션으로 설치하므로, 설치 중에 패키지 스크립트가 실행되지 않습니다.

## 2. 설정

앱은 환경 변수에서만 설정을 읽습니다. 이 값을 `.env`에 넣으세요. `npm run dev`와 `npm start`가 `node --env-file=.env`로 이 파일을 불러오며, 이 파일은 절대 커밋하지 마세요.

| 변수 | 필수 | 의미 |
|---|---|---|
| `KEENAUTH_ISSUER` | 예 | 환경의 발급자 주소, `https` |
| `KEENAUTH_CLIENT_ID` | 예 | 웹 애플리케이션의 `client_id` |
| `KEENAUTH_CLIENT_SECRET` | 예 | 클라이언트 비밀 값 |
| `APP_BASE_URL` | 예 | 앱의 공개 URL. 리디렉션 URI는 이 값으로 만들어집니다. `http`는 `localhost`, `127.0.0.1` 또는 `[::1]`에서만 사용하세요 |
| `SESSION_SECRET` | 예 | 최소 32자. 세션 쿠키에 서명합니다. `openssl rand -base64 32`로 생성할 수 있습니다 |
| `API_URL` | 예 | 앱이 호출하는 API(예: `http://localhost:8080`) |
| `KEENAUTH_API_AUDIENCE` | 예 | KeenAuth에서 해당 API의 식별자 |

`loadConfig`의 `app/lib/config.server.ts`는 첫 요청에서 이 값들을 확인합니다. 값이 없거나 사용할 수 없으면 요청이 중단되며, 어떤 오류 메시지에도 값이 포함되지 않습니다.

```ts
  const issuer = url("KEENAUTH_ISSUER", false);
  const appBaseUrl = url("APP_BASE_URL", true).origin;
  const sessionSecret = need("SESSION_SECRET");
  if (sessionSecret.length < 32) throw new Error("SESSION_SECRET must be at least 32 characters");
```

`.server.ts`로 끝나는 파일은 브라우저 번들에 절대 포함되지 않습니다. 페이지가 이런 파일을 브라우저용으로 가져오면 React Router가 빌드를 중단합니다.

## 3. OpenID Connect 클라이언트 만들기

`app/lib/keenauth.server.ts`는 디스커버리(`<issuer>/.well-known/openid-configuration`)로 클라이언트를 한 번만 만듭니다.

```ts
export function keenauth(): Promise<client.Configuration> {
  discovered ??= client
    .discovery(config().issuer, config().clientId, undefined, client.ClientSecretBasic(config().clientSecret), {
      // Also check the ID token signature with your environment's keys (jwks_uri), not only the TLS connection.
      execute: [client.enableNonRepudiationChecks],
    })
```

기본적으로 `openid-client`는 TLS를 통해 토큰 엔드포인트에서 직접 받은 ID 토큰을 신뢰합니다. `enableNonRepudiationChecks`는 환경이 공개하는 키로 서명도 검사합니다.

## 4. 세션을 서버에 보관하기

세션은 서버의 파일에 있고, 쿠키에는 서명된 세션 ID만 들어갑니다.

```ts
  storage ??= createFileSessionStorage<SessionData>({
    dir: ".sessions",
    cookie: {
      name: "__keenauth_session",
      httpOnly: true,
      sameSite: "lax",
      // Secure whenever the app is served over https; plain http only for localhost.
      secure: config().redirectUri.startsWith("https:"),
      path: "/",
      maxAge: 8 * 60 * 60,
      secrets: [config().sessionSecret],
    },
  });
```

파일 저장소는 서버 한 대에서만 동작합니다. 서버가 여러 대라면 `createSessionStorage`를 사용하고 Redis 같은 공유 저장소를 쓰는 세션 저장소를 작성하세요. 토큰을 쿠키 세션 저장소에 두지 마세요. 토큰 두 개는 쿠키의 4KB 제한을 넘을 수 있고, 그러면 브라우저가 토큰을 보관하게 됩니다.

## 5. 로그인 시작하기

경로는 `app/routes.ts`에 나열합니다.

```ts
export default [
  index("routes/home.tsx"),
  route("dashboard", "routes/dashboard.tsx"),
  route("login", "routes/login.ts"),
  route("auth/callback", "routes/callback.ts"),
  route("logout", "routes/logout.ts"),
] satisfies RouteConfig;
```

로그인은 `/login`로 POST를 보내는 폼입니다.

```tsx
        <Form method="post" action="/login" reloadDocument>
          <button type="submit">Sign in</button>
        </Form>
```

액션은 로그인할 때마다 새 `state`, `nonce`, PKCE 검증기를 만들고 이를 세션에 보관합니다.

```ts
export async function action({ request }: Route.ActionArgs) {
  assertSameOrigin(request);
  const session = await getSession(request);
  const transaction = newTransaction();
  session.set("transaction", transaction);
  const url = await authorizationUrl(transaction);
  return redirect(url.href, { headers: { "Set-Cookie": await sessions().commitSession(session) } });
}
```

인가 요청에는 검증기의 S256 챌린지와 API 식별자를 `resource`(RFC 8707)로 보냅니다. `resource`가 없으면 액세스 토큰은 이 API용이 아니며 API가 토큰을 거부합니다.

```ts
  return client.buildAuthorizationUrl(await keenauth(), {
    redirect_uri: config().redirectUri,
    // messages:read is a scope of your API; the example API requires it on /api/messages.
    scope: "openid profile email messages:read",
    state: transaction.state,
    nonce: transaction.nonce,
    // PKCE (RFC 7636): S256 of the verifier that only this session knows.
    code_challenge: await client.calculatePKCECodeChallenge(transaction.codeVerifier),
    code_challenge_method: "S256",
    // RFC 8707: an access token for your API.
    resource: config().apiAudience,
  });
```

### 자체 페이지에서 온 POST만 허용하기

React Router는 페이지의 액션에 대한 사이트 간 POST를 거부합니다. 하지만 `/login`와 `/logout`는 리소스 라우트(페이지 없이 액션만 있는 라우트)이므로, 이 검사는 직접 구현해야 합니다. `assertSameOrigin`는 `Origin` 헤더(또는 헤더가 없으면 `Referer`)가 `APP_BASE_URL`를 가리킬 때만 POST를 허용합니다.

```ts
  const app = new URL(config().redirectUri).origin;
  const source = request.headers.get("Origin") ?? request.headers.get("Referer");
```

## 6. 콜백 처리하기

콜백은 먼저 세션에서 트랜잭션을 꺼내므로, `state`는 한 번만 작동합니다.

```ts
  const old = await getSession(request);
  const transaction = old.get("transaction");
  // The transaction works once: it leaves the session before anything else happens.
  old.unset("transaction");
```

`authorizationCodeGrant`는 `state`와 환경이 모든 인가 응답에 보내는 `iss`(RFC 9207)를 검사하고, PKCE 검증기로 코드를 교환한 뒤 ID 토큰을 검사합니다. 검사 항목은 서명, `iss`, `aud`, `exp`, `iat`, 그리고 이번 로그인의 `nonce`입니다. 리디렉션 URI는 `APP_BASE_URL`로 만들며, `Host` 헤더에서 가져오지 않습니다.

```ts
  const callbackUrl = new URL(config().redirectUri);
  callbackUrl.search = new URL(request.url).search;
  return client.authorizationCodeGrant(await keenauth(), callbackUrl, {
    pkceCodeVerifier: transaction.codeVerifier,
    expectedState: transaction.state,
    expectedNonce: transaction.nonce,
    idTokenExpected: true,
  });
```

검사에 실패하면 `401`가 고정된 오류 코드로 응답합니다. 로그인에 성공하면 앱은 이전 세션을 삭제하고, 사용자와 토큰을 새 세션에 보관합니다.

```ts
  // A new session id after sign-in prevents session fixation: the old session is deleted, a new one is created.
  await sessions().destroySession(old);
  const session = await sessions().getSession(null);
```

## 7. 페이지 보호하고 API 호출하기

사용자가 필요한 로더는 `requireUser`를 호출하며, 세션이 없으면 홈 페이지로 리디렉션합니다. 예제에는 리프레시 토큰이 없습니다. 액세스 토큰이 만료되면 사용자가 다시 로그인해야 합니다.

```ts
export async function loader({ request }: Route.LoaderArgs) {
  const { user, accessToken } = await requireUser(request);
  const api = await fetchMessages(accessToken);
  return { user, api };
}
```

로더는 액세스 토큰을 베어러 토큰으로 사용해 API를 호출합니다. 토큰은 서버에 남고, 페이지는 사용자와 메시지만 받습니다.

```ts
    response = await fetch(`${config().apiUrl}/api/messages`, {
      headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
    });
```

## 8. 로그아웃하기

로그아웃은 이 앱의 페이지에서 보내는 `POST`입니다. 액션은 로컬 세션을 종료하고, 브라우저를 환경의 로그아웃 엔드포인트(OpenID Connect RP-Initiated Logout)로 보냅니다. 이때 `id_token_hint`, `client_id`, `post_logout_redirect_uri`를 함께 보내며, 이후 브라우저는 앱으로 돌아옵니다.

```ts
  const session = await getSession(request);
  const idToken = session.get("idToken");
  const destroyed = await sessions().destroySession(session);
  return redirect((await logoutUrl(idToken)).href, { headers: { "Set-Cookie": destroyed } });
```

## 9. 앱 실행하기

Express 빠른 시작의 API(또는 이 토큰을 받는 다른 API)를 시작한 뒤 앱을 실행하세요.

```sh
npm run dev
```

`http://localhost:5173`를 열고 **Sign in**을 선택한 뒤 KeenAuth에서 로그인하고 대시보드를 여세요.

## 10. 네트워크 없이 테스트하기

테스트에는 네트워크나 KeenAuth 계정이 필요하지 않습니다. `npm test`는 앱을 빌드하고, 빌드 결과를 React Router의 요청 핸들러로 실행합니다. 가짜 `fetch`가 환경(디스커버리, 테스트 실행용으로 만든 키 세트, 토큰 엔드포인트)과 API 역할을 맡습니다.

```ts
test("callback: an unknown or reused state is refused before any token request and starts no session", async () => {
  const b = browser();
  await startSignIn(b);
  const wrong = await returnFromKeenAuth(b, { state: "not-the-state" });
```

```sh
npm test
npm run deps    # exact pins, lockfile sources and licences
```

테스트는 다음을 확인합니다. 설정이 검증되는지, 그리고 어떤 오류 메시지에도 비밀 값이 포함되지 않는지. 로그인이 PKCE S256을 보내는지, 그리고 `state`, `nonce`, `resource`가 포함되며 다른 사이트에서 오거나 `GET`가 있으면 거부되는지. 코드가 일치하는 검증기와 클라이언트 인증으로 교환되는지. 액세스 토큰이 `Authorization` 헤더로 API에만 전달되고 브라우저에는 절대 전달되지 않는지. 알 수 없거나 재사용된 `state`, 없거나 잘못된 `iss`, 인가 오류, 다른 클라이언트용이거나 다른 nonce를 가졌거나 다른 키로 서명된 ID 토큰이 세션 없이 거부되는지. 로그인할 때 새 세션 ID가 시작되는지. 세션이 없는 페이지가 홈 페이지로 리디렉션되는지. 다른 사이트에서 온 로그아웃이 거부되고, 정상 로그아웃은 `id_token_hint`와 함께 로그아웃 엔드포인트로 가는지.

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

## 프록시 또는 로드 밸런서 뒤에서 사용하기

`APP_BASE_URL`에 앱의 공개 URL을 설정하세요. 리디렉션 URI와 Origin 검사는 이 값을 사용하며, `Host` 헤더는 사용하지 않습니다. 프록시가 TLS를 종료하더라도 `APP_BASE_URL`는 여전히 `https` URL이므로, 세션 쿠키는 `Secure` 상태를 유지합니다.

## 현재 제한 사항

- 리프레시 토큰과 자동 로그인이 없습니다. 액세스 토큰이 만료되면 사용자가 다시 로그인해야 합니다.
- 파일 세션 저장소는 `.sessions/` 아래의 일반 파일에 토큰을 저장하며, 만료된 파일을 삭제하지 않습니다. 이 디렉터리를 보호하거나, 세션을 암호화하고 만료시키는 공유 저장소를 사용하세요.
- 클라이언트는 클라이언트 비밀 값으로 인증합니다. `openid-client`도 `private_key_jwt`를 지원하지만 예제에서는 보여주지 않습니다.

## 다음 단계

- [Express API 보호하기](/docs/quickstarts/express): 이 앱이 호출하는 API입니다.
- [토큰 검증](/docs/sdk/token-verification): API 미들웨어 아래에서 동작하는 검증기입니다.
