Files
baron_qa_write/docs/sso_guides/rp-headless-consent.md
T
root c87e0aa0c1
Deploy EG-BIM QA Gateway / deploy (push) Successful in 56s
fix: 맵핑 관련 오류 로그 셋팅
2026-10-30 12:38:25 +09:00

386 lines
26 KiB
Markdown

# 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](https://gitea.hmac.kr/baron/baron-sso/issues/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을 소유하지 않습니다. [인증 정책](https://gitea.hmac.kr/baron/baron-sso/wiki/Authentication-and-Login-Flow)과 [시스템 경계](../principles/system-and-data.md)를 따릅니다.
## 로그인부터 동의 완료까지
### UserFront 페이지 방식
RP에는 시작·callback 구현이 필요합니다. 비밀번호 입력, 추가 인증, 동의 화면은 중앙 UserFront가 제공합니다. Headless client assertion이나 자체 consent adapter를 RP에 구현하지 않습니다.
```mermaid
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에서만 생성합니다.
```mermaid
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를 공유하지 않습니다.
1. RP는 authorization 요청마다 `state`, `nonce`, PKCE verifier/challenge를 만들고 해당 브라우저 transaction에 연결합니다.
2. RP Backend가 `login_challenge`와 서명한 `client_assertion`으로 `POST /api/v1/auth/headless/password/login`을 호출합니다. 설정·JWT 규칙은 [README](../../README.md)의 headless 항목을 따릅니다.
3. 성공 응답의 `redirectTo`를 RP Backend가 같은 서버 cookie jar로 중계해 consent Location을 받습니다. 이는 최종 callback URL을 보장하지 않습니다. 아직 `code`가 없다는 이유로 password login을 반복하지 않습니다.
4. Consent 진입 경로가 전달한 별도 `consent_challenge`로 아래 조회·승인·거절을 처리합니다.
5. 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](../../backend/docs/openapi.yaml)와 [Headless Consent handler](../../backend/internal/handler/auth_handler_headless_consent.go)입니다.
### 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을 그대로 재사용하지 않습니다.
```json
{
"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 경로와 필수 인증 필드를 사용합니다.
### 동의 정보 또는 자동 승인 결과 조회
```http
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 등 렌더링에 필요하지 않은 필드를 브라우저에 전달하지 않습니다.
```json
{
"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을 반환합니다.
```json
{ "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에서 다른 기본 선택 정책을 도입한다면 별도 정책 검토가 필요합니다.
### 사용자 승인
```http
POST /api/v1/auth/consent/accept
Content-Type: application/json
```
```json
{
"consent_challenge": "<consent-challenge>",
"grant_scope": ["openid", "profile"]
}
```
사용자가 승인한 scope와 필수 scope를 명시적으로 제출합니다. 성공 응답의 `redirectTo`로 이동하며, 중복 클릭과 자동 재제출을 막습니다.
**현재 `grant_scope` 생략 또는 빈 배열은 전체 거절이 아닙니다.** 요청 scope가 유지될 수 있고 필수 scope는 서버에서 다시 병합됩니다. 전체 거절은 아래 API를 사용합니다.
### 사용자 거절
```http
POST /api/v1/auth/consent/reject
Content-Type: application/json
```
```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](../../orgfront/README.md)의 API·모델·DOM renderer 분리를 따릅니다. [Consent SDK](../../userfront/consent-sdk/src/index.ts)는 `createConsentClient`, `createFetchTransport`, `mountConsent`를 제공합니다. UI는 Shadow DOM으로 RP 스타일과 격리하고 UserFront의 카드·서비스 정보·scope 목록·승인·취소 확인 구조를 사용합니다.
### 빌드와 배포 파일
저장소 루트에서 기존 OrgFront 개발 의존성을 설치한 후 SDK만 빌드합니다. 애플리케이션 배포 flow는 변경하지 않습니다.
```sh
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가 사용하는 파일에 맞춰 유지합니다.
```css
@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을 브라우저에 입력받는 값이 아닙니다.
```html
<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 정적 서버를 설정합니다.
```html
<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 캡처](https://gitea.hmac.kr/baron/baron-sso/issues/2104#issuecomment-68696)를 참고하시면 됩니다. 구조·색상·폭·권한 행 간격을 기준으로 맞추며 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가 제공하는 예시 경로입니다.
```json
{
"flowId": "<opaque-rp-flow-id>",
"operation": "accept",
"grant_scope": ["openid", "profile"]
}
```
`operation`은 `load`, `accept`, `reject`이며 `grant_scope`는 accept에만 사용합니다. [서버 전용 adapter 예제](../../userfront/consent-sdk/examples/rp-adapter.mjs)를 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`, exact `callbackURL`, 저장한 OIDC `state`를 전달하면 최종 callback을 검증하여 반환합니다. 이 함수는 callback을 직접 fetch하거나 code를 교환하지 않습니다. 브라우저가 RP callback을 호출하면 RP의 기존 OIDC 처리기가 code 교환·nonce 등 token 검증과 session 생성을 완료합니다.
## 오류와 429 처리
### API 응답 검증 순서
RP 자체 UI와 제공 JS UI 모두 다음 순서를 적용합니다. TypeScript 타입만 신뢰하지 않고 수신 JSON을 실행 시점에 검증합니다.
```mermaid
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 통합 가이드의 오류 처리](rp-integration.md#consent-오류-처리)를 따릅니다. `status`와 `code`를 표시하고, 선택적인 `message`는 번역하지 않는 영문 설명으로 취급합니다.
`next_action`과 `request_id`는 복구 안내와 문의에 사용합니다. `pkce_restart_required`만으로 PKCE 불일치를 단정하지 않습니다.
`restart_login`은 사용자 동작으로 새 authorization을 시작하는 뜻입니다. 기존 challenge·state·nonce·PKCE 값을 재사용하지 않습니다.
로그인 자격 판정에서 먼저 거부되면 [공통 consent 접근 검사](../../backend/internal/handler/auth_handler_consent_access.go)의 응답은 `code`로 원인을 구분합니다. `403 tenant_not_allowed`는 RP의 소속 조건 거부, `403 rp_whitelist_denied`는 whitelist 미등록, `503 temporarily_unavailable`은 정책 확인 불가입니다. Tenant 거부를 consent 미승인으로 바꾸어 안내하지 않습니다. 기존 동의가 있어도 로그인 자격 조건은 별도로 충족해야 합니다. 관리자 작업은 [whitelist 가져오기 절차](rp-integration.md#비공개-tenant에서-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을 로그에 남기지 않습니다. [오류 처리 정책](https://gitea.hmac.kr/baron/baron-sso/wiki/Error-Handling-Policy)을 따릅니다.
상세 개발 범위, RED/GREEN·실제 RP E2E 기준과 검증 증거는 [#2104](https://gitea.hmac.kr/baron/baron-sso/issues/2104)에서 관리합니다.