이슈 #375
진행중JWT 전역 인터셉터 구현 (alphea-ai-edge)
설명
배경¶
alphea-ai-edge (Rust + tonic gRPC 포팅 프로젝트) 에서 현재 JWT 인증은 핸들러별 명시적 체크 방식을 사용합니다. 각 보호 RPC 가 domain::auth::require_auth(&request, &self.state.cfg.jwt_secret) 를 직접 호출합니다 (#313, #314 에서 사용됨).
이 방식은 보호 RPC 가 1~2개일 때는 단순해서 좋지만, 다음 조건이 만족되면 전역 Tower Layer 인터셉터로 전환할 필요가 있습니다:
- 보호 RPC 가 5개 이상으로 늘어날 때
- 여러 서비스에 걸쳐 일관된 인증 정책이 필요할 때
- Access Log, 메트릭 같은 다른 전역 미들웨어와 체이닝이 필요할 때
레거시 Go 프로젝트 (alphea-rpc-next) 는 global AuthInterceptor + allow-list 방식을 사용했으며, 이 이슈는 그 패턴을 Rust 로 포팅합니다.
현재 상태 (2026-04-15 기준)¶
보호 RPC 목록¶
| 서비스 | RPC | 현재 인증 방식 |
|---|---|---|
NodeService |
DeleteNode |
require_auth + owner 체크 (handler/node.rs) |
WalletService |
GetReceipts |
resolve_wallet (wraps require_auth) |
WalletService |
GetEarningsSummary |
resolve_wallet |
공개 RPC 목록¶
| 서비스 | RPC |
|---|---|
SystemService |
Health, Info
|
AuthService |
Challenge, Verify, GenerateKeypair
|
NodeService |
ListNodes, GetNode, NodeChallenge, RegisterNode
|
인증 헬퍼 (현재)¶
// src/domain/auth/mod.rs
pub fn require_auth<T>(req: &Request<T>, jwt_secret: &str) -> Result<String, Status> {
let token = req
.metadata()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.ok_or_else(|| Status::unauthenticated("missing authorization header"))?;
let payload = verify_access_token(token, jwt_secret)
.map_err(|_| Status::unauthenticated("invalid token"))?;
Ok(payload.sub)
}
목표¶
기능 요구사항¶
- 전역 적용: 모든 gRPC 요청이 자동으로 인증 레이어를 통과
- Allow-list 기반 예외: 공개 RPC 는 인증 없이 통과
-
컨텍스트 주입: 인증 성공 시 호출자 정보 (
wallet,token_type,scope) 를request.extensions()에 주입 -
핸들러 단순화: 보호 RPC 는
extensions에서 호출자 읽기만 하면 되도록 -
Token type 검증: 특정 RPC 가
node토큰을 요구할 수 있도록 (ConnectNode용) -
에러 응답 표준화: 모든 auth 실패는
Status::unauthenticated또는Status::permission_denied로 통일
비기능 요구사항¶
- 성능: public RPC 는 추가 지연 없음, 보호 RPC 는 JWT 검증 한 번만
- 관찰성: 인증 실패/성공 이벤트를 tracing 스팬으로 기록
- 테스트 가능성: 인터셉터 로직 단위 테스트 가능 (Tower Service mock)
-
기존 코드 호환: 마이그레이션 기간 동안 기존
require_auth와 공존 가능
구현 방식¶
아키텍처 선택: Tower Layer + Service
¶
tonic 의 두 가지 미들웨어 옵션 중:
| 방식 | URI 접근 | 비동기 | 추천 |
|---|---|---|---|
tonic::service::Interceptor |
❌ (메타데이터만) | ❌ | 간단한 헤더 변형만 |
Tower Layer + Service |
✅ (HTTP path) | ✅ | 이 작업에 필수 |
URI path (/alphea.v1.NodeService/DeleteNode) 를 allow-list 와 매칭해야 하므로 Tower Layer 방식이 필수입니다.
모듈 구조¶
새 파일 src/server/auth_middleware.rs:
use std::collections::HashSet;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use http::{Request as HttpRequest, Response as HttpResponse};
use tonic::body::BoxBody;
use tonic::{Code, Status};
use tower::{Layer, Service};
use tracing::{debug, warn};
use crate::domain::auth::{JwtPayload, verify_access_token};
/// Information about the authenticated caller, injected into request
/// extensions so handlers can read it without re-validating the token.
#[derive(Debug, Clone)]
pub struct AuthContext {
pub wallet: String,
pub token_type: String, // "app" or "node"
pub scopes: Vec<String>,
}
#[derive(Clone)]
pub struct AuthLayer {
jwt_secret: Arc<String>,
public_paths: Arc<HashSet<&'static str>>,
}
impl AuthLayer {
pub fn new(jwt_secret: String) -> Self {
Self {
jwt_secret: Arc::new(jwt_secret),
public_paths: Arc::new(public_path_set()),
}
}
}
impl<S> Layer<S> for AuthLayer {
type Service = AuthMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
AuthMiddleware {
inner,
jwt_secret: self.jwt_secret.clone(),
public_paths: self.public_paths.clone(),
}
}
}
#[derive(Clone)]
pub struct AuthMiddleware<S> {
inner: S,
jwt_secret: Arc<String>,
public_paths: Arc<HashSet<&'static str>>,
}
type BoxFuture<T> = Pin<Box<dyn std::future::Future<Output = T> + Send>>;
impl<S, B> Service<HttpRequest<B>> for AuthMiddleware<S>
where
S: Service<HttpRequest<B>, Response = HttpResponse<BoxBody>> + Clone + Send + 'static,
S::Future: Send + 'static,
B: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future = BoxFuture<Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: HttpRequest<B>) -> Self::Future {
let path = req.uri().path().to_string();
let is_public = self.public_paths.contains(path.as_str());
if is_public {
// Fast path — no token check.
let fut = self.inner.call(req);
return Box::pin(fut);
}
// Protected path — validate JWT before forwarding.
let auth_header = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(str::to_string);
let token = match auth_header {
Some(t) => t,
None => {
debug!(%path, "auth middleware: missing bearer token");
return Box::pin(async move {
Ok(status_to_http_response(Status::unauthenticated(
"missing authorization header",
)))
});
}
};
let payload = match verify_access_token(&token, &self.jwt_secret) {
Ok(p) => p,
Err(err) => {
debug!(%path, %err, "auth middleware: invalid token");
return Box::pin(async move {
Ok(status_to_http_response(Status::unauthenticated("invalid token")))
});
}
};
// Inject AuthContext so handlers can read the caller.
req.extensions_mut().insert(AuthContext {
wallet: payload.sub.clone(),
token_type: payload.typ.clone(),
scopes: payload.scope.clone(),
});
let fut = self.inner.call(req);
Box::pin(fut)
}
}
fn status_to_http_response(status: Status) -> HttpResponse<BoxBody> {
// tonic provides Status::to_http() in 0.13; falls back to manual construction.
status.to_http()
}
Allow-list 구성¶
fn public_path_set() -> HashSet<&'static str> {
[
// System
"/alphea.v1.SystemService/Health",
"/alphea.v1.SystemService/Info",
// Auth (all public — entry points to the authenticated world)
"/alphea.v1.AuthService/Challenge",
"/alphea.v1.AuthService/Verify",
"/alphea.v1.AuthService/GenerateKeypair",
// Node public reads + registration
"/alphea.v1.NodeService/ListNodes",
"/alphea.v1.NodeService/GetNode",
"/alphea.v1.NodeService/NodeChallenge",
"/alphea.v1.NodeService/RegisterNode",
// User (public lookup)
"/alphea.v1.UserService/GetUser", // ← 포팅 후 추가
// gRPC reflection
"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo",
"/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo",
]
.into_iter()
.collect()
}
정책: "default deny" — allow-list 에 없으면 인증 필요. 새 RPC 추가 시 명시적으로 등록해야 공개됨 → 실수로 보호 해제되는 것을 원천 차단.
핸들러에서 읽기¶
새 헬퍼 src/domain/auth/mod.rs:
use crate::server::auth_middleware::AuthContext;
pub fn caller_from<T>(req: &Request<T>) -> Result<&AuthContext, Status> {
req.extensions()
.get::<AuthContext>()
.ok_or_else(|| Status::unauthenticated("missing auth context"))
}
pub fn caller_wallet<T>(req: &Request<T>) -> Result<String, Status> {
Ok(caller_from(req)?.wallet.clone())
}
핸들러 예시 (Before / After):
// BEFORE — 현재 DeleteNode
async fn delete_node(&self, request: Request<DeleteNodeRequest>) -> ... {
let caller = require_auth(&request, &self.state.cfg.jwt_secret)?;
// ...
}
// AFTER — 전역 인터셉터 적용
async fn delete_node(&self, request: Request<DeleteNodeRequest>) -> ... {
let caller = caller_wallet(&request)?; // O(1) extensions lookup
// ...
}
서버 등록¶
src/server.rs:
Server::builder()
.layer(AuthLayer::new(cfg.jwt_secret.clone())) // ← 추가
.add_service(SystemServiceServer::new(...))
.add_service(AuthServiceServer::new(...))
// ...
.serve(addr)
.await?;
Server::layer() 는 모든 등록된 서비스에 공통 적용되며, 서비스별 개별 레이어도 가능.
Node token 특수 처리¶
레거시는 NodeService.ConnectNode 에서 typ == "node" 토큰을 추가로 요구합니다. bidi streaming 이라 핸들러 내부에서 직접 검증하는 패턴이 유지될 수 있으나, 전역 인터셉터에 token type 요구사항 을 메타데이터로 추가할 수도 있습니다:
struct AuthRequirement {
path: &'static str,
required_token_type: Option<&'static str>, // None = any type
}
// ConnectNode → required_token_type: Some("node")
// 나머지 보호 RPC → required_token_type: None (app 또는 node 모두 허용)
초기 구현에서는 단순 allow-list 만 구현하고, token type 강제는 ConnectNode 포팅 시 별도 이슈로 처리 권장.
마이그레이션 전략¶
단계별 전환 (기존 핸들러와 공존):
Phase 1 — 레이어 도입 + 기존 유지¶
-
AuthLayer추가, 모든 서비스에 적용 - 기존
require_auth호출하는 핸들러도 그대로 유지 - 이유:
AuthLayer가 token 검증을 통과시켜도require_auth는 한 번 더 검증할 뿐 — 에러 경로가 중복되지만 기능적 문제 없음 - 이 단계에서 allow-list 만 맞추면 public RPC 는 정상 동작, 보호 RPC 는 이중 검증 됨
Phase 2 — 핸들러 전환¶
- 보호 RPC 하나씩
require_auth→caller_wallet(&request)로 교체 - RPC 단위로 점진적 이행 → 각 교체마다 E2E 테스트
- 교체 완료:
DeleteNode,GetReceipts,GetEarningsSummary
Phase 3 — 정리¶
-
require_auth,resolve_wallet의 JWT 검증 부분을 제거 (AuthContext 기반으로 재작성) -
resolve_wallet→caller_must_match(request, requested_wallet)로 간소화 - 문서 업데이트 (CLAUDE.md, wallet handler 주석)
테스트 방안¶
단위 테스트 (src/server/auth_middleware.rs)¶
-
Mock inner service —
tower::service_fn으로 echo 서비스 구성 - 시나리오:
- public path + 토큰 없음 → 200 (통과)
- public path + 유효 토큰 → 200 (통과, AuthContext 주입은 선택)
- 보호 path + 토큰 없음 → gRPC Status
Unauthenticated - 보호 path + 만료 토큰 →
Unauthenticated - 보호 path + 잘못된 서명 →
Unauthenticated - 보호 path + 유효 토큰 → inner service 로 전달 + extensions 에 AuthContext 주입 확인
E2E 테스트¶
기존 examples/ 스크립트 들을 그대로 통과해야 함:
auth_roundtrip.rsnode_register.rs-
node_delete.rs(핵심 — 인증 경로 변경 후에도 동일 결과) wallet_earnings.rs
추가 시나리오:
-
tests/auth_middleware_integration.rs— 전역 인터셉터 적용 서버에 대한 public/protected 호출 혼합 검증
회귀 방지¶
-
cargo test에 단위 테스트 포함 -
cargo clippy -- -D warnings로 경고 차단 - CI 파이프라인 추가 (별도 이슈)
완료 기준 (Definition of Done)¶
-
src/server/auth_middleware.rs구현 (AuthLayer, AuthMiddleware, AuthContext) - Allow-list 정의 (현재 6개 공개 RPC + reflection)
-
src/server.rs에.layer(AuthLayer::new(...))등록 -
domain::auth::caller_from/caller_wallet헬퍼 추가 - 단위 테스트 5개 이상 (시나리오별)
-
기존
DeleteNode,GetReceipts,GetEarningsSummary핸들러를 새 헬퍼로 전환 -
기존
examples/*.rs전부 통과 (회귀 없음) -
CLAUDE.md에 새 인증 패턴 문서화 - 향후 보호 RPC 추가 시 참고할 수 있도록 한 줄 가이드
리스크 및 주의사항¶
-
tonic-reflection public path: gRPC reflection 엔드포인트를 allow-list 에 빠뜨리면
grpcurl이 인증 실패. 현재 이미 public 이지만 명시적 등록 필요. -
Status::to_http()API 안정성: tonic 0.13 API. 향후 업그레이드 시 체크. -
Tower body type:
tonic::body::BoxBody와http::Bodytrait bound 가 까다로워 제네릭 Service 구현에 시간 소요 가능. 초기에axum::body::Body와 혼용 금지. -
AuthContextextensions 타입 충돌: 향후 다른 미들웨어 (Access Log, Rate Limit) 가 같은 extensions 를 사용할 때 타입 구분 필요. -
성능: public path 체크가 매 요청마다 실행됨 →
HashSet<&'static str>은 O(1) 이므로 문제 없음.
관련 이슈¶
- #256 사용자 인증 API (JWT) — 이번 이슈의 선행 작업. JWT 발급/검증 로직 구현됨
-
#313 노드 삭제 API — 현재 핸들러별
require_auth사용 중. 전환 대상 -
#314 ALP 수익 계산 API —
GetReceipts,GetEarningsSummary가resolve_wallet사용 중. 전환 대상 - #351 API 보안 점검 (JWT. Rate Limit) — Rate Limit 미들웨어를 동일한 Tower Layer 패턴으로 추가 예정. 이 이슈에서 패턴을 확립하면 Rate Limit 도 일관된 방식으로 추가 가능
예상 작업량¶
- Phase 1 (레이어 도입): 2~3 시간
- Phase 2 (핸들러 전환): 1~2 시간
- Phase 3 (정리 + 문서): 1 시간
- 총: 4~6 시간 (단위 테스트 포함)
참고 코드 / 링크¶
- 레거시 Go 구현:
~/Works/alphea-rpc-next/alphea-rpc/internal/server/interceptors.go(lines 110-188) — AuthInterceptor + public RPC list - tonic 0.13 Tower middleware 예제: https://github.com/hyperium/tonic/tree/master/examples/src/tower
- Rust crate:
tower,tower-http,http는 이미Cargo.lock에 존재 (tonic 이 의존)