-
Ory Hydra를 활용한 OAuth2/OIDC 서버 구축 가이드: 실제 프로젝트 적용 사례실제 경험과 인사이트를 AI와 함께 정리한 글 2025. 12. 7. 10:48
작성일: 2025년 12월 6일
카테고리: Authentication, OAuth2, Ory Stack
키워드: Ory Hydra, OAuth2, OIDC, Kratos, Keto, Oathkeeper, API Gateway요약
API Gateway 플랫폼에서 Ory Hydra를 OAuth2/OIDC 서버로 활용한 실제 구현 사례를 공유합니다. Hydra는 Ory 스택(Kratos, Keto, Oathkeeper)과 함께 사용되어, 사용자 인증은 Kratos가, 권한 관리는 Keto가, API 보호는 Oathkeeper가 담당하는 구조입니다. 이 글에서는 Docker 기반 배포, OAuth2 플로우 구현, 다른 Ory 서비스와의 통합 방법을 다룹니다.
배경
Ory 스택 소개
Ory는 클라우드 네이티브 인증/인가 오픈소스 생태계입니다:
서비스 역할 포트 Hydra OAuth2/OIDC 서버 4444 (public), 4445 (admin) Kratos 사용자 인증 (Identity) 4433 (public), 4434 (admin) Keto 권한 관리 (Authorization) 4466 (read), 4467 (write) Oathkeeper API 프록시 (Policy Enforcement) 4455 (proxy), 4456 (api) Hydra의 역할
Hydra는 OAuth2/OIDC 프로토콜 서버입니다. 중요한 점은 Hydra가 사용자 인증을 직접 처리하지 않는다는 것입니다. Login/Consent UI는 별도로 구현해야 하며, Hydra는 토큰 발급과 관리만 담당합니다.
graph LR Client[클라이언트] --> Hydra[Hydra] Hydra --> LoginUI[Login UI] LoginUI --> Kratos[Kratos] Kratos --> LoginUI LoginUI --> Hydra Hydra --> Client style Hydra stroke:#2563eb,stroke-width:3px style Kratos stroke:#16a34a,stroke-width:3px style LoginUI stroke:#ea580c,stroke-width:3px
아키텍처
전체 구성
graph TB Browser[브라우저] --> Envoy[Envoy Gateway :8080] Envoy --> Oathkeeper[Oathkeeper :4455] Oathkeeper --> |토큰 검증| Hydra[Hydra :4445] Oathkeeper --> API[Go API] API --> |권한 확인| Keto[Keto :4466] API --> |세션 확인| Kratos[Kratos :4433] API --> DB[(PostgreSQL)] style Hydra stroke:#2563eb,stroke-width:3px style Kratos stroke:#16a34a,stroke-width:3px style Keto stroke:#ea580c,stroke-width:3px style Oathkeeper stroke:#dc2626,stroke-width:3px서비스 역할 분담
- Envoy: 외부 요청의 진입점
- Oathkeeper: Bearer 토큰 검증, 세션 확인
- Hydra: OAuth2 토큰 발급 및 introspection
- Kratos: 사용자 로그인/회원가입 처리
- Keto: 리소스별 권한(RBAC) 관리
Docker Compose 설정
Hydra 서비스 정의
services: hydra-migrate: image: oryd/hydra:v2.2.0 environment: - DSN=postgres://user:password@postgres:5432/hydra?sslmode=disable command: migrate sql -e --yes depends_on: postgres: condition: service_healthy hydra: image: oryd/hydra:v2.2.0 container_name: imprun-hydra ports: - "4444:4444" # Public API (OAuth2 endpoints) - "4445:4445" # Admin API (토큰 관리, introspection) environment: - DSN=postgres://user:password@postgres:5432/hydra?sslmode=disable - SECRETS_SYSTEM=${HYDRA_SECRET_SYSTEM} - SECRETS_COOKIE=${HYDRA_SECRET_COOKIE} - URLS_SELF_ISSUER=http://localhost:4444 - URLS_CONSENT=${APP_URL}/consent - URLS_LOGIN=${APP_URL}/login - URLS_LOGOUT=${APP_URL}/logout - URLS_ERROR=${APP_URL}/error - URLS_POST_LOGOUT_REDIRECT=${APP_URL} - STRATEGIES_ACCESS_TOKEN=jwt - TTL_ACCESS_TOKEN=1h - TTL_REFRESH_TOKEN=720h - OAUTH2_EXPOSE_INTERNAL_ERRORS=true command: serve all --dev depends_on: hydra-migrate: condition: service_completed_successfully주요 환경 변수 설명
변수 설명 DSNPostgreSQL 연결 문자열 SECRETS_SYSTEM토큰 암호화 키 (32바이트 이상) SECRETS_COOKIE쿠키 암호화 키 URLS_SELF_ISSUERJWT issuer URL URLS_LOGIN로그인 UI 리다이렉트 URL URLS_CONSENT동의 화면 URL STRATEGIES_ACCESS_TOKEN토큰 형식 (jwt 또는 opaque) TTL_ACCESS_TOKEN액세스 토큰 유효기간 TTL_REFRESH_TOKEN리프레시 토큰 유효기간 Secret 생성 방법
# 32바이트 랜덤 시크릿 생성 openssl rand -hex 32 # .env 파일에 추가 HYDRA_SECRET_SYSTEM=your-generated-secret-here HYDRA_SECRET_COOKIE=your-generated-cookie-secret-here
Oathkeeper와 통합
Bearer 토큰 Introspection 설정
Oathkeeper는 API 요청의 Bearer 토큰을 Hydra를 통해 검증합니다.
oathkeeper.yml:
authenticators: bearer_token: enabled: true config: check_session_url: http://hydra:4445/oauth2/introspect preserve_path: true extra_from: "@this" subject_from: "sub" token_from: header: Authorization cookie_session: enabled: true config: check_session_url: http://kratos:4433/sessions/whoami preserve_path: true extra_from: "@this" subject_from: "identity.id" authorizers: allow: enabled: true mutators: header: enabled: true config: headers: X-User-ID: "{{ print .Subject }}"Access Rule 정의
access-rules.yml:
- id: api-protected upstream: url: http://api:8080 match: url: <http|https>://<.*>/api/v1/<.*> methods: - GET - POST - PUT - DELETE authenticators: - handler: bearer_token - handler: cookie_session authorizer: handler: allow mutators: - handler: header
Go API에서 토큰 검증
Hydra Client 설정
// pkg/config/config.go type Config struct { Hydra struct { PublicURL string `env:"HYDRA_PUBLIC_URL" envDefault:"http://localhost:4444"` AdminURL string `env:"HYDRA_ADMIN_URL" envDefault:"http://localhost:4445"` } }Token Introspection 구현
// internal/infrastructure/client/hydra_client.go package client import ( "context" "net/http" "net/url" "strings" hydra "github.com/ory/hydra-client-go/v2" ) type HydraClient struct { admin *hydra.APIClient } func NewHydraClient(adminURL string) *HydraClient { config := hydra.NewConfiguration() config.Servers = []hydra.ServerConfiguration{ {URL: adminURL}, } return &HydraClient{ admin: hydra.NewAPIClient(config), } } func (c *HydraClient) IntrospectToken(ctx context.Context, token string) (*hydra.IntrospectedOAuth2Token, error) { result, _, err := c.admin.OAuth2API. IntrospectOAuth2Token(ctx). Token(token). Execute() if err != nil { return nil, err } return result, nil }인증 미들웨어
// internal/interface/middleware/auth.go package middleware import ( "strings" "github.com/gin-gonic/gin" ) func AuthMiddleware(hydraClient *client.HydraClient, kratosClient *client.KratosClient) gin.HandlerFunc { return func(c *gin.Context) { // 1. Bearer 토큰 확인 authHeader := c.GetHeader("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { token := strings.TrimPrefix(authHeader, "Bearer ") result, err := hydraClient.IntrospectToken(c.Request.Context(), token) if err == nil && result.GetActive() { c.Set("user_id", result.GetSub()) c.Set("token_type", "bearer") c.Next() return } } // 2. 세션 쿠키 확인 (Kratos) cookie, err := c.Cookie("ory_kratos_session") if err == nil && cookie != "" { session, err := kratosClient.WhoAmI(c.Request.Context(), cookie) if err == nil && session.GetActive() { c.Set("user_id", session.Identity.GetId()) c.Set("token_type", "session") c.Next() return } } c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) } }
OAuth2 Client 등록
CLI를 통한 클라이언트 등록
# OAuth2 클라이언트 생성 docker exec imprun-hydra hydra create oauth2-client \ --endpoint http://localhost:4445 \ --name "API Gateway Client" \ --grant-type client_credentials \ --grant-type authorization_code \ --grant-type refresh_token \ --response-type code \ --response-type token \ --scope openid \ --scope offline_access \ --scope profile \ --redirect-uri http://localhost:3000/callback \ --token-endpoint-auth-method client_secret_post # 결과 예시 # CLIENT ID: abc123... # CLIENT SECRET: xyz789...Admin API를 통한 클라이언트 등록
func (c *HydraClient) CreateOAuth2Client(ctx context.Context, name string, redirectURIs []string) (*hydra.OAuth2Client, error) { client := hydra.NewOAuth2Client() client.SetClientName(name) client.SetGrantTypes([]string{"authorization_code", "refresh_token", "client_credentials"}) client.SetResponseTypes([]string{"code", "token"}) client.SetScope("openid offline_access profile") client.SetRedirectUris(redirectURIs) client.SetTokenEndpointAuthMethod("client_secret_post") result, _, err := c.admin.OAuth2API. CreateOAuth2Client(ctx). OAuth2Client(*client). Execute() return result, err }
OAuth2 플로우 구현
Authorization Code Flow
sequenceDiagram participant Client as 클라이언트 participant Hydra as Hydra participant Login as Login UI participant Kratos as Kratos participant Consent as Consent UI Client->>Hydra: GET /oauth2/auth Hydra->>Login: 리다이렉트 (login_challenge) Login->>Kratos: 사용자 인증 Kratos->>Login: 세션 생성 Login->>Hydra: PUT /oauth2/auth/requests/login/accept Hydra->>Consent: 리다이렉트 (consent_challenge) Consent->>Hydra: PUT /oauth2/auth/requests/consent/accept Hydra->>Client: 리다이렉트 (code) Client->>Hydra: POST /oauth2/token (code) Hydra->>Client: access_token, refresh_tokenLogin Challenge 처리
// Login UI 백엔드 func HandleLogin(c *gin.Context) { challenge := c.Query("login_challenge") // 1. Challenge 정보 조회 loginRequest, _, err := hydraClient.OAuth2API. GetOAuth2LoginRequest(c.Request.Context()). LoginChallenge(challenge). Execute() if err != nil { c.AbortWithError(500, err) return } // 2. 이미 인증된 사용자인 경우 스킵 if loginRequest.GetSkip() { acceptRequest := hydra.NewAcceptOAuth2LoginRequest(loginRequest.GetSubject()) result, _, _ := hydraClient.OAuth2API. AcceptOAuth2LoginRequest(c.Request.Context()). LoginChallenge(challenge). AcceptOAuth2LoginRequest(*acceptRequest). Execute() c.Redirect(302, result.GetRedirectTo()) return } // 3. 로그인 폼 표시 c.HTML(200, "login.html", gin.H{ "challenge": challenge, }) } func HandleLoginSubmit(c *gin.Context) { challenge := c.PostForm("challenge") email := c.PostForm("email") password := c.PostForm("password") // Kratos로 인증 session, err := kratosClient.Login(c.Request.Context(), email, password) if err != nil { c.HTML(400, "login.html", gin.H{"error": "Invalid credentials"}) return } // Hydra에 로그인 승인 acceptRequest := hydra.NewAcceptOAuth2LoginRequest(session.Identity.GetId()) acceptRequest.SetRemember(true) acceptRequest.SetRememberFor(3600) result, _, _ := hydraClient.OAuth2API. AcceptOAuth2LoginRequest(c.Request.Context()). LoginChallenge(challenge). AcceptOAuth2LoginRequest(*acceptRequest). Execute() c.Redirect(302, result.GetRedirectTo()) }Consent Challenge 처리
func HandleConsent(c *gin.Context) { challenge := c.Query("consent_challenge") // 1. Consent 요청 정보 조회 consentRequest, _, err := hydraClient.OAuth2API. GetOAuth2ConsentRequest(c.Request.Context()). ConsentChallenge(challenge). Execute() if err != nil { c.AbortWithError(500, err) return } // 2. 이미 동의한 경우 또는 자사 앱인 경우 스킵 if consentRequest.GetSkip() || isTrustedClient(consentRequest.GetClient().GetClientId()) { acceptRequest := hydra.NewAcceptOAuth2ConsentRequest() acceptRequest.SetGrantScope(consentRequest.GetRequestedScope()) acceptRequest.SetGrantAccessTokenAudience(consentRequest.GetRequestedAccessTokenAudience()) result, _, _ := hydraClient.OAuth2API. AcceptOAuth2ConsentRequest(c.Request.Context()). ConsentChallenge(challenge). AcceptOAuth2ConsentRequest(*acceptRequest). Execute() c.Redirect(302, result.GetRedirectTo()) return } // 3. 동의 화면 표시 c.HTML(200, "consent.html", gin.H{ "challenge": challenge, "requestedScopes": consentRequest.GetRequestedScope(), "clientName": consentRequest.GetClient().GetClientName(), }) }
Client Credentials Flow
서버 간 통신에 사용되는 플로우입니다.
func GetClientCredentialsToken(clientID, clientSecret string) (string, error) { data := url.Values{} data.Set("grant_type", "client_credentials") data.Set("client_id", clientID) data.Set("client_secret", clientSecret) data.Set("scope", "openid") resp, err := http.PostForm("http://localhost:4444/oauth2/token", data) if err != nil { return "", err } defer resp.Body.Close() var result struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } json.NewDecoder(resp.Body).Decode(&result) return result.AccessToken, nil }
Keto와 연동한 권한 관리
Hydra로 인증된 사용자의 권한은 Keto로 관리합니다.
Permission Check
func (m *AuthMiddleware) RequireOrgPermission(permission string) gin.HandlerFunc { return func(c *gin.Context) { userID := c.GetString("user_id") orgID := c.Param("orgId") // Keto에서 권한 확인 hasPermission, err := m.ketoClient.Check(c.Request.Context(), &keto.CheckRequest{ Namespace: "Organization", Object: orgID, Relation: permission, SubjectId: &userID, }) if err != nil || !hasPermission { c.AbortWithStatusJSON(403, gin.H{"error": "forbidden"}) return } c.Next() } }라우터 적용
func SetupRoutes(r *gin.Engine, authMiddleware *middleware.AuthMiddleware) { api := r.Group("/api/v1") api.Use(authMiddleware.Authenticate()) orgs := api.Group("/organizations/:orgId") { orgs.GET("", authMiddleware.RequireOrgPermission("view"), handlers.GetOrganization) orgs.PUT("", authMiddleware.RequireOrgPermission("manage"), handlers.UpdateOrganization) orgs.DELETE("", authMiddleware.RequireOrgPermission("owner"), handlers.DeleteOrganization) } }
트러블슈팅
일반적인 문제와 해결책
문제 원인 해결책 invalid_client클라이언트 ID/Secret 불일치 클라이언트 재등록 또는 확인 consent_requiredConsent 플로우 미구현 /consent엔드포인트 구현Token introspection 실패 Admin API URL 오류 4445 포트 사용 확인 JWT 서명 검증 실패 JWK 미동기화 /.well-known/jwks.json확인로그 확인
# Hydra 로그 확인 docker logs imprun-hydra -f # 특정 요청 디버깅 docker logs imprun-hydra 2>&1 | grep "oauth2"Health Check
# Public API curl http://localhost:4444/health/ready # Admin API curl http://localhost:4445/health/ready # OIDC Discovery curl http://localhost:4444/.well-known/openid-configuration
교훈
1. Hydra는 인증 서버가 아니다
Hydra는 OAuth2/OIDC 프로토콜 서버입니다. 사용자 인증은 별도 시스템(Kratos 등)이 담당합니다. Login/Consent UI를 직접 구현해야 한다는 점을 처음부터 인지해야 합니다.
2. Ory 스택의 역할 분리
각 서비스의 역할이 명확히 분리되어 있습니다:
- Hydra: 토큰 발급/관리
- Kratos: 사용자 인증
- Keto: 권한 관리
- Oathkeeper: API 보호
이 분리를 이해하면 전체 아키텍처 설계가 명확해집니다.
3. 개발 모드와 프로덕션 차이
--dev플래그는 개발 편의를 위한 것입니다. 프로덕션에서는:- HTTPS 필수
- 시크릿 키 안전한 관리
- CORS 설정 제한
- Rate limiting 적용
참고 자료
공식 문서
- Ory Hydra Documentation
- Ory Kratos Documentation
- Ory Keto Documentation
- Ory Oathkeeper Documentation
관련 문서
- APISIX Consumer 인증 아키텍처 - API Gateway 인증 패턴
- Application-Grant 아키텍처 - Consumer 인증 설계
'실제 경험과 인사이트를 AI와 함께 정리한 글' 카테고리의 다른 글
Ory Keto를 활용한 ReBAC 기반 권한 관리 시스템 구축 (0) 2025.12.07 Ory Kratos를 활용한 사용자 인증 시스템 구축: ImpRun 프로젝트 적용 사례 (0) 2025.12.07 Gemini 3.0 Pro + Antigravity 실사용 후기: Claude Code 사용자의 답답한 경험 (0) 2025.12.06 Go 개발 생산성 향상을 위한 Air Live Reload 도입 가이드 (0) 2025.12.06 Claude Opus 4.5 vs Gemini 3.0 Pro vs Gemini 2.5 vs GPT-5.1: 백엔드 설계문서 비교 (0) 2025.11.27