이슈 #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 이 의존)
JL James Lee이(가) 7달 전에 변경
- 상태을(를) 신규에서 진행(으)로 변경되었습니다.
Phase 1 + Phase 2 완료 (전역 레이어 도입 + 기존 보호 핸들러 전환)¶
commit:server|b2c3d29
변경 내용¶
-
src/server/auth_middleware.rs(신규) —AuthLayer(Tower Layer) +AuthMiddleware(Service) +AuthContext(request extension)- HTTP URI path 기준 allow-list 매칭 → default deny 정책
- Bearer JWT 검증 후
AuthContext { wallet, token_type, scopes }를http::Request::extensions_mut()에 주입 (tonic 의Request::from_http_parts가 extensions 를 그대로 보존) - 공개 경로:
SystemService/{Health,Info},AuthService/{Challenge,Verify,GenerateKeypair},NodeService/{ListNodes,GetNode,NodeChallenge,RegisterNode}, gRPC reflection (v1 + v1alpha)
-
src/server/mod.rs—Server::builder().layer(AuthLayer::new(state.cfg.jwt_secret.clone()))등록 (이 때문에src/server.rs→src/server/mod.rs구조 전환) -
src/domain/auth/mod.rs—caller(req)/caller_wallet(req)헬퍼 추가 (O(1) extensions 조회). 기존require_auth는 deprecated 표시 후 유지 (#351 Rate Limit 도입 시 일괄 제거 예정) -
핸들러 전환:
NodeService::DeleteNode(#313),WalletService::{GetReceipts, GetEarningsSummary}(#314) — JWT 재검증 없이caller_wallet()만 호출 -
Cargo.toml —
tower,tower-layer,tower-service,http,http-body,pin-project-litedirect dep 추가 (기존 transitive 를 명시화) -
src/lib.rs—#![allow(clippy::result_large_err)]추가 (tonic::Status176 bytes 로 크레이트 전반에서 트리거되는 것을 한 곳에서 silence)
검증¶
-
cargo test— 36개 통과 (AuthMiddleware 단위 테스트 6개 포함)- public path bypass
- 보호 path 토큰 없음 →
Unauthenticated - 보호 path 잘못된 토큰 포맷 →
Unauthenticated - 다른 secret 으로 서명된 토큰 →
Unauthenticated - 유효 토큰 →
AuthContext주입 확인 (wallet,token_type="app",scopes포함) - allow-list 정합성 (공개 RPC 는 포함, 보호 RPC 는 미포함)
-
cargo clippy --all-targets -- -D warnings— 경고 0 - 실서버 E2E 4종 통과:
auth_roundtrip,node_register,node_delete(stranger/unauth 차단 재확인),wallet_earnings(stranger/unauth 차단 재확인)
DoD 체크리스트¶
-
src/server/auth_middleware.rs(AuthLayer, AuthMiddleware, AuthContext) - Allow-list 정의 (현재 9개 공개 RPC + reflection 2종)
-
Server::builder().layer(AuthLayer::new(...))등록 -
caller/caller_wallet헬퍼 - 단위 테스트 6개 (시나리오별)
-
DeleteNode,GetReceipts,GetEarningsSummary를 새 헬퍼로 전환 -
기존
examples/*.rs전부 회귀 없음 통과 -
Phase 3 —
require_auth제거 : 새 보호 RPC (#351 Rate Limit, #355 등) 가 추가되어 이중검증 경로가 많아질 때 일괄 제거. 현재는 deprecated 표시로만 남겨 두고 이 이슈는 진행 상태 유지.
Node token 특수 처리¶
레거시의 ConnectNode 가 typ == "node" 토큰을 요구하는 부분은 이번 범위에서 제외. ConnectNode 포팅 시 별도 이슈에서 per-path required_token_type 확장 예정.
JL James Lee이(가) 7달 전에 변경
- 상태을(를) 진행에서 해결(으)로 변경되었습니다.
DoD 완료 — 해결 처리¶
commit:server|e8e2cd5
CLAUDE.md 에 "gRPC Auth Pattern" 섹션을 추가하여 DoD 의 남은 2개 항목을 충족.
추가된 문서 내용¶
- 전역
AuthLayer가 JWT 를 처리하고AuthContext를 주입한다는 정책 명시 - Allow-list 위치 (
public_path_set()) 와 Caller 헬퍼 (caller,caller_wallet) 위치 안내 -
한 줄 가이드 — 새 RPC 추가 시:
- 공개 RPC →
public_path_set()에 path 한 줄 추가, 핸들러 인증 코드 불필요 - 보호 RPC →
let caller = caller_wallet(&request)?;만 호출 - 소유권 체크 →
caller_wallet결과와 리소스 owner 필드eq_ignore_ascii_case비교
- 공개 RPC →
최종 DoD 체크리스트¶
-
src/server/auth_middleware.rs구현 (AuthLayer, AuthMiddleware, AuthContext) - Allow-list 정의 (9개 공개 RPC + reflection 2종)
-
.layer(AuthLayer::new(...))등록 -
caller/caller_wallet헬퍼 - 단위 테스트 6개
-
DeleteNode,GetReceipts,GetEarningsSummary핸들러 전환 -
examples/*.rs전부 회귀 없음 통과 -
CLAUDE.md에 새 인증 패턴 문서화 - 향후 보호 RPC 추가 시 참고할 한 줄 가이드
후속 작업 (이 이슈 범위 외)¶
-
Phase 3 —
require_auth제거: Migration Strategy 에 계획된 정리 단계. 보호 RPC 가 더 추가된 시점(#351 Rate Limit 또는 신규 보호 RPC 포팅)에 별도 이슈로 일괄 처리. -
Token type 강제 (
nodevsapp):ConnectNode포팅 시 별도 이슈에서required_token_type확장.
JL James Lee이(가) 6달 전에 변경
Go 원본 인터셉터 와 GoTrue 토큰 호환¶
commit:server|03c6c64 (이슈 #434)
본 이슈는 Rust 측의 Tower Layer 포팅이었고, Go 원본에는 이미 internal/server/interceptors.go 에 AuthInterceptor 와 allow-list 가 존재했다. 이번 Phase 1 백포트에서 다음을 업데이트:
- 토큰 컨텍스트를 단일 스트링(wallet_address) 에서 구조체
AuthContext{UserID, NodeID, Email, TokenType, Wallet}로 확장 -
verifyFn(token) (wallet string, err error)시그니쳐를VerifyFunc func(token) (*AuthContext, error)로 교체 -
UserIDFromContext/NodeIDFromContext/AuthFromContext헬퍼 도입, 기존WalletFromContext는 hose backward-compat 로 남겨두고 deprecated 표시 - public allow-list 에서 제거된 Auth RPC 항목 제거: Challenge / Verify / Login / SetPassword / EmailRegister / EmailRecover
GoTrue 호환 검증¶
internal/domain/auth/jwt_test.go 에 GoTrue 답식 의 user 토큰(sub=UUID, email, aud, role, no typ) 이 통과하고 TokenType()="user" 로 인식되는지 확인하는 단위 테스트 추가.