두 개의 Rails 앱이 있다. 하나는 내부 직원용 앱(OTP 로그인, 특정 도메인 전용), 다른 하나는 심사/관리 시스템으로 Devise + JWT 기반이다. 내부 직원이 심사 시스템에도 접근해야 하는데, 계정을 따로 만들어 관리하기 싫었다.
“이미 내부 앱에 로그인돼 있으면, 심사 시스템에서 버튼 하나로 자동 로그인되면 안 되나?”
OAuth2를 붙이면 정석이지만, Doorkeeper 설정하고 scope 관리하고… 내부 서비스 두 개 사이에 그게 과할 수 있다. 더 단순한 방법을 택했다.
OAuth2를 안 쓴 이유
OAuth2는 서드파티 클라이언트 지원, 세밀한 권한 범위(scope) 관리, 토큰 갱신 흐름, 공개 API 연동이 필요할 때 올바른 선택이다. 하지만 같은 팀이 운영하는 두 내부 서비스 사이에서 OAuth2를 도입하면 다음을 감수해야 한다.
- 별도로 관리해야 하는 인가 서버
- 클라이언트 등록과 scope 정의가 필요한 Doorkeeper 설정
- SP 쪽의 토큰 갱신 로직
- 실제 문제 크기에 비해 과도한 공격 표면
One-Time Token + HMAC 패턴은 핵심 요구사항인 “Service A가 이미 인증한 사용자임을 Service B에 위임한다"를 새 의존성 없이, 기존 HMAC webhook 패턴을 그대로 재사용해 해결한다.
구조 선택: One-Time Token + HMAC
이미 두 서비스 사이에 webhook 연동이 있었다. ITSM 이벤트를 다른 서비스에 전달할 때 HMAC-SHA256으로 서명하는 패턴이 있었고, 이걸 SSO에도 그대로 쓰기로 했다.
[Service A - 로그인 버튼 클릭]
→ Service B /sso/authorize (로그인 여부 확인 + 토큰 발급)
→ Redirect → Service A /sso/callback?token=xxx&state=yyy
→ Service A가 Service B에 POST /sso/verify (HMAC 서명)
→ Service B가 유저 정보 반환
→ Service A Devise 세션 생성
핵심 보안 장치:
- Token: 일회용, 5분 만료, DB 저장 (
used_at체크) - HMAC-SHA256: Verify 요청이 신뢰된 서비스에서 온 것인지 검증
- state: CSRF 방지 (세션에 저장, callback에서 비교)
- redirect_uri 화이트리스트: 허용된 주소로만 리다이렉트
흐름은 의도적으로 짧게 설계했다. 토큰은 최대 5분만 유효하고 딱 한 번만 쓸 수 있다. 공격자가 callback URL을 가로채더라도, verify 엔드포인트에서 유효한 응답을 받으려면 공유 HMAC 비밀값이 있어야 한다.
IdP 쪽 구현 (토큰 발급 서비스)
IdP(Identity Provider)는 내부 직원 앱이다. 권한 있는 사용자 레코드를 보유하고, 토큰 발급과 검증을 담당한다.
SsoToken 모델
모델은 의도적으로 최소한으로 유지했다. valid 스코프는 두 조건을 결합한다: 아직 사용되지 않았고(used_at이 nil), 만료되지 않은 토큰.
class SsoToken < ApplicationRecord
belongs_to :user
scope :valid, -> { where(used_at: nil).where("expires_at > ?", Time.current) }
def use!
update!(used_at: Time.current)
end
end
마이그레이션:
create_table :sso_tokens do |t|
t.string :token, null: false, index: { unique: true }
t.references :user, null: false, foreign_key: true
t.string :redirect_uri, null: false
t.string :state, null: false
t.string :client_id, null: false
t.datetime :expires_at, null: false
t.datetime :used_at
t.timestamps
end
client_id 컬럼은 어느 SP가 토큰을 요청했는지 기록한다. 나중에 여러 SP를 지원해야 할 때 필터링이나 감사(audit)에 사용할 수 있다. state 컬럼은 CSRF 검증을 위해 SP가 전달한 값을 저장한다.
SSO 컨트롤러
class Auth::SsoController < ApplicationController
ALLOWED_REDIRECT_URIS = -> {
ENV.fetch("SSO_ALLOWED_REDIRECT_URIS", "").split(",").map(&:strip)
}
SSO_SHARED_SECRET = -> { ENV.fetch("SSO_SHARED_SECRET") }
before_action :require_authentication, only: [:authorize]
skip_before_action :verify_authenticity_token, only: [:verify]
# GET /auth/sso/authorize
def authorize
redirect_uri = params[:redirect_uri]
# redirect_uri 화이트리스트 검증
unless ALLOWED_REDIRECT_URIS.call.any? { |uri| redirect_uri.start_with?(uri) }
return render plain: "Invalid redirect_uri", status: :bad_request
end
sso_token = SsoToken.create!(
token: SecureRandom.urlsafe_base64(32),
user: current_user,
redirect_uri: redirect_uri,
state: params[:state],
client_id: params[:client_id],
expires_at: 5.minutes.from_now
)
redirect_to "#{redirect_uri}?token=#{sso_token.token}&state=#{params[:state]}",
allow_other_host: true
end
# POST /auth/sso/verify
def verify
request_body = request.body.read
signature = request.headers["X-Signature-SHA256"]
unless valid_signature?(request_body, signature)
return render json: { error: "Invalid signature" }, status: :unauthorized
end
token = JSON.parse(request_body)["token"]
sso_token = SsoToken.valid.find_by(token: token)
return render json: { error: "Invalid or expired token" }, status: :unauthorized unless sso_token
sso_token.use!
render json: { email: sso_token.user.email, name: sso_token.user.name }
end
private
def valid_signature?(body, signature)
return false unless signature.present?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', SSO_SHARED_SECRET.call, body)}"
ActiveSupport::SecurityUtils.secure_compare(expected, signature)
end
end
포인트: require_authentication이 미로그인 유저를 로그인 페이지로 보낼 때, SSO authorize URL 전체(쿼리 파라미터 포함)를 session[:return_to]에 저장해야 한다. OTP 인증 후 redirect_back_or(dashboard_path)로 돌아오면 SSO 흐름이 이어진다. 저장하지 않으면 로그인은 완료되지만 SSO 컨텍스트를 잃어버려 처음부터 다시 시작해야 한다.
/verify에 skip_before_action :verify_authenticity_token을 쓰는 이유: verify 엔드포인트는 브라우저 폼이 아닌 SP의 Rails 백엔드가 서버 간 호출로 접근한다. Rails CSRF 보호는 브라우저 세션 기반이므로 이 경우에는 적용되지 않는다. 서버 간 호출에서는 X-Signature-SHA256의 HMAC 서명이 동등한 보호 역할을 한다.
SP 쪽 구현 (로그인 위임 서비스)
SP(Service Provider)는 심사/관리 시스템이다. 인증을 IdP에 위임하고, 검증된 사용자 정보를 돌려받는다.
SSO Service
require "faraday"
require "openssl"
class SsoService
SSO_SHARED_SECRET = -> { ENV.fetch("SSO_SHARED_SECRET") }
def self.authorize_url(redirect_uri:, state:)
params = { client_id: "my_service", redirect_uri: redirect_uri, state: state }
"#{ENV.fetch('IDP_URL')}/auth/sso/authorize?#{params.to_query}"
end
def self.verify_token(token)
body = { token: token }.to_json
signature = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', SSO_SHARED_SECRET.call, body)}"
response = Faraday.new(url: ENV.fetch('IDP_URL')).post("auth/sso/verify") do |req|
req.body = body
req.headers["Content-Type"] = "application/json"
req.headers["X-Signature-SHA256"] = signature
end
return nil unless response.success?
JSON.parse(response.body)
rescue Faraday::Error
nil
end
end
verify_token은 네트워크 오류, 2xx가 아닌 응답, JSON 파싱 오류 등 모든 실패 케이스에서 예외를 던지지 않고 nil을 반환한다. 호출 코드는 nil을 인증 실패로 처리해 로그인 페이지로 리다이렉트한다.
콜백 컨트롤러
class SsoController < ApplicationController
def initiate
state = SecureRandom.urlsafe_base64(32)
session[:sso_state] = state
redirect_to SsoService.authorize_url(
redirect_uri: sso_callback_url,
state: state
), allow_other_host: true
end
def callback
# CSRF 방지: state 검증
unless ActiveSupport::SecurityUtils.secure_compare(
params[:state], session.delete(:sso_state).to_s
)
redirect_to login_path, alert: "인증 실패 (state mismatch)"
return
end
user_data = SsoService.verify_token(params[:token])
return redirect_to login_path, alert: "인증 실패" unless user_data
# 유저 생성 또는 조회
user = User.find_or_initialize_by(email: user_data["email"])
if user.new_record?
user.assign_attributes(
name: user_data["name"],
role: :reviewer, # SSO 유저 기본 역할
password: SecureRandom.hex(16),
sso_provider: "internal"
)
user.save!
end
sign_in(user)
redirect_to root_path, notice: "로그인되었습니다."
end
end
첫 로그인 시 유저 자동 프로비저닝: 신규 SSO 유저는 랜덤 비밀번호(SSO를 통해서만 로그인하므로 실제로 사용되지 않음)와 기본 reviewer 역할로 자동 생성된다. sso_provider: "internal" 속성은 관리자 화면이나 쿼리에서 SSO 계정과 일반 Devise 계정을 구분하는 데 사용한다.
session.delete(:sso_state) vs session[:sso_state]: 읽기만 하지 않고 delete를 쓰는 것은 의도적이다. 단일 원자 연산으로 세션에서 state를 제거해, 이전 callback URL이 재전송되더라도 오래된 state 값을 재사용할 수 없게 막는다.
Render 배포 시 삽질한 부분
autoDeploy: no 서비스에서 env var 업데이트가 구 커밋으로 배포됨
환경변수를 Render API/MCP로 업데이트하면 자동으로 재배포가 트리거된다. 그런데 autoDeploy: no인 서비스는 env var 업데이트 시점의 최신 커밋이 아니라 마지막으로 배포됐던 커밋으로 빌드한다.
실제로 겪은 순서:
- SSO 코드를 추가하고 저장소에 push
- Render API로
SSO_SHARED_SECRET과IDP_URL업데이트 - Render가 재배포 트리거 — 하지만 SSO 코드가 없는 이전 커밋으로 빌드
- 로그인 버튼이 보이지 않는 이유가 여기 있었다
해결: Render REST API로 수동 배포 트리거.
curl -X POST "https://api.render.com/v1/services/{SERVICE_ID}/deploys" \
-H "Authorization: Bearer $RENDER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"clearCache": "do_not_clear"}'
응답에서 커밋 메시지와 SHA를 확인해 제대로 된 버전이 배포됐는지 검증할 수 있다.
프론트엔드: 로그인 버튼
Svelte + Inertia.js 환경에서 SSO 버튼은 Inertia router가 아닌 일반 <a> 태그를 써야 한다. Inertia는 내부 XHR 요청을 보내는데, SSO 흐름은 외부 서비스로 실제 페이지 이동(redirect)이 필요하기 때문이다. 브라우저가 IdP의 /sso/authorize로 실제 네비게이션을 해야 하고, IdP는 다시 SP의 callback URL로 리다이렉트한다. XHR은 이 리다이렉트 체인에 참여할 수 없다.
<a
href="/sso/initiate"
class="w-full flex items-center justify-center gap-2 py-3 px-4
bg-[#1e3a5f] hover:bg-[#162d4a] text-white rounded-xl transition-all"
>
<svg><!-- shield icon --></svg>
내부 계정으로 로그인
<span class="text-xs text-white/70">직원 전용</span>
</a>
기존 이메일/비밀번호 폼 위에 또는 구분선과 함께 배치했다.
라우트 설정
양쪽 앱에 각각 라우트를 추가한다.
IdP (config/routes.rb):
namespace :auth do
get "sso/authorize", to: "sso#authorize"
post "sso/verify", to: "sso#verify"
end
SP (config/routes.rb):
get "sso/initiate", to: "sso#initiate", as: :sso_initiate
get "sso/callback", to: "sso#callback", as: :sso_callback
환경변수
양쪽 서비스가 동일한 SSO_SHARED_SECRET 값을 공유한다. 데이터베이스 비밀번호를 교체하는 것과 동일한 기준으로 관리하고 주기적으로 교체한다.
IdP:
SSO_SHARED_SECRET=<긴-랜덤-값>
SSO_ALLOWED_REDIRECT_URIS=https://review-system.example.com/sso/callback
SP:
SSO_SHARED_SECRET=<동일한-긴-랜덤-값>
IDP_URL=https://internal-app.example.com
핵심 체크리스트
- OAuth2가 항상 정답은 아니다. 두 내부 서비스 사이라면 One-Time Token + HMAC 조합이 훨씬 가볍고 직관적이다.
- 기존 패턴을 재사용하라. 서비스 간 HMAC webhook이 이미 있다면, SSO에 동일 패턴을 적용해도 팀 전체가 보안 모델을 이미 이해하고 있다.
- Token은 반드시 일회용이다.
used_at컬럼과valid스코프가 함께 작동해 재전송 공격을 차단한다. ActiveSupport::SecurityUtils.secure_compare는 선택이 아니다. 일반 문자열 비교는 타이밍 공격에 취약하다. HMAC 검증에는 항상 상수 시간 비교를 사용한다.state파라미터로 CSRF를 방지한다. SSO 시작 시마다 새로 생성하고, 세션에 저장하고, callback 검증 시 읽기만 하지 말고delete로 삭제한다.redirect_uri화이트리스트는 필수다. 오픈 리다이렉트와 유효한 토큰의 조합은 자격증명 탈취 취약점이 된다. 명시적 허용 목록으로 검증한다.- Render
autoDeploy: no+ env var 업데이트 = 구 코드 배포. env var 업데이트 후 반드시 수동 배포를 트리거하고, 응답의 커밋 SHA를 확인한다. - Inertia.js 환경에서 SSO는 일반
<a>태그를 사용한다. Inertia의 XHR 기반 네비게이션은 리다이렉트 의존적인 인증 흐름을 망가뜨린다.

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.