26 KiB
Headless RP의 자체 Consent UI와 Baron UI 연동
Headless는 사용자가 보는 로그인·동의 화면을 RP가 제어하는 방식입니다. UserFront 페이지 방식은 Baron 화면으로 이동해서 같은 인증·동의 절차를 마칩니다. 두 방식 모두 최종 authorization code 교환과 token 검증이 필요하며, RP session은 RP가 만듭니다.
RP가 화면을 직접 디자인하려면 API를 사용하고, UserFront와 같은 구조·테마를 RP 페이지 안에 표시하려면 Baron Consent JS UI를 사용합니다. JS UI는 UserFront 페이지를 iframe으로 넣는 방식이 아니라 RP DOM에 동의 화면을 렌더링합니다.
구현과 검증 증거는 #2104에서 관리합니다. 이 가이드는 해당 코드 revision을 기준으로 하며 프로덕션 배포 여부는 이슈의 배포 상태를 별도로 확인합니다.
연동 방식 선택
| 방식 | RP가 구현할 부분 | 현재 상태 |
|---|---|---|
| RP 자체 UI + Consent API | 로그인·동의 화면, RP Backend adapter, 로그인 transaction 연결 | 서버 간 headless consent facade와 adapter 예제를 사용합니다. |
| Baron JS UI를 RP DOM에 탑재 | 자체 로그인 화면, container, adapter 연결, callback 설정 | 독립 ES/UMD 라이브러리로 UserFront 구조·테마를 탑재합니다. |
| 중앙 UserFront로 이동 | 반환 URL로 브라우저 이동, RP callback | 기존 기본 흐름입니다. |
자체 UI와 제공 JS UI는 같은 Backend의 동의·인가 판정을 사용합니다. 화면 라이브러리는 동의 원장이나 RP session을 소유하지 않습니다. 인증 정책과 시스템 경계를 따릅니다.
로그인부터 동의 완료까지
UserFront 페이지 방식
RP에는 시작·callback 구현이 필요합니다. 비밀번호 입력, 추가 인증, 동의 화면은 중앙 UserFront가 제공합니다. Headless client assertion이나 자체 consent adapter를 RP에 구현하지 않습니다.
sequenceDiagram
actor User as 사용자
participant RP as RP 웹/Backend
participant Hydra as Hydra Public
participant UF as UserFront
participant API as Baron Backend
User->>RP: 로그인 시작
RP-->>User: state/nonce/PKCE를 결속한 authorization URL
User->>Hydra: authorization 요청
Hydra-->>UF: login_challenge로 브라우저 이동
User->>UF: 로그인과 필요한 추가 인증
UF->>API: 로그인 처리
API-->>UF: redirectTo
UF->>Hydra: 브라우저 continuation
Hydra-->>UF: consent_challenge로 이동
UF->>API: GET /auth/consent
alt 기존 동의로 자동 승인
API-->>UF: redirectTo
else 사용자 선택 필요
API-->>UF: client/requested_scope/scope_details
User->>UF: 승인 또는 거절
UF->>API: POST /auth/consent/accept 또는 reject
API-->>UF: redirectTo
end
UF->>Hydra: 브라우저 continuation
Hydra-->>RP: callback code 또는 error + state
RP->>RP: state 검증, 성공 code 교환·token 검증, session 생성
Headless 자체 UI 또는 제공 JS UI 방식
RP Backend는 로그인 transaction과 Hydra cookie 문맥을 유지합니다. 자체 UI와 제공 JS UI의 차이는 렌더러이며, Backend adapter와 consent 원장은 같습니다. Client assertion은 RP Backend에서만 생성합니다.
sequenceDiagram
actor User as 사용자
participant UI as RP 자체 UI 또는 Baron JS UI
participant RP as RP Backend adapter
participant Hydra as Hydra Public
participant API as Baron Backend
User->>UI: RP 로그인 시작
UI->>RP: 브라우저 session에 연결된 로그인 transaction
RP->>Hydra: authorization, transaction별 cookie 문맥
Hydra-->>RP: 로그인 Location의 login_challenge
User->>UI: 로그인 정보 입력
UI->>RP: 로그인 제출
RP->>API: POST /auth/headless/password/login + consent_mode rp + assertion
API-->>RP: redirectTo
RP->>Hydra: 허용된 continuation만 manual follow
Hydra-->>RP: 중앙 consent Location의 consent_challenge
Note over RP: 중앙 화면은 fetch하지 않고 challenge를 transaction에 저장
RP-->>UI: RP consent 화면과 opaque flow ID
UI->>RP: load + session/CSRF
RP->>RP: Origin/session/CSRF/만료 및 transaction 결속 검증
RP->>API: POST /auth/headless/consent/info + 저장된 두 challenge + assertion
API->>API: client와 서버 login context 및 세션 연결 검증
alt 자동 승인
API-->>RP: redirectTo
else 동의 화면
API-->>RP: client/requested_scope/scope_details
RP-->>UI: 표시 정보
User->>UI: 승인 또는 거절
UI->>RP: 선택 scope 또는 reject + session/CSRF
RP->>API: POST /auth/headless/consent/accept 또는 reject
API-->>RP: redirectTo
end
RP->>Hydra: 같은 서버 cookie jar로 최종 continuation
Hydra-->>RP: RP callback Location, code 또는 error + state
RP->>RP: callback origin/path와 state 검증
RP-->>UI: 검증된 RP callback URL
UI->>RP: 브라우저 callback 이동
RP->>RP: state 검증, 성공 code 교환·token 검증, session 생성
이 서버 중계 방식은 authorization 시작부터 최종 continuation까지 같은 transaction의 서버 cookie jar를 사용합니다. RP callback 직전까지 중계하고 origin/path·state를 검증한 다음 브라우저를 RP callback으로 이동시킵니다. 서로 다른 사용자의 cookie jar를 공유하지 않습니다.
- RP는 authorization 요청마다
state,nonce, PKCE verifier/challenge를 만들고 해당 브라우저 transaction에 연결합니다. - RP Backend가
login_challenge와 서명한client_assertion으로POST /api/v1/auth/headless/password/login을 호출합니다. 설정·JWT 규칙은 README의 headless 항목을 따릅니다. - 성공 응답의
redirectTo를 RP Backend가 같은 서버 cookie jar로 중계해 consent Location을 받습니다. 이는 최종 callback URL을 보장하지 않습니다. 아직code가 없다는 이유로 password login을 반복하지 않습니다. - Consent 진입 경로가 전달한 별도
consent_challenge로 아래 조회·승인·거절을 처리합니다. - Consent 결과의
redirectTo도 RP Backend가 같은 jar로 중계합니다. 검증된 RP callback으로 브라우저를 이동시키고, 성공 code의 교환과 token 검증을 마친 뒤 RP session을 만듭니다. 오류 callback에도state를 검증합니다.
전역 URLS_CONSENT의 기본값은 ${USERFRONT_URL}/consent입니다. Headless RP는 서버에서 authorization continuation을 중계하고 이 Location의 challenge를 자신의 transaction에 연결합니다. Headless 설정이나 RP의 /consent 화면 추가만으로 Hydra가 RP 화면으로 이동시키지는 않습니다. 한 RP를 위해 전역 URL을 교체하지 않습니다.
RP 자체 UI가 사용하는 현재 API
API의 정본은 OpenAPI와 Headless Consent handler입니다.
RP 서버 전용 API와 중앙 UserFront API 구분
| 단계 | RP Backend가 호출하는 API | 중앙 UserFront API |
|---|---|---|
| 동의 정보·자동 승인 | POST /api/v1/auth/headless/consent/info |
GET /api/v1/auth/consent |
| 승인 | POST /api/v1/auth/headless/consent/accept |
POST /api/v1/auth/consent/accept |
| 거절 | POST /api/v1/auth/headless/consent/reject |
POST /api/v1/auth/consent/reject |
RP 자체 동의는 먼저 headless password login body에 consent_mode: "rp"를 넣습니다. Baron은 로그인과 consent를 연결하는 client·subject·Kratos session 정보를 서버 내부에 최대 10분 보관합니다. 기존 중앙 consent 방식은 이 필드를 생략합니다. 현재 이 opt-in은 password login에 적용되며 phone/link 전용 UI의 확장은 별도 범위입니다.
RP 전용 세 API는 매번 아래 필드를 보냅니다. client_assertion의 audience는 호출하는 endpoint 자신의 URL이어야 합니다. Password login assertion을 그대로 재사용하지 않습니다.
{
"client_id": "example-rp",
"client_assertion": "<endpoint-specific-signed-jwt>",
"login_challenge": "<original-login-challenge-stored-by-rp-backend>",
"consent_challenge": "<consent-challenge-stored-by-rp-backend>"
}
승인에만 grant_scope를 추가합니다. 서버 간 facade에는 브라우저의 Cookie나 사용자 Authorization을 전달하지 않습니다. 원래 로그인 세션과 중앙 정책을 서버에서 다시 검증합니다. RP는 Hydra 내부 ID를 추측·재구성하지 않고 자신이 받은 원본 login challenge를 보관해 전송합니다.
아래는 중앙 API를 기준으로 공통 응답·동의 선택 semantics를 설명합니다. RP는 위 facade 경로와 필수 인증 필드를 사용합니다.
동의 정보 또는 자동 승인 결과 조회
GET /api/v1/auth/consent?consent_challenge=<url-encoded-consent-challenge>
표준 URL parser로 challenge를 읽고 전송 시 인코딩합니다. Login challenge를 consent challenge 대신 사용하지 않습니다.
| 성공 응답 | UI 처리 |
|---|---|
redirectTo 포함 |
Backend가 이미 자동 승인했습니다. 브라우저를 이동시키고 추가 조회·accept를 하지 않습니다. |
requested_scope, client, 선택적 scope_details 포함 |
동의 화면을 표시하고 사용자 입력을 기다립니다. |
동의 화면 응답의 주요 필드는 다음과 같습니다. RP adapter는 내부 subject·challenge 등 렌더링에 필요하지 않은 필드를 브라우저에 전달하지 않습니다.
{
"client": { "client_id": "example-rp", "client_name": "업무 포털" },
"requested_scope": ["openid", "profile"],
"scope_details": {
"openid": { "description": "로그인 세션 확인", "mandatory": true },
"profile": { "description": "이름과 사용자 식별자", "mandatory": false }
}
}
자동 승인 또는 승인·거절 명령의 성공 응답은 다음 형태입니다. Baron은 Hydra continuation을 반환하고, 이 가이드의 RP adapter는 같은 서버 jar로 이를 처리한 후 브라우저용 RP callback URL을 반환합니다.
{ "redirectTo": "<verified-continuation-or-rp-callback-url>" }
이 GET은 자동 승인이라는 부작용이 있으므로 polling·prefetch·공유 cache 대상으로 사용하지 않습니다. skip만으로 완료를 추정하지 않고 redirectTo 응답으로 분기합니다.
애플리케이션 이름, requested_scope, scope_details[scope].description과 mandatory를 화면에 표시합니다. 설명은 HTML로 삽입하지 않습니다. 필수 scope는 구분하고 전체 거절 버튼도 제공합니다. 기존 중앙 UI는 openid를 필수로 유지하고 요청 scope를 기본 선택합니다. 제공 JS UI에서 다른 기본 선택 정책을 도입한다면 별도 정책 검토가 필요합니다.
사용자 승인
POST /api/v1/auth/consent/accept
Content-Type: application/json
{
"consent_challenge": "<consent-challenge>",
"grant_scope": ["openid", "profile"]
}
사용자가 승인한 scope와 필수 scope를 명시적으로 제출합니다. 성공 응답의 redirectTo로 이동하며, 중복 클릭과 자동 재제출을 막습니다.
현재 grant_scope 생략 또는 빈 배열은 전체 거절이 아닙니다. 요청 scope가 유지될 수 있고 필수 scope는 서버에서 다시 병합됩니다. 전체 거절은 아래 API를 사용합니다.
사용자 거절
POST /api/v1/auth/consent/reject
Content-Type: application/json
{
"consent_challenge": "<consent-challenge>"
}
반환 redirectTo로 OIDC 거절 흐름을 마칩니다. 거절을 자동 재로그인 조건으로 사용하지 않습니다.
RP Backend와 브라우저 경계
자체 UI는 RP의 same-origin Backend adapter를 통해 호출합니다. Adapter는 자신이 시작한 로그인 transaction에 연결된 challenge만 처리하고 client.client_id를 대조하며, 조회·승인·거절 모두 브라우저 결속과 CSRF 방어를 적용합니다. Baron은 자신이 생성한 Hydra login context와 내부 client·subject·session 결속을 대조하고 실제 세션·중앙 정책을 재검증합니다. RP 브라우저 transaction은 RP의 책임입니다.
client_assertion만으로 브라우저 결속까지 해결되지는 않습니다. 로그인 완료 전 RP에 token이 없는 시점에도 RP의 로그인 transaction을 별도로 유지하고 cookie 문맥·만료·일회 소비를 연결해야 합니다. Private key·client secret·조직도용 integration key는 JS에 넣지 않습니다. Ory Admin API를 RP 브라우저에서 직접 호출하지 않습니다.
GET도 자동 승인할 수 있으므로 adapter는 Baron 조회를 호출하기 전에 브라우저 transaction과 challenge의 결속을 검증해야 합니다. 조회 응답의 client 대조만으로 이미 발생한 자동 승인을 되돌릴 수는 없습니다.
Baron 제공 JS UI를 RP에 탑재하기
OrgFront 독립 SDK의 API·모델·DOM renderer 분리를 따릅니다. Consent SDK는 createConsentClient, createFetchTransport, mountConsent를 제공합니다. UI는 Shadow DOM으로 RP 스타일과 격리하고 UserFront의 카드·서비스 정보·scope 목록·승인·취소 확인 구조를 사용합니다.
빌드와 배포 파일
저장소 루트에서 기존 OrgFront 개발 의존성을 설치한 후 SDK만 빌드합니다. 애플리케이션 배포 flow는 변경하지 않습니다.
npm --prefix orgfront ci
npm --prefix userfront/consent-sdk run build
npm --prefix userfront/consent-sdk test
node userfront/consent-sdk/examples/rp-adapter.test.mjs
userfront/consent-sdk/dist에 다음 파일을 생성합니다. RP가 승인된 버전을 자신의 정적 파일 경로에 복사해 사용하며 공개 npm/CDN 배포는 전제하지 않습니다.
| 산출물 | 용도 |
|---|---|
baron-consent.js, baron-consent.min.js |
ES module |
baron-consent.umd.cjs, baron-consent.min.umd.cjs |
script 방식의 전역 BaronConsent |
index.d.ts |
TypeScript 타입 |
JS에 스타일이 포함되어 별도 CSS 파일은 필요하지 않습니다. UserFront와 동일한 글꼴을 사용하려면 RP도 Noto Sans KR을 로드해야 합니다. 예를 들어 저장소 userfront/assets/fonts의 Regular·Bold woff2를 RP 정적 파일로 제공하고 다음 CSS를 적용합니다. 배포 경로와 라이선스 파일은 RP가 사용하는 파일에 맞춰 유지합니다.
@font-face {
font-family: "Noto Sans KR";
src: url("/vendor/baron-consent/0.1.0/NotoSansKR-Regular.woff2");
font-weight: 400;
}
@font-face {
font-family: "Noto Sans KR";
src: url("/vendor/baron-consent/0.1.0/NotoSansKR-Bold.woff2");
font-weight: 700;
}
ES module 방식
아래 /vendor/...는 RP가 위 산출물을 호스팅하는 예시 경로입니다. rpFlowId와 csrfToken은 RP Backend가 해당 브라우저 session에 연결하여 HTML에 안전하게 전달한 값입니다. Hydra challenge나 client assertion을 브라우저에 입력받는 값이 아닙니다.
<div id="consent"></div>
<script type="module">
import {
createConsentClient, createFetchTransport, mountConsent,
} from "/vendor/baron-consent/0.1.0/baron-consent.js";
const boot = JSON.parse(document.querySelector("#rp-consent-config").textContent);
const client = createConsentClient({
expectedClientId: boot.clientId,
allowedRedirectOrigins: [location.origin],
allowedRedirectPaths: { [location.origin]: ["/auth/callback"] },
transport: createFetchTransport({
baseUrl: new URL("/auth/consent", location.origin).href,
headers: { "X-CSRF-Token": boot.csrfToken },
timeoutMs: 10000,
}),
});
const view = mountConsent(document.querySelector("#consent"), {
client,
challenge: boot.rpFlowId,
locale: "ko",
theme: "light",
onRedirect: (url) => location.assign(url),
onError: (error) => {
// 비밀이나 응답 원문 없이 문의용 ID만 진단에 사용합니다.
console.info("Consent failed", error.requestId ?? "unavailable");
},
});
window.addEventListener("pagehide", () => view.destroy(), { once: true });
// SPA는 해당 route의 cleanup에서도 view.destroy()를 호출합니다.
</script>
#rp-consent-config는 RP가 제공해야 하는 application/json 요소입니다. 서버가 JSON을 HTML에 삽입할 때 < 등을 이스케이프하여 script 종료 문자열을 데이터가 만들지 못하게 합니다. 허용 continuation은 실제 Hydra/RP callback origin과 exact path에 맞춰 서버·SDK 양쪽에 설정합니다.
일반 script 방식
같은 산출물을 JavaScript MIME type으로 호스팅합니다. .cjs 확장자도 다운로드 파일로 응답하지 않도록 RP 정적 서버를 설정합니다.
<script src="/vendor/baron-consent/0.1.0/baron-consent.umd.cjs"></script>
<script>
const { createConsentClient, createFetchTransport, mountConsent } = BaronConsent;
// 위 ES module 예제와 같은 설정으로 client와 view를 만듭니다.
</script>
locale은 ko·en, theme은 light·dark입니다. 최초 load부터 abort와 화면 해제가 연결되고, view.ready로 최초 렌더링 작업을 기다릴 수 있습니다. destroy() 후에는 늦은 응답이 화면이나 navigation을 변경하지 않습니다. 네트워크 취소가 이미 완료된 서버 승인을 취소한다는 뜻은 아닙니다.
createFetchTransport.timeoutMs는 기본 10초이며 caller의 취소 신호와 함께 적용됩니다. Timeout 후 자동 재전송하지 않습니다. 같은 Noto Sans KR·데이터·viewport에서 비교한 UserFront/SDK 캡처를 참고하시면 됩니다. 구조·색상·폭·권한 행 간격을 기준으로 맞추며 Flutter와 DOM의 모든 픽셀이 동일하다는 뜻은 아닙니다.
RP가 완전히 자체 UI를 만드는 경우
같은 createConsentClient에 transport를 연결하고 await client.load(rpFlowId, signal) 결과를 직접 렌더링합니다. kind: "redirect"이면 검증된 URL로 이동하고 kind: "consent"이면 clientName과 scopes의 name·description·mandatory를 사용합니다. 승인은 client.accept(rpFlowId, selectedScopes, signal), 전체 거절은 client.reject(rpFlowId, signal)입니다.
최초 load 이전부터 AbortController를 화면 생명주기에 연결합니다. 같은 client의 load 재호출은 표시 모델을 재사용하며 서버에 재전송하지 않습니다. 오류·불명확한 결과나 완료 이후에는 해당 client를 재사용하지 않고 사용자 동작으로 새 authorization부터 시작합니다.
Same-origin RP adapter
SDK transport는 POST /auth/consent에 아래 body와 X-CSRF-Token, same-origin cookie를 보냅니다. /auth/consent는 Baron endpoint가 아니라 RP가 제공하는 예시 경로입니다.
{
"flowId": "<opaque-rp-flow-id>",
"operation": "accept",
"grant_scope": ["openid", "profile"]
}
operation은 load, accept, reject이며 grant_scope는 accept에만 사용합니다. 서버 전용 adapter 예제를 HTTP handler에 연결할 때 다음 경계를 지킵니다.
sessionId는 RP가 무결성을 검증한 브라우저 로그인 transaction의 HttpOnly cookie에서,origin은 실제 요청의 Origin에서,csrfToken은 요청 header에서 얻습니다. 아직 로그인 완료된 RP session을 요구하는 뜻은 아닙니다. Body의 동명 필드를 신뢰하지 않습니다.- 서버 transaction에는
sessionId,csrfToken,expiresAt,loginChallenge,consentChallenge,state: "ready"와 별도 OIDC state·nonce·PKCE verifier·cookie jar를 저장합니다. Flow ID는 서버의 암호학적 난수로 만들고 해당 브라우저 transaction에만 연결합니다. 만료는 Baron 결속의 10분 이내로 설정합니다. - Adapter 설정의
signAssertion(audience)는 RP 서버 키로 해당 endpoint용 JWT를 생성합니다.continuationURLs에는 허용 origin+exact path를 넣습니다. - 예제의 Map과 상태 전이는 단일 프로세스 reference입니다. 다중 worker RP는 공유 transaction 저장소의 원자적 잠금/CAS로 같은 보장을 구현합니다.
- HTTP handler는 반환
status,headers,body를 그대로 전달하고 request body 크기를 제한합니다. 인증정보나 내부 오류를 추가하지 않습니다. resolveConsentContinuation에는 authorization 시작부터 유지한 transaction 전용 cookie jar의 fetch를 주입합니다. 허용 Hydra 경로를 따라 중앙 consent Location만 읽고 화면 자체는 호출하지 않습니다.completeAuthorization(redirectTo, flow)에는resolveAuthorizationCallback을 연결합니다. 같은 jar의 fetch,hydraOrigin, exactcallbackURL, 저장한 OIDCstate를 전달하면 최종 callback을 검증하여 반환합니다. 이 함수는 callback을 직접 fetch하거나 code를 교환하지 않습니다. 브라우저가 RP callback을 호출하면 RP의 기존 OIDC 처리기가 code 교환·nonce 등 token 검증과 session 생성을 완료합니다.
오류와 429 처리
API 응답 검증 순서
RP 자체 UI와 제공 JS UI 모두 다음 순서를 적용합니다. TypeScript 타입만 신뢰하지 않고 수신 JSON을 실행 시점에 검증합니다.
flowchart TD
A[API 응답] --> B{HTTP 성공?}
B -- 아니요 --> E[status와 안전한 reason/request_id 확인]
E --> F[현재 흐름 중단, 자동 재시도 금지]
F --> G{next_action}
G -- restart_login --> H[사용자 동작으로 새 authorization]
G -- contact_support 또는 미지원 --> I[문의 안내]
B -- 예 --> C{redirectTo 필드 있음?}
C -- 예 --> J{유효 URL 및 허용 origin/path?}
J -- 예 --> K[완료 처리 후 한 번 이동]
J -- 아니요 --> L[응답 계약 오류, 이동 금지]
C -- 아니요 --> D{load 응답이며 client와 scope가 유효?}
D -- 예 --> M[동의 UI 표시]
D -- 아니요 --> L
M --> N[명시적 승인 또는 거절, 중복 제출 차단]
N --> A
| 검증 대상 | 기대값과 실패 처리 |
|---|---|
| HTTP status와 JSON | 2xx여도 malformed JSON/필수 필드 누락은 성공이 아닙니다. Mutation을 다시 보내지 않습니다. |
redirectTo |
비어 있지 않은 URL, 허용 scheme/origin/path를 검증합니다. Credential 포함 URL과 임의 외부 redirect를 거부하고 query를 보존합니다. |
client.client_id |
RP가 시작한 client와 일치해야 합니다. 서버는 자동 승인 가능한 조회 전에도 결속을 검증합니다. |
requested_scope |
문자열 배열로 확인합니다. offline은 UserFront 표시 정책에 따라 제외하고 offline_access는 유지합니다. |
scope_details |
표시 설명과 필수 여부를 검증하고 텍스트로 렌더링합니다. 필수 openid를 해제하지 않습니다. |
승인 grant_scope |
조회한 scope 중 선택 목록과 필수 scope를 사용합니다. 전체 거절은 별도 reject입니다. |
code/next_action |
등록된 코드로 실패를 구분합니다. 알 수 없는 코드·행동도 자동 로그인을 유발하지 않습니다. |
request_id/Retry-After |
응답에 있을 때 문의·대기 안내에 사용합니다. 민감한 원문 응답을 화면에 노출하지 않습니다. |
| 화면 생명주기 | 최초 load 중에도 해제·취소할 수 있어야 합니다. 해제 후 응답은 렌더링·navigation을 일으키지 않습니다. |
Consent 오류는 RP 통합 가이드의 오류 처리를 따릅니다. status와 code를 표시하고, 선택적인 message는 번역하지 않는 영문 설명으로 취급합니다.
next_action과 request_id는 복구 안내와 문의에 사용합니다. pkce_restart_required만으로 PKCE 불일치를 단정하지 않습니다.
restart_login은 사용자 동작으로 새 authorization을 시작하는 뜻입니다. 기존 challenge·state·nonce·PKCE 값을 재사용하지 않습니다.
로그인 자격 판정에서 먼저 거부되면 공통 consent 접근 검사의 응답은 code로 원인을 구분합니다. 403 tenant_not_allowed는 RP의 소속 조건 거부, 403 rp_whitelist_denied는 whitelist 미등록, 503 temporarily_unavailable은 정책 확인 불가입니다. Tenant 거부를 consent 미승인으로 바꾸어 안내하지 않습니다. 기존 동의가 있어도 로그인 자격 조건은 별도로 충족해야 합니다. 관리자 작업은 whitelist 가져오기 절차를 참고합니다.
429에서는 해당 요청 루프를 중단하고 응답 host, query 없는 path, method, 시각·시간대, status, 요청 ID, 단계별 호출 횟수와 Retry-After 유무를 확인합니다. Header가 있다는 보장은 없으며, 대기 시간이 끝나도 consent mutation을 자동 재전송하지 않습니다. Timeout·409·5xx나 거절도 전체 로그인 자동 반복 조건이 아닙니다.
프로덕션 429와 consent 누락의 인과관계는 미확정입니다. 현재 코드에서 Kratos password SignIn upstream 429는 503 temporarily_unavailable로 변환되므로 응답 위치부터 구분해야 합니다. Password·token·assertion·cookie·challenge·verifier와 그 prefix, 전체 redirect URL을 로그에 남기지 않습니다. 오류 처리 정책을 따릅니다.
상세 개발 범위, RED/GREEN·실제 RP E2E 기준과 검증 증거는 #2104에서 관리합니다.