React Router 앱에 로그인 추가하기

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

서버가 없는 단일 페이지 앱이라면 React 단일 페이지 앱에 로그인 추가하기를 참고하세요.

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

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

시작하기 전에

1. 설치

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는 첫 요청에서 이 값들을 확인합니다. 값이 없거나 사용할 수 없으면 요청이 중단되며, 어떤 오류 메시지에도 값이 포함되지 않습니다.

  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)로 클라이언트를 한 번만 만듭니다.

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만 들어갑니다.

  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에 나열합니다.

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를 보내는 폼입니다.

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

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

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가 토큰을 거부합니다.

  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를 허용합니다.

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

6. 콜백 처리하기

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

  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 헤더에서 가져오지 않습니다.

  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가 고정된 오류 코드로 응답합니다. 로그인에 성공하면 앱은 이전 세션을 삭제하고, 사용자와 토큰을 새 세션에 보관합니다.

  // 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를 호출하며, 세션이 없으면 홈 페이지로 리디렉션합니다. 예제에는 리프레시 토큰이 없습니다. 액세스 토큰이 만료되면 사용자가 다시 로그인해야 합니다.

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

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

    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를 함께 보내며, 이후 브라우저는 앱으로 돌아옵니다.

  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)를 시작한 뒤 앱을 실행하세요.

npm run dev

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

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

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

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" });
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 상태를 유지합니다.

현재 제한 사항

다음 단계