미들웨어로 API 보호하기

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

미들웨어의 동작

Go(net/http)

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

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의 의존성이 아닙니다.

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]". 프레임워크는 해당 어댑터 모듈에서만 가져옵니다.

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:

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:

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:

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분을 더한 기간은 액세스 토큰의 최대 수명과 클록 오차를 고려한 값입니다.

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으로 거부합니다.

테스트

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