Skip to content
Open
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
42 changes: 21 additions & 21 deletions frontend/app/webauth/[requestCode]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
} from '@/utils/crypto'

import { getDevicePassword } from '@/utils/localStorage'
import { humanReadableExpiryTimestamp } from '@/utils/tokens'
import { WebAuthRequestParams, parseWebAuthRequest, expiryFromLifetime } from '@/utils/webAuth'
import { useMutation } from '@apollo/client'
import { Disclosure, Transition } from '@headlessui/react'
import axios from 'axios'
Expand All @@ -33,30 +35,13 @@ import { FaChevronRight, FaExclamationTriangle, FaCheckCircle, FaShieldAlt } fro
import { SiGithub, SiGnometerminal, SiSlack } from 'react-icons/si'
import { toast } from 'react-toastify'

interface WebAuthRequestParams {
port: number
publicKey: string
requestedTokenName: string
}

const handleCopy = (val: string) => {
copyToClipBoard(val)
toast.info('Copied', {
autoClose: 2000,
})
}

const getWebAuthRequestParams = (hash: string): WebAuthRequestParams => {
const delimiter = '-'
const params = hash.split(delimiter)

return {
port: Number(params[0]),
publicKey: params[1],
requestedTokenName: params[2],
}
}

export default function WebAuth({ params }: { params: { requestCode: string } }) {
const router = useRouter()
const { organisations } = useContext(organisationContext)
Expand All @@ -71,7 +56,12 @@ export default function WebAuth({ params }: { params: { requestCode: string } })

const { data: session } = useSession()

const handleCreatePat = (name: string, organisationId: string, keyring: OrganisationKeyring) => {
const handleCreatePat = (
name: string,
organisationId: string,
keyring: OrganisationKeyring,
expiry: number | null
) => {
return new Promise<string>(async (resolve, reject) => {
if (keyring) {
const userKxKeys = {
Expand All @@ -83,7 +73,7 @@ export default function WebAuth({ params }: { params: { requestCode: string } })
organisationId,
userKxKeys,
name,
null
expiry
)

const { data } = await createUserToken({
Expand Down Expand Up @@ -115,11 +105,14 @@ export default function WebAuth({ params }: { params: { requestCode: string } })
throw new Error('Incorrect sudo password')
}

const expiry = expiryFromLifetime(requestParams.requestedTokenLifetime)

try {
const pssUser = await handleCreatePat(
requestParams.requestedTokenName,
organisation.id,
keyring
keyring,
expiry
)
setUserToken(pssUser)

Expand Down Expand Up @@ -150,7 +143,7 @@ export default function WebAuth({ params }: { params: { requestCode: string } })
const validateWebAuthRequest = async () => {
try {
const decodedWebAuthReq = await decodeb64string(decodeURIComponent(params.requestCode))
const authRequestParams = getWebAuthRequestParams(decodedWebAuthReq)
const authRequestParams = parseWebAuthRequest(decodedWebAuthReq)

if (!authRequestParams.publicKey || !authRequestParams.requestedTokenName) {
setStatus('invalid')
Expand Down Expand Up @@ -308,6 +301,13 @@ export default function WebAuth({ params }: { params: { requestCode: string } })
<p className="text-neutral-500 text-lg">
Choose an account below to authenticate with the Phase CLI
</p>
{requestParams && (
<p className="text-neutral-500 text-sm pt-1">
{humanReadableExpiryTimestamp(
expiryFromLifetime(requestParams.requestedTokenLifetime)
)}
</p>
)}
</div>
<div className="flex flex-col gap-4 w-ful max-w-2xl">
{organisations?.map((organisation, index) => (
Expand Down
14 changes: 14 additions & 0 deletions frontend/tests/utils/tokens.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { humanReadableExpiryTimestamp } from '@/utils/tokens'

describe('humanReadableExpiryTimestamp', () => {
it('describes a null expiry as never expiring', () => {
expect(humanReadableExpiryTimestamp(null)).toBe('This token will never expire.')
})

it('describes a timestamp as a localized expiry date', () => {
const expiry = 1700000000000
expect(humanReadableExpiryTimestamp(expiry)).toBe(
`This token will expire on ${new Date(expiry).toLocaleDateString()}.`
)
})
})
94 changes: 94 additions & 0 deletions frontend/tests/utils/webAuth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expiryFromLifetime, parseWebAuthRequest } from '@/utils/webAuth'

describe('parseWebAuthRequest', () => {
describe('JSON payload (new CLI)', () => {
it('parses a JSON payload with a requested lifetime', () => {
const decoded = JSON.stringify({
port: 8002,
publicKey: 'abc123',
name: 'john@laptop',
lifetime: 604800,
})

expect(parseWebAuthRequest(decoded)).toEqual({
port: 8002,
publicKey: 'abc123',
requestedTokenName: 'john@laptop',
requestedTokenLifetime: 604800,
})
})

it('treats a missing lifetime as never-expiring', () => {
const decoded = JSON.stringify({ port: 8002, publicKey: 'abc123', name: 'john@laptop' })

expect(parseWebAuthRequest(decoded).requestedTokenLifetime).toBeNull()
})

it('treats a non-positive lifetime as never-expiring', () => {
const decoded = JSON.stringify({
port: 8002,
publicKey: 'abc123',
name: 'john@laptop',
lifetime: 0,
})

expect(parseWebAuthRequest(decoded).requestedTokenLifetime).toBeNull()
})

it('preserves a token name containing hyphens', () => {
const decoded = JSON.stringify({
port: 8002,
publicKey: 'abc123',
name: 'john@my-dev-laptop',
lifetime: 3600,
})

expect(parseWebAuthRequest(decoded).requestedTokenName).toBe('john@my-dev-laptop')
})
})

describe('legacy hyphen-joined payload (old CLI)', () => {
it('parses the legacy `port-pubKeyHex-patName` format with no lifetime', () => {
expect(parseWebAuthRequest('8002-abc123-john@laptop')).toEqual({
port: 8002,
publicKey: 'abc123',
requestedTokenName: 'john@laptop',
requestedTokenLifetime: null,
})
})

it('keeps a token name that itself contains hyphens', () => {
expect(parseWebAuthRequest('8002-abc123-john@my-dev-laptop').requestedTokenName).toBe(
'john@my-dev-laptop'
)
})

it('falls back to the legacy parse when the payload is valid JSON but not an object', () => {
// `8002` parses as a JSON number; the non-object guard must reject it and use the legacy path.
expect(parseWebAuthRequest('8002')).toEqual({
port: 8002,
publicKey: '',
requestedTokenName: '',
requestedTokenLifetime: null,
})
})
})
})

describe('expiryFromLifetime', () => {
it('returns null for a null lifetime', () => {
expect(expiryFromLifetime(null)).toBeNull()
})

it('returns null for a zero lifetime', () => {
expect(expiryFromLifetime(0)).toBeNull()
})

it('returns an absolute ms timestamp lifetime seconds in the future', () => {
const now = Date.now()
const expiry = expiryFromLifetime(604800)

expect(expiry).not.toBeNull()
expect(expiry!).toBeGreaterThanOrEqual(now + 604800 * 1000)
})
})
5 changes: 5 additions & 0 deletions frontend/utils/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export const humanReadableExpiry = (expiryOption: ExpiryOptionT) =>
? 'This token will never expire.'
: `This token will expire on ${new Date(expiryOption.getExpiry()!).toLocaleDateString()}.`

export const humanReadableExpiryTimestamp = (expiry: number | null) =>
expiry === null
? 'This token will never expire.'
: `This token will expire on ${new Date(expiry).toLocaleDateString()}.`

export const compareExpiryOptions = (a: ExpiryOptionT, b: ExpiryOptionT) => {
return a.getExpiry() === b.getExpiry()
}
61 changes: 61 additions & 0 deletions frontend/utils/webAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
export interface WebAuthRequestParams {
port: number
publicKey: string
requestedTokenName: string
// The token lifetime requested by the CLI, in seconds. null = never expires.
requestedTokenLifetime: number | null
}

/**
* Parse a decoded webauth request payload sent by the Phase CLI.
*
* New CLIs send a base64-encoded JSON object:
* { port: number, publicKey: string, name: string, lifetime?: number }
* where `lifetime` is the requested token lifetime in seconds (omitted, null or
* non-positive = never expires).
*
* Older CLIs send a hyphen-joined string `port-pubKeyHex-patName`. The token name
* itself can contain hyphens, so everything after the second hyphen is the name.
*
* @param {string} decoded - the base64-decoded payload string.
* @returns {WebAuthRequestParams}
*/
export const parseWebAuthRequest = (decoded: string): WebAuthRequestParams => {
try {
const payload = JSON.parse(decoded)

if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const lifetime =
typeof payload.lifetime === 'number' && payload.lifetime > 0 ? payload.lifetime : null

return {
port: Number(payload.port),
publicKey: typeof payload.publicKey === 'string' ? payload.publicKey : '',
requestedTokenName: typeof payload.name === 'string' ? payload.name : '',
requestedTokenLifetime: lifetime,
}
}
} catch {
// Not JSON - fall through to the legacy hyphen-joined format.
}

const delimiter = '-'
const params = decoded.split(delimiter)

return {
port: Number(params[0]),
publicKey: params[1] ?? '',
requestedTokenName: params.slice(2).join(delimiter),
requestedTokenLifetime: null,
}
}

/**
* Convert a requested token lifetime (in seconds) into an absolute Unix expiry
* timestamp in milliseconds, as expected by `generateUserToken`/`CreateUserTokenMutation`.
*
* @param {number | null} lifetime - the requested lifetime in seconds, or null.
* @returns {number | null} the absolute expiry timestamp in ms, or null for never-expiring.
*/
export const expiryFromLifetime = (lifetime: number | null): number | null =>
lifetime ? Date.now() + lifetime * 1000 : null
Loading