openapi: 3.1.0
info:
  title: logi Identity Provider API
  version: "0.1.0-alpha"
  description: |
    **logi**는 앱 기반 Identity Provider입니다. OAuth 2.0 / OpenID Connect 표준을 따르며
    PKCE, Passkey (WebAuthn), TOTP 2FA, App Attest, Webhook 이벤트를 지원합니다.

    모든 엔드포인트는 HTTPS를 통해 제공됩니다. PAK(Personal API Key) 인증은 `Authorization: Bearer logi_pak_...`
    헤더를, 세션 쿠키 인증은 `session_id` 쿠키를, OAuth Access Token은 JWT(RS256)을 사용합니다.

    ## 빠른 시작
    1. 개발자 계정 가입 → `/developer/signup`
    2. OAuth 앱 등록 → client_id + client_secret 발급 (1회 노출)
    3. Authorization Code + PKCE 플로우 구현
    4. `/oauth/userinfo` 로 스코프 기반 사용자 정보 조회

  contact:
    name: logi Developer Support
    url: https://github.com/seunghan91/logi
  license:
    name: MIT
    identifier: MIT

servers:
  - url: https://logi.example.com
    description: 프로덕션 (예시 — 실제 도메인은 TBD)
  - url: http://localhost:3000
    description: 로컬 개발

tags:
  - name: OAuth 2.0
    description: Authorization Code + PKCE + Refresh Rotation
  - name: OIDC
    description: OpenID Connect 1.0 확장 (id_token, userinfo, jwks)
  - name: Passkey
    description: WebAuthn / FIDO2 passwordless 인증
  - name: Session
    description: 웹/앱 세션 (쿠키 기반)
  - name: Personal API
    description: PAK 인증 기반 사용자 API (CLI/MCP용)
  - name: Developer
    description: OAuth 앱 CRUD + 관리 API (PAK 인증)

security:
  - bearerPAK: []

components:
  securitySchemes:
    bearerPAK:
      type: http
      scheme: bearer
      bearerFormat: "logi_pak_..."
      description: Personal API Key. 쿠키 세션으로 `/api/v1/me/api_keys`에서 발급.
    bearerOAuth:
      type: http
      scheme: bearer
      bearerFormat: "JWT"
      description: OAuth 2.0 Access Token (JWT RS256). `/oauth/userinfo`에서만 사용.
    sessionCookie:
      type: apiKey
      in: cookie
      name: session_id
      description: 쿠키 기반 웹/앱 세션.
    clientBasic:
      type: http
      scheme: basic
      description: OAuth `/oauth/token`에서 client_id + client_secret Basic auth.

  schemas:
    User:
      type: object
      properties:
        id: { type: integer }
        email_address: { type: string, format: email }
        role: { type: string, enum: [user, developer, admin] }
      required: [id, email_address, role]

    LoginResponse:
      type: object
      properties:
        user: { $ref: "#/components/schemas/User" }
        otp_verified: { type: boolean, nullable: true }

    OTPRequiredError:
      type: object
      properties:
        error: { type: string, enum: [otp_required, invalid_otp, invalid_credentials, account_locked] }
        locked_until: { type: string, format: date-time, nullable: true }
      required: [error]

    Application:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        client_id: { type: string, example: "logi_a1b2c3d4..." }
        client_secret:
          type: string
          description: "생성/회전 시에만 반환. 이후 조회 불가."
          example: "logi_secret_..."
        redirect_uris:
          type: array
          items: { type: string, format: uri }
        allowed_scopes:
          type: array
          items: { type: string, enum: [profile, email, phone, openid] }
        webhook_url: { type: string, format: uri, nullable: true }
        status: { type: string, enum: [pending, approved, suspended] }
        created_at: { type: string, format: date-time }
      required: [id, name, client_id, redirect_uris, status, created_at]

    TokenResponse:
      type: object
      properties:
        access_token: { type: string, description: "JWT RS256" }
        token_type: { type: string, enum: [Bearer] }
        expires_in: { type: integer, description: "초" }
        refresh_token: { type: string, description: "opaque, rotation 시 변경" }
        scope: { type: string }
        id_token:
          type: string
          description: "openid scope 요청 시 포함 (OIDC)"
      required: [access_token, token_type, expires_in, refresh_token, scope]

    OAuthError:
      type: object
      properties:
        error:
          type: string
          enum:
            - invalid_request
            - invalid_client
            - invalid_grant
            - unauthorized_client
            - unsupported_grant_type
            - invalid_scope
            - access_denied
        error_description: { type: string }
      required: [error]

    PersonalAPIKey:
      type: object
      properties:
        id: { type: integer }
        name: { type: string }
        prefix: { type: string, description: "디스플레이용 prefix (8자)" }
        scopes: { type: array, items: { type: string } }
        last_used_at: { type: string, format: date-time, nullable: true }
        last_used_ip: { type: string, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    LoginLog:
      type: object
      properties:
        id: { type: integer }
        ip_address: { type: string, nullable: true }
        user_agent: { type: string, nullable: true }
        country: { type: string, nullable: true }
        suspicious: { type: boolean }
        deleted_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

    Passkey:
      type: object
      properties:
        id: { type: integer }
        credential_id: { type: string }
        nickname: { type: string }
        transports: { type: array, items: { type: string } }
        last_used_at: { type: string, format: date-time, nullable: true }
        created_at: { type: string, format: date-time }

paths:
  # ─────────────────── Session (쿠키) ───────────────────

  /signup:
    post:
      operationId: post-signup
      tags: [Session]
      summary: 일반 사용자 가입
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                user:
                  type: object
                  properties:
                    email_address: { type: string, format: email }
                    password: { type: string, minLength: 12 }
                    device_uuid: { type: string }
                  required: [email_address, password]
      responses:
        "201":
          description: 가입 성공 + 세션 쿠키 설정
          headers:
            Set-Cookie: { schema: { type: string, example: "session_id=...; Secure; HttpOnly; SameSite=Lax" } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "422":
          description: 검증 실패 (이메일 중복, 비번 짧음 등)

  /session:
    post:
      operationId: post-session
      tags: [Session]
      summary: 로그인
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email_address: { type: string, format: email }
                password: { type: string }
                otp_code: { type: string, description: "OTP 활성화 사용자만, 6자리 또는 백업 코드" }
                device_uuid: { type: string }
              required: [email_address, password]
      responses:
        "200":
          description: 로그인 성공
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          description: 인증 실패 또는 OTP 필요
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OTPRequiredError" }
        "423":
          description: 계정 잠금 (10회 실패 → 30분)
    delete:
      operationId: delete-session
      tags: [Session]
      summary: 로그아웃
      security: [{ sessionCookie: [] }]
      responses:
        "200": { description: OK }

  /me:
    get:
      operationId: get-me
      tags: [Session]
      summary: 현재 로그인된 사용자 정보
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401": { description: 비인증 }

  # ─────────────────── OAuth 2.0 / OIDC ───────────────────

  /oauth/authorize:
    get:
      operationId: get-oauth-authorize
      tags: [OAuth 2.0]
      summary: Authorization Code + PKCE 진입점 (브라우저 리다이렉트)
      security: []
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri } }
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: scope, in: query, required: true, schema: { type: string, example: "profile email" } }
        - { name: state, in: query, required: true, schema: { type: string }, description: "CSRF 방어" }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
        - { name: nonce, in: query, required: false, schema: { type: string }, description: "OIDC 요청 시 replay 방어" }
      responses:
        "200":
          description: Consent HTML 페이지
        "302":
          description: redirect_uri?code=...&state=... (승인) 또는 ?error=... (거부/오류)
        "400":
          description: client_id/redirect_uri 무효 — 콜백 없이 JSON 에러

  # ─────────────────── QR 로그인 (브라우저 측) ───────────────────
  # RP 가 직접 호출하지 않습니다. logi `/oauth/authorize` 페이지 내부의 "QR 로 로그인"
  # 옵션이 호출하는 endpoint 들. 가이드: /oauth/qr-login

  /oauth/qr/start:
    post:
      operationId: post-oauth-qr-start
      tags: [OAuth 2.0 / QR Login]
      summary: QR 로그인 세션 시작 (브라우저 호출)
      description: |
        브라우저가 OAuth params 를 전달하면 logi 서버가 QrLoginSession 을 생성하고
        session_uuid + browser_nonce 를 signed cookie 로 굽습니다. QR 페이로드는
        `{ session, domain }` JSON 만 포함 (PKCE/state 는 서버에 보관).
      security: []
      parameters:
        - { name: client_id, in: query, required: true, schema: { type: string } }
        - { name: redirect_uri, in: query, required: true, schema: { type: string, format: uri } }
        - { name: response_type, in: query, required: true, schema: { type: string, enum: [code] } }
        - { name: scope, in: query, required: true, schema: { type: string } }
        - { name: state, in: query, required: false, schema: { type: string } }
        - { name: code_challenge, in: query, required: true, schema: { type: string } }
        - { name: code_challenge_method, in: query, required: true, schema: { type: string, enum: [S256] } }
        - { name: nonce, in: query, required: false, schema: { type: string } }
      responses:
        "200":
          description: 세션 생성 성공
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string, format: uuid }
                  qr_payload: { type: string, description: '`{ "session": "...", "domain": "api.1pass.dev" }` 직렬화 JSON' }
                  cable_path: { type: string, example: "/cable" }
                  expires_in: { type: integer, example: 600 }
        "400":
          description: OAuth params 검증 실패 (invalid_client, invalid_request 등)

  /oauth/qr/{id}/status:
    get:
      operationId: get-oauth-qr-status
      tags: [OAuth 2.0 / QR Login]
      summary: 폴링 fallback (Cable 실패 환경 대응)
      description: signed cookie `qr_session_uuid` + `qr_browser_nonce` 검증 필수.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: 현재 status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending, scanned, approved, denied, completed, expired] }
                  expires_in: { type: integer }
        "400": { description: cookie 변조 또는 누락 }
        "404": { description: 세션 없음 / 만료 }

  /oauth/qr/{id}/complete:
    get:
      operationId: get-oauth-qr-complete
      tags: [OAuth 2.0 / QR Login]
      summary: 승인 완료 후 grant 발급 + RP redirect
      description: |
        브라우저가 Cable push 로 `approved` 받으면 호출. transaction 내에서 status
        `approved → completed` 원자 전이 + OauthAccessGrant.create! + Consent.grant! +
        AuditLogger + LoginLog. 성공 시 RP redirect_uri 로 302.
      security: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "302":
          description: redirect_uri?code=...&state=... 또는 ?error=access_denied&state=...
        "400": { description: cookie 변조 }

  # ─────────────────── QR 로그인 (모바일 앱 측) ───────────────────
  # logi 1pass iOS/Android 앱 전용. PAK Bearer 인증.

  /api/v1/oauth/qr/{id}:
    get:
      operationId: get-api-v1-oauth-qr-show
      tags: [QR Login (Internal — Mobile App)]
      summary: 스캔 직후 세션 메타데이터 조회 (status mutate 안 함)
      security:
        - bearerPak: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200":
          description: RP·scope·browser 정보
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string }
                  status: { type: string }
                  application:
                    type: object
                    properties:
                      client_id: { type: string }
                      name: { type: string }
                      logo_url: { type: string, nullable: true }
                  scopes: { type: array, items: { type: string } }
                  browser:
                    type: object
                    properties:
                      label: { type: string, example: "Chrome on macOS" }
                      ip: { type: string }
                  expires_in: { type: integer }
        "404": { description: 만료 또는 세션 없음 }

  /api/v1/oauth/qr/{id}/scan:
    post:
      operationId: post-api-v1-oauth-qr-scan
      tags: [QR Login (Internal — Mobile App)]
      summary: 스캔 ack (pending → scanned)
      security:
        - bearerPak: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: 전이 성공 }
        "409": { description: 이미 다른 상태 (`{ "error": "invalid_state", "current": "..." }`) }
        "410": { description: 만료 }

  /api/v1/oauth/qr/{id}/approve:
    post:
      operationId: post-api-v1-oauth-qr-approve
      tags: [QR Login (Internal — Mobile App)]
      summary: 사용자 승인 (scanned → approved)
      description: |
        로그인된 사용자만 가능 (`current_user.anonymous?` 거절). approved_user_id
        를 함께 set. ActionCable 로 브라우저에 push.
      security:
        - bearerPak: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: 승인 성공 — 브라우저가 곧 /complete 호출 }
        "403": { description: 익명 사용자 — `{ "error": "anonymous_not_allowed" }` }
        "409": { description: 잘못된 상태 전이 }

  /api/v1/oauth/qr/{id}/deny:
    post:
      operationId: post-api-v1-oauth-qr-deny
      tags: [QR Login (Internal — Mobile App)]
      summary: 사용자 거절 (pending|scanned → denied)
      security:
        - bearerPak: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string, format: uuid } }
      responses:
        "200": { description: 거절 처리 }
        "409": { description: 잘못된 상태 전이 }

  /oauth/token:
    post:
      operationId: post-oauth-token
      tags: [OAuth 2.0]
      summary: 토큰 발급 (authorization_code + refresh_token)
      security:
        - clientBasic: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              oneOf:
                - type: object
                  title: Authorization Code Grant
                  properties:
                    grant_type: { type: string, enum: [authorization_code] }
                    code: { type: string }
                    redirect_uri: { type: string, format: uri }
                    code_verifier: { type: string }
                    client_id: { type: string }
                    client_secret: { type: string }
                  required: [grant_type, code, redirect_uri, code_verifier]
                - type: object
                  title: Refresh Token Grant
                  properties:
                    grant_type: { type: string, enum: [refresh_token] }
                    refresh_token: { type: string }
                    client_id: { type: string }
                    client_secret: { type: string }
                  required: [grant_type, refresh_token]
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "400":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }
        "401":
          description: invalid_client

  /oauth/revoke:
    post:
      operationId: post-oauth-revoke
      tags: [OAuth 2.0]
      summary: 토큰 revoke (RFC 7009)
      security:
        - clientBasic: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
              required: [token]
      responses:
        "200":
          description: OK (unknown / already revoked token 포함)
        "401":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/introspect:
    post:
      operationId: post-oauth-introspect
      tags: [OAuth 2.0]
      summary: 토큰 상태 조회 (RFC 7662)
      security:
        - clientBasic: []
        - {}
      requestBody:
        required: true
        content:
          application/x-www-form-urlencoded:
            schema:
              type: object
              properties:
                token: { type: string }
                token_type_hint: { type: string, enum: [access_token, refresh_token] }
                client_id: { type: string }
                client_secret: { type: string }
              required: [token]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  active: { type: boolean }
                  scope: { type: string }
                  client_id: { type: string }
                  token_type: { type: string, enum: [access_token, refresh_token] }
                  exp: { type: integer }
                  iat: { type: integer }
                  sub: { type: string }
                  aud: { type: string }
                  iss: { type: string }
                  jti: { type: string }
                required: [active]
        "401":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/OAuthError" }

  /oauth/userinfo:
    get:
      operationId: get-oauth-userinfo
      tags: [OIDC]
      summary: 사용자 정보 조회 (Bearer JWT)
      security: [{ bearerOAuth: [] }]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  sub: { type: string }
                  email: { type: string, format: email }
                  email_verified: { type: boolean }
                  identity_verified_level: { type: integer, enum: [0, 1, 2, 3] }
        "401":
          description: invalid_token
          headers:
            WWW-Authenticate: { schema: { type: string, example: "Bearer error=\"invalid_token\"" } }

  /.well-known/jwks.json:
    get:
      operationId: get-well-known-jwks-json
      tags: [OIDC]
      summary: 공개 키 세트 (JWKS)
      security: []
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      type: object
                      properties:
                        kty: { type: string, enum: [RSA] }
                        use: { type: string, enum: [sig] }
                        alg: { type: string, enum: [RS256] }
                        kid: { type: string }
                        "n": { type: string }
                        "e": { type: string }

  # ─────────────────── Passkey ───────────────────

  /api/v1/passkeys/authentication_options:
    post:
      operationId: post-api-v1-passkeys-authenticationoptions
      tags: [Passkey]
      summary: Passkey passwordless 인증 옵션 생성 (공개)
      security: []
      responses:
        "200":
          description: WebAuthn PublicKeyCredentialRequestOptions

  /api/v1/passkeys/authenticate:
    post:
      operationId: post-api-v1-passkeys-authenticate
      tags: [Passkey]
      summary: Passkey 인증 완료 (full session 발급)
      security: []
      responses:
        "200":
          headers:
            Set-Cookie: { schema: { type: string } }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LoginResponse" }
        "401":
          description: passkey_auth_failed

  # ─────────────────── Personal API (PAK) ───────────────────

  /api/v1/me/api_keys:
    get:
      operationId: get-api-v1-me-apikeys
      tags: [Personal API]
      summary: 내 PAK 목록 (쿠키 인증)
      security: [{ sessionCookie: [] }]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_keys:
                    type: array
                    items: { $ref: "#/components/schemas/PersonalAPIKey" }
    post:
      operationId: post-api-v1-me-apikeys
      tags: [Personal API]
      summary: 새 PAK 발급 (plaintext는 1회만 반환)
      security: [{ sessionCookie: [] }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                scopes:
                  type: array
                  items: { type: string }
      responses:
        "201":
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/PersonalAPIKey"
                  - type: object
                    properties:
                      plaintext: { type: string, example: "logi_pak_xxxxxxxx_..." }

  /api/v1/me/login_logs:
    get:
      operationId: get-api-v1-me-loginlogs
      tags: [Personal API]
      summary: 로그인 이력 (최근 100건)
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  login_logs:
                    type: array
                    items: { $ref: "#/components/schemas/LoginLog" }

  /api/v1/me/passkeys:
    get:
      operationId: get-api-v1-me-passkeys
      tags: [Personal API, Passkey]
      summary: 등록된 Passkey 목록
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  passkeys:
                    type: array
                    items: { $ref: "#/components/schemas/Passkey" }

  # ─────────────────── Developer (PAK) ───────────────────

  /api/v1/applications:
    get:
      operationId: get-api-v1-applications
      tags: [Developer]
      summary: OAuth 앱 목록
      security: [{ bearerPAK: [] }]
      responses:
        "200":
          content:
            application/json:
              schema:
                type: object
                properties:
                  applications:
                    type: array
                    items: { $ref: "#/components/schemas/Application" }
    post:
      operationId: post-api-v1-applications
      tags: [Developer]
      summary: 앱 등록 (client_secret 1회 반환)
      security: [{ bearerPAK: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                application:
                  type: object
                  properties:
                    name: { type: string }
                    redirect_uris: { type: array, items: { type: string, format: uri } }
                    allowed_scopes: { type: array, items: { type: string } }
                    webhook_url: { type: string, format: uri, nullable: true }
                  required: [name, redirect_uris]
      responses:
        "201":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }

  /api/v1/applications/{id}/rotate_secret:
    post:
      operationId: post-api-v1-applications-by-id-rotatesecret
      tags: [Developer]
      summary: client_secret 재발급 (구 secret 즉시 무효화)
      security: [{ bearerPAK: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: integer } }
      responses:
        "200":
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Application" }
