# logi (1pass.dev) — 전체 개발자 문서 > 이 파일은 docs.1pass.dev 의 모든 가이드를 한 파일로 합친 LLM 최적화 패키지입니다. > 생성 시각: 2026-04-29T13:02:37.406Z > 출처: https://github.com/seunghan91/logi/tree/main/docs-site --- # 앱 관리 *Source: `cli/apps.md`* # 앱 관리 OAuth 앱을 만들고 관리하는 모든 명령어. ## 새 앱 만들기 ```bash logi apps create \ --name "My Awesome App" \ --redirect-uri https://app.example.com/auth/callback ``` 출력 예: ``` ✓ App created client_id: logi_1ce2d868ff8c8f0e3e8f0abcfac8f4be client_secret: logi_secret_3f290948f9fa6a3cb24bbce... ← 1회만 표시 environment: test organization: personal-8 ⚠️ client_secret은 다시 볼 수 없습니다. 안전한 곳에 저장하세요. ``` ### 옵션 | 옵션 | 기본값 | 설명 | |---|---|---| | `--name` | (필수) | 사용자에게 보일 앱 이름 | | `--redirect-uri` | (필수) | OAuth 콜백 URL. 여러 개면 옵션 반복 | | `--scope` | `openid profile` | 공백으로 구분, 여러 개 | | `--webhook-url` | | 사용자 변화 알림 받을 URL | | `--logo-url` | | 로그인 화면에 표시될 로고 | ## 목록 보기 ```bash logi apps list ``` ``` ID NAME CLIENT_ID STATUS ENV 3 Demo Test App logi_1ce2d868... sandbox test 4 Production Site logi_a39bc01f... approved live ``` JSON으로: ```bash logi apps list --json | jq '.[] | select(.environment == "live")' ``` ## 상세 보기 ```bash logi apps show 3 ``` ``` Demo Test App (#3) client_id: logi_1ce2d868ff8c8f0e3e8f0abcfac8f4be redirect_uris: https://app.example.com/auth/callback scopes: openid, profile status: sandbox · test · free webhook_url: — created: 2026-04-27 17:55 ``` ## 수정 ```bash logi apps edit 3 \ --add-redirect-uri https://staging.example.com/cb \ --webhook-url https://hooks.example.com/logi ``` `--remove-redirect-uri` 도 같은 방식. ## client_secret 회전 ```bash logi apps rotate-secret 3 ``` 새 secret이 한 번만 출력됩니다. 기존 secret은 즉시 무효. 이전 secret으로 진행 중인 토큰 발급 요청은 모두 401. 자주 회전할수록 안전합니다. CI/CD에선 [3개월에 한 번 자동 회전](/cli/usage#secret-rotation) 권장. ::: warning ⚠️ 기존 앱 재등록 시 client_secret 가 안 보일 때 이전에 등록된 앱(같은 `name` 으로 재실행 등)을 다시 만지면 생성 단계에서 `client_secret` 이 **빈 값** 으로 나옵니다 — 평문 시크릿은 **최초 1회 생성 시점에만** 노출되며, 그 이후엔 logi DB 에 hash 만 남아 복원 불가. 증상: ``` client_id=logi_xxxxxxxxxxxxx client_secret= ← 비어있음 ``` 해결: `rotate-secret` 으로 새 시크릿 발급: ```bash logi apps rotate-secret ``` 또는 SSH/rails console 에서 직접: ```ruby app = OauthApplication.kept.find_by(name: "your_app") new_secret = app.rotate_client_secret! puts new_secret # ← RP env 로 즉시 복사 ``` 이 함정은 자동화 스크립트가 "create-or-update" 로직으로 동작할 때 자주 발생합니다 (앱이 이미 있어서 create 가 no-op 인데 secret 출력은 빈 값). 자동화는 "신규 생성 → secret 출력 / 기존이면 → rotate 명시" 두 분기로 나누세요. ::: ## 삭제 ```bash logi apps delete 3 # 정말 삭제하시겠어요? 이 앱으로 발급된 모든 토큰이 무효화됩니다. (y/N) ``` 복구는 7일 안에만: ```bash logi apps restore 3 ``` ## 다음 - [scope 추가하기](/oauth/scopes) - [팀 멤버 초대](/cli/team) - [CI/CD에서 secret 자동 회전](/cli/usage) --- # `logi` CLI *Source: `cli/index.md`* # `logi` CLI 브라우저 안 켜고도 터미널에서 logi 앱을 만들고 관리합니다. ## 무엇을 할 수 있나 - 앱 등록 / 수정 / 삭제 - `client_secret` 회전, redirect URI 추가 - 팀 멤버 초대 - JWT 토큰 검사 (디버그용) ## 30초 시작 ```bash brew install dcode-labs/tap/logi # 1) 설치 (예정) logi login # 2) 브라우저로 로그인 logi apps create --name "My App" --redirect-uri https://app.example.com/cb ``` 마지막 명령이 `client_id` / `client_secret`을 즉시 출력합니다. 끝. ## 다음 단계 - [설치 방법](/cli/install) - [로그인 흐름](/cli/login) — 브라우저 OAuth + 헤드리스(서버) 옵션 - [앱 관리 명령어 전체](/cli/apps) - [CI/CD 자동화](/cli/usage) --- # 설치 *Source: `cli/install.md`* # 설치 ## Homebrew (권장) ::: warning 준비 중 정식 출시 전입니다. 지금은 아래 "직접 빌드" 섹션을 참고하세요. ::: ```bash brew install dcode-labs/tap/logi ``` ## npm (Node) ```bash npm install -g @logi-auth/cli # 출시 예정 ``` ## 직접 빌드 (현재) ```bash git clone https://github.com/seunghan91/logi.git cd logi/cli bundle install bin/logi version ``` PATH 등록: ```bash echo 'export PATH="$HOME/toy/logi/cli/bin:$PATH"' >> ~/.zshrc source ~/.zshrc logi version ``` ## 자동 업데이트 확인 `logi`는 일주일에 한 번 새 버전이 있으면 알려줍니다. 알림은 끌 수 있습니다: ```bash export LOGI_DISABLE_UPDATE_CHECK=1 ``` ## 다음 설치가 끝나면 [로그인](/cli/login)으로 이동하세요. --- # 로그인 *Source: `cli/login.md`* # 로그인 ## 가장 간단한 방법 ```bash logi login ``` 브라우저가 자동으로 열리고, [start.1pass.dev](https://start.1pass.dev) 에서 로그인 + "logi-cli에게 권한 허용" 한 번 누르면 끝. GitHub `gh auth login`, Vercel `vercel login`과 동일한 방식입니다. 비밀번호를 터미널에 직접 입력하지 않아 안전합니다. ## 브라우저가 없을 때 (서버·SSH·도커) ```bash logi login --no-browser ``` 화면에 짧은 코드(예: `AB12-CD34`)가 뜹니다. 다른 기기 브라우저로 [start.1pass.dev/device](https://start.1pass.dev/device) 에 접속해 코드를 입력하면 CLI가 자동으로 로그인됩니다. ## 어디에 저장되나 ``` ~/.config/logi/credentials (chmod 600) ``` 토큰 자체는 표시되지 않습니다. 로그인된 계정만 확인하려면: ```bash logi whoami # → dev@example.com (Personal org) ``` ## 환경변수로 로그인 (CI 환경) 브라우저를 못 띄우는 CI/CD 환경에서는 미리 발급한 PAK(Personal API Key)를 환경변수로 줍니다: ```bash export LOGI_TOKEN=lpa_pat_xxxxxxxxxxxxx logi apps list # 로그인 단계 자동 skip ``` PAK는 [start.1pass.dev/settings/api-keys](https://start.1pass.dev) 에서 발급합니다. ## 로그아웃 ```bash logi logout ``` credentials 파일이 삭제되고, 서버 측 PAK도 자동 무효화됩니다. ## 흐름이 궁금하다면 브라우저 OAuth는 [PKCE 표준](/oauth/pkce)을 그대로 따릅니다. 헤드리스 모드는 [Device Flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628) 패턴. --- # 팀 관리 *Source: `cli/team.md`* # 팀 관리 logi의 OAuth 앱은 **조직(Organization)** 단위로 소유됩니다. 가입 시 자동으로 1인 조직이 만들어지고, 팀이 생기면 멤버를 초대할 수 있습니다. ## 멤버 목록 ```bash logi team members ``` ``` EMAIL ROLE JOINED dev@example.com owner 2026-04-27 alice@example.com admin 2026-04-28 bob@example.com developer 2026-04-29 ``` ## 초대 보내기 ```bash logi team invite alice@example.com --role admin ``` 상대방 이메일로 초대 링크가 발송됩니다. 받은 사람이 [start.1pass.dev](https://start.1pass.dev) 에 로그인하면 자동으로 조직에 합류. ### 권한 (역할) | 역할 | 앱 만들기 | secret 회전 | 멤버 초대 | 조직 정보 수정 | |---|---|---|---|---| | **owner** | ✅ | ✅ | ✅ | ✅ | | **admin** | ✅ | ✅ | ✅ | ✅ | | **developer** | ✅ | ❌ | ❌ | ❌ | owner는 항상 1명 이상 있어야 합니다. ## 권한 변경 ```bash logi team set-role alice@example.com developer ``` ## 멤버 제거 ```bash logi team remove bob@example.com ``` ## 대기 중 초대 보기 ```bash logi team invitations ``` 만료된 초대는 자동으로 정리됩니다 (기본 7일). ## 초대 재발송 / 취소 ```bash logi team invitations resend alice@example.com logi team invitations cancel alice@example.com ``` ## 다음 - [Web 대시보드에서 동일한 작업](https://start.1pass.dev/developer/organization) - [감사 로그](/guide/security#audit) — 누가 언제 무엇을 했는지 추적 --- # CI/CD에서 사용 *Source: `cli/usage.md`* # CI/CD에서 사용 GitHub Actions, GitLab CI, Jenkins 등에서 `logi`를 자동화합니다. ## 인증: 환경변수 CI 머신에서 브라우저 OAuth는 못 돌리니, **PAK(Personal API Key)** 를 발급해 환경변수로 제공: ```bash export LOGI_TOKEN=lpa_pat_xxxxxxxxxxxxx export LOGI_API_URL=https://api.1pass.dev logi whoami # → 자동으로 PAK 인증 ``` PAK 발급은 [start.1pass.dev/settings/api-keys](https://start.1pass.dev) 에서. ## GitHub Actions 예시 ```yaml # .github/workflows/rotate-secret.yml name: Rotate logi secret monthly on: schedule: - cron: "0 0 1 * *" # 매달 1일 00:00 UTC workflow_dispatch: jobs: rotate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install logi CLI run: | curl -fsSL https://1pass.dev/install/cli.sh | bash echo "$HOME/.logi/bin" >> $GITHUB_PATH - name: Rotate env: LOGI_TOKEN: ${{ secrets.LOGI_TOKEN }} run: | NEW_SECRET=$(logi apps rotate-secret ${{ vars.LOGI_APP_ID }} --json | jq -r .client_secret) echo "::add-mask::$NEW_SECRET" gh secret set OAUTH_CLIENT_SECRET --body "$NEW_SECRET" --repo my-org/my-app env: GH_TOKEN: ${{ secrets.GH_PAT }} ``` ## secret 회전 자동화 {#secret-rotation} 권장 주기: - **월 1회** — 일반 프로덕션 앱 - **주 1회** — 결제·민감 정보 다루는 앱 - **즉시** — 노출 의심 발생 시 GitHub Actions 매트릭스로 여러 앱을 한 번에 돌리는 패턴: ```yaml strategy: matrix: app: [my-web, my-mobile-bff, my-admin] steps: - run: logi apps rotate-secret ${{ matrix.app }} ``` ## 환경변수 레퍼런스 | 변수 | 기본값 | 설명 | |---|---|---| | `LOGI_TOKEN` | (없음) | PAK. 있으면 credentials 파일 무시 | | `LOGI_API_URL` | `https://api.1pass.dev` | 자체 호스팅 시 변경 | | `LOGI_CONFIG_PATH` | `~/.config/logi/credentials` | 다른 경로로 이동 | | `LOGI_DISABLE_UPDATE_CHECK` | `0` | `1`로 두면 업데이트 알림 끔 | | `LOGI_OUTPUT` | (TTY: human / 그 외: json) | 강제 지정 가능 | ## 종료 코드 | 코드 | 의미 | |---|---| | `0` | 성공 | | `1` | 일반 에러 | | `2` | 인증 실패 (PAK 만료/무효) | | `3` | 권한 부족 (developer 역할이 admin 작업 시도 등) | | `4` | 네트워크 오류 | ## 디버깅 ```bash LOGI_DEBUG=1 logi apps list # 모든 HTTP 요청·응답 로그 ``` PAK는 prefix 8자만 출력됩니다. ## 다음 - [모든 명령어 한 줄 요약](/reference/cli) - [MCP로 Claude/Cursor에서 자연어 조작](/reference/mcp) --- # 계정 삭제 안내 *Source: `guide/account-deletion.md`* # 계정 삭제 안내 1pass에서는 사용자가 직접 계정을 영구 삭제할 수 있습니다 (Apple App Store Guideline 5.1.1(v) 준수). ## 어디서 하나요? iOS / macOS 앱: **설정 → 계정 삭제** 버튼 확인 다이얼로그에서 "영구 삭제하기"를 누르면 즉시 처리됩니다. ## 삭제되는 항목 다음 데이터가 **모두 삭제**됩니다: - 연결된 로그인 (Apple, Google) - 등록된 모든 기기와 디바이스 시크릿 - Passkey (WebAuthn 자격증명) - OTP/TOTP 설정 + 백업 코드 - Personal API Key - 동의한 외부 앱(RP) 연결 기록 + 접근 토큰 - 접속 기록 (login logs) - 프로필 정보 (닉네임, 이메일, custom claims) ## 삭제 후 동작 삭제 즉시 다음이 일어납니다: - 모든 로그인 세션이 종료됩니다 (다른 기기 포함) - 1pass로 로그인되어 있던 외부 앱(RP)에 `token.revoked` / `consent.revoked` webhook이 발송되어 자동 로그아웃됩니다 - 같은 Apple/Google 계정으로의 즉시 재가입은 30일 동안 제한됩니다 ## 30일 유예 기간 - **30일 이내**: 운영팀(theqwe2000@gmail.com)에 연락하면 계정 복구가 가능합니다 - **30일 이후**: 모든 데이터가 데이터베이스에서 영구 삭제되며 복구 불가능합니다. 이후에는 같은 Apple/Google 계정으로 새로 가입할 수 있습니다 자동 영구 삭제는 백그라운드 Job(`PurgeUserJob`)이 처리하며, 사용자가 추가로 할 일은 없습니다. ## "로그아웃" / "계정 초기화"와의 차이 | 동작 | 서버 데이터 | 다른 기기 | 외부 앱 (RP) | |---|---|---|---| | **로그아웃** | 유지 | 영향 없음 | 영향 없음 | | **계정 초기화 (익명 계정 한정)** | 유지 (서버에 익명 사용자 row 남음) | 새 익명 사용자로 분기 | 영향 없음 | | **계정 삭제** | 즉시 사용 불가 + 30일 후 영구 삭제 | 즉시 강제 로그아웃 | 즉시 webhook 발송 | ## 자주 묻는 질문 ### Q. 실수로 삭제했어요. 복구할 수 있나요? 30일 이내라면 운영팀(theqwe2000@gmail.com)에 가입 시 사용한 Apple/Google ID 또는 닉네임을 알려주시면 복구할 수 있습니다. ### Q. 같은 Apple ID로 바로 다시 가입하려면? 30일 유예 기간이 끝나면 자동으로 가능해집니다. 그 전에는 "이 계정은 삭제 진행 중입니다"라는 메시지가 뜹니다. ### Q. 1pass로 연결해둔 외부 앱들은 어떻게 되나요? 각 앱에 즉시 webhook이 발송되어 자동 로그아웃 처리됩니다. 외부 앱마다 별도로 로그아웃하실 필요 없습니다. ### Q. 모든 플랫폼에서 삭제되나요? 네. 한 번 삭제하면 iOS/macOS/Android/CLI 어디서 만든 데이터든 모두 삭제됩니다. --- # AI 어시스턴트 통합 *Source: `guide/ai-assistants.md`* # AI 어시스턴트 통합 Claude Code · Cursor · Codex 같은 AI 코딩 어시스턴트에서 logi RP 통합을 자동화하기 위한 skill/prompt 모음입니다. 이 페이지는 계속 업데이트됩니다. ::: tip 외부 개발자 안내 여기 등록된 skill 들은 작성자(Seunghan) 의 워크스페이스 기준 자동화 절차를 포함하는 경우가 많습니다. **본인 환경에 적용할 때는 Render/Vercel/Fly service ID, SSH 호스트, 도메인을 본인 것으로 치환** 해야 합니다. 각 skill 항목 마지막의 "치환 가이드" 표를 확인하세요. ::: ## Claude Code skills ### `logi-rp-integrate` — RP 통합 5분 자동화 기존 Rails / Flutter / Next.js / iOS 프로젝트에 logi SSO 를 RP 로 붙이는 표준 절차를 한 번의 skill 호출로 끝냅니다. 기술 스택 자동 감지 → 표준 컨트롤러/서비스 드롭인 → logi 서버에 RP 등록 → 배포 플랫폼 env 주입 → live 검증까지 7단계. **언제 트리거되는가** - "logi 로그인 적용" / "1pass 통합" / "logi RP 등록" / "logi SSO 추가" 같은 요청 - 다중 프로젝트에 일괄 적용 ("다른쪽에도 적용", "프로젝트 전반 적용") **핵심 명령 미리보기** (skill 본문에서 추출) ```bash # 1. RP 등록 (CLI 권장 — SSH fallback 도 있음) logi apps create \ --name "$PROJECT" \ --redirect-uri "$SERVICE_URL/auth/logi/callback" \ --scope "openid profile:basic email" # 2. 배포 플랫폼 env 4개 주입 (Render 예시) # LOGI_API_URL / LOGI_CLIENT_ID / LOGI_CLIENT_SECRET / LOGI_SCOPES # 3. Post-deploy host 검증 (자주 빠지는 단계) curl -sI "$SERVICE_URL/auth/logi/start" | grep -i "^location:" \ | grep -q "api.1pass.dev" && echo "✓ env OK" ``` **설치 / 등록** > ⚠️ skill 본문은 작성자 워크스페이스의 내부 SSH/Render ID 가 박혀 있어 현재 외부 공개 안 된 상태입니다. 외부 개발자용 일반화 버전은 [GitHub Issues #?](https://github.com/seunghan91/logi/issues) 에서 진행 중. 그 사이에는 [Quickstart](/guide/quickstart) → [Rails 통합](/integrations/rails) → [Troubleshooting](/guide/troubleshooting) 의 절차를 그대로 따라가면 동일 결과입니다. **외부 개발자 치환 가이드** | skill 본문에 등장 | 본인 환경에서 치환 | |---|---| | `srv-d7mro4egvqtc73ag82jg` (logi-server Render ID) | logi 직접 호스팅 안 함 — `https://api.1pass.dev` HTTP API 사용 | | `srv-d5ds6plactks73ccnbug` (RP service ID 예시) | 본인 RP 서비스의 Render/Vercel/Fly ID | | `seunghan+logi@dcode-labs.com` (logi developer 계정) | 본인 logi 계정 (`logi login` 으로 로그인된 계정) | | `https://ax-admin-z42x.onrender.com` (RP 도메인) | 본인 RP prod 도메인 | | `ONE_PASS_*` env prefix | 본인 코드베이스 컨벤션 (`LOGI_*` 권장) | ## Cursor rules _아직 없음. 추가 예정._ ## 기타 어시스턴트 (Codex / Aider / Continue / ...) _아직 없음. 추가 예정._ ## 기여하기 자체 어시스턴트용 skill / prompt / rule 을 등록하고 싶으면: 1. [GitHub Issue](https://github.com/seunghan91/logi/issues/new) 로 제안 (어떤 도구 / 무엇을 자동화 / 본인 사용 사례) 2. 내부 인프라에 의존하는 부분은 placeholder 로 일반화 3. 이 페이지에 PR — 위 섹션 형식 따르기 --- **관련 문서** - [Quickstart](/guide/quickstart) — 5분 통합 (수동) - [Rails 통합](/integrations/rails) — 코드 레벨 가이드 - [Troubleshooting](/guide/troubleshooting) — 실패 시나리오 7개 + env 점검 체크리스트 --- # 핵심 개념 *Source: `guide/concepts.md`* # 핵심 개념 logi를 사용하기 전 5분만 투자해서 아래 개념을 이해하세요. 이후 모든 문서가 같은 용어를 씁니다. ## 역할 (Role) logi는 OAuth 2.0의 세 주체를 구분합니다: | 주체 | logi에서 이름 | 하는 일 | |------|-------------|--------| | Identity Provider | **logi** | 사용자를 인증하고 토큰을 발급 | | Relying Party / Client | **제휴사 앱** | logi로 로그인하도록 redirect, 토큰으로 userinfo 조회 | | Resource Owner | **사용자** | logi 앱/웹으로 자격 증명 입력, Consent 동의 | 추가로 logi는 `users` 테이블에 3단계 role을 관리합니다: - `user` — 일반 최종 사용자 - `developer` — 제휴사 앱을 등록/관리 - `admin` — 시스템 운영자 (승인, 감사) ## 식별자 - **client_id** — 공개. `logi_` prefix + 32자 hex. 제휴사 앱 식별. - **client_secret** — 비공개. `logi_secret_` prefix + 64자 hex. **발급 시 1회 노출**. DB에는 bcrypt digest만 저장. - **device_uuid** — 제휴사 앱/iOS 앱이 Keychain/SecureStorage에 저장하는 기기 식별자. - **jti** — 각 Access Token(JWT)의 고유 ID. revoke 조회용. - **kid** — 서명 키 식별자. JWKS rotation 시 구 키/신 키 공존 지원. ## Scope | scope | userinfo 필드 | |-------|-------------| | `profile` | sub, email, email_verified, identity_verified_level | | `email` | sub, email, email_verified | | `phone` | sub, phone_number (Phase 2) | | `openid` | id_token 발급 + sub | Scope는 콤마가 아닌 **공백 구분** 입니다 (OAuth 2.0 표준). ## Consent 재사용 규칙 사용자가 제휴사 A에 `profile email` 동의한 이후, - 같은 scope로 재인증 → **UI 스킵**, 즉시 code 발급 - scope 확장 요청 (예: `+phone`) → "NEW" 배지 + 추가 동의 요구 - 사용자가 `/settings`에서 revoke → 다음 인증 시 Consent 화면 다시 표시 Google 로그인과 같은 UX입니다. ## 토큰 수명 | 토큰 | 만료 | Revocation | |------|-----|-----------| | Authorization Code | **10분** · 1회 사용 | 자동 (consume!) | | Access Token (JWT) | **15분** | `jwt_jti` DB 조회로 revoke 체크 | | Refresh Token | **30일** · 사용 시 rotation | 즉시 revoke + 재사용 시 체인 전체 revoke | | Personal API Key | **무기한** (사용자 설정 가능) | `last_used_at` 모니터링 + 수동 revoke | ## JWT 구조 ``` Header: { alg: "RS256", kid: "", typ: "JWT" } Payload: { iss: "logi", sub: "", aud: "", exp: <15min>, iat: , jti: "", scope: "profile email" } Signature: RS256 over header.payload ``` 검증은 [/.well-known/jwks.json](/oauth/jwks) 에서 공개 키를 받아 수행합니다. ## 인증 메커니즘 요약 | 메커니즘 | 용도 | 전달 방법 | |---------|-----|---------| | 세션 쿠키 | 웹 UI (Developer Portal, Consent 화면) | `session_id=...; Secure; HttpOnly; SameSite=Lax` | | OAuth AT (JWT) | 제휴사가 userinfo 조회 | `Authorization: Bearer ` | | PAK (Personal API Key) | CLI/MCP, 사용자 직접 관리 | `Authorization: Bearer logi_pak_...` | | Client Basic | /oauth/token에서 제휴사 인증 | `Authorization: Basic ` | | Passkey (WebAuthn) | 패스워드리스 강인증 | `ASAuthorization*` / `navigator.credentials` | ## 2FA 상태 머신 ``` [비활성] --setup_otp!--> [키 생성됨] --enable_otp!(code)--> [활성] [활성] --disable_otp!(current_code)--> [비활성] [활성] --login_with(otp_code)--> session.otp_verified_at = Time.current [활성] --login_with(backup_code)--> 백업 1개 소진 ``` Passkey 인증은 User Verification 포함 시 OTP와 동등한 강인증으로 간주되어 `otp_verified_at`을 자동 설정합니다. ## 다음 단계 - [OAuth 플로우 시퀀스](/oauth/flow) — 다이어그램으로 전체 그림 이해 - [PKCE 상세](/oauth/pkce) — RFC 7636 벡터로 검증 --- # FAQ — 자주 묻는 질문 *Source: `guide/faq.md`* # FAQ — 자주 묻는 질문 1pass(logi)에 대해 가장 많이 받는 질문을 모았습니다. 서비스에 도입하려는 **개발자/사업자**와, 1pass로 로그인하는 **일반 사용자** 두 관점으로 나눠 정리했습니다. [[toc]] ## 1pass는 무엇인가요? > "패스키조차 짜증나서 만든 로그인." 이메일·비밀번호·소셜 로그인이 모두 마음에 안 들어서 직접 만든 **앱 기반 인증 시스템**입니다. 사용자는 **1pass 앱 하나만 깔면** 모든 연동 서비스에 로그인할 수 있고, 서비스 개발자는 **5분 안에** 자기 서비스에 로그인을 붙일 수 있습니다. - 가입할 때 **이메일·이름·전화번호 아무것도 받지 않습니다.** 앱 설치 = 가입 완료. - 인증 매체는 **휴대폰**입니다. 웹에서 로그인할 때는 QR 또는 SSO 푸시 승인으로 처리됩니다. - 앱 용량은 약 **5 MB**. --- ## 서비스를 만드는 분들 (B2B / 개발자) ### Q. 우리 서비스에 1pass 로그인을 붙이려면 어떻게 하나요? [Quickstart 가이드](/guide/quickstart)대로 하면 `curl` 한 번으로 첫 로그인까지 5분이면 끝납니다. AI 어시스턴트에게 [llms-full.txt](/llms-full.txt)를 던지고 "1pass 로그인 붙여줘"라고 하셔도 됩니다. ### Q. 웹 서비스에도 적용 가능한가요? 네. 웹/앱 모두 지원합니다. - **웹**: QR 코드 + SSO(Single Sign-On) 푸시 승인 - **모바일 앱**: Universal Links / App Links로 1pass 앱과 직접 통신 - **데스크톱**: 웹과 동일하게 QR 로그인 ### Q. SSO는 어떻게 동작하나요? 사용자가 여러분 서비스에서 "1pass로 로그인" 버튼을 누르면: 1. 1pass 서버에 인증 요청이 발생합니다 (OAuth 2.0 + PKCE). 2. 사용자 휴대폰의 1pass 앱으로 푸시가 갑니다 — *"○○ 서비스에서 로그인 요청이 있어요. 본인이 맞나요?"* 3. 사용자가 **승인**을 누르면 그 자리에서 로그인이 완료됩니다. 자세한 흐름은 [OAuth Authorization Code Flow](/oauth/flow) 참고. ### Q. 서버에서 사용자 이메일을 받을 수 있나요? 기본은 **익명 식별자(UUID + 디바이스 고유값)** 만 전달됩니다. 이메일·이름·연락처가 필요하면 [Scope](/oauth/scopes)를 추가로 요청할 수 있고, 사용자는 **항목별로 동의/거부**할 수 있습니다. 새로운 항목이 필요해질 때만 다시 동의를 받기 때문에, "처음에 다 동의받고 안 쓰는" 패턴이 사라집니다. ### Q. 유저 구분은 어떻게 되나요? 설치된 1pass 앱마다 **앱 UUID + 디바이스 고유값 + 난수**로 만든 유니크한 식별자(`sub`)를 발급합니다. 같은 사용자가 앱을 **삭제하지 않는 한** 동일한 식별자가 유지됩니다. > 앱을 삭제하고 다시 설치하면 새 사용자로 인식됩니다. 복구가 필요한 경우를 대비해 SSO 링크 / 백업 코드를 안내하세요. ### Q. 사용자한테 앱 설치를 강요해야 하는데, 다들 깔까요? 이게 가장 자주 받는 질문이고, 솔직한 답변은 이렇습니다. - **인증 매체는 무조건 1개 필요합니다.** 패스키든 이메일이든 OTP 앱이든. 1pass는 그걸 휴대폰 앱으로 통일했을 뿐입니다. - 앱은 **5 MB**짜리 단일 목적 앱이라, "구글 로그인 → 인앱 브라우저 막힘 → 가입 포기" 같은 이탈이 없습니다. - B2B로 여러 서비스가 1pass를 채택할수록 **한 번 깔면 N개 서비스에 쓸 수 있다**는 가치가 커집니다. ### Q. 인앱 브라우저(인스타/스레드 등)에서 구글·카카오 로그인이 막히는 문제도 해결되나요? 네. 1pass는 인앱 브라우저 정책에 의존하지 않습니다. 푸시 → 네이티브 앱 → 승인 흐름이라 브라우저 종류와 무관하게 동작합니다. ### Q. 가격 / 비용은요? 알파(`v0.1`) 단계에서는 무료로 제공합니다. 정식 가격 정책은 [변경 로그](/reference/changelog)에서 공지됩니다. ### Q. 자체 서버에 호스팅할 수 있나요? 가능합니다. 코드는 [GitHub](https://github.com/seunghan91/logi)에 MIT 라이선스로 공개되어 있고, [Render 배포 예시](/guide/quickstart)도 제공됩니다. --- ## 1pass를 사용하는 분들 (일반 사용자) ### Q. 회원가입할 때 뭘 입력하나요? **아무것도 입력하지 않습니다.** 1pass 앱을 설치하는 순간 가입이 완료됩니다. 이메일·이름·전화번호가 필요한 서비스에서 1pass로 로그인하면, 그때 그 서비스가 요구하는 항목만 사용자가 직접 골라서 동의하게 됩니다. ### Q. 개인정보를 너무 안 받는데, 안전한가요? 오히려 **저장하는 게 적을수록** 안전합니다. - 실명·주민번호 같은 민감 정보는 1pass 서버에 저장되지 않습니다. - 인증은 휴대폰의 **Face ID / Touch ID / 기기 인증**으로 처리됩니다. - 2단계 인증(TOTP)과 일회용 백업 코드도 함께 발급됩니다 — [보안 가이드](/guide/security) 참고. ### Q. 휴대폰을 잃어버리면 어떡하나요? 가입할 때 발급받은 **백업 코드**로 새 기기에서 복구할 수 있습니다. 자세한 절차는 [보안 가이드 - 백업 코드](/guide/security#two-factor-backup-codes)에 있습니다. ### Q. 아이폰만 되나요? 갤럭시(Android)는요? **둘 다 됩니다.** iOS / Android 모두 정식 지원합니다. ### Q. 웹사이트에서 1pass로 로그인하려면 어떻게 하나요? 웹사이트의 "1pass로 로그인" 버튼을 누르면 두 가지 방식 중 하나로 진행됩니다: - **QR 로그인** — 화면의 QR을 1pass 앱으로 스캔 - **SSO 푸시** — 휴대폰에 *"○○에서 로그인 요청이 왔어요"* 알림이 오고, **승인**을 누르면 끝 ### Q. 1pass 앱을 지우면 어떻게 되나요? 연결된 모든 서비스의 로그인 정보가 사라집니다. 다시 설치하면 새 사용자로 인식되므로, **앱을 지우기 전에 백업 코드를 챙겨두세요.** ### Q. 기존 서비스 계정(이메일/구글 로그인 등)을 1pass에 연결할 수 있나요? 네, [SSO 링크](/oauth/flow) 기능을 통해 연결할 수 있습니다. 이미 가입된 서비스에서 "1pass 연결하기"를 누르면 됩니다. --- ## 더 알아보기 - [5분 Quickstart](/guide/quickstart) — 직접 붙여보기 - [핵심 개념](/guide/concepts) — App·Scope·Token 30초 정리 - [보안 가이드](/guide/security) — 패스키, 2FA, 백업 코드 - [OAuth 2.0 Flow](/oauth/flow) — 인증 흐름 상세 - [GitHub](https://github.com/seunghan91/logi) — 소스 코드 --- # logi 개발자 가이드 *Source: `guide/index.md`* # logi 개발자 가이드 logi는 **최소 정보 보유형** Identity Provider입니다. 실명·주민번호는 저장하지 않고, 플래그(`identity_verified_level`)만 관리합니다. 인증 플로우는 OAuth 2.0 + PKCE 하나만 지원합니다 (벤더 락/하위호환 패턴 없음). ## 이 문서를 읽을 대상 - **제휴사 개발자**: 자사 웹/앱에 logi 로그인 추가 - **iOS/Android 개발자**: 네이티브 SSO 연동 (SwiftUI / Compose) - **보안 엔지니어**: logi를 IdP로 선택할 때 리뷰 - **SRE/운영자**: Cloudflare + Render 기반 배포 체크리스트 ## 문서 구조 | 섹션 | 내용 | |-----|-----| | [Quickstart](/guide/quickstart) | curl만으로 5분 안에 전체 플로우 체험 | | [핵심 개념](/guide/concepts) | IdP/Client/User/Scope/Consent/토큰 수명 | | [OAuth 2.0 + PKCE](/oauth/flow) | 시퀀스 다이어그램 + RFC 준수 포인트 | | [Security](/guide/security) | redirect_uri, state, PKCE, rotation, rate limit | | [Webhook](/guide/webhooks) | 이벤트 종류, HMAC 검증, 재시도 정책 | | [프레임워크](/integrations/nextjs) | Next.js, Rails, Swift, Express 실전 코드 | | [API 레퍼런스](/reference/api) | Scalar UI (OpenAPI 3.1) | | [CLI](/reference/cli) / [MCP](/reference/mcp) | 도구 사용법 | ## 3대 약속 1. **표준만 사용** — OAuth 2.0 / OIDC 1.0 / WebAuthn L3 / TOTP RFC 6238. 벤더 확장 없음. 2. **PII 최소화** — 이메일 + (선택적) 전화 + `identity_verified_level` 정수. 실명/주민번호 절대 보유 안 함. 3. **사용자 통제권** — Refresh Token/Passkey/Consent 개별 revoke. 로그인 이력 소프트 딜리트. ::: warning 알파 상태 logi는 현재 v0.1 알파입니다. 도메인·가격·SLA 확정 전 프로덕션 사용은 자제하세요. 현황은 [변경 로그](/reference/changelog)를 참고하세요. ::: --- # Quickstart (5분) *Source: `guide/quickstart.md`* # Quickstart (5분) ## 안 읽고 AI에게 부탁하기 문서 전체를 LLM 컨텍스트에 통째로 넣어 자동 통합:
📄 전체 문서 (.txt) 열기
AI 에게 그대로 던질 프롬프트 예시 ``` 아래는 logi 1pass IdP 의 전체 개발자 문서다. 내 프로젝트는 [Rails / Next.js / Flutter / iOS / Android] 다. 이걸 RP 로 통합하는 코드를 작성해줘. 필요한 env 변수, 라우트, 컨트롤러, 로그인 버튼 UI 까지 포함. [여기에 /llms-full.txt 내용 전체 붙여넣기] ``` Claude Code · Cursor · Codex 등 어디든 동작합니다.
--- ## 0a. 직접 눌러보기 — `start.1pass.dev/demo` 코드 한 줄 안 짜고 1pass 가 어떻게 동작하는지 30초 만에 확인하고 싶으시면: **→ 1pass 데모 열기** "1pass 로 로그인" 버튼 한 번 누르시면 동의 화면 → 결과 페이지에서 받은 `sub`, `email`, `id_token` payload 가 그대로 노출됩니다. 페이지 뒷단의 라우트 (`Web::DemoController` + `DemoSsoService`) 가 본 Quickstart 의 1–7 단계를 그대로 코드로 옮긴 reference 입니다. ## 0b. 통합 sample 코드 — `logi-sample-rp` (Next.js 15 + openid-client v6) 내 앱에 어떻게 붙여야 할지 코드 그대로 보고 싶으시면: **→ logi-sample-rp GitHub 열기** `fork → cp .env.example .env.local → npm run dev` 면 `http://localhost:3000` 에서 동작합니다. ~250 줄 TypeScript — `src/lib/logi.ts` (openid-client 설정) → `src/app/api/auth/login/route.ts` → `src/app/api/auth/callback/route.ts` → `src/app/profile/page.tsx` 순서대로 읽으시면 끝. Express / Fastify / 다른 스택은 README 의 "Adapting for your stack" 표 참조. --- ## 직접 따라가기 (curl 5분) curl만 사용해서 **가입 → OAuth 앱 등록 → Authorization Code + PKCE 플로우 → Access Token 발급 → 사용자 정보 조회** 전체 경로를 5분 안에 돌려봅니다. ## 사전 준비 - `bash`, `curl`, `python3` (또는 `openssl`) - logi 서버 URL (이하 `$LOGI` 로 표기). 로컬 개발은 `http://localhost:3000`. ```bash export LOGI="http://localhost:3000" ``` --- ## 1. 개발자 계정 생성 + 로그인 ```bash # 개발자 가입 (role=developer) curl -s -c /tmp/cookies.txt -X POST "$LOGI/developer/signup" \ -H 'Content-Type: application/json' \ -d '{"user": {"email_address": "dev@example.com", "password": "correctHorseBatteryStaple"}}' # 세션 쿠키가 /tmp/cookies.txt 에 저장됨 — 이후 요청에 -b 로 첨부 ``` ## 2. Personal API Key 발급 `/api/v1/applications`는 PAK(Personal API Key) Bearer 인증을 사용합니다. 개발자 세션 쿠키로 PAK를 한 번 발급합니다. ```bash curl -s -b /tmp/cookies.txt -X POST "$LOGI/api/v1/me/api_keys" \ -H 'Content-Type: application/json' \ -d '{ "name": "Quickstart CLI", "scopes": ["apps:manage", "apps:read"] }' | tee /tmp/pak.json PAK=$(python3 -c 'import json; print(json.load(open("/tmp/pak.json"))["plaintext"])') ``` ## 3. OAuth 앱 등록 ```bash curl -s -X POST "$LOGI/api/v1/applications" \ -H "Authorization: Bearer $PAK" \ -H 'Content-Type: application/json' \ -d '{ "application": { "name": "My App", "redirect_uris": ["http://localhost:4000/auth/callback"], "allowed_scopes": ["profile", "email"] } }' | tee /tmp/app.json CLIENT_ID=$(python3 -c 'import json; print(json.load(open("/tmp/app.json"))["client_id"])') SECRET=$(python3 -c 'import json; print(json.load(open("/tmp/app.json"))["client_secret"])') ``` ::: tip 💡 웹 포털(`/developer/applications/new`)에서 폼으로 만들 수도 있습니다. 이 경우 UI가 `client_secret`을 한 번만 큰 글씨로 노출합니다. ::: --- ## 4. 최종 사용자 가입 (테스트용) ```bash curl -s -c /tmp/user-cookies.txt -X POST "$LOGI/signup" \ -H 'Content-Type: application/json' \ -d '{"user": {"email_address": "user@example.com", "password": "correctHorseBatteryStaple", "device_uuid": "demo-device-1"}}' ``` ## 5. PKCE 페어 생성 ```bash # 43–128자 URL-safe verifier VERIFIER=$(openssl rand -hex 32) # S256 challenge = BASE64URL(SHA256(verifier)) CHALLENGE=$(printf '%s' "$VERIFIER" \ | openssl dgst -sha256 -binary \ | python3 -c 'import sys,base64; print(base64.urlsafe_b64encode(sys.stdin.buffer.read()).rstrip(b"=").decode())') echo "verifier = $VERIFIER" echo "challenge = $CHALLENGE" ``` ## 6. Authorization 엔드포인트 호출 ```bash REDIRECT="http://localhost:4000/auth/callback" open "$LOGI/oauth/authorize?\ client_id=$CLIENT_ID&\ redirect_uri=$REDIRECT&\ response_type=code&\ scope=profile+email&\ state=random_xyz&\ code_challenge=$CHALLENGE&\ code_challenge_method=S256" ``` 브라우저에서 로그인 후 "허용" 클릭 → `http://localhost:4000/auth/callback?code=...&state=random_xyz` 로 리다이렉트됩니다. `code` 를 복사합니다. --- ## 7. Access Token 교환 ```bash CODE="복사한_code" curl -s -X POST "$LOGI/oauth/token" \ -d grant_type=authorization_code \ -d code=$CODE \ -d redirect_uri=$REDIRECT \ -d code_verifier=$VERIFIER \ -d client_id=$CLIENT_ID \ -d client_secret=$SECRET ``` 응답 예시: ```json { "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9...", "token_type": "Bearer", "expires_in": 900, "refresh_token": "DWxB...", "scope": "profile email" } ``` ## 8. 사용자 정보 조회 ```bash ACCESS_TOKEN="위_응답의_access_token" curl -s -H "Authorization: Bearer $ACCESS_TOKEN" "$LOGI/oauth/userinfo" # → {"sub":"1","email":"user@example.com","email_verified":true,"identity_verified_level":0} ``` ## 9. (선택) Refresh Token Rotation ```bash REFRESH_TOKEN="위_응답의_refresh_token" curl -s -X POST "$LOGI/oauth/token" \ -d grant_type=refresh_token \ -d refresh_token=$REFRESH_TOKEN \ -d client_id=$CLIENT_ID \ -d client_secret=$SECRET ``` 기존 RT는 **즉시 무효화**되고 새 AT/RT가 발급됩니다. 구 RT 재사용 시 **체인 전체 revoke** — 탈취 방어. --- ## 다음 단계 - [OAuth Authorization Code + PKCE 상세](/oauth/flow) — 시퀀스 다이어그램 + RFC 링크 - [프레임워크 예제](/integrations/nextjs) — Next.js / Rails / Swift / Express - [Scope 레퍼런스](/oauth/scopes) — 어떤 정보가 어떤 scope에 묶이는지 - [보안 Best Practices](/guide/security) — redirect_uri, state, PKCE, rotation --- # Rate Limits *Source: `guide/rate-limits.md`* # Rate Limits logi는 2중 레이어로 rate limit을 적용합니다: 1. **Cloudflare 엣지** — IP 기반, 앱이 받기 전 차단 (배포 환경에서 활성) 2. **Rails 서버 (rack-attack)** — 정교한 키(client_id/user_id/IP) 기반, JSON 429 반환 Rails 레이어는 `Rails.cache`(production 환경에서는 SolidCache)를 store로 사용하므로 별도 Redis 인프라 없이 동작합니다. ## 엔드포인트별 한도 | 엔드포인트 | 한도 | 키 | 레이어 | 비고 | |----------|-----|---|-------|------| | `POST /session` (로그인) | 10 / 3min | IP | Rails | brute-force 방어 | | `POST /oauth/authorize` | 30 / min | IP | Rails + Cloudflare | | | `POST /oauth/token` | 20 / min | client_id | Rails | client_id 누락 시 IP fallback | | `POST /api/v1/me/otp/*` | 10 / min | session/user | Rails | SMS 비용 폭증 방어 | | `POST /api/v1/me/passkeys/*` | 20 / min | session/user | Rails | | | `POST /api/v1/devices` | 10 / min | IP | Rails | device bootstrap brute-force 방어 | | `POST /api/v1/users/:sub/identity_verified` | 100 / hour | client_id (Bearer hash) | Rails | reporter 앱당 | | 기타 `/api/*` | 60 / min | IP | Rails | 글로벌 fallback | | 정적/Health (`/up`, `/healthz`) | 무제한 | — | safelist | | ## 초과 시 응답 ```http HTTP/2 429 Too Many Requests Content-Type: application/json {"error":"rate_limited"} ``` 일부 엔드포인트는 `Retry-After` 헤더를 포함할 수 있습니다. ## 클라이언트 권장사항 - **지수 백오프**: 429 수신 시 `Retry-After` 헤더 우선, 없으면 1 → 2 → 4 → 8초로 간격 늘려 재시도 - **정상 흐름에서는 거의 안 부딪힘** — 지속적 429는 구현 오류(리트라이 루프) 의심 - Burst 예상되는 워크로드는 사전에 상담 (Phase 2에서 앱별 커스텀 한도 지원 예정) ## 구현 세부 서버측 구현은 `server/config/initializers/rack_attack.rb`에 있습니다. 한도가 변경되면 이 파일과 본 문서를 함께 업데이트해야 합니다. 테스트 환경에서는 `Rails.cache`가 `null_store`라 throttle이 비활성화됩니다. rate limit 자체를 테스트하려면 `spec/requests/rate_limit_spec.rb`처럼 MemoryStore로 swap하세요. --- # Security Best Practices *Source: `guide/security.md`* # Security Best Practices logi를 올바르게 쓰기 위한 핵심 7가지. ## 1. PKCE는 항상 S256 ``` code_challenge_method=S256 ``` `plain`은 logi가 거부합니다. 모바일/SPA/백엔드 모두 동일하게 S256 생성. ([PKCE 상세](/oauth/pkce)) ## 2. redirect_uri 완전 일치 등록된 URI와 **한 글자도 틀리지 않게** 일치해야 합니다. 아래 모두 **다른 URI**로 간주됩니다: ``` https://app.example.com/cb https://app.example.com/cb/ ← trailing slash https://APP.example.com/cb ← 대소문자 https://app.example.com/cb?foo=1 ← query ``` ## 3. state 파라미터는 필수 `/oauth/authorize` 요청에 **예측 불가능한 난수**를 `state`로 포함하고, 콜백에서 **세션에 저장된 값과 일치** 확인. 불일치 시 CSRF로 판단하고 요청을 폐기. ```ts const state = base64url(crypto.getRandomValues(new Uint8Array(32))); sessionStorage.setItem("oauth_state", state); // 콜백에서 if (params.get("state") !== sessionStorage.getItem("oauth_state")) { throw new Error("CSRF: state mismatch"); } ``` ## 4. Refresh Token은 안전한 저장소에만 저장 - **❌ 브라우저 localStorage/IndexedDB** — XSS 한 번에 털림 - **✅ httpOnly Secure SameSite=Strict 쿠키** — 클라이언트 JS 접근 불가 (웹) - **✅ 모바일 앱은 OS 보안 저장소 사용** — 플랫폼별 권장 방식이 다릅니다: | 플랫폼 | 권장 저장 방식 | 핵심 옵션 | |--------|---------------|-----------| | iOS Native | Keychain Services | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` (iCloud sync 차단) | | Android Native | DataStore + Tink **또는** Ackee Guardian | `setUserAuthenticationRequired(true)` | | Flutter | `flutter_secure_storage` 10.0+ | `KeychainAccessibility.first_unlock_this_device` | | React Native | `react-native-keychain` 10.0+ | `ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY` | ::: warning ⚠️ Android `EncryptedSharedPreferences` 비권장 `androidx.security:security-crypto` 1.1.0부터 deprecated. 신규 SDK 코드에서는 사용하지 말고 **DataStore + Tink** 또는 **Ackee Guardian**으로 마이그레이션하세요. ::: ::: tip 🔐 iCloud sync 주의 iOS Keychain은 기본값으로 iCloud Keychain에 sync됩니다. 토큰은 디바이스 바인딩이 원칙이므로 반드시 `...ThisDeviceOnly` accessibility를 명시하세요. 자세한 예시는 [iOS 통합 가이드](/integrations/swift)를 참고하세요. ::: ## 5. JWT 검증은 서명 + iss + aud + exp ```ts await jwtVerify(token, jwks, { issuer: "logi", audience: process.env.LOGI_CLIENT_ID, // 내 client_id와 정확히 일치 // exp, nbf 는 jose가 자동 검증 }); ``` `aud` 검증을 빼먹으면 **다른 logi 앱의 토큰**을 오인할 수 있습니다. ## 6. Client Secret 관리 - 발급 시 1회 노출 — 즉시 secret manager / env에 복사 - 소스 코드/Git에 **절대** 커밋 금지 - 유출 의심 시 `/developer/applications/:id/rotate_secret` 즉시 호출 — **구 secret 즉시 무효화** - CI 로그 마스킹 확인 ## 7. HTTPS + HSTS - 모든 OAuth 엔드포인트는 **HTTPS 필수** - logi 서버는 `Strict-Transport-Security: max-age=31536000; includeSubDomains; preload` 기본 발송 - 제휴사 redirect_uri도 **HTTPS만** 등록 (localhost는 개발용 예외) --- ## 공격 시나리오 별 방어 요약 | 공격 | logi 방어 | 제휴사 측 방어 | |-----|---------|-------------| | code 탈취 (네트워크/로그) | PKCE S256 | verifier를 sessionStorage에만 | | 다른 제휴사 code 주입 | code_challenge + client_secret 바인딩 | aud 검증 | | CSRF authorize | state 검증 | state를 세션에 저장 | | refresh token 재사용 | rotation + chain revoke | 쿠키 저장 + Set-Cookie 덮어쓰기 | | replay authorize | nonce (openid) | nonce 검증 | | open redirect | redirect_uri 화이트리스트 (exact) | redirect_uri 등록 엄격 | | 브루트포스 로그인 | 10회/15분 → 30분 lockout + Cloudflare | — | | 의심 로그인 | 국가 변경/새 device/burst 탐지 → `suspicious=true` | user.locked_until 체크 | ## Passkey 도입 권장 비밀번호 + OTP 대신 **Passkey가 가장 강한 기본값**입니다: - 피싱 불가 (origin 바인딩) - 재사용 불가 (기기별 고유) - 사용자 경험 최고 (Face ID 한 번) logi iOS 앱은 가입 직후 Passkey 등록을 권장합니다. 웹 클라이언트도 `navigator.credentials.create/get`로 통합 가능 — [OAuth Flow](/oauth/flow) 이후 단계로 도입하세요. ## 토큰 유출 자동 무력화 {#token-leak-response} 토큰은 “절대 새지 않는다”가 아니라 “새도 피해 범위를 빠르게 줄인다”는 기준으로 설계해야 합니다. logi는 access token, refresh token, client secret, PAK를 서로 다른 방식으로 다루고, 유출 징후가 보이면 다음 요청부터 실패하도록 만듭니다. ### Refresh token 재사용 감지 Refresh token은 사용할 때마다 새 토큰으로 교체됩니다. 정상 클라이언트라면 이전 refresh token을 다시 보낼 일이 없습니다. 1. 정상 앱이 refresh token A를 사용합니다. 2. logi가 A를 revoke하고 refresh token B를 발급합니다. 3. 공격자가 훔쳐 둔 A를 나중에 다시 사용합니다. 4. logi는 이를 재사용 공격으로 보고 같은 체인의 refresh token을 모두 revoke합니다. 이후 앱은 새 access token을 받을 수 없고, 사용자는 다시 로그인해야 합니다. 사용자가 직접 “유출 신고”를 하지 않아도 서버가 재사용을 근거로 차단합니다. ### Access token revoke 확인 JWT access token은 서명만 보면 만료 전까지 유효해 보일 수 있습니다. logi는 `jti`를 함께 발급하고, 민감 API에서는 revoke 상태를 조회해 이미 폐기된 토큰을 거부합니다. ```ts await jwtVerify(token, jwks, { issuer: "logi", audience: process.env.LOGI_CLIENT_ID }); // 민감 요청은 jti revoke 상태까지 확인 await assertNotRevoked(payload.jti); ``` 제휴사 앱은 `exp`, `iss`, `aud` 검증을 반드시 수행해야 합니다. logi API 또는 introspection을 사용하는 경우 revoke된 토큰은 더 이상 active로 취급되지 않습니다. ### Client secret과 PAK 유출 대응 Client secret은 앱 단위 비밀값이고, PAK는 사용자 또는 운영자가 CLI/API 자동화에 쓰는 개인 키입니다. 둘 다 유출 의심 즉시 회전 또는 revoke해야 합니다. | 유출 대상 | 자동/즉시 대응 | 운영자가 할 일 | |----------|---------------|----------------| | Refresh token | 재사용 감지 시 토큰 체인 revoke | 사용자 재로그인 안내 | | Access token | revoke 상태 확인 시 요청 거부 | 짧은 만료 시간 유지 | | Client secret | rotate 시 기존 secret 즉시 무효 | CI/CD secret 교체 | | PAK | revoke 시 다음 API 요청부터 401 | 새 PAK 발급 후 자동화 환경 갱신 | ### 사용자가 보게 되는 결과 - 기존 세션 또는 연동 앱이 갑자기 로그아웃될 수 있습니다. - 새 권한이 필요한 경우 consent 화면이 다시 표시됩니다. - 의심 로그인 정책이 켜져 있으면 계정이 임시 잠금될 수 있습니다. - 앱 관리자는 audit log에서 secret 회전, PAK 발급/폐기, 앱 삭제 기록을 확인할 수 있습니다. ## 2단계 인증과 백업 코드 {#two-factor-backup-codes} 2단계 인증은 비밀번호가 맞아도 추가 코드를 요구하는 보호 장치입니다. logi는 시간 기반 일회용 비밀번호(TOTP)를 사용하므로 Google Authenticator, 1Password, Authy, iCloud Passwords 같은 앱과 호환됩니다. ### 설정 흐름 1. 사용자가 보안 설정에서 2단계 인증 켜기를 누릅니다. 2. logi가 계정별 TOTP secret과 QR 코드를 생성합니다. 3. 사용자는 인증 앱으로 QR 코드를 스캔합니다. 4. 앱에 표시된 6자리 코드를 입력해 secret 소유를 증명합니다. 5. 검증에 성공하면 2단계 인증이 활성화되고 백업 코드가 발급됩니다. TOTP 코드는 보통 30초마다 바뀝니다. 서버와 휴대폰 시간이 크게 어긋나면 실패할 수 있으므로, 기기 시간 자동 설정을 켜두는 것이 좋습니다. ### 로그인 때 동작 2단계 인증이 켜진 계정은 비밀번호 검증 후 바로 로그인 완료 처리되지 않습니다. 다음 중 하나가 추가로 필요합니다. - 인증 앱의 6자리 TOTP 코드 - 아직 사용하지 않은 백업 코드 1개 - Passkey처럼 사용자 확인(User Verification)이 포함된 강인증 성공하면 해당 세션에 `otp_verified_at`이 기록됩니다. 민감 작업은 이 시간이 너무 오래되었거나 없으면 다시 2단계 인증을 요구할 수 있습니다. ### 백업 코드 백업 코드는 휴대폰 분실, 인증 앱 삭제, 새 기기 이전 실패 같은 상황에서 계정에 들어가기 위한 비상 수단입니다. - 발급 직후 한 번만 전체 목록을 보여줍니다. - 각 코드는 1회만 사용할 수 있고, 사용 즉시 폐기됩니다. - 비밀번호 관리자나 인쇄물처럼 인증 앱과 다른 장소에 보관해야 합니다. - 남은 코드가 적어지면 새 백업 코드를 재발급하고 기존 코드는 모두 무효화하는 것이 안전합니다. 백업 코드는 편의 기능이 아니라 계정 복구 수단입니다. 평소 로그인에는 인증 앱이나 Passkey를 쓰고, 백업 코드는 기기를 잃어버렸을 때만 사용하는 기준이 안전합니다. ## 로그/알림 정책 logi는 다음을 **자동 수행**합니다: - 모든 로그인 이벤트 `login_logs`에 기록 (IP, UA, country) - `login_notification_enabled` ON인 사용자에게 푸시/이메일 (Phase 2) - `suspicious=true` 로그인은 알림 OFF여도 **강제 알림** (Phase 2) - `auto_lock_on_suspicious_login` ON인 사용자는 의심 탐지 시 **24시간 자동 잠금** 이것들은 제휴사 측 구현이 **아닌**, logi의 책임입니다. 제휴사는 `user.unlinked` 등 Webhook 이벤트를 구독하면 자사 DB 동기화 가능. ## 점검 체크리스트 - [ ] 모든 엔드포인트 HTTPS? - [ ] PKCE S256 생성·전달·저장 경로 점검? - [ ] state 생성·검증 경로 점검? - [ ] client_secret은 env/secret manager에만? - [ ] Refresh Token은 서버 세션 또는 OS 키체인에만? - [ ] JWT 검증에 iss + aud + exp 포함? - [ ] redirect_uri 등록값과 코드의 URI 완전 일치? - [ ] CI/로그에 secret/token 마스킹? - [ ] `token.revoked` Webhook 구독? --- # Troubleshooting *Source: `guide/troubleshooting.md`* # Troubleshooting OAuth 연동에서 가장 흔한 실패 패턴은 **환경변수 누락**입니다. 코드는 그대로지만 prod 빌드에 env가 안 주입돼서 IdP URL이 `localhost`로 떨어지는 케이스가 압도적으로 많습니다. 이 페이지는 그 1순위부터 점검합니다. ## RP 측 필수 환경변수 체크리스트 OAuth 클라이언트(=RP, Relying Party) 앱이 logi에 붙으려면 다음 4개가 prod에 모두 주입돼야 합니다. | 변수 | 값 예시 | 빠지면 | |------|--------|-------| | `LOGI_API_URL` | `https://api.1pass.dev` | `localhost:3000`으로 fallback → 연결 실패 | | `LOGI_CLIENT_ID` | `logi_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | `client_id missing` 400 | | `LOGI_CLIENT_SECRET` | `logi_secret_…` | `/oauth/token` 단계 401 | | `LOGI_SCOPES` (선택) | `openid profile:basic email` | 코드 기본값 사용 (보통 OK) | ::: warning ⚠️ 변수명은 프로젝트 컨벤션을 따르세요 이 문서는 `LOGI_*` 네이밍을 권장합니다. 다른 컨벤션(`ONE_PASS_*`, `IDP_*` 등)을 쓰는 코드베이스라면 4개 모두 그 prefix로 통일하세요. **하나라도 prefix가 다르면 코드가 ENV.fetch에서 못 찾고 default fallback** 됩니다. ::: ## 자가 점검: 1분 안에 원인 찾기 ```bash # 1. RP에서 logi 서버 도달성 확인 curl -I "$LOGI_API_URL/.well-known/openid-configuration" # → 200 OK 가 떠야 함. 안 뜨면 LOGI_API_URL 잘못됨. # 2. client_id 가 logi에 등록돼 있는지 확인 (CLI) logi apps verify $LOGI_CLIENT_ID --redirect-uri "$YOUR_CALLBACK_URL" # 3. 직접 authorize URL 만들어서 브라우저로 열어보기 echo "$LOGI_API_URL/oauth/authorize?response_type=code&client_id=$LOGI_CLIENT_ID&redirect_uri=$YOUR_CALLBACK_URL&scope=openid&state=test&code_challenge=test&code_challenge_method=plain" # → consent 화면이 뜨면 RP→IdP 경로 정상. # → "Invalid redirect_uri" 가 뜨면 logi DB에 redirect_uri 미등록 또는 sandbox tier. ``` ## 흔한 실패 시나리오 ### ❌ 시나리오 1: 로컬은 되는데 prod에서 "리다이렉트가 안 됨" / 빈 화면 **증상**: 로그인 버튼 클릭 → 잠시 로딩 → `ERR_CONNECTION_REFUSED` 또는 빈 페이지. **원인**: `LOGI_API_URL` 환경변수가 prod에 안 주입됨. 코드 fallback이 `http://localhost:3000`이라 브라우저가 사용자 PC의 3000번 포트로 요청. **확인**: ```bash # Render 예시 curl -s "https://api.render.com/v1/services/$SERVICE_ID/env-vars" \ -H "Authorization: Bearer $RENDER_API_KEY" | jq '.[] | select(.envVar.key | startswith("LOGI"))' ``` **수정**: 배포 플랫폼(Vercel/Render/Fly/Railway/...)의 env 설정에 `LOGI_API_URL=https://api.1pass.dev` 추가 → redeploy. --- ### ❌ 시나리오 2: `Invalid redirect_uri` 에러 **증상**: logi authorize 페이지 진입은 되는데 `error=invalid_redirect_uri` 또는 빨간 에러 화면. **원인 A** — 등록 안 됨: prod callback URL이 logi DB에 등록 안 됨. **원인 B** — sandbox tier: 앱 tier가 `sandbox`면 `localhost`/`*.staging.*`/`*.test.*`/`*.localhost` 만 허용. `onrender.com`/`vercel.app` 같은 prod 도메인은 차단. **원인 C** — 🌟 **커스텀 도메인 누락 (가장 흔함)**: 배포 플랫폼 default URL (`*.onrender.com` / `*.vercel.app` / `*.fly.dev`) 만 등록하고 실제 prod 도메인 (`www.example.com`) 을 빠뜨린 경우. RP 코드는 자기 도메인으로 콜백을 만들어 보내는데, logi DB 에는 onrender URL 만 있어서 `redirect_uri not registered` 거부. `redirect_uri` 매칭은 **scheme + host + path 가 정확히 일치** 해야 함 — substring/wildcard 매칭 없음. **확인**: ```bash logi apps show $CLIENT_ID # tier: sandbox ← 이거면 production 으로 승급 필요 # redirect_uris: [...] ← prod URL 들어있는지 확인 ``` **수정**: ```bash # redirect_uri 추가 (변종 도메인 모두 등록 권장) logi apps add-redirect $CLIENT_ID "https://www.example.com/auth/callback" logi apps add-redirect $CLIENT_ID "https://example.com/auth/callback" # apex 도 등록 logi apps add-redirect $CLIENT_ID "https://yourapp.onrender.com/auth/callback" # 백업 (debug 용) # tier 승급 (개발자 포털 → Submit for review → 승인) # 자세한 절차: https://docs.1pass.dev/guide/security#promoting-to-production ``` ::: tip 🌟 권장: 커스텀 도메인 + 플랫폼 URL 모두 등록 배포 플랫폼 default URL (`*.onrender.com` 등) 도 함께 등록해두면: - 커스텀 도메인 DNS/SSL 사고 시 backup 경로 - staging/preview 배포 디버깅 - 도메인 마이그레이션 시 cutover 윈도우 확보 `redirect_uris` 는 배열이라 여러 개 등록 가능합니다 (개수 제한은 plan 기준 — free 5개, pro 20개). ::: ::: tip 왜 sandbox tier 가 prod 도메인을 차단하나요? 개발 단계 앱이 prod 사용자 데이터에 접근하지 못하도록 막는 안전장치입니다 (Stripe `pk_test_…` 와 동일 사상). 실수로 test 키로 prod 트래픽을 받는 사고를 원천 차단합니다. 자세한 보안 모델: [Security · Sandbox vs Production](/guide/security#sandbox-vs-production). ::: --- ### ❌ 시나리오 3: Callback 까지 오는데 `/oauth/token` 에서 401 **증상**: code 까지 받았는데 token 교환에서 `invalid_client` 401. **원인 후보**: 1. `LOGI_CLIENT_SECRET` prod env 미주입 → fallback 또는 빈 값 2. client_secret 회전됐는데 RP env 업데이트 누락 3. `redirect_uri` mismatch — authorize 때와 token 때의 redirect_uri 가 정확히 일치해야 함 (스키마/포트/trailing slash 포함) **수정**: ```bash # secret 회전 후 새 값 주입 logi apps rotate-secret $CLIENT_ID # → 출력된 client_secret 을 RP env 에 즉시 복사 ``` --- ### ❌ 시나리오 4: Mac/iOS 데스크톱에서 SSO 버튼 클릭 시 native 앱 안 열림 (브라우저로만 열림) **증상**: RP 로그인 페이지에서 "logi 로 로그인" 클릭 → Safari/Chrome에서 `api.1pass.dev/session/new` 가 그대로 보임. logi Mac 앱이 설치돼있는데도 안 열림. **원인**: RP가 server-side 302 redirect로 OAuth flow를 시작하는 패턴. macOS Universal Link는 **사용자가 직접 클릭한 링크의 도메인** 만 AASA 매칭하고, **redirect chain은 user-gesture를 끊어서 무시**합니다. iOS 14+ 는 redirect 후에도 매칭하지만 macOS는 더 엄격. **해결**: RP 로그인 페이지 렌더 시점에 **server에서 `api.1pass.dev/oauth/authorize?…` URL을 미리 생성**하여 `` 에 직접 박아둡니다. 클릭이 logi 도메인을 향해 가니까 macOS가 AASA 매칭 → 네이티브 앱 open. ```ruby # Rails 예시 — Web::SessionsController#new def new state = SecureRandom.urlsafe_base64(32) verifier, challenge = OnePassSsoService.generate_pkce session[:one_pass_state] = state session[:one_pass_code_verifier] = verifier render inertia: "Auth/Login", props: { one_pass_authorize_url: OnePassSsoService.authorize_url( redirect_uri: auth_1pass_callback_url, state: state, code_challenge: challenge ) } end ``` ```svelte logi 로 로그인 ``` PKCE verifier/state 는 server session에 그대로 보존 → 보안 영향 없음. callback flow 는 기존과 동일. > ⚠️ 주소창에 URL을 paste해서 enter는 macOS Universal Link를 트리거하지 **않습니다** (클릭 링크만 트리거). 검증할 때 반드시 실제 `` 태그 클릭으로 테스트. --- ### ❌ 시나리오 5: native 앱은 열리는데 동의 화면에 "불러오기 실패" **증상**: Universal Link로 logi 앱이 정상 open → 동의 sheet 표시 → "불러오기 실패" / 빈 화면 / 즉시 dismiss. **원인**: Cold start race condition. iOS/Mac 앱이 Universal Link 받으면 `OAuthConsentView`가 즉시 `/api/v1/oauth/authorize/preview` 를 호출하는데, **`SessionStore.bootstrap()`이 Keychain에서 PAK를 꺼내 `APIClient` 에 주입하기 전**에 호출이 나가버려서 `401 unauthenticated` 반환 → 동의 화면이 "불러오기 실패" 상태로 멈춤. **해결**: 동의 sheet 표시를 `session.state == .signedIn` 이후로 지연 (LogiApp.swift / LogiMacApp.swift): ```swift private var oauthSheetBinding: Binding { Binding( get: { guard case .signedIn = session.state else { return nil } // ← race 차단 return IncomingOAuthRouter.shared.pending.map(IdentifiableRequest.init) }, set: { value in if value == nil { IncomingOAuthRouter.shared.clear() } } ) } ``` 이렇게 하면 cold start 시: Universal Link 수신 → `pending` 보관 → `bootstrap()` 완료 → `signedIn` 전환 → 그 시점에 sheet 표시 (PAK 주입 끝난 상태). --- ### ❌ 시나리오 6: Localhost 에서도 안 됨 **원인**: logi 서버 미실행 또는 다른 포트. **확인**: ```bash curl http://localhost:3000/up # logi 서버 헬스체크 # OK → logi 동작 중 # Connection refused → logi 서버 안 돌아감 ``` **수정**: logi 디렉토리에서 `bin/rails server` 또는 `cd server && bin/dev`. --- ### ❌ 시나리오 7: TestFlight 빌드 설치했는데 Universal Link 가 안 열림 **증상**: TestFlight 로 logi 앱 설치 + 한 번 실행 + Safari 에서 RP 사이트 → 1pass 버튼 탭 → **logi 앱이 안 열리고 `api.1pass.dev/session/new` 가 그대로 보임**. 시나리오 4 (server-side 302) 이슈는 이미 해결됐고 RP 가 `` 로 직접 박는데도 안 됨. **원인**: entitlement 에 `applinks:api.1pass.dev?mode=developer` 가 들어간 채 Apple Distribution profile 로 빌드/제출됨. Apple 공식 문서 + WWDC19 session 717: > *"Apps signed for distribution on the App Store **or TestFlight** or Mac apps that have been signed and notarized cannot be used with this alternate mode."* 즉 Distribution-signed 빌드에서 `?mode=developer` 토큰은 **silently ignored**. 그 결과: - AASA association 이 OS 측에 등록조차 안 됨 - `swcd` 가 해당 도메인을 모르는 상태 - Universal Link 매칭 실패 → 브라우저로 fallback build 23 사고의 정확한 증상이 이것 (2026-04). **확인**: ```bash # (macOS 디바이스에서) 앱이 설치된 상태로 sudo swcutil show | grep -A 8 "1pass.dev" # 정상이면: Status: approved/approved # 비정상이면: 도메인 자체가 안 보이거나 "denied" 상태 ``` **수정**: 1. `ios/Sources/logi.entitlements` 와 `ios/project.yml` 의 entitlement 를 plain 으로: ```diff - applinks:api.1pass.dev?mode=developer + applinks:api.1pass.dev ``` 2. mac 도 동일하게 (`mac/Sources/LogiMac.entitlements` + `mac/project.yml`) 3. 빌드번호 +1 (23 → 24+) 후 새 TestFlight 업로드: ```bash cd ios && make testflight cd mac && make testflight ``` 4. 새 빌드 설치 후 앱 한 번 실행 → Safari 에서 RP 사이트 → 1pass 버튼 탭 검증 `?mode=developer` 가 정말 필요한 시점은 로컬 Xcode debug 빌드뿐. `make dev-mode-on` / `make dev-mode-off` 토글로 관리하고 절대 commit 금지. 자세한 내용은 `RELEASE_CHECKLIST.md` 참고. > 🚫 검증 시 주의: 주소창에 `https://api.1pass.dev/oauth/authorize?...` 직접 paste → 동일 도메인 출처라 Universal Link 트리거 자체가 무시됨 (Apple 의도 동작). 반드시 다른 도메인의 사이트에서 `` 클릭으로 검증. > 🚫 Chrome / Firefox / Brave / Safari 시크릿 모드는 Universal Link 트리거가 일관되지 않거나 아예 안 됨. **Safari 정상 모드** 에서만 검증. --- ## 배포 플랫폼별 env 설정 가이드 ### Render ```bash # 단일 변수 추가/수정 (collection PUT 은 절대 사용 금지 — 전체 교체됨) curl -X PUT "https://api.render.com/v1/services/$SERVICE_ID/env-vars/LOGI_API_URL" \ -H "Authorization: Bearer $RENDER_API_KEY" \ -H "Content-Type: application/json" \ -d '{"value": "https://api.1pass.dev"}' # 변경 후 redeploy curl -X POST "https://api.render.com/v1/services/$SERVICE_ID/deploys" \ -H "Authorization: Bearer $RENDER_API_KEY" \ -d '{"clearCache": "do_not_clear"}' ``` ### Vercel ```bash vercel env add LOGI_API_URL production # → 프롬프트에서 https://api.1pass.dev 입력 vercel --prod # redeploy ``` ### Fly.io ```bash fly secrets set LOGI_API_URL=https://api.1pass.dev # 자동 redeploy ``` --- ## 디버그 모드 RP 코드에서 IdP 응답을 자세히 보고 싶을 때: ```ruby # Rails 예시 Rails.logger.info "[logi] authorize_url=#{url}" Rails.logger.info "[logi] token_response=#{tokens.except('access_token', 'refresh_token').inspect}" ``` logi 서버 측 로그는 `logi token inspect ` 으로 디코드해서 확인 가능합니다. --- ## 그래도 안 되면 - [GitHub Issues](https://github.com/seunghan91/logi/issues) — 재현 가능한 최소 예제 첨부 - 로그에 `client_id`, `redirect_uri`, HTTP status code 만 포함하면 90%는 빠르게 진단됩니다 (access_token/secret 은 절대 첨부 금지). --- # Universal Links 통합 가이드 (RP 측) *Source: `guide/universal-links.md`* # Universal Links 통합 가이드 (RP 측) 1pass 로 로그인 버튼을 누른 사용자에게 **마찰 없이 네이티브 앱이 자동으로 열리는** 경험을 만드는 패턴 정리. 1pass IdP 와 OAuth 연동하는 모든 RP (Relying Party) 가 적용 대상. > 이 페이지는 RP 측 통합 패턴이고, 1pass 앱 자체의 entitlement 설정은 [`RELEASE_CHECKLIST.md`](https://github.com/seunghan91/logi/blob/main/RELEASE_CHECKLIST.md), 트러블슈팅은 [Troubleshooting 가이드](./troubleshooting) 의 시나리오 4·7 참고. --- ## 핵심 제약 Apple Universal Links 는 **Safari / WKWebView 안에서 클릭한 링크만** 트리거된다. Apple 공식: > *"With universal links, users open your app when they click links to your website within Safari and WKWebView."* > — [Allowing Apps and Websites to Link to Your Content](https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content) 즉: | 환경 | Universal Link 트리거 | |---|---| | iOS / macOS Safari | ✅ | | iOS / macOS WKWebView (in-app browser) | ✅ | | iOS / macOS Chrome / Firefox / Brave / Edge | ❌ | | Safari 시크릿 모드 | ⚠️ 일관되지 않음 | | Windows / Android / Linux | ❌ (Android 는 별도 — App Links 사용) | | 주소창에 URL 직접 paste | ❌ (Apple 의도된 동작) | | 같은 도메인 내 클릭 | ❌ (Apple 의도된 동작) | 따라서 RP 입장에서 "그냥 ``만 박아두면 알아서 앱 열림" 은 **Safari 사용자에게만 사실**. 비-Safari Apple OS 사용자에게는 추가 처리가 필요하다. --- ## 권장 3-tier 패턴 ### Tier 1 — 직접 `` (필수) 서버에서 OAuth authorize URL 을 미리 생성해 페이지에 직접 박는다. **server-side 302 redirect 금지** (시나리오 4 참고). ```html 1pass 로 로그인 ``` `data-turbo="false"` (또는 프레임워크별 등가물) 로 SPA 라우터 가로채기를 막는다. `rel="external"` 은 외부 도메인임을 명시. Safari 사용자: 클릭 → OS 가 AASA 매칭 → 앱 열림. 끝. ### Tier 2 — non-Safari Apple OS 핸드오프 (권장) 비-Safari 사용자가 클릭하면 `x-safari-https://` URL scheme 으로 Safari 핸드오프를 시도한다. ```html 1pass 로 로그인 ``` #### 왜 `x-safari-https://` 인가 iOS / macOS Safari 가 자기 자신에게 등록한 historical URL scheme. Apple 공식 문서화는 없지만 iOS 14+ 부터 안정 동작. Telegram, Slack, Discord 등 대부분의 메신저가 "Safari 에서 열기" 기능에 사용한다. 비-Apple OS 또는 등록된 Safari 가 없으면 그냥 실패하므로 안전 (페이지가 "이 링크를 열 수 없습니다" 같은 가벼운 에러만 표시). ### Tier 3 — 1pass `/session/new` 안내 banner (자동) Tier 2 가 실패하거나 사용자가 그냥 진행한 경우, 1pass 측 로그인 페이지에서 자동으로 안내 banner 가 표시된다. 이건 RP 가 추가 작업할 필요 없음 (1pass 가 처리). ``` 📱 1pass 앱이 설치되어 있다면 Safari 에서 열어보세요 Apple Universal Links 는 Safari 에서만 동작합니다 … ``` 조건: `return_to` 가 `/oauth/authorize` 로 시작 + UA 가 Apple OS + UA 가 non-Safari → 자동 표시. --- ## 구현 예시 ### Rails (Inertia.js + Svelte 5) ```ruby # app/controllers/web/sessions_controller.rb def new state = SecureRandom.urlsafe_base64(32) verifier, challenge = OnePassSsoService.generate_pkce session[:one_pass_state] = state session[:one_pass_code_verifier] = verifier session[:one_pass_initiated_at] = Time.current.to_i render inertia: "Auth/Login", props: { one_pass_authorize_url: OnePassSsoService.authorize_url( redirect_uri: auth_1pass_callback_url, state: state, code_challenge: challenge ) } end ``` ```svelte handleOnePassClick(e, one_pass_authorize_url)} data-turbo="false" rel="external" class="..." > 1pass 로 로그인 ``` ### Next.js (App Router) ```tsx 'use client' export function OnePassButton({ authorizeUrl }: { authorizeUrl: string }) { const handleClick = (e: React.MouseEvent) => { const ua = navigator.userAgent const isAppleOS = /iPhone|iPad|iPod|Macintosh|Mac OS X/.test(ua) const isNonSafari = /CriOS|FxiOS|EdgiOS|Chrome|Firefox|Edg|OPR\//.test(ua) if (isAppleOS && isNonSafari) { e.preventDefault() window.location.href = authorizeUrl.replace(/^https:/, 'x-safari-https:') } } return ( 1pass 로 로그인 ) } ``` 서버 측 (page.tsx) 에서 `authorizeUrl` 을 생성해 prop 으로 전달. PKCE verifier 는 cookie 또는 server-side session 에 저장. --- ## RP 측 검증 체크리스트 새 RP 통합 후 출시 전 이걸 다 통과하는지 확인: - [ ] **Safari (iOS / macOS)** 에서 1pass 버튼 → 앱이 native consent 화면으로 열림 - [ ] **Mac Chrome** 에서 1pass 버튼 → Safari 로 핸드오프 → 앱 열림 (Tier 2) - [ ] **iOS Chrome (CriOS)** 에서 1pass 버튼 → Safari 로 핸드오프 → 앱 열림 (Tier 2) - [ ] **앱 미설치 + Safari** → 1pass 웹 로그인 페이지로 진행 (정상) - [ ] **Windows / Android Chrome** → 1pass 웹 로그인 페이지로 진행 (정상, Universal Link 무관) - [ ] **시크릿 모드 / Safari 비활성화 환경** → 1pass `/session/new` 의 안내 banner 노출 (Tier 3 fallback) --- ## 흔한 실수 ### ❌ Server-side 302 redirect 로 OAuth flow 시작 RP 가 `` 같은 자기 도메인 링크를 두고 → server 가 `redirect_to "https://api.1pass.dev/oauth/authorize?..."` 로 302 응답. 문제: macOS Universal Links 는 user-gesture 가 직접 향하는 도메인만 매칭. redirect chain 은 user-gesture 를 끊는다. iOS 14+ 는 redirect 후에도 매칭하지만 macOS 는 더 엄격. 해결: 페이지 렌더 시점에 server 에서 OAuth URL 을 미리 생성해 `` 에 박아둔다. PKCE verifier 는 그대로 server session 에 저장 (보안 무영향). ### ❌ 주소창 paste 로 검증 `https://api.1pass.dev/oauth/authorize?...` 를 주소창에 paste → enter 는 Apple 의도된 동작으로 Universal Link 무시. 검증할 때는 반드시 다른 도메인의 페이지에서 `` **클릭** 으로 테스트. ### ❌ Chrome 에서만 테스트하고 끝 Apple Universal Links 는 Safari 에서만 트리거되므로 Chrome 검증은 무의미. 반드시 Safari 정상 모드에서 검증. ### ❌ `x-safari-https://` 만으로 충분하다고 가정 이 scheme 은 비공식 + Apple 이 언제든 차단 가능. Tier 2 가 실패하는 경우를 가정하고 Tier 3 (1pass 안내 banner) 가 항상 동작하도록 둔다. 이게 안전망. --- ## 관련 문서 - [Troubleshooting — 시나리오 4·7](./troubleshooting) — 1pass 버튼 클릭해도 앱이 안 열릴 때 - [OAuth Authorization Code Flow](/oauth/flow) — RP 측 OAuth 표준 flow - [PKCE (RFC 7636)](/oauth/pkce) — code verifier / challenge - [Apple TN3155 — Debugging Universal Links](https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links) - [Apple — Allowing Apps and Websites to Link to Your Content](https://developer.apple.com/documentation/xcode/allowing-apps-and-websites-to-link-to-your-content) --- # Webhook HMAC 서명 검증 *Source: `guide/webhook-verification.md`* # Webhook HMAC 서명 검증 `X-Logi-Signature: sha256=` 는 `HMAC-SHA256(webhook_secret, request_body)` 입니다. ## 검증 순서 1. **Timestamp 범위 확인** — `X-Logi-Timestamp`와 현재 시간의 차이가 ±5분 이내 (replay 방어) 2. **Signature 재계산 후 상수시간 비교** ## 언어별 예시 ::: code-group ```ts [Node.js] import crypto from "node:crypto"; export function verifyLogiWebhook(req, secret) { const ts = Number(req.header("X-Logi-Timestamp")); if (Math.abs(Date.now() / 1000 - ts) > 300) throw new Error("replay"); const sig = req.header("X-Logi-Signature")?.replace("sha256=", "") ?? ""; const expected = crypto.createHmac("sha256", secret).update(req.rawBody).digest("hex"); const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) throw new Error("bad sig"); } ``` ```ruby [Rails] require "openssl" def verify_logi!(request, secret) ts = request.headers["X-Logi-Timestamp"].to_i raise "replay" if (Time.current.to_i - ts).abs > 300 sig = request.headers["X-Logi-Signature"].to_s.sub("sha256=", "") expected = OpenSSL::HMAC.hexdigest("SHA256", secret, request.raw_post) raise "bad sig" unless ActiveSupport::SecurityUtils.secure_compare(sig, expected) end ``` ```python [Flask/Django] import hmac, hashlib, time def verify_logi(headers, raw_body, secret): ts = int(headers.get("X-Logi-Timestamp", "0")) if abs(time.time() - ts) > 300: raise ValueError("replay") sig = headers.get("X-Logi-Signature", "").replace("sha256=", "") expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest() if not hmac.compare_digest(sig, expected): raise ValueError("bad sig") ``` ::: **주의**: 반드시 **raw body** (JSON 파싱 전 원본 바이트)로 검증. 파싱 후 직렬화하면 공백/순서가 달라져 서명 불일치. --- # Webhook 연동 *Source: `guide/webhooks.md`* # Webhook 연동 logi는 제휴사에 4가지 이벤트를 통지합니다: | event_type | 언제 | |-----------|-----| | `user.deleted` | 사용자 계정 삭제 | | `user.unlinked` | 제휴사 앱 연결 해제 | | `consent.revoked` | scope 동의 철회 | | `token.revoked` | Access/Refresh Token 강제 무효화 | ## 설정 앱 등록 시 `webhook_url` 지정 (HTTPS 권장). 변경은 `PATCH /api/v1/applications/:id`. ## 요청 형식 ```http POST https://your.app/hooks/logi Content-Type: application/json X-Logi-Event: user.deleted X-Logi-Delivery-Id: 12345 X-Logi-Timestamp: 1735000000 X-Logi-Signature: sha256=a3d9...f0 {"id":12345,"event_type":"user.deleted","payload":{"user_id":42},"created_at":"..."} ``` ## 재시도 정책 - 최대 **10회** (24h 내) - 지수 백오프: 1m → 2m → 4m → 8m → 16m → 32m → 60m → 120m → 240m → 480m - 2xx 응답이면 전달 완료. 3xx/4xx/5xx 및 타임아웃은 재시도 - 10회 모두 실패 → `failed_at` 마킹 · 개발자 포털에 표시 [다음: HMAC 서명 검증 →](/guide/webhook-verification) --- # index *Source: `index.md`* ## 왜 1pass인가 - **5분 만에 끝납니다** — AI에게 요청하면 첫 로그인까지 5분. - **사용자 정보 최소 보관** — 실명·주민번호 같은 민감 정보는 저장하지 않습니다. - **사용자가 직접 통제** — 접속 기록·연결 앱·기기 모두 사용자가 직접 관리. ## 무엇부터 읽으면 되나 - [**5분 Quickstart**](/guide/quickstart) — `curl` 한 번으로 전체 플로우 확인 - [**핵심 개념**](/guide/concepts) — App, Scope, Token이 뭔지 30초 정리 - [**FAQ**](/guide/faq) — 도입 검토 / 사용자 질문 모음 - [**보안 가이드**](/guide/security) — 실수하기 쉬운 부분 모음 - [**API 레퍼런스**](/reference/api) — 인터랙티브 OpenAPI ## 자주 묻는 질문 (요약) **Q. 우리 서비스에 붙이려면?** — Quickstart대로 5분. AI에 [llms-full.txt](/llms-full.txt) 던지고 "1pass 붙여줘" 한 줄도 가능. **Q. 웹에서도 되나요?** — 네. QR 로그인 + SSO 푸시 승인 모두 지원. **Q. 가입할 때 뭘 입력하나요?** — 아무것도 안 받습니다. 앱 설치 = 가입 완료. 이메일·이름은 서비스가 요구할 때만 사용자가 항목별로 동의. **Q. 사용자가 앱을 깔아야 하나요?** — 네. 인증 매체는 1개 필수입니다. 5MB짜리 단일 목적 앱이라 인앱 브라우저에서 막히는 구글 로그인 이탈 문제가 없습니다. **Q. 아이폰만 되나요?** — 아뇨, iOS/Android 모두 지원. [→ 전체 FAQ 보기](/guide/faq) ## AI/LLM에게 통째로 던지기 [llms.txt 표준](https://llmstxt.org/)으로 전체 문서를 LLM 친화 형식으로 제공합니다. - [📥 `/llms.txt`](/llms.txt) — 페이지 색인 + 1줄 요약 (4 KB) - [📥 `/llms-full.txt`](/llms-full.txt) — 모든 본문 한 파일로 합침 (55 KB, 21 페이지) ChatGPT/Claude에 붙여넣고 "1pass로 로그인 붙이는 법 알려줘" 한마디면 끝. ---

1pass.dev

--- # Android (Kotlin) *Source: `integrations/android.md`* # Android (Kotlin) Android Custom Tabs + DataStore + Android Keystore로 네이티브 OAuth + PKCE를 구현합니다. ::: warning ⚠️ EncryptedSharedPreferences는 사용하지 마세요 `androidx.security:security-crypto` 1.1.0부터 deprecated. 신규 코드에서는 `DataStore + Tink` 또는 `Ackee Guardian` 같은 활성 라이브러리를 사용하세요. ::: ## 의존성 ```kotlin // app/build.gradle.kts dependencies { // Custom Tabs implementation("androidx.browser:browser:1.8.0") // DataStore + Tink for encrypted storage implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("com.google.crypto.tink:tink-android:1.13.0") // Biometric (선택, 민감 작업용) implementation("androidx.biometric:biometric:1.2.0-alpha05") } ``` ## OAuth + PKCE 플로우 ```kotlin import android.content.Context import android.net.Uri import androidx.browser.customtabs.CustomTabsIntent import java.security.MessageDigest import java.security.SecureRandom import android.util.Base64 import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.FormBody class LogiOAuth(private val context: Context) { private val base = "https://logi.example.com" private val clientId = "logi_..." private val redirectUri = "com.example.myapp://callback" private val client = OkHttpClient() private fun base64UrlEncode(data: ByteArray): String = Base64.encodeToString(data, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) fun startSignIn(): Pair { // 1. PKCE val verifier = ByteArray(32).also { SecureRandom().nextBytes(it) } .let { base64UrlEncode(it) } val challenge = base64UrlEncode( MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()) ) val state = base64UrlEncode( ByteArray(16).also { SecureRandom().nextBytes(it) } ) // 2. authorize URL val url = Uri.parse("$base/oauth/authorize").buildUpon() .appendQueryParameter("client_id", clientId) .appendQueryParameter("redirect_uri", redirectUri) .appendQueryParameter("response_type", "code") .appendQueryParameter("scope", "profile email") .appendQueryParameter("state", state) .appendQueryParameter("code_challenge", challenge) .appendQueryParameter("code_challenge_method", "S256") .build() // 3. Custom Tabs로 열기 CustomTabsIntent.Builder().build().launchUrl(context, url) return verifier to state // Activity에서 보관 → onNewIntent에서 검증 } suspend fun exchangeCode(code: String, verifier: String): TokenResponse { val body = FormBody.Builder() .add("grant_type", "authorization_code") .add("code", code) .add("redirect_uri", redirectUri) .add("code_verifier", verifier) .add("client_id", clientId) .build() val req = Request.Builder().url("$base/oauth/token").post(body).build() client.newCall(req).execute().use { resp -> // JSON 파싱 (kotlinx.serialization 등) return parseToken(resp.body!!.string()) } } } data class TokenResponse(val accessToken: String, val refreshToken: String) ``` `onNewIntent`에서 redirect callback URI를 받아 `state` 검증 후 `exchangeCode` 호출하세요. ## Refresh Token 저장 (DataStore + Tink) Tink는 Google이 만든 암호화 라이브러리로 키 관리를 자동화합니다. Android Keystore와 연동되어 키 자체는 하드웨어 백킹(가능 시), 데이터는 AEAD로 암호화됩니다. ```kotlin import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.google.crypto.tink.Aead import com.google.crypto.tink.KeyTemplates import com.google.crypto.tink.aead.AeadConfig import com.google.crypto.tink.integration.android.AndroidKeysetManager import android.util.Base64 private val Context.tokenStore by preferencesDataStore(name = "logi_tokens") class LogiTokenStorage(private val context: Context) { private val refreshKey = stringPreferencesKey("refresh_token_encrypted") init { AeadConfig.register() } private val aead: Aead by lazy { AndroidKeysetManager.Builder() .withSharedPref(context, "logi_master_keyset", "logi_prefs") .withKeyTemplate(KeyTemplates.get("AES256_GCM")) .withMasterKeyUri("android-keystore://logi_master_key") .build() .keysetHandle .getPrimitive(Aead::class.java) } suspend fun saveRefreshToken(token: String) { val ciphertext = aead.encrypt(token.toByteArray(), null) val encoded = Base64.encodeToString(ciphertext, Base64.NO_WRAP) context.tokenStore.edit { it[refreshKey] = encoded } } suspend fun loadRefreshToken(): String? { val encoded = context.tokenStore.data .map { it[refreshKey] } .firstOrNull() ?: return null val ciphertext = Base64.decode(encoded, Base64.NO_WRAP) return String(aead.decrypt(ciphertext, null)) } } ``` ## 백업에서 토큰 제외 (필수) Android의 auto-backup이 토큰을 Google Drive로 보내면 복원 시 키 불일치로 토큰이 무효화됩니다. **반드시** 토큰 저장소를 백업 대상에서 제외하세요: ```xml ``` ```xml ``` ## 민감 작업에 biometric 추가 Android Keystore의 `setUserAuthenticationRequired(true)`로 키 자체를 biometric으로 보호할 수 있습니다. high-assurance 액션 직전에만 사용하세요: ```kotlin import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import javax.crypto.KeyGenerator fun generateBiometricProtectedKey(alias: String) { val spec = KeyGenParameterSpec.Builder( alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT ) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .setUserAuthenticationRequired(true) .setUserAuthenticationParameters(0, KeyProperties.AUTH_BIOMETRIC_STRONG) .setInvalidatedByBiometricEnrollment(true) // 새 지문 추가 시 키 무효화 .apply { // StrongBox 가능 시 사용 (제조사 의존) try { setIsStrongBoxBacked(true) } catch (_: Exception) {} } .build() KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") .apply { init(spec) } .generateKey() } ``` `BiometricPrompt`로 사용자 인증을 트리거한 뒤 cipher를 받아 사용합니다. ::: warning StrongBox는 보장되지 않습니다 StrongBox(전용 보안 칩)는 API 28+ 일부 디바이스에만 있습니다. `StrongBoxUnavailableException`을 catch해서 fallback하세요. logi SDK는 자동으로 fallback합니다. ::: ::: warning Biometric 재등록 시 키 무효화 `setInvalidatedByBiometricEnrollment(true)`(권장)인 경우, 사용자가 새 지문을 추가하면 기존 키가 사용 불가가 됩니다. 토큰 재발급 + 재로그인 플로우를 준비하세요. ::: ## Device Bootstrap (`device_secret`) logi의 device bootstrap은 dual mode입니다: 1. **첫 호출 (bootstrap)**: `POST /api/v1/devices` with `{device_uuid, platform}` → 응답에 `device_secret` 1회 노출 + PAK 발급 2. **이후 호출 (refresh)**: 같은 endpoint에 `{device_uuid, platform, device_secret}` → 서버가 digest 검증 후 새 PAK 발급. **누락/불일치 시 401**. `device_secret`을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다. ```kotlin data class BootstrapResponse( val accessToken: String, val deviceSecret: String? // bootstrap/legacy grace에서만 존재 ) // 첫 호출 응답 처리 suspend fun handleBootstrap(resp: BootstrapResponse) { resp.deviceSecret?.let { storage.saveDeviceSecret(it) } } // 이후 PAK 갱신 시 val secret = storage.loadDeviceSecret() // null이면 OAuth 재로그인 유도 val body = mapOf( "device_uuid" to uuid, "platform" to "android", "device_secret" to secret ) ``` refresh token과 별도 DataStore 키로 저장하세요: ```kotlin private val deviceSecretKey = stringPreferencesKey("device_secret_encrypted") suspend fun saveDeviceSecret(secret: String) { val ciphertext = aead.encrypt(secret.toByteArray(), null) context.tokenStore.edit { it[deviceSecretKey] = Base64.encodeToString(ciphertext, Base64.NO_WRAP) } } ``` ❌ **절대 SharedPreferences 평문에 저장 금지** — 백업·루팅 디바이스에서 즉시 노출됩니다. ## 점검 체크리스트 - [ ] `EncryptedSharedPreferences` 사용하지 않음 (deprecated) - [ ] DataStore + Tink로 암호화 저장 - [ ] `backup_rules.xml`에 토큰 저장소 제외 명시 - [ ] PKCE S256 사용 (plain 금지) - [ ] `state` 생성·검증 - [ ] `device_secret`과 refresh token 별도 키로 저장 - [ ] 민감 작업에만 biometric 적용 (background refresh와 양립 불가) - [ ] StrongBox 미지원 디바이스 fallback 처리 --- # 로그인 버튼 컴포넌트 *Source: `integrations/buttons.md`* # 로그인 버튼 컴포넌트 logi SSO 버튼의 표준 사이즈 × 테마 조합. **vanilla HTML + CSS variables** — React/Vue/Svelte/Flutter 어느 스택에서도 복붙해서 색만 토큰으로 override 하면 그대로 동작합니다. ::: tip 왜 vanilla 만 제공하나요 프레임워크별 NPM 패키지는 채택 데이터 본 후 Phase 2에 진행 예정입니다. 그 전에는 vanilla 가 모든 스택에서 동작하고, 디자인 변경 시 RP 가 한 줄로 token 만 바꿀 수 있어 가장 유연합니다. QR 로그인 옵션은 logi 의 `/oauth/authorize` 페이지 안에 이미 통합돼 있어 별도 QR 버튼은 필요 없습니다 ([QR 로그인](../oauth/qr-login) 참고). ::: ## 공통 CSS 한 번만 박아두면 아래 모든 사이즈가 동작합니다. ```html ``` 브랜드 아이콘 (동심원 SVG): ```html ``` ## 1x1 — 아이콘 전용 (44×44) 사이드바 / FAB / 모바일 헤더 / 액션바 같이 좁은 공간 — 텍스트 없이 로고만.
```html ``` ::: warning 접근성 1x1 버튼은 텍스트가 없으므로 `aria-label="logi 로 로그인"` 필수. 스크린리더가 의미 전달 못 하면 WCAG 2.1 4.1.2 위반. ::: ## 2x1 — 인라인 (220×44) 로그인 폼 하단 / 모달 footer / "다른 방법으로 로그인" 옵션 묶음. ```html logi 로 로그인 ``` ## 4x1 — Hero (full-width × 56) 메인 로그인 페이지의 primary CTA. 한 줄을 통째로 차지. ```html logi 로 로그인 ``` ## 디자인 토큰 override 예시 RP 의 디자인 시스템에 맞추기: ```css /* Tailwind 프로젝트 */ :root { --logi-button-bg: theme('colors.indigo.600'); --logi-button-fg: theme('colors.white'); --logi-button-radius: theme('borderRadius.md'); } /* shadcn/ui */ :root { --logi-button-bg: hsl(var(--primary)); --logi-button-fg: hsl(var(--primary-foreground)); --logi-button-radius: var(--radius); } /* Bootstrap */ :root { --logi-button-bg: var(--bs-primary); --logi-button-fg: #fff; --logi-button-radius: var(--bs-border-radius); } ``` ## 프레임워크 인라인 (참고용) ### React / JSX ```jsx function LogiButton({ size = "2x1", theme = "dark", href = "/auth/1pass/initiate" }) { return ( {size !== "1x1" && "logi 로 로그인"} ); } ``` ### Svelte ```svelte {#if size !== "1x1"}logi 로 로그인{/if} ``` ### Flutter (`logi_button.dart`) 별도 SDK 패키지로 분리 예정. 그 사이엔 `flutter_web_auth_2` + `IconButton`/`ElevatedButton` 으로 위 사이즈 매트릭스를 직접 구성하세요. 색상은 `Theme.of(context).colorScheme.primary` 를 따르도록. ## 브랜드 가이드 | 항목 | 규칙 | |---|---| | 텍스트 | 한국어: "logi 로 로그인" / 영문: "Sign in with logi" / 일본어: "logi でログイン" | | 로고 | 동심원 3개 (semantic: 내·외층 + 중심점) — 변형 / 회전 / 색 분해 ❌ | | 최소 사이즈 | 32×32px (이보다 작으면 로고 식별 어려움) | | 클리어 영역 | 버튼 둘레 = 로고 1/2 폭 만큼 비워두기 | | 색 대비 | WCAG AA 4.5:1 이상 (light/dark 토큰 그대로 쓰면 충족) | ## 다음 (예정) - **NPM 패키지** `@logi-auth/button-react` / `@logi-auth/button-vue` / `@logi-auth/button-svelte` — 채택 데이터 보고 Phase 2 - **CDN CSS** `https://api.1pass.dev/buttons.css` — `` 한 줄 통합 - **Figma library** — 디자이너 협업용 이슈/요청: [GitHub](https://github.com/seunghan91/logi/issues) --- # Express.js *Source: `integrations/express.md`* # Express.js ```js import express from "express"; import crypto from "node:crypto"; import cookieParser from "cookie-parser"; const app = express(); app.use(cookieParser(process.env.COOKIE_SECRET)); const LOGI = process.env.LOGI_API_URL; const CLIENT_ID = process.env.LOGI_CLIENT_ID; const CLIENT_SECRET = process.env.LOGI_CLIENT_SECRET; const REDIRECT = process.env.LOGI_REDIRECT_URI; function b64url(buf) { return Buffer.from(buf).toString("base64url"); } app.get("/auth/login", (req, res) => { const verifier = b64url(crypto.randomBytes(32)); const challenge = b64url(crypto.createHash("sha256").update(verifier).digest()); const state = b64url(crypto.randomBytes(16)); res.cookie("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 }); res.cookie("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600_000 }); const url = new URL(`${LOGI}/oauth/authorize`); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("redirect_uri", REDIRECT); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", "profile email"); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); res.redirect(url.toString()); }); app.get("/auth/callback", async (req, res) => { if (req.query.state !== req.cookies.logi_state) return res.status(400).send("state mismatch"); const tokens = await fetch(`${LOGI}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "authorization_code", code: req.query.code, redirect_uri: REDIRECT, code_verifier: req.cookies.logi_pkce, client_id: CLIENT_ID, client_secret: CLIENT_SECRET, }), }).then(r => r.json()); res.cookie("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" }); res.clearCookie("logi_pkce"); res.clearCookie("logi_state"); res.redirect("/"); }); ``` --- # Flutter *Source: `integrations/flutter.md`* # Flutter `flutter_secure_storage`로 양 플랫폼 토큰 저장, `local_auth`로 biometric 게이팅, `flutter_web_auth_2`로 ASWebAuthenticationSession / Custom Tabs 통합 OAuth + PKCE. ## 의존성 ```yaml # pubspec.yaml dependencies: flutter_secure_storage: ^10.0.0 local_auth: ^2.3.0 flutter_web_auth_2: ^4.0.0 crypto: ^3.0.5 ``` ::: tip 최소 버전 요구 - Flutter 3.16+ (flutter_secure_storage 10.x 요구사항) - iOS 13+, Android API 23+ (biometric 사용 시) ::: ## OAuth + PKCE 플로우 ```dart import 'dart:convert'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:http/http.dart' as http; class LogiOAuth { static const _base = 'https://logi.example.com'; static const _clientId = 'logi_...'; static const _redirectScheme = 'com.example.myapp'; static const _redirectUri = '$_redirectScheme://callback'; String _b64url(List bytes) => base64UrlEncode(bytes).replaceAll('=', ''); Future<({String accessToken, String refreshToken})> signIn() async { // 1. PKCE final rng = Random.secure(); final verifier = _b64url(List.generate(32, (_) => rng.nextInt(256))); final challenge = _b64url(sha256.convert(utf8.encode(verifier)).bytes); final state = _b64url(List.generate(16, (_) => rng.nextInt(256))); // 2. authorize URL final authUrl = Uri.parse('$_base/oauth/authorize').replace(queryParameters: { 'client_id': _clientId, 'redirect_uri': _redirectUri, 'response_type': 'code', 'scope': 'profile email', 'state': state, 'code_challenge': challenge, 'code_challenge_method': 'S256', }); // 3. ASWebAuthSession (iOS) / Custom Tabs (Android) final result = await FlutterWebAuth2.authenticate( url: authUrl.toString(), callbackUrlScheme: _redirectScheme, ); final params = Uri.parse(result).queryParameters; if (params['state'] != state) { throw Exception('CSRF: state mismatch'); } // 4. token exchange final resp = await http.post( Uri.parse('$_base/oauth/token'), body: { 'grant_type': 'authorization_code', 'code': params['code']!, 'redirect_uri': _redirectUri, 'code_verifier': verifier, 'client_id': _clientId, }, ); final json = jsonDecode(resp.body); return ( accessToken: json['access_token'] as String, refreshToken: json['refresh_token'] as String, ); } } ``` ## Refresh Token 저장 (`flutter_secure_storage`) 플랫폼 위임이 자동이지만 **반드시 옵션을 명시**하세요. 기본값은 안전하지 않습니다. ```dart import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class LogiTokenStorage { static const _refreshKey = 'logi.refresh_token'; static const _deviceSecretKey = 'logi.device_secret'; // ⚠️ 옵션 명시 — 기본값으로는 iOS에서 iCloud sync됨 final _storage = const FlutterSecureStorage( iOptions: IOSOptions( accessibility: KeychainAccessibility.first_unlock_this_device, // ↑ first_unlock_this_device: // - 첫 잠금해제 후 background에서 접근 가능 (refresh에 적합) // - ThisDeviceOnly → iCloud sync 차단 ), aOptions: AndroidOptions( encryptedSharedPreferences: true, // EncryptedSharedPreferences 백엔드 강제 // 라이브러리 내부적으로 Tink 사용. EncryptedSharedPreferences는 Jetpack의 // 그것이 아니라 flutter_secure_storage 자체 구현이라 deprecation 영향 없음. ), ); Future saveRefreshToken(String token) => _storage.write(key: _refreshKey, value: token); Future loadRefreshToken() => _storage.read(key: _refreshKey); Future saveDeviceSecret(String secret) => _storage.write(key: _deviceSecretKey, value: secret); Future clearAll() => _storage.deleteAll(); } ``` ::: warning ⚠️ Android backup 제외 설정 (필수) flutter_secure_storage는 backup 제외를 자동 적용하지 않습니다. 직접 manifest와 backup rules를 설정해야 복원 시 토큰 무효화 사고를 막을 수 있습니다: ```xml ``` ```xml ``` ::: ## 민감 작업에 biometric 추가 (`local_auth`) flutter_secure_storage 자체는 biometric 게이팅을 일관되게 제공하지 않습니다. high-assurance 액션 직전에 `local_auth`로 인증한 뒤 토큰을 사용하세요: ```dart import 'package:local_auth/local_auth.dart'; final _auth = LocalAuthentication(); Future authenticateForSensitiveAction() async { final canCheck = await _auth.canCheckBiometrics; final isSupported = await _auth.isDeviceSupported(); if (!canCheck || !isSupported) return false; return await _auth.authenticate( localizedReason: '계정 삭제를 진행하려면 인증해주세요', options: const AuthenticationOptions( biometricOnly: true, stickyAuth: true, ), ); } // 사용 if (await authenticateForSensitiveAction()) { final token = await tokenStorage.loadRefreshToken(); // ... high-assurance API 호출 } ``` ::: tip 두 패키지 역할 분리 - `flutter_secure_storage` → 토큰 자체의 **보관** - `local_auth` → 사용자 **재인증** 게이트 biometric을 토큰 키 자체에 거는 패턴은 Flutter에서 양 플랫폼 일관성이 떨어지므로, "인증 → 토큰 사용" 2단계로 분리하는 것이 현실적입니다. ::: ## Device Bootstrap (`device_secret`) logi의 device bootstrap은 dual mode입니다: 1. **첫 호출 (bootstrap)**: `POST /api/v1/devices` with `{device_uuid, platform}` → 응답에 `device_secret` 1회 노출 + PAK 발급 2. **이후 호출 (refresh)**: 같은 endpoint에 `{device_uuid, platform, device_secret}` → 서버가 digest 검증 후 새 PAK 발급. **누락/불일치 시 401**. `device_secret`을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다. ```dart // 첫 호출 응답 처리 final resp = await http.post( Uri.parse('$base/api/v1/devices'), body: {'device_uuid': uuid, 'platform': 'ios'}, // 또는 'android' ); final json = jsonDecode(resp.body); if (json['device_secret'] != null) { await tokenStorage.saveDeviceSecret(json['device_secret']); } // 이후 PAK 갱신 시 final secret = await tokenStorage.loadDeviceSecret(); // null이면 OAuth 재로그인 await http.post( Uri.parse('$base/api/v1/devices'), body: {'device_uuid': uuid, 'platform': 'ios', 'device_secret': secret}, ); ``` `flutter_secure_storage`로 동일하게 저장하되 별도 key 사용: ```dart await tokenStorage.saveDeviceSecret(deviceSecret); ``` ❌ `SharedPreferences`(`shared_preferences` 패키지)에 절대 저장하지 마세요. ## 점검 체크리스트 - [ ] `IOSOptions`에 `KeychainAccessibility.first_unlock_this_device` 명시 - [ ] `backup_rules.xml`에 `FlutterSecureStorage.xml` 제외 명시 - [ ] AndroidManifest에 `android:fullBackupContent` 적용 - [ ] PKCE S256 + state 검증 - [ ] `flutter_web_auth_2`의 callback scheme이 `redirect_uri`와 일치 - [ ] biometric은 `local_auth`로 별도 게이트 (토큰 키에 거는 대신) - [ ] `device_secret`과 refresh token 분리 저장 --- # Next.js (App Router) *Source: `integrations/nextjs.md`* # Next.js (App Router) 로그인 버튼 → Authorization Code + PKCE 풀 플로우. ## 1. 환경변수 ```bash # .env.local LOGI_API_URL=https://logi.example.com LOGI_CLIENT_ID=logi_... LOGI_CLIENT_SECRET=logi_secret_... LOGI_REDIRECT_URI=http://localhost:3000/api/auth/callback ``` ## 2. 로그인 시작 라우트 ```ts // app/api/auth/login/route.ts import { cookies } from "next/headers"; import { NextResponse } from "next/server"; function base64url(buf: ArrayBuffer) { return Buffer.from(buf).toString("base64url"); } export async function GET() { const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const challenge = base64url( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) ); const state = base64url(crypto.getRandomValues(new Uint8Array(16))); const jar = await cookies(); jar.set("logi_pkce", verifier, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 }); jar.set("logi_state", state, { httpOnly: true, secure: true, sameSite: "lax", maxAge: 600 }); const url = new URL(`${process.env.LOGI_API_URL}/oauth/authorize`); url.searchParams.set("client_id", process.env.LOGI_CLIENT_ID!); url.searchParams.set("redirect_uri", process.env.LOGI_REDIRECT_URI!); url.searchParams.set("response_type", "code"); url.searchParams.set("scope", "profile email"); url.searchParams.set("state", state); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); return NextResponse.redirect(url); } ``` ## 3. 콜백 라우트 ```ts // app/api/auth/callback/route.ts import { cookies } from "next/headers"; import { NextResponse } from "next/server"; export async function GET(req: Request) { const u = new URL(req.url); const code = u.searchParams.get("code"); const state = u.searchParams.get("state"); const jar = await cookies(); if (!code || !state || state !== jar.get("logi_state")?.value) { return NextResponse.json({ error: "state mismatch" }, { status: 400 }); } const verifier = jar.get("logi_pkce")?.value!; const body = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: process.env.LOGI_REDIRECT_URI!, code_verifier: verifier, client_id: process.env.LOGI_CLIENT_ID!, client_secret: process.env.LOGI_CLIENT_SECRET!, }); const res = await fetch(`${process.env.LOGI_API_URL}/oauth/token`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body, }); const tokens = await res.json(); // tokens.refresh_token → httpOnly cookie (rotation 자동 커버) jar.set("logi_rt", tokens.refresh_token, { httpOnly: true, secure: true, sameSite: "strict" }); jar.delete("logi_pkce"); jar.delete("logi_state"); return NextResponse.redirect(new URL("/", req.url)); } ``` ## 4. 보호된 API에서 userinfo 조회 ```ts const me = await fetch(`${process.env.LOGI_API_URL}/oauth/userinfo`, { headers: { Authorization: `Bearer ${access_token}` }, }).then(r => r.json()); ``` JWT 검증(stateless)을 선호하면 [`jose`로 JWKS 검증](/oauth/jwks#node-js-jose). --- # Rails 8 *Source: `integrations/rails.md`* # Rails 8 Rails 8 + `omniauth` 스타일을 쓰지 않고 직접 OAuth 클라이언트를 구현합니다 (의존성 최소화). ::: tip 시작 전 체크 프로덕션 배포 시 `LOGI_API_URL` / `LOGI_CLIENT_ID` / `LOGI_CLIENT_SECRET` 3개를 호스팅 플랫폼 env에 모두 주입해야 합니다. 누락되면 코드는 `localhost:3000`으로 fallback해서 prod에서 연결 실패합니다 — 가장 흔한 실패 케이스입니다. 상세: [Troubleshooting](/guide/troubleshooting) ::: ```ruby # app/controllers/sessions_controller.rb class LogiSessionsController < ApplicationController def start verifier = SecureRandom.urlsafe_base64(32).delete("=") challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) state = SecureRandom.hex(16) session[:logi_pkce] = verifier session[:logi_state] = state redirect_to "#{ENV['LOGI_API_URL']}/oauth/authorize?" + { client_id: ENV["LOGI_CLIENT_ID"], redirect_uri: logi_callback_url, response_type: "code", scope: "profile email", state: state, code_challenge: challenge, code_challenge_method: "S256", }.to_query, allow_other_host: true end def callback return render_error("state mismatch") if params[:state] != session[:logi_state] res = Net::HTTP.post_form( URI("#{ENV['LOGI_API_URL']}/oauth/token"), grant_type: "authorization_code", code: params[:code], redirect_uri: logi_callback_url, code_verifier: session[:logi_pkce], client_id: ENV["LOGI_CLIENT_ID"], client_secret: ENV["LOGI_CLIENT_SECRET"] ) tokens = JSON.parse(res.body) session.delete(:logi_pkce); session.delete(:logi_state) cookies.signed.permanent[:logi_rt] = { value: tokens["refresh_token"], httponly: true, secure: Rails.env.production?, same_site: :strict } redirect_to root_path end end ``` JWT 검증: ```ruby gem "jwt" jwks = JSON.parse(Net::HTTP.get(URI("#{ENV['LOGI_API_URL']}/.well-known/jwks.json"))) payload, = JWT.decode( access_token, nil, true, algorithms: ["RS256"], jwks: JWT::JWK::Set.new(jwks), iss: "logi", verify_iss: true, aud: ENV["LOGI_CLIENT_ID"], verify_aud: true ) ``` ## Scope 환경변수화 (권장) 위 예시는 `scope: "profile email"` 가 하드코딩돼 있습니다. 실제 운영에서는 RP 별로 요구 scope 가 다르므로 env 로 분리하세요: ```ruby scope: ENV.fetch("LOGI_SCOPES", "openid profile email") ``` 표준 조합: - 일반 SSO: `openid profile email` - 식별번호 게이팅 포함: `openid profile:basic email` - ID Token 만 사용 (서버에서 토큰 검증 안 함): `openid` Scope 는 logi 앱 등록 시 `allowed_scopes` 와 일치해야 합니다 — 등록되지 않은 scope 를 요청하면 `invalid_scope`. 자세한 매트릭스: [Scope 레퍼런스](/oauth/scopes). ## Post-deploy 검증 (가장 자주 빠지는 단계) 배포 직후 브라우저로 로그인 흘러 들어가기 전에, **CLI 한 줄로 env 누락을 잡아냅니다**: ```bash curl -sIL "https://yourapp.com/auth/logi/start" | grep -iE "^(HTTP|location)" ``` 기대 출력: ``` HTTP/2 302 location: https://api.1pass.dev/oauth/authorize?client_id=logi_xxx&...&code_challenge=...&state=... ``` 체크 포인트: 1. **status `302`** — initiate 라우트 자체가 살아있는지 2. **location host = `api.1pass.dev`** — `LOGI_API_URL` 누락 시 `localhost:3000` 이 박혀서 옴 (가장 흔한 사고) 3. **`client_id` 쿼리** — `LOGI_CLIENT_ID` 누락 시 빈 값 또는 누락 4. **`scope` 쿼리** — `LOGI_SCOPES` 누락 시 코드 default 사용 자동화 (CI/배포 후 smoke test): ```bash curl -sI "https://yourapp.com/auth/logi/start" | grep -i "^location:" | grep -q "api.1pass.dev" \ && echo "✓ logi env OK" || { echo "✗ LOGI_API_URL 누락 — env 점검"; exit 1; } ``` ::: warning 302 만 봐선 안 됩니다 status `302` 만 체크하면 `localhost:3000` 으로 떨어지는 케이스를 못 잡습니다. **반드시 location 의 host 까지 확인** 하세요. 실제 운영 사고 다수가 이 단계에서 발생. ::: 기존 앱에 RP 를 추가하는 절차 + 트러블슈팅: [Troubleshooting](/guide/troubleshooting). --- # React Native *Source: `integrations/react-native.md`* # React Native `react-native-keychain`으로 양 플랫폼 토큰 저장, `react-native-app-auth`로 ASWebAuthenticationSession / Custom Tabs 통합 OAuth + PKCE. ## 의존성 ```json { "dependencies": { "react-native-keychain": "^10.0.0", "react-native-app-auth": "^8.0.3" } } ``` ::: tip 최소 버전 요구 - React Native 0.73+ - iOS 13+, Android API 23+ - iOS는 `Info.plist`에 `NSFaceIDUsageDescription` 추가 필수 (biometric 사용 시) ::: ::: warning ⚠️ SoLoader / Hermes 버전 충돌 주의 `react-native-keychain` 10.x는 SoLoader 버전에 민감합니다. 앱 시작 시 크래시가 발생하면 `android/app/build.gradle`에 SoLoader 버전을 명시적으로 강제하세요. ::: ## OAuth + PKCE 플로우 `react-native-app-auth`는 PKCE를 자동 처리합니다: ```typescript import { authorize, refresh, AuthConfiguration } from 'react-native-app-auth'; const config: AuthConfiguration = { issuer: 'https://logi.example.com', clientId: 'logi_...', redirectUrl: 'com.example.myapp://callback', scopes: ['profile', 'email'], // PKCE는 기본 활성화. usePKCE: true (default) serviceConfiguration: { authorizationEndpoint: 'https://logi.example.com/oauth/authorize', tokenEndpoint: 'https://logi.example.com/oauth/token', revocationEndpoint: 'https://logi.example.com/oauth/revoke', }, }; export async function signIn() { const result = await authorize(config); // result.accessToken, result.refreshToken, result.accessTokenExpirationDate await TokenStorage.save(result.accessToken, result.refreshToken); return result; } ``` ## Refresh Token 저장 (`react-native-keychain`) 옵션을 명시하지 않으면 iOS는 iCloud sync, Android는 KeyStore가 아닌 일반 저장소를 사용할 수 있습니다. 항상 **모든 옵션을 명시**하세요. ```typescript import * as Keychain from 'react-native-keychain'; const TOKEN_SERVICE = 'logi.tokens'; const DEVICE_SECRET_SERVICE = 'logi.device_secret'; export const TokenStorage = { async save(accessToken: string, refreshToken: string) { // 사이즈 검증: react-native-keychain은 65KB 초과 시 데이터 손상 가능 const payload = JSON.stringify({ accessToken, refreshToken }); if (payload.length > 60_000) { throw new Error('Token payload exceeds safe limit (60KB)'); } await Keychain.setGenericPassword('logi', payload, { service: TOKEN_SERVICE, // ⚠️ iCloud sync 차단 accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, // Android: AES + Keystore 백킹 storage: Keychain.STORAGE_TYPE.AES_GCM, // hardware-backed when available }); }, async load(): Promise<{ accessToken: string; refreshToken: string } | null> { const creds = await Keychain.getGenericPassword({ service: TOKEN_SERVICE }); if (!creds) return null; return JSON.parse(creds.password); }, async clear() { await Keychain.resetGenericPassword({ service: TOKEN_SERVICE }); }, }; ``` ### Accessibility 옵션 정리 | 옵션 | 잠금 상태 접근 | iCloud sync | 시나리오 | |------|---------------|-------------|----------| | `AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY` | ✅ | ❌ | **권장 기본값** (background refresh OK) | | `WHEN_UNLOCKED_THIS_DEVICE_ONLY` | ❌ | ❌ | 민감도 최상 | | `AFTER_FIRST_UNLOCK` | ✅ | ⚠️ on | iCloud sync 의도된 경우 | | `WHEN_UNLOCKED` | ❌ | ⚠️ on | ❌ 비권장 | ::: warning Android backup 제외 React Native Android 프로젝트도 backup rules가 필요합니다: ```xml ``` ```xml ``` ::: ## 민감 작업에 biometric 추가 `accessControl: BIOMETRY_CURRENT_SET`을 옵션에 추가하면 토큰 자체가 biometric으로 보호됩니다. 단 background refresh와 양립 불가하므로, **별도 service에 저장하는** high-assurance 토큰에만 적용하세요: ```typescript const HIGH_ASSURANCE_SERVICE = 'logi.high_assurance'; await Keychain.setGenericPassword('logi', sensitiveToken, { service: HIGH_ASSURANCE_SERVICE, accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, accessControl: Keychain.ACCESS_CONTROL.BIOMETRY_CURRENT_SET, // BIOMETRY_CURRENT_SET: 등록된 biometric 변경 시 키 무효화 authenticationPrompt: { title: '민감 작업 인증', subtitle: 'Face ID / 지문 인증이 필요합니다', cancel: '취소', }, }); // 조회 시 자동으로 biometric prompt 표시 const creds = await Keychain.getGenericPassword({ service: HIGH_ASSURANCE_SERVICE }); ``` ::: warning ⚠️ Background refresh와 biometric은 양립 불가 일반 refresh token에는 `accessControl`을 적용하지 마세요. silent refresh가 막힙니다. ::: ## Device Bootstrap (`device_secret`) logi의 device bootstrap은 dual mode입니다: 1. **첫 호출 (bootstrap)**: `POST /api/v1/devices` with `{device_uuid, platform}` → 응답에 `device_secret` 1회 노출 + PAK 발급 2. **이후 호출 (refresh)**: 같은 endpoint에 `{device_uuid, platform, device_secret}` → 서버가 digest 검증 후 새 PAK 발급. **누락/불일치 시 401**. `device_secret`을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다. ```typescript // 첫 호출 응답 처리 const resp = await fetch(`${BASE}/api/v1/devices`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_uuid: uuid, platform: 'ios' }), }); const data = await resp.json(); if (data.device_secret) { await saveDeviceSecret(data.device_secret); } // 이후 PAK 갱신 시 const secret = await loadDeviceSecret(); // null이면 OAuth 재로그인 유도 await fetch(`${BASE}/api/v1/devices`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_uuid: uuid, platform: 'ios', device_secret: secret }), }); ``` 별도 service로 분리: ```typescript export async function saveDeviceSecret(secret: string) { await Keychain.setGenericPassword('logi', secret, { service: DEVICE_SECRET_SERVICE, accessible: Keychain.ACCESSIBLE.AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY, storage: Keychain.STORAGE_TYPE.AES_GCM, }); } export async function loadDeviceSecret(): Promise { const creds = await Keychain.getGenericPassword({ service: DEVICE_SECRET_SERVICE }); return creds ? creds.password : null; } ``` ❌ `AsyncStorage`에 절대 저장 금지 — 평문 sqlite/file 저장입니다. ## 점검 체크리스트 - [ ] `accessible` 옵션을 `..._THIS_DEVICE_ONLY` 변형으로 명시 - [ ] `storage: AES_GCM`으로 Android Keystore 백킹 - [ ] payload 사이즈 60KB 미만 검증 - [ ] `backup_rules.xml`에 `RN_KEYCHAIN.xml` 제외 - [ ] iOS `Info.plist`에 `NSFaceIDUsageDescription` 추가 (biometric 사용 시) - [ ] SoLoader / Hermes 버전 정합성 - [ ] `device_secret`, refresh token, high-assurance token 별도 service - [ ] biometric은 high-assurance service에만 (background refresh 양립 불가) --- # iOS (Swift) *Source: `integrations/swift.md`* # iOS (Swift) `ASWebAuthenticationSession` + `CryptoKit` 으로 네이티브 OAuth + PKCE. ```swift import AuthenticationServices import CryptoKit final class LogiOAuth: NSObject, ASWebAuthenticationPresentationContextProviding { static let shared = LogiOAuth() let base = "https://logi.example.com" let clientId = "logi_..." let redirectScheme = "com.example.myapp" func signIn() async throws -> (accessToken: String, refreshToken: String) { let verifier = Data((0..<32).map { _ in UInt8.random(in: 0...255) }).base64URL let challenge = Data(SHA256.hash(data: Data(verifier.utf8))).base64URL let state = UUID().uuidString var comps = URLComponents(string: "\(base)/oauth/authorize")! comps.queryItems = [ .init(name: "client_id", value: clientId), .init(name: "redirect_uri", value: "\(redirectScheme)://callback"), .init(name: "response_type", value: "code"), .init(name: "scope", value: "profile email"), .init(name: "state", value: state), .init(name: "code_challenge", value: challenge), .init(name: "code_challenge_method", value: "S256"), ] let callback = try await withCheckedThrowingContinuation { (cont: CheckedContinuation) in let session = ASWebAuthenticationSession(url: comps.url!, callbackURLScheme: redirectScheme) { url, err in if let url { cont.resume(returning: url) } else { cont.resume(throwing: err!) } } session.presentationContextProvider = self session.prefersEphemeralWebBrowserSession = true session.start() } let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems ?? [] guard items.first(where: { $0.name == "state" })?.value == state, let code = items.first(where: { $0.name == "code" })?.value else { throw URLError(.badServerResponse) } var tokenReq = URLRequest(url: URL(string: "\(base)/oauth/token")!) tokenReq.httpMethod = "POST" tokenReq.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") tokenReq.httpBody = [ "grant_type=authorization_code", "code=\(code)", "redirect_uri=\(redirectScheme)://callback", "code_verifier=\(verifier)", "client_id=\(clientId)", // Public client: client_secret 없음. 백엔드 경유 권장. ].joined(separator: "&").data(using: .utf8) let (data, _) = try await URLSession.shared.data(for: tokenReq) struct Resp: Decodable { let access_token: String; let refresh_token: String } let r = try JSONDecoder().decode(Resp.self, from: data) return (r.access_token, r.refresh_token) } func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.windows.first }.first ?? ASPresentationAnchor() } } extension Data { var base64URL: String { base64EncodedString().replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } } ``` ## Refresh Token 저장 (Keychain) 토큰은 **반드시** Keychain에 저장하고, iCloud sync를 차단해야 합니다. iOS Keychain은 기본값으로 iCloud Keychain에 sync되므로 accessibility를 명시하지 않으면 토큰이 사용자의 다른 기기에도 복제됩니다. ### 최소 구현 (device-bound) ```swift import Foundation import Security enum LogiKeychain { private static let defaultService = "logi.refresh_token" static func save(_ token: String, service: String = defaultService) throws { let data = Data(token.utf8) // Delete existing item first (Keychain doesn't have native upsert) SecItemDelete([ kSecClass: kSecClassGenericPassword, kSecAttrService: service ] as CFDictionary) let status = SecItemAdd([ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecValueData: data, // ⚠️ 핵심: ThisDeviceOnly로 iCloud sync 차단 // afterFirstUnlock = 재부팅 후 첫 잠금해제 후부터 background에서도 접근 가능 kSecAttrAccessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly ] as CFDictionary, nil) guard status == errSecSuccess else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) } } static func load(service: String = defaultService) -> String? { var item: CFTypeRef? let status = SecItemCopyMatching([ kSecClass: kSecClassGenericPassword, kSecAttrService: service, kSecReturnData: true, kSecMatchLimit: kSecMatchLimitOne ] as CFDictionary, &item) guard status == errSecSuccess, let data = item as? Data else { return nil } return String(data: data, encoding: .utf8) } static func delete(service: String = defaultService) { SecItemDelete([ kSecClass: kSecClassGenericPassword, kSecAttrService: service ] as CFDictionary) } } ``` ### Accessibility 옵션 선택 | 옵션 | 잠금 상태 접근 | iCloud sync | 권장 시나리오 | |------|---------------|-------------|---------------| | `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | ✅ 첫 잠금해제 후 | ❌ | **권장 기본값** — background refresh 가능 | | `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` | ❌ | ❌ | 민감도 최상, background refresh 포기 | | `kSecAttrAccessibleAfterFirstUnlock` | ✅ | ⚠️ on | iCloud sync 의도된 경우 (드묾) | | `kSecAttrAccessibleWhenUnlocked` | ❌ | ⚠️ on | ❌ 토큰에는 비권장 | **suffix `ThisDeviceOnly`가 없으면 iCloud Keychain에 sync됩니다.** 토큰은 디바이스 바인딩이 보안 모델의 일부이므로 항상 `ThisDeviceOnly` 변형을 사용하세요. ### 민감 작업에 biometric 추가 송금·계정 삭제 같은 민감 작업에는 Face ID / Touch ID를 추가로 요구할 수 있습니다. `SecAccessControlCreateWithFlags`로 access control object를 만들어 `kSecAttrAccessControl` 키에 넘기면 됩니다: ```swift import LocalAuthentication let accessControl = SecAccessControlCreateWithFlags( nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, // biometric은 잠금해제 상태에서만 .biometryCurrentSet, // 등록된 biometric이 바뀌면 키 무효화 nil )! SecItemAdd([ kSecClass: kSecClassGenericPassword, kSecAttrService: "logi.high_assurance_token", kSecValueData: data, kSecAttrAccessControl: accessControl ] as CFDictionary, nil) ``` `.biometryAny`는 어떤 biometric이든 허용, `.biometryCurrentSet`은 현재 등록된 biometric이 바뀌면 키 자체를 무효화합니다. logi에서는 high-assurance용 토큰은 **`.biometryCurrentSet`을 권장**합니다 — 탈취된 후 공격자가 자기 지문을 추가해도 키 사용 불가. ::: warning ⚠️ Background refresh와 biometric은 양립 불가 biometric이 걸린 키는 사용자 인증 없이 접근할 수 없으므로, background에서 silent token refresh가 필요한 일반 토큰에는 적용하지 마세요. high-assurance 액션 직전에만 사용하세요. ::: ### Device Bootstrap (`device_secret`) logi의 device bootstrap 흐름은 dual mode입니다: 1. **첫 호출 (bootstrap)**: `POST /api/v1/devices` with `{device_uuid, platform}` → 응답에 `device_secret` 1회 노출 + PAK 발급 2. **이후 호출 (refresh)**: 같은 endpoint에 `{device_uuid, platform, device_secret}` → 서버가 secret digest 검증 후 새 PAK 발급. **secret 누락/불일치 시 401**. `device_secret`을 잃어버리면 anonymous 사용자는 OAuth 로그인으로 재인증해야 합니다 — 즉 secret은 anonymous 계정의 유일한 device-bound 자격증명이므로 **반드시 안전 저장**. ```swift // 첫 호출 응답 처리 struct BootstrapResponse: Decodable { let access_token: String let device_secret: String? // bootstrap/legacy grace에서만 존재 } if let secret = response.device_secret { try LogiKeychain.save(secret, service: "logi.device_secret") } // 이후 PAK 갱신 시 let secret = LogiKeychain.load(service: "logi.device_secret")! let body = ["device_uuid": uuid, "platform": "ios", "device_secret": secret] ``` 이 값은 위 Keychain 저장 패턴 그대로 보관하되 **별도 service 식별자**를 사용해 refresh token과 분리하세요: ```swift // device_secret은 별도 service로 분리 let deviceStore = LogiKeychain.self // 권장: 두 service 식별자를 분리 // "logi.device_secret" → device bootstrap용 // "logi.refresh_token" → OAuth refresh token용 ``` `device_secret`은 절대 `UserDefaults`에 저장하지 마세요 — 평문 plist로 백업·디바이스 간 공유될 수 있습니다. ::: tip 📚 더 자세한 비교 4개 플랫폼(iOS/Android/Flutter/RN) 통합 비교는 [보안 Best Practices](/guide/security)와 각 플랫폼 가이드를 참고하세요. ::: --- # OAuth 오류 코드 *Source: `oauth/errors.md`* # OAuth 오류 코드 모든 오류 응답은 **RFC 6749 §5.2 포맷**을 따릅니다. ```json { "error": "<기계 판독 가능 코드>", "error_description": "<사람이 읽기 위한 설명>" } ``` ## `/oauth/authorize` 에서 | error | 의미 | 어디로 | 대응 | |-------|-----|-------|-----| | `invalid_client` | client_id 미존재 | HTML/JSON 400 | client_id 오타/복사 실수 확인 | | `unauthorized_client` | 앱이 `pending`/`suspended` 상태 | HTML/JSON 400 | 관리자 승인 대기 | | `invalid_request` (redirect) | redirect_uri 화이트리스트 불일치 | HTML/JSON 400 | 앱 등록 정보의 redirect_uris와 **정확히** 일치 필요 (scheme/host/path/query) | | `invalid_request` (protocol) | PKCE 누락, code_challenge_method ≠ S256 | 302 redirect_uri?error=... | S256 + challenge 포함 | | `unsupported_response_type` | response_type ≠ code | 302 | `response_type=code` 고정 | | `invalid_scope` | `allowed_scopes` 초과 또는 비어있음 | 302 | 앱 등록 시 allowed_scopes 확인 | | `access_denied` | 사용자가 "거부" 선택 | 302 | UX 설계에 따라 재시도 유도 | ## `/oauth/token` 에서 | error | status | 의미 | 대응 | |-------|-------|-----|-----| | `invalid_client` | 401 | client_secret 불일치 또는 누락 | Basic auth 헤더 / body 파라미터 재확인 | | `unauthorized_client` | 400 | 앱 승인 전 | 관리자 승인 대기 | | `invalid_grant` | 400 | code not found | code가 이미 교환됐거나 존재 없음 | | `invalid_grant` | 400 | code already used | 플로우당 1회 — 새로 authorize부터 | | `invalid_grant` | 400 | code expired | 10분 초과, 새로 authorize | | `invalid_grant` | 400 | redirect_uri mismatch | authorize 때와 **완전히 동일**한 URI | | `invalid_grant` | 400 | PKCE verifier mismatch | verifier 저장소 확인 (sessionStorage 휘발 주의) | | `invalid_grant` | 400 | refresh token not found | RT가 DB에 없음 | | `invalid_grant` | 400 | refresh token reuse detected; chain revoked | **재사용 공격** 탐지됨. 사용자 재로그인 필요 | | `invalid_grant` | 400 | refresh token expired | 30일 경과 | | `unsupported_grant_type` | 400 | password/implicit/device 등 | authorization_code 또는 refresh_token만 | ## `/oauth/userinfo` 에서 | error | status | 헤더 | 의미 | |-------|-------|-----|-----| | `invalid_token` | 401 | `WWW-Authenticate: Bearer error="invalid_token"` | JWT 서명/만료/파싱 실패 | | `invalid_token` | 401 | — | 토큰 revoke됨 | | `invalid_token` | 401 | — | 사용자 soft-delete 상태 | ## `/oauth/revoke`, `/oauth/introspect` 에서 | error | status | 의미 | |-------|-------|-----| | `invalid_client` | 401 | client_secret 불일치 또는 누락 | `/oauth/introspect` 는 토큰이 유효하지 않아도 에러 대신 `{ "active": false }` 를 반환합니다. ## Rate Limit | 엔드포인트 | 한도 | 키 | 초과 시 | |----------|-----|---|-------| | `/session` | 5/min (Cloudflare) · 10/3min (Rails) | IP | `429 rate_limited` | | `/oauth/token` | 20/min | `client_id` | `429 rate_limited` | | `/api/v1/me/otp/*` | 10/min | `user_id` | `429 rate_limited` | 초과 시: ```json { "error": "rate_limited" } ``` ## 디버깅 팁 1. **`invalid_grant`가 계속 난다면?** — 대부분 PKCE verifier 소실. `sessionStorage`/`localStorage` 탭 전환·새로고침 동작 확인. 2. **`invalid_request` 콜백으로 돌아온다면?** — URL의 `error_description` 쿼리 파라미터가 정확한 원인 포함. 3. **JWKS 404?** — path 정확히 `/.well-known/jwks.json` (점·대시 주의). 4. **`redirect_uri mismatch` 이상한데?** — 등록된 URI는 `https://app.example.com/cb`인데 authorize/token에 `https://app.example.com/cb/` (trailing slash)라도 **거부**됩니다. ## 로깅 주의사항 절대 로그에 남기지 말 것: - `password`, `client_secret`, `code_verifier`, `refresh_token`, `access_token` (JWT 포함), `logi_pak_*` - logi 자체는 `password_digest`, `otp_secret_encrypted`, JWT, PAK plaintext를 한 번도 로그에 남기지 않습니다. --- # OAuth 2.0 Authorization Code + PKCE *Source: `oauth/flow.md`* # OAuth 2.0 Authorization Code + PKCE logi는 OAuth 2.0 Authorization Code Grant에 PKCE(RFC 7636) **S256**을 강제합니다. Implicit Flow, Password Grant, Device Code Flow는 지원하지 않습니다. ## 시퀀스 다이어그램 ```mermaid sequenceDiagram autonumber participant C as 제휴사 앱 participant L as logi participant U as 사용자 (브라우저/앱) C->>C: verifier 생성 (랜덤 32B) · challenge = SHA256(verifier) b64url C->>L: GET /oauth/authorize?client_id&redirect_uri&state&code_challenge&scope L->>L: redirect_uri 화이트리스트 검증 L-->>U: 로그인 요구 / Consent 화면 U->>L: 자격증명 + 필요 시 OTP/Passkey U->>L: "허용" 클릭 L-->>C: 302 redirect_uri?code=&state= C->>C: state 일치 검증 C->>L: POST /oauth/token (client_id+secret · code · code_verifier · redirect_uri) L->>L: client_secret bcrypt · code_challenge vs SHA256(verifier) · code 1회 소진 L-->>C: { access_token(JWT), refresh_token, expires_in, scope, id_token? } C->>L: GET /oauth/userinfo (Authorization: Bearer JWT) L-->>C: { sub, email?, identity_verified_level, ... } ``` ## 1. Authorization 요청 (브라우저) ``` GET /oauth/authorize ?client_id=logi_a1b2... &redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback &response_type=code &scope=profile+email &state=<32바이트_랜덤> &code_challenge= &code_challenge_method=S256 &nonce=<선택, OIDC> ``` **필수 파라미터**: - `client_id`, `redirect_uri`, `response_type=code`, `scope`, `state`, `code_challenge`, `code_challenge_method=S256` **선택**: `nonce` (openid scope 사용 시 id_token에 echo) ### 에러 처리 방침 | 오류 위치 | 응답 | |---------|-----| | `client_id` 미존재 / `redirect_uri` 불일치 | **HTML/JSON 400** (콜백 없이 — open redirect 방지) | | PKCE 누락, scope 무효, response_type 틀림 | `302 redirect_uri?error=invalid_request&state=...` | | 사용자 "거부" | `302 redirect_uri?error=access_denied&state=...` | ## 2. Token 교환 (백엔드 → 백엔드) ```http POST /oauth/token Content-Type: application/x-www-form-urlencoded Authorization: Basic base64(client_id:client_secret) # 또는 body에 동봉 grant_type=authorization_code &code=<받은 code> &redirect_uri=<1단계와 동일> &code_verifier=<생성한 verifier> ``` **검증 순서** (실패 시 즉시 `400 invalid_grant`): 1. client_secret bcrypt 일치 2. code 존재 + `used_at` null 3. `expires_at > now` (10분) 4. `redirect_uri` snapshot 일치 5. `BASE64URL(SHA256(verifier)) == code_challenge` **성공 응답**: ```json { "access_token": "", "token_type": "Bearer", "expires_in": 900, "refresh_token": "", "scope": "profile email", "id_token": "" } ``` ## 3. Refresh Token Rotation {#refresh} ```http POST /oauth/token grant_type=refresh_token &refresh_token=<이전 응답의 refresh_token> &client_id=...&client_secret=... ``` ### 동작 1. 제시된 RT 해시로 DB 조회 2. `revoked_at` 존재 → **체인 전체 revoke** + `400 invalid_grant` (재사용 공격 탐지) 3. `refresh_expires_at` 지남 → 해당 레코드만 revoke + 400 4. 정상 → 기존 레코드 revoke + 새 레코드 issue (`refreshed_from_id` 체인) **응답 구조는 authorization_code와 동일** — 클라이언트는 동일한 파싱 경로 사용 가능. ## 4. UserInfo ```http GET /oauth/userinfo Authorization: Bearer ``` 응답은 scope 기반: | scope | 포함 필드 | |-------|---------| | `profile`, `email` | `sub`, `email`, `email_verified`, `identity_verified_level` | | `openid` (id_token에) | `sub` (필수), `nonce` (요청 시 echo) | ## Revocation ```http POST /oauth/revoke token= &token_type_hint=access_token|refresh_token &client_id=...&client_secret=... ``` - 같은 OAuth client가 자기 토큰만 revoke 가능 - access token, refresh token 모두 허용 - 이미 revoke되었거나 존재하지 않는 토큰도 **200 OK** 반환 (RFC 7009) - refresh token revoke 시 해당 체인 레코드도 함께 revoke ## Introspection ```http POST /oauth/introspect token= &token_type_hint=access_token|refresh_token &client_id=...&client_secret=... ``` 응답 예시: ```json { "active": true, "scope": "profile email", "client_id": "logi_...", "token_type": "access_token", "exp": 1760000000, "iat": 1760000000, "sub": "123", "aud": "logi_...", "iss": "logi", "jti": "..." } ``` - 다른 client의 토큰이거나 만료/revoke된 토큰이면 `{ "active": false }` - access token은 JWT 서명/만료 검증 후 조회 - refresh token은 digest 기반으로 조회 ## QR 로그인 옵션 logi `/oauth/authorize` 페이지는 사용자에게 "📱 QR 로 로그인" 버튼을 노출합니다. 사용자가 QR 옵션을 선택하면 logi 모바일 앱으로 승인 후 브라우저가 자동으로 `redirect_uri?code=&state=` 로 이동합니다 — **표준 Authorization Code 흐름과 100% 동일한 응답**입니다. **RP 가 추가로 할 일은 없습니다.** 자세한 보안 모델, 시퀀스 다이어그램, 에러 처리는 [QR 로그인 가이드](./qr-login)를 참고하세요. ## 레퍼런스 - RFC 6749 §4.1 — Authorization Code Grant - RFC 7636 — PKCE (S256 필수) - RFC 9068 — Access Token JWT - OpenID Connect Core 1.0 §3.1 --- # JWKS & JWT 검증 *Source: `oauth/jwks.md`* # JWKS & JWT 검증 logi는 Access Token으로 **RS256 JWT**를 발급합니다. 제휴사는 **stateless**로 서명을 검증하거나, revocation을 확인하려면 `/oauth/userinfo` 를 호출합니다. ## JWKS 엔드포인트 ``` GET /.well-known/jwks.json ``` 응답 예시: ```json { "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "ffaa476406e8abec", "n": "xqJbzP...", "e": "AQAB" } ] } ``` `Cache-Control: public, max-age=3600` 헤더가 붙어있어 1시간 캐시 가능합니다. ## 키 Rotation - 분기 1회 새 키를 발급 + `kid` 변경 - 구 키는 JWKS에 **90일 grace period** 동안 유지 (기존 발급 토큰 검증용) - 신규 발급은 활성 `kid`로만 서명 제휴사 구현 시 JWKS 캐시를 **1시간 ~ 1일** 기준으로 refresh. 검증 실패 시 한 번 강제 refresh 후 재시도. ## Payload 스키마 ```json { "iss": "logi", "sub": "42", "aud": "logi_a1b2c3d4...", "exp": 1734567890, "iat": 1734566990, "jti": "9d6f...-...-...", "scope": "profile email" } ``` ## 언어별 검증 예시 ::: code-group ```ts [Node.js / jose] import { jwtVerify, createRemoteJWKSet } from "jose"; const jwks = createRemoteJWKSet(new URL("https://logi.example.com/.well-known/jwks.json")); const { payload } = await jwtVerify(accessToken, jwks, { issuer: "logi", audience: "logi_a1b2c3d4...", // 내 앱 client_id }); console.log(payload.sub); ``` ```ruby [Ruby / JWT gem] require "jwt" require "open-uri" jwks_raw = URI.open("https://logi.example.com/.well-known/jwks.json").read jwks = JWT::JWK::Set.new(JSON.parse(jwks_raw)) payload, _header = JWT.decode( access_token, nil, true, algorithms: ["RS256"], iss: "logi", verify_iss: true, aud: ENV["LOGI_CLIENT_ID"], verify_aud: true, jwks: jwks ) ``` ```python [Python / PyJWT] import jwt import requests jwks = jwt.PyJWKClient("https://logi.example.com/.well-known/jwks.json") signing_key = jwks.get_signing_key_from_jwt(access_token) payload = jwt.decode( access_token, signing_key.key, algorithms=["RS256"], issuer="logi", audience="logi_a1b2c3d4...", ) ``` ```go [Go / github.com/lestrrat-go/jwx] import "github.com/lestrrat-go/jwx/v2/jwk" jwks, _ := jwk.Fetch(ctx, "https://logi.example.com/.well-known/jwks.json") tok, err := jwt.Parse([]byte(accessToken), jwt.WithKeySet(jwks), jwt.WithIssuer("logi"), jwt.WithAudience("logi_a1b2c3d4..."), ) ``` ::: ## 만료 이후 - `exp` 지나면 `expired_token` 에러 - Refresh Token으로 [/oauth/token](/oauth/flow#refresh) 재호출하여 새 AT 발급 ## Revoke 즉시 확인이 필요하면 JWT는 stateless라 `jti`가 revoke되었는지는 자체 검증만으로는 알 수 없습니다. 아래 중 하나: 1. **옵트인** — 민감 엔드포인트만 `/oauth/userinfo` 호출해 logi에서 재검증 (15분 만료 기준으로 대부분 충분) 2. **Webhook** — `token.revoked` 이벤트 구독 (logi → 제휴사). `jwt_jti` 수신 시 로컬 블랙리스트에 추가 3. **Introspection** — `/oauth/introspect` 호출로 `active` 상태를 직접 확인 ## id_token (OIDC) `openid` scope 요청 시 `/oauth/token` 응답에 `id_token`이 포함됩니다. 포함 claim: - `iss`, `sub`, `aud`, `exp`, `iat`, `nonce` (요청 시) - `at_hash` — left 128bits of SHA256(access_token), base64url (RFC 7519 §3.1.3.6) 검증 방법은 AT와 동일하되 `nonce`와 `at_hash`를 비교하세요 (replay + token 바인딩 방어). --- # PKCE (RFC 7636) 상세 *Source: `oauth/pkce.md`* # PKCE (RFC 7636) 상세 logi는 **S256만** 수락합니다. `plain`은 거부됩니다 (boundary-safe 아님). ## 왜 PKCE가 필수인가 OAuth 2.0 Authorization Code는 네트워크/브라우저를 거쳐 제휴사 앱으로 돌아옵니다. 중간에서 code가 탈취되면: - **PKCE 없이**: 공격자가 훔친 code + 훔친 client_secret으로 토큰 교환 가능 - **PKCE 있음**: 탈취해도 `code_verifier`(원본 난수, 서버에 전달된 적 없음)가 없어 `invalid_grant` 모바일 앱/SPA는 client_secret을 안전하게 보관할 수 없으므로 PKCE가 더더욱 필수입니다. ## 생성 공식 ``` verifier = 43 ~ 128자 URL-safe 랜덤 (unreserved 문자만) challenge = BASE64URL-no-pad(SHA256(verifier)) ``` ## RFC 7636 Appendix B 검증 벡터 ``` verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" ``` 이 벡터는 logi RSpec에서도 사용합니다 (`spec/lib/oauth/rfc7636_pkce_vectors_spec.rb`). ## 언어별 구현 ::: code-group ```ts [TypeScript / Web] async function generatePKCE() { const verifier = base64url(crypto.getRandomValues(new Uint8Array(32))); const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)); const challenge = base64url(new Uint8Array(hash)); return { verifier, challenge }; } function base64url(bytes: Uint8Array): string { return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } ``` ```swift [Swift / iOS] import CryptoKit struct PKCE { let verifier: String let challenge: String static func generate() -> PKCE { let random = (0..<32).map { _ in UInt8.random(in: 0...255) } let verifier = Data(random).base64URL let hash = SHA256.hash(data: Data(verifier.utf8)) let challenge = Data(hash).base64URL return PKCE(verifier: verifier, challenge: challenge) } } extension Data { var base64URL: String { base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } } ``` ```ruby [Ruby] require "securerandom" require "digest" require "base64" verifier = SecureRandom.urlsafe_base64(32).delete("=") challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false) ``` ```python [Python] import secrets, hashlib, base64 verifier = secrets.token_urlsafe(32).rstrip("=") challenge = base64.urlsafe_b64encode( hashlib.sha256(verifier.encode()).digest() ).rstrip(b"=").decode() ``` ```kotlin [Kotlin / Android] import java.security.MessageDigest import android.util.Base64 val verifier = (1..32).map { ('A'..'Z') + ('a'..'z') + ('0'..'9') + '-' + '_' } .flatten().shuffled().take(43).joinToString("") val sha = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()) val challenge = Base64.encodeToString(sha, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP) ``` ::: ## 저장 위치 - **Web**: `sessionStorage` (tab 단위) — 탭 닫으면 자동 소멸 - **iOS**: 메모리만 (플로우가 한 액터/화면 내에서 끝남) — Keychain 저장 불필요 - **서버 사이드 render SSR**: 서명된 쿠키 또는 세션 store ::: danger verifier를 로그에 남기지 마세요 code 교환 직전까지만 필요한 값입니다. API 클라이언트 로거/에러 리포터에서 masking 확인하세요. ::: ## 검증 실패 예시 ```bash # 의도적으로 잘못된 verifier curl -X POST $LOGI/oauth/token \ -d grant_type=authorization_code \ -d code=$CODE \ -d redirect_uri=$REDIRECT \ -d code_verifier="wrong" \ -d client_id=$ID -d client_secret=$SECRET # 응답: 400 # {"error":"invalid_grant","error_description":"PKCE verifier mismatch"} ``` --- # QR 로그인 (앱 푸시 승인) *Source: `oauth/qr-login.md`* # QR 로그인 (앱 푸시 승인) WhatsApp Web · Slack · Discord 와 동일한 패턴. 사용자가 웹사이트에서 QR 코드를 보고, 모바일 logi 앱으로 스캔해 본인 확인 후 승인하면 브라우저가 자동으로 로그인됩니다. ::: tip RP(제휴사)에게 미치는 영향 **없습니다.** QR 로그인은 logi 의 `/oauth/authorize` 페이지가 사용자에게 제공하는 로그인 옵션 중 하나입니다. RP 가 받는 응답은 표준 [Authorization Code Flow](./flow) 와 동일한 `redirect_uri?code=&state=` 입니다. 별도의 SDK 변경, 클라이언트 설정, 또는 redirect_uri 추가가 필요하지 않습니다. ::: ## 언제 활성화되나 logi 의 로그인 페이지(`/session/new`)와 OAuth Consent 화면(`/oauth/authorize`)에 "📱 QR 로 로그인" 버튼이 노출됩니다. 사용자가 직접 트리거할 때만 QR 흐름이 시작됩니다 (자동 redirect 안 함). iOS Universal Link 가 server-side redirect chain 에서 트리거되지 않는 보안 정책 때문에, 표준 web→app 자동 점프는 안전하지 않거나 작동하지 않습니다. QR 은 **사용자가 명시적으로 시작**하는 out-of-band 채널을 제공해 이 한계를 우회합니다. ## 시퀀스 다이어그램 ```mermaid sequenceDiagram autonumber participant C as 제휴사 앱 participant B as 브라우저 participant L as logi 서버 participant A as logi 모바일 앱 C->>B: GET /oauth/authorize?client_id&redirect_uri&state&code_challenge&scope B->>L: 같은 요청 도착 L-->>B: Consent 페이지 (또는 /session/new) B->>L: "QR 로 로그인" 클릭 → POST /oauth/qr/start (OAuth params) L->>L: QrLoginSession 생성 + signed cookie (uuid + browser_nonce) L-->>B: { session_id, qr_payload, cable_path } B->>B: QR 캔버스 렌더 + ActionCable 구독 (/cable, params: { session_id }) A->>A: 카메라로 QR 스캔 → { session: uuid, domain: "api.1pass.dev" } A->>L: GET /api/v1/oauth/qr/:id (Bearer PAK) L-->>A: { application, scopes, browser: { label, ip } } A->>L: POST /api/v1/oauth/qr/:id/scan L-->>B: Cable push { status: "scanned" } A-->>A: OAuthConsentView 표시 (RP 이름·scope·브라우저) A->>L: POST /api/v1/oauth/qr/:id/approve (Bearer PAK) L->>L: session.transition!(scanned → approved, approved_user) L-->>B: Cable push { status: "approved" } B->>L: GET /oauth/qr/:id/complete (signed cookie 검증) L->>L: transaction { transition!(approved → completed); OauthAccessGrant.create!; Consent.grant!; AuditLogger; LoginLog } L-->>B: 302 redirect_uri?code=&state= B->>C: redirect 도착 — 표준 OAuth 흐름 재진입 C->>L: POST /oauth/token (code_verifier) L-->>C: access_token + refresh_token + id_token? ``` ## 보안 모델 | 위협 | 방어 | |---|---| | QR 도용 / 캡쳐 | session_uuid 단일 사용 (`completed` 후 재사용 불가) + TTL 10분 + sweeper job 으로 만료 처리 | | 브라우저 탈취 (다른 기기에서 `/complete` 호출) | signed cookie 두 개 검증 — `qr_session_uuid` + `qr_browser_nonce` (32B 랜덤). UA/IP 가 아니라 secret nonce 로 binding | | 앱 사칭 (PAK 없이 승인) | `POST /approve` 는 Bearer PAK 필수, 익명 user 거절 | | 상태 race (동시 `/approve` × 2) | `WHERE status = 'scanned' UPDATE status = 'approved'` 원자적 전이. affected_rows=0 → 409 | | 앱이 RP 사칭 | QR payload 의 `domain` 을 앱이 hard-code 검증 (`api.1pass.dev` 외 거절) | | /complete 재호출로 grant 중복 | transaction 내부 `transition!(approved → completed)` 가 한 번만 성공 | | CSRF (POST /oauth/qr/start) | Rails CSRF 토큰 (`X-CSRF-Token` 헤더) | | Brute force / DoS | Rack Attack — start 10/min/IP, status 60/min/IP | ::: warning v0.x 에서 미적용 (v2.x roadmap) - mTLS / DPoP — 앱-서버 mutual cert binding - 서명된 QR payload (앱이 logi 의 ECDSA 서명을 검증) - Suspicious-login detection (geolocation 변화, impossible travel) ::: ## 만료 / 거절 / 에러 | 상황 | RP 가 받는 응답 | |---|---| | 사용자가 앱에서 "거절" | `302 redirect_uri?error=access_denied&state=...` (표준) | | QR 만료 (10분) | `302 redirect_uri?error=access_denied&state=...` (표준) | | browser_nonce cookie 변조 | `400 Bad Request` (RP redirect 안 됨, 사용자가 다시 로그인) | | 같은 session 재사용 시도 | `302 redirect_uri?error=access_denied&state=...` | `access_denied` 는 [RFC 6749 §4.1.2.1](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1) 표준 코드입니다. RP 의 OAuth 라이브러리가 처리하는 그대로입니다. ## RP 가 추가로 할 일 — 없음 위 시퀀스 다이어그램의 4–13번 단계는 모두 logi 의 책임입니다. RP 입장에서는 [표준 Authorization Code + PKCE](./flow) 와 1바이트도 다르지 않습니다. ```javascript // 기존 OAuth 코드 그대로 window.location.href = `https://api.1pass.dev/oauth/authorize?${qs}`; // ... const { code, state } = await receiveRedirect(); const { access_token } = await fetch("/oauth/token", { /* ... */ }); ``` 사용자가 QR 로 로그인했는지 패스워드로 로그인했는지 RP 는 알 필요가 없고, JWT `access_token` 의 페이로드도 동일합니다. ## 모바일 앱 SDK API (logi 1pass 앱 전용) QR 측의 4개 endpoint 는 logi iOS/Android 앱이 사용합니다. 외부 RP 에는 노출되지 않습니다 (PAK Bearer 인증 + 앱 등록 필요). 자세한 스펙은 [API 레퍼런스 — Internal QR endpoints](../reference/api) 의 `/api/v1/oauth/qr/*` 섹션을 참고하세요. ## 레퍼런스 - [RFC 6749 §4.1 — Authorization Code Grant](https://www.rfc-editor.org/rfc/rfc6749#section-4.1) - [RFC 6749 §4.1.2.1 — Error Response](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2.1) - [RFC 7636 — PKCE (S256 필수)](https://www.rfc-editor.org/rfc/rfc7636) - [Action Cable Overview (Rails 8)](https://guides.rubyonrails.org/action_cable_overview.html) --- # Scope 레퍼런스 *Source: `oauth/scopes.md`* # Scope 레퍼런스 Scope는 **공백 구분** 문자열입니다 (`profile email phone`, 콤마 ❌). ## 표준 Scope | scope | userinfo 반환 필드 | 비고 | |-------|----------------|-----| | `profile` | `sub`, `email`, `email_verified`, `identity_verified_level` | 기본 신원 | | `email` | `sub`, `email`, `email_verified` | profile의 부분집합 | | `phone` | `sub`, `phone_number`, `phone_number_verified` | Phase 2에서 실제 필드 추가 예정 | | `openid` | id_token 발급 + `sub` | OpenID Connect 1.0 활성화 | ::: info identity_verified_level logi는 실명/주민번호를 **절대 보유하지 않습니다**. 대신 정수 플래그만 제공: - `0` unverified (기본) - `1` email_verified (logi 자체, magic link — Phase 2) - `2` phone_verified (logi 자체 — Phase 2) - `3` sp_verified (SP가 NICE/KCB 등으로 실명인증 후 보고 — Phase 2) 제휴사는 이 정수로 게이팅만 수행하고, 실제 실명 데이터는 자체 인증 서비스에서 관리합니다. ::: ## 커스텀 Scope (Phase 2) 제휴사는 자신의 도메인에 맞는 커스텀 scope를 등록할 수 있습니다. 네임스페이스 필수: ``` krx_listing:reviewer_role enterprise_x:tier blog:post.write ``` 형식: `:` — 콜론 1개 기준 prefix로 앱과 매칭. 서버 측에서는 `User#custom_claims` 가 jsonb로 `{namespace: {key: value}}` 구조로 저장되며, 해당 scope 요청 시 id_token/userinfo에 병합됩니다 (β1-4). ## 제휴사 등록 시 `allowed_scopes` 앱 등록 시 허용할 scope를 지정합니다: ```json { "oauth_application": { "name": "My App", "redirect_uris": ["https://app.example.com/cb"], "allowed_scopes": ["profile", "email"] } } ``` 사용자가 이보다 넓은 scope를 요청하면 — **Phase 1 (2026-04-29) 정책 변경**: 더 이상 hard reject 하지 않습니다. [Scope drift 정책](#scope-drift-policy-2026-04-29) 참고. ## Scope drift policy (2026-04-29~) {#scope-drift-policy-2026-04-29} > 배경: 운영 중인 RP 가 코드에서 신규 scope (예: `phone`) 를 요청했는데 logi 등록 시 `allowed_scopes` 업데이트를 깜빡한 케이스. 이전엔 `error=invalid_scope` 로 hard reject 해서 **사용자 로그인 자체가 막혔음**. 95%+ 케이스가 악의가 아니라 단순 등록 누락이라, 사용자 flow 를 끊는 건 과잉. ### 동작 logi 가 받은 scope 요청을 다음과 같이 처리합니다: | 케이스 | 동작 | |---|---| | 모든 요청 scope 가 등록됨 | ✅ 그대로 진행 | | 일부 요청 scope 가 미등록 (`allowed_scopes` 에 없음) | ⚠️ **미등록 scope 는 silent drop** + 서버 로그 + 등록된 subset 으로 consent 진행 | | 모든 요청 scope 가 미등록 | ❌ `invalid_scope` hard reject (consent 화면이 빈 상태가 됨) | | `required: true` scope 가 effective scope 에서 누락 | ❌ `invalid_scope` hard reject (RP 가 의도한 데이터 못 받음) | ### 보안 보장 미등록 scope 가 silent drop 되므로 **사용자는 미등록 scope 에 대한 consent 화면조차 보지 않습니다**. RP 는 등록된 scope 의 토큰만 받습니다 — 등록 외 데이터로 권한 escalation 불가능. ### RP 개발자 측 감지 방법 (Phase 1) 미등록 scope 요청은 logi 서버 로그에 기록됩니다: ``` [oauth] scope_drift app_id=4 client_id=logi_xxx dropped=phone,address kept=profile,email ``` 본인 RP 의 client_id 로 grep 해서 drift 발생 여부를 정기 체크하세요. 발견 시: 1. CLI 로 `allowed_scopes` 업데이트: ```bash logi apps edit --add-scope phone ``` 2. 또는 SSH/rails console 에서: ```ruby app = OauthApplication.kept.find_by(name: "your_app") app.set_scopes!(["openid", "profile", "email", "phone"]) ``` ### Phase 2 (2026-04-29~) ✅ **Webhook 이벤트 `scope.drift_detected` 발사** — 어떤 RP 의 어떤 scope 가 처음 drift 됐는지 즉시 알림. (Phase 3 grace period / dashboard / `X-Logi-Scope-Drift` 헤더는 별도 PR 예정.) #### Webhook payload 예시 ```json { "event_type": "scope.drift_detected", "application_id": 4, "payload": { "scope_name": "phone", "client_id": "logi_30ca68e5464b5456269b0502395285d9", "first_seen_at": "2026-04-29T01:30:00Z", "allowed_scopes": ["openid", "profile:basic", "email"], "message": "Your app requested an unregistered scope. logi dropped it and proceeded with the registered subset. Update allowed_scopes to include this scope, or stop requesting it." } } ``` 서명 검증은 다른 webhook 과 동일한 HMAC-SHA256 패턴: [HMAC 서명 검증](/guide/webhook-verification). #### 발사 규칙 (스팸 방지) - **(app_id, scope_name) 페어 당 1회** — 같은 RP 가 같은 미등록 scope 를 1000번 요청해도 webhook 은 첫 1회만 - 등록된 webhook URL 이 없으면 발사 안 함 (drift 자체는 DB 에 기록) - 한 요청에 미등록 scope 가 여러 개면 (`phone address`) → scope 별로 각각 1개씩 발사 #### 운영자가 직접 보기 ```ruby # Rails console ScopeDriftRecord.includes(:oauth_application) .order(last_seen_at: :desc).limit(20).each do |r| puts "#{r.oauth_application.name.ljust(20)} #{r.scope_name.ljust(15)} #{r.occurrence_count}회 #{r.last_seen_at}" end ``` ### Phase 3 (2026-04-29~) ✅ **Token 응답 헤더, 대시보드 배지, 7일 후 escalation webhook** 모두 라이브. > 설계 변경 노트 (Phase 1 약속 유지): Phase 3 의 원래 계획은 "7일 후 hard reject 자동 복귀"였으나, 이는 Phase 1 의 핵심 원칙 (사용자 flow 안 끊기) 와 정면 충돌. **사용자 flow 는 끝까지 보호**하고 RP 측 압박만 단계적으로 강화하는 escalation 모델로 변경. #### 1) `/oauth/token` 응답 헤더 `X-Logi-Scope-Drift` 토큰 발급 시 최근 7일 내 drift 가 있으면 헤더로 echo. RP 서버 코드가 webhook 도착을 기다리지 않고 매 요청에서 감지 가능. ```http HTTP/1.1 200 OK Content-Type: application/json X-Logi-Scope-Drift: address,phone { "access_token": "...", "token_type": "Bearer", ... } ``` RP 측 처리 예시: ```ruby res = Net::HTTP.post_form(URI("#{ENV['LOGI_API_URL']}/oauth/token"), token_params) if drift = res["X-Logi-Scope-Drift"] Rails.logger.warn("[logi] scope drift detected: #{drift}") # → 슬랙 알림, Sentry 이벤트, etc. end ``` #### 2) 개발자 대시보드 배지 - **Apps 목록**: drift 발생 RP 카드에 "Scope drift" 핀 + amber dot - **Apps 상세**: 별도 카드에 drift 테이블 (scope / 처음 발견 / 최근 / 횟수 / 상태) - 상태 컬럼: - `기록만`: webhook URL 미설정 또는 발사 직전 - `webhook 전송`: scope.drift_detected 발사됨 - `escalated`: 7일 이상 미해결, scope.drift_unresolved 발사됨 #### 3) Escalation webhook (`scope.drift_unresolved`) 매일 09:00 UTC `ScopeDriftEscalationJob` 실행. 다음 모두 충족 시 발사: 1. `webhook_fired_at <= 7.days.ago` (초기 알림 발사 ≥ 7일 경과) 2. `escalated_at IS NULL` (아직 escalation 안 됨) 3. `last_seen_at > webhook_fired_at` (drift 가 진행 중 — RP 가 안 고쳤음) 발사 후 `escalated_at` 세팅 → 영원히 1회만 발사. ```json { "event_type": "scope.drift_unresolved", "payload": { "scope_name": "phone", "client_id": "logi_xxx", "first_seen_at": "2026-04-22T01:30:00Z", "last_seen_at": "2026-04-29T08:45:00Z", "occurrence_count": 1247, "allowed_scopes": ["openid", "profile:basic", "email"], "message": "This scope has been drifting for over 7 days and is still being requested. logi continues to drop it from consent (user flow is preserved). Please update allowed_scopes or remove the scope from your authorize requests." } } ``` #### 사용자 flow 가 절대 깨지지 않는 이유 | 단계 | 사용자 영향 | |---|---| | Phase 1 (silent drop) | 0 — 등록된 scope 만 consent 화면에 표시 | | Phase 2 (drift webhook) | 0 — 서버 로그 + RP webhook | | Phase 3 (token header) | 0 — 응답 헤더, 클라이언트 코드는 무시 가능 | | Phase 3 (escalation webhook) | 0 — RP 운영자 알림 | 악의적 RP 가 미등록 scope 로 권한 escalation 시도해도, **사용자는 미등록 scope 의 consent 화면조차 보지 않으므로** 데이터 leak 불가. 정직한 RP 가 등록 갱신 깜빡한 경우엔 사용자 로그인은 정상 진행 + RP 개발자에게 webhook + dashboard 배지 + token header 3중 신호. 이슈/요청: [GitHub](https://github.com/seunghan91/logi/issues) ## `required` 마킹 (β1-6) 제휴사는 특정 scope를 **필수**로 표시할 수 있습니다. Consent 화면에 "필수" 배지가 붙고, 거부 시 "이 정보 없이는 서비스 이용 불가" 메시지가 표시됩니다. ```ruby # Rails console 예시 app.oauth_application_scopes.create!( oauth_scope: profile_scope, required: false ) app.oauth_application_scopes.create!( oauth_scope: email_scope, required: true # 필수 ) ``` 사용자가 필수 scope를 거부하면 `access_denied` + 정책 안내 페이지가 표시됩니다. ## 재인가 UX 사용자가 제휴사 A에 `profile email` 동의한 이후: | 요청 scope | 동작 | |-----------|-----| | `profile email` (동일) | **UI 스킵** · 즉시 code 발급 | | `profile` (축소) | UI 스킵 · 즉시 code 발급 | | `profile email phone` (확장) | "NEW" 배지 + 추가 동의 요구 | | Consent revoke 후 | Consent 화면 다시 노출 | 이는 Google 로그인과 같은 UX입니다. ## 요청 방법 ``` GET /oauth/authorize?...&scope=profile+email+openid&... ^^^^^^^ ^^^^^ ^^^^^^ 공백(+)으로 구분, URL encoding 시 %20 또는 + ``` 응답 `scope` 필드는 **실제 부여된** scope를 echo (요청 ≠ 응답 가능 — 사용자가 일부만 동의한 경우). --- # 개인정보처리방침 *Source: `privacy.md`* # 개인정보처리방침 logi 의 공식 개인정보처리방침은 다음 페이지에서 관리됩니다: → **** 이 페이지는 Google Play Console / Apple App Store / 외부 RP 가 인용하는 표준 URL 입니다. 두 위치 (dcode-labs.com 과 docs.1pass.dev) 어디서든 동일한 내용을 확인할 수 있습니다. --- # API 레퍼런스 *Source: `reference/api.md`* # API 레퍼런스 모든 엔드포인트는 아래 Scalar 뷰어에서 실시간 시도 가능합니다. OpenAPI 3.1 스펙은 [/openapi.yaml](/openapi.yaml)로 직접 다운로드할 수 있습니다.
::: info 대안: Redoc / Swagger UI OpenAPI yaml을 위 경로에서 받아 자체 툴에서 렌더링할 수도 있습니다. ::: --- # 변경 로그 *Source: `reference/changelog.md`* # 변경 로그 ## v0.1-alpha (2026-04-22 sync) **완료된 마일스톤** (GitHub tags 참조): - `m0-bootstrap` — Rails 8 scaffold, Cloudflare/Render 체크리스트 - `m1-auth` — 기본 Auth (role + device_uuid + lockout) - `m2-oauth-core` — OAuth 2.0 + PKCE S256 + JWKS + Refresh rotation - `m3-developer-portal` — Developer Portal + Admin (iOS 26 Liquid Glass) - `m4-consent` — Consent 화면 + Consent 레코드 - `m5-cli` — Personal API Keys + `logi` CLI (Ruby/Thor) - `m7-otp` — TOTP 2FA + 백업 코드 + 민감작업 게이트 - `m8-m10-security-observability` — Passkey + Login Logs + Webhooks - `m12-m13` — Suspicious Detection + Admin Audit - `m6-ios-scaffold` / `m6-m11-ios-mcp` — iOS 앱 + MCP 서버 - `m14-docs` — VitePress + Scalar 문서 사이트 + OpenAPI 3.1 - `m15-android-scaffold` / `m15-complete` — Android 앱 + Play Integrity round-trip + Sentry - `2026-04-22` — `/oauth/revoke` (RFC 7009), `/oauth/introspect` (RFC 7662), 로그인 이력 90일 purge recurring job ## 알려진 제약 - 실제 Render / Cloudflare / GitHub Pages 프로덕션 배포 전 - 푸시 알림 (APNs/FCM) 미구현 - Play Integrity production decode 및 `ANDROID_APP_CERT_SHA256` 주입 미완료 - iOS associated domain은 `api.1pass.dev` 로 마이그레이션 완료 (v0.4, 2026-04-22) ## 로드맵 (β) - β1: 동적 scope + required 마킹 (진행중) - β2: 커스텀 claim (`User#custom_claims` 구현 완료) - β3: 로그인 이력 알림 + APNs/FCM - β5: 모바일 프로덕션 하드닝 (Play Integrity decode, cert fingerprint, associated domain 정리) --- # logi CLI — 한 페이지 요약 *Source: `reference/cli.md`* # `logi` CLI — 한 페이지 요약 > 자세한 가이드는 [/cli/](/cli/) 섹션을 참고하세요. 이 페이지는 빠른 참고용 cheatsheet입니다. ## 설치 ```bash brew install dcode-labs/tap/logi # 출시 예정 # 또는: cd cli && bundle install && bin/logi version ``` → 자세히: [설치](/cli/install) ## 인증 ```bash logi login # 브라우저 OAuth (gh / vercel 패턴) logi login --no-browser # device flow (서버·SSH·도커) logi whoami # 현재 계정 + 조직 logi logout ``` → 자세히: [로그인](/cli/login) ## 앱 관리 ```bash logi apps create --name "App" --redirect-uri https://example.com/cb logi apps list logi apps show logi apps edit --add-redirect-uri https://staging.example.com/cb logi apps rotate-secret logi apps delete ``` → 자세히: [앱 관리](/cli/apps) ## 팀 관리 ```bash logi team members logi team invite alice@example.com --role admin logi team set-role alice@example.com developer logi team remove bob@example.com ``` → 자세히: [팀 관리](/cli/team) ## 토큰 디버그 ```bash logi token inspect # 헤더/페이로드 + JWKS 서명 검증 logi token introspect # 서버에 활성 여부 조회 ``` ## 환경변수 | 변수 | 설명 | |---|---| | `LOGI_TOKEN` | PAK. CI/CD에서 사용 | | `LOGI_API_URL` | 자체 호스팅 시 변경 (기본 `https://api.1pass.dev`) | | `LOGI_OUTPUT` | `json` / `human` | → 자세히: [CI/CD 사용](/cli/usage) --- # @logi/mcp — Claude/Cursor에서 logi 조작 *Source: `reference/mcp.md`* # `@logi/mcp` — Claude/Cursor에서 logi 조작 Claude Code, Claude Desktop, Cursor 등 MCP를 지원하는 AI 도구에서 자연어로 logi를 관리합니다. > 예: "내 logi 앱 중 production tier가 아닌 것 모두 보여줘", "alice@example.com 한테 admin 권한으로 초대 보내줘" ## 두 가지 사용 모드 | 모드 | 누구 | 무엇을 | |---|---|---| | **End-User 모드** | 일반 사용자 | 본인 로그인 이력, Passkey, 연결된 앱 관리 | | **Developer 모드** | OAuth 앱 개발자 | OAuth 앱 CRUD, secret 회전, 팀 관리 | 발급받는 PAK(Personal API Key)의 scope에 따라 노출되는 도구 셋이 달라집니다. ## 설치 ::: warning 출시 전 정식 npm publish 이전입니다. 현재는 로컬 빌드 후 직접 연결. ::: ```bash git clone https://github.com/seunghan91/logi.git cd logi/mcp && npm install && npm run build ``` `~/.claude.json` (Claude Code): ```json { "mcpServers": { "logi": { "command": "node", "args": ["/Users/you/toy/logi/mcp/dist/index.js"], "env": { "LOGI_API_URL": "https://api.1pass.dev", "LOGI_TOKEN": "lpa_pat_xxxxxxxxxxxxx" } } } } ``` publish 이후: ```json { "command": "npx", "args": ["-y", "@logi-auth/mcp"] } ``` PAK 발급은 [start.1pass.dev → API Keys](https://start.1pass.dev) 또는 `/api/v1/me/api_keys`에서. End-User 모드면 `login_history:read` 등을, Developer 모드면 `apps:manage`와 `apps:read`를 체크. ## End-User 모드 도구 (8종) | Tool | 설명 | 필요 scope | |---|---|---| | `logi_whoami` | 연결 상태 + 계정 확인 | — | | `logi_list_login_history` | 최근 로그인 이력 | `login_history:read` | | `logi_delete_login_log` | 특정 로그 소프트 삭제 | `login_history:write` | | `logi_list_trashed_logs` | 휴지통 조회 | `login_history:write` | | `logi_restore_login_log` | 복구 | `login_history:write` | | `logi_list_passkeys` | Passkey 목록 | `passkeys:read` | | `logi_delete_passkey` | Passkey 삭제 | `passkeys:manage` | | `logi_list_connected_apps` | 연결된 앱 + 권한 보기 | `apps:read` | ### 사용 예시 ``` > 내 logi 로그인 이력 최근 10건 보여줘 > 어제 의심스러운 로그인 있었어? 한국 외 지역만 필터. > 더 이상 안 쓰는 Passkey 정리해줘 > Notion 연결 해제하고 싶어 ``` ## Developer 모드 도구 (출시 예정) OAuth 앱 개발자가 Claude/Cursor에서 자연어로 앱을 관리: | Tool | 설명 | 필요 scope | |---|---|---| | `logi_apps_list` | 내 조직의 앱 목록 | `apps:read` | | `logi_apps_create` | 새 앱 등록 | `apps:manage` | | `logi_apps_show` | 앱 상세 | `apps:read` | | `logi_apps_edit` | 메타·redirect URI 수정 | `apps:manage` | | `logi_apps_rotate_secret` | client_secret 회전 | `apps:manage` | | `logi_apps_delete` | 앱 삭제 | `apps:manage` | | `logi_team_invite` | 멤버 초대 | `org:manage` | | `logi_audit_logs` | 감사 로그 조회 | `org:read` | ### 개발자 사용 예시 ``` > "Demo Test App"의 client_secret 회전하고 새 값을 .env에 적어줘 > 우리 조직에 alice@example.com을 admin으로 초대해줘 > 지난주 redirect URI 변경한 사람 누구야? > staging 환경 앱 새로 하나 만들고 redirect_uri는 https://staging.acme.com/cb ``` ::: tip 흐름 Claude가 도구 호출 → 결과 반환 → 다음 작업 제안. 예: secret 회전 후 자동으로 `.env` 파일 업데이트 제안. ::: ## 보안 - 모든 호출은 **PAK 인증** (env로만 주입, 메모리·로그 노출 X) - 민감 작업(삭제, secret 회전)은 향후 **iOS 앱 step-up 푸시 승인** 추가 예정 - MCP 서버는 stdout/stderr에 PAK prefix 8자만 출력, 본문 마스킹 ## 다음 - [PAK 발급 + scope 모델](/oauth/scopes) - [감사 로그 — 누가 무엇을 했는지](/guide/security#audit) - [CLI도 지원](/cli/) ---