State machine untuk kontrak API auth yang tahan retry adalah cara praktis untuk mengubah alur autentikasi yang biasanya implisit menjadi eksplisit, terukur, dan lebih aman saat menghadapi timeout, retry dari client, atau callback yang datang terlambat. Intinya bukan menambah teori, tetapi memastikan setiap aksi auth hanya boleh terjadi dari state tertentu, menghasilkan transisi yang jelas, dan punya respons yang stabil saat request yang sama dikirim ulang.
Masalah umum pada API auth biasanya bukan karena algoritma login atau OTP-nya salah, melainkan karena state tersembunyi di banyak tempat: database, cache, token store, worker queue, dan webhook handler. Akibatnya, login bisa diproses dua kali saat client timeout, OTP bisa tervalidasi lebih dari sekali, refresh token bisa balapan antar perangkat, dan revoke bisa datang terlambat setelah token baru sudah diterbitkan. Dengan lensa control structures, terutama ide bahwa alur sistem harus memiliki jalur eksekusi dan kelanjutan yang eksplisit, kita bisa memodelkan auth sebagai serangkaian state dan transisi yang dapat diuji.
Artikel ini memakai ide praktis dari pembahasan control structures: jangan biarkan kelanjutan proses auth tersebar sebagai efek samping implisit. Nyatakan state, transisi, dan hasil kontrak API secara eksplisit agar retry dan integrasi asynchronous tetap aman.
Mengapa alur auth sering rusak ketika state implisit
Pada implementasi yang terburu-buru, endpoint auth sering diperlakukan seperti fungsi stateless sederhana:
POST /loginmemeriksa password lalu mengirim OTP jika perlu.POST /verify-otpmemvalidasi kode lalu menerbitkan token.POST /refreshmenukar refresh token dengan access token baru.POST /revokemenghapus sesi.
Secara kasat mata itu cukup. Masalah muncul ketika realitas jaringan ikut bermain:
- Client mengirim request, server selesai memproses, tetapi respons hilang di jaringan.
- Client melakukan retry otomatis.
- Webhook dari provider OTP datang terlambat atau duplikat.
- Dua perangkat memakai refresh token yang sama hampir bersamaan.
- Job async untuk revoke tertunda, sementara token baru terlanjur dibuat.
Jika state tidak eksplisit, sistem akan menjawab pertanyaan penting dengan cara yang tidak konsisten:
- Apakah request ini sudah pernah diproses?
- Apakah OTP ini masih menunggu verifikasi atau sudah selesai?
- Apakah refresh token ini masih aktif atau sudah diputar?
- Apakah revoke ini final atau masih dalam proses?
- Jika callback datang dua kali, apakah hasilnya sama?
Di titik ini, masalahnya bukan sekadar error handling, melainkan kontrak sistem. Endpoint auth perlu diperlakukan sebagai mesin status, bukan rangkaian if-else yang berdiri sendiri.
Model state machine untuk kontrak API auth
State machine membantu kita mendefinisikan:
- State: kondisi sah dari sebuah proses auth.
- Transition: perpindahan state yang diizinkan.
- Event: aksi atau input yang memicu transisi.
- Invariant: aturan yang harus selalu benar.
Untuk auth, jangan pakai satu state global yang terlalu besar. Lebih aman jika memisahkan beberapa mesin yang saling terkait:
- Authentication Attempt untuk login dan challenge OTP.
- Token Session untuk access/refresh token.
- Revocation Request untuk pencabutan sesi.
- Delivery/Callback State untuk status webhook atau callback provider.
Contoh state untuk login berbasis OTP
States (Authentication Attempt):
- INITIATED
- CREDENTIALS_VERIFIED
- CHALLENGE_DISPATCHING
- CHALLENGE_SENT
- VERIFIED
- TOKEN_ISSUED
- FAILED
- EXPIRED
- CANCELLED
Events:
- submit_credentials
- credentials_valid
- credentials_invalid
- dispatch_otp
- otp_sent
- otp_delivery_failed
- verify_otp
- otp_valid
- otp_invalid
- issue_token
- token_issued
- timeout
- cancelTransisi yang masuk akal:
INITIATED -> CREDENTIALS_VERIFIEDsaat password benar.CREDENTIALS_VERIFIED -> CHALLENGE_DISPATCHINGsaat OTP akan dikirim.CHALLENGE_DISPATCHING -> CHALLENGE_SENTsaat dispatch diterima sistem internal atau provider.CHALLENGE_SENT -> VERIFIEDsaat OTP valid.VERIFIED -> TOKEN_ISSUEDsaat token berhasil dibuat.- State mana pun yang belum final dapat berpindah ke
FAILED,EXPIRED, atauCANCELLEDsesuai aturan.
Yang penting: setiap endpoint harus tahu state apa yang boleh diproses. Misalnya, endpoint verifikasi OTP tidak boleh diam-diam membuat attempt baru jika ID attempt tidak ditemukan. Ia hanya boleh memproses attempt dengan state CHALLENGE_SENT atau state retry yang eksplisit.
Contoh state untuk refresh token
States (Refresh Token Session):
- ACTIVE
- ROTATING
- ROTATED
- REVOKING
- REVOKED
- EXPIRED
- COMPROMISEDRefresh token sering gagal karena implementasi tidak memodelkan rotasi sebagai transisi atomik. Jika dua request refresh masuk hampir bersamaan, keduanya bisa lolos validasi. Dengan state machine:
ACTIVE -> ROTATINGsaat refresh mulai diproses.ROTATING -> ROTATEDsaat token baru sudah diterbitkan dan token lama dinonaktifkan.ACTIVE/ROTATING -> REVOKING -> REVOKEDsaat pencabutan terjadi.
Jika request kedua datang saat state sudah ROTATING atau ROTATED, server bisa memberi respons stabil: entah mengembalikan hasil yang sama untuk idempotency yang sama, atau menolak dengan kode error yang konsisten.
Desain contract API: endpoint, state, dan respons yang stabil
Kontrak API auth yang tahan retry harus menyatakan tiga hal dengan jelas:
- Status bisnis, bukan hanya HTTP status.
- Identity operasi melalui idempotency key atau operation id.
- State yang terobservasi client, agar integrator tahu apa yang harus dilakukan berikutnya.
Contoh: memulai login
POST /auth/login
Idempotency-Key: 9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911
Content-Type: application/json
{
"username": "[email protected]",
"password": "secret-password",
"client": {
"device_id": "device-123",
"ip": "203.0.113.10"
}
}Contoh respons bila password valid dan OTP dibutuhkan:
{
"attempt_id": "authatt_01HXYZ...",
"state": "CHALLENGE_SENT",
"next_action": "VERIFY_OTP",
"challenge": {
"type": "otp_sms",
"masked_destination": "+62******1234",
"expires_at": "2026-08-19T10:15:00Z"
},
"idempotency": {
"key": "9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911",
"replayed": false
}
}Jika request yang sama diulang karena timeout, server sebaiknya mengembalikan hasil yang sama untuk operasi yang sama:
{
"attempt_id": "authatt_01HXYZ...",
"state": "CHALLENGE_SENT",
"next_action": "VERIFY_OTP",
"challenge": {
"type": "otp_sms",
"masked_destination": "+62******1234",
"expires_at": "2026-08-19T10:15:00Z"
},
"idempotency": {
"key": "9c5e9f77-6b22-4f65-a0c4-2e0e8c28f911",
"replayed": true
}
}Perhatikan bahwa respons replay tidak perlu identik byte-per-byte, tetapi makna bisnisnya harus identik. Jangan kirim OTP baru hanya karena request diulang dengan idempotency key yang sama.
Contoh: verifikasi OTP
POST /auth/attempts/authatt_01HXYZ.../verify-otp
Idempotency-Key: 51d2d1ee-7130-4ac1-93cc-d70a9d616af4
Content-Type: application/json
{
"otp_code": "481209"
}Contoh respons sukses:
{
"attempt_id": "authatt_01HXYZ...",
"state": "TOKEN_ISSUED",
"next_action": "NONE",
"tokens": {
"access_token": "access-token-value",
"refresh_token": "refresh-token-value",
"token_type": "Bearer",
"expires_in": 3600
},
"session": {
"session_id": "sess_01ABC..."
}
}Jika OTP yang sama diverifikasi ulang dengan key yang sama karena client tidak menerima respons, server sebaiknya mengembalikan hasil yang sama. Jika request baru datang dengan key berbeda setelah attempt sudah final, server sebaiknya menolak secara eksplisit:
{
"error": {
"code": "AUTH_ATTEMPT_FINALIZED",
"message": "Authentication attempt is already finalized.",
"retryable": false,
"current_state": "TOKEN_ISSUED"
}
}Contoh: refresh token yang aman terhadap balapan
POST /auth/refresh
Idempotency-Key: 1de8ab9d-1cf8-4d2c-b8d6-c853ad0ce9b2
Content-Type: application/json
{
"refresh_token": "refresh-token-value"
}Contoh respons:
{
"state": "ROTATED",
"tokens": {
"access_token": "new-access-token",
"refresh_token": "new-refresh-token",
"token_type": "Bearer",
"expires_in": 3600
},
"rotation": {
"previous_session_id": "sess_old",
"new_session_id": "sess_new"
}
}Kalau request refresh yang sama diproses dua kali, sistem perlu aturan yang jelas:
- Untuk idempotency key yang sama, hasil harus sama.
- Untuk request bersamaan dengan key berbeda tetapi memakai refresh token yang sama, hanya satu yang boleh menang.
- Request yang kalah harus menerima kode stabil seperti
REFRESH_TOKEN_ALREADY_USEDatauSESSION_ROTATION_CONFLICT.
Invariant yang wajib dijaga
State machine tanpa invariant akan tetap rapuh. Tabel berikut merangkum aturan minimum yang perlu dipertahankan.
| Area | Invariant | Alasan |
|---|---|---|
| Authentication Attempt | Satu attempt hanya bisa final satu kali | Mencegah token diterbitkan ganda |
| OTP Verification | OTP valid hanya bisa mengubah state dari CHALLENGE_SENT ke VERIFIED sekali | Mencegah verifikasi berulang |
| Token Issuance | Setiap attempt final menghasilkan paling banyak satu sesi aktif | Mencegah duplikasi sesi saat retry |
| Refresh Rotation | Satu refresh token hanya boleh dipakai untuk satu rotasi sukses | Mencegah replay dan race condition |
| Revoke | Revoke bersifat monotonic: REVOKED tidak boleh kembali ACTIVE | Mencegah sesi hidup kembali |
| Idempotency | Key yang sama dengan payload semantik yang sama harus mengembalikan hasil bisnis yang sama | Membuat retry aman |
| Webhook Callback | Event provider diproses paling banyak sekali secara semantik | Mencegah update status ganda |
Secara implementasi, invariant ini biasanya dijaga dengan kombinasi:
- unique constraint di database,
- conditional update berdasarkan state saat ini,
- transaction,
- row locking bila perlu,
- outbox/inbox table untuk event asynchronous.
Idempotency key: desain yang benar untuk auth
Idempotency pada auth tidak cukup dengan menyimpan key lalu mengembalikan respons lama secara buta. Anda perlu mendefinisikan ruang lingkup dan identitas operasi.
Apa yang harus menjadi ruang lingkup key
Idempotency key sebaiknya terkait dengan:
- endpoint atau jenis operasi,
- identitas principal atau attempt,
- payload yang relevan secara semantik.
Contoh:
POST /auth/login: key unik per upaya login dari client.POST /auth/attempts/{id}/verify-otp: key unik per attempt verifikasi.POST /auth/refresh: key unik per permintaan rotasi refresh token.POST /auth/revoke: key unik per operasi revoke.
Jangan izinkan satu key dipakai lintas endpoint. Jangan juga menganggap key yang sama valid jika payload semantiknya berbeda, misalnya OTP yang berbeda.
Data yang perlu disimpan
idempotency_records
- key
- operation_type
- scope_id
- request_hash
- status -- IN_PROGRESS | COMPLETED | FAILED_REPLAYABLE
- response_code
- response_body
- resource_id
- created_at
- expires_atrequest_hash berguna untuk mendeteksi jika client memakai key yang sama tetapi isi request berbeda. Respons yang aman:
{
"error": {
"code": "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD",
"message": "The same idempotency key cannot be reused with a different payload.",
"retryable": false
}
}Status IN_PROGRESS saat timeout
Kasus sulit adalah ketika request pertama sedang diproses tetapi client sudah timeout lalu retry. Jika record idempotency masih IN_PROGRESS, ada dua pilihan:
- Kembalikan 409/202 dengan status operasi, lalu client melakukan polling.
- Tahan sebentar sampai hasil final tersedia, jika waktu proses singkat dan aman.
Untuk auth yang melibatkan OTP atau provider eksternal, pendekatan polling biasanya lebih stabil. Misalnya:
{
"operation_id": "op_01JKL...",
"state": "IN_PROGRESS",
"next_action": "POLL_OPERATION_STATUS"
}Ini lebih baik daripada memproses ulang request dari awal.
Webhook/callback auth: sumber edge case yang paling sering diabaikan
Banyak sistem auth modern bergantung pada pihak ketiga: provider OTP, push approval, magic link, atau identity provider enterprise. Begitu callback/webhook masuk, Anda memasuki dunia at-least-once delivery: event bisa terlambat, duplikat, atau tidak berurutan.
Masalah umum pada callback auth
- Provider mengirim
deliveredsetelah user sudah berhasil login. - Provider mengirim
failedlaludelivereduntuk message yang sama. - Webhook dikirim dua kali karena retry provider.
- Callback datang setelah attempt kadaluarsa.
Karena itu, jangan biarkan webhook mengubah state sembarangan. Webhook harus diproses seperti event pada state machine.
Contoh aturan transisi callback
Delivery States:
- PENDING
- SENT
- DELIVERED
- FAILED
- EXPIRED
Allowed transitions:
PENDING -> SENT
SENT -> DELIVERED
SENT -> FAILED
PENDING -> FAILED
PENDING -> EXPIRED
SENT -> EXPIRED
Disallowed:
DELIVERED -> FAILED
FAILED -> DELIVERED
EXPIRED -> SENTJika provider mengirim event yang bertentangan dengan transisi yang diizinkan, jangan paksa state mundur. Simpan event-nya untuk audit, tandai sebagai ignored due to invalid transition, lalu biarkan state akhir tetap konsisten.
Inbox pattern untuk webhook
Untuk mencegah proses ganda:
- Verifikasi signature webhook.
- Simpan event mentah ke inbox table dengan
provider_event_idunik. - Jika event sudah pernah ada, akui sebagai duplikat dan hentikan.
- Proses event dalam transaction terpisah.
- Gunakan conditional update berbasis state saat ini.
Ini jauh lebih aman daripada langsung memperbarui tabel auth dari handler HTTP webhook.
Pseudocode transisi yang aman
Verifikasi OTP dengan idempotency dan state check
function verifyOtp(attemptId, otpCode, idemKey):
existing = findIdempotency("VERIFY_OTP", attemptId, idemKey)
if existing exists:
return existing.response
begin transaction
createIdempotencyInProgress("VERIFY_OTP", attemptId, idemKey, hash(otpCode))
attempt = select attempt for update where id = attemptId
if attempt not found:
failIdempotency("AUTH_ATTEMPT_NOT_FOUND")
rollback
return error
if attempt.state == "TOKEN_ISSUED":
failIdempotency("AUTH_ATTEMPT_FINALIZED")
rollback
return error
if attempt.state != "CHALLENGE_SENT":
failIdempotency("INVALID_AUTH_STATE")
rollback
return error
if attempt.expires_at < now():
update attempt set state = "EXPIRED"
failIdempotency("OTP_EXPIRED")
commit
return error
if !isValidOtp(attempt, otpCode):
recordFailedOtpAttempt(attempt)
failIdempotency("OTP_INVALID")
commit
return error
update attempt set state = "VERIFIED"
session = issueSessionAndTokens(attempt.user_id)
update attempt set state = "TOKEN_ISSUED", session_id = session.id
response = successResponse(session)
completeIdempotency(response)
commit
return responsePoin pentingnya:
- Selalu cek idempotency sebelum memproses.
- Kunci resource utama saat transisi penting.
- Gunakan update berbasis state saat ini, bukan overwrite buta.
- Simpan hasil final agar retry bisa mengembalikan jawaban yang sama.
Refresh token dengan rotasi atomik
function refreshToken(refreshToken, idemKey):
existing = findIdempotency("REFRESH", tokenFingerprint(refreshToken), idemKey)
if existing exists:
return existing.response
begin transaction
createIdempotencyInProgress("REFRESH", tokenFingerprint(refreshToken), idemKey)
token = select refresh_session for update by token fingerprint
if token not found:
failIdempotency("REFRESH_TOKEN_INVALID")
rollback
return error
if token.state != "ACTIVE":
failIdempotency("REFRESH_TOKEN_ALREADY_USED")
commit
return error
update token set state = "ROTATING"
newSession = createNewSession(token.user_id)
update token set state = "ROTATED", rotated_to = newSession.id
response = issueNewTokens(newSession)
completeIdempotency(response)
commit
return responseJika revoke bisa berjalan paralel, tambahkan pengecekan state REVOKING atau REVOKED sebelum rotasi final diselesaikan.
Error code yang stabil dan ramah integrasi
Salah satu kesalahan umum adalah bergantung pada pesan teks yang berubah-ubah. Untuk kontrak API auth, gunakan error code yang stabil dan pisahkan dari message yang bisa lebih manusiawi.
Prinsip error code
- Stabil lintas perubahan wording.
- Spesifik cukup untuk automation.
- Tidak membocorkan informasi sensitif.
- Memiliki petunjuk apakah aman untuk retry.
Contoh error code yang berguna
{
"error": {
"code": "OTP_INVALID",
"message": "The provided OTP code is invalid.",
"retryable": true,
"current_state": "CHALLENGE_SENT"
}
}Daftar kode yang umum dipakai:
AUTH_INVALID_CREDENTIALSAUTH_ATTEMPT_NOT_FOUNDINVALID_AUTH_STATEOTP_INVALIDOTP_EXPIREDAUTH_ATTEMPT_FINALIZEDIDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOADREFRESH_TOKEN_INVALIDREFRESH_TOKEN_ALREADY_USEDSESSION_REVOKEDREVOCATION_IN_PROGRESSWEBHOOK_EVENT_DUPLICATEWEBHOOK_EVENT_OUT_OF_ORDER
Untuk HTTP status, pilih yang konservatif dan mudah dipahami integrator. Jangan memindahkan makna bisnis penting hanya ke HTTP status. Sebagian besar integrator akan lebih bergantung pada body JSON yang stabil.
Mencegah aksi ganda saat client timeout atau webhook terlambat
Saat client timeout
Client timeout tidak berarti operasi gagal. Ini prinsip dasar yang sering dilanggar. Agar aman:
- Setiap operasi mutasi auth harus punya idempotency key.
- Server menyimpan hasil final operasi.
- Jika operasi masih berjalan, sediakan endpoint status.
- Client harus retry dengan key yang sama, bukan membuat request baru tanpa jejak.
Contoh endpoint status:
GET /auth/operations/op_01JKL...
{
"operation_id": "op_01JKL...",
"state": "COMPLETED",
"result": {
"attempt_id": "authatt_01HXYZ...",
"state": "CHALLENGE_SENT",
"next_action": "VERIFY_OTP"
}
}Saat webhook terlambat
Webhook terlambat tidak boleh membangkitkan kembali proses yang sudah final. Strateginya:
- Beri setiap auth attempt dan delivery event waktu kadaluarsa yang jelas.
- Webhook hanya boleh memengaruhi state yang masih relevan.
- Event terlambat tetap disimpan untuk audit, tetapi diabaikan sebagai transisi bisnis.
- Jika callback penting datang setelah timeout, client harus memulai attempt baru, bukan memaksa state lama hidup kembali.
Saat revoke dan refresh balapan
Ini edge case yang sering memicu bug keamanan. Misalnya satu request ingin refresh, request lain ingin revoke. Solusinya:
- Gunakan lock pada record sesi atau refresh token.
- Pastikan revoke bersifat monotonic dan final.
- Definisikan prioritas: biasanya revoke menang atas refresh yang belum final.
- Jangan terbitkan token baru jika state sudah masuk
REVOKINGatauREVOKED.
Trade-off dan keterbatasan pendekatan state machine
Pendekatan ini tidak gratis. Ada biaya desain dan operasional:
- Lebih banyak tabel dan metadata: attempt, session, idempotency, inbox/outbox.
- Lebih banyak transisi yang harus diuji: terutama edge case balapan.
- Perlu disiplin kontrak: tim tidak boleh diam-diam menambah shortcut di endpoint.
- Perlu observabilitas: log transisi, correlation id, dan audit event.
Namun trade-off ini layak untuk auth, karena area ini sangat sensitif terhadap keamanan, integritas sesi, dan pengalaman integrator.
Kesalahan implementasi yang paling sering terjadi
- Menganggap retry sebagai request baru, bukan replay operasi lama.
- Menerbitkan token sebelum state attempt ditandai final.
- Menyimpan idempotency key tanpa request hash.
- Membiarkan webhook langsung meng-overwrite status.
- Menggunakan pesan error teks sebagai kontrak integrasi.
- Menghapus record sementara terlalu cepat sehingga replay tidak bisa dijawab konsisten.
- Tidak memisahkan state attempt auth dari state sesi token.
Debugging dan observabilitas
Jika ingin sistem auth berbasis state machine bisa dioperasikan dengan baik, siapkan data observabilitas sejak awal:
- correlation_id per request,
- idempotency_key di log,
- attempt_id, session_id, dan operation_id,
- log transisi: state lama, event, state baru,
- alasan penolakan transisi,
- jejak webhook: provider event id, waktu diterima, waktu diproses.
Untuk debugging race condition, event log transisi sering lebih berguna daripada log aplikasi umum. Anda ingin bisa menjawab: request mana yang lebih dulu, state apa yang dibaca, dan transisi mana yang ditolak.
Checklist implementasi
- Definisikan state eksplisit untuk login attempt, token session, revoke, dan callback delivery.
- Tentukan transisi yang diizinkan dan yang dilarang.
- Dokumentasikan invariant yang wajib dijaga.
- Tambahkan idempotency key pada semua endpoint mutasi auth.
- Simpan request hash untuk mendeteksi reuse key dengan payload berbeda.
- Simpan hasil final operasi agar replay bisa dijawab konsisten.
- Gunakan transaction dan conditional update pada transisi penting.
- Lindungi refresh token rotation dari balapan dengan locking atau CAS-style update.
- Pastikan revoke bersifat final dan monotonic.
- Proses webhook dengan inbox pattern dan deduplikasi event.
- Sediakan error code stabil dengan atribut
retryabledancurrent_state. - Sediakan endpoint status operasi jika ada proses async atau timeout panjang.
- Uji edge case: retry setelah timeout, OTP diverifikasi dua kali, refresh paralel, revoke vs refresh, webhook duplikat, webhook terlambat.
- Tambahkan audit log transisi dan correlation id.
Penutup
State machine untuk kontrak API auth yang tahan retry bukan sekadar pola desain yang rapi. Ini adalah cara untuk memastikan login, refresh token, verifikasi OTP, dan revoke tetap benar ketika jaringan gagal, client mengulang request, atau webhook datang tidak ideal. Dengan state yang eksplisit, transisi yang terbatas, invariant yang jelas, idempotency key yang dirancang baik, dan error code yang stabil, kontrak API auth menjadi jauh lebih mudah diintegrasikan dan lebih sulit rusak oleh edge case dunia nyata.
Jika Anda sedang memperbaiki API auth yang sering menghasilkan sesi ganda, OTP ganda, atau perilaku aneh setelah timeout, biasanya solusi utamanya bukan menambah retry logic di client. Solusinya adalah membuat alur auth menjadi eksplisit sebagai state machine, lalu menjadikan retry sebagai bagian resmi dari kontrak sistem.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!