
# Express API 보호하기

이 빠른 시작 가이드에서는 KeenAuth 액세스 토큰으로 Express 5 API를 보호합니다. Node.js SDK의 `expressAuth` 미들웨어를 추가하고, 한 경로에 스코프를 요구하도록 설정한 뒤, SDK 테스트 키트로 API를 오프라인에서 테스트합니다. Express는 SDK의 종속성이 아니며, 이 미들웨어는 Express 4에서도 동작합니다.

API에는 세 개의 엔드포인트가 있습니다.

| 엔드포인트 | 요구 사항 |
|---|---|
| `GET /api/public` | 없음 |
| `GET /api/private` | API에 대한 유효한 액세스 토큰 |
| `GET /api/messages` | `messages:read` 스코프가 포함된 유효한 액세스 토큰 |

## 시작하기 전에

- KeenAuth 환경. 발급자는 `https://your-environment.eu.keenauth.app` 형태입니다.
- 환경에 등록된 API([API 생성](/docs/api/management/apis/create)). API의 `identifier`(예: `https://api.example.com`)는 API가 수락하는 토큰의 대상(audience)입니다.
- 해당 API의 `messages:read` 스코프가 이를 호출하는 애플리케이션에 부여되어 있어야 합니다.
- Node.js 22 이상.

## 1. SDK 설치

```sh
npm install @keenauth/node express@5.2.1
```

SDK는 아직 게시되지 않았습니다. 게시되기 전까지 예제는 `package.json`에 있는 SDK 소스를 가리킵니다.

## 2. 설정

API는 환경 변수에서만 설정을 읽습니다. 이 값들을 `.env` 파일에 넣으세요. `npm start`가 `node --env-file-if-exists=.env`으로 이 파일을 로드하며, 이 파일은 절대 커밋하지 마세요.

| 변수 | 필수 여부 | 의미 |
|---|---|---|
| `KEENAUTH_ISSUER` | 예 | 환경의 발급자 |
| `KEENAUTH_AUDIENCE` | 예 | API 식별자 |
| `PUBLIC_BASE_URL` | 예 | 클라이언트가 호출하는 URL([프록시 뒤에서](#프록시-뒤에서) 참조) |
| `KEENAUTH_WEBHOOK_SECRET` | 권장 | `token.revoked`를 전달하는 웹훅 엔드포인트의 `idp_whsec_` 서명 비밀(키 교체 중에는 쉼표로 구분된 두 개 값). 이 값이 없으면 API는 취소된 토큰을 알 수 없습니다 |
| `PORT` | 아니요 | 수신 포트, 기본값 `8080` |

## 3. 경로 보호하기

`createApp`가 보호 객체와 경로를 생성합니다. `expressAuth(protector)`는 유효한 토큰을 요구하며, `{ scopes }`를 함께 지정하면 스코프도 요구합니다. 핸들러는 `req.auth`에서 검증된 클레임을 읽습니다.

```js
export function createApp({ verifier, publicBaseUrl, revocation, webhookSecrets = [] }) {
  const protector = createProtector(verifier, {
    publicBaseUrl,
    revocation,
    // The error never contains the token or claim values; never log the Authorization or DPoP header.
    onError: (err) => console.warn(`rejected: ${err.message}`),
  });

  const app = express();
  app.disable("x-powered-by");

  // No token needed.
  app.get("/api/public", (req, res) => {
    res.json({ message: "Hello from a public endpoint." });
  });

  // Any valid access token for this API.
  app.get("/api/private", expressAuth(protector), (req, res) => {
    res.json({ message: "Hello from a private endpoint.", sub: req.auth.subject });
  });

  // A valid access token that also carries the messages:read scope.
  app.get("/api/messages", expressAuth(protector, { scopes: ["messages:read"] }), (req, res) => {
    res.json({ messages: ["Hello from a scoped endpoint."] });
  });

  // token.revoked deliveries from KeenAuth; the handler reads the raw body itself, so no JSON parser runs before it.
  if (webhookSecrets.length > 0) {
    app.post(WEBHOOK_PATH, ...createWebhookHandler({ secrets: webhookSecrets, revocation }));
  } else {
    console.warn("KEENAUTH_WEBHOOK_SECRET is not set: token.revoked events are not received, so revoked tokens stay valid until they expire");
  }

  return app;
}
```

`server.js`는 설정으로부터 실제 검증기를 만듭니다. 검증기는 처음 토큰을 확인할 때 환경에서 서명 키를 가져옵니다.

```js
const issuer = process.env.KEENAUTH_ISSUER;
const audience = process.env.KEENAUTH_AUDIENCE;
// The URL clients call: DPoP proofs are checked against it, never against the Host header.
const publicBaseUrl = process.env.PUBLIC_BASE_URL;
if (!issuer || !audience || !publicBaseUrl) {
  console.error("KEENAUTH_ISSUER, KEENAUTH_AUDIENCE and PUBLIC_BASE_URL are required");
  process.exit(1);
}

// createVerifier refuses an issuer under the reserved .invalid domain, so a test token can never pass here.
const verifier = createVerifier({ issuer, audience });
const revocation = createMemoryRevocationCache();
// The signing secret (idp_whsec_...) of the webhook endpoint that delivers token.revoked; comma separated while rotating.
const webhookSecrets = (process.env.KEENAUTH_WEBHOOK_SECRET ?? "").split(",").map((s) => s.trim()).filter(Boolean);
const app = createApp({ verifier, publicBaseUrl, revocation, webhookSecrets });

const port = Number(process.env.PORT || 8080);
app.listen(port, () => console.log(`listening on port ${port}`));
```

## 4. 실행하고 API 호출하기

```sh
npm start
```

첫 호출을 위해, API에 `messages:read` 스코프를 가진 머신 간(M2M) 애플리케이션의 클라이언트 자격 증명 그랜트로 토큰을 받으세요.

```sh
ACCESS_TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  -d resource=https://api.example.com \
  -d scope=messages:read \
  https://your-environment.eu.keenauth.app/oauth2/token | jq -r .access_token)

curl -i http://localhost:8080/api/messages -H "Authorization: Bearer $ACCESS_TOKEN"
```

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

`@keenauth/node/testing`는 공개 테스트 키로 서명된 토큰을 발급하며, `createTestVerifier`는 이러한 토큰만 수락합니다. 테스트는 이 검증기를 `createApp`에 전달하고 로컬 포트에서 앱을 호출하므로 네트워크나 KeenAuth 계정이 필요하지 않습니다. [오프라인 테스트 키트](/docs/sdk/testing)를 참조하세요.

```js
test("scope check: 403 insufficient_scope without messages:read, 200 with it", async () => {
  await expectProblem(await get("/api/messages", bearer(mintAccessToken(AUDIENCE))), 403, "insufficient_scope", /scope="messages:read"/);
  const ok = await get("/api/messages", bearer(mintAccessToken(AUDIENCE, { claims: { scope: "profile messages:read" } })));
  assert.equal(ok.status, 200);
});
```

```sh
npm test
```

예제의 테스트는 유효한 토큰이 수락되는지, 그리고 토큰이 없거나 만료되었거나 대상이 잘못되었거나, 스코프가 없거나, URL 쿼리에 토큰이 있거나, 취소된 토큰인 경우 아래 상태 코드로 거부되는지 확인합니다. 또한 `PUBLIC_BASE_URL`에 대한 증명(proof)과 함께 DPoP에 바인딩된 토큰을 보내고, `server. The tests also send signed ` ` deliveries: a valid one revokes the token, a forged, tampered or stale one gets ` ` and revokes nothing, and a repeated one has one effect.js` 가 테스트 발급자로는 시작되지 않는지 확인합니다.

## 응답

| 요청 | 상태 | 본문의 `code` |
|---|---|---|
| `Authorization` 헤더 없음 | `401` | `missing_token` |
| 만료된 토큰, 다른 API용 토큰, 잘못된 서명, 취소된 토큰 | `401` | `invalid_token` |
| 필요한 스코프가 없는 토큰 | `403` | `insufficient_scope` |
| URL 쿼리의 토큰(`?access_token=`) | `400` | `invalid_request` |

본문은 `application/problem+json`이며, `WWW-Authenticate` 헤더에는 표준 챌린지가 담깁니다. 전체 표는 [미들웨어로 API 보호하기](/docs/sdk/protect-api#응답)에 있습니다.

## 프록시 뒤에서

클라이언트가 DPoP를 사용하는 경우, 미들웨어는 각 증명이 클라이언트가 호출한 URL을 대상으로 만들어졌는지 확인합니다. `PUBLIC_BASE_URL`를 클라이언트가 호출하는 외부 URL(예: `https://api.example.com`)로 설정하세요. 프록시나 로드 밸런서 뒤에 있더라도 마찬가지입니다. 이 값은 필수이며 `https`을 사용해야 합니다(`http`은 `localhost`, `127.0.0.1`, `[::1]`에서만 허용됩니다). 이 값이 없으면 앱은 시작되지 않습니다. Express의 `trust proxy` 설정이 무엇이든, 미들웨어는 `X-Forwarded-*` 헤더를 절대 신뢰하지 않습니다.

## 취소된 토큰

액세스 토큰은 만료될 때까지 유효하므로, KeenAuth는 토큰이 취소되었을 때 API에 이를 알립니다. 예제는 보호 객체에 메모리 내 취소 캐시를 전달하고, 이 캐시에 값을 채우는 `token.revoked` 웹훅을 `POST /webhooks/keenauth`(`webhook.js`)에서 제공합니다.

1. 대시보드에서 URL `https://<your host>/webhooks/keenauth`로 `token.revoked`을 구독하는 웹훅 엔드포인트를 만들고, 서명 비밀을 `KEENAUTH_WEBHOOK_SECRET`로 내보내세요.
2. 핸들러는 원시 본문에서 SDK로 서명을 검증하며, 서명이 잘못되었거나 타임스탬프가 5분을 넘게 차이 나면 `400`으로 응답합니다. 이벤트의 셀렉터(`access_token`, `session`, `user`, `client`, `org`, `org_user`, `client_user`)를 취소 마커로 변환하고 `204`으로 응답합니다. 그러면 미들웨어가 `not_before` 이전에 발급된 일치하는 모든 토큰을 거부합니다.
3. KeenAuth는 최소 한 번 전달하므로, 핸들러는 각 `webhook-id`를 한 번만 적용합니다. 마커를 저장할 수 없으면 `503`로 응답하고, KeenAuth는 이벤트를 다시 전달합니다. 다른 이벤트 유형과 어떤 액세스 토큰과도 일치할 수 없는 셀렉터(`refresh_family`, `api`)는 무시합니다.

핸들러는 이벤트 ID, 셀렉터 유형, 사유를 기록하며, 본문, 서명, 비밀은 절대 기록하지 않습니다. 각 프로세스는 자체 캐시를 가집니다. 여러 인스턴스를 운영한다면 마커를 같은 `revoke` 메서드 뒤의 공유 저장소에 보관하세요. [취소 캐시](/docs/sdk/protect-api#폐기-캐시tokenrevoked)를 참조하세요.

## 다음 단계

- [미들웨어로 API 보호하기](/docs/sdk/protect-api): Fastify, 단계별 인증 요구 사항, DPoP, 모든 옵션.
- [토큰 검증](/docs/sdk/token-verification): 미들웨어 아래에서 동작하는 검증기.
- [오프라인 테스트 키트](/docs/sdk/testing).
