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)을 전달받으면 다음 두 가지 규칙을 적용합니다.

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 챌린지만 응답합니다.

검증기가 확인하는 항목

오류

모든 오류에는 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에서는 오프라인 테스트 키트를 사용하십시오. 이 키트는 명시적인 테스트 모드의 검증기만 수락하는 테스트 토큰을 발급합니다. 운영 환경의 검증기는 이러한 토큰을 항상 거부합니다.