Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 3 additions & 82 deletions e2e/account-secrets.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { expect, test } from './playwright-utils.ts'

const e2eCookieSecret = 'LOCAL_AND_PREVIEW_COOKIE_SECRET_32_CHARS_MINIMUM'
const secretHostApprovalPurpose = 'secret-host-approval'

async function saveSecret(
page: Parameters<Parameters<typeof test>[1]>[0]['page'],
input: {
Expand All @@ -29,75 +26,6 @@ async function saveSecret(
expect(response.ok()).toBeTruthy()
}

function bytesToBase64Url(bytes: Uint8Array) {
let binary = ''
for (const value of bytes) {
binary += String.fromCharCode(value)
}
return btoa(binary)
.replaceAll('+', '-')
.replaceAll('/', '_')
.replaceAll('=', '')
}

async function deriveEncryptionKey(cookieSecret: string, purpose: string) {
const digest = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(`${purpose}:${cookieSecret}`),
)
return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, [
'encrypt',
'decrypt',
])
}

async function encryptStringWithPurpose(purpose: string, value: string) {
const key = await deriveEncryptionKey(e2eCookieSecret, purpose)
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv,
},
key,
new TextEncoder().encode(value),
)
return `${bytesToBase64Url(iv)}.${bytesToBase64Url(
new Uint8Array(ciphertext),
)}`
}

async function createHostApprovalToken(input: {
userId: string
name: string
requestedHost: string
}) {
const now = Date.now()
const token = await encryptStringWithPurpose(
secretHostApprovalPurpose,
JSON.stringify({
kind: 'host',
userId: input.userId,
name: input.name,
scope: 'user',
requestedHost: input.requestedHost,
storageContext: null,
iat: now,
exp: now + 60_000,
}),
)
return `host:${token}`
}

async function createStableUserIdFromEmail(email: string) {
const normalized = email.trim().toLowerCase()
const data = new TextEncoder().encode(normalized)
const hash = await crypto.subtle.digest('SHA-256', data)
return Array.from(new Uint8Array(hash), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('')
}

test('switching secrets updates detail view without a full reload', async ({
page,
login,
Expand Down Expand Up @@ -172,7 +100,7 @@ test('landing on an approval link shows already added when the host is present',
await saveSecret(page, secret)

await page.goto(
`/account/secrets/user/${secret.name}?allowed-host=api.cloudflare.com&request=stale-token`,
`/account/secrets/user/${secret.name}?allowed-host=api.cloudflare.com`,
)

await expect(
Expand All @@ -188,7 +116,7 @@ test('generated host approval link shows one-click approve and persists host', a
page,
login,
}) => {
const user = await login()
await login()
const nonce = Date.now().toString(36)
const secret = {
name: `fly-token-${nonce}`,
Expand All @@ -198,15 +126,8 @@ test('generated host approval link shows one-click approve and persists host', a
await saveSecret(page, secret)

const requestedHost = 'api.fly.io'
const token = await createHostApprovalToken({
userId: await createStableUserIdFromEmail(user.email),
name: secret.name,
requestedHost,
})
await page.goto(
`/account/secrets/user/${secret.name}?allowed-host=${requestedHost}&request=${encodeURIComponent(
token,
)}`,
`/account/secrets/user/${secret.name}?allowed-host=${requestedHost}`,
)

const approvalCard = page.getByRole('heading', {
Expand Down
8 changes: 1 addition & 7 deletions packages/worker/client/routes/account-approval-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ export type ApprovalAction = 'approve' | 'reject'
export type ApprovalScope = 'session' | 'app' | 'user'

export type ApprovalView = {
token: string
name: string
scope: ApprovalScope
requestedHost: string
Expand All @@ -27,11 +26,7 @@ export async function readJson<T>(response: Response) {

export async function submitApprovalRequest<
T extends { ok?: boolean; error?: string },
>(
action: ApprovalAction,
requestToken: string,
requestUrl = accountSecretsApiPath,
) {
>(action: ApprovalAction, requestUrl = accountSecretsApiPath) {
const response = await fetch(requestUrl, {
method: 'POST',
headers: {
Expand All @@ -41,7 +36,6 @@ export async function submitApprovalRequest<
credentials: 'include',
body: JSON.stringify({
action,
requestToken,
}),
})
if (response.status === 401) {
Expand Down
12 changes: 6 additions & 6 deletions packages/worker/client/routes/account-secrets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,11 +381,10 @@ function replaceSecretsLocation(to: string) {

function getDataRefreshKey(href: string) {
const url = new URL(href, 'http://localhost')
const request = url.searchParams.get('request') ?? ''
const requestedHost = url.searchParams.get('allowed-host') ?? ''
const requestedCapability = url.searchParams.get('capability') ?? ''
const requestedPackageId = url.searchParams.get('package_id') ?? ''
return `${url.pathname}?request=${request}&allowed-host=${requestedHost}&capability=${requestedCapability}&package_id=${requestedPackageId}`
return `${url.pathname}?allowed-host=${requestedHost}&capability=${requestedCapability}&package_id=${requestedPackageId}`
}

function readFilterState(
Expand Down Expand Up @@ -628,14 +627,16 @@ export function AccountSecretsRoute(handle: Handle) {
handle.update()

try {
const selection = getSelectionState(getCurrentHref())
const requestUrl = new URL(accountSecretsApiPath, getCurrentHref())
const currentUrl = new URL(getCurrentHref())
const selection = getSelectionState(currentUrl.toString())
const requestUrl = new URL(accountSecretsApiPath, currentUrl)
requestUrl.search = currentUrl.search
if (selection.selectedSecretId) {
requestUrl.searchParams.set('selected', selection.selectedSecretId)
}
const payload = await submitApprovalRequest<
AccountSecretsPayload & { error?: string; ok?: boolean }
>(action, approval.token, requestUrl.toString())
>(action, requestUrl.toString())
Comment thread
cursor[bot] marked this conversation as resolved.
if (!payload) return

applyPayload(
Expand Down Expand Up @@ -665,7 +666,6 @@ export function AccountSecretsRoute(handle: Handle) {
)
: buildHrefWithUpdatedFilters({}, { pathname: secretsBasePath })
const nextUrl = new URL(nextHref, window.location.href)
nextUrl.searchParams.delete('request')
nextUrl.searchParams.delete('allowed-host')
nextUrl.searchParams.delete('capability')
nextUrl.searchParams.delete('package_id')
Expand Down
107 changes: 1 addition & 106 deletions packages/worker/client/routes/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,23 @@ import {
cardCss,
cardTitleCss,
descriptionCss,
getPrimaryButtonCss,
getSecondaryButtonCss,
primaryLinkCss,
} from '#client/styles/style-primitives.ts'
import {
type AccountStatus,
type ApprovalAction,
type ApprovalView,
accountSecretsApiPath,
readJson,
submitApprovalRequest,
} from '#client/routes/account-approval-shared.ts'

type AccountSecretsPayload = {
ok: true
email: string
approval: ApprovalView | null
}

export function AccountRoute(handle: Handle) {
let status: AccountStatus = 'loading'
let email = ''
let approval: ApprovalView | null = null
let message: string | null = null
let submittingApprovalAction: ApprovalAction | null = null
let lastLoadedHref = ''

async function loadAccountSecrets(signal: AbortSignal) {
Expand All @@ -54,10 +46,8 @@ export function AccountRoute(handle: Handle) {
throw new Error('Unable to load your account secrets.')
}
email = payload.email
approval = payload.approval
status = 'ready'
message = null
submittingApprovalAction = null
handle.update()
} catch (error) {
if (signal.aborted) return
Expand All @@ -68,36 +58,6 @@ export function AccountRoute(handle: Handle) {
}
}

async function submitApproval(action: ApprovalAction) {
if (!approval || submittingApprovalAction != null) return
submittingApprovalAction = action
message = null
handle.update()
try {
const payload = await submitApprovalRequest<
AccountSecretsPayload & { error?: string; ok?: boolean }
>(action, approval.token)
if (!payload) return
email = payload.email
approval = payload.approval
submittingApprovalAction = null
message =
action === 'approve'
? 'Approved requested host.'
: 'Rejected host approval request.'
handle.update()
if (typeof window !== 'undefined' && window.location.search) {
window.history.replaceState(null, '', '/account')
lastLoadedHref = window.location.href
}
} catch (error) {
submittingApprovalAction = null
message =
error instanceof Error ? error.message : 'Unable to process approval.'
handle.update()
}
}

return () => {
const currentHref =
typeof window === 'undefined' ? '/account' : window.location.href
Expand Down Expand Up @@ -132,70 +92,8 @@ export function AccountRoute(handle: Handle) {
</p>
</header>

{approval && !isRefreshingForLocationChange ? (
<section
css={{
display: 'grid',
gap: spacing.md,
padding: spacing.lg,
borderRadius: '1rem',
border: `1px solid ${colors.primary}`,
backgroundColor: colors.primarySoftest,
}}
>
<div css={{ display: 'grid', gap: spacing.xs }}>
<h2
css={{
margin: 0,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text,
}}
>
Approve host access
</h2>
<p css={{ margin: 0, color: colors.textMuted }}>
Allow <code>{approval.requestedHost}</code> to receive secret{' '}
<code>{approval.name}</code> from the {approval.scope} scope.
</p>
<p css={{ margin: 0, color: colors.textMuted }}>
Current allowed hosts:{' '}
{approval.currentAllowedHosts.length > 0
? approval.currentAllowedHosts.join(', ')
: 'none'}
</p>
</div>
<div css={{ display: 'flex', gap: spacing.sm, flexWrap: 'wrap' }}>
<button
type="button"
disabled={
submittingApprovalAction != null ||
isRefreshingForLocationChange
}
on={{ click: () => void submitApproval('approve') }}
css={primaryButtonCss}
>
Approve host
</button>
<button
type="button"
disabled={
submittingApprovalAction != null ||
isRefreshingForLocationChange
}
on={{ click: () => void submitApproval('reject') }}
css={secondaryButtonCss}
>
Reject
</button>
</div>
</section>
) : null}

{status === 'loading' ? (
<p css={{ color: colors.textMuted, margin: 0 }}>
Loading secret approvals…
</p>
<p css={{ color: colors.textMuted, margin: 0 }}>Loading account…</p>
) : null}
{message ? (
<p
Expand Down Expand Up @@ -224,6 +122,3 @@ export function AccountRoute(handle: Handle) {
)
}
}

const primaryButtonCss = getPrimaryButtonCss({ mobileFullWidth: true })
const secondaryButtonCss = getSecondaryButtonCss({ mobileFullWidth: true })
Loading
Loading