
# API에서 토큰 검증하기

SDK 토큰 검증기는 KeenAuth 환경에서 발급한 액세스 토큰과 ID 토큰을 검사합니다. 보호할 API에서 사용하십시오. 세 가지 SDK(Go, TypeScript, Python) 모두 이 기능을 제공하며, 같은 규칙과 같은 테스트 벡터를 따릅니다.

## Go

```go
import "github.com/keenauth/keenauth-go/verify"

v, err := verify.New(verify.Config{
	Issuer:   "https://acme.eu.keenauth.app", // 환경의 발급자 값을 정확히 입력하십시오
	Audience: "https://api.acme.example",     // API의 식별자
})
if err != nil {
	return err
}

// HTTP 핸들러에서:
claims, err := v.VerifyAccessToken(r.Context(), token, &verify.DPoPInput{
	Proof:  r.Header.Get("DPoP"),
	Method: r.Method,
	URL:    "https://api.acme.example" + r.URL.Path,
})
if err != nil {
	w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
	http.Error(w, "unauthorized", http.StatusUnauthorized)
	return
}
// claims.Subject, claims.ClientID, claims.Scope
```

ID 토큰을 검증하려면 설정에서 `ClientID`를 지정하고 `v.VerifyIDToken(ctx, idToken, verify.IDTokenOptions{Nonce: nonce})`를 호출하십시오.

## TypeScript

TypeScript 검증기는 플랫폼의 WebCrypto와 `fetch`(Node.js 22 이상)만 사용하며 외부 의존성이 없습니다.

```ts
import { createVerifier, VerifyError } from "@keenauth/node";

const verifier = createVerifier({
  issuer: "https://acme.eu.keenauth.app", // 환경의 발급자 값을 정확히 입력하십시오
  audience: "https://api.acme.example",   // API의 식별자
});

// HTTP 핸들러에서:
try {
  const claims = await verifier.verifyAccessToken(token, {
    proof: req.get("DPoP") ?? "",
    method: req.method,
    url: "https://api.acme.example" + req.path,
  });
  // claims.subject, claims.clientId, claims.scope
} catch (e) {
  if (!(e instanceof VerifyError)) throw e;
  res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).send("unauthorized");
}
```

ID 토큰을 검증하려면 설정에서 `clientId`를 지정하고 `verifier.verifyIdToken(idToken, { nonce })`를 호출하십시오.

## Python

Python 검증기에는 `cryptography` 패키지가 필요하며, `verify` 추가 옵션으로 설치됩니다: `pip install "keenauth-python[verify]"`. 호출은 동기식이며 스레드 안전합니다.

```python
from keenauth.verify import DPoPInput, Verifier, VerifierConfig, VerifyError

verifier = Verifier(VerifierConfig(
    issuer="https://acme.eu.keenauth.app",  # 환경의 발급자 값을 정확히 입력하십시오
    audience="https://api.acme.example",    # API의 식별자
))

# HTTP 핸들러에서:
try:
    claims = verifier.verify_access_token(
        token,
        DPoPInput(
            proof=request.headers.get("DPoP", ""),
            method=request.method,
            url="https://api.acme.example" + request.path,
        ),
    )
    # claims.subject, claims.client_id, claims.scope
except VerifyError:
    return Response("unauthorized", status=401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
```

ID 토큰을 검증하려면 설정에서 `client_id`를 지정하고 `verifier.verify_id_token(id_token, nonce=nonce)`를 호출하십시오.

## Step-up (RFC 9470)

중요한 작업을 보호하려면 호출마다 더 강력하거나 더 최근의 로그인을 요구하십시오. 허용할 인증 컨텍스트 클래스 참조(`acr`)와 로그인의 최대 경과 시간(`auth_time`)을 전달하십시오. 아래의 `dpop`은 위 예제에서 사용한 DPoP 입력입니다(DPoP를 사용하지 않는 경우 `nil`/`undefined`/`None`).

### Go

```go
age := 15 * time.Minute
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
	AcceptableACR: []string{"urn:idp:acr:mfa", "urn:idp:acr:phr"},
	MaxAuthAge:    &age,
})
if challenge, ok := verify.StepUpChallenge(err); ok {
	w.Header().Set("WWW-Authenticate", challenge)
	http.Error(w, "step-up required", http.StatusUnauthorized)
	return
}
```

### TypeScript

```ts
import { createVerifier, stepUpChallenge } from "@keenauth/node";

try {
  const claims = await verifier.verifyAccessToken(token, dpop, {
    acceptableAcr: ["urn:idp:acr:mfa", "urn:idp:acr:phr"],
    maxAuthAgeSeconds: 900,
  });
} catch (e) {
  const challenge = stepUpChallenge(e);
  if (challenge === undefined) throw e;
  res.set("WWW-Authenticate", challenge).status(401).send("step-up required");
}
```

### Python

```python
from datetime import timedelta
from keenauth.verify import VerifyError, step_up_challenge

try:
    claims = verifier.verify_access_token(
        token,
        dpop,
        acceptable_acr=["urn:idp:acr:mfa", "urn:idp:acr:phr"],
        max_auth_age=timedelta(minutes=15),
    )
except VerifyError as err:
    challenge = step_up_challenge(err)
    if challenge is None:
        raise
    return Response("step-up required", status=401, headers={"WWW-Authenticate": challenge})
```

검사는 다른 모든 검사가 끝난 뒤에 수행되므로, 유효하지 않은 토큰은 항상 `invalid_token`이 됩니다. 유효하지만 요구 사항을 충족하지 않는 토큰에는 `insufficient_user_authentication` 코드가 반환되며, 오류에는 필요한 `acr_values`와 `max_age`가 포함됩니다(설정한 값만 포함됩니다). `StepUpChallenge`(Go), `stepUpChallenge`(TypeScript), `step_up_challenge`(Python)는 이 오류를 RFC 9470 §3의 `WWW-Authenticate` 헤더 값으로 변환합니다. 예를 들면 `Bearer error="insufficient_user_authentication", error_description="authentication too old", max_age="900"`과 같습니다. 이 챌린지를 이해하는 클라이언트는 해당 `acr_values`와 `max_age`를 사용하여 새로운 인증 요청을 시작합니다. `acr`은 허용 값 중 하나와 정확히 일치해야 합니다(대소문자 구분). 최대 경과 시간을 설정하면 `auth_time`이 필요하며, 시계 오차 허용 범위는 ID 토큰과 동일하게 적용됩니다. `urn:idp:acr:1fa`, `urn:idp:acr:mfa`, `urn:idp:acr:phr` 값은 환경이 `acr_values_supported`에 게시하는 값입니다.

## DPoP와 Authorization 스킴 (RFC 9449)

DPoP에 바인딩된 토큰(`cnf.jkt`)은 해당 클라이언트의 개인 키 없이는 쓸모가 없으므로, 탈취된 토큰은 재사용할 수 없습니다. 검증기는 요청의 `Authorization` 헤더 스킴(`Bearer` 또는 `DPoP`)을 전달받으면 다음 두 가지 규칙을 적용합니다.

- DPoP에 바인딩된 토큰을 `Bearer` 스킴으로 보내면, 유효한 증명이 첨부되어 있더라도 `invalid_token`으로 거부됩니다(RFC 9449 §7.2).
- `DPoP` 스킴으로 보내는 토큰은 반드시 DPoP에 바인딩되어 있어야 하며 유효한 증명이 함께 와야 합니다(RFC 9449 §7.1).

API에서 발신자 제약이 적용된 토큰만 허용하려면(민감한 API에 권장) "require DPoP"도 설정하십시오. 이 설정이 있으면 DPoP에 바인딩되지 않은 토큰은 `dpop_required` 코드를 받습니다. DPoP 챌린지 도우미는 모든 `401` 오류를 RFC 9449 §7.1의 `WWW-Authenticate` 값으로 변환합니다. 예를 들면 `DPoP error="invalid_token", error_description="a DPoP-bound access token is required", algs="ES256 EdDSA PS256 RS256"`과 같습니다.

### Go

```go
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
	Scheme:      scheme, // "Bearer" 또는 "DPoP", Authorization 헤더에서 가져옵니다
	RequireDPoP: true,
})
if challenge, ok := verify.DPoPChallenge(err); ok {
	w.Header().Set("WWW-Authenticate", challenge)
	http.Error(w, "unauthorized", http.StatusUnauthorized)
	return
}
```

### TypeScript

```ts
import { dpopChallenge } from "@keenauth/node";

try {
  const claims = await verifier.verifyAccessToken(token, dpop, { scheme, requireDPoP: true });
} catch (e) {
  const challenge = dpopChallenge(e);
  if (challenge === undefined) throw e;
  res.set("WWW-Authenticate", challenge).status(401).send("unauthorized");
}
```

### Python

```python
from keenauth.verify import VerifyError, dpop_challenge

try:
    claims = verifier.verify_access_token(token, dpop, scheme=scheme, require_dpop=True)
except VerifyError as err:
    challenge = dpop_challenge(err)
    if challenge is None:
        raise
    return Response("unauthorized", status=401, headers={"WWW-Authenticate": challenge})
```

리소스 서버 미들웨어(Go `protect`, TypeScript `@keenauth/node/middleware`, Python `keenauth.middleware`)는 스킴을 자동으로 읽습니다. API별 또는 라우트별 "require DPoP" 설정은 `Bearer` 요청에 대해 `401`과 DPoP 챌린지만 응답합니다.

## 검증기가 확인하는 항목

- **발급자와 키:** 토큰의 `iss`는 설정된 발급자와 일치해야 합니다. 키는 해당 발급자의 디스커버리 문서에 있는 `jwks_uri`에서만 가져오며, `kid`도 그 키 집합에서만 찾습니다. 다른 환경의 키로 서명된 토큰은 거부됩니다.
- **알고리즘:** RS256, PS256, ES256, EdDSA만 허용합니다. 키의 `alg`는 토큰의 `alg`와 같아야 하며, `none`과 HMAC 알고리즘은 항상 거부됩니다.
- **액세스 토큰:** `typ`는 `at+jwt`(RFC 9068)여야 하고, `aud`에는 설정한 대상(audience)이 포함되어야 하며, `sub`와 `client_id`가 있어야 합니다.
- **ID 토큰:** `aud`에는 클라이언트 ID가 포함되어야 하고, `azp`가 있으면 클라이언트 ID와 같아야 하며, `nonce`와 `max_age`는 전달한 경우 검사합니다.
- **시간:** `exp`는 필수이며, `nbf`와 `iat`는 미래 시점이면 안 됩니다. 최대 60초의 시계 오차가 허용됩니다.
- **DPoP:** 토큰이 키에 바인딩된 경우(`cnf.jkt`) 유효한 DPoP 증명(RFC 9449)이 필요합니다. 재사용된 증명은 거부됩니다. 스킴 옵션을 사용하면 바인딩된 토큰은 절대 베어러 토큰으로 허용되지 않으며, "require DPoP"를 사용하면 바인딩된 토큰만 허용됩니다.
- **키 캐시:** 키는 서버의 `max-age`(기본 300초, 최대 600초) 동안 캐시됩니다. 알 수 없는 `kid`가 나오면 1분에 최대 한 번만 다시 가져옵니다.

## 오류

모든 오류에는 `Code`가 있습니다. 오류 메시지에는 토큰이나 토큰의 클레임이 포함되지 않습니다.

| 코드 | 의미 | 응답 |
|---|---|---|
| `invalid_token` | 토큰이 유효하지 않거나 만료되었거나, 이 API용이 아닙니다. | `401` |
| `jwks_unavailable` | 발급자의 키를 가져올 수 없으며 캐시된 키도 없습니다. | `503` |
| `dpop_invalid` | DPoP 증명이 없거나 유효하지 않습니다. | DPoP 챌린지 도우미가 만든 `WWW-Authenticate`를 포함한 `401` |
| `dpop_required` | DPoP를 요구하도록 설정했지만 토큰이 DPoP에 바인딩되어 있지 않습니다. | DPoP 챌린지 도우미가 만든 `WWW-Authenticate`를 포함한 `401` |
| `insufficient_user_authentication` | 토큰은 유효하지만 `acr` 또는 `auth_time`이 요구 사항을 충족하지 않습니다(RFC 9470). | step-up 도우미가 만든 `WWW-Authenticate`를 포함한 `401` |
| `config_invalid` | 검증기 설정이 잘못되었습니다. | 설정을 수정하십시오. |

## 다른 JWT 라이브러리를 사용하는 경우

같은 체크리스트를 적용하십시오. 발급자를 디스커버리 문서에 바인딩하고, 키는 해당 `jwks_uri`에서만 가져오고, 키의 `alg`가 토큰의 `alg`와 같은지 확인하며, `none`과 HMAC 알고리즘을 거부하십시오.

## 오프라인 테스트

단위 테스트와 CI에서는 [오프라인 테스트 키트](/docs/sdk/testing)를 사용하십시오. 이 키트는 명시적인 테스트 모드의 검증기만 수락하는 테스트 토큰을 발급합니다. 운영 환경의 검증기는 이러한 토큰을 항상 거부합니다.
