> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Verifikasi JWT Autentikasi Didit: Token dan JWKS

> Panduan token Autentikasi Didit: pertukaran kode PKCE, klaim access dan ID token ES256, discovery JWKS, rotasi kunci, deteksi reuse refresh token.

> [!WARNING]
>
> Autentikasi adalah fitur baru. Setiap panggilan di halaman ini menggunakan kredensial **pengguna akhir** atau tidak sama sekali. Jangan pernah mengirim kunci API verifikasi atau token konsol Anda ke endpoint ini.

Setiap aplikasi adalah issuer OpenID Connect tersendiri. Issuer adalah origin login aplikasi Anda:

* `https://<name>.auth.didit.me` untuk aplikasi produksi,
* `https://<name>.sandbox.auth.didit.me` untuk aplikasi sandbox,
* atau domain kustom Anda, setelah domain tersebut aktif.

Produksi dan sandbox adalah issuer terpisah dengan kunci terpisah. Atur `DIDIT_AUTH_ISSUER` ke issuer Anda, tanpa garis miring di akhir, dan `DIDIT_APPLICATION_ID` ke ID aplikasi Anda: token membawanya sebagai `aud`. Kunci yang dapat dipublikasikan menyandikan issuer: setelah `pk_live_` atau `pk_test_`, terdapat issuer dalam base64url tanpa padding.

> [!NOTE]
>
> Ketika domain kustom Anda menjadi aktif, domain tersebut menggantikan host default sebagai issuer. Token baru menyebutkannya di `iss`, discovery mengarahkan `jwks_uri` ke domain tersebut, dan kunci yang dapat dipublikasikan Anda berubah. Token akses yang diterbitkan di bawah host default kemudian gagal pada `GET /session/` hingga disegarkan. Perbarui `DIDIT_AUTH_ISSUER` saat Anda mengaktifkan domain, atau terima kedua issuer hingga token lama kedaluwarsa, dalam waktu 5 menit.

Endpoint server di halaman ini (discovery, kunci, token dan sesi) ada dalam [referensi API Autentikasi](https://docs.didit.me/id/authentication/api-reference/get-openid-configuration.md), yang dibuat dari [openapi-authentication.json](https://docs.didit.me/openapi-authentication.json). Halaman masuk yang di-hosting tidak termasuk.

## Penemuan dan kunci

| Permintaan | Hasil |
| - | - |
| `GET {issuer}/.well-known/openid-configuration` | `issuer`, `authorization_endpoint`, `token_endpoint`, `jwks_uri` dan apa yang didukung issuer. |
| `GET {issuer}/.well-known/jwks.json` | `{"keys": [...]}`, kunci penandatanganan publik. |

Issuer mendukung:

* Response type `code` dan response mode `query`.
* Grant type `authorization_code` dan `refresh_token`.
* Metode PKCE `S256` saja. Klien bersifat publik: tidak ada client secret, dan `client_id` adalah ID aplikasi Anda.
* Algoritma penandatanganan `ES256` (kunci P-256).
* Scope `openid`, `email`, `phone`, `profile` dan `offline_access`.

Tidak ada endpoint userinfo, introspection, atau end-session, dan `prompt=none` tidak didukung. Gunakan [`GET /session/`](#periksa-sesi) untuk membaca dan memeriksa sesi.

### Caching dan rotasi

Kedua dokumen merespons dengan `Cache-Control: public, max-age=300`, sehingga Anda dan cache bersama dapat menyimpannya selama 5 menit. Keduanya mengizinkan 1,200 permintaan per menit dan 12,000 per jam per alamat IP, jauh lebih tinggi daripada endpoint masuk, karena banyak instance backend sering berbagi satu alamat.

* Setiap kunci memiliki `kid`; ID kunci baru dimulai dengan `k_`. Setiap token menyebutkan kuncinya di header `kid`.
* Kunci diputar setiap 30 hari. Didit mempublikasikan kunci berikutnya satu hari sebelum mulai menandatangani, dan tetap mempublikasikan kunci sebelumnya selama satu hari setelah berhenti menandatangani. JWKS yang di-cache selama 5 menit oleh karena itu tetap dapat memverifikasi selama rotasi.
* Simpan kunci di cache berdasarkan `kid`. Ketika sebuah token menyebutkan `kid` yang tidak Anda miliki, ambil ulang JWKS, paling banyak sekali per menit, dan tolak token jika kunci tersebut masih tidak ada.
* Ketika Didit mencabut kunci yang disusupi, Didit segera menghapus kunci itu dari JWKS, dan `GET /session/` menolak setiap token yang ditandatangani oleh kunci tersebut. Kunci pengganti langsung mulai menandatangani tanpa publikasi satu hari sebelumnya. JWKS yang di-cache hingga 5 menit sebelumnya masih menerima kunci yang dicabut dan belum mengenal kunci pengganti, sehingga token baru dapat gagal pada verifikasi offline hingga cache diperbarui.

## Memulai login

Buka halaman yang di-hosting, yaitu `authorization_endpoint`:

```text theme={null}
{issuer}/sign-in?redirect_uri=...&state=...&code_challenge=...&code_challenge_method=S256
```

| Parameter | Aturan |
| - | - |
| `redirect_uri` | Harus sama persis dengan salah satu [URL redirect terdaftar](https://docs.didit.me/id/authentication/overview.md#url-redirect) Anda. |
| `state` | Hingga 512 karakter. Periksa pada callback. |
| `code_challenge` | hasil base64url dari SHA-256 verifier PKCE Anda: tepat 43 karakter. |
| `code_challenge_method` | `S256`. |
| `client_id` | Opsional. Harus sama dengan ID aplikasi Anda. |
| `nonce` | Opsional. Disalin ke ID token, sehingga Anda dapat mengikatnya ke login ini. |
| `scope`, `login_hint`, `ui_locales`, `max_age`, `prompt` | Opsional. `login_hint` hanya mengisi kolom email terlebih dahulu. |

[Panduan mulai cepat](https://docs.didit.me/id/authentication/quickstarts/javascript.md) membuat permintaan ini untuk Anda.

## Menukar kode

Callback menerima `code` dan `state`. Periksa `state`, lalu tukarkan kode dalam 60 detik. Setiap kode hanya dapat digunakan sekali. Isi permintaan dapat berupa JSON atau dikodekan dalam format formulir.

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{
    "grant_type": "authorization_code",
    "code": "CODE_FROM_CALLBACK",
    "code_verifier": "YOUR_PKCE_VERIFIER",
    "redirect_uri": "https://app.example.com/auth/callback"
  }'
```

```json theme={null}
{
  "access_token": "eyJhbGciOiJFUzI1NiIs...",
  "id_token": "eyJhbGciOiJFUzI1NiIs...",
  "refresh_token": "N4yZTl6y24YKYq77X_SO...",
  "token_type": "Bearer",
  "expires_in": 299,
  "session_id": "c9abf6cc-2d64-4bf2-b31e-a4d09a070ab1"
}
```

`expires_in` menghitung sisa detik masa berlaku token akses. Saat Anda menonaktifkan banyak sesi, pertukaran ini mengakhiri sesi lain milik pengguna tersebut.

| Respons | Arti |
| - | - |
| `400 {"error": "invalid_grant"}` | Kode tidak dikenal, sudah digunakan atau kedaluwarsa; verifier atau `redirect_uri` tidak cocok, atau `redirect_uri` tidak lagi terdaftar; pengguna diblokir atau email mereka tidak lagi terverifikasi; atau Anda menonaktifkan metode masuk yang mereka gunakan. |
| `400 {"error": "invalid_client"}` | `client_id` bukan ID aplikasi Anda. |
| `400 {"error": "mfa_required"}` | Proses masuk tidak memenuhi kebijakan MFA Anda. |
| `400 {"<field>": [...]}` | Sebuah kolom hilang atau tidak valid, misalnya `{"non_field_errors": ["invalid_request"]}` ketika grant tidak memiliki kolom wajibnya. |
| `400 {"<field>": "unknown_field"}` | Body memiliki kolom yang tidak diterima endpoint, misalnya `client_secret`. Klien publik tidak mengirim apa pun. |

## Klaim token akses

Token akses adalah JWT dengan header `alg: ES256` dan `typ: at+jwt`. Masa berlakunya paling lama 5 menit, dan tidak pernah lebih lama dari sesinya.

| Klaim | Arti |
| - | - |
| `iss` | Penerbit Anda. |
| `aud` | ID aplikasi Anda. |
| `client_id` | ID aplikasi Anda. |
| `sub` | ID identitas orang tersebut di aplikasi ini. |
| `sid` | ID sesi. Untuk token agen AI, ID akses yang diberikan orang tersebut kepada agen. |
| `jti`, `iat`, `exp` | Pengenal token, waktu penerbitan, dan waktu kedaluwarsa. |
| `vendor_data` | Referensi tetap orang tersebut, yang dibagikan dengan verifikasi dan pemantauan transaksi. |
| `amr` | Metode login, misalnya `["passkey"]`. |
| `org` | `{"id", "role"}` dari keanggotaan organisasi tertua orang tersebut di aplikasi Anda, atau `null` jika tidak memiliki keanggotaan atau organisasi dinonaktifkan. `GET /session/` mencantumkan semua keanggotaan. |
| `verified` | Tingkat verifikasi dan klaim yang Anda pilih, atau `{}`. Pemeriksaan yang disetujui selama sesi akan masuk ke token pada refresh berikutnya. |
| `act` | `null` untuk orang. Untuk agen AI yang bertindak atas nama orang tersebut: `{"sub", "agent", "scopes"}`. Lihat [langkah 5](#verifikasi-token-di-server-anda). |

ID token memiliki header `typ: JWT`. Token ini memuat klaim yang sama kecuali `client_id`, ditambah `auth_time` dan `email_verified`, serta `email`, `name` dan `phone_number` jika memiliki nilai. Hanya ID token dari pertukaran kode yang memuat `nonce`, jika proses login memilikinya: periksa di sana, karena ID token dari refresh tidak pernah memuatnya. ID token tidak pernah diterima sebagai token akses.

## Verifikasi token di server Anda

Periksa semua ini sebelum mempercayai klaim apa pun:

1. `alg` bernilai `ES256` dan `typ` bernilai `at+jwt`. Tolak algoritma lain, termasuk `none`.
2. `kid` ada di JWKS aplikasi Anda, dan tanda tangannya terverifikasi.
3. `iss` sama dengan penerbit yang Anda konfigurasi, dan `aud` sama dengan ID aplikasi Anda.
4. `exp` dan `iat` valid, dengan toleransi perbedaan waktu hingga 60 detik, dan `sub`, `sid` serta `jti` harus ada.
5. `act` bernilai `null`. Token akses agen AI ditandatangani dengan kunci yang sama dan memuat `typ`, `iss` dan `aud` yang sama; hanya `act` yang membedakannya. Tolak token tersebut, kecuali endpoint Anda menerima agen: dalam kasus itu, izinkan hanya tindakan yang diizinkan oleh `act.scopes`. `GET /session/` selalu menolak token agen.

Ambil penerbit dari konfigurasi Anda, jangan pernah dari token.

```javascript Node.js theme={null}
// npm install jose
import { createRemoteJWKSet, jwtVerify } from "jose";

const ISSUER = process.env.DIDIT_AUTH_ISSUER; // https://<name>.auth.didit.me
const AUDIENCE = process.env.DIDIT_APPLICATION_ID;
// Cache keys for 5 minutes; refetch an unknown kid at most once a minute.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`), {
  cacheMaxAge: 5 * 60 * 1000,
  cooldownDuration: 60 * 1000,
});

export async function verifyAccessToken(token) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ["ES256"],
    typ: "at+jwt",
    clockTolerance: 60,
    maxTokenAge: "5m", // also rejects an iat in the future
    requiredClaims: ["sub", "sid", "jti", "exp", "iat"],
  });
  if (payload.act !== null) throw new Error("agent tokens are not accepted");
  return payload; // payload.vendor_data is the person across Didit products
}
```

```python Python theme={null}
# pip install "pyjwt[crypto]"
import os
import threading
import time

import jwt

ISSUER = os.environ["DIDIT_AUTH_ISSUER"]  # https://<name>.auth.didit.me
AUDIENCE = os.environ["DIDIT_APPLICATION_ID"]
jwks = jwt.PyJWKClient(f"{ISSUER}/.well-known/jwks.json", lifespan=300)
refresh_lock = threading.Lock()
last_refresh = 0.0


def signing_key(kid: str | None) -> jwt.PyJWK:
    # PyJWKClient refetches for every unknown kid: limit it to once a minute.
    global last_refresh
    keys = {key.key_id: key for key in jwks.get_signing_keys()}
    if kid not in keys:
        with refresh_lock:
            if time.monotonic() - last_refresh >= 60:
                last_refresh = time.monotonic()
                fresh = jwks.get_signing_keys(refresh=True)
                keys = {key.key_id: key for key in fresh}
    if kid not in keys:
        raise jwt.InvalidTokenError("unknown signing key")
    return keys[kid]


def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("typ") != "at+jwt":
        raise jwt.InvalidTokenError("not an access token")
    claims = jwt.decode(
        token,
        signing_key(header.get("kid")).key,
        algorithms=["ES256"],
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=60,
        options={"require": ["iss", "aud", "sub", "sid", "jti", "exp", "iat"]},
    )
    if "act" not in claims or claims["act"] is not None:
        raise jwt.InvalidTokenError("agent tokens are not accepted")
    return claims
```

Verifikasi offline tidak dapat melihat sesi yang baru saja berakhir. Jika hal itu penting, misalnya sebelum pencairan dana, panggil `GET /session/`.

## Periksa sesi

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/session/" \
  -H "Authorization: Bearer $DIDIT_ACCESS_TOKEN"
```

Respons berisi `sub`, `sid`, `application_id`, `vendor_data`, `amr`, `mfa_verified`, `email`, `email_verified`, `phone`, `verified_identity`, `verified`, `expires_at`, `post_logout_redirect_uri` dan `organizations`. Setiap entri organisasi memiliki `organization_id`, `organization__name`, `role` dan `custom_role`.

Pemeriksaan ini lebih ketat daripada verifikasi offline. Pemeriksaan ini juga gagal jika sesi dicabut, menjadi tidak aktif, atau melewati masa berlakunya; jika orang tersebut diblokir atau emailnya tidak lagi terverifikasi; jika sesi tidak lagi memenuhi kebijakan MFA Anda (misalnya setelah orang tersebut menambahkan aplikasi authenticator atau menjadi admin organisasi); atau jika token telah diganti oleh refresh. Jika gagal, respons `401 {"detail": "invalid_token"}` dengan `WWW-Authenticate: Bearer` dikembalikan.

`GET /session/` mengizinkan 30 permintaan per menit dan 300 per jam per alamat, batas yang juga dipakai bersama dengan `DELETE /session/`. Verifikasi token secara offline pada setiap permintaan, dan gunakan panggilan ini hanya pada saat diperlukan. Setiap panggilan yang berhasil dihitung sebagai aktivitas, sehingga memulai ulang `idle_timeout` sesi.

Email yang terverifikasi bukan verifikasi identitas. Baca `verified` untuk hal itu: di sini nilainya `null` jika tidak ada, sementara token memuat `{}`.

## Refresh token

```bash theme={null}
curl --fail-with-body "${DIDIT_AUTH_ISSUER}/token/" \
  -H 'Content-Type: application/json' \
  -d '{"grant_type": "refresh_token", "refresh_token": "YOUR_REFRESH_TOKEN"}'
```

Refresh mengembalikan token akses, ID token, dan refresh token baru, dan refresh token yang Anda kirim berhenti berfungsi.

* Refresh token adalah string acak yang berlaku hingga sesi berakhir.
* **Penggunaan ulang mengakhiri sesi.** Ketika refresh token yang sudah dipakai datang lagi, Didit menganggapnya sebagai tanda pencurian: sesi dicabut dan respons `400 {"error": "invalid_grant"}` dikembalikan. Token sesi juga berhenti berfungsi, termasuk yang berasal dari refresh sebelumnya: refresh token-nya gagal, dan token akses-nya langsung gagal pada `GET /session/`, meskipun masih lolos verifikasi offline hingga kedaluwarsa dalam waktu 5 menit.
* Jalankan hanya satu refresh secara bersamaan untuk sebuah sesi, di seluruh tab, perangkat, dan instans server, lalu ganti ketiga token sekaligus.
* Jika refresh gagal dengan `400` atau tidak mendapat jawaban, mulai login lagi. Jangan mencoba ulang secara membabi buta: percobaan ulang setelah refresh yang sebenarnya berhasil merupakan penggunaan ulang.
* `503 {"error": "temporarily_unavailable"}` berarti refresh tidak terjadi dan refresh token Anda masih berfungsi. Coba lagi dengan backoff.
* Didit menjawab `429` sebelum refresh dijalankan, jadi coba lagi setelah `Retry-After`. Endpoint token mengizinkan 30 permintaan per menit dan 300 per jam per alamat. Selain itu, setiap `POST` dan `DELETE` aplikasi Anda berbagi bucket proteksi bot: 60 permintaan per menit per alamat di seluruh endpoint tersebut, dan 30 per menit per perangkat. Untuk seluruh aplikasi, refresh memiliki bucket 6,000 per menit, dan setiap `POST` dan `DELETE` lainnya (termasuk permintaan dari browser dan langkah login) memiliki bucket 3,000 per menit, sehingga refresh tidak pernah menghabiskan kuota login Anda.
* Dengan token akses 5 menit, 300 refresh per jam cukup untuk sekitar 25 orang yang tetap aktif. Jika server Anda melakukan refresh untuk banyak orang dari satu alamat, lakukan refresh hanya saat sebuah permintaan membutuhkan token baru, dan lakukan refresh di browser atau aplikasi bila memungkinkan.

## Keluar

`DELETE /session/` dengan access token pengguna saat ini mengakhiri sesi dan mengembalikan `204`. Refresh token-nya berhenti berfungsi, dan access token-nya langsung gagal pada `GET /session/`. Token yang Anda verifikasi secara offline tetap berlaku hingga kedaluwarsa. Setelah itu, arahkan pengguna ke `post_logout_redirect_uri` Anda, jika Anda menetapkannya.

Keluar memerlukan access token yang akan diterima oleh `GET /session/`: token yang belum kedaluwarsa dan belum diganti melalui refresh. Respons `401` berarti sesi belum berakhir. Lakukan refresh, lalu keluar dengan access token yang baru. Jika refresh gagal dengan `400`, sesi tidak dapat digunakan lagi.

Sesi juga berakhir apabila:

* melewati `session_lifetime` (default 30 hari) atau `idle_timeout` (default 1 hari),
* pengguna masuk kembali saat sesi multipel dinonaktifkan,
* pengguna mengatur ulang kata sandinya saat `revoke_on_password_change` aktif,
* tim Anda mencabut sesi atau memblokir pengguna di konsol,
* refresh token yang sudah dipakai dikirim lagi.

## Masa berlaku

| Item | Masa berlaku |
| - | - |
| Transaksi masuk (`POST /authorize/`) | 10 menit |
| Kode otorisasi | 60 detik, sekali pakai |
| Access token | 5 menit, paling lama |
| ID token | Sama dengan `exp` pada access token yang diterbitkan bersamanya |
| Refresh token | Hingga sesi berakhir, sekali pakai |
| Sesi | `session_lifetime` dan `idle_timeout` |
| Step-up | 30 menit |
| Cache discovery dan JWKS | 5 menit |

## Simpan kredensial sesuai tujuannya

* Simpan kunci API verifikasi dan token konsol di server Anda.
* Simpan access token browser di memori jika memungkinkan. Untuk aplikasi yang di-render di server, gunakan cookie HttpOnly yang aman dan lindungi permintaan yang diautentikasi dengan cookie dari CSRF.
* Simpan refresh token aplikasi seluler di penyimpanan yang dilindungi platform. Hapus state lokal setelah keluar.
* Jangan pernah menaruh token di URL atau log.

Lanjutkan dengan [quickstart JavaScript](https://docs.didit.me/id/authentication/quickstarts/javascript.md), [quickstart Next.js](https://docs.didit.me/id/authentication/quickstarts/nextjs.md) atau [referensi API Autentikasi](https://docs.didit.me/id/authentication/api-reference/get-openid-configuration.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.