API에서 토큰 검증하기
SDK 토큰 검증기는 KeenAuth 환경에서 발급한 액세스 토큰과 ID 토큰을 검사합니다. 보호할 API에서 사용하십시오. 세 가지 SDK(Go, TypeScript, Python) 모두 이 기능을 제공하며, 같은 규칙과 같은 테스트 벡터를 따릅니다.
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 이상)만 사용하며 외부 의존성이 없습니다.
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]". 호출은 동기식이며 스레드 안전합니다.
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
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
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
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
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
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
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에서는 오프라인 테스트 키트를 사용하십시오. 이 키트는 명시적인 테스트 모드의 검증기만 수락하는 테스트 토큰을 발급합니다. 운영 환경의 검증기는 이러한 토큰을 항상 거부합니다.