1089 lines
67 KiB
JavaScript
1089 lines
67 KiB
JavaScript
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
|
|
export const AUTH_PREFIX = '/_dsh_platform/auth/'
|
|
export const ACCESS_PREFIX = '/_dsh_platform/access/'
|
|
export const TRANSITION_PREFIX = '/_dsh_platform/transition/'
|
|
export const DSH_SESSION_COOKIE = 'dsh_gateway_session'
|
|
export const DSH_CSRF_COOKIE = 'dsh_gateway_csrf'
|
|
export const AUTH_CSRF_COOKIE = 'dsh_auth_csrf'
|
|
export const AUTH_RETRY_COOKIE = 'dsh_auth_retry_source'
|
|
export const TOTP_LOGIN_COOKIE = 'dsh_totp_login'
|
|
export const MANAGEMENT_SESSION_COOKIE = 'dsh_management_compat_session'
|
|
export const MANAGEMENT_CSRF_COOKIE = 'dsh_management_csrf'
|
|
export const MANAGEMENT_PENDING_COOKIE = 'dsh_management_pending'
|
|
|
|
const DSH_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60
|
|
const MANAGEMENT_SESSION_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
|
|
const RESERVED_COOKIES = new Set([
|
|
DSH_SESSION_COOKIE,
|
|
DSH_CSRF_COOKIE,
|
|
AUTH_CSRF_COOKIE,
|
|
AUTH_RETRY_COOKIE,
|
|
TOTP_LOGIN_COOKIE,
|
|
MANAGEMENT_SESSION_COOKIE,
|
|
MANAGEMENT_CSRF_COOKIE,
|
|
MANAGEMENT_PENDING_COOKIE,
|
|
'dsh_management_secure_session',
|
|
])
|
|
|
|
export function isReservedCookieName(name) { return RESERVED_COOKIES.has(name) }
|
|
|
|
function token(prefix) { return `${prefix}_${randomBytes(32).toString('base64url')}` }
|
|
function digest(value) { return createHash('sha256').update(value).digest('base64url') }
|
|
|
|
export function cookieValue(header, name) {
|
|
if (typeof header !== 'string') return undefined
|
|
for (const part of header.split(';')) {
|
|
const separator = part.indexOf('=')
|
|
if (separator < 0 || part.slice(0, separator).trim() !== name) continue
|
|
const value = part.slice(separator + 1).trim()
|
|
return /^[A-Za-z0-9_-]{1,512}$/.test(value) ? value : undefined
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export function stripReservedCookies(header) {
|
|
if (typeof header !== 'string') return undefined
|
|
const kept = header.split(';').map(value => value.trim()).filter(value => {
|
|
const separator = value.indexOf('=')
|
|
return separator > 0 && !RESERVED_COOKIES.has(value.slice(0, separator).trim())
|
|
})
|
|
return kept.length === 0 ? undefined : kept.join('; ')
|
|
}
|
|
|
|
function sameSecret(left, right) {
|
|
const a = Buffer.from(left ?? '')
|
|
const b = Buffer.from(right ?? '')
|
|
return a.byteLength === b.byteLength && a.byteLength > 0 && timingSafeEqual(a, b)
|
|
}
|
|
|
|
function language(request) {
|
|
return cookieValue(request.headers.cookie, 'dsh_locale') === 'zh'
|
|
|| (cookieValue(request.headers.cookie, 'dsh_locale') === undefined
|
|
&& /^zh\b/i.test(request.headers['accept-language'] ?? '')) ? 'zh' : 'en'
|
|
}
|
|
|
|
export function requestOrigin(request, { requireHeader = false } = {}) {
|
|
const header = request.headers.origin
|
|
if (typeof header === 'string') {
|
|
try {
|
|
const parsed = new URL(header)
|
|
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username !== '' || parsed.password !== ''
|
|
|| parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== ''
|
|
|| parsed.host.toLowerCase() !== String(request.headers.host ?? '').toLowerCase()) return undefined
|
|
return parsed.origin
|
|
} catch { return undefined }
|
|
}
|
|
if (requireHeader) return undefined
|
|
const protocol = request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
|
|
try { return new URL(`${protocol}://${String(request.headers.host ?? '')}`).origin }
|
|
catch { return undefined }
|
|
}
|
|
|
|
function requestTargetOrigin(request) {
|
|
const protocol = request.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
|
|
try { return new URL(`${protocol}://${String(request.headers.host ?? '')}`).origin }
|
|
catch { return undefined }
|
|
}
|
|
|
|
function loginClient(request) {
|
|
const remoteAddress = request.socket?.remoteAddress
|
|
const ip = typeof remoteAddress === 'string' && remoteAddress.length <= 128
|
|
? remoteAddress.replace(/^::ffff:/, '') : null
|
|
const header = request.headers['user-agent']
|
|
const userAgent = typeof header === 'string' && header.length > 0
|
|
? header.slice(0, 512) : null
|
|
return { ip, userAgent }
|
|
}
|
|
|
|
function authenticationSource(request) {
|
|
const browser = cookieValue(request.headers.cookie, AUTH_RETRY_COOKIE)
|
|
if (browser !== undefined) return `browser:${digest(browser)}`
|
|
const ip = loginClient(request).ip
|
|
return ip === null ? 'unknown' : `ip:${digest(ip)}`
|
|
}
|
|
|
|
function canonicalOrigin(value) {
|
|
try {
|
|
const parsed = new URL(value)
|
|
return ['http:', 'https:'].includes(parsed.protocol) && parsed.origin === value ? parsed.origin : undefined
|
|
} catch { return undefined }
|
|
}
|
|
|
|
function secureCookie(origin) { return origin.startsWith('https://') ? '; Secure' : '' }
|
|
|
|
function sessionCookies(session, origin) {
|
|
return [
|
|
`${DSH_SESSION_COOKIE}=${session.token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${DSH_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`,
|
|
`${DSH_CSRF_COOKIE}=${session.csrfToken}; SameSite=Strict; Path=/; Max-Age=${DSH_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`,
|
|
`${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
]
|
|
}
|
|
|
|
function clearSessionCookies(origin) {
|
|
return [
|
|
`${DSH_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secureCookie(origin)}`,
|
|
`${DSH_CSRF_COOKIE}=; SameSite=Strict; Path=/; Max-Age=0${secureCookie(origin)}`,
|
|
`${AUTH_CSRF_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
`${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
]
|
|
}
|
|
|
|
function clearInvalidBrowserSessionCookies(origin, managementPath = '/_dsh_platform/') {
|
|
return [
|
|
`${DSH_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0${secureCookie(origin)}`,
|
|
`${DSH_CSRF_COOKIE}=; SameSite=Strict; Path=/; Max-Age=0${secureCookie(origin)}`,
|
|
`${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
...clearManagementCookies(origin, managementPath),
|
|
]
|
|
}
|
|
|
|
function managementCookies(session, origin, path = '/_dsh_platform/') {
|
|
return [
|
|
// Top-level navigation between the isolated DSH and Management origins
|
|
// must carry an existing Management session. Keep mutations protected by
|
|
// the Strict CSRF cookie below; Lax only permits safe document navigations.
|
|
`${MANAGEMENT_SESSION_COOKIE}=${session.token}; HttpOnly; SameSite=Lax; Path=${path}; Max-Age=${MANAGEMENT_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`,
|
|
`${MANAGEMENT_CSRF_COOKIE}=${session.csrfToken}; SameSite=Strict; Path=${path}; Max-Age=${MANAGEMENT_SESSION_MAX_AGE_SECONDS}${secureCookie(origin)}`,
|
|
`${MANAGEMENT_PENDING_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
]
|
|
}
|
|
|
|
function clearManagementCookies(origin, path = '/_dsh_platform/') {
|
|
return [
|
|
`${MANAGEMENT_SESSION_COOKIE}=; HttpOnly; SameSite=Lax; Path=${path}; Max-Age=0${secureCookie(origin)}`,
|
|
`${MANAGEMENT_CSRF_COOKIE}=; SameSite=Strict; Path=${path}; Max-Age=0${secureCookie(origin)}`,
|
|
`${MANAGEMENT_PENDING_COOKIE}=; HttpOnly; SameSite=Strict; Path=${AUTH_PREFIX}; Max-Age=0${secureCookie(origin)}`,
|
|
]
|
|
}
|
|
|
|
function pendingCookie(pending, origin, authPrefix = AUTH_PREFIX) {
|
|
return `${MANAGEMENT_PENDING_COOKIE}=${pending.token}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}`
|
|
}
|
|
|
|
function totpLoginCookie(challenge, origin, authPrefix = AUTH_PREFIX) {
|
|
return `${TOTP_LOGIN_COOKIE}=${challenge.token}; HttpOnly; SameSite=Strict; Path=${authPrefix}; Max-Age=300${secureCookie(origin)}`
|
|
}
|
|
|
|
function clearTotpLoginCookie(origin, authPrefix = AUTH_PREFIX) {
|
|
return `${TOTP_LOGIN_COOKIE}=; HttpOnly; SameSite=Strict; Path=${authPrefix}; Max-Age=0${secureCookie(origin)}`
|
|
}
|
|
|
|
function authenticationContext(request, origin, authPrefix = AUTH_PREFIX) {
|
|
const csrfToken = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE) ?? token('dsha')
|
|
const retrySource = cookieValue(request.headers.cookie, AUTH_RETRY_COOKIE) ?? token('dshr')
|
|
return {
|
|
csrfToken,
|
|
cookies: [
|
|
`${AUTH_CSRF_COOKIE}=${csrfToken}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}`,
|
|
`${AUTH_RETRY_COOKIE}=${retrySource}; HttpOnly; SameSite=Strict; Path=${authPrefix}${secureCookie(origin)}`,
|
|
],
|
|
}
|
|
}
|
|
|
|
function managementAuthenticationContext(request, origin, managementPath = '/_dsh_platform/') {
|
|
const csrfToken = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) ?? token('dshm')
|
|
return {
|
|
csrfToken,
|
|
cookies: [`${MANAGEMENT_CSRF_COOKIE}=${csrfToken}; SameSite=Strict; Path=${managementPath}${secureCookie(origin)}`],
|
|
}
|
|
}
|
|
|
|
function sendJson(response, status, value, headers = {}) {
|
|
const bytes = Buffer.from(`${JSON.stringify(value)}\n`)
|
|
response.writeHead(status, {
|
|
'cache-control': 'no-store',
|
|
'content-length': String(bytes.byteLength),
|
|
'content-type': 'application/json; charset=utf-8',
|
|
...headers,
|
|
})
|
|
response.end(bytes)
|
|
}
|
|
|
|
function sendSameOriginNavigation(response, target, cookies) {
|
|
const scriptTarget = JSON.stringify(target)
|
|
.replaceAll('<', '\\u003c')
|
|
.replaceAll('\u2028', '\\u2028')
|
|
.replaceAll('\u2029', '\\u2029')
|
|
const body = Buffer.from(`<!doctype html><meta charset="utf-8"><script>location.replace(${scriptTarget})</script>`)
|
|
response.writeHead(200, {
|
|
'cache-control': 'no-store',
|
|
'content-length': String(body.byteLength),
|
|
'content-security-policy': "default-src 'none'; script-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'",
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'referrer-policy': 'no-referrer',
|
|
...(cookies === undefined ? {} : { 'set-cookie': cookies }),
|
|
})
|
|
response.end(body)
|
|
}
|
|
|
|
async function jsonBody(request) {
|
|
const chunks = []
|
|
let size = 0
|
|
for await (const chunk of request) {
|
|
size += chunk.byteLength
|
|
if (size > 64 * 1024) throw Object.assign(new Error('authentication request is too large'), { statusCode: 413 })
|
|
chunks.push(chunk)
|
|
}
|
|
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
catch { throw Object.assign(new Error('authentication request is invalid'), { statusCode: 400 }) }
|
|
}
|
|
|
|
function validBrowserMutationShape(request) {
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
const site = request.headers['sec-fetch-site']
|
|
const mode = request.headers['sec-fetch-mode']
|
|
return origin !== undefined
|
|
&& (site === undefined || site === 'same-origin')
|
|
&& (mode === undefined || mode === 'cors' || mode === 'same-origin')
|
|
&& typeof request.headers['content-type'] === 'string'
|
|
&& request.headers['content-type'].toLowerCase().startsWith('application/json')
|
|
}
|
|
|
|
function validBrowserMutation(request, csrf) {
|
|
return validBrowserMutationShape(request) && sameSecret(request.headers['x-dsh-csrf'], csrf)
|
|
}
|
|
|
|
function validAuthenticationContextRequest(request) {
|
|
const site = request.headers['sec-fetch-site']
|
|
const mode = request.headers['sec-fetch-mode']
|
|
return requestOrigin(request) !== undefined
|
|
&& (site === undefined || site === 'same-origin')
|
|
&& (mode === undefined || mode === 'cors' || mode === 'same-origin')
|
|
}
|
|
|
|
function validSessionMutation(request, cookieName) {
|
|
const csrf = cookieValue(request.headers.cookie, cookieName)
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
const site = request.headers['sec-fetch-site']
|
|
const mode = request.headers['sec-fetch-mode']
|
|
return origin !== undefined
|
|
&& (site === undefined || site === 'same-origin')
|
|
&& (mode === undefined || mode === 'cors' || mode === 'same-origin')
|
|
&& sameSecret(request.headers['x-dsh-csrf'], csrf)
|
|
}
|
|
|
|
function authenticationCountdownScript(submitLabel) {
|
|
return `let retryUntil=0,retryTimer;function showRetryCountdown(template,seconds){retryUntil=Date.now()+seconds*1000;clearTimeout(retryTimer);button.disabled=true;const tick=()=>{const remaining=Math.max(0,Math.ceil((retryUntil-Date.now())/1000));if(remaining>0){error.textContent=template.replace('{seconds}',String(remaining));error.hidden=false;retryTimer=setTimeout(tick,Math.min(1000,Math.max(1,retryUntil-Date.now())));return}retryUntil=0;error.textContent='';error.hidden=true;button.disabled=false;button.textContent=${JSON.stringify(submitLabel)}};tick()}function showRetryFailure(payload){const template=payload.code==='AUTHENTICATION_RETRY_REQUIRED'&&Number.isInteger(payload.retryAfterSeconds)?retryMessage:payload.code==='AUTHENTICATION_RATE_LIMITED'&&Number.isInteger(payload.retryAfterSeconds)?rateLimitMessage:null;if(template===null)return false;showRetryCountdown(template,payload.retryAfterSeconds);return true}`
|
|
}
|
|
|
|
function htmlEscape(value) {
|
|
return String(value).replace(/[&<>"']/g, character => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
})[character])
|
|
}
|
|
|
|
const USERNAME_INPUT_PATTERN = String.raw`[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{1,64}`
|
|
const PASSWORD_INPUT_PATTERN = String.raw`[^\p{Cc}\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]{8,1024}`
|
|
|
|
function authenticationPage(request, state, csrf, authenticationContextId, returnPath, { resetForm = false } = {}) {
|
|
const zh = language(request) === 'zh'
|
|
const copy = zh ? {
|
|
brand: 'HARNESS', username: '用户名', password: '密码', confirmPassword: '确认密码', passwordMismatch: '两次输入的新密码不一致。', totp: '动态验证码', register: '注册', login: '登录',
|
|
initializing: '正在创建管理员账户…', signingIn: '正在登录…', resetting: '正在重置管理员认证…',
|
|
failed: '用户名或密码错误', registrationFailed: '无法创建管理员账户,请检查填写内容后重试。',
|
|
concurrent: '管理员账户已创建,请使用现有账户登录。',
|
|
invalidUsername: '用户名支持 1 至 64 个字符,可使用中文、字母、数字、空格和常用符号;不能包含控制字符或双向控制字符。',
|
|
invalidPassword: '密码支持 8 至 1024 个字符,可使用中文、字母、数字、空格和符号;不能包含控制字符或双向控制字符。',
|
|
retryRequired: '当前浏览器已连续多次输入错误,请在 {seconds} 秒后重试。',
|
|
rateLimited: '管理员登录尝试过多,请在 {seconds} 秒后重试。',
|
|
totpRequired: '请输入身份验证器中的 6 位动态验证码。', totpFailed: '动态验证码不正确,请重试。',
|
|
loginUpdated: '登录状态已更新,请重新输入主密码。',
|
|
totpRetryRequired: '动态验证码连续多次错误,请在 {seconds} 秒后重试。',
|
|
totpRateLimited: '动态验证码尝试次数过多,请在 {seconds} 秒后重试。',
|
|
serviceUnavailable: '认证服务暂不可用,请稍后重试。',
|
|
invalidResetKey: '认证重置密钥无效、已过期或已使用,请重新生成。',
|
|
migrationTitle: '需要迁移管理员认证', migrationDetail: '请在容器 Root 终端运行 dsh-platform access generate-key,然后使用一次性密钥创建新账户。',
|
|
resetTitle: '重置管理员认证', resetDetail: '请先在容器 Root 终端运行 dsh-platform access generate-key。重置会替换损坏或缺失的管理员账户。', setupKey: '认证重置密钥',
|
|
recoveryTitle: '需要恢复管理员认证', recoveryDetail: '认证状态损坏或缺失。请使用 Root 终端生成认证重置密钥,然后重新创建管理员账户。', recoveryAction: '重置管理员认证',
|
|
waitingTitle: '正在准备认证服务', waitingDetail: '平台正在确认本地管理员状态,请稍候。',
|
|
} : {
|
|
brand: 'HARNESS', username: 'Username', password: 'Password', confirmPassword: 'Confirm password', passwordMismatch: 'The new password confirmation does not match.', totp: 'Authentication code', register: 'Register', login: 'Sign in',
|
|
initializing: 'Creating the administrator account…', signingIn: 'Signing in…', resetting: 'Resetting administrator authentication…',
|
|
failed: 'The username or password is incorrect.', registrationFailed: 'The administrator account could not be created. Check the entered values and try again.',
|
|
concurrent: 'The administrator account already exists. Sign in with it.',
|
|
invalidUsername: 'Use 1 to 64 characters. Unicode letters, numbers, spaces, and common symbols are supported; control and bidirectional-control characters are not.',
|
|
invalidPassword: 'Use 8 to 1024 characters. Unicode letters, numbers, spaces, and symbols are supported; control and bidirectional-control characters are not.',
|
|
retryRequired: 'This browser has made several consecutive failed attempts. Try again in {seconds} seconds.',
|
|
rateLimited: 'Too many administrator sign-in attempts. Try again in {seconds} seconds.',
|
|
totpRequired: 'Enter the 6-digit code from your authenticator app.', totpFailed: 'The authentication code is incorrect. Try again.',
|
|
loginUpdated: 'The sign-in state has changed. Enter the main password again.',
|
|
totpRetryRequired: 'Too many incorrect codes. Try again in {seconds} seconds.',
|
|
totpRateLimited: 'Too many authentication-code attempts. Try again in {seconds} seconds.',
|
|
serviceUnavailable: 'The authentication service is temporarily unavailable. Try again shortly.',
|
|
invalidResetKey: 'The authentication reset key is invalid, expired, or already used. Generate a new key.',
|
|
migrationTitle: 'Administrator migration required', migrationDetail: 'Run dsh-platform access generate-key from a root container terminal, then create a new account with the one-time key.',
|
|
resetTitle: 'Reset administrator authentication', resetDetail: 'First run dsh-platform access generate-key from a root container terminal. Resetting replaces the missing or damaged administrator account.', setupKey: 'Authentication reset key',
|
|
recoveryTitle: 'Administrator recovery required', recoveryDetail: 'Authentication state is missing or damaged. Generate an authentication reset key from a root container terminal, then recreate the administrator account.', recoveryAction: 'Reset administrator authentication',
|
|
waitingTitle: 'Preparing authentication', waitingDetail: 'The platform is determining local administrator state. Please wait.',
|
|
}
|
|
let content
|
|
if (state === 'never-initialized' || state === 'initialized' || state === 'migration-required'
|
|
|| (state === 'recovery-required' && resetForm)) {
|
|
const initialize = state === 'never-initialized'
|
|
const reset = state === 'migration-required' || state === 'recovery-required'
|
|
const validateCredentials = initialize || reset
|
|
const submit = initialize ? copy.register : reset ? copy.recoveryAction : copy.login
|
|
const submitPath = reset
|
|
? `${AUTH_PREFIX}reset`
|
|
: `${AUTH_PREFIX}session?return=${encodeURIComponent(returnPath)}`
|
|
const registrationErrors = {
|
|
ALREADY_INITIALIZED: copy.concurrent,
|
|
USERNAME_INVALID: copy.invalidUsername,
|
|
PASSWORD_POLICY_VIOLATION: copy.invalidPassword,
|
|
AUTHENTICATION_RESET_KEY_INVALID: copy.invalidResetKey,
|
|
ACCESS_MANAGER_UNAVAILABLE: copy.serviceUnavailable,
|
|
}
|
|
const fallbackError = initialize || reset ? copy.registrationFailed : copy.failed
|
|
const context = reset
|
|
? `<h1>${state === 'migration-required' ? copy.migrationTitle : copy.resetTitle}</h1><p class="detail">${state === 'migration-required' ? copy.migrationDetail : copy.resetDetail}</p>`
|
|
: ''
|
|
const usernameValidation = validateCredentials
|
|
? ` required pattern="${htmlEscape(USERNAME_INPUT_PATTERN)}"` : ''
|
|
const passwordValidation = validateCredentials
|
|
? ` required minlength="8" pattern="${htmlEscape(PASSWORD_INPUT_PATTERN)}"` : ''
|
|
const usernameError = validateCredentials
|
|
? `<small class="field-error" data-field-error="username" role="alert" hidden>${htmlEscape(copy.invalidUsername)}</small>` : ''
|
|
const passwordError = validateCredentials
|
|
? `<small class="field-error" data-field-error="password" role="alert" hidden>${htmlEscape(copy.invalidPassword)}</small>` : ''
|
|
const passwordConfirmation = validateCredentials
|
|
? `<label data-credential-field>${copy.confirmPassword}<input name="confirmPassword" type="password" autocomplete="new-password" required maxlength="1024"><small class="field-error" data-field-error="confirmPassword" role="alert" hidden>${htmlEscape(copy.passwordMismatch)}</small></label>` : ''
|
|
const validationScript = validateCredentials
|
|
? `function validateField(name){const input=form.elements[name],message=form.querySelector('[data-field-error="'+name+'"]'),valid=input.validity.valid;message.hidden=valid;return valid}function validatePasswordConfirmation(show=false){const input=form.elements.confirmPassword,message=form.querySelector('[data-field-error="confirmPassword"]'),matches=input.value===form.elements.password.value;message.hidden=matches||(!show&&input.value.length===0);return matches}form.elements.password.addEventListener('input',()=>{validateField('password');validatePasswordConfirmation()});form.elements.confirmPassword.addEventListener('input',()=>validatePasswordConfirmation());`
|
|
: ''
|
|
// Field hints are presentation-only. The Access Manager remains the sole
|
|
// authority for registration, recovery, and credential validation.
|
|
const validationGate = validateCredentials
|
|
? `validateField('username');validateField('password');if(!validatePasswordConfirmation(true))return;` : ''
|
|
const totpField = state === 'initialized'
|
|
? `<label id="totp-field" hidden>${copy.totp}<input name="totpCode" autocomplete="one-time-code" inputmode="numeric" maxlength="6"></label>` : ''
|
|
content = `${context}<form method="post" action="${htmlEscape(submitPath)}" novalidate>${reset ? `<label>${copy.setupKey}<input name="setupKey" type="password" autocomplete="one-time-code" required maxlength="512" autofocus></label>` : ''}<label data-credential-field>${copy.username}<input name="username" autocomplete="username" maxlength="256"${usernameValidation}${reset ? '' : ' autofocus'}>${usernameError}</label><label data-credential-field>${copy.password}<input name="password" type="password" autocomplete="${validateCredentials ? 'new-password' : 'current-password'}" maxlength="1024"${passwordValidation}>${passwordError}</label>${passwordConfirmation}${totpField}<button type="submit">${submit}</button><p class="error" role="alert" hidden></p></form><script>const form=document.querySelector('form'),error=document.querySelector('.error'),button=form.querySelector('button'),totpField=document.querySelector('#totp-field'),failureMessages=${JSON.stringify(registrationErrors)},retryMessage=${JSON.stringify(copy.retryRequired)},rateLimitMessage=${JSON.stringify(copy.rateLimited)},totpRetryMessage=${JSON.stringify(copy.totpRetryRequired)},totpRateLimitMessage=${JSON.stringify(copy.totpRateLimited)};let navigating=false,csrfToken=${JSON.stringify(csrf)},authenticationContextId=${JSON.stringify(authenticationContextId)},totpPending=false,totpRetryRefreshTimer;${authenticationCountdownScript(submit)}${validationScript}function clearRetryCountdown(){clearTimeout(retryTimer);retryUntil=0;error.textContent='';error.hidden=true;button.disabled=false;button.textContent=${JSON.stringify(submit)}}async function refreshTotpRetry(){clearTimeout(totpRetryRefreshTimer);if(!totpPending||retryUntil<=Date.now())return;try{const response=await fetch(${JSON.stringify(`${AUTH_PREFIX}context`)},{headers:{accept:'application/json'},cache:'no-store'}),next=await response.json().catch(()=>({}));if(response.ok&&typeof next.csrfToken==='string'&&typeof next.authenticationContext==='string'){csrfToken=next.csrfToken;authenticationContextId=next.authenticationContext;const retry=next.totpRetry;if(retry&&Number.isInteger(retry.retryAfterSeconds)){if(retry.retryAfterSeconds<=0){clearRetryCountdown();return}const template=retry.kind==='rate'?totpRateLimitMessage:totpRetryMessage;if(Math.abs(retryUntil-Date.now()-retry.retryAfterSeconds*1000)>1500)showRetryCountdown(template,retry.retryAfterSeconds)}}}catch{}if(totpPending&&retryUntil>Date.now())totpRetryRefreshTimer=setTimeout(refreshTotpRetry,1000)}function showTotpRetry(payload){showRetryCountdown(payload.code==='TOTP_RATE_LIMITED'?totpRateLimitMessage:totpRetryMessage,payload.retryAfterSeconds);totpRetryRefreshTimer=setTimeout(refreshTotpRetry,1000)}function showTotp(){totpPending=true;for(const field of form.querySelectorAll('[data-credential-field]'))field.hidden=true;totpField.hidden=false;form.elements.password.value='';error.textContent=${JSON.stringify(copy.totpRequired)};error.hidden=false;form.elements.totpCode.focus()}function resetTotp(message){totpPending=false;clearTimeout(totpRetryRefreshTimer);for(const field of form.querySelectorAll('[data-credential-field]'))field.hidden=false;totpField.hidden=true;form.elements.totpCode.value='';error.textContent=message;error.hidden=false;form.elements.username.focus()}async function submitAuthentication(values,retry=true){const requestBody=totpPending?{totpCode:values.get('totpCode')}:{${reset ? "setupKey:values.get('setupKey')," : ''}username:values.get('username'),password:values.get('password')},body=JSON.stringify({...requestBody,authenticationContext:authenticationContextId}),response=await fetch(${JSON.stringify(submitPath)},{method:'POST',headers:{'content-type':'application/json','x-dsh-csrf':csrfToken},body});const payload=await response.json().catch(()=>({}));if(response.status===409&&payload.code==='AUTHENTICATION_CONTEXT_STALE'&&retry&&!totpPending){const refreshed=await fetch(${JSON.stringify(`${AUTH_PREFIX}context`)},{headers:{accept:'application/json'},cache:'no-store'}),next=await refreshed.json().catch(()=>({}));if(refreshed.ok&&typeof next.csrfToken==='string'&&typeof next.authenticationContext==='string'){csrfToken=next.csrfToken;authenticationContextId=next.authenticationContext;return submitAuthentication(values,false)}}return{response,payload}}form.addEventListener('submit',async event=>{event.preventDefault();error.hidden=true;if(!totpPending){${validationGate}}button.disabled=true;button.textContent=${JSON.stringify(initialize ? copy.initializing : reset ? copy.resetting : copy.signingIn)};const values=new FormData(form);try{const{response,payload}=await submitAuthentication(values);if(response.ok&&payload.totpRequired===true){showTotp();return}if(response.ok){navigating=true;location.replace(typeof payload.next==='string'?payload.next:${JSON.stringify(returnPath)});return}if(totpPending){if(payload.code==='TOTP_LOGIN_INVALID'||payload.code==='AUTHENTICATION_CONTEXT_STALE'){resetTotp(${JSON.stringify(copy.loginUpdated)})}else if(payload.code==='TOTP_RETRY_REQUIRED'||payload.code==='TOTP_RATE_LIMITED'){showTotpRetry(payload)}else{error.textContent=${JSON.stringify(copy.totpFailed)};error.hidden=false;form.elements.totpCode.select()}}else if(!showRetryFailure(payload)){error.textContent=failureMessages[payload.code]??${JSON.stringify(fallbackError)};error.hidden=false}}catch{error.textContent=${JSON.stringify(copy.serviceUnavailable)};error.hidden=false}finally{if(!navigating&&retryUntil<=Date.now())button.disabled=false;if(!navigating)button.textContent=${JSON.stringify(submit)}}})</script>`
|
|
if (state === 'initialized') {
|
|
content += `<script>(()=>{const input=document.querySelector('input[name="totpCode"]'),form=input.form,button=form.querySelector('button'),error=form.querySelector('.error'),initialMessage=${JSON.stringify(copy.totpRequired)};const sync=()=>{error.hidden=true;error.textContent='';button.disabled=input.value.length!==6};input.addEventListener('focus',()=>queueMicrotask(()=>{if(error.textContent===initialMessage)sync()}));input.addEventListener('input',sync)})()</script>`
|
|
}
|
|
} else if (state === 'recovery-required') {
|
|
const resetPath = `${AUTH_PREFIX}reset?return=${encodeURIComponent(returnPath)}`
|
|
content = `<h1>${copy.recoveryTitle}</h1><p class="detail">${copy.recoveryDetail}</p><a class="button" href="${htmlEscape(resetPath)}">${copy.recoveryAction}</a>`
|
|
} else content = `<section class="waiting"><h1>${copy.waitingTitle}</h1><p>${copy.waitingDetail}</p></section><script>setTimeout(()=>location.reload(),1000)</script>`
|
|
return `<!doctype html><html lang="${zh ? 'zh-CN' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${htmlEscape(copy.brand)}</title><style>html{height:100%;color-scheme:light dark}*{box-sizing:border-box}body{min-height:100%;margin:0;display:grid;place-items:center;background:#151517;color:#f3f3f4;font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.card{width:min(400px,calc(100% - 32px));padding:28px;border:1px solid #3e3e42;border-radius:12px;background:#242426}.brand{text-align:center;margin:0 0 24px;font-size:18px;letter-spacing:.12em}h1{margin:0 0 8px;font-size:20px}p{margin:0;color:#aaaab0}.waiting{text-align:center}.detail{margin-bottom:20px}form{display:grid;gap:16px}label{display:grid;gap:7px;font-weight:600}.field-error{color:#ff7777;font-size:12px;font-weight:400;line-height:1.5}input{width:100%;padding:10px 12px;border:1px solid #55555b;border-radius:7px;background:#19191b;color:inherit;font:inherit}button,.button{display:block;width:100%;padding:10px 14px;border:0;border-radius:7px;background:#f2f2f3;color:#202124;font:600 14px/1.4 inherit;text-align:center;text-decoration:none;cursor:pointer}input:focus-visible{border-color:#f3f3f4;outline:none}button:focus-visible,.button:focus-visible{outline:2px solid #6ea8fe;outline-offset:2px}.error{color:#ff7777}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f7f7f8;color:#202124}.card{background:#fff;border-color:#d7d7da}p{color:#6d6f76}input{background:#fff;border-color:#c7c8cc}button,.button{background:#202124;color:#fff}}</style></head><body><main class="card"><div class="brand">${htmlEscape(copy.brand)}</div>${content}</main></body></html>`
|
|
}
|
|
|
|
function managementTabSuffix(searchParams) {
|
|
return searchParams?.get('tab') === 'auth-settings' ? '?tab=auth-settings' : ''
|
|
}
|
|
|
|
function managementConsoleDestination(searchParams, consolePath) {
|
|
return searchParams?.get('tab') === 'auth-settings' ? `${consolePath}#auth-settings` : consolePath
|
|
}
|
|
|
|
function configuredManagementConsoleHref(current) {
|
|
const access = current.account?.managementAccess
|
|
if (access?.mode !== 'isolated') return '/_dsh_platform/console/'
|
|
const entry = access.isolatedEntry
|
|
const origin = entry?.kind === 'public' ? entry.managementPublicOrigin : entry?.managementLocalOrigin
|
|
return typeof origin === 'string' ? `${origin}/` : '/_dsh_platform/console/'
|
|
}
|
|
|
|
function managementLoginPage(request, csrf, authenticationContextId, mainCredentialVersion, {
|
|
authPrefix = AUTH_PREFIX,
|
|
consolePath = '/_dsh_platform/console/',
|
|
searchParams = new URLSearchParams(),
|
|
} = {}) {
|
|
const zh = language(request) === 'zh'
|
|
const copy = zh ? {
|
|
title: 'DSH 管理中心', detail: '请输入管理中心密码。',
|
|
password: '管理中心密码', submit: '登录', failed: '验证失败,请重试。',
|
|
retryRequired: '当前浏览器已连续多次输入错误,请在 {seconds} 秒后重试。',
|
|
rateLimited: '管理员登录尝试过多,请在 {seconds} 秒后重试。',
|
|
serviceUnavailable: '认证服务暂不可用,请稍后重试。',
|
|
} : {
|
|
title: 'DSH Management Console', detail: 'Enter the Management console password.',
|
|
password: 'Management console password', submit: 'Sign in', failed: 'Authentication failed. Try again.',
|
|
retryRequired: 'This browser has made several consecutive failed attempts. Try again in {seconds} seconds.',
|
|
rateLimited: 'Too many administrator sign-in attempts. Try again in {seconds} seconds.',
|
|
serviceUnavailable: 'The authentication service is temporarily unavailable. Try again shortly.',
|
|
}
|
|
const submitPath = `${authPrefix}management/pending${managementTabSuffix(searchParams)}`
|
|
const destination = managementConsoleDestination(searchParams, consolePath)
|
|
return `<!doctype html><html lang="${zh ? 'zh-CN' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${copy.title}</title><style>html{height:100%;color-scheme:light dark}*{box-sizing:border-box}body{min-height:100%;margin:0;display:grid;place-items:center;background:#151517;color:#f3f3f4;font:14px/1.5 Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.card{width:min(400px,calc(100% - 32px));padding:28px;border:1px solid #3e3e42;border-radius:12px;background:#242426}h1{margin:0 0 6px;font-size:20px}p{margin:0 0 22px;color:#aaaab0}form{display:grid;gap:16px}label{display:grid;gap:7px;font-weight:600}input{width:100%;padding:10px 12px;border:1px solid #55555b;border-radius:7px;background:#19191b;color:inherit;font:inherit}button{padding:10px 14px;border:0;border-radius:7px;background:#f2f2f3;color:#202124;font:600 14px/1.4 inherit;cursor:pointer}input:focus-visible{border-color:#f3f3f4;outline:none}button:focus-visible{outline:2px solid #6ea8fe;outline-offset:2px}.error{color:#ff7777}[hidden]{display:none}@media(prefers-color-scheme:light){body{background:#f7f7f8;color:#202124}.card{background:#fff;border-color:#d7d7da}p{color:#6d6f76}input{background:#fff;border-color:#c7c8cc}button{background:#202124;color:#fff}}</style></head><body><main class="card"><h1>${copy.title}</h1><p>${copy.detail}</p><form method="post" action="${htmlEscape(submitPath)}" novalidate><label>${copy.password}<input name="password" type="password" autocomplete="current-password" maxlength="1024" autofocus></label><button type="submit">${copy.submit}</button><p class="error" role="alert" hidden>${copy.failed}</p></form></main><script>const form=document.querySelector('form'),error=document.querySelector('.error'),button=form.querySelector('button'),retryMessage=${JSON.stringify(copy.retryRequired)},rateLimitMessage=${JSON.stringify(copy.rateLimited)},mainCredentialVersion=${JSON.stringify(mainCredentialVersion)};let navigating=false,csrfToken=${JSON.stringify(csrf)},authenticationContextId=${JSON.stringify(authenticationContextId)};${authenticationCountdownScript(copy.submit)}async function submitAuthentication(password){const body=JSON.stringify({password,authenticationContext:authenticationContextId}),response=await fetch(${JSON.stringify(submitPath)},{method:'POST',headers:{'content-type':'application/json','x-dsh-csrf':csrfToken},body});const payload=await response.json().catch(()=>({}));if(response.status===409&&payload.code==='AUTHENTICATION_CONTEXT_STALE'){location.replace(${JSON.stringify(`${authPrefix}management${managementTabSuffix(searchParams)}`)});return new Promise(()=>{})}return{response,payload}}form.addEventListener('submit',async event=>{event.preventDefault();error.hidden=true;button.disabled=true;const values=new FormData(form);try{const{response,payload}=await submitAuthentication(values.get('password'));if(response.ok){navigating=true;location.replace(${JSON.stringify(destination)});return}if(payload.code==='PENDING_LOGIN_INVALID'){navigating=true;location.replace(${JSON.stringify(`${authPrefix}management${managementTabSuffix(searchParams)}`)});return}if(!showRetryFailure(payload)){error.textContent=payload.code==='ACCESS_MANAGER_UNAVAILABLE'?${JSON.stringify(copy.serviceUnavailable)}:${JSON.stringify(copy.failed)};error.hidden=false;form.elements.password.select()}}catch{error.textContent=${JSON.stringify(copy.serviceUnavailable)};error.hidden=false}finally{if(!navigating&&retryUntil<=Date.now())button.disabled=false}})</script></body></html>`
|
|
}
|
|
|
|
export function createBrowserAuthentication({
|
|
access,
|
|
safeReturnPath,
|
|
report = async () => {},
|
|
wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
|
|
paths: {
|
|
authPrefix = AUTH_PREFIX,
|
|
accessPrefix = ACCESS_PREFIX,
|
|
transitionPrefix = TRANSITION_PREFIX,
|
|
consolePath = '/_dsh_platform/console/',
|
|
} = {},
|
|
}) {
|
|
const managementCookiePath = consolePath === '/' ? '/' : '/_dsh_platform/'
|
|
async function accessRequest(method, path, body) {
|
|
try { return await access.request(method, path, body) }
|
|
catch (error) {
|
|
if (error !== null && typeof error === 'object'
|
|
&& (!Number.isInteger(error.statusCode) || error.statusCode >= 500)) {
|
|
error.browserAuthenticationBackend = true
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
const accessFailureStatus = error => Number.isInteger(error?.statusCode) ? error.statusCode : 503
|
|
const accessFailureCode = (error, fallback) => Number.isInteger(error?.statusCode)
|
|
? error.code ?? fallback
|
|
: 'ACCESS_MANAGER_UNAVAILABLE'
|
|
const accessFailureBody = (error, fallback) => ({
|
|
error: error.message,
|
|
code: accessFailureCode(error, fallback),
|
|
...(Number.isInteger(error?.retryAfterSeconds)
|
|
? { retryAfterSeconds: error.retryAfterSeconds } : {}),
|
|
})
|
|
const accessFailureHeaders = error => Number.isInteger(error?.retryAfterSeconds)
|
|
? { 'retry-after': String(error.retryAfterSeconds) } : {}
|
|
const isAuthenticationDenial = error => [401, 403].includes(error?.statusCode)
|
|
|
|
async function status() { return accessRequest('GET', '/v1/status') }
|
|
|
|
async function authenticationStatus() {
|
|
for (let attempt = 0; ; attempt++) {
|
|
let current
|
|
try { current = await status() }
|
|
catch (error) {
|
|
if (error?.browserAuthenticationBackend !== true || attempt >= 20) throw error
|
|
await wait(250)
|
|
continue
|
|
}
|
|
if (current.state !== 'classification-pending' || attempt >= 20) return current
|
|
await wait(250)
|
|
}
|
|
}
|
|
|
|
async function validateDsh(request, { requireCsrf = false } = {}) {
|
|
const origin = requestOrigin(request)
|
|
const value = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE)
|
|
if (origin === undefined || value === undefined) return { authenticated: false }
|
|
if (requireCsrf && !validSessionMutation(request, DSH_CSRF_COOKIE)) return { authenticated: false }
|
|
return accessRequest('POST', '/v1/sessions/validate', {
|
|
kind: 'dsh', token: value, origin,
|
|
requireCsrf,
|
|
csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined,
|
|
}).catch(error => {
|
|
if (isAuthenticationDenial(error)) return { authenticated: false }
|
|
throw error
|
|
})
|
|
}
|
|
|
|
async function validateManagement(request, { requireCsrf = false } = {}) {
|
|
const origin = requestOrigin(request)
|
|
const value = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE)
|
|
if (origin === undefined || value === undefined) return { authenticated: false }
|
|
if (requireCsrf && !validSessionMutation(request, MANAGEMENT_CSRF_COOKIE)) return { authenticated: false }
|
|
return accessRequest('POST', '/v1/sessions/validate', {
|
|
kind: 'management', token: value, origin,
|
|
requireCsrf,
|
|
csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined,
|
|
}).catch(error => {
|
|
if (isAuthenticationDenial(error)) return { authenticated: false }
|
|
throw error
|
|
})
|
|
}
|
|
|
|
async function authorizeManagement(request, { audience, method, target, requireCsrf = false } = {}) {
|
|
const origin = requestOrigin(request)
|
|
const managementToken = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE)
|
|
if (origin === undefined || managementToken === undefined) return { authorized: false }
|
|
if (requireCsrf && !validSessionMutation(request, MANAGEMENT_CSRF_COOKIE)) return { authorized: false }
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/capabilities', {
|
|
managementToken,
|
|
origin,
|
|
csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined,
|
|
requireCsrf,
|
|
audience,
|
|
method,
|
|
target,
|
|
})
|
|
return {
|
|
authorized: true,
|
|
capability: result.capability,
|
|
headers: cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE) === undefined
|
|
? {}
|
|
: {
|
|
// Refresh legacy Strict cookies while the browser is still on the
|
|
// Management origin so the next cross-origin root navigation keeps
|
|
// the existing session instead of starting a handoff.
|
|
'set-cookie': managementCookies({
|
|
token: managementToken,
|
|
csrfToken: cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE),
|
|
}, origin, managementCookiePath),
|
|
},
|
|
}
|
|
} catch (error) {
|
|
if (isAuthenticationDenial(error)) return { authorized: false }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function authorizePlugin(request, { method, target, requireCsrf = false } = {}) {
|
|
const origin = requestOrigin(request)
|
|
const dshToken = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE)
|
|
if (origin === undefined || dshToken === undefined) return { authorized: false }
|
|
if (requireCsrf && !validSessionMutation(request, DSH_CSRF_COOKIE)) return { authorized: false }
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/dsh/capabilities', {
|
|
dshToken,
|
|
origin,
|
|
csrfToken: requireCsrf ? request.headers['x-dsh-csrf'] : undefined,
|
|
requireCsrf,
|
|
method,
|
|
target,
|
|
})
|
|
return { authorized: true, capability: result.capability }
|
|
} catch (error) {
|
|
if (isAuthenticationDenial(error)) return { authorized: false }
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function sendManagementLogin(request, response, origin, searchParams) {
|
|
const authContext = managementAuthenticationContext(request, origin, managementCookiePath)
|
|
const current = await authenticationStatus()
|
|
const bytes = Buffer.from(managementLoginPage(
|
|
request, authContext.csrfToken, current.authenticationContext,
|
|
current.account?.mainCredentialVersion, { authPrefix, consolePath, searchParams },
|
|
))
|
|
response.writeHead(200, {
|
|
'cache-control': 'no-store',
|
|
'content-length': String(bytes.byteLength),
|
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'cross-origin-opener-policy': 'same-origin',
|
|
'referrer-policy': 'no-referrer',
|
|
'set-cookie': authContext.cookies,
|
|
'x-content-type-options': 'nosniff',
|
|
})
|
|
response.end(bytes)
|
|
}
|
|
|
|
async function enterManagement(request, response, searchParams) {
|
|
const origin = requestOrigin(request)
|
|
if (origin === undefined) { sendJson(response, 400, { error: 'request origin is invalid' }); return }
|
|
if ((await validateManagement(request)).authenticated) {
|
|
sendSameOriginNavigation(response, managementConsoleDestination(searchParams, consolePath))
|
|
return
|
|
}
|
|
const current = await status()
|
|
const valid = await validateDsh(request)
|
|
if (!valid.authenticated) {
|
|
const dshOrigin = current.account?.managementAccess?.mode === 'isolated'
|
|
? current.account.managementAccess.dshPublicOrigin
|
|
: origin
|
|
if (typeof dshOrigin !== 'string') {
|
|
sendJson(response, 409, { error: 'DSH authentication entry is unavailable' })
|
|
return
|
|
}
|
|
response.writeHead(303, {
|
|
'cache-control': 'no-store',
|
|
location: `${dshOrigin}${AUTH_PREFIX}?return=${encodeURIComponent(`${AUTH_PREFIX}management/start${managementTabSuffix(searchParams)}`)}`,
|
|
'referrer-policy': 'no-referrer',
|
|
})
|
|
response.end()
|
|
return
|
|
}
|
|
const entry = current.account?.managementAccess?.isolatedEntry
|
|
if (current.account?.managementAccess?.mode === 'isolated' && entry?.kind === 'local-only') {
|
|
const targetOrigin = entry.managementLocalOrigin
|
|
if (typeof targetOrigin !== 'string') { sendJson(response, 409, { error: 'local Management entry is unavailable' }); return }
|
|
const created = await accessRequest('POST', '/v1/management/handoffs', {
|
|
dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE),
|
|
dshOrigin: origin,
|
|
targetOrigin,
|
|
})
|
|
response.writeHead(303, {
|
|
'cache-control': 'no-store',
|
|
location: `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}`,
|
|
'referrer-policy': 'no-referrer',
|
|
})
|
|
response.end()
|
|
return
|
|
}
|
|
const targetOrigin = current.account?.managementAccess?.mode === 'isolated'
|
|
? entry?.managementPublicOrigin
|
|
: origin
|
|
if (typeof targetOrigin !== 'string') { sendJson(response, 409, { error: 'Management entry is unavailable' }); return }
|
|
const created = await accessRequest('POST', '/v1/management/handoffs', {
|
|
dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE),
|
|
dshOrigin: origin,
|
|
targetOrigin,
|
|
})
|
|
response.writeHead(303, {
|
|
'cache-control': 'no-store',
|
|
location: current.account?.managementAccess?.mode === 'isolated'
|
|
? `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}`
|
|
: `${AUTH_PREFIX}management/handoff?token=${encodeURIComponent(created.handoff.token)}${managementTabSuffix(searchParams).replace('?', '&')}`,
|
|
'referrer-policy': 'no-referrer',
|
|
})
|
|
response.end()
|
|
}
|
|
|
|
async function returnToDsh(request, response) {
|
|
const topLevel = request.headers['sec-fetch-mode'] === undefined
|
|
|| (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document')
|
|
const origin = requestTargetOrigin(request)
|
|
if (!topLevel || origin === undefined) {
|
|
sendJson(response, 403, { error: 'DSH return navigation is forbidden', code: 'REQUEST_FORBIDDEN' })
|
|
return
|
|
}
|
|
const valid = await validateManagement(request)
|
|
if (!valid.authenticated) {
|
|
sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' })
|
|
return
|
|
}
|
|
const current = await authenticationStatus()
|
|
const dshOrigin = current.account?.managementAccess?.mode === 'isolated'
|
|
? current.account.managementAccess.dshPublicOrigin
|
|
: origin
|
|
if (typeof dshOrigin !== 'string' || canonicalOrigin(dshOrigin) === undefined) {
|
|
sendJson(response, 409, { error: 'DSH return address is unavailable', code: 'DSH_RETURN_UNAVAILABLE' })
|
|
return
|
|
}
|
|
response.writeHead(303, {
|
|
'cache-control': 'no-store',
|
|
location: `${dshOrigin}/`,
|
|
'referrer-policy': 'no-referrer',
|
|
})
|
|
response.end()
|
|
}
|
|
|
|
async function managementHandoffLocation(current, { dshToken, dshOrigin }) {
|
|
const entry = current.account?.managementAccess?.isolatedEntry
|
|
const isolated = current.account?.managementAccess?.mode === 'isolated'
|
|
const targetOrigin = isolated
|
|
? entry?.kind === 'local-only' ? entry.managementLocalOrigin : entry?.managementPublicOrigin
|
|
: dshOrigin
|
|
if (typeof targetOrigin !== 'string') throw Object.assign(new Error('Management entry is unavailable'), {
|
|
code: 'MANAGEMENT_ENTRY_UNAVAILABLE', statusCode: 409,
|
|
})
|
|
const created = await accessRequest('POST', '/v1/management/handoffs', { dshToken, dshOrigin, targetOrigin })
|
|
return isolated
|
|
? `${targetOrigin}/auth/management/handoff?token=${encodeURIComponent(created.handoff.token)}`
|
|
: `${authPrefix}management/handoff?token=${encodeURIComponent(created.handoff.token)}`
|
|
}
|
|
|
|
async function renewManagementLogin(request, origin, value) {
|
|
const current = await status()
|
|
const dshToken = cookieValue(request.headers.cookie, DSH_SESSION_COOKIE)
|
|
const dshOrigin = current.account?.managementAccess?.mode === 'isolated'
|
|
? current.account.managementAccess.dshPublicOrigin
|
|
: origin
|
|
if (dshToken === undefined || typeof dshOrigin !== 'string') {
|
|
throw Object.assign(new Error('authentication context is stale'), {
|
|
code: 'AUTHENTICATION_CONTEXT_STALE', statusCode: 409,
|
|
})
|
|
}
|
|
try {
|
|
const created = await accessRequest('POST', '/v1/management/handoffs', {
|
|
dshToken, dshOrigin, targetOrigin: origin,
|
|
})
|
|
const exchanged = await accessRequest('POST', '/v1/management/handoffs/consume', {
|
|
token: created.handoff.token, origin,
|
|
})
|
|
if (exchanged.session !== undefined) return exchanged
|
|
if (exchanged.pending?.token === undefined) throw new Error('renewed Management login is invalid')
|
|
return await accessRequest('POST', '/v1/management/pending/complete', {
|
|
...value,
|
|
authenticationContext: (await authenticationStatus()).authenticationContext,
|
|
origin,
|
|
pendingToken: exchanged.pending.token,
|
|
})
|
|
} catch (error) {
|
|
if (['SESSION_INVALID', 'HANDOFF_INVALID', 'PENDING_LOGIN_INVALID'].includes(error?.code)) {
|
|
throw Object.assign(new Error('authentication context is stale'), {
|
|
code: 'AUTHENTICATION_CONTEXT_STALE', statusCode: 409,
|
|
})
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
|
|
async function handle(request, response, pathname, searchParams) {
|
|
if (pathname === transitionPrefix + 'probe' && request.method === 'GET') {
|
|
// Same-origin GET fetches may omit Origin. The target authority is a
|
|
// valid source only for that same-origin case; cross-origin probes still
|
|
// require the browser-supplied Origin header.
|
|
const candidateOrigin = requestTargetOrigin(request)
|
|
const requestOrigin = canonicalOrigin(request.headers.origin)
|
|
const sourceOrigin = requestOrigin ?? candidateOrigin
|
|
if (sourceOrigin === undefined || candidateOrigin === undefined) {
|
|
sendJson(response, 400, { error: 'transition probe origin is invalid', code: 'TRANSITION_PROBE_INVALID' })
|
|
return true
|
|
}
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/management/transitions/probe', {
|
|
transitionId: searchParams.get('transitionId'),
|
|
nonce: searchParams.get('nonce'),
|
|
sourceOrigin,
|
|
candidateOrigin,
|
|
})
|
|
sendJson(response, 200, result, {
|
|
'access-control-allow-origin': sourceOrigin,
|
|
'access-control-expose-headers': 'content-type',
|
|
vary: 'Origin',
|
|
})
|
|
} catch (error) {
|
|
sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'TRANSITION_PROBE_INVALID') }, {
|
|
'access-control-allow-origin': sourceOrigin,
|
|
vary: 'Origin',
|
|
})
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === transitionPrefix + 'continue' && request.method === 'GET') {
|
|
const origin = requestTargetOrigin(request)
|
|
const topLevel = request.headers['sec-fetch-mode'] === undefined
|
|
|| (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document')
|
|
if (!topLevel || origin === undefined) {
|
|
sendJson(response, 403, { error: 'Management continuation is forbidden', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/management/continuations/consume', {
|
|
token: searchParams.get('token'), origin,
|
|
})
|
|
sendSameOriginNavigation(response, consolePath, managementCookies(result.session, origin, managementCookiePath))
|
|
} catch (error) {
|
|
sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'CONTINUATION_INVALID') })
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === accessPrefix + 'status' && request.method === 'GET') {
|
|
const current = await status()
|
|
sendJson(response, 200, { state: current.state })
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'context' && request.method === 'GET') {
|
|
if (!validAuthenticationContextRequest(request)) {
|
|
sendJson(response, 403, { error: 'authentication context request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request)
|
|
const authContext = authenticationContext(request, origin, authPrefix)
|
|
const current = await authenticationStatus()
|
|
const totpRetry = current.state === 'initialized' && current.account?.totp?.enabled === true
|
|
? await accessRequest('POST', '/v1/authentication-retry/status', {
|
|
credential: 'totp', source: authenticationSource(request),
|
|
})
|
|
: { kind: null, retryAfterSeconds: 0 }
|
|
sendJson(response, 200, {
|
|
csrfToken: authContext.csrfToken,
|
|
authenticationContext: current.authenticationContext,
|
|
managementAdditionalPasswordEnabled:
|
|
current.account?.managementAdditionalCredential?.enabled === true,
|
|
mainCredentialVersion: current.account?.mainCredentialVersion ?? null,
|
|
totpRetry,
|
|
}, { 'set-cookie': authContext.cookies })
|
|
return true
|
|
}
|
|
const authenticationEntry = pathname === authPrefix.slice(0, -1) || pathname === authPrefix
|
|
const authenticationReset = pathname === authPrefix + 'reset'
|
|
if (authenticationEntry || (authenticationReset && ['GET', 'HEAD'].includes(request.method ?? 'GET'))) {
|
|
if (!['GET', 'HEAD'].includes(request.method ?? 'GET')) return false
|
|
const current = await status()
|
|
const valid = await validateDsh(request)
|
|
const returnPath = safeReturnPath(searchParams.get('return'))
|
|
if (current.state === 'initialized' && valid.authenticated) {
|
|
sendSameOriginNavigation(response, returnPath)
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request) ?? 'http://invalid.local'
|
|
const authContext = authenticationContext(request, origin, authPrefix)
|
|
const resetForm = authenticationReset && current.state === 'recovery-required'
|
|
if (authenticationReset && !resetForm) {
|
|
response.writeHead(303, { 'cache-control': 'no-store', location: `${authPrefix}?return=${encodeURIComponent(returnPath)}` })
|
|
response.end()
|
|
return true
|
|
}
|
|
const bytes = Buffer.from(authenticationPage(
|
|
request, current.state, authContext.csrfToken, current.authenticationContext, returnPath, { resetForm },
|
|
))
|
|
response.writeHead(200, {
|
|
'cache-control': 'no-store',
|
|
'content-length': String(bytes.byteLength),
|
|
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
'content-type': 'text/html; charset=utf-8',
|
|
'referrer-policy': 'no-referrer',
|
|
'set-cookie': [
|
|
...authContext.cookies,
|
|
...clearInvalidBrowserSessionCookies(origin, managementCookiePath),
|
|
],
|
|
'x-dsh-csrf': authContext.csrfToken,
|
|
'x-content-type-options': 'nosniff',
|
|
})
|
|
response.end(request.method === 'HEAD' ? undefined : bytes)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'session' && request.method === 'POST') {
|
|
const csrf = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE)
|
|
if (!validBrowserMutationShape(request)) {
|
|
sendJson(response, 403, { error: 'authentication request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
const value = await jsonBody(request)
|
|
const current = await authenticationStatus()
|
|
const completingTotp = current.state === 'initialized' && typeof value.totpCode === 'string'
|
|
if (completingTotp && (await validateDsh(request)).authenticated) {
|
|
sendJson(response, 200, {
|
|
authenticated: true,
|
|
next: safeReturnPath(searchParams.get('return')),
|
|
}, { 'set-cookie': clearTotpLoginCookie(origin, authPrefix) })
|
|
return true
|
|
}
|
|
if (!validBrowserMutation(request, csrf)) {
|
|
sendJson(response, 409, { error: 'authentication context is stale', code: 'AUTHENTICATION_CONTEXT_STALE' })
|
|
return true
|
|
}
|
|
const route = current.state === 'never-initialized'
|
|
? '/v1/dsh/initialize' : completingTotp ? '/v1/dsh/totp/complete' : '/v1/dsh/login'
|
|
if (!['never-initialized', 'initialized'].includes(current.state)) {
|
|
sendJson(response, 409, { error: 'administrator access is unavailable', code: 'ACCESS_UNAVAILABLE' })
|
|
return true
|
|
}
|
|
try {
|
|
const result = await accessRequest('POST', route, {
|
|
...value,
|
|
origin,
|
|
client: loginClient(request),
|
|
authenticationSource: authenticationSource(request),
|
|
...(completingTotp
|
|
? { challengeToken: cookieValue(request.headers.cookie, TOTP_LOGIN_COOKIE), code: value.totpCode }
|
|
: {}),
|
|
})
|
|
if (result.totpRequired === true && result.challenge !== undefined) {
|
|
sendJson(response, 202, { authenticated: false, totpRequired: true }, {
|
|
'set-cookie': totpLoginCookie(result.challenge, origin, authPrefix),
|
|
})
|
|
return true
|
|
}
|
|
const returnPath = safeReturnPath(searchParams.get('return'))
|
|
const next = current.state === 'initialized' && returnPath === `${authPrefix}management/start`
|
|
? await managementHandoffLocation(current, { dshToken: result.session.token, dshOrigin: origin })
|
|
: returnPath
|
|
await report(current.state === 'never-initialized' ? 'gateway.access.initialized' : 'gateway.access.logged-in')
|
|
sendJson(response, current.state === 'never-initialized' ? 201 : 200, { authenticated: true, next }, {
|
|
'set-cookie': sessionCookies(result.session, origin),
|
|
})
|
|
} catch (error) {
|
|
await report('gateway.access.login-failed', { code: error.code ?? null, level: 'warning' })
|
|
sendJson(response, accessFailureStatus(error), accessFailureBody(error, 'AUTHENTICATION_FAILED'), accessFailureHeaders(error))
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'reset' && request.method === 'POST') {
|
|
const csrf = cookieValue(request.headers.cookie, AUTH_CSRF_COOKIE)
|
|
if (!validBrowserMutationShape(request)) {
|
|
sendJson(response, 403, { error: 'authentication reset request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
if (!validBrowserMutation(request, csrf)) {
|
|
sendJson(response, 409, { error: 'authentication context is stale', code: 'AUTHENTICATION_CONTEXT_STALE' })
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
const current = await authenticationStatus()
|
|
if (!['migration-required', 'recovery-required'].includes(current.state)) {
|
|
sendJson(response, 409, { error: 'administrator authentication reset is unavailable', code: 'AUTHENTICATION_RESET_UNAVAILABLE' })
|
|
return true
|
|
}
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/dsh/reset-authentication', {
|
|
...await jsonBody(request),
|
|
origin,
|
|
client: loginClient(request),
|
|
authenticationSource: authenticationSource(request),
|
|
})
|
|
await report('gateway.access.authentication-reset', { previousState: current.state })
|
|
sendJson(response, 201, { authenticated: true }, { 'set-cookie': sessionCookies(result.session, origin) })
|
|
} catch (error) {
|
|
await report('gateway.access.authentication-reset-failed', { code: error.code ?? null, level: 'warning' })
|
|
sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'AUTHENTICATION_RESET_FAILED') })
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'session-context' && request.method === 'GET') {
|
|
const valid = await validateDsh(request)
|
|
if (!valid.authenticated) {
|
|
sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' })
|
|
return true
|
|
}
|
|
const current = await status()
|
|
sendJson(response, 200, {
|
|
managementAdditionalPasswordEnabled:
|
|
current.account?.managementAdditionalCredential?.enabled === true,
|
|
managementConsoleHref: configuredManagementConsoleHref(current),
|
|
})
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/session-context' && request.method === 'GET') {
|
|
const valid = await validateManagement(request)
|
|
if (!valid.authenticated) {
|
|
sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' })
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request)
|
|
const managementToken = cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE)
|
|
const csrfToken = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE)
|
|
const headers = origin !== undefined && managementToken !== undefined && csrfToken !== undefined
|
|
? { 'set-cookie': managementCookies({ token: managementToken, csrfToken }, origin, managementCookiePath) }
|
|
: {}
|
|
sendJson(response, 200, { authenticated: true }, headers)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'browser-logout' && request.method === 'POST') {
|
|
const valid = await validateDsh(request, { requireCsrf: true })
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
if (!valid.authenticated || origin === undefined) {
|
|
sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
const value = await jsonBody(request)
|
|
if (!['management', 'all'].includes(value.scope)) {
|
|
sendJson(response, 400, { error: 'logout scope is invalid', code: 'REQUEST_INVALID' })
|
|
return true
|
|
}
|
|
const result = await accessRequest('POST', '/v1/dsh/browser-logout', {
|
|
scope: value.scope,
|
|
dshToken: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE),
|
|
dshOrigin: origin,
|
|
managementToken: cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE),
|
|
})
|
|
const cookies = value.scope === 'all'
|
|
? [...clearSessionCookies(origin), ...clearManagementCookies(origin, managementCookiePath)]
|
|
: clearManagementCookies(origin, managementCookiePath)
|
|
await report('gateway.access.browser-sessions-logged-out', { scope: value.scope })
|
|
sendJson(response, 200, result, { 'set-cookie': cookies })
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'logout' && request.method === 'POST') {
|
|
const valid = await validateDsh(request, { requireCsrf: true })
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
if (!validBrowserMutation(request, cookieValue(request.headers.cookie, DSH_CSRF_COOKIE), DSH_CSRF_COOKIE)
|
|
|| !valid.authenticated) {
|
|
sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
await accessRequest('POST', '/v1/sessions/logout', {
|
|
kind: 'dsh', token: cookieValue(request.headers.cookie, DSH_SESSION_COOKIE),
|
|
})
|
|
sendJson(response, 200, { authenticated: false }, {
|
|
'set-cookie': [...clearSessionCookies(origin), ...clearManagementCookies(origin, managementCookiePath)],
|
|
})
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
await enterManagement(request, response, searchParams)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/start' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
await enterManagement(request, response, searchParams)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'return-dsh' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
await returnToDsh(request, response)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/handoff' && request.method === 'GET') {
|
|
const topLevel = request.headers['sec-fetch-mode'] === undefined
|
|
|| (request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document')
|
|
const origin = requestOrigin(request)
|
|
if (!topLevel || origin === undefined) {
|
|
sendJson(response, 403, { error: 'handoff request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
try {
|
|
const result = await accessRequest('POST', '/v1/management/handoffs/consume', {
|
|
token: searchParams.get('token'), origin,
|
|
})
|
|
if (result.pending !== undefined) {
|
|
sendSameOriginNavigation(response, `${authPrefix}management/pending${managementTabSuffix(searchParams)}`, pendingCookie(result.pending, origin, authPrefix))
|
|
} else {
|
|
sendSameOriginNavigation(response, managementConsoleDestination(searchParams, consolePath), managementCookies(result.session, origin, managementCookiePath))
|
|
}
|
|
} catch (error) {
|
|
sendJson(response, accessFailureStatus(error), { error: error.message, code: accessFailureCode(error, 'HANDOFF_INVALID') })
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/pending' && ['GET', 'HEAD'].includes(request.method ?? 'GET')) {
|
|
const origin = requestOrigin(request)
|
|
if (origin === undefined || cookieValue(request.headers.cookie, MANAGEMENT_PENDING_COOKIE) === undefined) {
|
|
response.writeHead(303, { 'cache-control': 'no-store', location: `${authPrefix}management${managementTabSuffix(searchParams)}` })
|
|
response.end()
|
|
} else await sendManagementLogin(request, response, origin, searchParams)
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/pending' && request.method === 'POST') {
|
|
const csrf = cookieValue(request.headers.cookie, MANAGEMENT_CSRF_COOKIE)
|
|
if (!validBrowserMutationShape(request)) {
|
|
sendJson(response, 403, { error: 'authentication request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
try {
|
|
const value = await jsonBody(request)
|
|
let result
|
|
if (validBrowserMutation(request, csrf)) {
|
|
try {
|
|
result = await accessRequest('POST', '/v1/management/pending/complete', {
|
|
...value, origin,
|
|
pendingToken: cookieValue(request.headers.cookie, MANAGEMENT_PENDING_COOKIE),
|
|
})
|
|
} catch (error) {
|
|
if (!['AUTHENTICATION_CONTEXT_STALE', 'PENDING_LOGIN_INVALID'].includes(error?.code)) throw error
|
|
}
|
|
}
|
|
result ??= await renewManagementLogin(request, origin, value)
|
|
sendJson(response, 200, { authenticated: true }, { 'set-cookie': managementCookies(result.session, origin, managementCookiePath) })
|
|
} catch (error) {
|
|
sendJson(response, accessFailureStatus(error), accessFailureBody(error, 'AUTHENTICATION_FAILED'), accessFailureHeaders(error))
|
|
}
|
|
return true
|
|
}
|
|
if (pathname === authPrefix + 'management/logout' && request.method === 'POST') {
|
|
const valid = await validateManagement(request, { requireCsrf: true })
|
|
const origin = requestOrigin(request, { requireHeader: true })
|
|
if (!valid.authenticated || origin === undefined) {
|
|
sendJson(response, 403, { error: 'logout request rejected', code: 'REQUEST_FORBIDDEN' })
|
|
return true
|
|
}
|
|
await accessRequest('POST', '/v1/sessions/logout', {
|
|
kind: 'management', token: cookieValue(request.headers.cookie, MANAGEMENT_SESSION_COOKIE),
|
|
})
|
|
sendJson(response, 200, { authenticated: false }, { 'set-cookie': clearManagementCookies(origin, managementCookiePath) })
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
return Object.freeze({ status, validateDsh, validateManagement, authorizeManagement, authorizePlugin, enterManagement, handle })
|
|
}
|
|
|
|
export function rejectDshAuthentication(request, response, safeReturnPath) {
|
|
const url = new URL(request.url ?? '/', 'http://gateway.internal')
|
|
const navigation = request.method === 'GET'
|
|
&& (request.headers['sec-fetch-mode'] === 'navigate' || request.headers.accept?.includes('text/html'))
|
|
if (navigation) {
|
|
const returnPath = safeReturnPath(`${url.pathname}${url.search}${url.hash}`)
|
|
response.writeHead(303, {
|
|
'cache-control': 'no-store',
|
|
location: `${AUTH_PREFIX}?return=${encodeURIComponent(returnPath)}`,
|
|
})
|
|
response.end()
|
|
} else sendJson(response, 401, { error: 'authentication required', code: 'AUTHENTICATION_REQUIRED' })
|
|
}
|