미들웨어로 API 보호하기
각 서버 SDK에는 KeenAuth 액세스 토큰으로 API를 보호하는 경량 미들웨어가 포함되어 있습니다. Go에서는 net/http, Node.js에서는 Express와 Fastify, Python에서는 Django, Flask, FastAPI를 지원합니다. 이 미들웨어는 토큰 검증기 위에서 동작하며, 자체적인 토큰 로직은 포함하지 않습니다.
미들웨어의 동작
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를 받는 모든 라우터와 함께 사용할 수 있습니다.
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 증명 도우미가 없으므로, 테스트에서는 해당 언어의 암호화 라이브러리로 증명에 서명하십시오.