refactor: 문서 개선 중
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1926-remediation-reference-pattern-selection",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"startedAt": "2026-09-19T10:26:47+00:00",
|
||||
"finishedAt": "2026-09-19T10:26:49+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:47+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:47+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md -o runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:47+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 별도 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:48+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이 기록은 Reference이고 이번 수정은 선택 기준 문장 정리다. 새 순서·구조·측정 관계가 추가되지 않았으며 Reference는 TechLog 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:48+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:48+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. hard prose gate는 PASS이며 style profile은 측정값으로만 사용했다. 수치 맞추기용 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:49+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:49+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:26:49+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice gate와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:49+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:26:49+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md -o runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S3/command-initial.json",
|
||||
"sha256": "eb41d2a40e9289239e4235f05531f0f9539074bb15629cb0f3971a198f3e36f2"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md -o runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/S6/command-final.json",
|
||||
"sha256": "eb41d2a40e9289239e4235f05531f0f9539074bb15629cb0f3971a198f3e36f2"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "8a1643617fdfc6dad59bc52b50988b468fdf391b322639d89434ce1258347a3d",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1926-remediation-reference-pattern-selection/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "b959bb94a9372317825d58baeb1d25f6402c0a9b7a3ad9d052583c90f33f3b0f"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:26:47+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:26:47+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:26:48+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:26:49+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:26:49+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "8a1643617fdfc6dad59bc52b50988b468fdf391b322639d89434ce1258347a3d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: 3f886154-1b85-407b-bda4-57d28370e745
|
||||
kind: REFERENCE
|
||||
slug: oauth-oidc-pattern-selection-criteria
|
||||
title: OAuth/OIDC 인증 패턴 선택 기준
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 23
|
||||
verifiedOn: 2026-08-30
|
||||
studio: "https://hyeonworks.com/studio/documents/3f886154-1b85-407b-bda4-57d28370e745/edit"
|
||||
public: "https://hyeonworks.com/references/oauth-oidc-pattern-selection-criteria"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-책임과-데이터
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-사다리가-아니라
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-변경-경로
|
||||
---
|
||||
|
||||
# OAuth/OIDC 인증 패턴 선택 기준
|
||||
|
||||
SPA(Single Page Application), Mediator, BFF(Backend for Frontend), Forward-Auth는 토큰과 인증 상태를 다루는 방식이 서로 다르다. 브라우저가 액세스 토큰을 직접 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 보관하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF(Cross-Site Request Forgery)를 어느 계층에서 처리하는지를 나란히 놓고 비교할 수 있다. 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 인가 코드를 토큰으로 바꾸고, 그 토큰을 들고 있다가, API까지 직접 호출한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
Mediator가 리프레시 토큰을 서버에 두는데, 브라우저는 넘겨받은 액세스 토큰으로 Resource Server를 직접 호출한다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
BFF가 인가 코드 교환과 토큰 보관, Resource Server 호출을 모두 처리하고 브라우저는 세션 쿠키만 받는다.
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
인증을 엣지로 옮기면 보호 자원이 검증하는 대상이 JWT에서 헤더로 바뀐다.
|
||||
|
||||
## 목적
|
||||
|
||||
브라우저에 OAuth 토큰이 노출되는 정도만 놓고 보면 구조마다 차이가 난다. 다만 토큰을 다른 계층으로 옮기면 브라우저에 노출되는 범위가 달라지는데, 그 토큰을 맡은 계층에서는 처리해야 할 항목이 늘어난다.
|
||||
|
||||
예를 들어 BFF는 OAuth 토큰을 서버에 보관해 브라우저에서 토큰 원문을 없앨 수 있다. 하지만 그러려면 서버가 세션과 Authorized Client를 관리해야 한다. Authorized Client는 서버가 액세스 토큰과 리프레시 토큰을 보관하는 곳이다. 그래서 세션 보호와 CSRF 방어, 공유 저장소 같은 설계가 새로 필요해진다.
|
||||
|
||||
Forward-Auth 구조에서는 애플리케이션이 OAuth 토큰을 직접 관리하는 책임을 더 줄일 수 있다. 대신 애플리케이션이 엣지에서 넘어온 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
|
||||
|
||||
선택 기준은 어느 구조가 더 안전한지에 대한 단일 순위가 아니라, 요구사항마다 달라지는 자격 증명 위치와 운영 책임이다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 다섯 항목으로 구조를 비교한다
|
||||
|
||||
브라우저가 액세스 토큰을 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 관리하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF를 어디에서 처리하는지를 확인한다.
|
||||
|
||||
비교 단위는 패턴 이름이 아니라 요청 한 번의 실제 경로다. 비교 입력에는 엔드포인트와 메서드, 중간에 생기는 자격 증명, 성공·실패 응답이 포함되고 로그인 구간과 로그인 뒤 API 호출 구간을 각각 나눠 본다.
|
||||
|
||||
SPA와 Mediator에서는 브라우저가 액세스 토큰으로 Resource Server를 직접 호출한다. SPA는 Bearer 액세스 토큰을 Authorization 헤더에 직접 넣고 인증에는 쿠키를 쓰지 않는다. Mediator는 로그인 세션과 OAuth 토큰을 서버에서도 관리하고, 로그인이 끝나면 브라우저에 액세스 토큰을 전달한다.
|
||||
|
||||
BFF에서는 브라우저가 세션 쿠키로 BFF를 호출하고, BFF가 서버에 저장한 액세스 토큰으로 Resource Server를 호출한다. 그래서 브라우저에는 OAuth 토큰을 전달하지 않지만 세션과 Authorized Client를 서버에서 관리해야 한다.
|
||||
|
||||
Forward-Auth에서는 인증 프록시가 세션을 관리하고, 인증이 끝난 요청에 사용자 정보를 붙여 애플리케이션으로 넘긴다. 애플리케이션이 이 정보를 인증 근거로 쓴다면 엣지가 붙인 헤더를 믿을 수 있도록 직접 접근 차단과 헤더 덮어쓰기, 내부 자격 증명 검증 같은 보호를 따로 둬야 한다.
|
||||
|
||||
### 2. 피해야 할 조건을 먼저 확인한다
|
||||
|
||||
정책상 OAuth 토큰을 브라우저에 둘 수 없다면, 토큰을 Local Storage 대신 JavaScript 메모리에만 보관해도 요구사항을 채우지 못한다. 저장 위치만 달라졌을 뿐 브라우저 JavaScript가 여전히 토큰을 직접 다루기 때문이다. 이때는 브라우저가 액세스 토큰을 받는 SPA와 현재의 Mediator 구조를 선택 대상에서 뺀다.
|
||||
|
||||
마찬가지로 애플리케이션으로 바로 들어오는 경로를 막을 수 없거나, 밖에서 들어온 사용자 정보 헤더를 엣지에서 확실히 지우거나 덮어쓸 수 없다면, 엣지가 전달한 사용자 정보를 인증 근거로 쓰는 구조는 고르지 않는다.
|
||||
|
||||
### 3. 선택 조건과 운영 책임을 같이 문서화한다
|
||||
|
||||
선택 기록에는 구조 이름과 함께 그 선택을 만든 보안 요구사항과 운영 조건이 들어간다.
|
||||
|
||||
적용이 어려운 조건도 선택 기준의 일부다. 브라우저에 OAuth 토큰을 둘 수 없는 환경에서는 SPA를 고르기 어렵고, 애플리케이션 직접 경로나 사용자 정보 헤더를 통제할 수 없는 환경에서는 Forward-Auth를 적용하기 어렵다.
|
||||
|
||||
### 4. 이름으로 운영 속성을 추정하지 않는다
|
||||
|
||||
운영 조건에는 서버 재시작이나 인스턴스 장애 뒤 로그인 유지 여부, 여러 레플리카의 세션·토큰 상태 공유 방식, 저장소 장애 복구 방식이 포함된다.
|
||||
|
||||
내부 자격 증명과 암호화 키 같은 비밀값의 보관·교체 방식도 같은 운영 조건에 속한다.
|
||||
|
||||
### 5. 자격 증명의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
|
||||
|
||||
패턴이 바뀌면 저장·전달·검증 책임도 다른 계층으로 이동한다.
|
||||
|
||||
예를 들어 Forward-Auth 구조에서는 엣지가 인증된 사용자 정보를 헤더로 애플리케이션에 전달할 수 있다. 처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘면서 역할이나 권한, 도메인에 묶인 사용자 정보까지 헤더에 계속 붙을 수 있다.
|
||||
|
||||
엣지가 전달할 정보가 역할·권한·도메인 정보까지 늘어나고 여러 API 응답의 조합과 인가 판단도 필요해지면, BFF가 인가와 API 호출을 소유하는 구성이 비교 대상이 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 인증 구조를 처음 고를 때
|
||||
- 한 구조에서 다른 구조로 옮기려 할 때
|
||||
- 구조를 문서로 비교할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 이 문서에서 비교하는 AP1~AP4 네 패턴만 놓고 보면, 브라우저에 OAuth token을 둘 수 없고 server-side API composition이 필요할 때 AP3 BFF가 해당 조건을 만족한다.
|
||||
- 학습이나 시연이 목적이면 운영 속성까지 비교하지 않아도 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
- SPA: 브라우저가 인가 코드 교환과 토큰 보관, API 호출을 모두 맡는다.
|
||||
- Mediator: 리프레시 토큰은 서버에 두고, 액세스 토큰은 응답 본문으로 브라우저에 돌려준다.
|
||||
- BFF: 서버가 인가 코드 교환과 토큰 관리, API 호출을 맡고 브라우저는 세션 쿠키로 BFF를 호출한다.
|
||||
- Forward-Auth: 엣지가 인증하고 애플리케이션은 엣지가 붙인 헤더를 본다.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: 3f886154-1b85-407b-bda4-57d28370e745
|
||||
kind: REFERENCE
|
||||
slug: oauth-oidc-pattern-selection-criteria
|
||||
title: OAuth/OIDC 인증 패턴 선택 기준
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 23
|
||||
verifiedOn: 2026-08-30
|
||||
studio: "https://hyeonworks.com/studio/documents/3f886154-1b85-407b-bda4-57d28370e745/edit"
|
||||
public: "https://hyeonworks.com/references/oauth-oidc-pattern-selection-criteria"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-책임과-데이터
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-사다리가-아니라
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-변경-경로
|
||||
---
|
||||
|
||||
# OAuth/OIDC 인증 패턴 선택 기준
|
||||
|
||||
SPA(Single Page Application), Mediator, BFF(Backend for Frontend), Forward-Auth는 토큰과 인증 상태를 다루는 방식이 서로 다르다. 브라우저가 액세스 토큰을 직접 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 보관하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF(Cross-Site Request Forgery)를 어느 계층에서 처리하는지를 나란히 놓고 비교할 수 있다. 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 인가 코드를 토큰으로 바꾸고, 그 토큰을 들고 있다가, API까지 직접 호출한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
Mediator가 리프레시 토큰을 서버에 두는데, 브라우저는 넘겨받은 액세스 토큰으로 Resource Server를 직접 호출한다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
BFF가 인가 코드 교환과 토큰 보관, Resource Server 호출을 모두 처리하고 브라우저는 세션 쿠키만 받는다.
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
인증을 엣지로 옮기면 보호 자원이 검증하는 대상이 JWT에서 헤더로 바뀐다.
|
||||
|
||||
## 목적
|
||||
|
||||
브라우저에 OAuth 토큰이 노출되는 정도만 놓고 보면 구조마다 차이가 난다. 다만 토큰을 다른 계층으로 옮기면 브라우저에 노출되는 범위가 달라지는데, 그 토큰을 맡은 계층에서는 처리해야 할 항목이 늘어난다.
|
||||
|
||||
예를 들어 BFF는 OAuth 토큰을 서버에 보관해 브라우저에서 토큰 원문을 없앨 수 있다. 하지만 그러려면 서버가 세션과 Authorized Client를 관리해야 한다. Authorized Client는 서버가 액세스 토큰과 리프레시 토큰을 보관하는 곳이다. 그래서 세션 보호와 CSRF 방어, 공유 저장소 같은 설계가 새로 필요해진다.
|
||||
|
||||
Forward-Auth 구조에서는 애플리케이션이 OAuth 토큰을 직접 관리하는 책임을 더 줄일 수 있다. 대신 애플리케이션이 엣지에서 넘어온 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
|
||||
|
||||
선택 기준은 어느 구조가 더 안전한지에 대한 단일 순위가 아니라, 요구사항마다 달라지는 자격 증명 위치와 운영 책임이다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 다섯 항목으로 구조를 비교한다
|
||||
|
||||
브라우저가 액세스 토큰을 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 관리하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF를 어디에서 처리하는지를 확인한다.
|
||||
|
||||
비교 단위는 패턴 이름이 아니라 요청 한 번의 실제 경로다. 비교 입력에는 엔드포인트와 메서드, 중간에 생기는 자격 증명, 성공·실패 응답이 포함되고 로그인 구간과 로그인 뒤 API 호출 구간을 각각 나눠 본다.
|
||||
|
||||
SPA와 Mediator에서는 브라우저가 액세스 토큰으로 Resource Server를 직접 호출한다. SPA는 Bearer 액세스 토큰을 Authorization 헤더에 직접 넣고 인증에는 쿠키를 쓰지 않는다. Mediator는 로그인 세션과 OAuth 토큰을 서버에서도 관리하고, 로그인이 끝나면 브라우저에 액세스 토큰을 전달한다.
|
||||
|
||||
BFF에서는 브라우저가 세션 쿠키로 BFF를 호출하고, BFF가 서버에 저장한 액세스 토큰으로 Resource Server를 호출한다. 그래서 브라우저에는 OAuth 토큰을 전달하지 않지만 세션과 Authorized Client를 서버에서 관리해야 한다.
|
||||
|
||||
Forward-Auth에서는 인증 프록시가 세션을 관리하고, 인증이 끝난 요청에 사용자 정보를 붙여 애플리케이션으로 넘긴다. 애플리케이션이 이 정보를 인증 근거로 쓴다면 엣지가 붙인 헤더를 믿을 수 있도록 직접 접근 차단과 헤더 덮어쓰기, 내부 자격 증명 검증 같은 보호를 따로 둬야 한다.
|
||||
|
||||
### 2. 피해야 할 조건을 먼저 확인한다
|
||||
|
||||
정책상 OAuth 토큰을 브라우저에 둘 수 없다면, 토큰을 Local Storage 대신 JavaScript 메모리에만 보관해도 요구사항을 채우지 못한다. 저장 위치만 달라졌을 뿐 브라우저 JavaScript가 여전히 토큰을 직접 다루기 때문이다. 이때는 브라우저가 액세스 토큰을 받는 SPA와 현재의 Mediator 구조를 선택 대상에서 뺀다.
|
||||
|
||||
마찬가지로 애플리케이션으로 바로 들어오는 경로를 막을 수 없거나, 밖에서 들어온 사용자 정보 헤더를 엣지에서 확실히 지우거나 덮어쓸 수 없다면, 엣지가 전달한 사용자 정보를 인증 근거로 쓰는 구조는 고르지 않는다.
|
||||
|
||||
### 3. 선택 조건과 운영 책임을 같이 문서화한다
|
||||
|
||||
선택 기록에는 구조 이름과 함께 그 선택을 만든 보안 요구사항과 운영 조건이 들어간다.
|
||||
|
||||
적용이 어려운 조건도 선택 기준의 일부다. 브라우저에 OAuth 토큰을 둘 수 없는 환경에서는 SPA를 고르기 어렵고, 애플리케이션 직접 경로나 사용자 정보 헤더를 통제할 수 없는 환경에서는 Forward-Auth를 적용하기 어렵다.
|
||||
|
||||
### 4. 이름으로 운영 속성을 추정하지 않는다
|
||||
|
||||
운영 조건에는 서버 재시작이나 인스턴스 장애 뒤 로그인 유지 여부, 여러 레플리카의 세션·토큰 상태 공유 방식, 저장소 장애 복구 방식이 포함된다.
|
||||
|
||||
내부 자격 증명과 암호화 키 같은 비밀값의 보관·교체 방식도 같은 운영 조건에 속한다.
|
||||
|
||||
### 5. 자격 증명의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
|
||||
|
||||
패턴이 바뀌면 저장·전달·검증 책임도 다른 계층으로 이동한다.
|
||||
|
||||
예를 들어 Forward-Auth 구조에서는 엣지가 인증된 사용자 정보를 헤더로 애플리케이션에 전달할 수 있다. 처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘면서 역할이나 권한, 도메인에 묶인 사용자 정보까지 헤더에 계속 붙을 수 있다.
|
||||
|
||||
엣지가 전달할 정보가 역할·권한·도메인 정보까지 늘어나고 여러 API 응답의 조합과 인가 판단도 필요해지면, BFF가 인가와 API 호출을 소유하는 구성이 비교 대상이 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 인증 구조를 처음 고를 때
|
||||
- 한 구조에서 다른 구조로 옮기려 할 때
|
||||
- 구조를 문서로 비교할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 이 문서에서 비교하는 AP1~AP4 네 패턴만 놓고 보면, 브라우저에 OAuth token을 둘 수 없고 server-side API composition이 필요할 때 AP3 BFF가 해당 조건을 만족한다.
|
||||
- 학습이나 시연이 목적이면 운영 속성까지 비교하지 않아도 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
- SPA: 브라우저가 인가 코드 교환과 토큰 보관, API 호출을 모두 맡는다.
|
||||
- Mediator: 리프레시 토큰은 서버에 두고, 액세스 토큰은 응답 본문으로 브라우저에 돌려준다.
|
||||
- BFF: 서버가 인가 코드 교환과 토큰 관리, API 호출을 맡고 브라우저는 세션 쿠키로 BFF를 호출한다.
|
||||
- Forward-Auth: 엣지가 인증하고 애플리케이션은 엣지가 붙인 헤더를 본다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "8a1643617fdfc6dad59bc52b50988b468fdf391b322639d89434ce1258347a3d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: 3f886154-1b85-407b-bda4-57d28370e745
|
||||
kind: REFERENCE
|
||||
slug: oauth-oidc-pattern-selection-criteria
|
||||
title: OAuth/OIDC 인증 패턴 선택 기준
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 23
|
||||
verifiedOn: 2026-08-30
|
||||
studio: "https://hyeonworks.com/studio/documents/3f886154-1b85-407b-bda4-57d28370e745/edit"
|
||||
public: "https://hyeonworks.com/references/oauth-oidc-pattern-selection-criteria"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-책임과-데이터
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-사다리가-아니라
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-변경-경로
|
||||
---
|
||||
|
||||
# OAuth/OIDC 인증 패턴 선택 기준
|
||||
|
||||
SPA(Single Page Application), Mediator, BFF(Backend for Frontend), Forward-Auth는 토큰과 인증 상태를 다루는 방식이 서로 다르다. 브라우저가 액세스 토큰을 직접 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 보관하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF(Cross-Site Request Forgery)를 어느 계층에서 처리하는지를 나란히 놓고 비교할 수 있다. 애플리케이션의 요구사항과 배포·운영 환경에 맞춰 고른다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 인가 코드를 토큰으로 바꾸고, 그 토큰을 들고 있다가, API까지 직접 호출한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
Mediator가 리프레시 토큰을 서버에 두는데, 브라우저는 넘겨받은 액세스 토큰으로 Resource Server를 직접 호출한다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
BFF가 인가 코드 교환과 토큰 보관, Resource Server 호출을 모두 처리하고 브라우저는 세션 쿠키만 받는다.
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
인증을 엣지로 옮기면 보호 자원이 검증하는 대상이 JWT에서 헤더로 바뀐다.
|
||||
|
||||
## 목적
|
||||
|
||||
브라우저에 OAuth 토큰이 노출되는 정도만 놓고 보면 구조마다 차이가 난다. 다만 토큰을 다른 계층으로 옮기면 브라우저에 노출되는 범위가 달라지는데, 그 토큰을 맡은 계층에서는 처리해야 할 항목이 늘어난다.
|
||||
|
||||
예를 들어 BFF는 OAuth 토큰을 서버에 보관해 브라우저에서 토큰 원문을 없앨 수 있다. 하지만 그러려면 서버가 세션과 Authorized Client를 관리해야 한다. Authorized Client는 서버가 액세스 토큰과 리프레시 토큰을 보관하는 곳이다. 그래서 세션 보호와 CSRF 방어, 공유 저장소 같은 설계가 새로 필요해진다.
|
||||
|
||||
Forward-Auth 구조에서는 애플리케이션이 OAuth 토큰을 직접 관리하는 책임을 더 줄일 수 있다. 대신 애플리케이션이 엣지에서 넘어온 사용자 정보 헤더를 신뢰하게 되므로, 헤더 위조 방지와 직접 접근 차단, 신뢰할 수 있는 네트워크 경계를 구성해야 한다.
|
||||
|
||||
선택 기준은 어느 구조가 더 안전한지에 대한 단일 순위가 아니라, 요구사항마다 달라지는 자격 증명 위치와 운영 책임이다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 다섯 항목으로 구조를 비교한다
|
||||
|
||||
브라우저가 액세스 토큰을 쓰는지, Resource Server를 누가 호출하는지, 서버가 어떤 인증 상태를 관리하는지, Resource Server가 어떤 자격 증명을 검증하는지, CSRF를 어디에서 처리하는지를 확인한다.
|
||||
|
||||
비교 단위는 패턴 이름이 아니라 요청 한 번의 실제 경로다. 비교 입력에는 엔드포인트와 메서드, 중간에 생기는 자격 증명, 성공·실패 응답이 포함되고 로그인 구간과 로그인 뒤 API 호출 구간을 각각 나눠 본다.
|
||||
|
||||
SPA와 Mediator에서는 브라우저가 액세스 토큰으로 Resource Server를 직접 호출한다. SPA는 Bearer 액세스 토큰을 Authorization 헤더에 직접 넣고 인증에는 쿠키를 쓰지 않는다. Mediator는 로그인 세션과 OAuth 토큰을 서버에서도 관리하고, 로그인이 끝나면 브라우저에 액세스 토큰을 전달한다.
|
||||
|
||||
BFF에서는 브라우저가 세션 쿠키로 BFF를 호출하고, BFF가 서버에 저장한 액세스 토큰으로 Resource Server를 호출한다. 그래서 브라우저에는 OAuth 토큰을 전달하지 않지만 세션과 Authorized Client를 서버에서 관리해야 한다.
|
||||
|
||||
Forward-Auth에서는 인증 프록시가 세션을 관리하고, 인증이 끝난 요청에 사용자 정보를 붙여 애플리케이션으로 넘긴다. 애플리케이션이 이 정보를 인증 근거로 쓴다면 엣지가 붙인 헤더를 믿을 수 있도록 직접 접근 차단과 헤더 덮어쓰기, 내부 자격 증명 검증 같은 보호를 따로 둬야 한다.
|
||||
|
||||
### 2. 피해야 할 조건을 먼저 확인한다
|
||||
|
||||
정책상 OAuth 토큰을 브라우저에 둘 수 없다면, 토큰을 Local Storage 대신 JavaScript 메모리에만 보관해도 요구사항을 채우지 못한다. 저장 위치만 달라졌을 뿐 브라우저 JavaScript가 여전히 토큰을 직접 다루기 때문이다. 이때는 브라우저가 액세스 토큰을 받는 SPA와 현재의 Mediator 구조를 선택 대상에서 뺀다.
|
||||
|
||||
마찬가지로 애플리케이션으로 바로 들어오는 경로를 막을 수 없거나, 밖에서 들어온 사용자 정보 헤더를 엣지에서 확실히 지우거나 덮어쓸 수 없다면, 엣지가 전달한 사용자 정보를 인증 근거로 쓰는 구조는 고르지 않는다.
|
||||
|
||||
### 3. 선택 조건과 운영 책임을 같이 문서화한다
|
||||
|
||||
선택 기록에는 구조 이름과 함께 그 선택을 만든 보안 요구사항과 운영 조건이 들어간다.
|
||||
|
||||
적용이 어려운 조건도 선택 기준의 일부다. 브라우저에 OAuth 토큰을 둘 수 없는 환경에서는 SPA를 고르기 어렵고, 애플리케이션 직접 경로나 사용자 정보 헤더를 통제할 수 없는 환경에서는 Forward-Auth를 적용하기 어렵다.
|
||||
|
||||
### 4. 이름으로 운영 속성을 추정하지 않는다
|
||||
|
||||
운영 조건에는 서버 재시작이나 인스턴스 장애 뒤 로그인 유지 여부, 여러 레플리카의 세션·토큰 상태 공유 방식, 저장소 장애 복구 방식이 포함된다.
|
||||
|
||||
내부 자격 증명과 암호화 키 같은 비밀값의 보관·교체 방식도 같은 운영 조건에 속한다.
|
||||
|
||||
### 5. 자격 증명의 위치가 바뀌면 저장·전달·검증 주체도 바뀐다
|
||||
|
||||
패턴이 바뀌면 저장·전달·검증 책임도 다른 계층으로 이동한다.
|
||||
|
||||
예를 들어 Forward-Auth 구조에서는 엣지가 인증된 사용자 정보를 헤더로 애플리케이션에 전달할 수 있다. 처음에는 사용자 이름이나 이메일처럼 인증에 필요한 정보만 전달하더라도, 애플리케이션의 요구사항이 늘면서 역할이나 권한, 도메인에 묶인 사용자 정보까지 헤더에 계속 붙을 수 있다.
|
||||
|
||||
엣지가 전달할 정보가 역할·권한·도메인 정보까지 늘어나고 여러 API 응답의 조합과 인가 판단도 필요해지면, BFF가 인가와 API 호출을 소유하는 구성이 비교 대상이 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 인증 구조를 처음 고를 때
|
||||
- 한 구조에서 다른 구조로 옮기려 할 때
|
||||
- 구조를 문서로 비교할 때
|
||||
|
||||
## 예외
|
||||
|
||||
- 이 문서에서 비교하는 AP1~AP4 네 패턴만 놓고 보면, 브라우저에 OAuth token을 둘 수 없고 server-side API composition이 필요할 때 AP3 BFF가 해당 조건을 만족한다.
|
||||
- 학습이나 시연이 목적이면 운영 속성까지 비교하지 않아도 된다.
|
||||
|
||||
## 예시
|
||||
|
||||
- SPA: 브라우저가 인가 코드 교환과 토큰 보관, API 호출을 모두 맡는다.
|
||||
- Mediator: 리프레시 토큰은 서버에 두고, 액세스 토큰은 응답 본문으로 브라우저에 돌려준다.
|
||||
- BFF: 서버가 인가 코드 교환과 토큰 관리, API 호출을 맡고 브라우저는 세션 쿠키로 BFF를 호출한다.
|
||||
- Forward-Auth: 엣지가 인증하고 애플리케이션은 엣지가 붙인 헤더를 본다.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"sourceSha256": "8a1643617fdfc6dad59bc52b50988b468fdf391b322639d89434ce1258347a3d",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-pattern-selection.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/evidence 계약에 다시 대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-01-case-ap2-split-custody",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"startedAt": "2026-09-19T10:27:51+00:00",
|
||||
"finishedAt": "2026-09-19T10:27:54+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:51+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:51+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md -o runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:52+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:52+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 이미 반영된 Record 결과를 재검증하는 작업이며 새 관계·순서·측정 주장을 추가하지 않았다. 기존 asset 배정이 있으면 그대로 재사용하고 현재 12개 SVG를 다시 그리지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:52+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:53+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:53+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:53+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:54+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:54+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md -o runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S3/command-initial.json",
|
||||
"sha256": "7ec3f38b1d9fe48adc8789b1f054fae8dd340d70e65f777d084a670a5def7bf9"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md -o runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/S6/command-final.json",
|
||||
"sha256": "7ec3f38b1d9fe48adc8789b1f054fae8dd340d70e65f777d084a670a5def7bf9"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "b0f636ace59c9f8f0278a49cbf825c9172d239e8c44abb0bb781f8251a95989c",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-01-case-ap2-split-custody/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "f4f7ea17f41d589c21cde61fedfa65689a761de22717e277a7f15faf6cf66be2"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:27:51+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:51+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:52+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:53+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:27:54+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "b0f636ace59c9f8f0278a49cbf825c9172d239e8c44abb0bb781f8251a95989c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
---
|
||||
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
|
||||
kind: CASE
|
||||
slug: split-custody-access-token
|
||||
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 22
|
||||
verifiedOn: 2026-08-24
|
||||
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
|
||||
public: "https://hyeonworks.com/cases/split-custody-access-token"
|
||||
assets:
|
||||
- key: ap2-mediator-architecture
|
||||
file: ../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg
|
||||
- key: ap2-mediator-handoff-flow
|
||||
file: ../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap2
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap2
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
|
||||
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)를 부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮겼다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
액세스 토큰은 응답 본문과 JavaScript 지역 변수와 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 한다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
|
||||
|
||||
## 문제
|
||||
|
||||
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
|
||||
|
||||
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
|
||||
|
||||
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
|
||||
|
||||
액세스 토큰이 사용되는 위치
|
||||
/token/access 응답 본문 : o
|
||||
JavaScript 지역 변수 : o
|
||||
/api/me Authorization 헤더 : o
|
||||
|
||||
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
||||
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms
|
||||
client-confidential : o
|
||||
implicit flow, direct grant : x
|
||||
client_authentication : client_secret_basic
|
||||
grant_type : authorization_code
|
||||
scopes : openid profile email
|
||||
callback : http://localhost:8082/login/oauth2/
|
||||
code/keycloak
|
||||
principal claim : preferred_username
|
||||
|
||||
OAuth2AuthorizedClientService : Spring Boot의 in-memory
|
||||
Spring Session, Redis, JDBC token store 의존성 : x
|
||||
|
||||
Resource Server CORS allowlist
|
||||
origin : http://localhost:8082
|
||||
method : GET, OPTIONS
|
||||
header : Authorization, Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출한다.
|
||||
|
||||
accessTokenStored : true
|
||||
refreshTokenStored : true
|
||||
browserReceivesRefreshToken : false
|
||||
|
||||
2. /token/access 응답에 다음 세 key만 있는지 확인한다.
|
||||
|
||||
access_token, token_type, expires_at
|
||||
|
||||
3. 같은 응답의 Cache-Control에 no-store가 있는지 확인한다.
|
||||
|
||||
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
|
||||
|
||||
5. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
|
||||
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
|
||||
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP2의 mediator는 server에서 client credential을 보호하고 client authentication을 수행할 수 있는 confidential client다. 이 프로젝트에서는 `client_secret_basic`을 사용하고 client secret을 서버에 둔다. Mediator가 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관하지만, 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
|
||||
|
||||
## 서버로 옮긴 값과 브라우저로 돌아오는 값
|
||||
|
||||
:::evidence key="ap2-mediator-architecture" alt="브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
||||
|
||||
각 값의 위치는 다음과 같다.
|
||||
|
||||
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|
||||
|---|---|---|
|
||||
| client secret | x | o |
|
||||
| refresh token | x | o |
|
||||
| access token | o | o |
|
||||
| 로그인 상태 | AP2_SESSION | HttpSession |
|
||||
|
||||
## AP2_SESSION이 생성되는 시점
|
||||
|
||||
`AP2_SESSION`은 토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
||||
|
||||
Spring Security는 로그인을 시작하면서 인가 요청과 `state`를 `HttpSession`에 저장한다. 브라우저가 KeyCloak으로 갔다가 callback으로 돌아왔을 때 앞에서 시작한 그 로그인 요청을 찾아야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
|
||||
|
||||
```text label="callback 하나가 두 개의 상태로 나뉜다"
|
||||
AP2_SESSION
|
||||
→ servlet HttpSession의 login SecurityContext
|
||||
→ Authentication(principal name = preferred_username)
|
||||
|
||||
("keycloak", principal name)
|
||||
→ OAuth2AuthorizedClientService
|
||||
→ access token + refresh token
|
||||
```
|
||||
|
||||
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰은 거기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
|
||||
|
||||
:::warning
|
||||
|
||||
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
|
||||
|
||||
:::
|
||||
|
||||
## /token/access가 반환하는 세 가지 값
|
||||
|
||||
:::evidence key="ap2-mediator-handoff-flow" alt="브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
|
||||
|
||||
```http label="브라우저 입력 — cookie 한 개"
|
||||
GET http://localhost:8082/token/access
|
||||
Accept: application/json
|
||||
Cookie: AP2_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값만 응답에 담는다.
|
||||
|
||||
```http label="응답 헤더"
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json label="응답 본문 — refresh_token은 없음"
|
||||
{
|
||||
"access_token": "<raw-keycloak-jwt>",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "<ISO-8601-instant>"
|
||||
}
|
||||
```
|
||||
|
||||
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
|
||||
|
||||
## 액세스 토큰이 브라우저에서 지나가는 세 곳
|
||||
|
||||
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
|
||||
|
||||
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
|
||||
const {
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt
|
||||
} = await tokenResponse.json();
|
||||
```
|
||||
|
||||
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
|
||||
|
||||
```http label="mediator를 지나지 않는 경로"
|
||||
GET http://localhost:8081/api/me
|
||||
Accept: application/json
|
||||
Authorization: Bearer <raw-keycloak-jwt>
|
||||
Origin: http://localhost:8082
|
||||
```
|
||||
|
||||
이 요청에 Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
|
||||
|
||||
```text
|
||||
/token/access response body
|
||||
→ JavaScript local variable
|
||||
→ /api/me Authorization header
|
||||
```
|
||||
|
||||
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
|
||||
|
||||
## /token/access는 일회성 전달이 아니다
|
||||
|
||||
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
|
||||
|
||||
| one-time handoff 요건 | 있나 |
|
||||
|---|---|
|
||||
| handoff ID | x |
|
||||
| nonce | x |
|
||||
| 사용 표시(consume flag) | x |
|
||||
| 건넨 뒤 삭제 | x |
|
||||
| 재호출 거부 | x |
|
||||
|
||||
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
|
||||
|
||||
```text
|
||||
repeatable GET
|
||||
→ current authorized client lookup/refresh opportunity
|
||||
→ current raw access token response
|
||||
```
|
||||
|
||||
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
|
||||
|
||||
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
|
||||
|
||||
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 이 프로젝트에서는 mediator가 server-side에서 client credential을 보호하고 `client_secret_basic`으로 자신을 인증하도록 confidential client로 구성했다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
|
||||
|
||||
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
||||
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
|
||||
|
||||
## 현재 자동 테스트로 확인한 범위
|
||||
|
||||
아래 항목은 커밋된 자동 테스트가 확인하도록 적어 둔 것들이다.
|
||||
|
||||
| 항목 | 확인했나? |
|
||||
|---|---|
|
||||
| server access·refresh boolean이 true | o |
|
||||
| `browserReceivesRefreshToken`이 false | o |
|
||||
| 응답이 세 개 | o |
|
||||
| `Cache-Control`에 `no-store` | o |
|
||||
| audience에 `keycloak-pattern-api` 포함 | o |
|
||||
| Resource Server 직접 호출 200 | o |
|
||||
| cookie HttpOnly · SameSite=Lax | o |
|
||||
| Web Storage에 token 문자열 없음 | o |
|
||||
| 두 번째 `/token/access` 거부 | x |
|
||||
| 만료 뒤 실제 refresh | x |
|
||||
| logout 때 두 상태 삭제 | x |
|
||||
| 재시작·replica 이동 뒤 복구 | x |
|
||||
| 허용 밖 origin의 CORS 거부 | x |
|
||||
|
||||
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
---
|
||||
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
|
||||
kind: CASE
|
||||
slug: split-custody-access-token
|
||||
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 22
|
||||
verifiedOn: 2026-08-24
|
||||
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
|
||||
public: "https://hyeonworks.com/cases/split-custody-access-token"
|
||||
assets:
|
||||
- key: ap2-mediator-architecture
|
||||
file: ../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg
|
||||
- key: ap2-mediator-handoff-flow
|
||||
file: ../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap2
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap2
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
|
||||
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)를 부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮겼다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
액세스 토큰은 응답 본문과 JavaScript 지역 변수와 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 한다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
|
||||
|
||||
## 문제
|
||||
|
||||
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
|
||||
|
||||
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
|
||||
|
||||
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
|
||||
|
||||
액세스 토큰이 사용되는 위치
|
||||
/token/access 응답 본문 : o
|
||||
JavaScript 지역 변수 : o
|
||||
/api/me Authorization 헤더 : o
|
||||
|
||||
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
||||
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms
|
||||
client-confidential : o
|
||||
implicit flow, direct grant : x
|
||||
client_authentication : client_secret_basic
|
||||
grant_type : authorization_code
|
||||
scopes : openid profile email
|
||||
callback : http://localhost:8082/login/oauth2/
|
||||
code/keycloak
|
||||
principal claim : preferred_username
|
||||
|
||||
OAuth2AuthorizedClientService : Spring Boot의 in-memory
|
||||
Spring Session, Redis, JDBC token store 의존성 : x
|
||||
|
||||
Resource Server CORS allowlist
|
||||
origin : http://localhost:8082
|
||||
method : GET, OPTIONS
|
||||
header : Authorization, Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출한다.
|
||||
|
||||
accessTokenStored : true
|
||||
refreshTokenStored : true
|
||||
browserReceivesRefreshToken : false
|
||||
|
||||
2. /token/access 응답에 다음 세 key만 있는지 확인한다.
|
||||
|
||||
access_token, token_type, expires_at
|
||||
|
||||
3. 같은 응답의 Cache-Control에 no-store가 있는지 확인한다.
|
||||
|
||||
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
|
||||
|
||||
5. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
|
||||
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
|
||||
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP2의 mediator는 server에서 client credential을 보호하고 client authentication을 수행할 수 있는 confidential client다. 이 프로젝트에서는 `client_secret_basic`을 사용하고 client secret을 서버에 둔다. Mediator가 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관하지만, 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
|
||||
|
||||
## 서버로 옮긴 값과 브라우저로 돌아오는 값
|
||||
|
||||
:::evidence key="ap2-mediator-architecture" alt="브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
||||
|
||||
각 값의 위치는 다음과 같다.
|
||||
|
||||
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|
||||
|---|---|---|
|
||||
| client secret | x | o |
|
||||
| refresh token | x | o |
|
||||
| access token | o | o |
|
||||
| 로그인 상태 | AP2_SESSION | HttpSession |
|
||||
|
||||
## AP2_SESSION이 생성되는 시점
|
||||
|
||||
`AP2_SESSION`은 토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
||||
|
||||
Spring Security는 로그인을 시작하면서 인가 요청과 `state`를 `HttpSession`에 저장한다. 브라우저가 KeyCloak으로 갔다가 callback으로 돌아왔을 때 앞에서 시작한 그 로그인 요청을 찾아야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
|
||||
|
||||
```text label="callback 하나가 두 개의 상태로 나뉜다"
|
||||
AP2_SESSION
|
||||
→ servlet HttpSession의 login SecurityContext
|
||||
→ Authentication(principal name = preferred_username)
|
||||
|
||||
("keycloak", principal name)
|
||||
→ OAuth2AuthorizedClientService
|
||||
→ access token + refresh token
|
||||
```
|
||||
|
||||
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰은 거기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
|
||||
|
||||
:::warning
|
||||
|
||||
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
|
||||
|
||||
:::
|
||||
|
||||
## /token/access가 반환하는 세 가지 값
|
||||
|
||||
:::evidence key="ap2-mediator-handoff-flow" alt="브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
|
||||
|
||||
```http label="브라우저 입력 — cookie 한 개"
|
||||
GET http://localhost:8082/token/access
|
||||
Accept: application/json
|
||||
Cookie: AP2_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값만 응답에 담는다.
|
||||
|
||||
```http label="응답 헤더"
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json label="응답 본문 — refresh_token은 없음"
|
||||
{
|
||||
"access_token": "<raw-keycloak-jwt>",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "<ISO-8601-instant>"
|
||||
}
|
||||
```
|
||||
|
||||
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
|
||||
|
||||
## 액세스 토큰이 브라우저에서 지나가는 세 곳
|
||||
|
||||
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
|
||||
|
||||
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
|
||||
const {
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt
|
||||
} = await tokenResponse.json();
|
||||
```
|
||||
|
||||
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
|
||||
|
||||
```http label="mediator를 지나지 않는 경로"
|
||||
GET http://localhost:8081/api/me
|
||||
Accept: application/json
|
||||
Authorization: Bearer <raw-keycloak-jwt>
|
||||
Origin: http://localhost:8082
|
||||
```
|
||||
|
||||
이 요청에 Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
|
||||
|
||||
```text
|
||||
/token/access response body
|
||||
→ JavaScript local variable
|
||||
→ /api/me Authorization header
|
||||
```
|
||||
|
||||
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
|
||||
|
||||
## /token/access는 일회성 전달이 아니다
|
||||
|
||||
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
|
||||
|
||||
| one-time handoff 요건 | 있나 |
|
||||
|---|---|
|
||||
| handoff ID | x |
|
||||
| nonce | x |
|
||||
| 사용 표시(consume flag) | x |
|
||||
| 건넨 뒤 삭제 | x |
|
||||
| 재호출 거부 | x |
|
||||
|
||||
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
|
||||
|
||||
```text
|
||||
repeatable GET
|
||||
→ current authorized client lookup/refresh opportunity
|
||||
→ current raw access token response
|
||||
```
|
||||
|
||||
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
|
||||
|
||||
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
|
||||
|
||||
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 이 프로젝트에서는 mediator가 server-side에서 client credential을 보호하고 `client_secret_basic`으로 자신을 인증하도록 confidential client로 구성했다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
|
||||
|
||||
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
||||
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
|
||||
|
||||
## 현재 자동 테스트로 확인한 범위
|
||||
|
||||
아래 항목은 커밋된 자동 테스트가 확인하도록 적어 둔 것들이다.
|
||||
|
||||
| 항목 | 확인했나? |
|
||||
|---|---|
|
||||
| server access·refresh boolean이 true | o |
|
||||
| `browserReceivesRefreshToken`이 false | o |
|
||||
| 응답이 세 개 | o |
|
||||
| `Cache-Control`에 `no-store` | o |
|
||||
| audience에 `keycloak-pattern-api` 포함 | o |
|
||||
| Resource Server 직접 호출 200 | o |
|
||||
| cookie HttpOnly · SameSite=Lax | o |
|
||||
| Web Storage에 token 문자열 없음 | o |
|
||||
| 두 번째 `/token/access` 거부 | x |
|
||||
| 만료 뒤 실제 refresh | x |
|
||||
| logout 때 두 상태 삭제 | x |
|
||||
| 재시작·replica 이동 뒤 복구 | x |
|
||||
| 허용 밖 origin의 CORS 거부 | x |
|
||||
|
||||
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "b0f636ace59c9f8f0278a49cbf825c9172d239e8c44abb0bb781f8251a95989c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
---
|
||||
id: 488ce49b-afa4-42a5-a2ce-de2e0653cd82
|
||||
kind: CASE
|
||||
slug: split-custody-access-token
|
||||
title: Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 22
|
||||
verifiedOn: 2026-08-24
|
||||
studio: "https://hyeonworks.com/studio/documents/488ce49b-afa4-42a5-a2ce-de2e0653cd82/edit"
|
||||
public: "https://hyeonworks.com/cases/split-custody-access-token"
|
||||
assets:
|
||||
- key: ap2-mediator-architecture
|
||||
file: ../../../final/assets/ap2-mediator-architecture/ap2-mediator-architecture.svg
|
||||
- key: ap2-mediator-handoff-flow
|
||||
file: ../../../final/assets/ap2-mediator-handoff-flow/ap2-mediator-handoff-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap2
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap2
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap2-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap2
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출
|
||||
|
||||
confidential client인 mediator가 authorization code를 교환하고, 받은 두 토큰을 서버 쪽 authorized client에 넣는다. 그런데 보호 자원 서버(Resource Server)를 부르는 쪽은 브라우저라서 액세스 토큰이 브라우저에도 있어야 하고, 그 값은 JSON 응답으로 다시 내려온다. 그래서 이 구조가 브라우저 밖으로 옮긴 것은 client secret과 리프레시 토큰까지이고, 액세스 토큰을 다루는 일은 브라우저가 계속 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
SPA에서는 브라우저가 코드 교환과 토큰 보관을 모두 맡는데, 여기서는 그중 리프레시 토큰을 서버로 옮겼다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
mediator는 confidential client인데도 액세스 토큰을 브라우저 응답으로 돌려준다. 클라이언트를 어느 종류로 등록했는지가 토큰이 브라우저까지 가는지를 정하지는 않는다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
액세스 토큰은 응답 본문과 JavaScript 지역 변수와 Authorization 헤더를 지나가고, 애플리케이션 쪽 로그인 상태는 그와 별도로 관리된다.
|
||||
- **OAuth/OIDC 인증 패턴 선택 기준**
|
||||
리프레시 토큰은 서버에 두면서 액세스 토큰은 브라우저로 보내는 구성이라, 이 패턴을 고르면 mediator의 서버 상태까지 함께 운영해야 한다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
여기서는 리프레시 토큰 rotation과 재사용 0회로 설정했다. 여러 replica에서 갱신이 겹치는 경우는 이 구성으로 확인하지 못해 그 질문에서 따로 다룬다.
|
||||
|
||||
## 문제
|
||||
|
||||
Spring mediator가 confidential client가 되어 code를 교환한다. 받은 액세스 토큰과 리프레시 토큰은 server-side authorized-client service에 저장하고, 브라우저가 로그인 상태로 들고 있는 것은 HttpOnly가 붙은 AP2_SESSION뿐이다.
|
||||
|
||||
서버가 토큰을 보관한다는 점만 보면 BFF와 비슷한데, 이 구조에서는 보호 자원 서버를 브라우저가 직접 부른다. 그 요청에 액세스 토큰이 필요하기 때문에 mediator가 액세스 토큰을 다시 응답으로 돌려준다.
|
||||
|
||||
/token/access 응답부터 보호 자원 서버 요청까지 따라가면 서버가 가져간 것은 리프레시 토큰까지였고 액세스 토큰을 다루는 일은 브라우저가 계속 하고 있었다.
|
||||
|
||||
## 결론
|
||||
|
||||
서버로 옮긴 것은 client secret과 리프레시 토큰이다. 액세스 토큰은 브라우저에서 다음 세 곳을 지나간다.
|
||||
|
||||
액세스 토큰이 사용되는 위치
|
||||
/token/access 응답 본문 : o
|
||||
JavaScript 지역 변수 : o
|
||||
/api/me Authorization 헤더 : o
|
||||
|
||||
서버 상태 : mediator의 session과 authorized-client 저장소를 운영해야 한다.
|
||||
브라우저 노출 : 액세스 토큰이 브라우저 실행 영역 안에서 쓰이는 것은 막지 못했다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms
|
||||
client-confidential : o
|
||||
implicit flow, direct grant : x
|
||||
client_authentication : client_secret_basic
|
||||
grant_type : authorization_code
|
||||
scopes : openid profile email
|
||||
callback : http://localhost:8082/login/oauth2/
|
||||
code/keycloak
|
||||
principal claim : preferred_username
|
||||
|
||||
OAuth2AuthorizedClientService : Spring Boot의 in-memory
|
||||
Spring Session, Redis, JDBC token store 의존성 : x
|
||||
|
||||
Resource Server CORS allowlist
|
||||
origin : http://localhost:8082
|
||||
method : GET, OPTIONS
|
||||
header : Authorization, Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. Mediator UI에서 로그인한 뒤 /token/boundary를 호출한다.
|
||||
|
||||
accessTokenStored : true
|
||||
refreshTokenStored : true
|
||||
browserReceivesRefreshToken : false
|
||||
|
||||
2. /token/access 응답에 다음 세 key만 있는지 확인한다.
|
||||
|
||||
access_token, token_type, expires_at
|
||||
|
||||
3. 같은 응답의 Cache-Control에 no-store가 있는지 확인한다.
|
||||
|
||||
4. 반환된 access JWT를 decode해 audience에 keycloak-pattern-api가 있는지 확인한다.
|
||||
|
||||
5. 브라우저가 그 토큰으로 보호 자원 서버를 직접 호출했을 때 200을 받는지 확인한다.
|
||||
|
||||
6. 쿠키가 AP2_SESSION이며 HttpOnly와 SameSite=Lax인지 확인한다.
|
||||
|
||||
7. Local Storage와 Session Storage에 액세스 토큰 원문이나 refresh_token 문자열이 없는지 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP2의 mediator는 server에서 client credential을 보호하고 client authentication을 수행할 수 있는 confidential client다. 이 프로젝트에서는 `client_secret_basic`을 사용하고 client secret을 서버에 둔다. Mediator가 브라우저 대신 authorization code를 토큰으로 바꿔 서버에 보관하지만, 보호 자원 서버(Resource Server)는 브라우저가 직접 부른다. 리프레시 토큰만 브라우저에서 걷어내면 브라우저가 무엇을 계속 다루게 되는지 확인했다.
|
||||
|
||||
## 서버로 옮긴 값과 브라우저로 돌아오는 값
|
||||
|
||||
:::evidence key="ap2-mediator-architecture" alt="브라우저가 Spring mediator에서 access token만 받아 Resource Server를 직접 호출하고 refresh token은 authorized-client store에 남기는 AP2 split-custody 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
SPA 구조에서는 브라우저가 코드를 직접 교환하고 받은 토큰도 브라우저에서 관리했다. mediator를 두면 그 코드를 교환하는 쪽이 Spring mediator로 바뀌고, 액세스 토큰과 리프레시 토큰은 둘 다 서버 쪽 authorized client에 저장된다. 그런데 보호 자원 서버를 부르는 쪽은 여전히 브라우저라서, 액세스 토큰은 `/token/access`를 통해 다시 브라우저로 건너온다.
|
||||
|
||||
각 값의 위치는 다음과 같다.
|
||||
|
||||
| 무엇 | 브라우저에 있나 | 서버에 있나 |
|
||||
|---|---|---|
|
||||
| client secret | x | o |
|
||||
| refresh token | x | o |
|
||||
| access token | o | o |
|
||||
| 로그인 상태 | AP2_SESSION | HttpSession |
|
||||
|
||||
## AP2_SESSION이 생성되는 시점
|
||||
|
||||
`AP2_SESSION`은 토큰 교환을 마친 뒤가 아니라 로그인을 시작할 때 발급된다.
|
||||
|
||||
Spring Security는 로그인을 시작하면서 인가 요청과 `state`를 `HttpSession`에 저장한다. 브라우저가 KeyCloak으로 갔다가 callback으로 돌아왔을 때 앞에서 시작한 그 로그인 요청을 찾아야 하기 때문에, 세션 쿠키가 이 시점에 먼저 만들어진다.
|
||||
|
||||
```text label="callback 하나가 두 개의 상태로 나뉜다"
|
||||
AP2_SESSION
|
||||
→ servlet HttpSession의 login SecurityContext
|
||||
→ Authentication(principal name = preferred_username)
|
||||
|
||||
("keycloak", principal name)
|
||||
→ OAuth2AuthorizedClientService
|
||||
→ access token + refresh token
|
||||
```
|
||||
|
||||
`AP2_SESSION` 안에 토큰이 들어 있는 것은 아니다. 이 쿠키는 `HttpSession`을 찾는 세션 ID이고, 그 `HttpSession`에 로그인 `SecurityContext`가 저장되어 있다. 토큰은 거기서 확인한 principal로 별도 저장소의 authorized client를 조회해서 찾는다.
|
||||
|
||||
:::warning
|
||||
|
||||
`OAuth2AuthorizedClientService`는 Spring Boot 자동구성이 고르는 in-memory 구현을 사용한다. Spring Session·Redis·JDBC token store 의존성도 없기 때문에 로그인 상태와 토큰 상태가 둘 다 지금 프로세스의 메모리에 있다. 세션과 authorized client를 여러 인스턴스가 공유하는지는 이 구성으로 확인하지 못했다.
|
||||
|
||||
:::
|
||||
|
||||
## /token/access가 반환하는 세 가지 값
|
||||
|
||||
:::evidence key="ap2-mediator-handoff-flow" alt="브라우저, Spring mediator, authorized-client store, Resource Server 사이에서 AP2_SESSION 요청, access-only 응답, 브라우저 Bearer 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 부르려면 액세스 토큰이 있어야 하고, 그 값을 주는 것이 `/token/access`다.
|
||||
|
||||
```http label="브라우저 입력 — cookie 한 개"
|
||||
GET http://localhost:8082/token/access
|
||||
Accept: application/json
|
||||
Cookie: AP2_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
컨트롤러는 `OAuth2AuthorizeRequest.withClientRegistrationId("keycloak")`을 만들고 현재 `Authentication`을 principal로 넣는다. 이 요청으로 `OAuth2AuthorizedClientManager.authorize()`를 부른 뒤, 돌아온 authorized client에서 액세스 토큰을 꺼내 다음 세 값만 응답에 담는다.
|
||||
|
||||
```http label="응답 헤더"
|
||||
HTTP/1.1 200 OK
|
||||
Cache-Control: no-store
|
||||
Pragma: no-cache
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json label="응답 본문 — refresh_token은 없음"
|
||||
{
|
||||
"access_token": "<raw-keycloak-jwt>",
|
||||
"token_type": "Bearer",
|
||||
"expires_at": "<ISO-8601-instant>"
|
||||
}
|
||||
```
|
||||
|
||||
응답 본문에 실리는 토큰은 액세스 토큰 하나이고 리프레시 토큰은 넣지 않는다. authorized client나 액세스 토큰이 없으면 401을 돌려준다.
|
||||
|
||||
## 액세스 토큰이 브라우저에서 지나가는 세 곳
|
||||
|
||||
브라우저 JavaScript는 `/token/access` 응답에서 액세스 토큰을 읽어 지역 변수에 넣는다.
|
||||
|
||||
```javascript label="Web Storage에도 cookie에도 쓰지 않는다"
|
||||
const {
|
||||
access_token: accessToken,
|
||||
expires_at: expiresAt
|
||||
} = await tokenResponse.json();
|
||||
```
|
||||
|
||||
이 값은 바로 다음 보호 자원 서버 요청의 `Authorization` 헤더에 들어간다.
|
||||
|
||||
```http label="mediator를 지나지 않는 경로"
|
||||
GET http://localhost:8081/api/me
|
||||
Accept: application/json
|
||||
Authorization: Bearer <raw-keycloak-jwt>
|
||||
Origin: http://localhost:8082
|
||||
```
|
||||
|
||||
이 요청에 Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
액세스 토큰이 지나가는 곳은 다음 세 곳이다.
|
||||
|
||||
```text
|
||||
/token/access response body
|
||||
→ JavaScript local variable
|
||||
→ /api/me Authorization header
|
||||
```
|
||||
|
||||
memory-only는 Local Storage나 Session Storage 같은 영구 저장소에 토큰을 쓰지 않는다는 뜻이고, 실행 중인 스크립트가 응답이나 지역 변수의 토큰에 닿지 못한다는 뜻은 아니다.
|
||||
|
||||
## /token/access는 일회성 전달이 아니다
|
||||
|
||||
`/token/access`의 동작을 one-time handoff라고 부를 수 있을지 살펴봤다. 한 번 건넨 뒤에는 같은 토큰을 다시 받을 수 없어야 그렇게 부를 수 있다.
|
||||
|
||||
| one-time handoff 요건 | 있나 |
|
||||
|---|---|
|
||||
| handoff ID | x |
|
||||
| nonce | x |
|
||||
| 사용 표시(consume flag) | x |
|
||||
| 건넨 뒤 삭제 | x |
|
||||
| 재호출 거부 | x |
|
||||
|
||||
지금 구현에는 한 번 건넨 토큰을 사용 처리하거나 다음 호출을 거부하는 코드가 없어서, 같은 인증된 세션이라면 현재 액세스 토큰을 다시 요청할 수 있다. 그래서 이 구현이 보장하는 것은 리프레시 토큰을 응답에서 빼는 것까지이고, 액세스 토큰을 한 번만 건네는 기능은 없다. 리프레시 토큰을 응답에서 뺀 것만 확인했고, 액세스 토큰을 한 번만 주는 기능은 없다.
|
||||
|
||||
```text
|
||||
repeatable GET
|
||||
→ current authorized client lookup/refresh opportunity
|
||||
→ current raw access token response
|
||||
```
|
||||
|
||||
정말 한 번만 건네야 한다면 이 raw access token endpoint를 재사용할 수 없다. 짧게 사는 일회용 code를 만들고 audience가 제한된 exchange endpoint에서 한 번만 소비하는 별도 protocol이 필요하다.
|
||||
|
||||
## 이 구조를 고를 때 함께 오는 서버 상태와 액세스 토큰 노출
|
||||
|
||||
mediator를 넣은 이유는 하나다. 리프레시 토큰은 브라우저 JavaScript 메모리에서 서버로 옮기고, 브라우저가 보호 자원 서버를 직접 부르는 방식은 바꾸지 않으려고 했다. 이 프로젝트에서는 mediator가 server-side에서 client credential을 보호하고 `client_secret_basic`으로 자신을 인증하도록 confidential client로 구성했다. 구현을 마치고 보니 이 선택은 두 비용을 함께 남겼다.
|
||||
|
||||
- 서버 상태 : mediator의 HttpSession과 authorized-client 저장소를 운영해야 한다
|
||||
- 브라우저 노출 : 액세스 토큰이 응답 본문과 `Authorization` 헤더를 지나가는 것은 막지 못했다
|
||||
|
||||
브라우저가 보호 자원 서버를 직접 불러야 한다는 요구가 분명하면 이 구조를 고를 수 있다. 이 구조는 두 구조의 비용을 함께 갖는다 — mediator 상태를 확장하고 복구해야 하는데 액세스 토큰은 여전히 XSS에 노출된다. 브라우저에서 액세스 토큰까지 없애야 한다면 이 구조로는 답이 되지 않고, 서버 상태 자체를 둘 수 없다면 SPA 구성이 더 단순하다.
|
||||
|
||||
## 현재 자동 테스트로 확인한 범위
|
||||
|
||||
아래 항목은 커밋된 자동 테스트가 확인하도록 적어 둔 것들이다.
|
||||
|
||||
| 항목 | 확인했나? |
|
||||
|---|---|
|
||||
| server access·refresh boolean이 true | o |
|
||||
| `browserReceivesRefreshToken`이 false | o |
|
||||
| 응답이 세 개 | o |
|
||||
| `Cache-Control`에 `no-store` | o |
|
||||
| audience에 `keycloak-pattern-api` 포함 | o |
|
||||
| Resource Server 직접 호출 200 | o |
|
||||
| cookie HttpOnly · SameSite=Lax | o |
|
||||
| Web Storage에 token 문자열 없음 | o |
|
||||
| 두 번째 `/token/access` 거부 | x |
|
||||
| 만료 뒤 실제 refresh | x |
|
||||
| logout 때 두 상태 삭제 | x |
|
||||
| 재시작·replica 이동 뒤 복구 | x |
|
||||
| 허용 밖 origin의 CORS 거부 | x |
|
||||
|
||||
`OAuth2AuthorizedClientManager`에는 authorization-code provider와 refresh-token provider가 함께 구성돼 있다. 다만 실제로 만료를 기다린 뒤 갱신이 성공하는지, rotation된 토큰이 저장되는지는 아직 확인하지 않았다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"sourceSha256": "b0f636ace59c9f8f0278a49cbf825c9172d239e8c44abb0bb781f8251a95989c",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-ap2-split-custody.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-02-case-browser-credential-boundary",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"startedAt": "2026-09-19T10:27:54+00:00",
|
||||
"finishedAt": "2026-09-19T10:27:56+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:54+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md -o runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:54+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:55+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 이미 반영된 Record 결과를 재검증하는 작업이며 새 관계·순서·측정 주장을 추가하지 않았다. 기존 asset 배정이 있으면 그대로 재사용하고 현재 12개 SVG를 다시 그리지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:55+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:55+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:56+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:56+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:56+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md -o runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S3/command-initial.json",
|
||||
"sha256": "8bc642d1d6cb8400375dce8aaabf2dfd9e8146bc760f1e490c15ac5b0b14870d"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md -o runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/S6/command-final.json",
|
||||
"sha256": "8bc642d1d6cb8400375dce8aaabf2dfd9e8146bc760f1e490c15ac5b0b14870d"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "f52977123664fe13262ee346920bcf72bfebccd5aaea4e25dd2f905a5a39781d",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-02-case-browser-credential-boundary/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "fce23cd43b464972e31eff418f5e1b8270e76bdac88cb17587352b07d6a94533"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:27:54+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:54+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:55+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:55+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:27:56+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "f52977123664fe13262ee346920bcf72bfebccd5aaea4e25dd2f905a5a39781d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
---
|
||||
id: bf675775-4f3e-4744-8014-f0efff51422a
|
||||
kind: CASE
|
||||
slug: spa-browser-credential-boundary
|
||||
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 31
|
||||
verifiedOn: 2026-08-22
|
||||
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
|
||||
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
|
||||
assets:
|
||||
- key: ap1-direct-architecture
|
||||
file: ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
|
||||
- key: ap1-browser-bearer-flow
|
||||
file: ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap1
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1
|
||||
---
|
||||
|
||||
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
|
||||
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 토큰을 교환하는 과정을 직접 확인한 내용이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려워 public client로 구성했고 PKCE S256을 사용했다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
JavaScript 메모리에 있는 토큰과 Keycloak의 SSO 쿠키는 서로 다른 상태여서, 새로고침으로 SPA의 토큰이 없어져도 Keycloak의 SSO까지 끝나는 것은 아니다.
|
||||
|
||||
## 문제
|
||||
|
||||
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)까지 막을 수 있는지였다.
|
||||
|
||||
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 들고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE가 실제로 어느 구간에 적용되는지도 같이 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
memory-only로 보관하면 새로고침 뒤에는 액세스·리프레시·ID 토큰이 모두 사라진다.
|
||||
|
||||
다만 페이지가 실행 중일 때는 JavaScript가 토큰을 다루므로, 악성 스크립트가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. 액세스 토큰은 JavaScript 메모리에만 있는 값도 아니어서 Resource Server 요청의 Authorization 헤더에도 실린다.
|
||||
|
||||
Resource Server는 SessionCreationPolicy.STATELESS로 동작해 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 넣지 않았다.
|
||||
|
||||
그 대신 현재 구성은 액세스 토큰 수명을 300초로 두고 리프레시 토큰 rotation을 쓰며, Resource Server에서 issuer와 audience도 확인한다.
|
||||
|
||||
PKCE는 authorization code를 토큰으로 교환하는 구간에 쓰는 것이지, 이미 발급된 액세스 토큰을 브라우저에서 숨겨 주는 기능은 아니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms 설정
|
||||
public-client, standard flow : o
|
||||
implicit flow, direct grant : x
|
||||
authority : http://localhost:8080/realms/keycloak-patterns
|
||||
redirect_uri : http://localhost:8088/callback.html
|
||||
scope : openid profile email
|
||||
userStore : InMemoryWebStorage
|
||||
stateStore : sessionStorage
|
||||
automaticSilentRenew : true
|
||||
|
||||
Resource Server
|
||||
SessionCreationPolicy.STATELESS
|
||||
CSRF x
|
||||
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
|
||||
|
||||
2. 토큰 응답의 액세스·리프레시·ID 토큰이 비어 있지 않은지 확인한다.
|
||||
|
||||
3. 브라우저 fetch를 가로채 /api/me 요청의 Authorization 헤더에서 Bearer 액세스 토큰을 확인한다.
|
||||
|
||||
4. Local Storage와 Session Storage에 액세스 토큰 문자열이 남지 않는지 확인한다.
|
||||
|
||||
5. 같은 정상 JWT를 expected issuer와 audience가 다른 진단용 서버 두 곳에 보내고 401이 반환되는지 확인한다.
|
||||
|
||||
6. 리프레시 토큰으로 새 토큰을 받은 뒤 이전 리프레시 토큰이 거부되는지 확인한다. revocation 뒤에는 갱신이 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP1의 SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려운 public client다. 이 프로젝트는 SPA에 shared client secret을 두지 않았고, authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 한다. 받은 액세스·리프레시·ID 토큰은 `InMemoryWebStorage`를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.
|
||||
|
||||
## SPA가 토큰을 다루는 위치
|
||||
|
||||
:::evidence key="ap1-direct-architecture" alt="SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
|
||||
|
||||
그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.
|
||||
|
||||
## 새로고침 전후에 브라우저에 남는 값
|
||||
|
||||
`oidc-client-ts`의 `InMemoryWebStorage`는 로그인 결과를 Local Storage나 Session Storage가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 메모리에 있던 로그인 정보와 토큰이 사라진다.
|
||||
|
||||
리프레시 토큰만 서버로 옮기거나 모든 토큰을 서버가 맡는 쪽도 검토했다. 다만 그렇게 하면 브라우저가 code를 교환하고 토큰의 수명을 관리하는 모습이 가려지기 때문에, 그 과정을 그대로 보려고 액세스·리프레시·ID 토큰을 JavaScript 메모리에 두었다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못한다.
|
||||
|
||||
| 위치 | 새로고침 전 | 새로고침 뒤 |
|
||||
|---|---|---|
|
||||
| JavaScript 메모리 | `User`, 액세스·리프레시·ID 토큰, 만료 시각, 프로필 | 사라짐 |
|
||||
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
|
||||
| Local Storage | 해당 없음 | 해당 없음 |
|
||||
| Keycloak origin 쿠키 | IdP의 SSO 상태가 존재할 수 있음 | 애플리케이션 메모리와 별개 |
|
||||
|
||||
JavaScript 메모리의 `User`가 사라지는 것과 Keycloak의 SSO 세션이 끝나는 것은 다른 사건이다. SPA가 들고 있던 토큰이 없어져도 Keycloak SSO 쿠키가 그대로면 다음 authorization request에서 기존 로그인 상태가 다시 쓰일 수 있다.
|
||||
|
||||
## memory-only가 줄이는 것과 줄이지 못하는 것
|
||||
|
||||
액세스·리프레시·ID 토큰은 실행 중 메모리에 있고, 악성 스크립트는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS가 할 수 있는 일이 아니다.
|
||||
|
||||
| 위협 | memory-only가 막아주나 |
|
||||
|---|---|
|
||||
| 새로고침 뒤에도 남는 토큰 복사본 | 막아준다 |
|
||||
| 실행 중 스크립트가 fetch를 가로채는 것 | 막아주지 않는다 |
|
||||
| 실행 중 스크립트가 사용자 대신 API를 부르는 것 | 막아주지 않는다 |
|
||||
| 요청 헤더에 실리는 액세스 토큰 | 막아주지 않는다 |
|
||||
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
||||
|
||||
SPA가 Resource Server를 부를 때는 액세스 토큰을 `Authorization` 헤더에 넣는다.
|
||||
|
||||
```http label="브라우저가 Resource Server를 직접 부를 때"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
그래서 액세스 토큰은 JavaScript 메모리 안에만 머무르지 않고, API를 부르는 동안 요청 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
|
||||
|
||||
즉시 없앨 수단이 없으니 짧은 수명과 검증이 가드레일이 된다.
|
||||
|
||||
액세스 토큰 : 300초
|
||||
리프레시 토큰 rotation, 재사용 허용 : x
|
||||
issuer·audience : 검증
|
||||
|
||||
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기려면 저장 위치만 바꿔서는 안 되고, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
|
||||
|
||||
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
|
||||
|
||||
## PKCE가 적용되는 구간
|
||||
|
||||
:::evidence key="ap1-browser-bearer-flow" alt="브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
|
||||
|
||||
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
|
||||
response_type=code
|
||||
client_id=spa-public
|
||||
redirect_uri=http://localhost:8088/callback.html
|
||||
scope=openid profile email
|
||||
state=<opaque-state>
|
||||
code_challenge=<opaque-challenge>
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
이 구성에서는 `response_type=code`를 쓰고, authorization request에 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 들어가는 것을 확인했다.
|
||||
|
||||
PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳까지다. 토큰이 발급된 뒤에 브라우저가 액세스 토큰을 쓰지 못하게 막아 주는 기능은 아니다.
|
||||
|
||||
## 테스트에서 확인한 범위
|
||||
|
||||
여기서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 테스트가 확인하도록 정의한 항목이다.
|
||||
|
||||
| 테스트에 있나 | 항목 |
|
||||
|---|---|
|
||||
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
|
||||
| o | 토큰 응답에 비어 있지 않은 액세스·리프레시·ID 토큰 |
|
||||
| o | `/api/me` 200과 decode한 액세스 토큰의 audience 포함 |
|
||||
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer 토큰 관측 |
|
||||
| o | Local Storage와 Session Storage에 액세스 토큰 문자열 없음 |
|
||||
| o | 리프레시 토큰 rotation — 새 토큰 발급, 이전 토큰 거부, revocation 뒤 갱신 실패 |
|
||||
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
|
||||
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
|
||||
| x | 서명이 깨진 JWT, 만료된 JWT |
|
||||
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
|
||||
| x | callback에 error가 실려 돌아왔을 때의 화면 |
|
||||
| x | `automaticSilentRenew`의 실제 갱신 경로 |
|
||||
|
||||
표에서 fetch를 가로채 Bearer 토큰을 본 항목은 노출의 한계를 일부러 재현한 것이다. Local Storage와 Session Storage에 토큰 문자열이 없다는 항목과 함께 봐야, 브라우저 저장소에는 없지만 실행 중 JavaScript 경계에는 있다는 것이 확인된다.
|
||||
|
||||
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
|
||||
|
||||
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
|
||||
|
||||
:::warning
|
||||
|
||||
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지보다 JSON parse error가 먼저 보일 수 있다.
|
||||
|
||||
:::
|
||||
|
||||
## Redirect URI와 CORS에서 아직 확인하지 않은 것
|
||||
|
||||
local realm의 redirect allowlist는 wildcard로 설정되어 있다.
|
||||
|
||||
```text
|
||||
http://localhost:8088/*
|
||||
http://127.0.0.1:8088/*
|
||||
```
|
||||
|
||||
SPA가 실제로 쓰는 callback은 `/callback.html` 하나인데 등록된 목록은 그보다 넓다.
|
||||
|
||||
SPA : `/callback.html`만 o
|
||||
exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부하는 검사 : x
|
||||
|
||||
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 절대 URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거친다. 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
---
|
||||
id: bf675775-4f3e-4744-8014-f0efff51422a
|
||||
kind: CASE
|
||||
slug: spa-browser-credential-boundary
|
||||
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 31
|
||||
verifiedOn: 2026-08-22
|
||||
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
|
||||
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
|
||||
assets:
|
||||
- key: ap1-direct-architecture
|
||||
file: ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
|
||||
- key: ap1-browser-bearer-flow
|
||||
file: ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap1
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1
|
||||
---
|
||||
|
||||
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
|
||||
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 토큰을 교환하는 과정을 직접 확인한 내용이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려워 public client로 구성했고 PKCE S256을 사용했다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
JavaScript 메모리에 있는 토큰과 Keycloak의 SSO 쿠키는 서로 다른 상태여서, 새로고침으로 SPA의 토큰이 없어져도 Keycloak의 SSO까지 끝나는 것은 아니다.
|
||||
|
||||
## 문제
|
||||
|
||||
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)까지 막을 수 있는지였다.
|
||||
|
||||
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 들고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE가 실제로 어느 구간에 적용되는지도 같이 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
memory-only로 보관하면 새로고침 뒤에는 액세스·리프레시·ID 토큰이 모두 사라진다.
|
||||
|
||||
다만 페이지가 실행 중일 때는 JavaScript가 토큰을 다루므로, 악성 스크립트가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. 액세스 토큰은 JavaScript 메모리에만 있는 값도 아니어서 Resource Server 요청의 Authorization 헤더에도 실린다.
|
||||
|
||||
Resource Server는 SessionCreationPolicy.STATELESS로 동작해 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 넣지 않았다.
|
||||
|
||||
그 대신 현재 구성은 액세스 토큰 수명을 300초로 두고 리프레시 토큰 rotation을 쓰며, Resource Server에서 issuer와 audience도 확인한다.
|
||||
|
||||
PKCE는 authorization code를 토큰으로 교환하는 구간에 쓰는 것이지, 이미 발급된 액세스 토큰을 브라우저에서 숨겨 주는 기능은 아니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms 설정
|
||||
public-client, standard flow : o
|
||||
implicit flow, direct grant : x
|
||||
authority : http://localhost:8080/realms/keycloak-patterns
|
||||
redirect_uri : http://localhost:8088/callback.html
|
||||
scope : openid profile email
|
||||
userStore : InMemoryWebStorage
|
||||
stateStore : sessionStorage
|
||||
automaticSilentRenew : true
|
||||
|
||||
Resource Server
|
||||
SessionCreationPolicy.STATELESS
|
||||
CSRF x
|
||||
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
|
||||
|
||||
2. 토큰 응답의 액세스·리프레시·ID 토큰이 비어 있지 않은지 확인한다.
|
||||
|
||||
3. 브라우저 fetch를 가로채 /api/me 요청의 Authorization 헤더에서 Bearer 액세스 토큰을 확인한다.
|
||||
|
||||
4. Local Storage와 Session Storage에 액세스 토큰 문자열이 남지 않는지 확인한다.
|
||||
|
||||
5. 같은 정상 JWT를 expected issuer와 audience가 다른 진단용 서버 두 곳에 보내고 401이 반환되는지 확인한다.
|
||||
|
||||
6. 리프레시 토큰으로 새 토큰을 받은 뒤 이전 리프레시 토큰이 거부되는지 확인한다. revocation 뒤에는 갱신이 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP1의 SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려운 public client다. 이 프로젝트는 SPA에 shared client secret을 두지 않았고, authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 한다. 받은 액세스·리프레시·ID 토큰은 `InMemoryWebStorage`를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.
|
||||
|
||||
## SPA가 토큰을 다루는 위치
|
||||
|
||||
:::evidence key="ap1-direct-architecture" alt="SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
|
||||
|
||||
그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.
|
||||
|
||||
## 새로고침 전후에 브라우저에 남는 값
|
||||
|
||||
`oidc-client-ts`의 `InMemoryWebStorage`는 로그인 결과를 Local Storage나 Session Storage가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 메모리에 있던 로그인 정보와 토큰이 사라진다.
|
||||
|
||||
리프레시 토큰만 서버로 옮기거나 모든 토큰을 서버가 맡는 쪽도 검토했다. 다만 그렇게 하면 브라우저가 code를 교환하고 토큰의 수명을 관리하는 모습이 가려지기 때문에, 그 과정을 그대로 보려고 액세스·리프레시·ID 토큰을 JavaScript 메모리에 두었다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못한다.
|
||||
|
||||
| 위치 | 새로고침 전 | 새로고침 뒤 |
|
||||
|---|---|---|
|
||||
| JavaScript 메모리 | `User`, 액세스·리프레시·ID 토큰, 만료 시각, 프로필 | 사라짐 |
|
||||
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
|
||||
| Local Storage | 해당 없음 | 해당 없음 |
|
||||
| Keycloak origin 쿠키 | IdP의 SSO 상태가 존재할 수 있음 | 애플리케이션 메모리와 별개 |
|
||||
|
||||
JavaScript 메모리의 `User`가 사라지는 것과 Keycloak의 SSO 세션이 끝나는 것은 다른 사건이다. SPA가 들고 있던 토큰이 없어져도 Keycloak SSO 쿠키가 그대로면 다음 authorization request에서 기존 로그인 상태가 다시 쓰일 수 있다.
|
||||
|
||||
## memory-only가 줄이는 것과 줄이지 못하는 것
|
||||
|
||||
액세스·리프레시·ID 토큰은 실행 중 메모리에 있고, 악성 스크립트는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS가 할 수 있는 일이 아니다.
|
||||
|
||||
| 위협 | memory-only가 막아주나 |
|
||||
|---|---|
|
||||
| 새로고침 뒤에도 남는 토큰 복사본 | 막아준다 |
|
||||
| 실행 중 스크립트가 fetch를 가로채는 것 | 막아주지 않는다 |
|
||||
| 실행 중 스크립트가 사용자 대신 API를 부르는 것 | 막아주지 않는다 |
|
||||
| 요청 헤더에 실리는 액세스 토큰 | 막아주지 않는다 |
|
||||
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
||||
|
||||
SPA가 Resource Server를 부를 때는 액세스 토큰을 `Authorization` 헤더에 넣는다.
|
||||
|
||||
```http label="브라우저가 Resource Server를 직접 부를 때"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
그래서 액세스 토큰은 JavaScript 메모리 안에만 머무르지 않고, API를 부르는 동안 요청 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
|
||||
|
||||
즉시 없앨 수단이 없으니 짧은 수명과 검증이 가드레일이 된다.
|
||||
|
||||
액세스 토큰 : 300초
|
||||
리프레시 토큰 rotation, 재사용 허용 : x
|
||||
issuer·audience : 검증
|
||||
|
||||
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기려면 저장 위치만 바꿔서는 안 되고, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
|
||||
|
||||
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
|
||||
|
||||
## PKCE가 적용되는 구간
|
||||
|
||||
:::evidence key="ap1-browser-bearer-flow" alt="브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
|
||||
|
||||
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
|
||||
response_type=code
|
||||
client_id=spa-public
|
||||
redirect_uri=http://localhost:8088/callback.html
|
||||
scope=openid profile email
|
||||
state=<opaque-state>
|
||||
code_challenge=<opaque-challenge>
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
이 구성에서는 `response_type=code`를 쓰고, authorization request에 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 들어가는 것을 확인했다.
|
||||
|
||||
PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳까지다. 토큰이 발급된 뒤에 브라우저가 액세스 토큰을 쓰지 못하게 막아 주는 기능은 아니다.
|
||||
|
||||
## 테스트에서 확인한 범위
|
||||
|
||||
여기서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 테스트가 확인하도록 정의한 항목이다.
|
||||
|
||||
| 테스트에 있나 | 항목 |
|
||||
|---|---|
|
||||
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
|
||||
| o | 토큰 응답에 비어 있지 않은 액세스·리프레시·ID 토큰 |
|
||||
| o | `/api/me` 200과 decode한 액세스 토큰의 audience 포함 |
|
||||
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer 토큰 관측 |
|
||||
| o | Local Storage와 Session Storage에 액세스 토큰 문자열 없음 |
|
||||
| o | 리프레시 토큰 rotation — 새 토큰 발급, 이전 토큰 거부, revocation 뒤 갱신 실패 |
|
||||
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
|
||||
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
|
||||
| x | 서명이 깨진 JWT, 만료된 JWT |
|
||||
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
|
||||
| x | callback에 error가 실려 돌아왔을 때의 화면 |
|
||||
| x | `automaticSilentRenew`의 실제 갱신 경로 |
|
||||
|
||||
표에서 fetch를 가로채 Bearer 토큰을 본 항목은 노출의 한계를 일부러 재현한 것이다. Local Storage와 Session Storage에 토큰 문자열이 없다는 항목과 함께 봐야, 브라우저 저장소에는 없지만 실행 중 JavaScript 경계에는 있다는 것이 확인된다.
|
||||
|
||||
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
|
||||
|
||||
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
|
||||
|
||||
:::warning
|
||||
|
||||
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지보다 JSON parse error가 먼저 보일 수 있다.
|
||||
|
||||
:::
|
||||
|
||||
## Redirect URI와 CORS에서 아직 확인하지 않은 것
|
||||
|
||||
local realm의 redirect allowlist는 wildcard로 설정되어 있다.
|
||||
|
||||
```text
|
||||
http://localhost:8088/*
|
||||
http://127.0.0.1:8088/*
|
||||
```
|
||||
|
||||
SPA가 실제로 쓰는 callback은 `/callback.html` 하나인데 등록된 목록은 그보다 넓다.
|
||||
|
||||
SPA : `/callback.html`만 o
|
||||
exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부하는 검사 : x
|
||||
|
||||
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 절대 URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거친다. 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "f52977123664fe13262ee346920bcf72bfebccd5aaea4e25dd2f905a5a39781d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
---
|
||||
id: bf675775-4f3e-4744-8014-f0efff51422a
|
||||
kind: CASE
|
||||
slug: spa-browser-credential-boundary
|
||||
title: SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 31
|
||||
verifiedOn: 2026-08-22
|
||||
studio: "https://hyeonworks.com/studio/documents/bf675775-4f3e-4744-8014-f0efff51422a/edit"
|
||||
public: "https://hyeonworks.com/cases/spa-browser-credential-boundary"
|
||||
assets:
|
||||
- key: ap1-direct-architecture
|
||||
file: ../../../final/assets/ap1-direct-architecture/ap1-direct-architecture.svg
|
||||
- key: ap1-browser-bearer-flow
|
||||
file: ../../../final/assets/ap1-browser-bearer-flow/ap1-browser-bearer-flow.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap1
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
- final/document.md#결정이-지켜지는지-확인하는-방법-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap1
|
||||
---
|
||||
|
||||
# SPA에서 OAuth Token을 JavaScript Memory에 보관한 경우
|
||||
|
||||
AP1에서는 SPA(Single Page Application)를 public OAuth client로 구성하고 authorization code도 브라우저에서 직접 교환한다. 발급받은 액세스 토큰, 리프레시 토큰, ID 토큰은 Web Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
SPA에서 authorization request를 보내고 authorization code를 받은 뒤 토큰을 교환하는 과정을 직접 확인한 내용이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려워 public client로 구성했고 PKCE S256을 사용했다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
JavaScript 메모리에 있는 토큰과 Keycloak의 SSO 쿠키는 서로 다른 상태여서, 새로고침으로 SPA의 토큰이 없어져도 Keycloak의 SSO까지 끝나는 것은 아니다.
|
||||
|
||||
## 문제
|
||||
|
||||
AP1에서는 토큰을 Local Storage나 Session Storage에 저장하지 않고 JavaScript 메모리에만 둔다.
|
||||
|
||||
확인하고 싶었던 것은 토큰을 Web Storage에 쓰지 않는 것만으로 실행 중 XSS(Cross-Site Scripting, 사이트 간 스크립팅)까지 막을 수 있는지였다.
|
||||
|
||||
그래서 SPA가 authorization code를 직접 교환한 뒤 토큰을 어디에 들고 있는지, API를 부를 때 액세스 토큰이 어느 곳을 지나는지 따라갔다. PKCE가 실제로 어느 구간에 적용되는지도 같이 봤다.
|
||||
|
||||
## 결론
|
||||
|
||||
memory-only로 보관하면 새로고침 뒤에는 액세스·리프레시·ID 토큰이 모두 사라진다.
|
||||
|
||||
다만 페이지가 실행 중일 때는 JavaScript가 토큰을 다루므로, 악성 스크립트가 같은 페이지에서 실행되면 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. 액세스 토큰은 JavaScript 메모리에만 있는 값도 아니어서 Resource Server 요청의 Authorization 헤더에도 실린다.
|
||||
|
||||
Resource Server는 SessionCreationPolicy.STATELESS로 동작해 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout과 동시에 없애는 처리도 넣지 않았다.
|
||||
|
||||
그 대신 현재 구성은 액세스 토큰 수명을 300초로 두고 리프레시 토큰 rotation을 쓰며, Resource Server에서 issuer와 audience도 확인한다.
|
||||
|
||||
PKCE는 authorization code를 토큰으로 교환하는 구간에 쓰는 것이지, 이미 발급된 액세스 토큰을 브라우저에서 숨겨 주는 기능은 아니다.
|
||||
|
||||
## 검증 환경
|
||||
|
||||
Keycloak 26.7.0
|
||||
|
||||
realms 설정
|
||||
public-client, standard flow : o
|
||||
implicit flow, direct grant : x
|
||||
authority : http://localhost:8080/realms/keycloak-patterns
|
||||
redirect_uri : http://localhost:8088/callback.html
|
||||
scope : openid profile email
|
||||
userStore : InMemoryWebStorage
|
||||
stateStore : sessionStorage
|
||||
automaticSilentRenew : true
|
||||
|
||||
Resource Server
|
||||
SessionCreationPolicy.STATELESS
|
||||
CSRF x
|
||||
CORS allowlist : localhost:8088, 127.0.0.1:8088, GET·OPTIONS, Authorization·Content-Type
|
||||
|
||||
HTTPS : x
|
||||
HTTP : o
|
||||
|
||||
## 재현 조건
|
||||
|
||||
1. SPA를 열고 로그인한 뒤 Keycloak authorization request에서 response_type=code, code_challenge_method=S256, 비어 있지 않은 code_challenge를 확인한다.
|
||||
|
||||
2. 토큰 응답의 액세스·리프레시·ID 토큰이 비어 있지 않은지 확인한다.
|
||||
|
||||
3. 브라우저 fetch를 가로채 /api/me 요청의 Authorization 헤더에서 Bearer 액세스 토큰을 확인한다.
|
||||
|
||||
4. Local Storage와 Session Storage에 액세스 토큰 문자열이 남지 않는지 확인한다.
|
||||
|
||||
5. 같은 정상 JWT를 expected issuer와 audience가 다른 진단용 서버 두 곳에 보내고 401이 반환되는지 확인한다.
|
||||
|
||||
6. 리프레시 토큰으로 새 토큰을 받은 뒤 이전 리프레시 토큰이 거부되는지 확인한다. revocation 뒤에는 갱신이 실패하는지, 이미 발급된 access JWT는 만료 전까지 200을 받는지도 확인한다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
AP1의 SPA는 브라우저 실행 환경에서 장기 client credential의 기밀성을 유지하기 어려운 public client다. 이 프로젝트는 SPA에 shared client secret을 두지 않았고, authorization code를 토큰으로 바꾸는 일까지 브라우저가 직접 한다. 받은 액세스·리프레시·ID 토큰은 `InMemoryWebStorage`를 써서 실행 중 메모리에만 둔다. 이 구성이 무엇을 줄이고 무엇은 줄이지 못하는지 확인했다.
|
||||
|
||||
## SPA가 토큰을 다루는 위치
|
||||
|
||||
:::evidence key="ap1-direct-architecture" alt="SPA, Keycloak, 브라우저 JavaScript memory, Resource Server가 왼쪽에서 오른쪽으로 연결된 AP1 직접 인증 아키텍처." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
authorization code 교환, 토큰 보관, `Authorization` 헤더 조립까지 모두 브라우저에서 일어난다. 액세스·리프레시·ID 토큰은 JavaScript 메모리에 있고, Resource Server를 부를 때 쓸 `Authorization` 헤더도 같은 페이지에서 만든다.
|
||||
|
||||
그래서 이 페이지에서 악성 스크립트가 실행되면 JavaScript가 토큰을 다루는 경로에도 닿을 수 있다.
|
||||
|
||||
## 새로고침 전후에 브라우저에 남는 값
|
||||
|
||||
`oidc-client-ts`의 `InMemoryWebStorage`는 로그인 결과를 Local Storage나 Session Storage가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 메모리에 있던 로그인 정보와 토큰이 사라진다.
|
||||
|
||||
리프레시 토큰만 서버로 옮기거나 모든 토큰을 서버가 맡는 쪽도 검토했다. 다만 그렇게 하면 브라우저가 code를 교환하고 토큰의 수명을 관리하는 모습이 가려지기 때문에, 그 과정을 그대로 보려고 액세스·리프레시·ID 토큰을 JavaScript 메모리에 두었다. 그 대신 새로고침 뒤에는 인증 상태를 복구하지 못한다.
|
||||
|
||||
| 위치 | 새로고침 전 | 새로고침 뒤 |
|
||||
|---|---|---|
|
||||
| JavaScript 메모리 | `User`, 액세스·리프레시·ID 토큰, 만료 시각, 프로필 | 사라짐 |
|
||||
| Session Storage | redirect transaction용 state와 verifier | callback 완료 뒤 제거 |
|
||||
| Local Storage | 해당 없음 | 해당 없음 |
|
||||
| Keycloak origin 쿠키 | IdP의 SSO 상태가 존재할 수 있음 | 애플리케이션 메모리와 별개 |
|
||||
|
||||
JavaScript 메모리의 `User`가 사라지는 것과 Keycloak의 SSO 세션이 끝나는 것은 다른 사건이다. SPA가 들고 있던 토큰이 없어져도 Keycloak SSO 쿠키가 그대로면 다음 authorization request에서 기존 로그인 상태가 다시 쓰일 수 있다.
|
||||
|
||||
## memory-only가 줄이는 것과 줄이지 못하는 것
|
||||
|
||||
액세스·리프레시·ID 토큰은 실행 중 메모리에 있고, 악성 스크립트는 같은 화면에서 fetch를 가로채거나 사용자를 대신해 API를 부를 수 있다. memory-only로 줄어드는 것은 새로고침 뒤에도 남는 복사본이지 실행 중 XSS가 할 수 있는 일이 아니다.
|
||||
|
||||
| 위협 | memory-only가 막아주나 |
|
||||
|---|---|
|
||||
| 새로고침 뒤에도 남는 토큰 복사본 | 막아준다 |
|
||||
| 실행 중 스크립트가 fetch를 가로채는 것 | 막아주지 않는다 |
|
||||
| 실행 중 스크립트가 사용자 대신 API를 부르는 것 | 막아주지 않는다 |
|
||||
| 요청 헤더에 실리는 액세스 토큰 | 막아주지 않는다 |
|
||||
| 이미 발급된 access JWT의 만료 전 유효성 | 막아주지 않는다 |
|
||||
|
||||
SPA가 Resource Server를 부를 때는 액세스 토큰을 `Authorization` 헤더에 넣는다.
|
||||
|
||||
```http label="브라우저가 Resource Server를 직접 부를 때"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
그래서 액세스 토큰은 JavaScript 메모리 안에만 머무르지 않고, API를 부르는 동안 요청 헤더에도 실린다. Resource Server가 돌려주는 것은 `subject`·`username`·`issuer`·`audience` 네 필드다.
|
||||
|
||||
Resource Server는 `SessionCreationPolicy.STATELESS`로 설정되어 있어 서버에서 지울 애플리케이션 세션이 없고, 이미 발급된 self-contained JWT를 logout 시점에 곧바로 무효화하는 처리도 넣지 않았다. logout은 Keycloak SSO 종료와 SPA의 사용자 제거까지만 하고, 발급된 access JWT를 deny-list로 따로 관리하지는 않는다.
|
||||
|
||||
즉시 없앨 수단이 없으니 짧은 수명과 검증이 가드레일이 된다.
|
||||
|
||||
액세스 토큰 : 300초
|
||||
리프레시 토큰 rotation, 재사용 허용 : x
|
||||
issuer·audience : 검증
|
||||
|
||||
저장 위치를 옮기는 선택지는 저마다 다른 것을 요구한다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침 뒤에도 값을 다시 읽을 수 있지만 브라우저 저장소에 토큰 복사본이 생긴다. HttpOnly 쿠키로 옮기려면 저장 위치만 바꿔서는 안 되고, 브라우저가 액세스 토큰을 꺼내 Resource Server로 직접 보내는 지금 방식 대신 서버가 세션이나 토큰 중계를 맡는 구조가 있어야 한다.
|
||||
|
||||
실행 중 스크립트 자체의 위험은 CSP(Content Security Policy)와 의존성 무결성 검사로 따로 줄인다.
|
||||
|
||||
## PKCE가 적용되는 구간
|
||||
|
||||
:::evidence key="ap1-browser-bearer-flow" alt="브라우저 SPA, Keycloak, Resource Server 사이에서 authorization request, callback, token 교환, Bearer API 호출과 JSON 응답이 이어지는 순서도." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
PKCE(Proof Key for Code Exchange)를 쓰면 authorization request에는 `code_challenge`가 들어가고, authorization code를 토큰으로 교환할 때는 원본인 `code_verifier`를 함께 보낸다. 두 값이 맞아야 code를 교환할 수 있다.
|
||||
|
||||
```text label="oidc-client-ts가 만드는 authorization request의 핵심 query"
|
||||
response_type=code
|
||||
client_id=spa-public
|
||||
redirect_uri=http://localhost:8088/callback.html
|
||||
scope=openid profile email
|
||||
state=<opaque-state>
|
||||
code_challenge=<opaque-challenge>
|
||||
code_challenge_method=S256
|
||||
```
|
||||
|
||||
이 구성에서는 `response_type=code`를 쓰고, authorization request에 `code_challenge_method=S256`과 비어 있지 않은 `code_challenge`가 들어가는 것을 확인했다.
|
||||
|
||||
PKCE가 걸리는 구간은 authorization code를 토큰으로 교환하는 곳까지다. 토큰이 발급된 뒤에 브라우저가 액세스 토큰을 쓰지 못하게 막아 주는 기능은 아니다.
|
||||
|
||||
## 테스트에서 확인한 범위
|
||||
|
||||
여기서 확인했다고 적은 것은 마지막 실행 결과가 아니라 커밋된 테스트가 확인하도록 정의한 항목이다.
|
||||
|
||||
| 테스트에 있나 | 항목 |
|
||||
|---|---|
|
||||
| o | authorization request의 `response_type=code`, S256 method, 비어 있지 않은 challenge |
|
||||
| o | 토큰 응답에 비어 있지 않은 액세스·리프레시·ID 토큰 |
|
||||
| o | `/api/me` 200과 decode한 액세스 토큰의 audience 포함 |
|
||||
| o | 브라우저 fetch를 가로채 Authorization 헤더의 Bearer 토큰 관측 |
|
||||
| o | Local Storage와 Session Storage에 액세스 토큰 문자열 없음 |
|
||||
| o | 리프레시 토큰 rotation — 새 토큰 발급, 이전 토큰 거부, revocation 뒤 갱신 실패 |
|
||||
| o | issuer나 audience가 다른 진단용 서버 두 곳의 401 |
|
||||
| x | token request body의 `code_verifier`·`client_id`·`redirect_uri`·code 값 대조 |
|
||||
| x | 서명이 깨진 JWT, 만료된 JWT |
|
||||
| x | 브라우저 간 요청(CORS)의 preflight 응답 |
|
||||
| x | callback에 error가 실려 돌아왔을 때의 화면 |
|
||||
| x | `automaticSilentRenew`의 실제 갱신 경로 |
|
||||
|
||||
표에서 fetch를 가로채 Bearer 토큰을 본 항목은 노출의 한계를 일부러 재현한 것이다. Local Storage와 Session Storage에 토큰 문자열이 없다는 항목과 함께 봐야, 브라우저 저장소에는 없지만 실행 중 JavaScript 경계에는 있다는 것이 확인된다.
|
||||
|
||||
authorization request 쪽은 `response_type=code`, S256 method, 비어 있지 않은 challenge까지 봤지만, token request body에 실제로 들어간 `code_verifier`, `client_id`, `redirect_uri`, code 값이 서로 어떻게 대조됐는지는 아직 확인하지 않았다. 구현이 의도한 PKCE 순서와 테스트가 실제로 붙잡은 필드를 같은 증거로 쓸 수 없다.
|
||||
|
||||
서명이 깨진 JWT와 만료된 JWT도 전용 테스트로 넣지 않았다. 단위 테스트에서 합성 JWT를 주입해 컨트롤러가 200을 내는 것은 봤지만, 그것이 실제 Nimbus 서명 검증과 issuer 검증을 지났다는 증거는 아니다.
|
||||
|
||||
:::warning
|
||||
|
||||
SPA는 non-2xx 응답에서도 `response.ok`을 확인하기 전에 `response.json()`을 시도한다. 401 body가 비어 있거나 JSON이 아니면 의도한 메시지보다 JSON parse error가 먼저 보일 수 있다.
|
||||
|
||||
:::
|
||||
|
||||
## Redirect URI와 CORS에서 아직 확인하지 않은 것
|
||||
|
||||
local realm의 redirect allowlist는 wildcard로 설정되어 있다.
|
||||
|
||||
```text
|
||||
http://localhost:8088/*
|
||||
http://127.0.0.1:8088/*
|
||||
```
|
||||
|
||||
SPA가 실제로 쓰는 callback은 `/callback.html` 하나인데 등록된 목록은 그보다 넓다.
|
||||
|
||||
SPA : `/callback.html`만 o
|
||||
exact callback만 허용하는 운영 가드레일, 잘못된 redirect를 거부하는 검사 : x
|
||||
|
||||
frontend Nginx에도 `/api/` proxy가 있지만 SPA는 상대 URL이 아니라 절대 URL인 `http://localhost:8081/api/me`를 부른다. 그래서 지금 요청은 브라우저에서 Resource Server로 곧장 나가 CORS allowlist를 거친다. 상대 URL로 Nginx를 통해 불렀다면 이 CORS 경로는 지나지 않았을 것이다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"sourceSha256": "f52977123664fe13262ee346920bcf72bfebccd5aaea4e25dd2f905a5a39781d",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/case/case-browser-credential-boundary.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"startedAt": "2026-09-19T10:27:56+00:00",
|
||||
"finishedAt": "2026-09-19T10:27:58+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:56+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:56+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md -o runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:56+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:57+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 이미 반영된 Record 결과를 재검증하는 작업이며 새 관계·순서·측정 주장을 추가하지 않았다. 기존 asset 배정이 있으면 그대로 재사용하고 현재 12개 SVG를 다시 그리지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:57+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:58+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:58+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:58+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:58+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:58+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:58+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:58+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:58+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md -o runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S3/command-initial.json",
|
||||
"sha256": "8c0dc807b5837d56df05d21068a50d1fd2f8e5e25f7bc99367ab5a4c254bbe5d"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md -o runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/S6/command-final.json",
|
||||
"sha256": "8c0dc807b5837d56df05d21068a50d1fd2f8e5e25f7bc99367ab5a4c254bbe5d"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "43b3ff7332210ce8fc456d57a6a27aae6f8782ba1ce2caa332e3fae8e7b50844",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-03-concept-authorization-code-and-pkce/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "3e4f929a6fbc1325bf8eb08daf04c0f13a6a2a7272a8c9b0a49fc231035cee49"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:27:56+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:56+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:57+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:58+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:27:58+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "43b3ff7332210ce8fc456d57a6a27aae6f8782ba1ce2caa332e3fae8e7b50844",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
|
||||
kind: CONCEPT
|
||||
slug: authorization-code-and-pkce
|
||||
title: Authorization Code와 PKCE가 보호하는 구간
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts
|
||||
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
|
||||
assets:
|
||||
- key: login-api-phase-split
|
||||
file: ../../../final/assets/login-api-phase-split/login-api-phase-split.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
|
||||
---
|
||||
|
||||
# Authorization Code와 PKCE가 보호하는 구간
|
||||
|
||||
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에는 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 개념을 endpoint별 기준으로 정리한 기록이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 code를 직접 교환한 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## code를 한 번 더 교환하는 이유
|
||||
|
||||
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
|
||||
|
||||
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리와 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 싣고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
|
||||
|
||||
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
|
||||
|
||||
## authorization request에 들어가는 challenge
|
||||
|
||||
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
|
||||
|
||||
```http label="authorization request"
|
||||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||
?client_id=spa-public
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
|
||||
&response_type=code
|
||||
&scope=openid%20profile%20email
|
||||
&state=<opaque-state>
|
||||
&code_challenge=<opaque-challenge>
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
|
||||
`response_type=code`는 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`은 PKCE를 쓴다는 뜻이다. `state`와 challenge 값은 요청마다 달라진다.
|
||||
|
||||
`state`와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
|
||||
|
||||
## token request가 제출하는 verifier
|
||||
|
||||
callback으로 돌아온 code는 다음 요청으로 교환된다.
|
||||
|
||||
```http label="token request"
|
||||
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&client_id=spa-public
|
||||
&code=<authorization-code>
|
||||
&redirect_uri=http://localhost:8088/callback.html
|
||||
&code_verifier=<original-verifier>
|
||||
```
|
||||
|
||||
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 이 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
|
||||
|
||||
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, `grant_type=authorization_code`까지다. body에 실린 `code_verifier`와 `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다.
|
||||
|
||||
## S256과 plain의 차이
|
||||
|
||||
verifier에서 challenge를 만드는 방법은 두 가지다. `plain`은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. `S256`은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
|
||||
|
||||
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|
||||
|---|---|---|
|
||||
| `plain` | verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
|
||||
| `S256` | verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
|
||||
|
||||
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 `"S256"`을 함께 반환한다. 다만 로그인을 시작하는 `signinRedirect()`는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
|
||||
|
||||
## PKCE가 막지 않는 것
|
||||
|
||||
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중인 악성 스크립트가 Bearer 토큰을 읽거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
|
||||
|
||||
`state`는 PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code를 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
|
||||
|
||||
## 로그인을 끝내는 쪽과 API를 부르는 쪽이 다르다
|
||||
|
||||
code 교환이 끝나도 API 요청까지 같은 곳에서 처리되는 것은 아니다. 누가 token을 받았는지와 누가 보호 자원을 부르는지가 패턴마다 갈린다.
|
||||
|
||||
:::evidence key="login-api-phase-split" alt="로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
AP2에서는 mediator가 token을 받고 API는 브라우저가 부른다. AP3에서는 BFF가 두 일을 모두 맡는다. AP4에서는 oauth2-proxy가 code 교환과 `AP4_SESSION` 검증을 하고, Nginx가 upstream 요청과 identity header를 만든다.
|
||||
|
||||
그래서 `Browser → Keycloak → API`처럼 한 줄로 그리면 서로 다른 이동이 하나로 뭉친다. 두 구간으로 나누어 읽는다.
|
||||
|
||||
| 구간 | 여기서 무엇이 일어나나 |
|
||||
|---|---|
|
||||
| 로그인 구간 | authorization request, callback, code 교환, 로그인 상태 생성 |
|
||||
| 애플리케이션 요청 구간 | 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답 |
|
||||
|
||||
## 클라이언트 종류에 따라 달라지는 인증
|
||||
|
||||
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
|
||||
|
||||
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착한다. 그러면 프레임워크가 `state`와 verifier를 만든다.
|
||||
|
||||
반면 AP2의 `token-mediating-confidential`은 client 설정에서 S256을 강제하지 않고 AP2 E2E도 authorization request의 challenge를 검사하지 않는다. 따라서 AP2는 Authorization Code를 사용하는 confidential client라는 사실까지 확인했으며, PKCE S256이 고정되었다고 기록하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
|
||||
kind: CONCEPT
|
||||
slug: authorization-code-and-pkce
|
||||
title: Authorization Code와 PKCE가 보호하는 구간
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts
|
||||
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
|
||||
assets:
|
||||
- key: login-api-phase-split
|
||||
file: ../../../final/assets/login-api-phase-split/login-api-phase-split.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
|
||||
---
|
||||
|
||||
# Authorization Code와 PKCE가 보호하는 구간
|
||||
|
||||
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에는 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 개념을 endpoint별 기준으로 정리한 기록이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 code를 직접 교환한 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## code를 한 번 더 교환하는 이유
|
||||
|
||||
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
|
||||
|
||||
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리와 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 싣고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
|
||||
|
||||
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
|
||||
|
||||
## authorization request에 들어가는 challenge
|
||||
|
||||
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
|
||||
|
||||
```http label="authorization request"
|
||||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||
?client_id=spa-public
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
|
||||
&response_type=code
|
||||
&scope=openid%20profile%20email
|
||||
&state=<opaque-state>
|
||||
&code_challenge=<opaque-challenge>
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
|
||||
`response_type=code`는 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`은 PKCE를 쓴다는 뜻이다. `state`와 challenge 값은 요청마다 달라진다.
|
||||
|
||||
`state`와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
|
||||
|
||||
## token request가 제출하는 verifier
|
||||
|
||||
callback으로 돌아온 code는 다음 요청으로 교환된다.
|
||||
|
||||
```http label="token request"
|
||||
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&client_id=spa-public
|
||||
&code=<authorization-code>
|
||||
&redirect_uri=http://localhost:8088/callback.html
|
||||
&code_verifier=<original-verifier>
|
||||
```
|
||||
|
||||
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 이 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
|
||||
|
||||
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, `grant_type=authorization_code`까지다. body에 실린 `code_verifier`와 `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다.
|
||||
|
||||
## S256과 plain의 차이
|
||||
|
||||
verifier에서 challenge를 만드는 방법은 두 가지다. `plain`은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. `S256`은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
|
||||
|
||||
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|
||||
|---|---|---|
|
||||
| `plain` | verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
|
||||
| `S256` | verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
|
||||
|
||||
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 `"S256"`을 함께 반환한다. 다만 로그인을 시작하는 `signinRedirect()`는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
|
||||
|
||||
## PKCE가 막지 않는 것
|
||||
|
||||
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중인 악성 스크립트가 Bearer 토큰을 읽거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
|
||||
|
||||
`state`는 PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code를 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
|
||||
|
||||
## 로그인을 끝내는 쪽과 API를 부르는 쪽이 다르다
|
||||
|
||||
code 교환이 끝나도 API 요청까지 같은 곳에서 처리되는 것은 아니다. 누가 token을 받았는지와 누가 보호 자원을 부르는지가 패턴마다 갈린다.
|
||||
|
||||
:::evidence key="login-api-phase-split" alt="로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
AP2에서는 mediator가 token을 받고 API는 브라우저가 부른다. AP3에서는 BFF가 두 일을 모두 맡는다. AP4에서는 oauth2-proxy가 code 교환과 `AP4_SESSION` 검증을 하고, Nginx가 upstream 요청과 identity header를 만든다.
|
||||
|
||||
그래서 `Browser → Keycloak → API`처럼 한 줄로 그리면 서로 다른 이동이 하나로 뭉친다. 두 구간으로 나누어 읽는다.
|
||||
|
||||
| 구간 | 여기서 무엇이 일어나나 |
|
||||
|---|---|
|
||||
| 로그인 구간 | authorization request, callback, code 교환, 로그인 상태 생성 |
|
||||
| 애플리케이션 요청 구간 | 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답 |
|
||||
|
||||
## 클라이언트 종류에 따라 달라지는 인증
|
||||
|
||||
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
|
||||
|
||||
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착한다. 그러면 프레임워크가 `state`와 verifier를 만든다.
|
||||
|
||||
반면 AP2의 `token-mediating-confidential`은 client 설정에서 S256을 강제하지 않고 AP2 E2E도 authorization request의 challenge를 검사하지 않는다. 따라서 AP2는 Authorization Code를 사용하는 confidential client라는 사실까지 확인했으며, PKCE S256이 고정되었다고 기록하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "43b3ff7332210ce8fc456d57a6a27aae6f8782ba1ce2caa332e3fae8e7b50844",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
---
|
||||
id: 75c6c657-3e03-47a0-a9d0-5637fce9dd3f
|
||||
kind: CONCEPT
|
||||
slug: authorization-code-and-pkce
|
||||
title: Authorization Code와 PKCE가 보호하는 구간
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts
|
||||
studio: "https://hyeonworks.com/studio/documents/75c6c657-3e03-47a0-a9d0-5637fce9dd3f/edit"
|
||||
assets:
|
||||
- key: login-api-phase-split
|
||||
file: ../../../final/assets/login-api-phase-split/login-api-phase-split.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-로그인-흐름과-api-흐름
|
||||
---
|
||||
|
||||
# Authorization Code와 PKCE가 보호하는 구간
|
||||
|
||||
authorization code는 로그인을 마친 사용자가 애플리케이션으로 돌아올 때 잠시 들고 오는 교환용 값이다. 이 code를 액세스 토큰으로 바꾸는 구간을 PKCE가 보호한다. authorization request에는 code_challenge를 담아 보내고, token request에는 그 원본인 code_verifier를 보내 두 값이 대응하는지 확인한다. 두 값이 맞아야 교환이 끝난다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 개념을 endpoint별 기준으로 정리한 기록이다.
|
||||
- **Public Client와 Confidential Client 구분 기준**
|
||||
클라이언트 종류에 따라 token endpoint의 인증 방식이 달라진다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 code를 직접 교환한 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## code를 한 번 더 교환하는 이유
|
||||
|
||||
로그인 한 번에 요청은 두 번 오간다. 브라우저가 먼저 Keycloak으로 이동하고, 로그인이 끝나면 authorization code를 들고 redirect URI로 돌아온다. 이 code로는 아직 API를 부를 수 없다. OAuth 클라이언트가 code를 token endpoint에 제출해야 액세스 토큰을 받는다.
|
||||
|
||||
authorization request는 브라우저 전체가 옮겨 가는 요청(full-page navigation)이어서 그 URL이 주소창과 히스토리와 Authorization Server 접근 로그에 남는다. 그래서 이 요청에는 그런 곳에 남아도 되는 값만 code로 싣고, 액세스 토큰은 별도 요청의 body로 받는다. 교환을 둘로 나눈 덕분에 액세스 토큰이 브라우저 주소창을 지나지 않는다.
|
||||
|
||||
PKCE 값이 어디서 만들어져 어디서 확인되는지, 두 요청을 순서대로 본다.
|
||||
|
||||
## authorization request에 들어가는 challenge
|
||||
|
||||
oidc-client-ts가 만드는 요청의 핵심 모양은 다음과 같다.
|
||||
|
||||
```http label="authorization request"
|
||||
GET http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
|
||||
?client_id=spa-public
|
||||
&redirect_uri=http%3A%2F%2Flocalhost%3A8088%2Fcallback.html
|
||||
&response_type=code
|
||||
&scope=openid%20profile%20email
|
||||
&state=<opaque-state>
|
||||
&code_challenge=<opaque-challenge>
|
||||
&code_challenge_method=S256
|
||||
```
|
||||
|
||||
`response_type=code`는 Authorization Code Flow를 쓴다는 뜻이고, `code_challenge`와 `code_challenge_method=S256`은 PKCE를 쓴다는 뜻이다. `state`와 challenge 값은 요청마다 달라진다.
|
||||
|
||||
`state`와 PKCE verifier는 Keycloak에 갔다가 돌아오는 사이에도 있어야 해서 브라우저가 들고 있는다. AP1은 이 둘을 Session Storage에 두고 Keycloak 왕복을 건넌다.
|
||||
|
||||
## token request가 제출하는 verifier
|
||||
|
||||
callback으로 돌아온 code는 다음 요청으로 교환된다.
|
||||
|
||||
```http label="token request"
|
||||
POST http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/token
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
grant_type=authorization_code
|
||||
&client_id=spa-public
|
||||
&code=<authorization-code>
|
||||
&redirect_uri=http://localhost:8088/callback.html
|
||||
&code_verifier=<original-verifier>
|
||||
```
|
||||
|
||||
`code_verifier`는 authorization request를 시작할 때 만든 원본 값이다. Authorization Server는 challenge와 이 verifier가 대응하는지 확인한 뒤에 교환을 끝낸다. 이 확인이 authorization request를 시작한 클라이언트와 code를 교환하는 주체를 이어 준다.
|
||||
|
||||
다만 커밋된 AP1 브라우저 테스트가 직접 보는 것은 authorization request의 challenge와 token request의 endpoint, `grant_type=authorization_code`까지다. body에 실린 `code_verifier`와 `client_id`, `redirect_uri`, code 값을 하나씩 비교하지는 않는다.
|
||||
|
||||
## S256과 plain의 차이
|
||||
|
||||
verifier에서 challenge를 만드는 방법은 두 가지다. `plain`은 verifier를 그대로 challenge로 보내는데, 중간에서 challenge를 본 쪽은 그 값을 그대로 verifier로 쓸 수 있다. `S256`은 verifier에 SHA-256을 적용한 값을 challenge로 보내기 때문에 challenge만으로는 verifier를 되돌릴 수 없다.
|
||||
|
||||
| method | challenge 값 | 중간에서 challenge를 본 경우 |
|
||||
|---|---|---|
|
||||
| `plain` | verifier 그대로 | 그대로 verifier로 쓸 수 있다 |
|
||||
| `S256` | verifier의 SHA-256 | verifier를 되돌릴 수 없다 |
|
||||
|
||||
AP1 realm은 S256을 요구한다. AP1 코드에는 `createPkcePair()`라는 손으로 만든 보조 함수도 있다. 이 함수는 무작위 32바이트를 패딩 없는 Base64URL verifier로 바꾸고, 거기에 SHA-256을 적용한 challenge와 `"S256"`을 함께 반환한다. 다만 로그인을 시작하는 `signinRedirect()`는 이 함수를 부르지 않는다. 화면의 PKCE 데모 버튼이 쓰는 코드이고, 실제 로그인은 버전을 고정한 oidc-client-ts가 수행한다.
|
||||
|
||||
## PKCE가 막지 않는 것
|
||||
|
||||
PKCE는 탈취된 authorization code의 교환을 어렵게 한다. 이미 발급된 액세스 토큰을 숨겨 주지는 않는다. 브라우저가 토큰을 직접 다루는 구성에서 실행 중인 악성 스크립트가 Bearer 토큰을 읽거나 사용자 권한으로 API를 부르는 것은 PKCE가 막는 문제가 아니다.
|
||||
|
||||
`state`는 PKCE 값과 하는 일이 다르다. `state`는 돌아온 callback이 브라우저가 처음 시작한 트랜잭션의 것인지 대조하고, verifier는 code를 교환하는 주체를 authorization request를 시작한 클라이언트에 묶는다.
|
||||
|
||||
## 로그인을 끝내는 쪽과 API를 부르는 쪽이 다르다
|
||||
|
||||
code 교환이 끝나도 API 요청까지 같은 곳에서 처리되는 것은 아니다. 누가 token을 받았는지와 누가 보호 자원을 부르는지가 패턴마다 갈린다.
|
||||
|
||||
:::evidence key="login-api-phase-split" alt="로그인 구간과 애플리케이션 요청 구간을 나누어 AP2 mediator·브라우저, AP3 BFF, AP4 oauth2-proxy·Nginx의 책임 배치를 비교한 다이어그램." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
AP2에서는 mediator가 token을 받고 API는 브라우저가 부른다. AP3에서는 BFF가 두 일을 모두 맡는다. AP4에서는 oauth2-proxy가 code 교환과 `AP4_SESSION` 검증을 하고, Nginx가 upstream 요청과 identity header를 만든다.
|
||||
|
||||
그래서 `Browser → Keycloak → API`처럼 한 줄로 그리면 서로 다른 이동이 하나로 뭉친다. 두 구간으로 나누어 읽는다.
|
||||
|
||||
| 구간 | 여기서 무엇이 일어나나 |
|
||||
|---|---|
|
||||
| 로그인 구간 | authorization request, callback, code 교환, 로그인 상태 생성 |
|
||||
| 애플리케이션 요청 구간 | 브라우저 입력, 중간 계층의 credential 변환, 보호 자원의 검증, 최종 응답 |
|
||||
|
||||
## 클라이언트 종류에 따라 달라지는 인증
|
||||
|
||||
`spa-public`은 secret이 없는 public client다. token endpoint에서 클라이언트 인증을 하지 않고 PKCE만 사용한다.
|
||||
|
||||
confidential client는 여기에 클라이언트 인증을 더한다. AP3의 `bff-confidential`은 `client_secret_basic`으로 자기 클라이언트를 인증하면서 PKCE S256도 함께 쓴다. Spring Security에서는 authorization request를 만드는 resolver에 `OAuth2AuthorizationRequestCustomizers.withPkce()`를 장착한다. 그러면 프레임워크가 `state`와 verifier를 만든다.
|
||||
|
||||
반면 AP2의 `token-mediating-confidential`은 client 설정에서 S256을 강제하지 않고 AP2 E2E도 authorization request의 challenge를 검사하지 않는다. 따라서 AP2는 Authorization Code를 사용하는 confidential client라는 사실까지 확인했으며, PKCE S256이 고정되었다고 기록하지 않는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"sourceSha256": "43b3ff7332210ce8fc456d57a6a27aae6f8782ba1ce2caa332e3fae8e7b50844",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-authorization-code-and-pkce.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+385
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"startedAt": "2026-09-19T10:27:58+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:59+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:59+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md -o runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:27:59+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**그림의 사실은 SSOT 절이 댄다.** 기록은 SSOT 의 인용이라 줄 번호가 근거가 되지 못한다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/final/.techviz/bearer-jwt-validation-chain/spec.json",
|
||||
"docs/keycloak/final/assets/bearer-jwt-validation-chain/bearer-jwt-validation-chain.svg"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "TECHVIZ_HOME=/tmp/technical-visualization-haness scripts/techviz lint docs/keycloak/final/.techviz/bearer-jwt-validation-chain/spec.json --context docs/keycloak/final/.techviz/bearer-jwt-validation-chain/context.json --json",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-text.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-overlap.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/preview-figure.py --file docs/keycloak/final/assets/bearer-jwt-validation-chain/bearer-jwt-validation-chain.svg -o runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S4/preview",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-provenance.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:00+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "직전 수정에서 생성한 bearer-jwt-validation-chain 정본과 SVG를 current remediation에서 다시 검증했다. 새로 그리지 않았고 lint/text/overlap/provenance와 PNG preview를 다시 확인했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:00+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:01+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:01+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:01+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md -o runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S3/command-initial.json",
|
||||
"sha256": "6975f2146d2fd174702e7a6ab0f207683167fc294275615495cbd8b5bffffd05"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md -o runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/S6/command-final.json",
|
||||
"sha256": "6975f2146d2fd174702e7a6ab0f207683167fc294275615495cbd8b5bffffd05"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "4809284ed5c21b74c35964f958526572d1a02c57c6eaca41d8d6047a620cb5bd",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-04-concept-bearer-jwt-validation-chain/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "6f44bf42a76321436232491e525d8c3cf327397f0fe7a380aa079d8876aaa8d8"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:27:58+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S4",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:27:59+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:00+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:01+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 29,
|
||||
"updatedAt": "2026-09-19T10:28:02+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "4809284ed5c21b74c35964f958526572d1a02c57c6eaca41d8d6047a620cb5bd",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
id: 87000d59-b69f-4010-9481-0b71c8bde32d
|
||||
kind: CONCEPT
|
||||
slug: bearer-jwt-validation-chain
|
||||
title: Bearer JWT가 인증된 principal이 되기까지
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
|
||||
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
|
||||
assets:
|
||||
- key: bearer-jwt-validation-chain
|
||||
file: ../../../final/assets/bearer-jwt-validation-chain/bearer-jwt-validation-chain.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
---
|
||||
|
||||
# Bearer JWT가 인증된 principal이 되기까지
|
||||
|
||||
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 이 헤더를 직접 만든 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Resource Server가 받는 입력
|
||||
|
||||
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다.
|
||||
|
||||
```http label="Resource Server 입력"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
|
||||
|
||||
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
|
||||
|
||||
## 변환 순서
|
||||
|
||||
검증과 변환은 다음 순서로 이어진다.
|
||||
|
||||
:::evidence key="bearer-jwt-validation-chain" alt="Bearer JWT 입력이 JwtDecoder, issuer·시간 검증, audience 검증, role converter를 거쳐 authenticated principal이 되는 검증 사슬." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
|
||||
|
||||
## 서명 확인 하나로 끝내지 않는 이유
|
||||
|
||||
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
|
||||
|
||||
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|
||||
|---|---|---|
|
||||
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
|
||||
| issuer | 기대한 realm인가 | 아직 유효한가 |
|
||||
| timestamp | 만료 전인가 | 이 API가 대상인가 |
|
||||
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
|
||||
| 역할 변환 | 어떤 권한을 갖는가 | — |
|
||||
|
||||
## expected issuer와 JWK URL이 다른 이유
|
||||
|
||||
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
|
||||
|
||||
```text label="issuer와 JWK URL"
|
||||
expected issuer = http://localhost:8080/realms/keycloak-patterns
|
||||
JWK URL = http://keycloak:8080/.../certs
|
||||
```
|
||||
|
||||
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
|
||||
|
||||
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
|
||||
|
||||
## audience 검증
|
||||
|
||||
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
|
||||
|
||||
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
|
||||
|
||||
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
|
||||
|
||||
## realm role이 authority가 되는 변환
|
||||
|
||||
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_`을 붙인다.
|
||||
|
||||
```text label="role 변환"
|
||||
realm_access.roles: ["user-role"]
|
||||
→ ROLE_user-role
|
||||
```
|
||||
|
||||
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
|
||||
|
||||
## 인증과 인가는 다른 endpoint에서 갈린다
|
||||
|
||||
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
|
||||
|
||||
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
|
||||
|
||||
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
||||
|
||||
```json label="ApiController가 반환하는 JSON"
|
||||
{
|
||||
"subject": "<keycloak-user-sub>",
|
||||
"username": "regular-user",
|
||||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||||
}
|
||||
```
|
||||
|
||||
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
|
||||
|
||||
<!-- body:end -->
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
id: 87000d59-b69f-4010-9481-0b71c8bde32d
|
||||
kind: CONCEPT
|
||||
slug: bearer-jwt-validation-chain
|
||||
title: Bearer JWT가 인증된 principal이 되기까지
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
|
||||
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
|
||||
assets:
|
||||
- key: bearer-jwt-validation-chain
|
||||
file: ../../../final/assets/bearer-jwt-validation-chain/bearer-jwt-validation-chain.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
---
|
||||
|
||||
# Bearer JWT가 인증된 principal이 되기까지
|
||||
|
||||
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 이 헤더를 직접 만든 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Resource Server가 받는 입력
|
||||
|
||||
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다.
|
||||
|
||||
```http label="Resource Server 입력"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
|
||||
|
||||
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
|
||||
|
||||
## 변환 순서
|
||||
|
||||
검증과 변환은 다음 순서로 이어진다.
|
||||
|
||||
:::evidence key="bearer-jwt-validation-chain" alt="Bearer JWT 입력이 JwtDecoder, issuer·시간 검증, audience 검증, role converter를 거쳐 authenticated principal이 되는 검증 사슬." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
|
||||
|
||||
## 서명 확인 하나로 끝내지 않는 이유
|
||||
|
||||
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
|
||||
|
||||
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|
||||
|---|---|---|
|
||||
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
|
||||
| issuer | 기대한 realm인가 | 아직 유효한가 |
|
||||
| timestamp | 만료 전인가 | 이 API가 대상인가 |
|
||||
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
|
||||
| 역할 변환 | 어떤 권한을 갖는가 | — |
|
||||
|
||||
## expected issuer와 JWK URL이 다른 이유
|
||||
|
||||
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
|
||||
|
||||
```text label="issuer와 JWK URL"
|
||||
expected issuer = http://localhost:8080/realms/keycloak-patterns
|
||||
JWK URL = http://keycloak:8080/.../certs
|
||||
```
|
||||
|
||||
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
|
||||
|
||||
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
|
||||
|
||||
## audience 검증
|
||||
|
||||
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
|
||||
|
||||
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
|
||||
|
||||
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
|
||||
|
||||
## realm role이 authority가 되는 변환
|
||||
|
||||
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_`을 붙인다.
|
||||
|
||||
```text label="role 변환"
|
||||
realm_access.roles: ["user-role"]
|
||||
→ ROLE_user-role
|
||||
```
|
||||
|
||||
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
|
||||
|
||||
## 인증과 인가는 다른 endpoint에서 갈린다
|
||||
|
||||
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
|
||||
|
||||
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
|
||||
|
||||
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
||||
|
||||
```json label="ApiController가 반환하는 JSON"
|
||||
{
|
||||
"subject": "<keycloak-user-sub>",
|
||||
"username": "regular-user",
|
||||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||||
}
|
||||
```
|
||||
|
||||
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "4809284ed5c21b74c35964f958526572d1a02c57c6eaca41d8d6047a620cb5bd",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
---
|
||||
id: 87000d59-b69f-4010-9481-0b71c8bde32d
|
||||
kind: CONCEPT
|
||||
slug: bearer-jwt-validation-chain
|
||||
title: Bearer JWT가 인증된 principal이 되기까지
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · Spring Security OAuth2 Resource Server
|
||||
studio: "https://hyeonworks.com/studio/documents/87000d59-b69f-4010-9481-0b71c8bde32d/edit"
|
||||
assets:
|
||||
- key: bearer-jwt-validation-chain
|
||||
file: ../../../final/assets/bearer-jwt-validation-chain/bearer-jwt-validation-chain.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-ap1-완주
|
||||
---
|
||||
|
||||
# Bearer JWT가 인증된 principal이 되기까지
|
||||
|
||||
Resource Server가 받는 입력은 `Authorization` 헤더에 실려 온 문자열 하나다. 이 문자열은 서명 검증, issuer와 시간 검증, audience 검증, 역할 변환을 차례로 지나야 인증된 요청 주체(principal)가 된다. OAuth 2.0과 JWT를 한 번이라도 다뤄 본 사람을 대상으로, 먼저 Resource Server가 받는 입력부터 이 사슬이 단계마다 무엇을 확인하고 무엇을 다음 단계로 넘기는지 따라간다. 서명 검증을 통과했다고 이 API를 위해 발급된 토큰인 것은 아니고, 그 확인은 audience 검증이 따로 맡는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
이 검증을 통과한 JWT와 애플리케이션 세션은 다른 상태다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
이 JWT가 어느 엔드포인트에서 발급되는지 정리한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
브라우저가 이 헤더를 직접 만든 구성이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## Resource Server가 받는 입력
|
||||
|
||||
브라우저가 보내든 BFF(Backend For Frontend, 프런트엔드 전용 백엔드)가 보내든 요청의 모양은 같다.
|
||||
|
||||
```http label="Resource Server 입력"
|
||||
GET http://localhost:8081/api/me
|
||||
Authorization: Bearer <access-token>
|
||||
```
|
||||
|
||||
Spring이 받는 것은 이 헤더에 실려 온 문자열 그대로다. 요청마다 JWT로 인증하고 애플리케이션 세션을 만들지 않으려고 `SecurityConfig.apiSecurity()`는 CORS를 켜고 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 보호를 끄며 `SessionCreationPolicy.STATELESS`를 선택한다.
|
||||
|
||||
세션을 만들지 않으니 로그아웃하는 순간에 서버에서 지울 상태도 없다. 이미 발급된 JWT는 필요한 값을 자기 안에 담고 있어서 만료 전까지 유효하고, 짧은 TTL(Time To Live, 유효 시간)과 검증기가 그 범위를 좁힌다.
|
||||
|
||||
## 변환 순서
|
||||
|
||||
검증과 변환은 다음 순서로 이어진다.
|
||||
|
||||
:::evidence key="bearer-jwt-validation-chain" alt="Bearer JWT 입력이 JwtDecoder, issuer·시간 검증, audience 검증, role converter를 거쳐 authenticated principal이 되는 검증 사슬." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
헤더를 꺼내 JWT 인증 제공자에 넘기고 설정된 디코더를 부르는 일은 Spring OAuth2 Resource Server가 한다. 저장소의 코드는 Spring 내부 필터를 직접 만들지 않으므로, 이 순서에는 설정 DSL(Domain Specific Language, 설정 전용 문법)이 붙여 주는 부분과 직접 만들어 끼운 빈이 함께 들어 있다.
|
||||
|
||||
## 서명 확인 하나로 끝내지 않는 이유
|
||||
|
||||
서명이 맞다는 것은 그 IdP(Identity Provider, 인증 제공자)가 발급했다는 뜻이다. 서명 검증에 쓰는 JWK(JSON Web Key, 공개키를 JSON으로 표현한 형식)는 그 IdP의 공개키를 담고 있을 뿐이어서, 같은 IdP가 다른 API를 위해 발급한 토큰도 서명은 똑같이 맞다. 그래서 서명만 확인하고 끝내지 않는다.
|
||||
|
||||
| 확인 단계 | 확인하는 것 | 이 단계가 답하지 못하는 것 |
|
||||
|---|---|---|
|
||||
| JWK signature | 이 realm이 발급했는가 | 어느 API를 위한 토큰인가 |
|
||||
| issuer | 기대한 realm인가 | 아직 유효한가 |
|
||||
| timestamp | 만료 전인가 | 이 API가 대상인가 |
|
||||
| audience | 이 API를 위해 발급됐는가 | 무엇을 할 수 있는가 |
|
||||
| 역할 변환 | 어떤 권한을 갖는가 | — |
|
||||
|
||||
## expected issuer와 JWK URL이 다른 이유
|
||||
|
||||
두 값은 같은 realm을 가리키지만 쓰임이 다르다.
|
||||
|
||||
```text label="issuer와 JWK URL"
|
||||
expected issuer = http://localhost:8080/realms/keycloak-patterns
|
||||
JWK URL = http://keycloak:8080/.../certs
|
||||
```
|
||||
|
||||
expected issuer는 토큰 안에 적혀 브라우저까지 보이는 값이다. 브라우저가 도달하는 주소로 발급됐으니 클레임을 검증하는 기준도 그 주소여야 한다. JWK URL은 공개키를 가져오는 컨테이너 네트워크 경로이고, Resource Server는 같은 Docker 네트워크 안에서 서비스 이름으로 Keycloak에 도달한다.
|
||||
|
||||
두 값을 같게 맞추려고 expected issuer를 컨테이너 주소로 바꾸면 브라우저가 받은 토큰의 `iss`와 어긋난다.
|
||||
|
||||
## audience 검증
|
||||
|
||||
audience는 이 토큰이 어느 API를 위해 발급됐는지 담는 값이다. `AudienceValidator`는 `jwt.getAudience()`에 `keycloak-pattern-api`가 들어 있는지 확인하고, 없으면 `invalid_token` 결과를 만든다.
|
||||
|
||||
정상적으로 발급받은 같은 JWT를 expected audience가 다른 진단용 Resource Server에 그대로 제출하면 401이 돌아온다. expected issuer가 다른 서버도 마찬가지로 401이다.
|
||||
|
||||
다만 이 두 서버가 재 준 범위는 audience 검증과 issuer 검증까지다. 서명이 틀린 JWT나 만료된 JWT를 넣는 전용 E2E 계약은 만들지 않았다. 단위 테스트에 합성 JWT를 주입해 컨트롤러가 200을 반환하는 것은 확인할 수 있지만, 그 200은 그 요청이 실제 `NimbusJwtDecoder`의 서명 검증과 issuer 검증을 지났다는 증거가 아니다. 사슬의 앞 두 단계는 이 계약 안에서 부정 입력으로 확인하지 않았다.
|
||||
|
||||
## realm role이 authority가 되는 변환
|
||||
|
||||
`KeycloakRealmRoleConverter`는 `realm_access.roles`에 들어 있는 문자열을 골라 앞에 `ROLE_`을 붙인다.
|
||||
|
||||
```text label="role 변환"
|
||||
realm_access.roles: ["user-role"]
|
||||
→ ROLE_user-role
|
||||
```
|
||||
|
||||
Spring Security의 `hasRole("user-role")`이 `ROLE_user-role`이라는 이름의 권한을 찾기 때문에 이 접두사가 필요하다.
|
||||
|
||||
## 인증과 인가는 다른 endpoint에서 갈린다
|
||||
|
||||
`/api/me`는 특정 역할을 요구하지 않고 `.authenticated()`만 요구한다. 그래서 역할 클레임이 없어 변환 결과가 빈 목록이어도, JWT가 유효하기만 하면 `/api/me`는 통과한다.
|
||||
|
||||
`admin-role`의 효과는 `/api/admin`에서 나타난다. 일반 사용자는 403, 관리자 사용자는 200이다.
|
||||
|
||||
`ApiController`는 검증을 마친 JWT에서 값을 꺼내 사용자 JSON을 만든다.
|
||||
|
||||
```json label="ApiController가 반환하는 JSON"
|
||||
{
|
||||
"subject": "<keycloak-user-sub>",
|
||||
"username": "regular-user",
|
||||
"issuer": "http://localhost:8080/realms/keycloak-patterns",
|
||||
"audience": ["<possibly-other-audiences>", "keycloak-pattern-api"]
|
||||
}
|
||||
```
|
||||
|
||||
이 JSON의 `regular-user`는 프록시가 만들어 붙이는 identity header에도 똑같이 들어갈 수 있는 값이다. 화면에 보이는 이름은 같아도 그 값을 무엇이 보증했는지는 다르다. 여기서는 서명과 issuer, audience를 확인한 JWT에서 꺼냈다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"sourceSha256": "4809284ed5c21b74c35964f958526572d1a02c57c6eaca41d8d6047a620cb5bd",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-bearer-jwt-validation-chain.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-05-concept-browser-credential-storage",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"startedAt": "2026-09-19T10:28:02+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:04+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md -o runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:02+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 이미 반영된 Record 결과를 재검증하는 작업이며 새 관계·순서·측정 주장을 추가하지 않았다. 기존 asset 배정이 있으면 그대로 재사용하고 현재 12개 SVG를 다시 그리지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:02+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:03+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:03+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:04+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:04+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:04+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md -o runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S3/command-initial.json",
|
||||
"sha256": "73401f61b6455b3057a127c37e80a6b042f92af1976df99e87b9d680baef7dc8"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md -o runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/S6/command-final.json",
|
||||
"sha256": "73401f61b6455b3057a127c37e80a6b042f92af1976df99e87b9d680baef7dc8"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "fafda4e3edfc0a137c0e13645cef61b08b3b0bc79e91876e38a9ad8828b7356c",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-05-concept-browser-credential-storage/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "786c10390dd189860efd54ddbdc2d99385514b2c89561724a680b67099ee98f4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:02+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:03+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:04+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "fafda4e3edfc0a137c0e13645cef61b08b3b0bc79e91876e38a9ad8828b7356c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
|
||||
kind: CONCEPT
|
||||
slug: browser-credential-storage
|
||||
title: 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts · oauth2-proxy 7.15.2
|
||||
studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07/edit"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
|
||||
---
|
||||
|
||||
# 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
|
||||
브라우저에는 JavaScript 메모리, Session Storage, Local Storage, 쿠키가 있고 각각 수명과 접근 경로가 다르다. 자격 증명(credential)을 어디에 두느냐에 따라 새로고침 뒤에 무엇이 유지되는지, JavaScript가 무엇을 읽을 수 있는지, 어떤 값이 요청에 자동으로 실리는지가 갈린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
여기 있는 값들을 어떤 이름으로 갈라 부르는지 정한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
memory-only 구성을 실제로 확인한 기록이다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
브라우저에 세션 쿠키만 두는 구조의 설계 항목이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 네 위치의 성질
|
||||
|
||||
네 곳을 수명, JavaScript의 접근 여부, 요청 자동 첨부 세 가지로 갈라 보면 이렇다. 표의 `HttpOnly`는 JavaScript가 쿠키 값을 직접 읽지 못하게 하는 쿠키 속성인데, 나머지 성질은 뒤에서 따로 살펴보겠다.
|
||||
|
||||
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|
||||
|---|---|---|---|
|
||||
| JavaScript 메모리 | 초기화 | 읽는다 | 붙지 않는다 |
|
||||
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
|
||||
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
|
||||
| HttpOnly 쿠키 | 만료까지 유지 | 읽지 못한다 | 붙는다 |
|
||||
|
||||
네 곳 가운데 쿠키만 요청에 자동으로 붙기 때문에, 쿠키를 자격 증명으로 쓰면 공격 사이트가 쿠키만 이용해 만든 cross-site forged request와 애플리케이션이 anti-CSRF token을 가지고 만든 요청을 구분하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 따라붙는다.
|
||||
|
||||
## userStore와 stateStore를 나눈다
|
||||
|
||||
oidc-client-ts의 `UserManager`는 저장소를 두 개 따로 받는다. AP1은 이렇게 설정했다.
|
||||
|
||||
```text label="AP1의 UserManager 저장소 설정"
|
||||
userStore = InMemoryWebStorage
|
||||
stateStore = sessionStorage
|
||||
```
|
||||
|
||||
`userStore`는 로그인 뒤 `User`와 토큰 묶음을 보관하고, `stateStore`는 리다이렉트를 건너야 하는 authorization transaction을 보관한다.
|
||||
|
||||
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|
||||
|---|---|---|
|
||||
| userStore | `User`, access·refresh·ID token, 만료 시각, 프로필 | 로그인 상태가 유지되는 동안 |
|
||||
| stateStore | `state`, PKCE verifier | 콜백 처리가 끝날 때까지 |
|
||||
|
||||
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 메모리에 둘 수 없다. 이 두 값이 Session Storage에 있는 것과 토큰이 Web Storage에 있는 것은 서로 다른 설정이다.
|
||||
|
||||
## memory-only가 뜻하는 범위
|
||||
|
||||
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 `User`와 토큰이 초기화되고, Local Storage와 Session Storage에도 토큰 복사본이 만들어지지 않는다.
|
||||
|
||||
이 설정은 저장 위치를 견준 뒤에 고른 것이다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침은 편해지지만 노출 시간도 길어진다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸면 끝나는 작업이 아니라 서버가 세션이나 토큰 중계를 맡는 AP3 계열 구조가 있어야 한다. refresh token만 mediator로 옮기는 AP2도 함께 검토했지만, 두 구조 모두 브라우저가 code를 교환하고 토큰 수명을 관리하는 모습을 가린다. AP1은 그 과정을 보여 주려고 access·refresh·ID token을 JavaScript 메모리에 두었고, 새로고침 뒤에 인증 상태를 복구하지 못하는 것을 감수했다.
|
||||
|
||||
memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중인 스크립트가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저의 `fetch`를 가로채면 API 호출에 실린 Bearer access token을 그대로 관측할 수 있다.
|
||||
|
||||
```text label="관측한 두 결과"
|
||||
Local Storage · Session Storage → access token 문자열 없음
|
||||
실행 중 fetch hook → Authorization: Bearer 관측됨
|
||||
```
|
||||
|
||||
AP2에도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 되고, 세 경계를 지나는 동안 영구 저장소에는 쓰이지 않는다.
|
||||
|
||||
## HttpOnly cookie
|
||||
|
||||
`HttpOnly` 쿠키는 `document.cookie`로 조회되지 않지만, 브라우저는 요청마다 이 값을 자동으로 붙여 보낸다.
|
||||
|
||||
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. AP3와 AP4는 브라우저 JavaScript에 OAuth 토큰을 넘기지 않는다. 브라우저를 확인하면 HttpOnly 세션 쿠키가 남아 있고 요청할 때마다 자동으로 붙는다. 그래서 이 기록에서 「브라우저에 없다」는 말은 애플리케이션이 쓰는 OAuth 토큰에만 쓴다. 인증 상태 자체는 이 쿠키가 들고 있다.
|
||||
|
||||
Keycloak 도메인의 SSO 쿠키도 별도로 존재할 수 있다. 애플리케이션 메모리의 `User`가 사라진 것과 IdP(Identity Provider) 세션이 끝난 것은 다른 사건이다.
|
||||
|
||||
## opaque cookie
|
||||
|
||||
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
|
||||
|
||||
AP2와 AP3의 세션 쿠키는 서버 쪽 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 쿠키 안에는 없다.
|
||||
|
||||
AP4에서 `session-cookie-minimal=true`를 쓰면 서버 쪽 세션 저장소 없이 엣지가 필요로 하는 최소 정보만 쿠키 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수 없다.
|
||||
|
||||
AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
|
||||
|
||||
## 학습 환경의 cookie 속성을 일반화하지 않는다
|
||||
|
||||
지금 구성은 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTPS가 아니라 HTTP를 쓰고 있어서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
|
||||
|
||||
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
|
||||
kind: CONCEPT
|
||||
slug: browser-credential-storage
|
||||
title: 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts · oauth2-proxy 7.15.2
|
||||
studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07/edit"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
|
||||
---
|
||||
|
||||
# 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
|
||||
브라우저에는 JavaScript 메모리, Session Storage, Local Storage, 쿠키가 있고 각각 수명과 접근 경로가 다르다. 자격 증명(credential)을 어디에 두느냐에 따라 새로고침 뒤에 무엇이 유지되는지, JavaScript가 무엇을 읽을 수 있는지, 어떤 값이 요청에 자동으로 실리는지가 갈린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
여기 있는 값들을 어떤 이름으로 갈라 부르는지 정한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
memory-only 구성을 실제로 확인한 기록이다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
브라우저에 세션 쿠키만 두는 구조의 설계 항목이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 네 위치의 성질
|
||||
|
||||
네 곳을 수명, JavaScript의 접근 여부, 요청 자동 첨부 세 가지로 갈라 보면 이렇다. 표의 `HttpOnly`는 JavaScript가 쿠키 값을 직접 읽지 못하게 하는 쿠키 속성인데, 나머지 성질은 뒤에서 따로 살펴보겠다.
|
||||
|
||||
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|
||||
|---|---|---|---|
|
||||
| JavaScript 메모리 | 초기화 | 읽는다 | 붙지 않는다 |
|
||||
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
|
||||
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
|
||||
| HttpOnly 쿠키 | 만료까지 유지 | 읽지 못한다 | 붙는다 |
|
||||
|
||||
네 곳 가운데 쿠키만 요청에 자동으로 붙기 때문에, 쿠키를 자격 증명으로 쓰면 공격 사이트가 쿠키만 이용해 만든 cross-site forged request와 애플리케이션이 anti-CSRF token을 가지고 만든 요청을 구분하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 따라붙는다.
|
||||
|
||||
## userStore와 stateStore를 나눈다
|
||||
|
||||
oidc-client-ts의 `UserManager`는 저장소를 두 개 따로 받는다. AP1은 이렇게 설정했다.
|
||||
|
||||
```text label="AP1의 UserManager 저장소 설정"
|
||||
userStore = InMemoryWebStorage
|
||||
stateStore = sessionStorage
|
||||
```
|
||||
|
||||
`userStore`는 로그인 뒤 `User`와 토큰 묶음을 보관하고, `stateStore`는 리다이렉트를 건너야 하는 authorization transaction을 보관한다.
|
||||
|
||||
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|
||||
|---|---|---|
|
||||
| userStore | `User`, access·refresh·ID token, 만료 시각, 프로필 | 로그인 상태가 유지되는 동안 |
|
||||
| stateStore | `state`, PKCE verifier | 콜백 처리가 끝날 때까지 |
|
||||
|
||||
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 메모리에 둘 수 없다. 이 두 값이 Session Storage에 있는 것과 토큰이 Web Storage에 있는 것은 서로 다른 설정이다.
|
||||
|
||||
## memory-only가 뜻하는 범위
|
||||
|
||||
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 `User`와 토큰이 초기화되고, Local Storage와 Session Storage에도 토큰 복사본이 만들어지지 않는다.
|
||||
|
||||
이 설정은 저장 위치를 견준 뒤에 고른 것이다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침은 편해지지만 노출 시간도 길어진다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸면 끝나는 작업이 아니라 서버가 세션이나 토큰 중계를 맡는 AP3 계열 구조가 있어야 한다. refresh token만 mediator로 옮기는 AP2도 함께 검토했지만, 두 구조 모두 브라우저가 code를 교환하고 토큰 수명을 관리하는 모습을 가린다. AP1은 그 과정을 보여 주려고 access·refresh·ID token을 JavaScript 메모리에 두었고, 새로고침 뒤에 인증 상태를 복구하지 못하는 것을 감수했다.
|
||||
|
||||
memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중인 스크립트가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저의 `fetch`를 가로채면 API 호출에 실린 Bearer access token을 그대로 관측할 수 있다.
|
||||
|
||||
```text label="관측한 두 결과"
|
||||
Local Storage · Session Storage → access token 문자열 없음
|
||||
실행 중 fetch hook → Authorization: Bearer 관측됨
|
||||
```
|
||||
|
||||
AP2에도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 되고, 세 경계를 지나는 동안 영구 저장소에는 쓰이지 않는다.
|
||||
|
||||
## HttpOnly cookie
|
||||
|
||||
`HttpOnly` 쿠키는 `document.cookie`로 조회되지 않지만, 브라우저는 요청마다 이 값을 자동으로 붙여 보낸다.
|
||||
|
||||
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. AP3와 AP4는 브라우저 JavaScript에 OAuth 토큰을 넘기지 않는다. 브라우저를 확인하면 HttpOnly 세션 쿠키가 남아 있고 요청할 때마다 자동으로 붙는다. 그래서 이 기록에서 「브라우저에 없다」는 말은 애플리케이션이 쓰는 OAuth 토큰에만 쓴다. 인증 상태 자체는 이 쿠키가 들고 있다.
|
||||
|
||||
Keycloak 도메인의 SSO 쿠키도 별도로 존재할 수 있다. 애플리케이션 메모리의 `User`가 사라진 것과 IdP(Identity Provider) 세션이 끝난 것은 다른 사건이다.
|
||||
|
||||
## opaque cookie
|
||||
|
||||
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
|
||||
|
||||
AP2와 AP3의 세션 쿠키는 서버 쪽 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 쿠키 안에는 없다.
|
||||
|
||||
AP4에서 `session-cookie-minimal=true`를 쓰면 서버 쪽 세션 저장소 없이 엣지가 필요로 하는 최소 정보만 쿠키 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수 없다.
|
||||
|
||||
AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
|
||||
|
||||
## 학습 환경의 cookie 속성을 일반화하지 않는다
|
||||
|
||||
지금 구성은 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTPS가 아니라 HTTP를 쓰고 있어서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
|
||||
|
||||
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "fafda4e3edfc0a137c0e13645cef61b08b3b0bc79e91876e38a9ad8828b7356c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
---
|
||||
id: bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07
|
||||
kind: CONCEPT
|
||||
slug: browser-credential-storage
|
||||
title: 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 · oidc-client-ts · oauth2-proxy 7.15.2
|
||||
studio: "https://hyeonworks.com/studio/documents/bb5c37ae-2d94-48f7-ad4e-a37c61c3fd07/edit"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-브라우저에-없다
|
||||
---
|
||||
|
||||
# 브라우저가 credential을 보관하는 위치와 그 성질
|
||||
|
||||
브라우저에는 JavaScript 메모리, Session Storage, Local Storage, 쿠키가 있고 각각 수명과 접근 경로가 다르다. 자격 증명(credential)을 어디에 두느냐에 따라 새로고침 뒤에 무엇이 유지되는지, JavaScript가 무엇을 읽을 수 있는지, 어떤 값이 요청에 자동으로 실리는지가 갈린다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
여기 있는 값들을 어떤 이름으로 갈라 부르는지 정한 기록이다.
|
||||
- **SPA에서 토큰을 직접 관리하면서 드러난 Browser Credential 경계**
|
||||
memory-only 구성을 실제로 확인한 기록이다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
브라우저에 세션 쿠키만 두는 구조의 설계 항목이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 네 위치의 성질
|
||||
|
||||
네 곳을 수명, JavaScript의 접근 여부, 요청 자동 첨부 세 가지로 갈라 보면 이렇다. 표의 `HttpOnly`는 JavaScript가 쿠키 값을 직접 읽지 못하게 하는 쿠키 속성인데, 나머지 성질은 뒤에서 따로 살펴보겠다.
|
||||
|
||||
| 위치 | 새로고침 뒤 | JavaScript가 읽나 | 요청에 자동으로 붙나 |
|
||||
|---|---|---|---|
|
||||
| JavaScript 메모리 | 초기화 | 읽는다 | 붙지 않는다 |
|
||||
| Session Storage | 탭이 살아 있으면 유지 | 읽는다 | 붙지 않는다 |
|
||||
| Local Storage | 유지 | 읽는다 | 붙지 않는다 |
|
||||
| HttpOnly 쿠키 | 만료까지 유지 | 읽지 못한다 | 붙는다 |
|
||||
|
||||
네 곳 가운데 쿠키만 요청에 자동으로 붙기 때문에, 쿠키를 자격 증명으로 쓰면 공격 사이트가 쿠키만 이용해 만든 cross-site forged request와 애플리케이션이 anti-CSRF token을 가지고 만든 요청을 구분하는 CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 검증이 따라붙는다.
|
||||
|
||||
## userStore와 stateStore를 나눈다
|
||||
|
||||
oidc-client-ts의 `UserManager`는 저장소를 두 개 따로 받는다. AP1은 이렇게 설정했다.
|
||||
|
||||
```text label="AP1의 UserManager 저장소 설정"
|
||||
userStore = InMemoryWebStorage
|
||||
stateStore = sessionStorage
|
||||
```
|
||||
|
||||
`userStore`는 로그인 뒤 `User`와 토큰 묶음을 보관하고, `stateStore`는 리다이렉트를 건너야 하는 authorization transaction을 보관한다.
|
||||
|
||||
| 저장소 | 들어가는 것 | 언제까지 필요한가 |
|
||||
|---|---|---|
|
||||
| userStore | `User`, access·refresh·ID token, 만료 시각, 프로필 | 로그인 상태가 유지되는 동안 |
|
||||
| stateStore | `state`, PKCE verifier | 콜백 처리가 끝날 때까지 |
|
||||
|
||||
`state`와 verifier는 Keycloak 왕복을 건너야 하므로 메모리에 둘 수 없다. 이 두 값이 Session Storage에 있는 것과 토큰이 Web Storage에 있는 것은 서로 다른 설정이다.
|
||||
|
||||
## memory-only가 뜻하는 범위
|
||||
|
||||
`InMemoryWebStorage`는 로그인 결과를 브라우저의 영구 저장소가 아니라 실행 중 메모리에만 둔다. 그래서 새로고침하면 `User`와 토큰이 초기화되고, Local Storage와 Session Storage에도 토큰 복사본이 만들어지지 않는다.
|
||||
|
||||
이 설정은 저장 위치를 견준 뒤에 고른 것이다. Local Storage나 Session Storage에 토큰을 저장하면 새로고침은 편해지지만 노출 시간도 길어진다. HttpOnly 쿠키로 옮기는 것은 저장 위치만 바꾸면 끝나는 작업이 아니라 서버가 세션이나 토큰 중계를 맡는 AP3 계열 구조가 있어야 한다. refresh token만 mediator로 옮기는 AP2도 함께 검토했지만, 두 구조 모두 브라우저가 code를 교환하고 토큰 수명을 관리하는 모습을 가린다. AP1은 그 과정을 보여 주려고 access·refresh·ID token을 JavaScript 메모리에 두었고, 새로고침 뒤에 인증 상태를 복구하지 못하는 것을 감수했다.
|
||||
|
||||
memory-only는 영구 저장소에 쓰지 않는다는 뜻이지, 실행 중인 스크립트가 응답이나 지역 변수를 읽을 수 없다는 뜻은 아니다. 브라우저의 `fetch`를 가로채면 API 호출에 실린 Bearer access token을 그대로 관측할 수 있다.
|
||||
|
||||
```text label="관측한 두 결과"
|
||||
Local Storage · Session Storage → access token 문자열 없음
|
||||
실행 중 fetch hook → Authorization: Bearer 관측됨
|
||||
```
|
||||
|
||||
AP2에도 같은 구분이 필요하다. `/token/access` 응답의 access token은 JavaScript 지역 변수로 들어갔다가 다음 요청 헤더가 되고, 세 경계를 지나는 동안 영구 저장소에는 쓰이지 않는다.
|
||||
|
||||
## HttpOnly cookie
|
||||
|
||||
`HttpOnly` 쿠키는 `document.cookie`로 조회되지 않지만, 브라우저는 요청마다 이 값을 자동으로 붙여 보낸다.
|
||||
|
||||
AP2의 `AP2_SESSION`, AP3의 `AP3_SESSION`, AP4의 `AP4_SESSION`이 모두 HttpOnly다. AP3와 AP4는 브라우저 JavaScript에 OAuth 토큰을 넘기지 않는다. 브라우저를 확인하면 HttpOnly 세션 쿠키가 남아 있고 요청할 때마다 자동으로 붙는다. 그래서 이 기록에서 「브라우저에 없다」는 말은 애플리케이션이 쓰는 OAuth 토큰에만 쓴다. 인증 상태 자체는 이 쿠키가 들고 있다.
|
||||
|
||||
Keycloak 도메인의 SSO 쿠키도 별도로 존재할 수 있다. 애플리케이션 메모리의 `User`가 사라진 것과 IdP(Identity Provider) 세션이 끝난 것은 다른 사건이다.
|
||||
|
||||
## opaque cookie
|
||||
|
||||
opaque는 내부 값을 브라우저가 해석하지 않고 그대로 돌려준다는 뜻이다.
|
||||
|
||||
AP2와 AP3의 세션 쿠키는 서버 쪽 상태를 찾는 열쇠다. 실제 access token과 refresh token은 authorized-client store에 있고 쿠키 안에는 없다.
|
||||
|
||||
AP4에서 `session-cookie-minimal=true`를 쓰면 서버 쪽 세션 저장소 없이 엣지가 필요로 하는 최소 정보만 쿠키 자체에 담는다. access·refresh·ID token은 여기에 들어가지 않는다. 그래서 AP4가 refresh token을 지속해서 보관한다고 말할 수 없다.
|
||||
|
||||
AP2와 AP3의 쿠키는 서버 쪽 상태를 찾는 열쇠이고, AP4의 쿠키는 최소 상태를 담은 값이다.
|
||||
|
||||
## 학습 환경의 cookie 속성을 일반화하지 않는다
|
||||
|
||||
지금 구성은 쿠키 속성과 리다이렉트를 눈으로 확인하려고 HTTPS가 아니라 HTTP를 쓰고 있어서 `AP4_SESSION`의 `Secure`가 `false`다. 운영 HTTPS에서는 먼저 `Secure=true`를 설정해야 한다.
|
||||
|
||||
`Secure`, Domain, 만료를 로컬 YAML이 고정하지 않는 구성도 있다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"sourceSha256": "fafda4e3edfc0a137c0e13645cef61b08b3b0bc79e91876e38a9ad8828b7356c",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-browser-credential-storage.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-06-concept-cookie-auth-csrf",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"startedAt": "2026-09-19T10:28:04+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:06+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:04+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:04+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md -o runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:05+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "이번 remediation은 이미 반영된 Record 결과를 재검증하는 작업이며 새 관계·순서·측정 주장을 추가하지 않았다. 기존 asset 배정이 있으면 그대로 재사용하고 현재 12개 SVG를 다시 그리지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:05+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:05+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:06+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:06+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:06+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:06+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md -o runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S3/command-initial.json",
|
||||
"sha256": "26efdf19417cd3e986e95c1cc50efad94a682cd7960aed5e887177b3acf53e90"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md -o runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/S6/command-final.json",
|
||||
"sha256": "26efdf19417cd3e986e95c1cc50efad94a682cd7960aed5e887177b3acf53e90"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "7afa04c88add29d29a1dd9999fb5f9befd19d1dd53e1bc7e80b11c67f243700d",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-06-concept-cookie-auth-csrf/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "098edbdb9eae9b4fc066cac66bcfeaa63115fa7d2ca468b73f7fa6ca657a4210"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:04+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:05+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:05+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:06+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "7afa04c88add29d29a1dd9999fb5f9befd19d1dd53e1bc7e80b11c67f243700d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
|
||||
kind: CONCEPT
|
||||
slug: cookie-auth-csrf
|
||||
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
||||
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
||||
assets:
|
||||
- key: ap3-csrf-boundary
|
||||
file: ../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap3
|
||||
---
|
||||
|
||||
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
|
||||
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청에는 쿠키만으로 만든 cross-site forged request를 구분할 별도 검증 값이 필요하다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰은 예상한 anti-CSRF token이 함께 온 요청인지 검사하고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 확인이 필요한 구조의 설계 항목이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
이 동작을 실제로 재현한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## cookie가 credential이 되면 생기는 일
|
||||
|
||||
BFF(Backend for Frontend)는 브라우저에 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
|
||||
|
||||
이 쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
|
||||
|
||||
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
|
||||
|
||||
## token을 받아 오는 요청
|
||||
|
||||
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
|
||||
|
||||
```http label="CSRF token 요청"
|
||||
GET http://localhost:8083/bff/csrf
|
||||
Accept: application/json
|
||||
Cookie: AP3_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
|
||||
|
||||
```json label="CsrfController가 반환하는 JSON"
|
||||
{
|
||||
"headerName": "X-XSRF-TOKEN",
|
||||
"parameterName": "_csrf",
|
||||
"token": "<xor-masked-csrf-token>"
|
||||
}
|
||||
```
|
||||
|
||||
:::evidence key="ap3-csrf-boundary" alt="BFF의 /bff/csrf 하나에서 두 갈래가 갈리는 그림. Set-Cookie로 나가는 CSRF 쿠키에는 가리지 않은 원본 값이 들어가고 JSON 본문에는 가린 토큰과 headerName이 들어간다. 브라우저 코드는 JSON에서 headerName만 쓰고 실제 헤더 값은 쿠키의 원본 값을 쓴다. Spring CSRF filter가 raw cookie와 raw header를 대조해 일치하면 controller로 보내고 부재나 불일치면 403을 낸다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## body의 token과 cookie의 값은 다르다
|
||||
|
||||
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
|
||||
|
||||
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
|
||||
|
||||
| 위치 | 값 |
|
||||
|---|---|
|
||||
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
|
||||
| `XSRF-TOKEN` 쿠키 | 원본 값 |
|
||||
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
|
||||
|
||||
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
|
||||
|
||||
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
|
||||
|
||||
## 검증이 controller보다 먼저 일어난다
|
||||
|
||||
검증을 통과하는 상태 변경 요청은 다음과 같다.
|
||||
|
||||
```http label="CSRF 검증을 통과하는 POST"
|
||||
POST http://localhost:8083/bff/api/preferences
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
||||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||||
|
||||
theme=dark
|
||||
```
|
||||
|
||||
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
|
||||
|
||||
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
|
||||
|
||||
## SameSite가 정하는 것과 CSRF token이 정하는 것
|
||||
|
||||
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 cookie가 자동 첨부되는 상태 변경 요청에 별도 검증 값을 요구해 cross-site forged request를 구분하는 애플리케이션 규약이다.
|
||||
|
||||
| | SameSite | CSRF 토큰 |
|
||||
|---|---|---|
|
||||
| 누가 판단하나 | 브라우저 | 서버 |
|
||||
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
|
||||
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
|
||||
|
||||
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
|
||||
|
||||
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
|
||||
|
||||
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|
||||
|---|---|---|---|
|
||||
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
|
||||
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
|
||||
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
|
||||
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
|
||||
|
||||
## CSRF가 XSS를 대신하지 않는다
|
||||
|
||||
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
|
||||
|
||||
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
|
||||
kind: CONCEPT
|
||||
slug: cookie-auth-csrf
|
||||
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
||||
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
||||
assets:
|
||||
- key: ap3-csrf-boundary
|
||||
file: ../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap3
|
||||
---
|
||||
|
||||
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
|
||||
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청에는 쿠키만으로 만든 cross-site forged request를 구분할 별도 검증 값이 필요하다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰은 예상한 anti-CSRF token이 함께 온 요청인지 검사하고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 확인이 필요한 구조의 설계 항목이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
이 동작을 실제로 재현한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## cookie가 credential이 되면 생기는 일
|
||||
|
||||
BFF(Backend for Frontend)는 브라우저에 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
|
||||
|
||||
이 쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
|
||||
|
||||
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
|
||||
|
||||
## token을 받아 오는 요청
|
||||
|
||||
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
|
||||
|
||||
```http label="CSRF token 요청"
|
||||
GET http://localhost:8083/bff/csrf
|
||||
Accept: application/json
|
||||
Cookie: AP3_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
|
||||
|
||||
```json label="CsrfController가 반환하는 JSON"
|
||||
{
|
||||
"headerName": "X-XSRF-TOKEN",
|
||||
"parameterName": "_csrf",
|
||||
"token": "<xor-masked-csrf-token>"
|
||||
}
|
||||
```
|
||||
|
||||
:::evidence key="ap3-csrf-boundary" alt="BFF의 /bff/csrf 하나에서 두 갈래가 갈리는 그림. Set-Cookie로 나가는 CSRF 쿠키에는 가리지 않은 원본 값이 들어가고 JSON 본문에는 가린 토큰과 headerName이 들어간다. 브라우저 코드는 JSON에서 headerName만 쓰고 실제 헤더 값은 쿠키의 원본 값을 쓴다. Spring CSRF filter가 raw cookie와 raw header를 대조해 일치하면 controller로 보내고 부재나 불일치면 403을 낸다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## body의 token과 cookie의 값은 다르다
|
||||
|
||||
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
|
||||
|
||||
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
|
||||
|
||||
| 위치 | 값 |
|
||||
|---|---|
|
||||
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
|
||||
| `XSRF-TOKEN` 쿠키 | 원본 값 |
|
||||
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
|
||||
|
||||
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
|
||||
|
||||
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
|
||||
|
||||
## 검증이 controller보다 먼저 일어난다
|
||||
|
||||
검증을 통과하는 상태 변경 요청은 다음과 같다.
|
||||
|
||||
```http label="CSRF 검증을 통과하는 POST"
|
||||
POST http://localhost:8083/bff/api/preferences
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
||||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||||
|
||||
theme=dark
|
||||
```
|
||||
|
||||
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
|
||||
|
||||
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
|
||||
|
||||
## SameSite가 정하는 것과 CSRF token이 정하는 것
|
||||
|
||||
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 cookie가 자동 첨부되는 상태 변경 요청에 별도 검증 값을 요구해 cross-site forged request를 구분하는 애플리케이션 규약이다.
|
||||
|
||||
| | SameSite | CSRF 토큰 |
|
||||
|---|---|---|
|
||||
| 누가 판단하나 | 브라우저 | 서버 |
|
||||
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
|
||||
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
|
||||
|
||||
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
|
||||
|
||||
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
|
||||
|
||||
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|
||||
|---|---|---|---|
|
||||
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
|
||||
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
|
||||
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
|
||||
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
|
||||
|
||||
## CSRF가 XSS를 대신하지 않는다
|
||||
|
||||
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
|
||||
|
||||
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "7afa04c88add29d29a1dd9999fb5f9befd19d1dd53e1bc7e80b11c67f243700d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
---
|
||||
id: 5c8f12d5-1ead-469b-8e91-2de69401df48
|
||||
kind: CONCEPT
|
||||
slug: cookie-auth-csrf
|
||||
title: Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Spring Security 6 CSRF · AP3 BFF 구성
|
||||
studio: "https://hyeonworks.com/studio/documents/5c8f12d5-1ead-469b-8e91-2de69401df48/edit"
|
||||
assets:
|
||||
- key: ap3-csrf-boundary
|
||||
file: ../../../final/assets/ap3-csrf-boundary/ap3-csrf-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap3
|
||||
---
|
||||
|
||||
# Cookie로 인증하는 요청에서 CSRF token이 하는 일
|
||||
|
||||
세션 쿠키는 브라우저가 요청마다 자동으로 붙이기 때문에, 상태를 바꾸는 요청에는 쿠키만으로 만든 cross-site forged request를 구분할 별도 검증 값이 필요하다. CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 토큰은 예상한 anti-CSRF token이 함께 온 요청인지 검사하고, SameSite는 브라우저가 쿠키를 언제 보낼지 정하는 별도의 정책이다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 확인이 필요한 구조의 설계 항목이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
이 동작을 실제로 재현한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
세션 쿠키와 CSRF 토큰은 서로 다른 값이다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## cookie가 credential이 되면 생기는 일
|
||||
|
||||
BFF(Backend for Frontend)는 브라우저에 OAuth 토큰을 내려보내지 않는다. 대신 JavaScript가 읽을 수 없는 HttpOnly 세션 쿠키 하나로 로그인한 사용자를 알아본다.
|
||||
|
||||
이 쿠키를 요청에 붙이는 쪽은 애플리케이션 코드가 아니라 브라우저다. 다른 사이트가 만든 요청에도 같은 쿠키가 실릴 수 있다는 뜻이다. `GET /bff/api/me`처럼 읽기만 하는 요청에서는 이것이 문제로 보이지 않으므로, 값을 바꾸는 요청을 따로 봐야 한다.
|
||||
|
||||
브라우저에 OAuth 토큰을 내려보내지 않기로 하면서 로그인 상태가 서버로 옮겨 왔고, 값을 바꾸는 요청을 가려내는 일도 그때 BFF가 맡게 됐다. 먼저 브라우저가 토큰을 받아 오는 요청부터 본다.
|
||||
|
||||
## token을 받아 오는 요청
|
||||
|
||||
브라우저는 상태를 바꾸는 요청을 보내기 전에 CSRF 값부터 받아 온다.
|
||||
|
||||
```http label="CSRF token 요청"
|
||||
GET http://localhost:8083/bff/csrf
|
||||
Accept: application/json
|
||||
Cookie: AP3_SESSION=<opaque-session-id>
|
||||
```
|
||||
|
||||
`CookieCsrfTokenRepository.withHttpOnlyFalse()`는 JavaScript가 읽을 수 있는 `XSRF-TOKEN` 쿠키를 경로 `/`에 만든다. 컨트롤러는 다음 JSON을 반환한다.
|
||||
|
||||
```json label="CsrfController가 반환하는 JSON"
|
||||
{
|
||||
"headerName": "X-XSRF-TOKEN",
|
||||
"parameterName": "_csrf",
|
||||
"token": "<xor-masked-csrf-token>"
|
||||
}
|
||||
```
|
||||
|
||||
:::evidence key="ap3-csrf-boundary" alt="BFF의 /bff/csrf 하나에서 두 갈래가 갈리는 그림. Set-Cookie로 나가는 CSRF 쿠키에는 가리지 않은 원본 값이 들어가고 JSON 본문에는 가린 토큰과 headerName이 들어간다. 브라우저 코드는 JSON에서 headerName만 쓰고 실제 헤더 값은 쿠키의 원본 값을 쓴다. Spring CSRF filter가 raw cookie와 raw header를 대조해 일치하면 controller로 보내고 부재나 불일치면 403을 낸다." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
## body의 token과 cookie의 값은 다르다
|
||||
|
||||
같은 CSRF 값이 응답 본문, 쿠키, 요청 헤더 세 곳에 서로 다른 형태로 놓인다.
|
||||
|
||||
`XorCsrfTokenRequestAttributeHandler`가 요청 속성에 넣는 토큰을 XOR와 Base64로 가리기 때문에, 위 JSON에는 가려진 값이 담긴다. 쿠키 쪽은 다르다. `XSRF-TOKEN`에는 가리지 않은 원본 값이 들어간다. 그래서 SPA는 JSON에서 `headerName`만 읽고, 실제로 보낼 값은 `document.cookie`에서 `XSRF-TOKEN`을 찾아 쓴다.
|
||||
|
||||
| 위치 | 값 |
|
||||
|---|---|
|
||||
| 응답 본문의 `token` | XOR와 Base64로 가린 값 |
|
||||
| `XSRF-TOKEN` 쿠키 | 원본 값 |
|
||||
| POST의 `X-XSRF-TOKEN` 헤더 | 쿠키와 같은 원본 값 |
|
||||
|
||||
`SpaCsrfTokenRequestHandler`가 이 조합을 맞춘다. 서버가 기대하는 헤더가 요청에 있으면 제출된 원본 토큰을 그대로 읽고, 없으면 XOR resolver 경로를 쓴다.
|
||||
|
||||
응답 JSON의 `token`을 그대로 헤더에 복사하면 값이 맞지 않아 403이 된다.
|
||||
|
||||
## 검증이 controller보다 먼저 일어난다
|
||||
|
||||
검증을 통과하는 상태 변경 요청은 다음과 같다.
|
||||
|
||||
```http label="CSRF 검증을 통과하는 POST"
|
||||
POST http://localhost:8083/bff/api/preferences
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
Cookie: AP3_SESSION=<opaque-session-id>; XSRF-TOKEN=<raw-csrf-token>
|
||||
X-XSRF-TOKEN: <same-raw-csrf-token>
|
||||
|
||||
theme=dark
|
||||
```
|
||||
|
||||
컨트롤러에 닿기 전에 Spring CSRF 필터가 저장소에 있는 기대값과 제출된 헤더를 비교한다. 헤더가 없거나 값이 맞지 않으면 컨트롤러는 실행되지 않고 403이 된다. 검증하는 곳이 컨트롤러보다 앞이라, 엔드포인트를 새로 추가해도 같은 필터를 지난다.
|
||||
|
||||
다만 이 필터가 보는 것은 CSRF 토큰뿐이다. 경로별 인가는 이 예제의 자동 테스트가 확인하는 범위 밖이라, 새 엔드포인트가 같은 필터를 지났다는 것으로 그 엔드포인트의 인가까지 확인되지는 않는다.
|
||||
|
||||
## SameSite가 정하는 것과 CSRF token이 정하는 것
|
||||
|
||||
SameSite는 쿠키를 다른 사이트로 보낼지 브라우저가 정하는 정책이고, CSRF 토큰은 cookie가 자동 첨부되는 상태 변경 요청에 별도 검증 값을 요구해 cross-site forged request를 구분하는 애플리케이션 규약이다.
|
||||
|
||||
| | SameSite | CSRF 토큰 |
|
||||
|---|---|---|
|
||||
| 누가 판단하나 | 브라우저 | 서버 |
|
||||
| 무엇을 정하나 | 쿠키를 보낼지 | 요청을 받아들일지 |
|
||||
| 언제 작동하나 | 요청을 만들 때 | 요청을 처리할 때 |
|
||||
|
||||
포트가 달라도 사이트 계산상 같은 사이트로 잡히는 경우가 있어서 둘은 서로를 대신하지 못한다. SameSite가 쿠키를 빼지 않는 요청에도 CSRF 검증이 걸려야 한다.
|
||||
|
||||
네 가지 입력에서 쿠키와 CSRF 검증이 각각 어떻게 동작하는지는 다음과 같다.
|
||||
|
||||
| 입력 | 쿠키 동작 | CSRF 동작 | 결과 |
|
||||
|---|---|---|---|
|
||||
| same-origin, CSRF 헤더 없음 | 세션 쿠키 붙음 | 토큰이 없어 거부 | 403 |
|
||||
| same-origin, 원본 쿠키와 헤더 일치 | 세션 쿠키 붙음 | 토큰 일치 | 200 |
|
||||
| 다른 포트지만 same-site, 헤더 없음 | 쿠키가 붙을 수 있음 | 토큰이 없어 거부 | 403 |
|
||||
| cross-site POST | SameSite=Lax로 쿠키 제외 | 이후 서버 처리는 고정하지 않음 | 쿠키가 빠졌는지가 확인 지점 |
|
||||
|
||||
## CSRF가 XSS를 대신하지 않는다
|
||||
|
||||
브라우저에 OAuth 토큰을 주지 않아도 XSS가 무해해지지는 않는다. 같은 출처에서 실행되는 악성 스크립트는 피해자의 세션으로 BFF 엔드포인트를 그대로 부를 수 있고, JavaScript가 읽으라고 열어 둔 `XSRF-TOKEN`도 함께 읽을 수 있기 때문이다.
|
||||
|
||||
이 구조가 줄이는 것은 액세스 토큰과 리프레시 토큰의 원문이 스크립트로 새어 나가 다른 클라이언트나 직접 API 호출에 다시 쓰이는 범위다. CSP, 출력 인코딩, 의존성 무결성, 애플리케이션 인가는 이 구조가 대신 막아 주지 않으므로 각각 따로 세워야 한다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"sourceSha256": "7afa04c88add29d29a1dd9999fb5f9befd19d1dd53e1bc7e80b11c67f243700d",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-cookie-auth-csrf.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-07-concept-idp-brokering",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"startedAt": "2026-09-19T10:28:06+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:09+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:06+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:06+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md -o runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:06+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:07+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**그림의 사실은 SSOT 절이 댄다.** 기록은 SSOT 의 인용이라 줄 번호가 근거가 되지 못한다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/final/.techviz/idp-broker-upstream-downstream-boundary/spec.json",
|
||||
"docs/keycloak/final/assets/idp-broker-upstream-downstream-boundary/idp-broker-upstream-downstream-boundary.svg"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "TECHVIZ_HOME=/tmp/technical-visualization-haness scripts/techviz lint docs/keycloak/final/.techviz/idp-broker-upstream-downstream-boundary/spec.json --context docs/keycloak/final/.techviz/idp-broker-upstream-downstream-boundary/context.json --json",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-text.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-overlap.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/preview-figure.py --file docs/keycloak/final/assets/idp-broker-upstream-downstream-boundary/idp-broker-upstream-downstream-boundary.svg -o runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S4/preview",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "python3 scripts/check-figure-provenance.py keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "직전 수정에서 생성한 idp-broker-upstream-downstream-boundary 정본과 SVG를 current remediation에서 다시 검증했다. 새로 그리지 않았고 lint/text/overlap/provenance와 PNG preview를 다시 확인했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:08+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:08+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:09+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:09+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:09+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md -o runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S3/command-initial.json",
|
||||
"sha256": "e71be99c0ed55465fad9479abb5730290dd04906665c41177150249aef60884e"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md -o runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/S6/command-final.json",
|
||||
"sha256": "e71be99c0ed55465fad9479abb5730290dd04906665c41177150249aef60884e"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "079548c3c6511e9c6878e0409cb6407266148a21593bbc469bb94819382f8547",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-07-concept-idp-brokering/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "4ae583fb48d1d4d038236d6066497eb1ba591718e07fa6433d7ba5b7a2333704"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:06+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:06+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S4",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:07+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:08+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:08+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 29,
|
||||
"updatedAt": "2026-09-19T10:28:09+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "079548c3c6511e9c6878e0409cb6407266148a21593bbc469bb94819382f8547",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
|
||||
kind: CONCEPT
|
||||
slug: idp-brokering
|
||||
title: 외부 IdP Brokering의 동작
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 identity brokering
|
||||
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
|
||||
assets:
|
||||
- key: idp-broker-upstream-downstream-boundary
|
||||
file: ../../../final/assets/idp-broker-upstream-downstream-boundary/idp-broker-upstream-downstream-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
|
||||
---
|
||||
|
||||
# 외부 IdP Brokering의 동작
|
||||
|
||||
브로커링(brokering)은 브로커가 외부 IdP(Identity Provider)의 인증 결과를 대신 받아 검증하고, 자기 realm 안의 사용자와 연결하는 동작이다. 연결이 끝나면 브로커는 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 코드와 토큰은 언제나 브로커가 발급한 것이라, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer, 곧 그 토큰을 발급한 주체는 바뀌지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **외부 IdP 연동과 Application 인증 구조의 경계**
|
||||
이 동작을 경계 기준으로 정리한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
외부 IdP 세션과 애플리케이션 상태를 구분하는 기준이다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
브로커가 발급하는 코드가 지나는 endpoint다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 두 개의 OAuth 왕복이 이어진다
|
||||
|
||||
사용자가 브로커 로그인 화면에서 어느 외부 IdP로 로그인할지 고르면 인증 왕복이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이에서, 뒤의 왕복은 애플리케이션과 브로커 사이에서 일어난다. 브로커보다 앞에 있는 외부 IdP 쪽을 upstream이라고 부른다.
|
||||
|
||||
먼저 브라우저가 외부 IdP에 authorization 요청을 보내고, 브로커는 돌아온 응답을 검증해 자기 realm의 사용자와 연결한다. 그다음 애플리케이션으로 나가는 값은 브로커가 다시 만든다. Upstream IdP와 애플리케이션 경계는 다음처럼 이어진다.
|
||||
|
||||
:::evidence key="idp-broker-upstream-downstream-boundary" alt="Upstream IdP의 identity assertion이 Keycloak broker에서 local identity와 Keycloak authorization code로 바뀐 뒤 기존 AP1·AP2·AP3·AP4 경계로 이어지는 흐름." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
외부 IdP가 보낸 것은 첫 줄의 `Google identity assertion` 하나이고, 그 아래 `Keycloak local user/session`과 `Keycloak authorization code`는 브로커가 만든다.
|
||||
|
||||
## 애플리케이션이 상대하는 issuer는 그대로다
|
||||
|
||||
AP1의 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 주고받는 authorization code의 issuer도 브로커이며, AP4의 oauth2-proxy가 OIDC(OpenID Connect) 공급자로 연결하는 곳도 브로커다. 외부 IdP가 발급한 토큰은 애플리케이션까지 내려가지 않는다.
|
||||
|
||||
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|
||||
|---|---|---|
|
||||
| 외부 IdP | upstream identity assertion | 브로커 |
|
||||
| 브로커 | authorization code, access·ID token | 애플리케이션과 Resource Server |
|
||||
|
||||
그래서 소셜 로그인을 붙여도 브라우저가 토큰을 받는지, 어느 계층이 API를 부르는지는 달라지지 않는다. 그 둘은 AP1부터 AP4까지 네 패턴 중 무엇을 골랐는지가 정한다.
|
||||
|
||||
## account identity를 정하는 key
|
||||
|
||||
브로커가 외부 IdP의 사용자를 자기 realm의 사용자와 연결할 때 쓰는 안정적인 키는 provider alias와 upstream `sub`를 묶은 값이다. alias는 브로커에 등록한 외부 IdP마다 붙인 이름이고, `sub`는 그 IdP가 사용자 한 명에게 부여하는 고유 식별자다.
|
||||
|
||||
이메일은 이 키가 아니다. 외부 IdP가 보낸 이메일이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 이메일의 소유권을 증명하지 않은 채로 계정이 합쳐지기 때문이다. 계정 연결은 인증 구조와 떼어서 따로 설계할 항목이다.
|
||||
|
||||
## 경계를 섞으면 생기는 일
|
||||
|
||||
외부 IdP 연동을 다섯 번째 애플리케이션 인증 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 다르다. 그러면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
|
||||
|
||||
화면에서 어느 외부 IdP로 로그인할지 고르게 하거나 IdP마다 계정 연결을 다루는 것은 브로커가 하는 일이라 경계를 넘지 않는다. Resource Server의 토큰 검증이나 애플리케이션 인가가 외부 IdP별로 갈리기 시작하면, 브로커 경계가 애플리케이션까지 샜는지 확인한다. 외부 IdP의 토큰을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단도 함께 빠진다.
|
||||
|
||||
## 현재 검증한 범위
|
||||
|
||||
지금 자동화는 실제 Google 대신 controllable mock OIDC provider를 세워 브로커와 claim mapping 계약을 확인하도록 작성되어 있다. 실제 Google 계정과 공개 HTTPS redirect가 성공하는지는 증명하지 않았다. 사용자 동의와 운영 도메인 정책을 통과했다는 뜻도 아니다.
|
||||
|
||||
그래서 upstream IdP를 어디까지 검증했는지와 애플리케이션이 다루는 자격 증명 경계는 따로 적는다.
|
||||
|
||||
<!-- body:end -->
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
|
||||
kind: CONCEPT
|
||||
slug: idp-brokering
|
||||
title: 외부 IdP Brokering의 동작
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 identity brokering
|
||||
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
|
||||
assets:
|
||||
- key: idp-broker-upstream-downstream-boundary
|
||||
file: ../../../final/assets/idp-broker-upstream-downstream-boundary/idp-broker-upstream-downstream-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
|
||||
---
|
||||
|
||||
# 외부 IdP Brokering의 동작
|
||||
|
||||
브로커링(brokering)은 브로커가 외부 IdP(Identity Provider)의 인증 결과를 대신 받아 검증하고, 자기 realm 안의 사용자와 연결하는 동작이다. 연결이 끝나면 브로커는 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 코드와 토큰은 언제나 브로커가 발급한 것이라, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer, 곧 그 토큰을 발급한 주체는 바뀌지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **외부 IdP 연동과 Application 인증 구조의 경계**
|
||||
이 동작을 경계 기준으로 정리한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
외부 IdP 세션과 애플리케이션 상태를 구분하는 기준이다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
브로커가 발급하는 코드가 지나는 endpoint다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 두 개의 OAuth 왕복이 이어진다
|
||||
|
||||
사용자가 브로커 로그인 화면에서 어느 외부 IdP로 로그인할지 고르면 인증 왕복이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이에서, 뒤의 왕복은 애플리케이션과 브로커 사이에서 일어난다. 브로커보다 앞에 있는 외부 IdP 쪽을 upstream이라고 부른다.
|
||||
|
||||
먼저 브라우저가 외부 IdP에 authorization 요청을 보내고, 브로커는 돌아온 응답을 검증해 자기 realm의 사용자와 연결한다. 그다음 애플리케이션으로 나가는 값은 브로커가 다시 만든다. Upstream IdP와 애플리케이션 경계는 다음처럼 이어진다.
|
||||
|
||||
:::evidence key="idp-broker-upstream-downstream-boundary" alt="Upstream IdP의 identity assertion이 Keycloak broker에서 local identity와 Keycloak authorization code로 바뀐 뒤 기존 AP1·AP2·AP3·AP4 경계로 이어지는 흐름." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
외부 IdP가 보낸 것은 첫 줄의 `Google identity assertion` 하나이고, 그 아래 `Keycloak local user/session`과 `Keycloak authorization code`는 브로커가 만든다.
|
||||
|
||||
## 애플리케이션이 상대하는 issuer는 그대로다
|
||||
|
||||
AP1의 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 주고받는 authorization code의 issuer도 브로커이며, AP4의 oauth2-proxy가 OIDC(OpenID Connect) 공급자로 연결하는 곳도 브로커다. 외부 IdP가 발급한 토큰은 애플리케이션까지 내려가지 않는다.
|
||||
|
||||
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|
||||
|---|---|---|
|
||||
| 외부 IdP | upstream identity assertion | 브로커 |
|
||||
| 브로커 | authorization code, access·ID token | 애플리케이션과 Resource Server |
|
||||
|
||||
그래서 소셜 로그인을 붙여도 브라우저가 토큰을 받는지, 어느 계층이 API를 부르는지는 달라지지 않는다. 그 둘은 AP1부터 AP4까지 네 패턴 중 무엇을 골랐는지가 정한다.
|
||||
|
||||
## account identity를 정하는 key
|
||||
|
||||
브로커가 외부 IdP의 사용자를 자기 realm의 사용자와 연결할 때 쓰는 안정적인 키는 provider alias와 upstream `sub`를 묶은 값이다. alias는 브로커에 등록한 외부 IdP마다 붙인 이름이고, `sub`는 그 IdP가 사용자 한 명에게 부여하는 고유 식별자다.
|
||||
|
||||
이메일은 이 키가 아니다. 외부 IdP가 보낸 이메일이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 이메일의 소유권을 증명하지 않은 채로 계정이 합쳐지기 때문이다. 계정 연결은 인증 구조와 떼어서 따로 설계할 항목이다.
|
||||
|
||||
## 경계를 섞으면 생기는 일
|
||||
|
||||
외부 IdP 연동을 다섯 번째 애플리케이션 인증 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 다르다. 그러면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
|
||||
|
||||
화면에서 어느 외부 IdP로 로그인할지 고르게 하거나 IdP마다 계정 연결을 다루는 것은 브로커가 하는 일이라 경계를 넘지 않는다. Resource Server의 토큰 검증이나 애플리케이션 인가가 외부 IdP별로 갈리기 시작하면, 브로커 경계가 애플리케이션까지 샜는지 확인한다. 외부 IdP의 토큰을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단도 함께 빠진다.
|
||||
|
||||
## 현재 검증한 범위
|
||||
|
||||
지금 자동화는 실제 Google 대신 controllable mock OIDC provider를 세워 브로커와 claim mapping 계약을 확인하도록 작성되어 있다. 실제 Google 계정과 공개 HTTPS redirect가 성공하는지는 증명하지 않았다. 사용자 동의와 운영 도메인 정책을 통과했다는 뜻도 아니다.
|
||||
|
||||
그래서 upstream IdP를 어디까지 검증했는지와 애플리케이션이 다루는 자격 증명 경계는 따로 적는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "079548c3c6511e9c6878e0409cb6407266148a21593bbc469bb94819382f8547",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
---
|
||||
id: d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719
|
||||
kind: CONCEPT
|
||||
slug: idp-brokering
|
||||
title: 외부 IdP Brokering의 동작
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 전
|
||||
version: 4
|
||||
basisVersion: Keycloak 26.7.0 identity brokering
|
||||
studio: "https://hyeonworks.com/studio/documents/d99fdec9-fe9e-4e0f-a50b-6fb9b9ed5719/edit"
|
||||
assets:
|
||||
- key: idp-broker-upstream-downstream-boundary
|
||||
file: ../../../final/assets/idp-broker-upstream-downstream-boundary/idp-broker-upstream-downstream-boundary.svg
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택이-코드와-흐름에-반영되는-방식-google-login
|
||||
---
|
||||
|
||||
# 외부 IdP Brokering의 동작
|
||||
|
||||
브로커링(brokering)은 브로커가 외부 IdP(Identity Provider)의 인증 결과를 대신 받아 검증하고, 자기 realm 안의 사용자와 연결하는 동작이다. 연결이 끝나면 브로커는 자기가 만든 authorization code를 애플리케이션으로 보낸다. 애플리케이션이 받는 코드와 토큰은 언제나 브로커가 발급한 것이라, 외부 IdP를 붙여도 애플리케이션이 상대하는 issuer, 곧 그 토큰을 발급한 주체는 바뀌지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **외부 IdP 연동과 Application 인증 구조의 경계**
|
||||
이 동작을 경계 기준으로 정리한 기록이다.
|
||||
- **OAuth Token과 Application Session을 구분하는 기준**
|
||||
외부 IdP 세션과 애플리케이션 상태를 구분하는 기준이다.
|
||||
- **Authorization Code Flow의 Endpoint와 Credential 이동 기준**
|
||||
브로커가 발급하는 코드가 지나는 endpoint다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 두 개의 OAuth 왕복이 이어진다
|
||||
|
||||
사용자가 브로커 로그인 화면에서 어느 외부 IdP로 로그인할지 고르면 인증 왕복이 두 번 일어난다. 앞의 왕복은 브로커와 외부 IdP 사이에서, 뒤의 왕복은 애플리케이션과 브로커 사이에서 일어난다. 브로커보다 앞에 있는 외부 IdP 쪽을 upstream이라고 부른다.
|
||||
|
||||
먼저 브라우저가 외부 IdP에 authorization 요청을 보내고, 브로커는 돌아온 응답을 검증해 자기 realm의 사용자와 연결한다. 그다음 애플리케이션으로 나가는 값은 브로커가 다시 만든다. Upstream IdP와 애플리케이션 경계는 다음처럼 이어진다.
|
||||
|
||||
:::evidence key="idp-broker-upstream-downstream-boundary" alt="Upstream IdP의 identity assertion이 Keycloak broker에서 local identity와 Keycloak authorization code로 바뀐 뒤 기존 AP1·AP2·AP3·AP4 경계로 이어지는 흐름." caption=" " zoom="true"
|
||||
:::
|
||||
|
||||
외부 IdP가 보낸 것은 첫 줄의 `Google identity assertion` 하나이고, 그 아래 `Keycloak local user/session`과 `Keycloak authorization code`는 브로커가 만든다.
|
||||
|
||||
## 애플리케이션이 상대하는 issuer는 그대로다
|
||||
|
||||
AP1의 Resource Server가 검증하는 issuer도 브로커이고, AP2와 AP3가 주고받는 authorization code의 issuer도 브로커이며, AP4의 oauth2-proxy가 OIDC(OpenID Connect) 공급자로 연결하는 곳도 브로커다. 외부 IdP가 발급한 토큰은 애플리케이션까지 내려가지 않는다.
|
||||
|
||||
| 계층 | 무엇을 발급하나 | 누가 검증하나 |
|
||||
|---|---|---|
|
||||
| 외부 IdP | upstream identity assertion | 브로커 |
|
||||
| 브로커 | authorization code, access·ID token | 애플리케이션과 Resource Server |
|
||||
|
||||
그래서 소셜 로그인을 붙여도 브라우저가 토큰을 받는지, 어느 계층이 API를 부르는지는 달라지지 않는다. 그 둘은 AP1부터 AP4까지 네 패턴 중 무엇을 골랐는지가 정한다.
|
||||
|
||||
## account identity를 정하는 key
|
||||
|
||||
브로커가 외부 IdP의 사용자를 자기 realm의 사용자와 연결할 때 쓰는 안정적인 키는 provider alias와 upstream `sub`를 묶은 값이다. alias는 브로커에 등록한 외부 IdP마다 붙인 이름이고, `sub`는 그 IdP가 사용자 한 명에게 부여하는 고유 식별자다.
|
||||
|
||||
이메일은 이 키가 아니다. 외부 IdP가 보낸 이메일이 기존 계정과 같다는 이유만으로 자동 연결하면, 그 이메일의 소유권을 증명하지 않은 채로 계정이 합쳐지기 때문이다. 계정 연결은 인증 구조와 떼어서 따로 설계할 항목이다.
|
||||
|
||||
## 경계를 섞으면 생기는 일
|
||||
|
||||
외부 IdP 연동을 다섯 번째 애플리케이션 인증 구조로 세면 upstream IdP 경계와 애플리케이션 OAuth 경계를 같은 기준으로 묶게 되는데, 두 경계는 검증 방법이 다르다. 그러면 비교표에 성격이 다른 항목이 끼어들고, 계정 연결 규칙도 인증 구조 이야기에 섞여서 따로 설계하지 않고 넘어가게 된다.
|
||||
|
||||
화면에서 어느 외부 IdP로 로그인할지 고르게 하거나 IdP마다 계정 연결을 다루는 것은 브로커가 하는 일이라 경계를 넘지 않는다. Resource Server의 토큰 검증이나 애플리케이션 인가가 외부 IdP별로 갈리기 시작하면, 브로커 경계가 애플리케이션까지 샜는지 확인한다. 외부 IdP의 토큰을 애플리케이션이 직접 받아 검증하는 경로를 만들면 브로커가 하던 계정 연결과 정책 판단도 함께 빠진다.
|
||||
|
||||
## 현재 검증한 범위
|
||||
|
||||
지금 자동화는 실제 Google 대신 controllable mock OIDC provider를 세워 브로커와 claim mapping 계약을 확인하도록 작성되어 있다. 실제 Google 계정과 공개 HTTPS redirect가 성공하는지는 증명하지 않았다. 사용자 동의와 운영 도메인 정책을 통과했다는 뜻도 아니다.
|
||||
|
||||
그래서 upstream IdP를 어디까지 검증했는지와 애플리케이션이 다루는 자격 증명 경계는 따로 적는다.
|
||||
|
||||
<!-- body:end -->
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"sourceSha256": "079548c3c6511e9c6878e0409cb6407266148a21593bbc469bb94819382f8547",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/concept/concept-idp-brokering.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-08-question-bff-state-store",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"startedAt": "2026-09-19T10:28:09+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:11+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:09+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:09+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md -o runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:10+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "Question 기록이며 이번 remediation에서 새 순서·구조·측정 관계가 추가되지 않았다. 해당 종류는 TechLog 본문 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:10+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:10+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:11+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:11+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:11+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:11+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:11+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:11+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:11+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md -o runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S3/command-initial.json",
|
||||
"sha256": "187eb229dea7abfc1fec81044cce42b3f93e9c6515ffc1e5e69aa60d306521d9"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md -o runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/S6/command-final.json",
|
||||
"sha256": "187eb229dea7abfc1fec81044cce42b3f93e9c6515ffc1e5e69aa60d306521d9"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "43c14adead2671f899b2842147ff7f184de8d0ad45ba338207e9b041b0f7b52c",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-08-question-bff-state-store/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "615f18faf2d52677c13755bc440a921840bce61d51ea5a57a0c114529169d6b4"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:09+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:10+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:11+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:11+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "43c14adead2671f899b2842147ff7f184de8d0ad45ba338207e9b041b0f7b52c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
|
||||
kind: QUESTION
|
||||
slug: bff-session-authorized-client-store
|
||||
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 32
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
|
||||
public: "https://hyeonworks.com/questions/bff-session-authorized-client-store"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap3
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
|
||||
---
|
||||
|
||||
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
|
||||
현재 BFF(Backend for Frontend)의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
이 질문에서 저장소 부분만 떼어 낸 것이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
세션과 Authorized Client를 찾는 열쇠가 다르다는 사실의 출처다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 기준의 저장소 항목은 여기에 답이 나와야 채울 수 있다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
저장소를 공유한 다음에야 레플리카 경쟁을 재현할 수 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- BFF가 서버에 들고 있는 상태는 둘이다.
|
||||
하나는 브라우저의 로그인 세션이고, 다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
|
||||
JavaScript 응답에 OAuth 토큰이 보이지 않았을 때는 토큰을 다루는 일도 같이 사라진 것처럼 보였지만,
|
||||
BFF 코드를 따라가 보니 BFF가 세션에서 Authorized Client를 찾아 액세스 토큰을 붙여 내부 API를 대신 불렀다.
|
||||
- 현재 구성에는 Spring Session도, Redis도, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
|
||||
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
|
||||
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
|
||||
- 세션은 session ID로 조회하지만 Authorized Client는 registration 이름과 principal name으로 조회한다.
|
||||
두 저장 구조를 공유 저장소로 옮길 때는 각각 따로 설계해야 한다.
|
||||
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
|
||||
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
|
||||
- 현재 확인 범위는 한 대에서 실행한 학습 환경의 코드와 테스트다.
|
||||
여러 인스턴스가 세션과 Authorized Client를 공유하는지는 실행해 확인하지 못했다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 두 상태를 같은 저장소에 둘 필요는 없다.
|
||||
- 저장한 리프레시 토큰을 평문으로 두면 안 된다.
|
||||
- 세션 만료와 토큰 만료 중 하나가 먼저 오면 그 순간에 무엇이 일어날지 정해 두어야 한다.
|
||||
토큰이 만료됐을 때 사용자의 로그인 상태가 유지되는지가 그런 예다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- Redis와 JDBC 중 어느 쪽이 이 상태의 접근 패턴에 맞는가.
|
||||
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
||||
- 세션과 Authorized Client를 한 저장소에 둘지 나눌지.
|
||||
- 암호화 키를 어디에 두고 어떻게 교체하는가. 교체하는 동안 이전 키로 저장한 값은 어떻게 읽는가.
|
||||
- 세션 TTL과 리프레시 토큰 수명 중 어느 쪽을 기준으로 만료를 맞추는가.
|
||||
- 로그아웃 한 번으로 세션과 Authorized Client 두 저장소를 어떻게 같이 지우는가.
|
||||
- 스티키 세션이 영속 저장소의 대안인가 보완인가.
|
||||
|
||||
## 제약
|
||||
|
||||
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
|
||||
- 현재 테스트에는 저장소 계약이 없다.
|
||||
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
|
||||
다만 이 학습 환경에서는 처리량을 재지 못해서, 어느 지점부터 느려지는지는 숫자로 말할 수 없다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 세션과 Authorized Client를 모두 Redis에 둔다
|
||||
|
||||
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 쓰면 여러 애플리케이션 인스턴스가 같은 세션과 Authorized Client를 조회할 수 있다.
|
||||
상태가 특정 인스턴스의 메모리에 묶이지 않으니 서버가 재시작되거나 요청이 다른 레플리카로 전달되는 환경에서도 인증 상태를 이어 쓰기 쉬워진다.
|
||||
|
||||
세션과 Authorized Client에 설정한 유효 시간에 맞춰 Redis의 TTL로 저장한 상태를 만료시키는 구조도 짤 수 있다.
|
||||
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 세션 정책과 OAuth 토큰 수명에 맞춰 따로 정한다.
|
||||
|
||||
대신 인증 경로가 Redis의 가용성에 매인다.
|
||||
Redis에 장애가 났을 때 기존 세션과 Authorized Client를 읽지 못하는 상황을 어떻게 처리할지 정해야 하고, 장애 복구와 데이터 유지 방식도 같이 본다.
|
||||
|
||||
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 담은 토큰을 어떻게 보호할지도 정해야 한다.
|
||||
암호화해서 저장할지, 암호화한다면 키를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
|
||||
|
||||
### 2. 세션과 Authorized Client를 모두 JDBC에 둔다
|
||||
|
||||
JDBC 기반 저장소를 쓰면 이미 운영 중인 관계형 DB에 세션과 Authorized Client를 담을 수 있다.
|
||||
|
||||
인증 과정에서 세션이나 Authorized Client를 조회할 때마다 DB 접근이 일어나므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 매이게 된다.
|
||||
요청량이 늘었을 때 세션 조회가 지연되지 않는지, 인증 조회와 기존 애플리케이션 쿼리가 서로 영향을 주지 않는지 확인해야 한다.
|
||||
|
||||
만료된 세션과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기도 정한다.
|
||||
|
||||
### 3. 세션만 공유하고 스티키 세션을 쓴다
|
||||
|
||||
세션 어피니티를 쓰면 같은 사용자의 요청을 되도록 같은 애플리케이션 인스턴스로 보낼 수 있어서 기존 구조를 덜 건드린다.
|
||||
|
||||
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되지는 않는다.
|
||||
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 있다면, 요청이 다른 인스턴스로 전달되거나 그 인스턴스가 종료됐을 때 기존 토큰 정보를 찾지 못한다.
|
||||
|
||||
세션과 Authorized Client를 공유하는 문제, 장애 뒤에 인증 상태를 잇는 문제는 따로 풀어야 한다.
|
||||
인스턴스 장애와 레플리카 간 이동까지 보려면 세션과 Authorized Client의 저장 방식을 따로 설계한다.
|
||||
|
||||
### 4. 세션은 Redis, 토큰은 암호화해 JDBC에 둔다
|
||||
|
||||
세션과 Authorized Client를 서로 다른 저장소에 담는 방법도 있다.
|
||||
요청마다 자주 조회되는 세션은 Redis처럼 빠르게 접근할 수 있는 저장소에 두고, 액세스 토큰과 리프레시 토큰은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 담는 식이다.
|
||||
|
||||
대신 두 저장소의 상태를 함께 관리해야 한다.
|
||||
세션이 만료됐는데 Authorized Client는 아직 지워지지 않거나, 반대로 Authorized Client가 먼저 지워져서 유효한 세션인데도 토큰을 찾지 못하는 상황이 생길 수 있다.
|
||||
그래서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
|
||||
|
||||
로그아웃에서도 세션과 Authorized Client가 서로 다른 저장소에 있으니 두 상태를 모두 지워야 하고, 한쪽을 지우다 실패했을 때 어떻게 할지도 같이 정한다.
|
||||
|
||||
Redis와 관계형 DB를 둘 다 인증 경로에서 쓰게 되므로 모니터링, 장애 대응, 백업까지 운영할 저장소가 늘어난다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
후보마다 같은 입력으로 비교한다.
|
||||
|
||||
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
|
||||
2. 저장소를 직접 열어 리프레시 토큰이 평문으로 보이는지 확인한다.
|
||||
3. 세션 TTL과 토큰 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
|
||||
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
|
||||
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
|
||||
|
||||
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
|
||||
|
||||
암호화 키 교체 절차는 후보를 고른 뒤에 따로 설계한다.
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
|
||||
kind: QUESTION
|
||||
slug: bff-session-authorized-client-store
|
||||
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 32
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
|
||||
public: "https://hyeonworks.com/questions/bff-session-authorized-client-store"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap3
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
|
||||
---
|
||||
|
||||
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
|
||||
현재 BFF(Backend for Frontend)의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
이 질문에서 저장소 부분만 떼어 낸 것이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
세션과 Authorized Client를 찾는 열쇠가 다르다는 사실의 출처다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 기준의 저장소 항목은 여기에 답이 나와야 채울 수 있다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
저장소를 공유한 다음에야 레플리카 경쟁을 재현할 수 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- BFF가 서버에 들고 있는 상태는 둘이다.
|
||||
하나는 브라우저의 로그인 세션이고, 다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
|
||||
JavaScript 응답에 OAuth 토큰이 보이지 않았을 때는 토큰을 다루는 일도 같이 사라진 것처럼 보였지만,
|
||||
BFF 코드를 따라가 보니 BFF가 세션에서 Authorized Client를 찾아 액세스 토큰을 붙여 내부 API를 대신 불렀다.
|
||||
- 현재 구성에는 Spring Session도, Redis도, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
|
||||
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
|
||||
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
|
||||
- 세션은 session ID로 조회하지만 Authorized Client는 registration 이름과 principal name으로 조회한다.
|
||||
두 저장 구조를 공유 저장소로 옮길 때는 각각 따로 설계해야 한다.
|
||||
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
|
||||
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
|
||||
- 현재 확인 범위는 한 대에서 실행한 학습 환경의 코드와 테스트다.
|
||||
여러 인스턴스가 세션과 Authorized Client를 공유하는지는 실행해 확인하지 못했다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 두 상태를 같은 저장소에 둘 필요는 없다.
|
||||
- 저장한 리프레시 토큰을 평문으로 두면 안 된다.
|
||||
- 세션 만료와 토큰 만료 중 하나가 먼저 오면 그 순간에 무엇이 일어날지 정해 두어야 한다.
|
||||
토큰이 만료됐을 때 사용자의 로그인 상태가 유지되는지가 그런 예다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- Redis와 JDBC 중 어느 쪽이 이 상태의 접근 패턴에 맞는가.
|
||||
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
||||
- 세션과 Authorized Client를 한 저장소에 둘지 나눌지.
|
||||
- 암호화 키를 어디에 두고 어떻게 교체하는가. 교체하는 동안 이전 키로 저장한 값은 어떻게 읽는가.
|
||||
- 세션 TTL과 리프레시 토큰 수명 중 어느 쪽을 기준으로 만료를 맞추는가.
|
||||
- 로그아웃 한 번으로 세션과 Authorized Client 두 저장소를 어떻게 같이 지우는가.
|
||||
- 스티키 세션이 영속 저장소의 대안인가 보완인가.
|
||||
|
||||
## 제약
|
||||
|
||||
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
|
||||
- 현재 테스트에는 저장소 계약이 없다.
|
||||
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
|
||||
다만 이 학습 환경에서는 처리량을 재지 못해서, 어느 지점부터 느려지는지는 숫자로 말할 수 없다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 세션과 Authorized Client를 모두 Redis에 둔다
|
||||
|
||||
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 쓰면 여러 애플리케이션 인스턴스가 같은 세션과 Authorized Client를 조회할 수 있다.
|
||||
상태가 특정 인스턴스의 메모리에 묶이지 않으니 서버가 재시작되거나 요청이 다른 레플리카로 전달되는 환경에서도 인증 상태를 이어 쓰기 쉬워진다.
|
||||
|
||||
세션과 Authorized Client에 설정한 유효 시간에 맞춰 Redis의 TTL로 저장한 상태를 만료시키는 구조도 짤 수 있다.
|
||||
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 세션 정책과 OAuth 토큰 수명에 맞춰 따로 정한다.
|
||||
|
||||
대신 인증 경로가 Redis의 가용성에 매인다.
|
||||
Redis에 장애가 났을 때 기존 세션과 Authorized Client를 읽지 못하는 상황을 어떻게 처리할지 정해야 하고, 장애 복구와 데이터 유지 방식도 같이 본다.
|
||||
|
||||
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 담은 토큰을 어떻게 보호할지도 정해야 한다.
|
||||
암호화해서 저장할지, 암호화한다면 키를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
|
||||
|
||||
### 2. 세션과 Authorized Client를 모두 JDBC에 둔다
|
||||
|
||||
JDBC 기반 저장소를 쓰면 이미 운영 중인 관계형 DB에 세션과 Authorized Client를 담을 수 있다.
|
||||
|
||||
인증 과정에서 세션이나 Authorized Client를 조회할 때마다 DB 접근이 일어나므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 매이게 된다.
|
||||
요청량이 늘었을 때 세션 조회가 지연되지 않는지, 인증 조회와 기존 애플리케이션 쿼리가 서로 영향을 주지 않는지 확인해야 한다.
|
||||
|
||||
만료된 세션과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기도 정한다.
|
||||
|
||||
### 3. 세션만 공유하고 스티키 세션을 쓴다
|
||||
|
||||
세션 어피니티를 쓰면 같은 사용자의 요청을 되도록 같은 애플리케이션 인스턴스로 보낼 수 있어서 기존 구조를 덜 건드린다.
|
||||
|
||||
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되지는 않는다.
|
||||
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 있다면, 요청이 다른 인스턴스로 전달되거나 그 인스턴스가 종료됐을 때 기존 토큰 정보를 찾지 못한다.
|
||||
|
||||
세션과 Authorized Client를 공유하는 문제, 장애 뒤에 인증 상태를 잇는 문제는 따로 풀어야 한다.
|
||||
인스턴스 장애와 레플리카 간 이동까지 보려면 세션과 Authorized Client의 저장 방식을 따로 설계한다.
|
||||
|
||||
### 4. 세션은 Redis, 토큰은 암호화해 JDBC에 둔다
|
||||
|
||||
세션과 Authorized Client를 서로 다른 저장소에 담는 방법도 있다.
|
||||
요청마다 자주 조회되는 세션은 Redis처럼 빠르게 접근할 수 있는 저장소에 두고, 액세스 토큰과 리프레시 토큰은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 담는 식이다.
|
||||
|
||||
대신 두 저장소의 상태를 함께 관리해야 한다.
|
||||
세션이 만료됐는데 Authorized Client는 아직 지워지지 않거나, 반대로 Authorized Client가 먼저 지워져서 유효한 세션인데도 토큰을 찾지 못하는 상황이 생길 수 있다.
|
||||
그래서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
|
||||
|
||||
로그아웃에서도 세션과 Authorized Client가 서로 다른 저장소에 있으니 두 상태를 모두 지워야 하고, 한쪽을 지우다 실패했을 때 어떻게 할지도 같이 정한다.
|
||||
|
||||
Redis와 관계형 DB를 둘 다 인증 경로에서 쓰게 되므로 모니터링, 장애 대응, 백업까지 운영할 저장소가 늘어난다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
후보마다 같은 입력으로 비교한다.
|
||||
|
||||
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
|
||||
2. 저장소를 직접 열어 리프레시 토큰이 평문으로 보이는지 확인한다.
|
||||
3. 세션 TTL과 토큰 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
|
||||
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
|
||||
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
|
||||
|
||||
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
|
||||
|
||||
암호화 키 교체 절차는 후보를 고른 뒤에 따로 설계한다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "43c14adead2671f899b2842147ff7f184de8d0ad45ba338207e9b041b0f7b52c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
---
|
||||
id: 18a5cde2-dd1e-4bff-9f1c-997577ae438f
|
||||
kind: QUESTION
|
||||
slug: bff-session-authorized-client-store
|
||||
title: BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 32
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/18a5cde2-dd1e-4bff-9f1c-997577ae438f/edit"
|
||||
public: "https://hyeonworks.com/questions/bff-session-authorized-client-store"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap3
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap3
|
||||
---
|
||||
|
||||
# BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가
|
||||
|
||||
현재 BFF(Backend for Frontend)의 세션과 Authorized Client는 프로세스 메모리에 저장된다. 재시작과 레플리카 이동 뒤에도 로그인 상태를 유지하려면 두 상태를 어디에 저장할지 정해야 한다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
이 질문에서 저장소 부분만 떼어 낸 것이다.
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
세션과 Authorized Client를 찾는 열쇠가 다르다는 사실의 출처다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 기준의 저장소 항목은 여기에 답이 나와야 채울 수 있다.
|
||||
- **Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가**
|
||||
저장소를 공유한 다음에야 레플리카 경쟁을 재현할 수 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- BFF가 서버에 들고 있는 상태는 둘이다.
|
||||
하나는 브라우저의 로그인 세션이고, 다른 하나는 Keycloak에서 받은 액세스 토큰과 리프레시 토큰을 담아 두는 Authorized Client다.
|
||||
JavaScript 응답에 OAuth 토큰이 보이지 않았을 때는 토큰을 다루는 일도 같이 사라진 것처럼 보였지만,
|
||||
BFF 코드를 따라가 보니 BFF가 세션에서 Authorized Client를 찾아 액세스 토큰을 붙여 내부 API를 대신 불렀다.
|
||||
- 현재 구성에는 Spring Session도, Redis도, JDBC 저장소도, 토큰을 암호화해 담는 저장소도 없다.
|
||||
- HttpSession은 서블릿 컨테이너의 메모리 구현을 쓰기 때문에, 그 프로세스가 종료되면 세션 데이터도 같이 사라진다.
|
||||
- OAuth2AuthorizedClientService도 Spring Boot 자동구성이 고르는 메모리 구현이다.
|
||||
- 세션은 session ID로 조회하지만 Authorized Client는 registration 이름과 principal name으로 조회한다.
|
||||
두 저장 구조를 공유 저장소로 옮길 때는 각각 따로 설계해야 한다.
|
||||
- Authorized Client 매니저에는 authorization-code와 refresh-token provider가 함께 구성돼 있어서,
|
||||
저장소를 공유하면 여러 인스턴스가 같은 항목을 동시에 갱신할 수 있다.
|
||||
- 현재 확인 범위는 한 대에서 실행한 학습 환경의 코드와 테스트다.
|
||||
여러 인스턴스가 세션과 Authorized Client를 공유하는지는 실행해 확인하지 못했다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 두 상태를 같은 저장소에 둘 필요는 없다.
|
||||
- 저장한 리프레시 토큰을 평문으로 두면 안 된다.
|
||||
- 세션 만료와 토큰 만료 중 하나가 먼저 오면 그 순간에 무엇이 일어날지 정해 두어야 한다.
|
||||
토큰이 만료됐을 때 사용자의 로그인 상태가 유지되는지가 그런 예다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- Redis와 JDBC 중 어느 쪽이 이 상태의 접근 패턴에 맞는가.
|
||||
요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
|
||||
- 세션과 Authorized Client를 한 저장소에 둘지 나눌지.
|
||||
- 암호화 키를 어디에 두고 어떻게 교체하는가. 교체하는 동안 이전 키로 저장한 값은 어떻게 읽는가.
|
||||
- 세션 TTL과 리프레시 토큰 수명 중 어느 쪽을 기준으로 만료를 맞추는가.
|
||||
- 로그아웃 한 번으로 세션과 Authorized Client 두 저장소를 어떻게 같이 지우는가.
|
||||
- 스티키 세션이 영속 저장소의 대안인가 보완인가.
|
||||
|
||||
## 제약
|
||||
|
||||
- Authorized Client를 찾을 때는 session ID를 쓰지 않아서, 세션만 공유해도 같은 사용자의 여러 세션이 같은 토큰을 본다.
|
||||
- 현재 테스트에는 저장소 계약이 없다.
|
||||
- 모든 화면 요청이 BFF를 지나기 때문에 저장소가 느려지면 화면도 바로 느려진다.
|
||||
다만 이 학습 환경에서는 처리량을 재지 못해서, 어느 지점부터 느려지는지는 숫자로 말할 수 없다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 세션과 Authorized Client를 모두 Redis에 둔다
|
||||
|
||||
Spring Session Redis와 Redis 기반 Authorized Client 저장소를 쓰면 여러 애플리케이션 인스턴스가 같은 세션과 Authorized Client를 조회할 수 있다.
|
||||
상태가 특정 인스턴스의 메모리에 묶이지 않으니 서버가 재시작되거나 요청이 다른 레플리카로 전달되는 환경에서도 인증 상태를 이어 쓰기 쉬워진다.
|
||||
|
||||
세션과 Authorized Client에 설정한 유효 시간에 맞춰 Redis의 TTL로 저장한 상태를 만료시키는 구조도 짤 수 있다.
|
||||
다만 어떤 상태를 얼마 동안 유지할지는 애플리케이션의 세션 정책과 OAuth 토큰 수명에 맞춰 따로 정한다.
|
||||
|
||||
대신 인증 경로가 Redis의 가용성에 매인다.
|
||||
Redis에 장애가 났을 때 기존 세션과 Authorized Client를 읽지 못하는 상황을 어떻게 처리할지 정해야 하고, 장애 복구와 데이터 유지 방식도 같이 본다.
|
||||
|
||||
액세스 토큰과 리프레시 토큰을 Redis에 담는다면 담은 토큰을 어떻게 보호할지도 정해야 한다.
|
||||
암호화해서 저장할지, 암호화한다면 키를 어디에 보관하고 어떻게 교체할지까지 저장소 설계에 포함한다.
|
||||
|
||||
### 2. 세션과 Authorized Client를 모두 JDBC에 둔다
|
||||
|
||||
JDBC 기반 저장소를 쓰면 이미 운영 중인 관계형 DB에 세션과 Authorized Client를 담을 수 있다.
|
||||
|
||||
인증 과정에서 세션이나 Authorized Client를 조회할 때마다 DB 접근이 일어나므로, 인증 요청이 기존 관계형 DB의 가용성과 성능에 매이게 된다.
|
||||
요청량이 늘었을 때 세션 조회가 지연되지 않는지, 인증 조회와 기존 애플리케이션 쿼리가 서로 영향을 주지 않는지 확인해야 한다.
|
||||
|
||||
만료된 세션과 Authorized Client 데이터가 계속 쌓이지 않도록 정리하는 방법과 주기도 정한다.
|
||||
|
||||
### 3. 세션만 공유하고 스티키 세션을 쓴다
|
||||
|
||||
세션 어피니티를 쓰면 같은 사용자의 요청을 되도록 같은 애플리케이션 인스턴스로 보낼 수 있어서 기존 구조를 덜 건드린다.
|
||||
|
||||
하지만 이것만으로 인증 상태가 여러 인스턴스에 공유되지는 않는다.
|
||||
Authorized Client가 여전히 특정 애플리케이션 인스턴스의 메모리에 있다면, 요청이 다른 인스턴스로 전달되거나 그 인스턴스가 종료됐을 때 기존 토큰 정보를 찾지 못한다.
|
||||
|
||||
세션과 Authorized Client를 공유하는 문제, 장애 뒤에 인증 상태를 잇는 문제는 따로 풀어야 한다.
|
||||
인스턴스 장애와 레플리카 간 이동까지 보려면 세션과 Authorized Client의 저장 방식을 따로 설계한다.
|
||||
|
||||
### 4. 세션은 Redis, 토큰은 암호화해 JDBC에 둔다
|
||||
|
||||
세션과 Authorized Client를 서로 다른 저장소에 담는 방법도 있다.
|
||||
요청마다 자주 조회되는 세션은 Redis처럼 빠르게 접근할 수 있는 저장소에 두고, 액세스 토큰과 리프레시 토큰은 암호화와 장기 보관 정책을 적용하기 쉬운 관계형 DB에 담는 식이다.
|
||||
|
||||
대신 두 저장소의 상태를 함께 관리해야 한다.
|
||||
세션이 만료됐는데 Authorized Client는 아직 지워지지 않거나, 반대로 Authorized Client가 먼저 지워져서 유효한 세션인데도 토큰을 찾지 못하는 상황이 생길 수 있다.
|
||||
그래서 각각의 만료 정책을 어떻게 맞출지 정해야 한다.
|
||||
|
||||
로그아웃에서도 세션과 Authorized Client가 서로 다른 저장소에 있으니 두 상태를 모두 지워야 하고, 한쪽을 지우다 실패했을 때 어떻게 할지도 같이 정한다.
|
||||
|
||||
Redis와 관계형 DB를 둘 다 인증 경로에서 쓰게 되므로 모니터링, 장애 대응, 백업까지 운영할 저장소가 늘어난다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
후보마다 같은 입력으로 비교한다.
|
||||
|
||||
1. 인스턴스 두 대에서 로그인 유지와 재시작 복구가 되는지 본다.
|
||||
2. 저장소를 직접 열어 리프레시 토큰이 평문으로 보이는지 확인한다.
|
||||
3. 세션 TTL과 토큰 만료를 어긋나게 두고 그 순간의 응답과 화면을 기록한다.
|
||||
4. 로그아웃 뒤 두 저장소에 잔여 항목이 없는지 확인한다.
|
||||
5. 저장소를 끊은 상태에서 로그인과 API 호출이 어떤 오류를 내는지 본다.
|
||||
|
||||
토큰 보호와 만료 처리, 로그아웃 뒤 상태 정리까지 검증한 다음에 저장 구조를 정한다.
|
||||
|
||||
암호화 키 교체 절차는 후보를 고른 뒤에 따로 설계한다.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"sourceSha256": "43c14adead2671f899b2842147ff7f184de8d0ad45ba338207e9b041b0f7b52c",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-bff-state-store.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-09-question-edge-authorization-scope",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"startedAt": "2026-09-19T10:28:11+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:11+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:12+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md -o runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:12+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "Question 기록이며 이번 remediation에서 새 순서·구조·측정 관계가 추가되지 않았다. 해당 종류는 TechLog 본문 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:12+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:13+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:13+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:13+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md -o runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S3/command-initial.json",
|
||||
"sha256": "4b8d858a4ece15b83c2b313417388b9d8cf087d66abe10a60a0c29d90f33027e"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md -o runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/S6/command-final.json",
|
||||
"sha256": "4b8d858a4ece15b83c2b313417388b9d8cf087d66abe10a60a0c29d90f33027e"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "c2544a49e22ad6695bf9681de36e6aa72efafc7b20871d72a4ef2e318809966f",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-09-question-edge-authorization-scope/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "cba23324e09e0d443523ba5b87f8652aecf08919bdd81cd34a19a9ffc36bdbdf"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:11+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:12+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:13+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:14+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "c2544a49e22ad6695bf9681de36e6aa72efafc7b20871d72a4ef2e318809966f",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
|
||||
kind: QUESTION
|
||||
slug: edge-authorization-scope
|
||||
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 36
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
|
||||
public: "https://hyeonworks.com/questions/edge-authorization-scope"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap4
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
|
||||
---
|
||||
|
||||
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
|
||||
지금 엣지는 인증된 사용자의 `user`와 `email`만 헤더로 넘기고, 업스트림 애플리케이션은 역할(role)을 보고 인가를 판단하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
엣지가 user와 email만 넘긴다는 사실을 여기서 가져왔다.
|
||||
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
||||
넘길 헤더를 허용 목록(allowlist)으로 두는 기준과 검증 조건이 이 기록에 있다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
네 번째 선택지로 되돌아갈 때 따를 기준이 이 기록이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- 지금 엣지 응답은 user와 email만 넘긴다. 역할과 그룹, 테넌트, 인증 방식, 토큰 만료는 넘기지 않는다.
|
||||
- 업스트림의 신원 응답 엔드포인트는 역할을 확인하지 않고 누가 왔는지만 돌려준다.
|
||||
- 내부 토큰(internal token) 검증은 컨트롤러 한 곳에서만 하고, Spring Security 설정은 그 경로를 permitAll로 열어 둔다. 검증이 그 컨트롤러 안에만 있으니 같은 내부 경로 아래에 엔드포인트를 새로 추가해도 검증이 따라붙지 않는다. 필터나 인터셉터, Spring Security의 인증 처리 단계처럼 모든 요청이 지나는 공통 경계로 이 검사를 옮겨야 한다.
|
||||
- Nginx는 클라이언트가 보낸 같은 이름의 헤더를 합치지 않고 덮어쓴다. 헤더를 늘리면 늘어난 헤더도 똑같이 덮어쓰게 해야 한다.
|
||||
- 업스트림은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값이 맞는지 확인할 방법이 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 헤더 종류가 늘어나면 그만큼 정해야 할 계약도 늘어난다.
|
||||
- 역할이 바뀌는 시각과 요청이 들어오는 시각이 달라서, 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지.
|
||||
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
|
||||
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
|
||||
현재 fixture는 `/api/edge`와 `/`를 모두 `/edge/me`로 바꿔서 큰 헤더가 실린 요청을 통과시켜 본 적이 없다.
|
||||
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가에 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
|
||||
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
|
||||
|
||||
## 제약
|
||||
|
||||
- 넘길 헤더는 허용 목록으로 정해야 하고, 클라이언트가 보낸 같은 이름의 헤더는 언제나 덮어써야 한다.
|
||||
- 내부 토큰 검사가 컨트롤러 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 한다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 인증만 엣지에 둔다
|
||||
|
||||
엣지가 넘기는 헤더를 user와 email 정도로 묶어 두면 엣지와 업스트림 사이의 계약을 작게 유지할 수 있다. 역할이나 권한을 헤더에 계속 더하지 않으니 헤더가 커지는 문제도 줄일 수 있다.
|
||||
|
||||
대신 인가는 업스트림 애플리케이션이 직접 판단한다. 넘겨받은 사용자 식별 정보로 자기 저장소에서 역할이나 권한을 조회하고, 그 요청을 허용할지 정해야 한다.
|
||||
|
||||
인가를 서비스가 직접 판단하다 보니 권한 조회와 인가 코드를 서비스마다 따로 만들어야 한다.
|
||||
|
||||
### 2. 역할 전달까지 엣지에 둔다
|
||||
|
||||
엣지가 공통 역할을 확인해 업스트림에 넘기면, 서비스마다 사용자 권한을 따로 조회하는 일을 줄일 수 있다.
|
||||
|
||||
대신 역할을 헤더로 넘기는 계약을 먼저 정해야 한다. 한 사용자가 역할을 여럿 가질 때 어떤 형식으로 직렬화할지, 헤더에 허용할 최대 크기를 얼마로 둘지, 역할을 바꾼 뒤 언제부터 새 값이 요청에 실리는지를 적어 두어야 한다.
|
||||
|
||||
업스트림은 넘겨받은 역할이 원래 인증 시스템의 값과 같은지 확인할 수 있어야 한다. 확인하지 못하면 엣지가 넘긴 값을 그대로 믿게 되는데, 엣지가 역할을 잘못 계산하거나 오래된 값을 넘기면 업스트림의 인가 판단도 그대로 틀어질 수 있다.
|
||||
|
||||
이 선택지를 고르면 엣지가 역할을 만드는 과정과 헤더가 지나는 경로까지 신뢰 경계 안으로 들어온다. 역할을 언제 갱신하고 전달 오류를 어떻게 잡을지도 같이 설계해야 한다.
|
||||
|
||||
### 3. 테넌트와 인가 판단까지 엣지에 둔다
|
||||
|
||||
테넌트는 어느 조직의 데이터까지 볼 수 있는지를 정하는 값이다. 그래서 잘못된 테넌트 값 하나가 넘어가면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
|
||||
|
||||
테넌트를 엣지 헤더로 넘기려면 업스트림이 그 사용자가 실제로 그 테넌트에 속하는지 다시 확인할 방법이 있어야 한다. 지금 구성에는 다시 확인할 수단이 없어서 테넌트까지 엣지가 맡는 구조로 넓히기에는 위험이 크다.
|
||||
|
||||
테넌트나 역할로 인가를 판단하는 일까지 엣지로 옮기면 엣지가 애플리케이션의 도메인 규칙을 알아야 한다. 어떤 사용자가 어느 조직의 어떤 기능을 쓸 수 있는지 같은 정책이 바뀔 때마다 엣지 코드도 함께 고쳐 배포해야 한다.
|
||||
|
||||
그래서 엣지는 인증된 사용자 정보를 넘기는 쪽에 가깝게 두고, 테넌트 소속 여부나 도메인에 묶인 인가 규칙은 업스트림 애플리케이션이 검증하는 구조를 먼저 검토한다.
|
||||
|
||||
### 4. 헤더 계약 대신 BFF가 인가와 API 호출을 맡는다
|
||||
|
||||
역할이나 테넌트 같은 값을 엣지 헤더에 계속 더하는 대신, BFF(Backend For Frontend)가 필요한 정보를 직접 조회해 인가를 판단하고 API까지 부르는 구조도 고를 수 있다. BFF는 화면에 필요한 API를 브라우저 대신 불러 조합하는 서버다.
|
||||
|
||||
이러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책을 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
|
||||
|
||||
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 한다. 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙으니, 값을 바꾸는 요청에는 별도 anti-CSRF token을 요구해 cross-site forged request를 구분하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
|
||||
|
||||
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
업스트림이 실제로 요구하는 사용자 속성(claim)부터 적는다.
|
||||
|
||||
1. 넘기려는 속성이 계속 늘어나는가.
|
||||
2. 역할이나 테넌트를 바꾸면 곧바로 반영돼야 하는가.
|
||||
3. 정책이 애플리케이션 도메인을 알아야 하는가.
|
||||
4. 헤더 값이 인가 판단의 근거가 되는가.
|
||||
5. 서비스마다 정책 차이가 커지는가.
|
||||
|
||||
2번부터 5번 가운데 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
|
||||
|
||||
역할을 헤더에 담는 구성을 먼저 만들고, 값을 여러 개 넣고 크기 상한까지 올려 무엇이 먼저 깨지는지 확인한다. 역할을 바꾼 뒤 몇 번째 요청부터 새 값이 실리는지도 센다.
|
||||
|
||||
어느 쪽으로 옮길지는 새로 일을 맡는 쪽이 상태와 검증을 감당할 수 있는지를 보고 정한다.
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
|
||||
kind: QUESTION
|
||||
slug: edge-authorization-scope
|
||||
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 36
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
|
||||
public: "https://hyeonworks.com/questions/edge-authorization-scope"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap4
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
|
||||
---
|
||||
|
||||
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
|
||||
지금 엣지는 인증된 사용자의 `user`와 `email`만 헤더로 넘기고, 업스트림 애플리케이션은 역할(role)을 보고 인가를 판단하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
엣지가 user와 email만 넘긴다는 사실을 여기서 가져왔다.
|
||||
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
||||
넘길 헤더를 허용 목록(allowlist)으로 두는 기준과 검증 조건이 이 기록에 있다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
네 번째 선택지로 되돌아갈 때 따를 기준이 이 기록이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- 지금 엣지 응답은 user와 email만 넘긴다. 역할과 그룹, 테넌트, 인증 방식, 토큰 만료는 넘기지 않는다.
|
||||
- 업스트림의 신원 응답 엔드포인트는 역할을 확인하지 않고 누가 왔는지만 돌려준다.
|
||||
- 내부 토큰(internal token) 검증은 컨트롤러 한 곳에서만 하고, Spring Security 설정은 그 경로를 permitAll로 열어 둔다. 검증이 그 컨트롤러 안에만 있으니 같은 내부 경로 아래에 엔드포인트를 새로 추가해도 검증이 따라붙지 않는다. 필터나 인터셉터, Spring Security의 인증 처리 단계처럼 모든 요청이 지나는 공통 경계로 이 검사를 옮겨야 한다.
|
||||
- Nginx는 클라이언트가 보낸 같은 이름의 헤더를 합치지 않고 덮어쓴다. 헤더를 늘리면 늘어난 헤더도 똑같이 덮어쓰게 해야 한다.
|
||||
- 업스트림은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값이 맞는지 확인할 방법이 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 헤더 종류가 늘어나면 그만큼 정해야 할 계약도 늘어난다.
|
||||
- 역할이 바뀌는 시각과 요청이 들어오는 시각이 달라서, 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지.
|
||||
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
|
||||
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
|
||||
현재 fixture는 `/api/edge`와 `/`를 모두 `/edge/me`로 바꿔서 큰 헤더가 실린 요청을 통과시켜 본 적이 없다.
|
||||
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가에 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
|
||||
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
|
||||
|
||||
## 제약
|
||||
|
||||
- 넘길 헤더는 허용 목록으로 정해야 하고, 클라이언트가 보낸 같은 이름의 헤더는 언제나 덮어써야 한다.
|
||||
- 내부 토큰 검사가 컨트롤러 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 한다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 인증만 엣지에 둔다
|
||||
|
||||
엣지가 넘기는 헤더를 user와 email 정도로 묶어 두면 엣지와 업스트림 사이의 계약을 작게 유지할 수 있다. 역할이나 권한을 헤더에 계속 더하지 않으니 헤더가 커지는 문제도 줄일 수 있다.
|
||||
|
||||
대신 인가는 업스트림 애플리케이션이 직접 판단한다. 넘겨받은 사용자 식별 정보로 자기 저장소에서 역할이나 권한을 조회하고, 그 요청을 허용할지 정해야 한다.
|
||||
|
||||
인가를 서비스가 직접 판단하다 보니 권한 조회와 인가 코드를 서비스마다 따로 만들어야 한다.
|
||||
|
||||
### 2. 역할 전달까지 엣지에 둔다
|
||||
|
||||
엣지가 공통 역할을 확인해 업스트림에 넘기면, 서비스마다 사용자 권한을 따로 조회하는 일을 줄일 수 있다.
|
||||
|
||||
대신 역할을 헤더로 넘기는 계약을 먼저 정해야 한다. 한 사용자가 역할을 여럿 가질 때 어떤 형식으로 직렬화할지, 헤더에 허용할 최대 크기를 얼마로 둘지, 역할을 바꾼 뒤 언제부터 새 값이 요청에 실리는지를 적어 두어야 한다.
|
||||
|
||||
업스트림은 넘겨받은 역할이 원래 인증 시스템의 값과 같은지 확인할 수 있어야 한다. 확인하지 못하면 엣지가 넘긴 값을 그대로 믿게 되는데, 엣지가 역할을 잘못 계산하거나 오래된 값을 넘기면 업스트림의 인가 판단도 그대로 틀어질 수 있다.
|
||||
|
||||
이 선택지를 고르면 엣지가 역할을 만드는 과정과 헤더가 지나는 경로까지 신뢰 경계 안으로 들어온다. 역할을 언제 갱신하고 전달 오류를 어떻게 잡을지도 같이 설계해야 한다.
|
||||
|
||||
### 3. 테넌트와 인가 판단까지 엣지에 둔다
|
||||
|
||||
테넌트는 어느 조직의 데이터까지 볼 수 있는지를 정하는 값이다. 그래서 잘못된 테넌트 값 하나가 넘어가면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
|
||||
|
||||
테넌트를 엣지 헤더로 넘기려면 업스트림이 그 사용자가 실제로 그 테넌트에 속하는지 다시 확인할 방법이 있어야 한다. 지금 구성에는 다시 확인할 수단이 없어서 테넌트까지 엣지가 맡는 구조로 넓히기에는 위험이 크다.
|
||||
|
||||
테넌트나 역할로 인가를 판단하는 일까지 엣지로 옮기면 엣지가 애플리케이션의 도메인 규칙을 알아야 한다. 어떤 사용자가 어느 조직의 어떤 기능을 쓸 수 있는지 같은 정책이 바뀔 때마다 엣지 코드도 함께 고쳐 배포해야 한다.
|
||||
|
||||
그래서 엣지는 인증된 사용자 정보를 넘기는 쪽에 가깝게 두고, 테넌트 소속 여부나 도메인에 묶인 인가 규칙은 업스트림 애플리케이션이 검증하는 구조를 먼저 검토한다.
|
||||
|
||||
### 4. 헤더 계약 대신 BFF가 인가와 API 호출을 맡는다
|
||||
|
||||
역할이나 테넌트 같은 값을 엣지 헤더에 계속 더하는 대신, BFF(Backend For Frontend)가 필요한 정보를 직접 조회해 인가를 판단하고 API까지 부르는 구조도 고를 수 있다. BFF는 화면에 필요한 API를 브라우저 대신 불러 조합하는 서버다.
|
||||
|
||||
이러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책을 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
|
||||
|
||||
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 한다. 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙으니, 값을 바꾸는 요청에는 별도 anti-CSRF token을 요구해 cross-site forged request를 구분하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
|
||||
|
||||
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
업스트림이 실제로 요구하는 사용자 속성(claim)부터 적는다.
|
||||
|
||||
1. 넘기려는 속성이 계속 늘어나는가.
|
||||
2. 역할이나 테넌트를 바꾸면 곧바로 반영돼야 하는가.
|
||||
3. 정책이 애플리케이션 도메인을 알아야 하는가.
|
||||
4. 헤더 값이 인가 판단의 근거가 되는가.
|
||||
5. 서비스마다 정책 차이가 커지는가.
|
||||
|
||||
2번부터 5번 가운데 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
|
||||
|
||||
역할을 헤더에 담는 구성을 먼저 만들고, 값을 여러 개 넣고 크기 상한까지 올려 무엇이 먼저 깨지는지 확인한다. 역할을 바꾼 뒤 몇 번째 요청부터 새 값이 실리는지도 센다.
|
||||
|
||||
어느 쪽으로 옮길지는 새로 일을 맡는 쪽이 상태와 검증을 감당할 수 있는지를 보고 정한다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "c2544a49e22ad6695bf9681de36e6aa72efafc7b20871d72a4ef2e318809966f",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: 7ff40767-a00b-4db2-98f6-0cdfce8c8936
|
||||
kind: QUESTION
|
||||
slug: edge-authorization-scope
|
||||
title: Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 36
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/7ff40767-a00b-4db2-98f6-0cdfce8c8936/edit"
|
||||
public: "https://hyeonworks.com/questions/edge-authorization-scope"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#검토한-선택지와-막힌-지점-ap4
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap4
|
||||
---
|
||||
|
||||
# Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가
|
||||
|
||||
지금 엣지는 인증된 사용자의 `user`와 `email`만 헤더로 넘기고, 업스트림 애플리케이션은 역할(role)을 보고 인가를 판단하지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Forward-Auth에서 Client가 보낸 Identity Header를 신뢰하면 안 되는 이유**
|
||||
엣지가 user와 email만 넘긴다는 사실을 여기서 가져왔다.
|
||||
- **Forward-Auth에서 Identity Header를 신뢰하기 위한 조건**
|
||||
넘길 헤더를 허용 목록(allowlist)으로 두는 기준과 검증 조건이 이 기록에 있다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
네 번째 선택지로 되돌아갈 때 따를 기준이 이 기록이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- 지금 엣지 응답은 user와 email만 넘긴다. 역할과 그룹, 테넌트, 인증 방식, 토큰 만료는 넘기지 않는다.
|
||||
- 업스트림의 신원 응답 엔드포인트는 역할을 확인하지 않고 누가 왔는지만 돌려준다.
|
||||
- 내부 토큰(internal token) 검증은 컨트롤러 한 곳에서만 하고, Spring Security 설정은 그 경로를 permitAll로 열어 둔다. 검증이 그 컨트롤러 안에만 있으니 같은 내부 경로 아래에 엔드포인트를 새로 추가해도 검증이 따라붙지 않는다. 필터나 인터셉터, Spring Security의 인증 처리 단계처럼 모든 요청이 지나는 공통 경계로 이 검사를 옮겨야 한다.
|
||||
- Nginx는 클라이언트가 보낸 같은 이름의 헤더를 합치지 않고 덮어쓴다. 헤더를 늘리면 늘어난 헤더도 똑같이 덮어쓰게 해야 한다.
|
||||
- 업스트림은 JWT를 입력으로 받지 않아서 헤더로 넘어온 값이 맞는지 확인할 방법이 없다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 헤더 종류가 늘어나면 그만큼 정해야 할 계약도 늘어난다.
|
||||
- 역할이 바뀌는 시각과 요청이 들어오는 시각이 달라서, 그 사이에 들어온 요청은 바뀌기 전 값을 볼 수도 있다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 역할 기반 인가가 필요한 요구가 들어왔을 때 어떻게 처리할지. 엣지가 사용자의 역할까지 확인해 헤더로 넘길지, 애플리케이션이 역할과 권한을 직접 조회해 인가를 판단할지.
|
||||
- 역할이 여러 개일 때 어떤 구분자와 이스케이프 규칙으로 보낼지. 값 안에 그 구분자가 들어오면 어떻게 되는지.
|
||||
- 헤더 크기 상한을 넘으면 어떻게 되는지. 프록시가 잘라 내는지, 요청 자체가 거부되는지.
|
||||
현재 fixture는 `/api/edge`와 `/`를 모두 `/edge/me`로 바꿔서 큰 헤더가 실린 요청을 통과시켜 본 적이 없다.
|
||||
- 역할이 바뀌었을 때 프록시 세션과 다운스트림 인가에 언제 반영되는지. 권한을 바꾸고 몇 분 뒤에 반영되는지.
|
||||
- 업스트림이 헤더가 있는지만 볼지, 값과 내부 서비스 식별값(service identity)까지 함께 볼지.
|
||||
|
||||
## 제약
|
||||
|
||||
- 넘길 헤더는 허용 목록으로 정해야 하고, 클라이언트가 보낸 같은 이름의 헤더는 언제나 덮어써야 한다.
|
||||
- 내부 토큰 검사가 컨트롤러 한 곳에만 있다. 헤더를 늘리기 전에 이 검사를 공통 경계로 옮겨야 한다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 인증만 엣지에 둔다
|
||||
|
||||
엣지가 넘기는 헤더를 user와 email 정도로 묶어 두면 엣지와 업스트림 사이의 계약을 작게 유지할 수 있다. 역할이나 권한을 헤더에 계속 더하지 않으니 헤더가 커지는 문제도 줄일 수 있다.
|
||||
|
||||
대신 인가는 업스트림 애플리케이션이 직접 판단한다. 넘겨받은 사용자 식별 정보로 자기 저장소에서 역할이나 권한을 조회하고, 그 요청을 허용할지 정해야 한다.
|
||||
|
||||
인가를 서비스가 직접 판단하다 보니 권한 조회와 인가 코드를 서비스마다 따로 만들어야 한다.
|
||||
|
||||
### 2. 역할 전달까지 엣지에 둔다
|
||||
|
||||
엣지가 공통 역할을 확인해 업스트림에 넘기면, 서비스마다 사용자 권한을 따로 조회하는 일을 줄일 수 있다.
|
||||
|
||||
대신 역할을 헤더로 넘기는 계약을 먼저 정해야 한다. 한 사용자가 역할을 여럿 가질 때 어떤 형식으로 직렬화할지, 헤더에 허용할 최대 크기를 얼마로 둘지, 역할을 바꾼 뒤 언제부터 새 값이 요청에 실리는지를 적어 두어야 한다.
|
||||
|
||||
업스트림은 넘겨받은 역할이 원래 인증 시스템의 값과 같은지 확인할 수 있어야 한다. 확인하지 못하면 엣지가 넘긴 값을 그대로 믿게 되는데, 엣지가 역할을 잘못 계산하거나 오래된 값을 넘기면 업스트림의 인가 판단도 그대로 틀어질 수 있다.
|
||||
|
||||
이 선택지를 고르면 엣지가 역할을 만드는 과정과 헤더가 지나는 경로까지 신뢰 경계 안으로 들어온다. 역할을 언제 갱신하고 전달 오류를 어떻게 잡을지도 같이 설계해야 한다.
|
||||
|
||||
### 3. 테넌트와 인가 판단까지 엣지에 둔다
|
||||
|
||||
테넌트는 어느 조직의 데이터까지 볼 수 있는지를 정하는 값이다. 그래서 잘못된 테넌트 값 하나가 넘어가면 다른 조직의 데이터에 접근하는 문제로 바로 이어질 수 있다.
|
||||
|
||||
테넌트를 엣지 헤더로 넘기려면 업스트림이 그 사용자가 실제로 그 테넌트에 속하는지 다시 확인할 방법이 있어야 한다. 지금 구성에는 다시 확인할 수단이 없어서 테넌트까지 엣지가 맡는 구조로 넓히기에는 위험이 크다.
|
||||
|
||||
테넌트나 역할로 인가를 판단하는 일까지 엣지로 옮기면 엣지가 애플리케이션의 도메인 규칙을 알아야 한다. 어떤 사용자가 어느 조직의 어떤 기능을 쓸 수 있는지 같은 정책이 바뀔 때마다 엣지 코드도 함께 고쳐 배포해야 한다.
|
||||
|
||||
그래서 엣지는 인증된 사용자 정보를 넘기는 쪽에 가깝게 두고, 테넌트 소속 여부나 도메인에 묶인 인가 규칙은 업스트림 애플리케이션이 검증하는 구조를 먼저 검토한다.
|
||||
|
||||
### 4. 헤더 계약 대신 BFF가 인가와 API 호출을 맡는다
|
||||
|
||||
역할이나 테넌트 같은 값을 엣지 헤더에 계속 더하는 대신, BFF(Backend For Frontend)가 필요한 정보를 직접 조회해 인가를 판단하고 API까지 부르는 구조도 고를 수 있다. BFF는 화면에 필요한 API를 브라우저 대신 불러 조합하는 서버다.
|
||||
|
||||
이러면 엣지는 애플리케이션의 역할과 테넌트, 권한 정책을 알 필요가 없다. BFF가 사용자와 권한을 조회해 인가를 판단하고, 화면에 필요한 여러 Resource Server의 API를 불러 결과를 합칠 수 있다.
|
||||
|
||||
대신 BFF를 넣으면 서버가 다시 인증 상태를 들고 있어야 한다. 브라우저와 BFF 사이의 애플리케이션 세션을 보호해야 한다. 쿠키 기반 세션을 쓴다면 쿠키가 요청마다 자동으로 붙으니, 값을 바꾸는 요청에는 별도 anti-CSRF token을 요구해 cross-site forged request를 구분하는 CSRF(Cross-Site Request Forgery) 검증도 있어야 한다. BFF를 여러 대로 늘려 인증 상태를 유지하려면 세션과 authorized client를 어떻게 공유할지 정하고, 공유 저장소의 장애와 만료 처리까지 운영해야 한다.
|
||||
|
||||
이 구조를 골랐다가 다시 엣지 쪽으로 되돌린다면 BFF가 맡던 사용자별 인가를 업스트림이나 별도 정책 서비스로 다시 옮겨야 한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
업스트림이 실제로 요구하는 사용자 속성(claim)부터 적는다.
|
||||
|
||||
1. 넘기려는 속성이 계속 늘어나는가.
|
||||
2. 역할이나 테넌트를 바꾸면 곧바로 반영돼야 하는가.
|
||||
3. 정책이 애플리케이션 도메인을 알아야 하는가.
|
||||
4. 헤더 값이 인가 판단의 근거가 되는가.
|
||||
5. 서비스마다 정책 차이가 커지는가.
|
||||
|
||||
2번부터 5번 가운데 하나라도 그렇다면 헤더를 늘리는 대신 BFF 구조를 검토한다.
|
||||
|
||||
역할을 헤더에 담는 구성을 먼저 만들고, 값을 여러 개 넣고 크기 상한까지 올려 무엇이 먼저 깨지는지 확인한다. 역할을 바꾼 뒤 몇 번째 요청부터 새 값이 실리는지도 센다.
|
||||
|
||||
어느 쪽으로 옮길지는 새로 일을 맡는 쪽이 상태와 검증을 감당할 수 있는지를 보고 정한다.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"sourceSha256": "c2544a49e22ad6695bf9681de36e6aa72efafc7b20871d72a4ef2e318809966f",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-edge-authorization-scope.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-10-question-multi-instance-session",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"startedAt": "2026-09-19T10:28:14+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:16+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md -o runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:14+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "Question 기록이며 이번 remediation에서 새 순서·구조·측정 관계가 추가되지 않았다. 해당 종류는 TechLog 본문 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:14+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:15+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:15+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:16+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:16+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md -o runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S3/command-initial.json",
|
||||
"sha256": "6ee0107a8b862c460ccbd2e424e12bb70d6bff9998f6780688368679af204e26"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md -o runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/S6/command-final.json",
|
||||
"sha256": "6ee0107a8b862c460ccbd2e424e12bb70d6bff9998f6780688368679af204e26"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "1e5163fde17c65b3689435bfe8bffc49bc52ae8017c71b97b597ef1318b5742d",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-10-question-multi-instance-session/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "ee36febb590ddd04c51cf3cd8aa1bb0e4ad809c26d7652f1baf7241ff5e71885"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:14+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:15+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:16+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1e5163fde17c65b3689435bfe8bffc49bc52ae8017c71b97b597ef1318b5742d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
|
||||
kind: QUESTION
|
||||
slug: server-session-pattern-multi-instance
|
||||
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 39
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
|
||||
public: "https://hyeonworks.com/questions/server-session-pattern-multi-instance"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
|
||||
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
두 상태가 모두 인스턴스 하나의 메모리에 있다는 사실을 이 기록에서 가져왔다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
이 패턴도 세션과 토큰을 같은 방식으로 저장한다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 질문에 답하면 그 기준에서 비어 있는 항목이 채워진다.
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
저장소 후보를 견주는 일만 따로 떼어 낸 질문이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- Mediator와 BFF는 로그인 상태와 OAuth 토큰을 서로 다른 저장소에 둔다. 로그인 상태는 HttpSession에 담고 세션 ID로 찾는다. OAuth 토큰은 OAuth2AuthorizedClientService에 담고, client registration 이름과 principal name으로 찾는다.
|
||||
- 세션과 OAuth 토큰을 담을 저장소를 따로 설정하지 않아서, Spring Boot 자동 구성이 고르는 메모리 기반 기본 구현이 쓰인다. 다만 저장소를 직접 만드는 Bean이 코드에 없으므로, 어떤 구현체가 실제로 올라오는지는 자동 구성 결과까지 열어 봐야 정확히 알 수 있다.
|
||||
- Spring Session이나 Redis, JDBC 기반 Token Store 같은 외부 저장소를 쓰지 않기 때문에, 로그인 세션과 OAuth 토큰은 모두 그 애플리케이션 인스턴스의 메모리에 있다. 인스턴스가 종료되거나 재시작되면 그 인스턴스가 들고 있던 로그인 세션과 OAuth 토큰도 같이 사라진다.
|
||||
- Authorized Client는 세션별로 나뉘지 않는다. 조회 기준에 세션 ID가 없다 보니 같은 사용자가 여러 브라우저에서 로그인하면 모두 같은 Authorized Client 하나를 쓴다.
|
||||
- OAuth2-Proxy 구조는 로그인 상태를 서버 저장소에 두지 않고, 필요한 최소한의 정보만 브라우저의 세션 쿠키에 담는다. 지금 설정에서 이 쿠키의 유효 시간은 1시간이다.
|
||||
- 지금 테스트에는 서버를 재시작하거나 요청이 다른 인스턴스로 갔을 때 로그인 상태와 OAuth 토큰을 그대로 쓸 수 있는지 확인하는 항목이 없다. 그래서 세션이나 토큰의 저장 방식을 바꿔도 기존 동작이 깨지지 않는지는 따로 확인해야 한다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 인스턴스가 둘 이상이다.
|
||||
- 재시작과 배포가 로그인 상태를 끊어서는 안 된다. 지금 구조에서는 끊긴다.
|
||||
- 같은 사용자가 여러 브라우저에서 로그인해도, 한쪽 세션이 다른 쪽의 토큰 항목을 덮어써서는 안 된다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 재시작한 뒤에 로그인이 유지되는가. 지금은 유지되지 않는다는 것까지만 확인했고, 무엇을 바꿀지는 아직 정하지 않았다.
|
||||
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
|
||||
- 같은 사용자의 여러 세션이 Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
|
||||
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
|
||||
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가. 여러 인스턴스에 흩어져 있는 세션과 토큰은 어떻게 함께 지우는가.
|
||||
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
|
||||
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금은 인스턴스 하나로만 실행하는 학습 환경이어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다. 장애 복구에 걸리는 시간과 비밀값 교체 절차도 확인한 범위에 넣지 못했다.
|
||||
- Authorized Client는 세션 ID로 저장하지도 조회하지도 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
|
||||
- Resource Server의 8081이 호스트에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 공유 저장소 — 상태 일관성과 저장소 가용성
|
||||
|
||||
HttpSession과 Authorized Client를 외부 공유 저장소에 두면 요청이 다른 인스턴스로 이동해도 같은 로그인 상태와 OAuth 토큰을 조회할 수 있다. 인스턴스 재시작과 라우팅 변경에서 상태를 이어 가는 대신 인증 경로가 그 저장소의 가용성에 의존한다.
|
||||
|
||||
이 선택의 핵심은 두 상태의 수명과 보호 방식이다. 세션과 토큰의 만료 시점, 로그아웃 때 지우는 순서, 저장 token 보호가 맞지 않으면 한쪽 상태만 남을 수 있다.
|
||||
|
||||
### 2. session affinity — 라우팅은 고정하지만 node loss는 남는다
|
||||
|
||||
Sticky Session은 같은 세션의 요청을 되도록 같은 인스턴스로 보낸다. 기존 메모리 저장 구조를 유지할 수 있다는 장점은 있지만 상태를 다른 인스턴스에 복제하지는 않는다.
|
||||
|
||||
고정된 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 사라진다. 따라서 이 선택은 정상 라우팅 중의 이동을 줄이는 방법이지, node loss 뒤 상태 복구 방법은 아니다.
|
||||
|
||||
### 3. 브라우저 토큰 — 공유할 서버 상태 자체를 없앤다
|
||||
|
||||
SPA(Single Page Application)처럼 브라우저가 OAuth 토큰을 들고 Resource Server를 직접 호출하면 애플리케이션 인스턴스가 공유할 로그인 세션과 OAuth 토큰 저장소가 사라진다. Resource Server는 요청마다 전달된 Access Token을 검증한다.
|
||||
|
||||
대신 자격 증명 소유권이 브라우저로 이동한다. 브라우저에 OAuth 토큰을 노출하지 않는 것이 요구사항이면 저장소 문제를 없애더라도 이 선택은 조건과 충돌한다.
|
||||
|
||||
### 4. client-side cookie — 저장소 문제가 아니라 신뢰 경계가 바뀐다
|
||||
|
||||
Forward-Auth의 최소 client-side cookie는 공유 저장소나 Sticky Session과 같은 층의 선택지가 아니다. 서버에 application-owned OAuth 상태를 두는 구조를 줄이고 인증 프록시가 cookie를 검증한 결과를 upstream identity로 전달한다.
|
||||
|
||||
레플리카는 같은 Cookie Secret을 검증할 수 있어야 하고, upstream은 프록시가 전달한 사용자 정보를 신뢰한다. 그래서 이 구조의 핵심 문제는 세션 저장소 일관성보다 secret 배포·교체, 직접 접근 차단, client-supplied identity header 제거 또는 덮어쓰기다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
인스턴스를 둘로 띄우고 순서대로 확인한다.
|
||||
|
||||
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 그대로 나오는지 본다.
|
||||
2. 인스턴스 하나를 재시작하고 같은 세션 쿠키로 로그인 상태가 유지되는지 본다.
|
||||
3. 같은 사용자로 두 브라우저에서 로그인해 Authorized Client 항목이 서로를 덮어쓰는지 본다.
|
||||
4. 한쪽에서 로그아웃한 뒤 다른 쪽 요청이 어떤 응답을 받는지 본다.
|
||||
5. 세션 만료를 토큰 만료보다 짧게 두고, 다시 길게 두고, 각 경우의 응답과 화면을 기록한다.
|
||||
|
||||
각 항목의 결과는 어느 엔드포인트와 핸들러가 요청을 받았는지, 다음 요청에 무엇이 입력으로 들어갔는지, 최종 응답이 무엇이었는지까지 적는다.
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
|
||||
kind: QUESTION
|
||||
slug: server-session-pattern-multi-instance
|
||||
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 39
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
|
||||
public: "https://hyeonworks.com/questions/server-session-pattern-multi-instance"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
|
||||
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
두 상태가 모두 인스턴스 하나의 메모리에 있다는 사실을 이 기록에서 가져왔다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
이 패턴도 세션과 토큰을 같은 방식으로 저장한다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 질문에 답하면 그 기준에서 비어 있는 항목이 채워진다.
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
저장소 후보를 견주는 일만 따로 떼어 낸 질문이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- Mediator와 BFF는 로그인 상태와 OAuth 토큰을 서로 다른 저장소에 둔다. 로그인 상태는 HttpSession에 담고 세션 ID로 찾는다. OAuth 토큰은 OAuth2AuthorizedClientService에 담고, client registration 이름과 principal name으로 찾는다.
|
||||
- 세션과 OAuth 토큰을 담을 저장소를 따로 설정하지 않아서, Spring Boot 자동 구성이 고르는 메모리 기반 기본 구현이 쓰인다. 다만 저장소를 직접 만드는 Bean이 코드에 없으므로, 어떤 구현체가 실제로 올라오는지는 자동 구성 결과까지 열어 봐야 정확히 알 수 있다.
|
||||
- Spring Session이나 Redis, JDBC 기반 Token Store 같은 외부 저장소를 쓰지 않기 때문에, 로그인 세션과 OAuth 토큰은 모두 그 애플리케이션 인스턴스의 메모리에 있다. 인스턴스가 종료되거나 재시작되면 그 인스턴스가 들고 있던 로그인 세션과 OAuth 토큰도 같이 사라진다.
|
||||
- Authorized Client는 세션별로 나뉘지 않는다. 조회 기준에 세션 ID가 없다 보니 같은 사용자가 여러 브라우저에서 로그인하면 모두 같은 Authorized Client 하나를 쓴다.
|
||||
- OAuth2-Proxy 구조는 로그인 상태를 서버 저장소에 두지 않고, 필요한 최소한의 정보만 브라우저의 세션 쿠키에 담는다. 지금 설정에서 이 쿠키의 유효 시간은 1시간이다.
|
||||
- 지금 테스트에는 서버를 재시작하거나 요청이 다른 인스턴스로 갔을 때 로그인 상태와 OAuth 토큰을 그대로 쓸 수 있는지 확인하는 항목이 없다. 그래서 세션이나 토큰의 저장 방식을 바꿔도 기존 동작이 깨지지 않는지는 따로 확인해야 한다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 인스턴스가 둘 이상이다.
|
||||
- 재시작과 배포가 로그인 상태를 끊어서는 안 된다. 지금 구조에서는 끊긴다.
|
||||
- 같은 사용자가 여러 브라우저에서 로그인해도, 한쪽 세션이 다른 쪽의 토큰 항목을 덮어써서는 안 된다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 재시작한 뒤에 로그인이 유지되는가. 지금은 유지되지 않는다는 것까지만 확인했고, 무엇을 바꿀지는 아직 정하지 않았다.
|
||||
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
|
||||
- 같은 사용자의 여러 세션이 Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
|
||||
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
|
||||
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가. 여러 인스턴스에 흩어져 있는 세션과 토큰은 어떻게 함께 지우는가.
|
||||
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
|
||||
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금은 인스턴스 하나로만 실행하는 학습 환경이어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다. 장애 복구에 걸리는 시간과 비밀값 교체 절차도 확인한 범위에 넣지 못했다.
|
||||
- Authorized Client는 세션 ID로 저장하지도 조회하지도 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
|
||||
- Resource Server의 8081이 호스트에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 공유 저장소 — 상태 일관성과 저장소 가용성
|
||||
|
||||
HttpSession과 Authorized Client를 외부 공유 저장소에 두면 요청이 다른 인스턴스로 이동해도 같은 로그인 상태와 OAuth 토큰을 조회할 수 있다. 인스턴스 재시작과 라우팅 변경에서 상태를 이어 가는 대신 인증 경로가 그 저장소의 가용성에 의존한다.
|
||||
|
||||
이 선택의 핵심은 두 상태의 수명과 보호 방식이다. 세션과 토큰의 만료 시점, 로그아웃 때 지우는 순서, 저장 token 보호가 맞지 않으면 한쪽 상태만 남을 수 있다.
|
||||
|
||||
### 2. session affinity — 라우팅은 고정하지만 node loss는 남는다
|
||||
|
||||
Sticky Session은 같은 세션의 요청을 되도록 같은 인스턴스로 보낸다. 기존 메모리 저장 구조를 유지할 수 있다는 장점은 있지만 상태를 다른 인스턴스에 복제하지는 않는다.
|
||||
|
||||
고정된 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 사라진다. 따라서 이 선택은 정상 라우팅 중의 이동을 줄이는 방법이지, node loss 뒤 상태 복구 방법은 아니다.
|
||||
|
||||
### 3. 브라우저 토큰 — 공유할 서버 상태 자체를 없앤다
|
||||
|
||||
SPA(Single Page Application)처럼 브라우저가 OAuth 토큰을 들고 Resource Server를 직접 호출하면 애플리케이션 인스턴스가 공유할 로그인 세션과 OAuth 토큰 저장소가 사라진다. Resource Server는 요청마다 전달된 Access Token을 검증한다.
|
||||
|
||||
대신 자격 증명 소유권이 브라우저로 이동한다. 브라우저에 OAuth 토큰을 노출하지 않는 것이 요구사항이면 저장소 문제를 없애더라도 이 선택은 조건과 충돌한다.
|
||||
|
||||
### 4. client-side cookie — 저장소 문제가 아니라 신뢰 경계가 바뀐다
|
||||
|
||||
Forward-Auth의 최소 client-side cookie는 공유 저장소나 Sticky Session과 같은 층의 선택지가 아니다. 서버에 application-owned OAuth 상태를 두는 구조를 줄이고 인증 프록시가 cookie를 검증한 결과를 upstream identity로 전달한다.
|
||||
|
||||
레플리카는 같은 Cookie Secret을 검증할 수 있어야 하고, upstream은 프록시가 전달한 사용자 정보를 신뢰한다. 그래서 이 구조의 핵심 문제는 세션 저장소 일관성보다 secret 배포·교체, 직접 접근 차단, client-supplied identity header 제거 또는 덮어쓰기다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
인스턴스를 둘로 띄우고 순서대로 확인한다.
|
||||
|
||||
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 그대로 나오는지 본다.
|
||||
2. 인스턴스 하나를 재시작하고 같은 세션 쿠키로 로그인 상태가 유지되는지 본다.
|
||||
3. 같은 사용자로 두 브라우저에서 로그인해 Authorized Client 항목이 서로를 덮어쓰는지 본다.
|
||||
4. 한쪽에서 로그아웃한 뒤 다른 쪽 요청이 어떤 응답을 받는지 본다.
|
||||
5. 세션 만료를 토큰 만료보다 짧게 두고, 다시 길게 두고, 각 경우의 응답과 화면을 기록한다.
|
||||
|
||||
각 항목의 결과는 어느 엔드포인트와 핸들러가 요청을 받았는지, 다음 요청에 무엇이 입력으로 들어갔는지, 최종 응답이 무엇이었는지까지 적는다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "1e5163fde17c65b3689435bfe8bffc49bc52ae8017c71b97b597ef1318b5742d",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
---
|
||||
id: c72656b5-842d-45d9-b5f6-82b66b09d0b9
|
||||
kind: QUESTION
|
||||
slug: server-session-pattern-multi-instance
|
||||
title: 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 39
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/c72656b5-842d-45d9-b5f6-82b66b09d0b9/edit"
|
||||
public: "https://hyeonworks.com/questions/server-session-pattern-multi-instance"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#문제를-어렵게-만든-제약-학습-환경
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# 서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가
|
||||
|
||||
Mediator와 BFF(Backend for Frontend)는 브라우저의 로그인 세션과 OAuth 토큰을 서로 다른 저장소에 둔다. 지금 구현은 두 저장소를 모두 애플리케이션 서버의 메모리에 두기 때문에, 서버 프로세스가 끝나면 담아 둔 상태도 같이 사라진다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **Browser Token을 없애면서 BFF에 Session과 CSRF 책임이 생긴 과정**
|
||||
두 상태가 모두 인스턴스 하나의 메모리에 있다는 사실을 이 기록에서 가져왔다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
이 패턴도 세션과 토큰을 같은 방식으로 저장한다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
이 질문에 답하면 그 기준에서 비어 있는 항목이 채워진다.
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
저장소 후보를 견주는 일만 따로 떼어 낸 질문이다.
|
||||
|
||||
## 사실
|
||||
|
||||
- Mediator와 BFF는 로그인 상태와 OAuth 토큰을 서로 다른 저장소에 둔다. 로그인 상태는 HttpSession에 담고 세션 ID로 찾는다. OAuth 토큰은 OAuth2AuthorizedClientService에 담고, client registration 이름과 principal name으로 찾는다.
|
||||
- 세션과 OAuth 토큰을 담을 저장소를 따로 설정하지 않아서, Spring Boot 자동 구성이 고르는 메모리 기반 기본 구현이 쓰인다. 다만 저장소를 직접 만드는 Bean이 코드에 없으므로, 어떤 구현체가 실제로 올라오는지는 자동 구성 결과까지 열어 봐야 정확히 알 수 있다.
|
||||
- Spring Session이나 Redis, JDBC 기반 Token Store 같은 외부 저장소를 쓰지 않기 때문에, 로그인 세션과 OAuth 토큰은 모두 그 애플리케이션 인스턴스의 메모리에 있다. 인스턴스가 종료되거나 재시작되면 그 인스턴스가 들고 있던 로그인 세션과 OAuth 토큰도 같이 사라진다.
|
||||
- Authorized Client는 세션별로 나뉘지 않는다. 조회 기준에 세션 ID가 없다 보니 같은 사용자가 여러 브라우저에서 로그인하면 모두 같은 Authorized Client 하나를 쓴다.
|
||||
- OAuth2-Proxy 구조는 로그인 상태를 서버 저장소에 두지 않고, 필요한 최소한의 정보만 브라우저의 세션 쿠키에 담는다. 지금 설정에서 이 쿠키의 유효 시간은 1시간이다.
|
||||
- 지금 테스트에는 서버를 재시작하거나 요청이 다른 인스턴스로 갔을 때 로그인 상태와 OAuth 토큰을 그대로 쓸 수 있는지 확인하는 항목이 없다. 그래서 세션이나 토큰의 저장 방식을 바꿔도 기존 동작이 깨지지 않는지는 따로 확인해야 한다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 인스턴스가 둘 이상이다.
|
||||
- 재시작과 배포가 로그인 상태를 끊어서는 안 된다. 지금 구조에서는 끊긴다.
|
||||
- 같은 사용자가 여러 브라우저에서 로그인해도, 한쪽 세션이 다른 쪽의 토큰 항목을 덮어써서는 안 된다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 재시작한 뒤에 로그인이 유지되는가. 지금은 유지되지 않는다는 것까지만 확인했고, 무엇을 바꿀지는 아직 정하지 않았다.
|
||||
- 요청이 다른 인스턴스로 가도 같은 세션을 찾는가.
|
||||
- 같은 사용자의 여러 세션이 Authorized Client 항목 하나를 함께 쓰는가, 아니면 서로 덮어쓰는가. 한쪽에서 로그아웃하면 다른 쪽 로그인도 끊기는가.
|
||||
- 저장한 Refresh Token이 암호화되는가. 저장소를 여는 사람이 그 값을 그대로 읽는가.
|
||||
- 로그아웃할 때 HttpSession과 Authorized Client를 모두 지우는가. 한쪽만 지웠을 때 다음 요청이나 재로그인에서 어떤 상태가 살아나는가. 여러 인스턴스에 흩어져 있는 세션과 토큰은 어떻게 함께 지우는가.
|
||||
- 세션 만료와 토큰 만료가 어긋나면 무엇이 먼저 실패하고 사용자 화면에는 무엇이 보이는가.
|
||||
- OAuth2-Proxy 구조의 레플리카들은 같은 Cookie Secret을 어떻게 나눠 갖고 어떻게 바꾸는가. 바꾸는 동안 이미 로그인해 있던 사람은 어떻게 되는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- 지금은 인스턴스 하나로만 실행하는 학습 환경이어서 레플리카 사이의 세션 조회나 장애 조치(failover) 동작은 아직 만들지 않았다. 장애 복구에 걸리는 시간과 비밀값 교체 절차도 확인한 범위에 넣지 못했다.
|
||||
- Authorized Client는 세션 ID로 저장하지도 조회하지도 않는다. 그래서 여러 인스턴스가 같은 세션을 쓰도록 세션 저장소만 공유 저장소로 바꿔서는 부족하다. 로그인 세션을 여러 인스턴스가 나눠 쓰는 방법과, OAuth 토큰이 들어 있는 Authorized Client를 어디에 어떻게 저장할지는 각각 따로 설계해야 한다.
|
||||
- Resource Server의 8081이 호스트에도 열려 있어서, 모든 클라이언트가 BFF만 거치도록 네트워크가 강제하고 있지는 않다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 공유 저장소 — 상태 일관성과 저장소 가용성
|
||||
|
||||
HttpSession과 Authorized Client를 외부 공유 저장소에 두면 요청이 다른 인스턴스로 이동해도 같은 로그인 상태와 OAuth 토큰을 조회할 수 있다. 인스턴스 재시작과 라우팅 변경에서 상태를 이어 가는 대신 인증 경로가 그 저장소의 가용성에 의존한다.
|
||||
|
||||
이 선택의 핵심은 두 상태의 수명과 보호 방식이다. 세션과 토큰의 만료 시점, 로그아웃 때 지우는 순서, 저장 token 보호가 맞지 않으면 한쪽 상태만 남을 수 있다.
|
||||
|
||||
### 2. session affinity — 라우팅은 고정하지만 node loss는 남는다
|
||||
|
||||
Sticky Session은 같은 세션의 요청을 되도록 같은 인스턴스로 보낸다. 기존 메모리 저장 구조를 유지할 수 있다는 장점은 있지만 상태를 다른 인스턴스에 복제하지는 않는다.
|
||||
|
||||
고정된 인스턴스가 종료되면 그 메모리에 있던 로그인 세션과 OAuth 토큰도 사라진다. 따라서 이 선택은 정상 라우팅 중의 이동을 줄이는 방법이지, node loss 뒤 상태 복구 방법은 아니다.
|
||||
|
||||
### 3. 브라우저 토큰 — 공유할 서버 상태 자체를 없앤다
|
||||
|
||||
SPA(Single Page Application)처럼 브라우저가 OAuth 토큰을 들고 Resource Server를 직접 호출하면 애플리케이션 인스턴스가 공유할 로그인 세션과 OAuth 토큰 저장소가 사라진다. Resource Server는 요청마다 전달된 Access Token을 검증한다.
|
||||
|
||||
대신 자격 증명 소유권이 브라우저로 이동한다. 브라우저에 OAuth 토큰을 노출하지 않는 것이 요구사항이면 저장소 문제를 없애더라도 이 선택은 조건과 충돌한다.
|
||||
|
||||
### 4. client-side cookie — 저장소 문제가 아니라 신뢰 경계가 바뀐다
|
||||
|
||||
Forward-Auth의 최소 client-side cookie는 공유 저장소나 Sticky Session과 같은 층의 선택지가 아니다. 서버에 application-owned OAuth 상태를 두는 구조를 줄이고 인증 프록시가 cookie를 검증한 결과를 upstream identity로 전달한다.
|
||||
|
||||
레플리카는 같은 Cookie Secret을 검증할 수 있어야 하고, upstream은 프록시가 전달한 사용자 정보를 신뢰한다. 그래서 이 구조의 핵심 문제는 세션 저장소 일관성보다 secret 배포·교체, 직접 접근 차단, client-supplied identity header 제거 또는 덮어쓰기다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
인스턴스를 둘로 띄우고 순서대로 확인한다.
|
||||
|
||||
1. 한쪽에서 로그인한 뒤 다른 인스턴스로 요청을 보내 200이 그대로 나오는지 본다.
|
||||
2. 인스턴스 하나를 재시작하고 같은 세션 쿠키로 로그인 상태가 유지되는지 본다.
|
||||
3. 같은 사용자로 두 브라우저에서 로그인해 Authorized Client 항목이 서로를 덮어쓰는지 본다.
|
||||
4. 한쪽에서 로그아웃한 뒤 다른 쪽 요청이 어떤 응답을 받는지 본다.
|
||||
5. 세션 만료를 토큰 만료보다 짧게 두고, 다시 길게 두고, 각 경우의 응답과 화면을 기록한다.
|
||||
|
||||
각 항목의 결과는 어느 엔드포인트와 핸들러가 요청을 받았는지, 다음 요청에 무엇이 입력으로 들어갔는지, 최종 응답이 무엇이었는지까지 적는다.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"sourceSha256": "1e5163fde17c65b3689435bfe8bffc49bc52ae8017c71b97b597ef1318b5742d",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-multi-instance-session.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-11-question-refresh-rotation-replica",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"startedAt": "2026-09-19T10:28:16+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:18+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:16+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:16+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md -o runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:16+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:16+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:16+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:17+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "Question 기록이며 이번 remediation에서 새 순서·구조·측정 관계가 추가되지 않았다. 해당 종류는 TechLog 본문 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:17+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:17+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:18+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:18+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:18+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:18+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md -o runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S3/command-initial.json",
|
||||
"sha256": "4ea497450f6ce6c97b561bf96d7132db1775e06d3468de4faf727c61a09f9eb4"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md -o runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/S6/command-final.json",
|
||||
"sha256": "4ea497450f6ce6c97b561bf96d7132db1775e06d3468de4faf727c61a09f9eb4"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "f24103187a3de269757f2574c82d30efb1e50072fc2860610a192304d35e111c",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-11-question-refresh-rotation-replica/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "ff67da4b9a2dcdc8cde4dc10c6aa8515c56383d35bb270bbc8c061a68e913f6a"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:16+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:16+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:17+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:17+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:18+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "f24103187a3de269757f2574c82d30efb1e50072fc2860610a192304d35e111c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
|
||||
kind: QUESTION
|
||||
slug: refresh-rotation-replica-contention
|
||||
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 33
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
|
||||
public: "https://hyeonworks.com/questions/refresh-rotation-replica-contention"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
|
||||
realm은 refresh token rotation과 최대 재사용 횟수 0회를 쓴다. `refreshTokenMaxReuse`는 시간 창이 아니라 동일 refresh token의 허용 재사용 횟수를 나타낸다. 한 번 갱신하면 이전 refresh token이 바로
|
||||
무효가 되기 때문에, 두 replica가 같은 refresh token으로 동시에 갱신하면 두 번째 사용이 거부될 수 있다.
|
||||
실제 Keycloak 응답과 session에 미치는 영향은 아직 재현하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
이 질문에 답하기 전에 저장소부터 정해야 한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
rotation과 최대 재사용 횟수 0회를 쓰는 구성이 여기서 나왔다.
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
인스턴스를 여러 대 띄워야 이 경쟁이 생긴다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준에 들어 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- realm에는 refresh token rotation과 최대 재사용 횟수 0회가 설정되어 있다.
|
||||
- 커밋된 테스트는 새 refresh token이 발급되는지, 이전 token이 거부되는지, revocation 뒤 refresh가
|
||||
실패하는지를 확인한다. 다만 이 셋은 refresh token을 직접 써서 받은 결과라, 애플리케이션이 스스로
|
||||
갱신할 때도 같은 결과가 나온다고 보지 않는다.
|
||||
- authorized client manager에는 refresh-token provider가 구성되어 있어, access token이 만료되면
|
||||
refresh를 시도할 수 있다.
|
||||
- `refreshTokenMaxReuse`는 재사용 횟수 설정이고 refresh token lifespan은 별도의 시간 설정이다. 이 문서에서는 둘을 같은 허용 시간으로 취급하지 않는다.
|
||||
- access token이 만료될 때까지 기다려 실제로 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
|
||||
- 현재 authorized client 저장소는 프로세스 안에만 있어서(process-local) replica끼리 같은 refresh token
|
||||
상태를 공유하지 않는다. 그래서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
|
||||
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안 화면은
|
||||
정상으로 보인다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 replica를 둘 이상 띄우고 저장소를 공유하므로, 두 replica가 같은 authorized client 항목을 읽는다.
|
||||
- 두 replica의 access token이 비슷한 시각에 만료되면 각 replica가 따로 갱신을 시도한다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하는가.
|
||||
- 최대 재사용 횟수 0회에서 두 번째 사용이 거부되면 사용자 화면에 무엇이 보이는가.
|
||||
로그인 만료로 보이는가, 일시적 오류로 보이는가.
|
||||
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하는가, 아니면 재인증까지 해야 하는가.
|
||||
- 갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가.
|
||||
- lock을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가.
|
||||
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- rotation과 최대 재사용 횟수 0회는 이미 realm에 설정했다. 이 전제는 바꾸지 않고 답한다.
|
||||
- 이미 발급된 access token은 만료 전까지 쓸 수 있으므로 refresh 실패가 곧바로 드러나지 않을 수 있다.
|
||||
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
|
||||
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 분산 lock으로 갱신을 직렬화한다
|
||||
|
||||
분산 lock은 여러 프로세스가 같은 자원을 동시에 건드리지 못하도록 프로세스 밖에 두는 잠금이다.
|
||||
한 replica만 갱신하고 나머지는 그 갱신이 끝나기를 기다렸다가 결과를 읽는다.
|
||||
|
||||
대신 lock 저장소의 가용성, lock 만료, 재진입, lock을 쥔 프로세스가 내려간 상황까지 함께 처리해야 한다.
|
||||
|
||||
### 2. 각자 갱신하고 실패는 재시도로 처리한다
|
||||
|
||||
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는
|
||||
전제인데, 이 재시도가 성립하는지부터 확인해야 한다.
|
||||
|
||||
같은 refresh token이 두 번 들어오는 것을 잡아내는 reuse detection 정책에 따라, 두 번째 사용이
|
||||
token family 전체에 영향을 줄 수 있다. 그러면 재시도 한 번으로 끝나지 않고 재인증이 필요할 수 있다.
|
||||
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 저장소를 다시 읽는 순서도 따로
|
||||
확인해야 한다.
|
||||
|
||||
### 3. 갱신 전용 경로를 하나 둔다
|
||||
|
||||
refresh를 전담하는 구성요소 하나만 refresh token을 쓰고, 다른 replica는 그 결과를 조회하게 둘 수 있다.
|
||||
|
||||
이 구성요소가 멈추면 access token이 만료된 뒤에 갱신할 주체가 없어진다. 그래서 이 구성요소를 어떻게
|
||||
살려 두고 어떻게 복구할지를 먼저 정해야 한다.
|
||||
|
||||
### 4. 제약상 제외 — 최대 재사용 횟수를 늘린다
|
||||
|
||||
최대 재사용 횟수를 늘리면 동일 refresh token의 추가 사용을 일정 횟수 받아들이도록 구성할 수 있다. 그만큼 탈취된 refresh token의 replay를 허용할 여지도 커진다. 현재 실험은 rotation과 최대 재사용 횟수 0회를 전제로 하므로 이 선택지는 제외한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
저장소를 공유한 뒤에 재현한다.
|
||||
|
||||
1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.
|
||||
2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.
|
||||
3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.
|
||||
4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.
|
||||
5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.
|
||||
|
||||
실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
||||
rotation과 최대 재사용 횟수 0회를 바꾸는 선택지는 지금은 제외로 두고 나중에 다시 본다. 정확한 Keycloak runtime의 동시 refresh 결과는 source repository와 실행 환경을 다시 사용할 수 있을 때 realm 값과 함께 별도 evidence로 확인한다.
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
|
||||
kind: QUESTION
|
||||
slug: refresh-rotation-replica-contention
|
||||
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 33
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
|
||||
public: "https://hyeonworks.com/questions/refresh-rotation-replica-contention"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
|
||||
realm은 refresh token rotation과 최대 재사용 횟수 0회를 쓴다. `refreshTokenMaxReuse`는 시간 창이 아니라 동일 refresh token의 허용 재사용 횟수를 나타낸다. 한 번 갱신하면 이전 refresh token이 바로
|
||||
무효가 되기 때문에, 두 replica가 같은 refresh token으로 동시에 갱신하면 두 번째 사용이 거부될 수 있다.
|
||||
실제 Keycloak 응답과 session에 미치는 영향은 아직 재현하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
이 질문에 답하기 전에 저장소부터 정해야 한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
rotation과 최대 재사용 횟수 0회를 쓰는 구성이 여기서 나왔다.
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
인스턴스를 여러 대 띄워야 이 경쟁이 생긴다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준에 들어 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- realm에는 refresh token rotation과 최대 재사용 횟수 0회가 설정되어 있다.
|
||||
- 커밋된 테스트는 새 refresh token이 발급되는지, 이전 token이 거부되는지, revocation 뒤 refresh가
|
||||
실패하는지를 확인한다. 다만 이 셋은 refresh token을 직접 써서 받은 결과라, 애플리케이션이 스스로
|
||||
갱신할 때도 같은 결과가 나온다고 보지 않는다.
|
||||
- authorized client manager에는 refresh-token provider가 구성되어 있어, access token이 만료되면
|
||||
refresh를 시도할 수 있다.
|
||||
- `refreshTokenMaxReuse`는 재사용 횟수 설정이고 refresh token lifespan은 별도의 시간 설정이다. 이 문서에서는 둘을 같은 허용 시간으로 취급하지 않는다.
|
||||
- access token이 만료될 때까지 기다려 실제로 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
|
||||
- 현재 authorized client 저장소는 프로세스 안에만 있어서(process-local) replica끼리 같은 refresh token
|
||||
상태를 공유하지 않는다. 그래서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
|
||||
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안 화면은
|
||||
정상으로 보인다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 replica를 둘 이상 띄우고 저장소를 공유하므로, 두 replica가 같은 authorized client 항목을 읽는다.
|
||||
- 두 replica의 access token이 비슷한 시각에 만료되면 각 replica가 따로 갱신을 시도한다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하는가.
|
||||
- 최대 재사용 횟수 0회에서 두 번째 사용이 거부되면 사용자 화면에 무엇이 보이는가.
|
||||
로그인 만료로 보이는가, 일시적 오류로 보이는가.
|
||||
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하는가, 아니면 재인증까지 해야 하는가.
|
||||
- 갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가.
|
||||
- lock을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가.
|
||||
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- rotation과 최대 재사용 횟수 0회는 이미 realm에 설정했다. 이 전제는 바꾸지 않고 답한다.
|
||||
- 이미 발급된 access token은 만료 전까지 쓸 수 있으므로 refresh 실패가 곧바로 드러나지 않을 수 있다.
|
||||
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
|
||||
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 분산 lock으로 갱신을 직렬화한다
|
||||
|
||||
분산 lock은 여러 프로세스가 같은 자원을 동시에 건드리지 못하도록 프로세스 밖에 두는 잠금이다.
|
||||
한 replica만 갱신하고 나머지는 그 갱신이 끝나기를 기다렸다가 결과를 읽는다.
|
||||
|
||||
대신 lock 저장소의 가용성, lock 만료, 재진입, lock을 쥔 프로세스가 내려간 상황까지 함께 처리해야 한다.
|
||||
|
||||
### 2. 각자 갱신하고 실패는 재시도로 처리한다
|
||||
|
||||
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는
|
||||
전제인데, 이 재시도가 성립하는지부터 확인해야 한다.
|
||||
|
||||
같은 refresh token이 두 번 들어오는 것을 잡아내는 reuse detection 정책에 따라, 두 번째 사용이
|
||||
token family 전체에 영향을 줄 수 있다. 그러면 재시도 한 번으로 끝나지 않고 재인증이 필요할 수 있다.
|
||||
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 저장소를 다시 읽는 순서도 따로
|
||||
확인해야 한다.
|
||||
|
||||
### 3. 갱신 전용 경로를 하나 둔다
|
||||
|
||||
refresh를 전담하는 구성요소 하나만 refresh token을 쓰고, 다른 replica는 그 결과를 조회하게 둘 수 있다.
|
||||
|
||||
이 구성요소가 멈추면 access token이 만료된 뒤에 갱신할 주체가 없어진다. 그래서 이 구성요소를 어떻게
|
||||
살려 두고 어떻게 복구할지를 먼저 정해야 한다.
|
||||
|
||||
### 4. 제약상 제외 — 최대 재사용 횟수를 늘린다
|
||||
|
||||
최대 재사용 횟수를 늘리면 동일 refresh token의 추가 사용을 일정 횟수 받아들이도록 구성할 수 있다. 그만큼 탈취된 refresh token의 replay를 허용할 여지도 커진다. 현재 실험은 rotation과 최대 재사용 횟수 0회를 전제로 하므로 이 선택지는 제외한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
저장소를 공유한 뒤에 재현한다.
|
||||
|
||||
1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.
|
||||
2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.
|
||||
3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.
|
||||
4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.
|
||||
5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.
|
||||
|
||||
실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
||||
rotation과 최대 재사용 횟수 0회를 바꾸는 선택지는 지금은 제외로 두고 나중에 다시 본다. 정확한 Keycloak runtime의 동시 refresh 결과는 source repository와 실행 환경을 다시 사용할 수 있을 때 realm 값과 함께 별도 evidence로 확인한다.
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"authority": "deterministic",
|
||||
"gate": "command-pedagogy-signals",
|
||||
"section_id": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"mode": "reference",
|
||||
"source_sha256": "f24103187a3de269757f2574c82d30efb1e50072fc2860610a192304d35e111c",
|
||||
"result": "PASS",
|
||||
"requires_editor": false,
|
||||
"blocks": [],
|
||||
"findings": [],
|
||||
"extensions": {
|
||||
"command_like_text_blocks": []
|
||||
}
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
---
|
||||
id: 9ae4ec71-a32e-49a7-88c2-f7368541c28d
|
||||
kind: QUESTION
|
||||
slug: refresh-rotation-replica-contention
|
||||
title: Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
topic: oauth-oidc-auth-boundary
|
||||
topicName: OAuth/OIDC 인증 경계
|
||||
project: KeyCloak Patterns
|
||||
status: 게시 중
|
||||
version: 33
|
||||
questionStatus: OPEN
|
||||
studio: "https://hyeonworks.com/studio/documents/9ae4ec71-a32e-49a7-88c2-f7368541c28d/edit"
|
||||
public: "https://hyeonworks.com/questions/refresh-rotation-replica-contention"
|
||||
sourceRevision: keycloak-patterns-lab@2026-08
|
||||
source:
|
||||
- final/document.md#선택의-이유와-지킨-경계-ap1
|
||||
- final/document.md#얻은-것-잃은-것-적용하지-않을-때-ap2
|
||||
---
|
||||
|
||||
# Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가
|
||||
|
||||
realm은 refresh token rotation과 최대 재사용 횟수 0회를 쓴다. `refreshTokenMaxReuse`는 시간 창이 아니라 동일 refresh token의 허용 재사용 횟수를 나타낸다. 한 번 갱신하면 이전 refresh token이 바로
|
||||
무효가 되기 때문에, 두 replica가 같은 refresh token으로 동시에 갱신하면 두 번째 사용이 거부될 수 있다.
|
||||
실제 Keycloak 응답과 session에 미치는 영향은 아직 재현하지 않았다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가**
|
||||
이 질문에 답하기 전에 저장소부터 정해야 한다.
|
||||
- **Refresh Token 관리만 서버로 이전, Access Token은 여전히 Browser에 노출**
|
||||
rotation과 최대 재사용 횟수 0회를 쓰는 구성이 여기서 나왔다.
|
||||
- **서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가**
|
||||
인스턴스를 여러 대 띄워야 이 경쟁이 생긴다.
|
||||
- **BFF 인증 구조 설계 기준**
|
||||
갱신 실패를 화면 오류로 바꾸는 규칙이 이 기준에 들어 있다.
|
||||
|
||||
## 사실
|
||||
|
||||
- realm에는 refresh token rotation과 최대 재사용 횟수 0회가 설정되어 있다.
|
||||
- 커밋된 테스트는 새 refresh token이 발급되는지, 이전 token이 거부되는지, revocation 뒤 refresh가
|
||||
실패하는지를 확인한다. 다만 이 셋은 refresh token을 직접 써서 받은 결과라, 애플리케이션이 스스로
|
||||
갱신할 때도 같은 결과가 나온다고 보지 않는다.
|
||||
- authorized client manager에는 refresh-token provider가 구성되어 있어, access token이 만료되면
|
||||
refresh를 시도할 수 있다.
|
||||
- `refreshTokenMaxReuse`는 재사용 횟수 설정이고 refresh token lifespan은 별도의 시간 설정이다. 이 문서에서는 둘을 같은 허용 시간으로 취급하지 않는다.
|
||||
- access token이 만료될 때까지 기다려 실제로 갱신이 성공하고 새 token이 저장되는지까지는 확인하지 않았다.
|
||||
- 현재 authorized client 저장소는 프로세스 안에만 있어서(process-local) replica끼리 같은 refresh token
|
||||
상태를 공유하지 않는다. 그래서 이번 단일 인스턴스 검증에서는 동시 refresh 경쟁을 재현하지 않았다.
|
||||
- 이미 발급된 access token은 만료 전까지 API에서 계속 통하기 때문에, 갱신이 실패해도 그동안 화면은
|
||||
정상으로 보인다.
|
||||
|
||||
## 가정
|
||||
|
||||
- 운영에서는 replica를 둘 이상 띄우고 저장소를 공유하므로, 두 replica가 같은 authorized client 항목을 읽는다.
|
||||
- 두 replica의 access token이 비슷한 시각에 만료되면 각 replica가 따로 갱신을 시도한다.
|
||||
|
||||
## 미지수
|
||||
|
||||
- 같은 refresh token으로 두 replica가 동시에 갱신하면 각 replica가 어떻게 동작하는가.
|
||||
- 최대 재사용 횟수 0회에서 두 번째 사용이 거부되면 사용자 화면에 무엇이 보이는가.
|
||||
로그인 만료로 보이는가, 일시적 오류로 보이는가.
|
||||
- 저장소에서 새 token을 다시 읽어 재시도하면 성공하는가, 아니면 재인증까지 해야 하는가.
|
||||
- 갱신을 한 곳에서만 할 것인가, 각자 하게 두고 실패는 재시도로 처리할 것인가.
|
||||
- lock을 쓴다면 어디에 두고 얼마나 잡는가. 잡은 채로 프로세스가 내려가면 어떻게 푸는가.
|
||||
- 갱신 실패를 로그인 만료와 구분해 표시할 수 있는가.
|
||||
|
||||
## 제약
|
||||
|
||||
- rotation과 최대 재사용 횟수 0회는 이미 realm에 설정했다. 이 전제는 바꾸지 않고 답한다.
|
||||
- 이미 발급된 access token은 만료 전까지 쓸 수 있으므로 refresh 실패가 곧바로 드러나지 않을 수 있다.
|
||||
재현 테스트는 access token 만료 직후에 맞춰 실행한다.
|
||||
- 이 경쟁은 저장소를 공유한 뒤에야 재현되므로 저장소를 정한 다음에 이어서 푼다.
|
||||
|
||||
## 선택지
|
||||
|
||||
### 1. 분산 lock으로 갱신을 직렬화한다
|
||||
|
||||
분산 lock은 여러 프로세스가 같은 자원을 동시에 건드리지 못하도록 프로세스 밖에 두는 잠금이다.
|
||||
한 replica만 갱신하고 나머지는 그 갱신이 끝나기를 기다렸다가 결과를 읽는다.
|
||||
|
||||
대신 lock 저장소의 가용성, lock 만료, 재진입, lock을 쥔 프로세스가 내려간 상황까지 함께 처리해야 한다.
|
||||
|
||||
### 2. 각자 갱신하고 실패는 재시도로 처리한다
|
||||
|
||||
구현이 가장 단순하다. 지는 쪽이 거부를 받으면 저장소에서 최신 token을 다시 읽어 재시도한다는
|
||||
전제인데, 이 재시도가 성립하는지부터 확인해야 한다.
|
||||
|
||||
같은 refresh token이 두 번 들어오는 것을 잡아내는 reuse detection 정책에 따라, 두 번째 사용이
|
||||
token family 전체에 영향을 줄 수 있다. 그러면 재시도 한 번으로 끝나지 않고 재인증이 필요할 수 있다.
|
||||
갱신에 성공한 replica가 새 token을 저장하기 전에 다른 replica가 저장소를 다시 읽는 순서도 따로
|
||||
확인해야 한다.
|
||||
|
||||
### 3. 갱신 전용 경로를 하나 둔다
|
||||
|
||||
refresh를 전담하는 구성요소 하나만 refresh token을 쓰고, 다른 replica는 그 결과를 조회하게 둘 수 있다.
|
||||
|
||||
이 구성요소가 멈추면 access token이 만료된 뒤에 갱신할 주체가 없어진다. 그래서 이 구성요소를 어떻게
|
||||
살려 두고 어떻게 복구할지를 먼저 정해야 한다.
|
||||
|
||||
### 4. 제약상 제외 — 최대 재사용 횟수를 늘린다
|
||||
|
||||
최대 재사용 횟수를 늘리면 동일 refresh token의 추가 사용을 일정 횟수 받아들이도록 구성할 수 있다. 그만큼 탈취된 refresh token의 replay를 허용할 여지도 커진다. 현재 실험은 rotation과 최대 재사용 횟수 0회를 전제로 하므로 이 선택지는 제외한다.
|
||||
|
||||
## 다음 검증
|
||||
|
||||
저장소를 공유한 뒤에 재현한다.
|
||||
|
||||
1. replica 두 대에서 같은 사용자로 access token 만료 직후 동시에 요청을 보낸다.
|
||||
2. 이긴 쪽과 지는 쪽의 응답을 각각 기록한다.
|
||||
3. 지는 쪽이 저장된 새 token으로 재시도해 성공하는지 본다.
|
||||
4. 지는 쪽 사용자 화면에 무엇이 보이는지 기록한다.
|
||||
5. lock을 넣은 구성과 안 넣은 구성을 같은 입력으로 비교해 실패율과 지연을 잰다.
|
||||
|
||||
실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
|
||||
rotation과 최대 재사용 횟수 0회를 바꾸는 선택지는 지금은 제외로 두고 나중에 다시 본다. 정확한 Keycloak runtime의 동시 refresh 결과는 source repository와 실행 환경을 다시 사용할 수 있을 때 realm 값과 함께 별도 evidence로 확인한다.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"sourceSha256": "f24103187a3de269757f2574c82d30efb1e50072fc2860610a192304d35e111c",
|
||||
"verdict": "PASS",
|
||||
"checks": [
|
||||
{
|
||||
"name": "required-content",
|
||||
"cmd": "python3 scripts/check-required-content.py --file docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/question/question-refresh-rotation-replica.md",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "tree",
|
||||
"cmd": "python3 scripts/verify-tech-log-tree.py keycloak",
|
||||
"exit": 0
|
||||
},
|
||||
{
|
||||
"name": "evidence-local",
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0
|
||||
}
|
||||
],
|
||||
"liveSourceReconciliation": "UNVERIFIABLE",
|
||||
"liveSourcePath": "/home/donghyeon/workspace/keycloak-pattern",
|
||||
"note": "현재 최종 Record를 기존 SSOT/tree/local evidence 계약에 재대조했다. source repository가 없으면 live reconciliation은 UNVERIFIABLE로 남긴다."
|
||||
}
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"schemaVersion": 4,
|
||||
"runId": "2026-09-19-1928-remediation-12-reference-authorization-code-endpoints",
|
||||
"project": "keycloak",
|
||||
"record": "docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"startedAt": "2026-09-19T10:28:18+00:00",
|
||||
"finishedAt": "2026-09-19T10:28:20+00:00",
|
||||
"stages": [
|
||||
{
|
||||
"id": "S1",
|
||||
"name": "코드베이스 → SSOT",
|
||||
"skill": "analyzing-codebase-for-tech-log",
|
||||
"runBy": "ssot-analyst",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "기존 SSOT docs/keycloak/final/document.md가 있고 이번 리뷰는 이미 반영된 2026-09-19 Record 결과의 current remediation 원장 생성이다. SSOT 본문을 다시 수정하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:18+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S2",
|
||||
"name": "SSOT → 분해 계약",
|
||||
"skill": "deriving-tech-log-root-tree",
|
||||
"runBy": "tree-deriver",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "해당 기록은 tech-log-tree.json의 기존 PROMOTE/CONFIRMED 노드이며 Tree/분해 계약이 이미 PASS다. 이번 remediation에서는 분해 계약을 변경하지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "ab59130196d79e947b32e3b5e6b75335a9e5c1eb",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:18+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S3",
|
||||
"name": "글감 → 기록",
|
||||
"skill": "writing-tech-log-records",
|
||||
"runBy": "record-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "**인용한 줄은 SSOT 에서 찾아 대조한다.**",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "python3 scripts/studio-body.py docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md -o runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S3/studio-body.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:18+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S3/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 remediation에서 record-writer 역할 계약으로 최종 Record를 다시 읽고 S3 gate를 실행했다. 본문은 추가 수정하지 않았다. live source repo는 현재 머신에 없어 repo reconciliation은 fact review에서 UNVERIFIABLE로 기록한다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:19+00:00",
|
||||
"elapsedSeconds": 1,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S4",
|
||||
"name": "기록 → 그림",
|
||||
"skill": "technical-visualizer",
|
||||
"runBy": "diagram-maker",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "Reference 기록이며 이번 remediation에서 새 순서·구조·측정 관계가 추가되지 않았다. 해당 종류는 TechLog 본문 그림 렌더 대상이 아니므로 새 SVG를 만들지 않는다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:19+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S5",
|
||||
"name": "AI 티 제거",
|
||||
"skill": "rewriting-technical-prose-naturally",
|
||||
"runBy": "prose-rewriter",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "This is an **editorial** pass. The source's facts, evidence, causal chain, uncertainty, decision status, and technical depth are the contract.",
|
||||
"skillRevision": null,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/style_profile.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"exit": 1,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S5/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:19+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "현재 최종 prose를 prose-rewriter 계약으로 다시 읽고 검사했다. check_prose는 PASS했고 style profile은 측정으로만 기록했다. advisory 수치를 맞추기 위한 문장 수정은 하지 않았다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:19+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S6",
|
||||
"name": "일한 사람의 목소리",
|
||||
"skill": "writing-as-the-person-who-did-it",
|
||||
"runBy": "voice-writer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"skillEcho": "찾은 것이 없으면 **이 스킬은 여기서 끝난다.** 없는 목소리를 채우지 않는다.",
|
||||
"skillRevision": "edd45dfec66d155a621cd41186b83b5582f2244c",
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
"docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md"
|
||||
],
|
||||
"gates": [
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-as-the-person-who-did-it/scripts/check_voice.mjs docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:20+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/rewriting-technical-prose-naturally/scripts/check_prose.mjs --warn docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:20+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node --experimental-transform-types .agents/skills/writing-tech-log-records/scripts/check_body.mjs runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S6/studio-body.md --frontend /shared/codebase/tech-log-frontend",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:20+00:00"
|
||||
},
|
||||
{
|
||||
"cmd": "node .agents/skills/writing-tech-log-records/scripts/check_evidence.mjs keycloak",
|
||||
"exit": 0,
|
||||
"session": "chatgpt-current-remediation",
|
||||
"generation": 1,
|
||||
"at": "2026-09-19T10:28:20+00:00"
|
||||
}
|
||||
],
|
||||
"notes": "voice-writer 계약으로 현재 최종본을 다시 확인했다. 자료에 없는 경험 문장을 추가하지 않았고 Voice와 재실행 prose/body/evidence gate가 통과했다.",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:20+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"generation": 1,
|
||||
"owner": "chatgpt-current-remediation",
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
},
|
||||
{
|
||||
"id": "S7",
|
||||
"name": "Studio 저장",
|
||||
"skill": "publishing-tech-log-to-studio",
|
||||
"runBy": "studio-validator",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "사용자가 이번 리뷰에서 Studio import/save를 요청하지 않았다. 기존 Studio 문서 version을 변경하지 않고 현재 저장소의 remediation 원장과 검증 결과만 남긴다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": "862e502af3e956b49ccd2ae0a8de3fd32f90df9c",
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"gates": [],
|
||||
"notes": "",
|
||||
"startedAt": null,
|
||||
"finishedAt": "2026-09-19T10:28:20+00:00",
|
||||
"elapsedSeconds": 0,
|
||||
"finishedBy": "chatgpt-current-remediation"
|
||||
}
|
||||
],
|
||||
"qualityReviews": {
|
||||
"commandPedagogy": {
|
||||
"initialAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md -o runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S3/command-initial.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S3/command-initial.json",
|
||||
"sha256": "9264b4ce10d052ca47c24a969a2536259b5aeb51a09679bbfc8a1da5919d4576"
|
||||
}
|
||||
},
|
||||
"finalAnalysis": {
|
||||
"cmd": "python3 scripts/check-command-pedagogy.py --mode reference docs/keycloak/tech-log-studio/oauth-oidc-auth-boundary/reference/reference-authorization-code-endpoints.md -o runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S6/command-final.json",
|
||||
"exit": 0,
|
||||
"shellBlocks": 0,
|
||||
"findings": 0,
|
||||
"majorFindings": 0,
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/S6/command-final.json",
|
||||
"sha256": "9264b4ce10d052ca47c24a969a2536259b5aeb51a09679bbfc8a1da5919d4576"
|
||||
}
|
||||
},
|
||||
"planner": {
|
||||
"runBy": "command-pedagogy-planner",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"editor": {
|
||||
"runBy": "command-pedagogy-editor",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null
|
||||
},
|
||||
"reviewer": {
|
||||
"runBy": "command-pedagogy-reviewer",
|
||||
"skill": "writing-practitioner-guides",
|
||||
"status": "SKIPPED",
|
||||
"skipReason": "결정론적 command analysis에서 shell block 0 · finding 0으로 판정되어 이 역할이 필요하지 않다.",
|
||||
"skillEcho": "",
|
||||
"skillRevision": null,
|
||||
"verdict": null,
|
||||
"notes": "initial/final analysis artifact로 shell/CLI가 없음을 확인했다.",
|
||||
"artifact": null,
|
||||
"sourceSha256": null
|
||||
}
|
||||
},
|
||||
"technicalEvidence": {
|
||||
"runBy": "fact-reviewer",
|
||||
"status": "DONE",
|
||||
"skipReason": "",
|
||||
"verdict": "PASS",
|
||||
"notes": "현재 최종 파일 hash를 기준으로 SSOT/tree/local evidence를 재대조했다. live source reconciliation = UNVERIFIABLE: /home/donghyeon/workspace/keycloak-pattern 이 현재 머신에 없다. 별도 Agent tool은 노출되지 않아 current remediation 세션이 fact-reviewer 계약을 직접 수행했다.",
|
||||
"sourceSha256": "dadbfa8796fda7bcbd5a65858ade39bf5534af43271ad55bdeb1979487542def",
|
||||
"artifact": {
|
||||
"path": "runs/keycloak/2026-09-19-1928-remediation-12-reference-authorization-code-endpoints/stage/quality/technical-evidence-review.json",
|
||||
"sha256": "a973e23a1034b1b45dc9d228cb8ab32b1d08b00f998c45ebc1fe0cdc356e3f0e"
|
||||
}
|
||||
}
|
||||
},
|
||||
"riders": [],
|
||||
"sessions": [
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"openedAt": "2026-09-19T10:28:18+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S3",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:18+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S5",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:19+00:00"
|
||||
},
|
||||
{
|
||||
"session": "chatgpt-current-remediation",
|
||||
"stage": "S6",
|
||||
"generation": 1,
|
||||
"beganAt": "2026-09-19T10:28:20+00:00"
|
||||
}
|
||||
],
|
||||
"revision": 23,
|
||||
"updatedAt": "2026-09-19T10:28:20+00:00",
|
||||
"executionEnvironment": {
|
||||
"mode": "current-remediation-contract-replay",
|
||||
"session": "chatgpt-current-remediation",
|
||||
"agentToolAvailable": false,
|
||||
"note": "별도 Agent(subagent_type) 실행 도구가 현재 ChatGPT/Coka 환경에 노출되지 않았다. current remediation 세션이 .claude/agents 역할 계약과 각 SKILL.md를 읽고 동일한 gate를 현재 파일에 직접 실행했다. runBy는 verifier 계약 역할명이며 별도 Agent 프로세스 실행을 주장하지 않는다."
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user