OtpError now carries an optional details object; rate-limit branches in checkRateLimits compute retry_after_seconds (cooldown delta for OTP_COOLDOWN, window-roll-out delta for OTP_RATE_LIMIT_PHONE / OTP_RATE_LIMIT_IP) so the client can disable Kirim OTP / Kirim ulang CTAs with a real countdown. All four sendAuthError helpers (client, mitra, shared, internal) now surface err.details and log unhandled (no statusCode) errors at level 50. New GET /api/shared/config/otp returns the resend cooldown so the OTP screen can gate the resend countdown without hardcoding. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
3.8 KiB
JavaScript
135 lines
3.8 KiB
JavaScript
import { authenticate } from '../../plugins/auth.js'
|
|
import { getCcUserById } from '../../services/cc-user.service.js'
|
|
import {
|
|
signInCcUser,
|
|
refreshTokens,
|
|
logout,
|
|
} from '../../services/auth.service.js'
|
|
import { UserType } from '../../constants.js'
|
|
|
|
const REFRESH_COOKIE_NAME = 'cc_refresh_token'
|
|
|
|
const extractDeviceInfo = (request) => ({
|
|
user_agent: request.headers['user-agent'] || null,
|
|
ip: request.ip || null,
|
|
})
|
|
|
|
const sendAuthError = (reply, err) => {
|
|
if (!err.statusCode) reply.request.log.error({ err }, 'Unhandled auth error')
|
|
return reply.code(err.statusCode || 500).send({
|
|
success: false,
|
|
error: {
|
|
code: err.code || 'INTERNAL',
|
|
message: err.message,
|
|
...(err.details && { details: err.details }),
|
|
},
|
|
})
|
|
}
|
|
|
|
const cookieOpts = () => ({
|
|
httpOnly: true,
|
|
secure: process.env.NODE_ENV === 'production',
|
|
sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
|
|
path: '/',
|
|
})
|
|
|
|
const setRefreshCookie = (reply, refreshToken, expiresAt) => {
|
|
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, {
|
|
...cookieOpts(),
|
|
expires: new Date(expiresAt),
|
|
})
|
|
}
|
|
|
|
const clearRefreshCookie = (reply) => {
|
|
reply.clearCookie(REFRESH_COOKIE_NAME, cookieOpts())
|
|
}
|
|
|
|
export const internalAuthRoutes = async (app) => {
|
|
app.post('/login', async (request, reply) => {
|
|
const { email, password } = request.body || {}
|
|
if (!email || !password) {
|
|
return reply.code(422).send({
|
|
success: false,
|
|
error: { code: 'VALIDATION_ERROR', message: 'email and password are required' },
|
|
})
|
|
}
|
|
try {
|
|
const { tokens, profile } = await signInCcUser({
|
|
email,
|
|
password,
|
|
deviceInfo: extractDeviceInfo(request),
|
|
})
|
|
setRefreshCookie(reply, tokens.refresh_token, tokens.refresh_token_expires_at)
|
|
return reply.send({
|
|
success: true,
|
|
data: {
|
|
access_token: tokens.access_token,
|
|
access_token_expires_in: tokens.access_token_expires_in,
|
|
profile,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
return sendAuthError(reply, err)
|
|
}
|
|
})
|
|
|
|
app.post('/refresh', async (request, reply) => {
|
|
const refreshToken = request.cookies?.[REFRESH_COOKIE_NAME]
|
|
if (!refreshToken) {
|
|
return reply.code(401).send({
|
|
success: false,
|
|
error: { code: 'REFRESH_MISSING', message: 'Refresh token missing' },
|
|
})
|
|
}
|
|
try {
|
|
const { tokens, profile } = await refreshTokens({
|
|
refreshToken,
|
|
deviceInfo: extractDeviceInfo(request),
|
|
})
|
|
setRefreshCookie(reply, tokens.refresh_token, tokens.refresh_token_expires_at)
|
|
return reply.send({
|
|
success: true,
|
|
data: {
|
|
access_token: tokens.access_token,
|
|
access_token_expires_in: tokens.access_token_expires_in,
|
|
profile,
|
|
},
|
|
})
|
|
} catch (err) {
|
|
clearRefreshCookie(reply)
|
|
return sendAuthError(reply, err)
|
|
}
|
|
})
|
|
|
|
app.post('/logout', { preHandler: authenticate }, async (request, reply) => {
|
|
await logout({ sessionId: request.auth.sessionId })
|
|
clearRefreshCookie(reply)
|
|
return reply.send({ success: true })
|
|
})
|
|
|
|
app.get('/me', { preHandler: authenticate }, async (request, reply) => {
|
|
if (request.auth.userType !== UserType.CC_USER) {
|
|
return reply.code(403).send({
|
|
success: false,
|
|
error: { code: 'FORBIDDEN', message: 'Control center account required' },
|
|
})
|
|
}
|
|
const user = await getCcUserById(request.auth.userId)
|
|
if (!user) {
|
|
return reply.code(404).send({
|
|
success: false,
|
|
error: { code: 'NOT_FOUND', message: 'Control center account not found' },
|
|
})
|
|
}
|
|
return reply.send({
|
|
success: true,
|
|
data: {
|
|
id: user.id,
|
|
email: user.email,
|
|
display_name: user.display_name,
|
|
role: user.role,
|
|
},
|
|
})
|
|
})
|
|
}
|