
# 미들웨어로 API 보호하기

각 서버 SDK에는 KeenAuth 액세스 토큰으로 API를 보호하는 경량 미들웨어가 포함되어 있습니다. Go에서는 `net/http`, Node.js에서는 Express와 Fastify, Python에서는 Django, Flask, FastAPI를 지원합니다. 이 미들웨어는 [토큰 검증기](/docs/sdk/token-verification) 위에서 동작하며, 자체적인 토큰 로직은 포함하지 않습니다.

## 미들웨어의 동작

- **`Authorization` 헤더에서 토큰을 읽습니다.** `Bearer` 또는 `DPoP` 스킴을 사용합니다. URL 쿼리(`?access_token=`)에 포함된 토큰은 URL이 로그와 `Referer` 헤더에 남을 수 있으므로 항상 거부합니다. `Authorization` 헤더가 두 개 이상이거나 자격 증명이 두 개 이상인 요청도 거부합니다.
- **DPoP를 검사합니다.** `DPoP` 스킴을 사용하면 `DPoP` 증명 헤더, 요청 메서드, 절대 요청 URL(설정된 공개 기본 URL과 요청 경로를 합친 값)을 검증기에 전달합니다. DPoP에 바인딩된 토큰을 `Bearer` 스킴으로 보내면 거부하며, DPoP 바인딩이 없는 토큰을 `DPoP` 스킴으로 보내도 거부합니다.
- **경로의 요구 사항을 확인합니다.** 필수 스코프(모두 충족해야 함)와 선택적으로 단계별 인증(step-up)을 확인합니다. 단계별 인증에는 허용되는 `acr` 값과 로그인 후 경과 시간의 최댓값이 포함됩니다(RFC 9470).
- **폐기 캐시를 조회합니다.** 폐기 캐시를 설정한 경우, 해당 토큰이 폐기되었는지 캐시에 확인합니다.
- **검증된 클레임을 요청에 첨부합니다.** Go에서는 `protect.ClaimsFrom(ctx)`, Express와 Fastify에서는 `req.auth`, Django에서는 `request.auth`, Flask에서는 `g.auth`, FastAPI에서는 의존성의 반환값을 사용합니다.
- **실패 시 응답합니다.** 표준 `WWW-Authenticate` 챌린지와 간단한 `application/problem+json` 본문으로 응답합니다. 응답에는 토큰이나 클레임 값이 포함되지 않으며, 미들웨어는 이를 로그에 기록하지 않습니다.

## Go(net/http)

`protect` 패키지는 표준 라이브러리만 사용하며, `http.Handler`를 받는 모든 라우터와 함께 사용할 수 있습니다.

```go
import (
	"github.com/keenauth/keenauth-go/protect"
	"github.com/keenauth/keenauth-go/verify"
)

v, err := verify.New(verify.Config{
	Issuer:   "https://acme.eu.keenauth.app",
	Audience: "https://api.acme.example",
})
if err != nil {
	return err
}
m, err := protect.New(v, protect.Options{
	PublicBaseURL:       "https://api.acme.example",
	ResourceMetadataURL: "https://api.acme.example/.well-known/oauth-protected-resource",
})
if err != nil {
	return err
}

mux := http.NewServeMux()
mux.Handle("GET /orders", m.Require(protect.Requirement{Scopes: []string{"orders:read"}})(http.HandlerFunc(
	func(w http.ResponseWriter, r *http.Request) {
		claims, _ := protect.ClaimsFrom(r.Context())
		fmt.Fprintln(w, claims.Subject)
	})))

age := 10 * time.Minute
mux.Handle("POST /payouts", m.Require(protect.Requirement{
	Scopes:        []string{"payouts:write"},
	AcceptableACR: []string{"urn:idp:acr:mfa"},
	MaxAuthAge:    &age,
})(payoutsHandler))
```

`m.Handler(next)`는 유효한 토큰만 요구하고 추가 요구 사항이 없는 핸들러를 보호합니다. `Require`는 스코프가 올바른 스코프 토큰이 아니면 시작 시 패닉을 일으킵니다. 이는 `http.ServeMux`가 잘못된 패턴에 대해 동작하는 방식과 같습니다.

## Node.js(Express 및 Fastify)

미들웨어는 `@keenauth/node/middleware` 하위 경로에 있습니다. Express와 Fastify는 SDK의 의존성이 아닙니다.

```ts
import { createVerifier } from "@keenauth/node";
import { createProtector, expressAuth, fastifyAuth } from "@keenauth/node/middleware";

const verifier = createVerifier({ issuer: "https://acme.eu.keenauth.app", audience: "https://api.acme.example" });
const protector = createProtector(verifier, { publicBaseUrl: "https://api.acme.example" });

// Express
app.get("/orders", expressAuth(protector, { scopes: ["orders:read"] }), (req, res) => {
  res.json({ sub: req.auth.subject });
});

// Fastify
fastify.decorateRequest("auth", null);
fastify.get("/orders", { preHandler: fastifyAuth(protector, { scopes: ["orders:read"] }) }, async (request) => {
  return { sub: request.auth.subject };
});
```

단계별 인증은 `{ acceptableAcr: ["urn:idp:acr:mfa"], maxAuthAgeSeconds: 600 }`으로 설정합니다. TypeScript에서 `req.auth`를 읽으려면 요청 타입을 하위 경로에서 내보내는 `AuthenticatedRequest`로 지정하십시오.

## Python(Django, Flask, FastAPI)

사용하는 프레임워크에 맞는 extra를 설치하십시오: `pip install "keenauth-python[django]"`, `"keenauth-python[flask]"` 또는 `"keenauth-python[fastapi]"`. 프레임워크는 해당 어댑터 모듈에서만 가져옵니다.

```python
from keenauth.middleware import Protector
from keenauth.verify import Verifier, VerifierConfig

verifier = Verifier(VerifierConfig(issuer="https://acme.eu.keenauth.app", audience="https://api.acme.example"))
protector = Protector(verifier, public_base_url="https://api.acme.example")
```

Django:

```python
from django.http import JsonResponse
from keenauth.middleware.django import protect

@protect(protector, scopes=["orders:read"])
def orders(request):
    return JsonResponse({"sub": request.auth.subject})
```

Flask:

```python
from flask import g
from keenauth.middleware.flask import protect

@app.get("/orders")
@protect(protector, scopes=["orders:read"])
def orders():
    return {"sub": g.auth.subject}
```

FastAPI:

```python
from fastapi import Depends, FastAPI
from keenauth.middleware.fastapi import RequireAuth, install
from keenauth.verify import AccessClaims

app = FastAPI()
install(app)

@app.get("/orders")
def orders(claims: AccessClaims = Depends(RequireAuth(protector, scopes=["orders:read"]))):
    return {"sub": claims.subject}
```

`install(app)`는 문제 본문을 작성하고 챌린지마다 `WWW-Authenticate` 헤더를 하나씩 쓰는 핸들러를 등록합니다. 단계별 인증은 `acceptable_acr=["urn:idp:acr:mfa"], max_auth_age=timedelta(minutes=10)`으로 설정합니다.

## 공개 기본 URL(필수)

DPoP 증명에는 클라이언트가 호출한 URL(`htu`)이 포함됩니다. 미들웨어는 이 값을 설정한 공개 기본 URL(`PublicBaseURL`, `publicBaseUrl`, `public_base_url`)과 요청 경로를 합친 값과 비교합니다. 클라이언트가 사용하는 주소로 설정하십시오. 예를 들어 `https://api.acme.example` 또는 `https://acme.example/api`입니다. 이 값은 필수입니다. 클라이언트가 Bearer 토큰만 보내더라도 이 값 없이 미들웨어를 생성하면 시작 시 설정 오류가 발생합니다.

미들웨어는 요청의 `Host` 헤더나 `X-Forwarded-*` 헤더로 이 URL을 만들지 않습니다. Python에서도 프레임워크 고유의 요청 URL을 사용하지 않으며, 프레임워크나 서버가 전달된 헤더를 신뢰하도록 설정되어 있더라도 마찬가지입니다. 이 값들은 클라이언트가 정할 수 있습니다. API가 두 개의 호스트 이름으로 제공되는 경우, 한쪽 호스트용으로 만든 증명이 다른 쪽에서 재사용될 수 있습니다. 로드 밸런서나 리버스 프록시 뒤에 있다면, 공개 기본 URL은 클라이언트가 보는 스킴, 호스트, 경로 접두사를 미들웨어에 알려 주는 역할도 합니다. 공개 기본 URL은 `localhost`, `127.0.0.1`, `[::1]`을 제외하고 `https`를 사용해야 합니다(RFC 6750 §5.3). 스킴과 호스트는 대소문자를 구분하지 않고 비교하며, 기본 포트(`:443`, `:80`)는 기본 URL과 증명의 `htu` 모두에서 무시됩니다.

## 응답

| 상황 | 상태 코드 | `WWW-Authenticate` | `code` |
|---|---|---|---|
| 토큰이 없거나 지원하지 않는 스킴 | `401` | `Bearer`와 `DPoP algs="…"`, 오류 없음 | `missing_token` |
| 쿼리에 토큰이 있거나, `Authorization`이 반복되거나 형식이 잘못됨 | `400` | `Bearer error="invalid_request"` | `invalid_request` |
| 토큰이 유효하지 않거나, 만료되었거나, 폐기됨 | `401` | `error="invalid_token"` | `invalid_token` |
| DPoP 증명이 없거나, 반복되거나, 유효하지 않음 | `401` | `DPoP error="invalid_dpop_proof"` | `invalid_dpop_proof` |
| 필수 스코프가 없음 | `403` | `error="insufficient_scope", scope="…"` | `insufficient_scope` |
| 단계별 인증이 필요함 | `401` | `error="insufficient_user_authentication", acr_values="…", max_age="…"` | `insufficient_user_authentication` |
| 서명 키 또는 폐기 상태를 사용할 수 없음 | `503` | 없음 | `jwks_unavailable`, `revocation_unavailable` |

챌린지는 클라이언트가 사용한 스킴을 따릅니다. 리소스 메타데이터 URL을 설정하면 모든 `401` 챌린지에 `resource_metadata`(RFC 9728)가 포함됩니다. 본문에는 `type`, `title`, `status`, `code`와 고정된 `detail`이 들어가며, 토큰이 유효하지 않은 이유는 포함되지 않습니다. 로그에 남기려면 오류 콜백(`OnError`, `onError`, `on_error`)을 전달하십시오. 콜백은 검증기의 오류를 받으며, 이 오류에는 토큰이나 클레임 값이 포함되지 않습니다. `Authorization` 또는 `DPoP` 헤더를 직접 로그에 기록하지 마십시오.

## 폐기 캐시(`token.revoked`)

JWT 액세스 토큰은 만료될 때까지 유효합니다. 폐기된 토큰을 더 빨리 거부하려면 `token.revoked` 웹훅을 처리하고, 그 선택자(selector)를 미들웨어가 조회하는 폐기 캐시에 기록하십시오. SDK에는 메모리 기반 캐시가 포함되어 있습니다. Go에서는 `protect.NewMemoryRevocationCache`, Node.js에서는 `createMemoryRevocationCache`, Python에서는 `MemoryRevocationCache`를 사용합니다.

선택자는 토큰 ID(`jti`), 세션(`sid`), 사용자(`sub`), 조직(`org_id`), 애플리케이션(`client_id`), 또는 조직과 사용자, 애플리케이션과 사용자의 조합으로 지정합니다. `jti` 선택자는 해당 토큰 하나를 폐기합니다. 다른 선택자는 이벤트의 `not_before` 이전에 발급된 모든 토큰을 폐기하며, 이후에 발급된 토큰은 허용됩니다. 기본적으로 마커는 24시간 동안 보관되며, 여기에 1분을 더한 기간은 액세스 토큰의 최대 수명과 클록 오차를 고려한 값입니다.

```go
cache := protect.NewMemoryRevocationCache(0, nil)
m, _ := protect.New(v, protect.Options{PublicBaseURL: "https://api.acme.example", Revocation: cache})

// 검증된 token.revoked 웹훅 핸들러에서:
err := cache.Revoke(protect.Selector{SessionID: "ses_…"}, notBefore)
```

메모리 캐시는 하나의 프로세스에만 속합니다. API가 여러 인스턴스에서 실행된다면, 공유 저장소 위에 폐기 검사기 인터페이스를 구현하십시오. 캐시가 응답할 수 없는 경우 미들웨어는 요청을 `503`으로 거부합니다.

## 테스트

미들웨어 테스트에서 토큰을 발급하려면 [오프라인 테스트 키트](/docs/sdk/testing)를 사용하십시오. 이 키트에는 DPoP 증명 도우미가 없으므로, 테스트에서는 해당 언어의 암호화 라이브러리로 증명에 서명하십시오.
