@starasia/sso-api
v3.6.0
Published
Validasi access token SSO Starasia (OIDC resource server), token server-ke-server, login halaman sendiri, dan BFF untuk backend Node.js/Express
Readme
@starasia/sso-api
Validasi access token SSO Starasia untuk backend Node.js/Express (OAuth 2.0 resource server).
Untuk aplikasi yang login memakai @starasia/sso (OIDC authorization code + PKCE).
Backend tidak menukar token atau menyimpan client secret aplikasi FE — cukup memvalidasi
access token yang dikirim frontend.
Sejak 3.1.0 juga untuk server-ke-server: createServiceClient mengambilkan token layanan
(client_credentials) untuk memanggil API layanan lain, dan requireAuth({ services })
membatasi endpoint hanya untuk layanan tertentu. Lihat Server-ke-server.
Sejak 3.2.0 juga untuk aplikasi internal yang punya halaman login sendiri:
createPasswordLoginClient dan createLoginRouter. Lihat Halaman login sendiri.
Sejak 3.6.0 juga untuk pola BFF: login tetap lewat halaman SSO, tetapi backend yang menukar
kode login dan menyimpan token di cookie terenkripsi, jadi browser tidak memegang token:
createBff. Lihat BFF.
v3 menghapus API lama (
config(),auth(),AUTH_TYPE, rute/auth/*) dan memperketat validasi token. Lihat CHANGELOG untuk daftar breaking change dan langkah migrasi.
npm install @starasia/sso-api@^3import express from 'express'
import { createResourceServer, errorHandler, RequestWithAuth } from '@starasia/sso-api'
const sso = createResourceServer({
issuer: 'https://sso.starasia.tech', // = `iss` token (issuer di discovery)
audiences: ['APP-xxxxxxxx', 'APP-yyyyyyyy'], // client ID app FE yang boleh memanggil backend ini
})
const app = express()
app.use('/api', sso.requireAuth()) // wajib login
app.get('/api/me', (req, res) => res.json((req as RequestWithAuth).auth))
app.get('/api/me/email', sso.requireAuth({ scopes: ['email'] }), handler) // wajib scope OAuth
app.use(errorHandler)Contoh lengkap: examples/resource-server.ts.
Yang diverifikasi di setiap request
- Signature RS256 dengan kunci dari JWKS (
<issuer>/.well-known/jwks.json), dipilih berdasarkankid. Bila JWK menyebutalg, nilainya wajib sama denganalgheader token (RFC 8725 §3.1).kidbaru (rotasi kunci) memicu pengambilan ulang JWKS, dibatasi sekali per 30 detik. JWKS di-cache 10 menit (jwksCacheTtl). - Header
typ = at+jwt(RFC 9068) — selalu dicek, tidak bisa dimatikan. ID token (typ: JWT) yang dikirim sebagai bearer ditolak. - Klaim wajib access token:
sub(string tidak kosong),client_id,exp,iat. Token tanpaclient_id(mis. ID token) ditolak walau semua pengecekan lain lolos. iss=issuer,aud∈audiences,exp/nbf(toleransi 5 detik).- Opsional: status token ke
/introspect(lihat Introspeksi).
req.auth (AuthContext): sub, clientId, isService (token layanan: sub = clientId),
scopes, jti, issuedAt, expiresAt, authTime, sid (ID sesi SSO), claims, token.
Scope ≠ role user
requireAuth({ scopes }) mengecek scope OAuth yang didelegasikan user ke aplikasi (client),
bukan role atau izin user. SSO hanya pernah memberikan openid, profile, email, dan
offline_access; scope lain yang diminta (mis. admin) dibuang oleh SSO, sehingga
requireAuth({ scopes: ['admin'] }) tidak pernah terpenuhi — dan seandainya bisa pun,
scope tidak menyatakan hak akses user.
Otorisasi user (admin, owner, anggota organisasi, dst.) wajib dicek di backend dari data
entitlement server-side berdasarkan req.auth.sub:
import { AppError, ERROR_MESSAGES, HTTP_STATUS } from '@starasia/sso-api'
app.delete('/api/items/:id', sso.requireAuth(), async (req, res, next) => {
const { sub } = (req as RequestWithAuth).auth
if (!(await entitlements.isAdmin(sub))) {
return next(new AppError(HTTP_STATUS.FORBIDDEN, ERROR_MESSAGES.ACCESS_DENIED))
}
// ...
})Token belum membawa klaim organisasi/role — cek keanggotaan organisasi di backend (mis. dari header
x-organization-id) sendiri.
Audience: client ID atau identifier API (RFC 8707)
Secara default aud access token = client ID app FE, sehingga backend mendaftar client ID
setiap app FE yang boleh memanggilnya di audiences.
Untuk API yang dipakai beberapa app, gunakan resource indicator (RFC 8707) — token
diterbitkan khusus untuk API Anda dengan aud = identifier API:
Admin dashboard: daftarkan identifier API (URI absolut, mis.
https://api.example.com/hris) di "Allowed Resources" setiap client FE yang boleh memanggil API ini.Frontend (
@starasia/sso): kirimresourceke/authorizelewat opsiresource:setupSSO({ authority: 'https://sso.starasia.tech', clientId: 'APP-xxxxxxxx', resource: 'https://api.example.com/hris', })(
extraQueryParams: { resource: 'https://api.example.com/hris' }juga tetap bisa dipakai.)resourceyang tidak ada di allowlist client ditolak SSO (error=invalid_target, login gagal) — tidak jatuh balik keaud= client ID. Tanparesource,audtetap client ID.Backend: set
audienceske identifier yang sama persis dengan yang tampil di admin dashboard —auddicocokkan apa adanya. SSO menyimpan identifier dalam bentuk baku: origin tanpa path mendapat garis miring di akhir (https://api.example.com→https://api.example.com/), jadi pakai identifier ber-path seperti contoh ini atau tulis lengkap dengan/.const sso = createResourceServer({ issuer: 'https://sso.starasia.tech', audiences: ['https://api.example.com/hris'], })req.auth.clientIdtetap berisi app FE pemohon (client_id).
Introspeksi (opsional)
JWT bersifat mandiri: tanpa introspeksi, token yang sudah diterbitkan tetap diterima sampai
exp. Dengan introspection aktif, backend menanyakan status token ke POST <issuer>/introspect
(RFC 7662) dan menolak token dalam jendela cache (default 30 detik) setelah:
- user logout (sesi SSO diakhiri),
- reset password atau user dinonaktifkan (semua token user dicabut),
- user dihapus / tidak aktif.
Introspeksi wajib confidential client: daftarkan client dengan secret di admin dashboard,
lalu isi clientId + clientSecret (dikirim sebagai client_secret_basic). Tanpa keduanya
createResourceServer langsung melempar error.
const sso = createResourceServer({
issuer: 'https://sso.starasia.tech',
audiences: ['APP-xxxxxxxx'],
introspection: {
clientId: process.env.SSO_INTROSPECTION_CLIENT_ID!,
clientSecret: process.env.SSO_INTROSPECTION_CLIENT_SECRET!,
// cacheTtl: 30, // detik
},
})Respons introspeksi divalidasi: active === true, token_type (bila ada) = access_token,
sub sama dengan sub JWT, client_id (bila ada) sama dengan client_id JWT. Hasil aktif
maupun tidak aktif di-cache per token; error tidak di-cache. Request serentak untuk token yang
sama digabung jadi satu.
Rate limit SSO: 1200 request/menit per client. Saat SSO membalas 429/503 dengan Retry-After,
selama jendela itu introspeksi langsung gagal 503 (dengan header Retry-After) tanpa request
ke SSO; token yang hasilnya masih ter-cache tetap dilayani.
Server-ke-server
Untuk layanan yang memanggil API layanan lain tanpa user (job terjadwal, sinkronisasi,
antar-backend) — pengganti kunci statis bersama (x-guard-key, x-admin-key). Butuh sso-api
yang mendukung grant client_credentials.
Persiapan (admin dashboard): daftarkan layanan pemanggil sebagai Confidential client, isi Allowed Resources dengan identifier API tujuan, simpan Client Secret (muncul sekali) di secret manager layanan pemanggil.
Layanan pemanggil (A) — token diambil, di-cache sampai mendekati kedaluwarsa, dan request bersamaan digabung jadi satu request ke SSO:
import { createServiceClient } from '@starasia/sso-api'
const hris = createServiceClient({
issuer: 'https://sso.starasia.tech',
clientId: process.env.SSO_CLIENT_ID!,
clientSecret: process.env.SSO_CLIENT_SECRET!, // dari secret manager
resource: 'https://api.starasia.tech/hris', // identifier API tujuan (Allowed Resources)
})
// fetch global + Authorization: Bearer; 401 dari tujuan → token baru, coba sekali lagi
const res = await hris.fetch(`${HRIS_URL}/internal/employees`)
// HTTP client lain (axios, dll.)
await axios.get(url, { headers: { Authorization: await hris.authorizationHeader() } })| Method | Keterangan |
|---|---|
| getToken() | Access token layanan (cache; diperbarui refreshSkew detik sebelum kedaluwarsa) |
| authorizationHeader() | Bearer <token> |
| fetch(input, init?) | fetch global dengan token; 401 → ambil token baru & coba sekali lagi (body stream tidak dicoba ulang) |
| invalidate(token?) | Buang token dari cache |
Opsi: issuer, clientId, clientSecret, resource (wajib); tokenEndpoint
(default ${issuer}/token), refreshSkew (default 60 detik), timeoutMs (default 5000).
Error: SSO menolak (secret salah, client nonaktif, resource tidak diizinkan) → AppError 500
SSO rejected the service token request (<kode OAuth>); SSO tidak tersedia / 429 / 503 →
503 SSO unavailable (Retry-After dihormati, selama jendela itu tanpa request ke SSO).
Layanan tujuan (B) — audiences = identifier-nya sendiri; services = client ID layanan
yang boleh memanggil endpoint ini (token user dan layanan lain → 403):
const sso = createResourceServer({
issuer: 'https://sso.starasia.tech',
audiences: ['https://api.starasia.tech/hris'],
})
app.get('/internal/employees', sso.requireAuth({ services: [process.env.PAYROLL_CLIENT_ID!] }), handler)Token layanan tidak membawa scope — otorisasinya berdasarkan layanan mana yang memanggil
(req.auth.clientId, req.auth.isService === true). Untuk endpoint yang menerima user maupun
layanan, pakai requireAuth() lalu periksa req.auth.isService sendiri.
BFF: login lewat halaman SSO, token di server
Pola Backend For Frontend: user login di halaman SSO (single sign-on tetap berjalan),
tetapi kode login dikirim ke backend aplikasi. Backend menukarnya dengan token memakai Client
Secret, lalu menyimpan token di cookie HttpOnly yang terenkripsi. JavaScript di browser
tidak pernah memegang token, jadi XSS tidak bisa mencurinya. Cocok bila frontend dan backend
satu origin (mis. Express yang juga menyajikan SPA, atau server-rendered).
Persiapan (admin dashboard): daftarkan sekali sebagai tipe Server:
| Isian | Contoh |
|---|---|
| Redirect URIs | https://app.example.com/auth/callback (path tempat router dipasang + /callback) |
| Post-Logout Redirect URIs | https://app.example.com/ |
Client Secret dan SESSION_SECRET (acak, ≥ 32 karakter, mis. openssl rand -base64 48)
disimpan di secret manager.
import express from 'express'
import { createBff, errorHandler, RequestWithAuth } from '@starasia/sso-api'
const bff = createBff({
issuer: 'https://sso.starasia.tech',
clientId: process.env.SSO_CLIENT_ID!,
clientSecret: process.env.SSO_CLIENT_SECRET!,
redirectUri: 'https://app.example.com/auth/callback',
sessionSecret: process.env.SESSION_SECRET!,
})
const app = express()
app.use('/auth', bff.router())
app.use('/api', bff.requireAuth()) // req.auth seperti createResourceServer
app.get('/api/me', (req, res) => res.json((req as RequestWithAuth).auth))
app.use(errorHandler)| Rute | Hasil |
|---|---|
| GET /auth/login?returnTo=/halaman | Redirect ke halaman login SSO (PKCE S256, state, nonce) |
| GET /auth/callback | Dari SSO: tukar kode, pasang cookie sesi, redirect ke returnTo (hanya path di aplikasi ini) |
| GET /auth/session | { user, expiresAt }, atau 401 bila belum login |
| POST /auth/logout | { redirectTo } — refresh token dicabut, cookie dihapus; arahkan browser ke redirectTo (end_session SSO) agar sesi SSO ikut berakhir |
Di frontend cukup fetch biasa — cookie ikut otomatis, tanpa header Authorization:
const res = await fetch('/api/me')
if (res.status === 401) location.assign(`/auth/login?returnTo=${encodeURIComponent(location.pathname)}`)
// Logout
const { data } = await (await fetch('/auth/logout', { method: 'POST' })).json()
location.assign(data.redirectTo)Yang diurus createBff:
- Cookie sesi
sa_session; Path=/; HttpOnly; Secure; SameSite=Lax, isinya access token, refresh token, dan profil, dienkripsi AES-256-GCM dengansessionSecret.sessionSecretberupa array untuk rotasi kunci (yang pertama dipakai mengenkripsi). - Perpanjangan otomatis: access token yang tersisa < 60 detik diperpanjang dengan refresh token saat request masuk, dan cookie baru dikirim. Request bersamaan memakai satu perpanjangan. Sesi yang sudah berakhir di SSO (logout, reset password, nonaktif) → 401 dan cookie dihapus.
- CSRF: request POST/PUT/PATCH/DELETE dari origin lain ditolak 403 (
Sec-Fetch-Site, laluOrigin). Frontend di origin lain yang sah →trustedOrigins. - Verifikasi token sama dengan
createResourceServer(aud=clientId, atauresourcebila diisi).introspection: truemembuat token yang dicabut langsung ditolak, tanpa menunggu access token kedaluwarsa.
Memanggil API lain atas nama user: const token = await bff.getAccessToken(req, res).
| Opsi | Default | Keterangan |
|---|---|---|
| issuer, clientId, clientSecret, redirectUri, sessionSecret | — (wajib) | |
| postLogoutRedirectUri | origin redirectUri + / | Harus terdaftar di Post-Logout Redirect URIs |
| scope | openid profile email offline_access | |
| resource | — | Identifier API (RFC 8707) → aud access token |
| audiences | [resource ?? clientId] | aud yang diterima requireAuth |
| introspection | false | true / { cacheTtl } — cek pencabutan token ke SSO |
| trustedOrigins | — | Origin lain yang boleh mengirim request pengubah data |
| cookie | { name: 'sa_session', secure: true, sameSite: 'Lax', maxAgeSec: 2592000 } | secure: false hanya untuk lokal tanpa HTTPS |
| onLoginError | halaman HTML sederhana | (error, req, res) — BffLoginError.code: login_cancelled, login_expired, login_failed |
| timeoutMs | 5000 | |
Satu aplikasi BFF = satu cookie sesi per host. Aplikasi lain di host yang sama (beda port atau path) harus memakai
cookie.nameyang berbeda.
Halaman login sendiri
Untuk aplikasi internal yang tidak mau redirect ke halaman login SSO: backend aplikasi
mengirim username (email atau NRP) + password user ke SSO (grant password) dan menerima
token biasa. Butuh sso-api yang mendukung grant ini; panduan lengkap di sso-api
docs/panduan-login-halaman-sendiri.md.
Bukan single sign-on. Akun dan
subsama dengan aplikasi lain, tetapi user yang sudah login di aplikasi lain tetap harus mengetik password di aplikasi ini (dan sebaliknya). Reset password, penonaktifan, dan force logout admin tetap mengeluarkan user dari aplikasi ini.
Persiapan (admin dashboard): aplikasi Confidential client, superadmin menyalakan
Login dengan halaman sendiri (allowPasswordGrant), opsional IP server di
passwordGrantIps. Client Secret di secret manager.
Cara cepat — router siap pakai (pola BFF): refresh token disimpan di cookie HttpOnly
milik aplikasi, frontend hanya menerima access token (simpan di memori).
import express from 'express'
import { createPasswordLoginClient, createLoginRouter } from '@starasia/sso-api'
const sso = createPasswordLoginClient({
issuer: 'https://sso.starasia.tech',
clientId: process.env.SSO_CLIENT_ID!,
clientSecret: process.env.SSO_CLIENT_SECRET!, // dari secret manager
})
const app = express()
app.set('trust proxy', 1) // agar req.ip = IP user di belakang proxy (untuk audit SSO)
app.use(express.json())
app.use('/auth', createLoginRouter(sso))| Rute | Body (JSON) | Hasil |
|---|---|---|
| POST /auth/login | { username, password } | { accessToken, expiresIn, user } + cookie refresh token |
| POST /auth/refresh | — | { accessToken, expiresIn, user } + cookie baru |
| POST /auth/logout | — | 204, token dicabut di SSO, cookie dihapus |
| POST /auth/activate-nrp | { nrp, email, password, name? } | 204 — aktivasi akun NRP (password = NRP) |
| POST /auth/resend-verification | { email } | 204 |
| POST /auth/forgot-password | { email } | 204 |
| POST /auth/reset-password | { token, password } | 204 — token = resetToken dari error login, link email, atau /verify-email |
| POST /auth/verify-email | { token } | { resetToken? } — token dari link email; resetToken ada → user harus set password baru |
Semua rute wajib Content-Type: application/json (pertahanan CSRF) dan membalas
Cache-Control: no-store. Cookie default sa_refresh; Path=<mount>; HttpOnly; Secure;
SameSite=Strict; Max-Age=30 hari — atur lewat createLoginRouter(sso, { cookie: { name,
secure, sameSite, path, domain, maxAgeSec }, endUserIp, refreshTokenIn }). Rute lain diteruskan
ke next().
Aplikasi native (mobile/desktop) — form login native, backend aplikasi tetap memegang Client Secret. Aplikasi native tidak cocok dengan cookie, jadi refresh token lewat body JSON dan disimpan aplikasi di Keychain/Keystore (sejak 3.4.0):
app.use('/auth', createLoginRouter(sso)) // frontend web: cookie
app.use('/mobile/auth', createLoginRouter(sso, { refreshTokenIn: 'body' })) // aplikasi native| Rute (refreshTokenIn: 'body') | Body (JSON) | Hasil |
|---|---|---|
| POST /mobile/auth/login | { username, password } | { accessToken, expiresIn, refreshToken, user } |
| POST /mobile/auth/refresh | { refreshToken } | { accessToken, expiresIn, refreshToken, user } — simpan refreshToken yang baru |
| POST /mobile/auth/logout | { refreshToken } | 204, token dicabut di SSO |
Rute pendukung dan format error sama. Tidak ada Set-Cookie; opsi cookie diabaikan. Jangan
pakai mode ini untuk frontend web — refresh token jadi terbaca JavaScript.
Refresh bersamaan dengan refresh token yang sama (mis. beberapa tab) digabung jadi satu request ke SSO.
IP user (endUserIp, default req.ip) ikut ke SSO di login dan endpoint pendukung, sehingga
rate limit SSO menghitung per user, bukan per IP server aplikasi.
Halaman reset password & verifikasi email sendiri — isi URL Halaman Reset Password dan
URL Halaman Verifikasi Email aplikasi di Admin Dashboard (butuh sso-api yang mendukungnya).
Email yang diminta lewat klien/router ini lalu berisi link ke halaman aplikasi dengan ?token=:
| Halaman aplikasi | Yang dilakukan |
|---|---|
| Reset password (?token=) | Form password baru → POST /auth/reset-password { token, password } |
| Verifikasi email (?token=) | POST /auth/verify-email { token }; ada resetToken → form password baru → /auth/reset-password dengan resetToken; tidak ada → user login |
Email yang diminta tanpa kredensial aplikasi (mis. lewat halaman SSO) tetap berisi link ke halaman SSO.
Error untuk frontend — code menentukan layar:
{ "success": false, "error": { "message": "...", "code": "password_change_required", "statusCode": 403, "resetToken": "..." } }| code | HTTP | Layar di aplikasi |
|---|---|---|
| invalid_grant | 401 | Username/password salah; di /refresh = sesi berakhir, login ulang |
| nrp_activation_required | 403 | Form aktivasi NRP → /auth/activate-nrp |
| email_not_verified | 403 | Info + tombol kirim ulang → /auth/resend-verification |
| account_inactive | 403 | Akun nonaktif, hubungi admin |
| password_change_required | 403 | Form password baru → /auth/reset-password dengan resetToken |
| too_many_requests | 429 | Tunggu Retry-After |
Tanpa code: 500 SSO rejected the password login client (<kode OAuth>) = konfigurasi
aplikasi salah (secret, flag belum dinyalakan, IP); 503 SSO unavailable; 4xx lain dari
endpoint pendukung diteruskan dengan pesan SSO (mis. NRP tidak ditemukan).
Tanpa router — pakai klien langsung, mis. untuk sesi server sendiri:
try {
const { accessToken, refreshToken, user } = await sso.login({ username, password, endUserIp: req.ip })
} catch (err) {
if (err instanceof PasswordLoginError) {
// err.code, err.resetToken, err.statusCode, err.retryAfter
}
throw err
}
const next = await sso.refresh(refreshToken) // refresh token berputar: simpan yang baru
await sso.logout(refreshToken)| Method | Keterangan |
|---|---|
| login({ username, password, endUserIp?, scope? }) | Token + user (sub, name, email, emailVerified) dari ID token |
| refresh(refreshToken) | Token baru; refresh token lama tidak berlaku lagi |
| logout(refreshToken) | Cabut token & akhiri sesi aplikasi ini di SSO |
| activateNrp, resendVerification, forgotPassword, resetPassword, verifyEmail | Endpoint /auth/* SSO, dengan clientId dan kredensial aplikasi; opsi endUserIp → X-End-User-IP |
Opsi: issuer, clientId, clientSecret (wajib); scope (default openid profile email
offline_access), resource, tokenEndpoint, revocationEndpoint, timeoutMs (default 5000).
API aplikasi tetap memvalidasi access token dengan createResourceServer seperti biasa —
audiences memuat client ID aplikasi ini.
Fail closed
Bila keabsahan token tidak bisa dipastikan, request ditolak — tidak pernah diloloskan.
| Kondisi | Respons |
|---|---|
| JWKS gagal diambil, kunci lama belum lewat jwksCacheTtl + jwksMaxStale | kunci lama dipakai |
| JWKS belum pernah berhasil diambil / kunci lama lewat jwksMaxStale (default 24 jam) | 503 SSO unavailable + Retry-After |
| Gagal ambil JWKS berulang | backoff 1 dtk → 2 → 4 … maks. 60 dtk; selama backoff tidak ada request ke SSO |
| JWKS sah tapi keys: [] | kunci lama dibuang → token 401 |
| Introspeksi 429/503 | 503 + Retry-After; jendela dihormati |
| Introspeksi 5xx lain / timeout / jaringan | 503 SSO unavailable |
| Introspeksi 401 (invalid_client) | 500 — salah konfigurasi clientId/clientSecret, bukan kesalahan user |
Opsi
| Opsi | Default | Keterangan |
|---|---|---|
| issuer | — (wajib) | Issuer SSO |
| audiences | — (wajib) | string \| string[] — client ID app FE dan/atau identifier API (RFC 8707) |
| jwksUri | ${issuer}/.well-known/jwks.json | |
| jwksCacheTtl | 600 | detik — JWKS dianggap segar |
| jwksMaxStale | 86400 | detik — batas kunci lama dipakai setelah TTL bila SSO tidak terjangkau |
| algorithms | ['RS256'] | |
| clockTolerance | 5 | detik |
| requiredTokenType | 'at+jwt' | nilai typ kustom (string); tidak bisa dimatikan |
| introspection | — | { clientId, clientSecret, endpoint?, cacheTtl? } — clientId/clientSecret wajib |
| timeoutMs | 5000 | request ke SSO |
Hasil createResourceServer: requireAuth({ scopes?, services? }), optionalAuth(), verify(token)
(tanpa Express), refreshKeys() (ambil ulang JWKS sekarang; gagal → 503),
clearIntrospectionCache().
Error
Semua error berupa AppError (statusCode, message, retryAfter?) yang diteruskan ke
next(err). errorHandler paket ini membalas JSON standar dan memasang Retry-After bila ada.
| Status | Pesan | Kapan |
|---|---|---|
| 401 | Authorization token is required | Header Authorization: Bearer … tidak ada |
| 401 | Invalid or expired token / Token has expired | Token rusak, klaim wajib tidak ada, kedaluwarsa |
| 401 | Invalid token signature / issuer / audience | Signature, kid/alg, iss, aud tidak cocok |
| 401 | Wrong token type for this endpoint | Bukan access token (typ, client_id) |
| 401 | Token has been revoked | Introspeksi active: false |
| 403 | Insufficient scope for this endpoint | Scope OAuth kurang (insufficient_scope) |
| 403 | This endpoint only accepts tokens from allowed services | requireAuth({ services }): bukan token layanan yang diizinkan |
| 500 | SSO rejected the introspection client credentials | Kredensial introspeksi salah |
| 500 | SSO rejected the service token request (<kode>) | createServiceClient: secret salah, client nonaktif, resource tidak diizinkan |
| 500 | SSO rejected the password login client (<kode>) | createPasswordLoginClient: secret salah, flag belum dinyalakan, IP tidak diizinkan |
| 401 | Sign-in required | createBff: belum login atau sesi berakhir |
| 403 | Cross-site request rejected | createBff: request pengubah data dari origin lain |
| 500 | SSO rejected the BFF client (<kode>) | createBff: secret salah, Redirect URI tidak terdaftar |
| 503 | SSO unavailable | JWKS/introspeksi tidak tersedia (fail closed) |
401 membawa WWW-Authenticate: Bearer error="invalid_token", 403 membawa
error="insufficient_scope" (RFC 6750).
{
"success": false,
"error": {
"message": "Invalid or expired token",
"statusCode": 401
}
}Helper Express lain yang tetap diekspor: errorHandler, notFoundHandler, asyncWrapper,
successResponse, errorResponse, AppError, ERROR_MESSAGES, HTTP_STATUS.
PasswordLoginError (turunan AppError) untuk status akun di Halaman login sendiri,
BffLoginError untuk login BFF yang tidak selesai.
Migrasi dari v1 (config() + auth())
API v1 sudah dihapus di v3.
| v1 | v3 |
|---|---|
| config({ clientId, clientSecret, baseUrl, redisUrl, publicKeyUrl }) | createResourceServer({ issuer, audiences }) |
| Public key tunggal dari /auth/public-key-test | JWKS by kid |
| Klaim type === 'ACCESS' | Header typ: at+jwt + klaim client_id |
| aud = client backend | aud ∈ client app FE atau identifier API (audiences) |
| Cek Redis sso:token:* | Opsional /introspect (confidential client) |
| req.tokenPayload | req.auth |
| Rute /auth/* (login, callback, refresh, logout) | Tidak perlu — FE bicara langsung ke SSO |
Requirements
- Node.js >= 18.0.0
- Express.js >= 4.18.0
Development
pnpm install
pnpm test # jest
pnpm run build # tsc → lib/
pnpm run lintLicense
MIT © Starasia
Support
For issues and questions, please open an issue on GitHub.
