티스토리 뷰
Ory Hydra를 활용한 OAuth2/OIDC 서버 구축 가이드: 실제 프로젝트 적용 사례
pak2251 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
주요 환경 변수 설명
| 변수 | 설명 |
|---|---|
DSN |
PostgreSQL 연결 문자열 |
SECRETS_SYSTEM |
토큰 암호화 키 (32바이트 이상) |
SECRETS_COOKIE |
쿠키 암호화 키 |
URLS_SELF_ISSUER |
JWT 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_token
Login 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_required |
Consent 플로우 미구현 | /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 |
- Total
- Today
- Yesterday
- workflow
- authorization
- security
- claude code
- troubleshooting
- knowledge graph
- Tailwind CSS
- AI agent
- LLM
- Next.js
- Rag
- ai 개발 도구
- PYTHON
- Ontology
- Kubernetes
- api gateway
- react
- architecture
- 개발 도구
- AI
- Claude
- AI Development
- Go
- authentication
- frontend
- SHACL
- backend
- LangChain
- Developer Tools
- Tax Analysis
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |