# logi (1pass.dev) — 전체 개발자 문서 > 이 파일은 docs.1pass.dev 의 모든 가이드를 한 파일로 합친 LLM 최적화 패키지입니다. > 생성 시각: 2026-04-27T11:47:38.341Z > 출처: 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) 권장. ## 삭제 ```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 seunghan91/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 seunghan91/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/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 벡터로 검증 --- # 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분) 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 구독? --- # 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`* ## 왜 logi인가 - **5분이면 붙입니다** — Quickstart 따라가면 첫 로그인 성공까지 5분. - **사용자 정보 최소 보관** — 실명·주민번호 같은 민감 정보는 저장하지 않습니다. - **사용자가 직접 통제** — 접속 기록·연결 앱·기기 모두 사용자가 직접 관리. - **AI 친화** — 문서를 통째로 LLM에 던질 수 있는 [llms.txt](/llms.txt) 제공. ## 무엇부터 읽으면 되나 - [**5분 Quickstart**](/guide/quickstart) — `curl` 한 번으로 전체 플로우 확인 - [**핵심 개념**](/guide/concepts) — App, Scope, Token이 뭔지 30초 정리 - [**보안 가이드**](/guide/security) — 실수하기 쉬운 부분 모음 - [**API 레퍼런스**](/reference/api) — 인터랙티브 OpenAPI ## 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에 붙여넣고 "logi로 로그인 붙이는 법 알려줘" 한마디면 끝. ---

logi by 1Pass

--- # 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 처리 --- # 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 클라이언트를 구현합니다 (의존성 최소화). ```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 ) ``` --- # 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 기반으로 조회 ## 레퍼런스 - 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"} ``` --- # 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를 요청하면 `302 redirect_uri?error=invalid_scope`. ## `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 (요청 ≠ 응답 가능 — 사용자가 일부만 동의한 경우). --- # 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 seunghan91/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/) ---