feat(auth): add opaque bearer sessions
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import { normalizeAuthUserSnapshot, resolveAuthUserAdminFlag } from '../utils/authUser.js'
|
||||
import {
|
||||
buildBearerHeaders,
|
||||
notifyAuthSessionExpired,
|
||||
readAuthAccessToken
|
||||
} from '../utils/authSessionStorage.js'
|
||||
|
||||
const API_BASE_STORAGE_KEY = 'x-financial-api-base-url'
|
||||
const AUTH_USER_STORAGE_KEY = 'x-financial-auth-user'
|
||||
|
||||
function isHeaderValueSafe(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
@@ -33,85 +36,6 @@ export function pickSafeHeaderValue(value, fallback = '') {
|
||||
return ''
|
||||
}
|
||||
|
||||
function readCurrentUserHeaders() {
|
||||
if (typeof window === 'undefined') {
|
||||
return {}
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(AUTH_USER_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
const user = normalizeAuthUserSnapshot(payload)
|
||||
const username = user.username
|
||||
const name = user.name || username
|
||||
const roleCodes = user.roleCodes
|
||||
const isAdmin = resolveAuthUserAdminFlag(payload, roleCodes)
|
||||
const department = user.department || user.departmentName
|
||||
const costCenter = user.costCenter
|
||||
const position = user.position
|
||||
const grade = user.grade
|
||||
const employeeNo = user.employeeNo
|
||||
const managerName = user.managerName
|
||||
const safeUsername = pickSafeHeaderValue(username)
|
||||
const safeName = pickSafeHeaderValue(name)
|
||||
const safeDepartment = pickSafeHeaderValue(department)
|
||||
const safeCostCenter = pickSafeHeaderValue(costCenter)
|
||||
const safePosition = pickSafeHeaderValue(position)
|
||||
const safeGrade = pickSafeHeaderValue(grade)
|
||||
const safeEmployeeNo = pickSafeHeaderValue(employeeNo)
|
||||
const safeManagerName = pickSafeHeaderValue(managerName)
|
||||
|
||||
if (!safeUsername && !safeName) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'x-auth-role-codes': roleCodes.join(','),
|
||||
'x-auth-is-admin': String(isAdmin)
|
||||
}
|
||||
|
||||
if (safeUsername) {
|
||||
headers['x-auth-username'] = safeUsername
|
||||
}
|
||||
|
||||
if (safeName) {
|
||||
headers['x-auth-name'] = safeName
|
||||
}
|
||||
|
||||
if (safeDepartment) {
|
||||
headers['x-auth-department'] = safeDepartment
|
||||
}
|
||||
|
||||
if (safeCostCenter) {
|
||||
headers['x-auth-cost-center'] = safeCostCenter
|
||||
}
|
||||
|
||||
if (safePosition) {
|
||||
headers['x-auth-position'] = safePosition
|
||||
}
|
||||
|
||||
if (safeGrade) {
|
||||
headers['x-auth-grade'] = safeGrade
|
||||
}
|
||||
|
||||
if (safeEmployeeNo) {
|
||||
headers['x-auth-employee-no'] = safeEmployeeNo
|
||||
}
|
||||
|
||||
if (safeManagerName) {
|
||||
headers['x-auth-manager-name'] = safeManagerName
|
||||
}
|
||||
|
||||
return headers
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
return String(value || '/api/v1').replace(/\/$/, '')
|
||||
}
|
||||
@@ -260,8 +184,20 @@ function sanitizeHeaders(headers) {
|
||||
return nextHeaders
|
||||
}
|
||||
|
||||
function buildApiResponseError(response, payload, { auth, handleUnauthorized }) {
|
||||
const error = new Error(resolveErrorMessage(payload))
|
||||
error.status = response.status
|
||||
if (response.status === 401 && auth && handleUnauthorized && readAuthAccessToken()) {
|
||||
error.code = 'AUTH_SESSION_EXPIRED'
|
||||
notifyAuthSessionExpired()
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
export async function apiRequest(path, options = {}) {
|
||||
const {
|
||||
auth = true,
|
||||
handleUnauthorized = true,
|
||||
contentType = 'application/json',
|
||||
responseType = 'json',
|
||||
headers: customHeaders,
|
||||
@@ -270,10 +206,15 @@ export async function apiRequest(path, options = {}) {
|
||||
...fetchOptions
|
||||
} = options
|
||||
|
||||
const headers = sanitizeHeaders({
|
||||
...readCurrentUserHeaders(),
|
||||
...(customHeaders || {})
|
||||
})
|
||||
const headers = sanitizeHeaders(customHeaders || {})
|
||||
if (auth) {
|
||||
Object.keys(headers).forEach((key) => {
|
||||
if (key.toLowerCase() === 'authorization') {
|
||||
delete headers[key]
|
||||
}
|
||||
})
|
||||
Object.assign(headers, buildBearerHeaders())
|
||||
}
|
||||
|
||||
if (contentType !== null && typeof headers['Content-Type'] === 'undefined') {
|
||||
headers['Content-Type'] = contentType
|
||||
@@ -327,7 +268,7 @@ export async function apiRequest(path, options = {}) {
|
||||
payload = null
|
||||
}
|
||||
|
||||
throw new Error(resolveErrorMessage(payload))
|
||||
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||||
}
|
||||
|
||||
return response.blob()
|
||||
@@ -341,7 +282,7 @@ export async function apiRequest(path, options = {}) {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(resolveErrorMessage(payload))
|
||||
throw buildApiResponseError(response, payload, { auth, handleUnauthorized })
|
||||
}
|
||||
|
||||
return payload
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
||||
import { buildBearerHeaders } from '../utils/authSessionStorage.js'
|
||||
|
||||
export function login(payload) {
|
||||
return apiRequest('/auth/login', {
|
||||
auth: false,
|
||||
handleUnauthorized: false,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
@@ -11,6 +14,14 @@ export function fetchCurrentAuthUser() {
|
||||
return apiRequest('/auth/me')
|
||||
}
|
||||
|
||||
export function revokeCurrentAuthSession(payload) {
|
||||
return apiRequest('/auth/logout', {
|
||||
method: 'POST',
|
||||
handleUnauthorized: false,
|
||||
body: JSON.stringify(payload || {})
|
||||
})
|
||||
}
|
||||
|
||||
export function finishSession(sessionId, payload) {
|
||||
return apiRequest(`/auth/sessions/${encodeURIComponent(sessionId)}/finish`, {
|
||||
method: 'POST',
|
||||
@@ -29,13 +40,12 @@ export function finishSessionOnUnload(sessionId, payload) {
|
||||
const url = `${getRuntimeApiBaseUrl()}/auth/sessions/${encodeURIComponent(normalizedSessionId)}/finish`
|
||||
const body = JSON.stringify(payload || {})
|
||||
|
||||
if (typeof window.navigator?.sendBeacon === 'function') {
|
||||
return window.navigator.sendBeacon(url, new Blob([body], { type: 'application/json' }))
|
||||
}
|
||||
|
||||
window.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...buildBearerHeaders()
|
||||
},
|
||||
body,
|
||||
keepalive: true
|
||||
}).catch(() => {})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { apiRequest, getRuntimeApiBaseUrl } from './api.js'
|
||||
import {
|
||||
buildBearerHeaders,
|
||||
notifyAuthSessionExpired
|
||||
} from '../utils/authSessionStorage.js'
|
||||
|
||||
export function fetchStewardPlan(payload, options = {}) {
|
||||
return apiRequest('/steward/plans', {
|
||||
@@ -63,7 +67,8 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
response = await fetch(`${getRuntimeApiBaseUrl()}/steward/plans/stream`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
...buildBearerHeaders()
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller?.signal
|
||||
@@ -78,6 +83,9 @@ export async function fetchStewardPlanStream(payload, handlers = {}, options = {
|
||||
|
||||
if (!response.ok) {
|
||||
clearAbortTimer()
|
||||
if (response.status === 401) {
|
||||
notifyAuthSessionExpired()
|
||||
}
|
||||
throw new Error(await resolveStreamError(response))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user