openapi: 3.1.0
info:
  title: TOTP/HOTP Authenticator
  description: Hasilkan secret, QR code enrolment, dan verifikasi kode TOTP maupun HOTP. Mendukung toleransi waktu, panjang digit, serta kode cadangan sekali pakai.
  version: 1.0.0
servers:
- url: https://api.apipedia.id/v1/otp
  description: Buat dan verifikasi kode dua faktor yang kompatibel dengan Google Authenticator.
tags:
- name: Authenticator
  description: "TOTP/HOTP Authenticator — secret dan QR enrolment kompatibel Google Authenticator, verifikasi kode TOTP (RFC 6238) dan HOTP (RFC 4226), serta kode cadangan sekali pakai. Stateless: APIPedia tidak menyimpan secret maupun kode cadangan"
security:
- bearerJWT: []
paths:
  /backup-codes:
    post:
      operationId: otpBackupCodes
      summary: Terbitkan kode cadangan sekali pakai beserta hash-nya
      description: |-
        Kode cadangan untuk pengguna yang kehilangan perangkat autentikatornya. Setiap kode diambil acak (CSPRNG, tanpa bias modulo) dari 31 simbol yang tidak mudah tertukar (`abcdefghjkmnpqrstuvwxyz23456789` - tanpa 0/o, 1/l/i); kode dalam satu set dijamin unik.

        Body: `count` 1-20 (bawaan 10); `length` 8-16 karakter (bawaan 10, ~49 bit; `entropyBits` melaporkan kekuatannya); `format` `grouped` (bawaan, dikelompokkan dengan tanda hubung: kelompok 5 untuk panjang 10/15, selain itu kelompok 4) atau `plain`.

        `codes` hanya untuk DITAMPILKAN SEKALI ke pengguna (minta mereka mencatat atau mengunduhnya). `hashes[i]` adalah SHA-256 hex dari `codes[i]` yang sudah dinormalisasi (huruf kecil, tanpa spasi dan tanda hubung) - simpan HANYA hash-nya. APIPedia tidak menyimpan keduanya.

        Cara verifikasi di sisi Anda: normalisasi masukan pengguna dengan aturan yang sama (`kode.replace(/[\s-]+/g, '').toLowerCase()`), hitung SHA-256 hex-nya, bandingkan dengan hash tersimpan milik pengguna itu secara constant-time, lalu HAPUS hash yang cocok supaya kodenya tidak bisa dipakai dua kali. Untuk lapisan tambahan, Anda boleh meng-HMAC hash ini dengan pepper milik server sebelum menyimpannya.
      tags:
      - Authenticator
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
            example:
              count: 3
              length: 10
              format: grouped
      responses:
        "200":
          description: Berhasil
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiEnvelope"
              example:
                is_success: true
                message: 3 backup codes created. Show the codes to the user ONCE only and store only their hashes; APIPedia stores neither.
                data:
                  codes:
                  - wq4as-gja3z
                  - nnzyy-hkf44
                  - pb583-ss4qs
                  hashes:
                  - 06537940e025d88456fa20c9ebddbf0e80d409caf15730397830bf4ce0e80885
                  - a29ebb92d808e552df0240cf08ff2ea3e0cb5817a33535be88ea279896f7404d
                  - 6a6087d81872388ee239c2493b6c9e25685144c40fc2adc5d12fb4cf2576efec
                  hashAlgorithm: sha256
                  normalization: lowercase, without spaces and hyphens
                  count: 3
                  length: 10
                  format: grouped
                  entropyBits: 49
                  stored: false
        "400":
          description: "`count` di luar 1-20, `length` di luar 8-16, atau `format` bukan grouped/plain"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Field \"count\" must be an integer between 1 and 20."
                data: null
        "401":
          description: Token tidak ada, tidak dikenal, kedaluwarsa, atau akun disuspend
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Unauthorized: invalid or expired token"
                data: null
        "403":
          description: API key valid tetapi cakupannya (scopes) tidak mencakup endpoint ini
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Forbidden: this API key has not been granted access to that endpoint. Change its scopes on the Tokens page."
                data: null
        "404":
          description: Path tidak dikenal atau sumber daya tidak ditemukan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Endpoint GET /v1/contoh not found.
                data: null
        "429":
          description: Cadangan untuk rate limit/kuota harian per paket (belum ditegakkan server saat ini)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Daily quota exhausted. Please try again after 00:00 WIB (UTC+7).
                data: null
        "500":
          description: Kesalahan internal; detail tidak pernah dibocorkan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: An internal server error occurred.
                data: null
        "502":
          description: Sumber data hulu (BMKG, ECB, OpenStreetMap, Composio, Tripay, mesin OCR) gagal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: The earthquake data source (BMKG) cannot be reached right now.
                data: null
  /enroll:
    post:
      operationId: otpEnroll
      summary: Buat secret, URI otpauth://, dan QR enrolment
      description: |-
        Langkah pertama pemasangan 2FA. Menghasilkan secret base32 (160 bit acak dari CSPRNG, RFC 4648 tanpa padding) atau memakai `secret` milik Anda sendiri, merangkai Key URI format Google Authenticator (`otpauth://totp/ISSUER:ACCOUNT?secret=...&issuer=...&algorithm=...&digits=...&period=...`), dan merender QR-nya untuk dipindai Google Authenticator, Microsoft Authenticator, Authy, 1Password, dan sejenisnya.

        **APIPedia TIDAK menyimpan secret.** Endpoint ini stateless: simpan `secret` di basis data Anda (sebaiknya terenkripsi), karena secret wajib dikirim ulang ke `POST /otp/verify`. Respons tidak pernah di-cache (`Cache-Control: no-store`) dan setiap panggilan tanpa `secret` menghasilkan secret baru.

        Body: `account` wajib (label, mis. email; maks 128 karakter, tanpa `:`); `issuer` (bawaan `APIPedia`, maks 64, tanpa `:`; isi dengan nama aplikasi Anda); `type` `totp` (bawaan) atau `hotp`; `algorithm` `SHA1` (bawaan - satu-satunya yang didukung semua aplikasi, termasuk Google Authenticator), `SHA256`, atau `SHA512`; `digits` 6 (bawaan) atau 8; `period` 15-120 detik (bawaan 30, hanya totp); `counter` awal (bawaan 0, hanya hotp); `secret` opsional (base32, minimal 128 bit / 26 karakter); `qrFormat` `png` (bawaan, 256 px) atau `svg`. `qr` selalu berupa data URL yang bisa langsung dipasang di `<img src>`.

        Alur yang disarankan: enroll -> tampilkan `qr` -> minta pengguna memasukkan satu kode -> `POST /otp/verify` -> baru tandai 2FA aktif dan simpan secret-nya.
      tags:
      - Authenticator
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              account: budi@contoh.id
              issuer: TokoBudi
              type: totp
              algorithm: SHA1
              digits: 6
              period: 30
      responses:
        "200":
          description: Berhasil
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiEnvelope"
              example:
                is_success: true
                message: OTP secret created. APIPedia does NOT store this secret - store it on your side (encrypted) because it must be sent again on verification.
                data:
                  secret: X3WKFDH2KBNBF2MXZHJNI2DI2VIJTJK7
                  otpauthUrl: otpauth://totp/TokoBudi:budi%40contoh.id?secret=X3WKFDH2KBNBF2MXZHJNI2DI2VIJTJK7&issuer=TokoBudi&algorithm=SHA1&digits=6&period=30
                  qr: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAA...
                  qrFormat: png
                  type: totp
                  issuer: TokoBudi
                  account: budi@contoh.id
                  algorithm: SHA1
                  digits: 6
                  period: 30
                  secretSource: generated
                  stored: false
        "400":
          description: "`account` kosong/lebih dari 128 karakter, `account` atau `issuer` memuat `:`, `type`/`algorithm`/`digits`/`period`/`counter`/`qrFormat` di luar pilihan, atau `secret` bukan base32 sah atau kurang dari 128 bit"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Field \"account\" is required, e.g. an email or username."
                data: null
        "401":
          description: Token tidak ada, tidak dikenal, kedaluwarsa, atau akun disuspend
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Unauthorized: invalid or expired token"
                data: null
        "403":
          description: API key valid tetapi cakupannya (scopes) tidak mencakup endpoint ini
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Forbidden: this API key has not been granted access to that endpoint. Change its scopes on the Tokens page."
                data: null
        "404":
          description: Path tidak dikenal atau sumber daya tidak ditemukan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Endpoint GET /v1/contoh not found.
                data: null
        "429":
          description: Cadangan untuk rate limit/kuota harian per paket (belum ditegakkan server saat ini)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Daily quota exhausted. Please try again after 00:00 WIB (UTC+7).
                data: null
        "500":
          description: Kesalahan internal; detail tidak pernah dibocorkan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: An internal server error occurred.
                data: null
        "502":
          description: Sumber data hulu (BMKG, ECB, OpenStreetMap, Composio, Tripay, mesin OCR) gagal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: The earthquake data source (BMKG) cannot be reached right now.
                data: null
  /verify:
    post:
      operationId: otpVerify
      summary: Verifikasi kode TOTP atau HOTP
      description: |-
        Mencocokkan kode dari aplikasi autentikator dengan `secret` yang Anda simpan saat enroll. TOTP mengikuti RFC 6238 (langkah waktu `floor(unix / period)`), HOTP mengikuti RFC 4226 (HMAC + dynamic truncation). Pembandingan kode constant-time, dan seluruh jendela toleransi selalu dihitung tanpa berhenti lebih awal.

        Body: `secret` wajib (base32; spasi, tanda hubung, dan huruf kecil diterima; minimal 80 bit demi secret lama); `code` wajib berupa TEKS angka sepanjang `digits` (angka JSON ditolak karena nol di depan hilang); `type`, `algorithm`, `digits`, `period` harus sama dengan saat enroll; `counter` (hotp) counter tersimpan; `window` 0-10 (bawaan 1); `timestamp` opsional (detik Unix, totp, bawaan jam server - untuk pengujian).

        Toleransi: TOTP memeriksa langkah `-window..+window`; `delta` -1 berarti kode dari 30 detik sebelumnya. HOTP hanya memeriksa ke depan (`counter..counter+window`) karena counter perangkat tidak bisa mundur; bila valid, SIMPAN `nextCounter` sebagai counter baru.

        Kode salah BUKAN error: jawabannya HTTP 200 dengan `valid: false` dan `delta: null`. HTTP 400 hanya untuk masukan rusak.

        Anti-replay menjadi tanggung jawab Anda karena endpoint ini stateless: untuk TOTP simpan `matchedStep` terakhir yang diterima dan tolak kode berikutnya bila `matchedStep` tidak lebih besar; untuk HOTP cukup selalu memakai `nextCounter`. Batasi juga jumlah percobaan per pengguna.
      tags:
      - Authenticator
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              secret: GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
              code: "94287082"
              type: totp
              algorithm: SHA1
              digits: 8
              period: 30
              window: 1
              timestamp: 89
      responses:
        "200":
          description: Berhasil
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiEnvelope"
              example:
                is_success: true
                message: OTP code is valid.
                data:
                  valid: true
                  delta: -1
                  timeStep: 2
                  matchedStep: 1
                  type: totp
                  algorithm: SHA1
                  digits: 8
                  period: 30
                  window: 1
        "400":
          description: "`secret` kosong/bukan base32/kurang dari 80 bit, `code` bukan teks angka atau panjangnya tidak sama dengan `digits`, atau `window`/`timestamp`/parameter lain di luar rentang. Kode yang SALAH tidak dijawab 400, melainkan 200 dengan `valid: false`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Field \"code\" must be 8 digits, matching \"digits\"."
                data: null
        "401":
          description: Token tidak ada, tidak dikenal, kedaluwarsa, atau akun disuspend
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Unauthorized: invalid or expired token"
                data: null
        "403":
          description: API key valid tetapi cakupannya (scopes) tidak mencakup endpoint ini
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: "Forbidden: this API key has not been granted access to that endpoint. Change its scopes on the Tokens page."
                data: null
        "404":
          description: Path tidak dikenal atau sumber daya tidak ditemukan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Endpoint GET /v1/contoh not found.
                data: null
        "429":
          description: Cadangan untuk rate limit/kuota harian per paket (belum ditegakkan server saat ini)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: Daily quota exhausted. Please try again after 00:00 WIB (UTC+7).
                data: null
        "500":
          description: Kesalahan internal; detail tidak pernah dibocorkan
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: An internal server error occurred.
                data: null
        "502":
          description: Sumber data hulu (BMKG, ECB, OpenStreetMap, Composio, Tripay, mesin OCR) gagal
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiErrorEnvelope"
              example:
                is_success: false
                message: The earthquake data source (BMKG) cannot be reached right now.
                data: null
components:
  securitySchemes:
    bearerJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API key `apk_...` dari halaman Token (disarankan untuk integrasi; bisa dibatasi cakupannya) atau JWT sesi console. Satu token berlaku untuk semua API di gateway yang masuk cakupannya.
  schemas:
    ApiEnvelope:
      type: object
      description: Amplop seragam yang membungkus setiap respons gateway.
      required:
      - is_success
      - message
      - data
      properties:
        is_success:
          type: boolean
          description: Apakah permintaan berhasil diproses.
        message:
          type: string
          description: Penjelasan singkat yang layak ditampilkan ke pengguna.
        data:
          description: Muatan jawaban; bentuknya berbeda per endpoint dan null saat gagal.
    ApiErrorEnvelope:
      allOf:
      - $ref: "#/components/schemas/ApiEnvelope"
      description: "Amplop untuk respons gagal: `is_success` selalu false dan `data` selalu null."
