From 624f85eae865da82f4ac5a334c20a1410edb99aa Mon Sep 17 00:00:00 2001 From: tomaskir Date: Sun, 28 Jun 2026 17:55:01 +0000 Subject: [PATCH] feat(webauth): honor a CLI-requested token lifetime The CLI webauth login flow always minted non-expiring PATs because the approval page hardcoded the token expiry to null. Let the CLI request a token lifetime in the webauth payload and mint the PAT with that expiry. - Add parseWebAuthRequest: accept a base64(JSON) payload { port, publicKey, name, lifetime? } where lifetime is in seconds, with a fallback to the legacy port-pubKeyHex-patName format. The legacy parse also stops truncating token names that contain hyphens. - Add expiryFromLifetime to convert the requested lifetime (seconds) into an absolute millisecond expiry passed to generateUserToken. - Show the resulting token expiry on the approval screen. - No requested lifetime keeps the current never-expires behavior. The backend CreateUserTokenMutation already supports per-token expiry, so no backend change is needed. CLI side tracked in phasehq/cli#302. Closes #928 --- frontend/app/webauth/[requestCode]/page.tsx | 42 ++++----- frontend/tests/utils/tokens.test.ts | 14 +++ frontend/tests/utils/webAuth.test.ts | 94 +++++++++++++++++++++ frontend/utils/tokens.ts | 5 ++ frontend/utils/webAuth.ts | 61 +++++++++++++ 5 files changed, 195 insertions(+), 21 deletions(-) create mode 100644 frontend/tests/utils/tokens.test.ts create mode 100644 frontend/tests/utils/webAuth.test.ts create mode 100644 frontend/utils/webAuth.ts diff --git a/frontend/app/webauth/[requestCode]/page.tsx b/frontend/app/webauth/[requestCode]/page.tsx index 47b0a5e37..a3400c695 100644 --- a/frontend/app/webauth/[requestCode]/page.tsx +++ b/frontend/app/webauth/[requestCode]/page.tsx @@ -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' @@ -33,12 +35,6 @@ 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', { @@ -46,17 +42,6 @@ const handleCopy = (val: string) => { }) } -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) @@ -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(async (resolve, reject) => { if (keyring) { const userKxKeys = { @@ -83,7 +73,7 @@ export default function WebAuth({ params }: { params: { requestCode: string } }) organisationId, userKxKeys, name, - null + expiry ) const { data } = await createUserToken({ @@ -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) @@ -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') @@ -308,6 +301,13 @@ export default function WebAuth({ params }: { params: { requestCode: string } })

Choose an account below to authenticate with the Phase CLI

+ {requestParams && ( +

+ {humanReadableExpiryTimestamp( + expiryFromLifetime(requestParams.requestedTokenLifetime) + )} +

+ )}
{organisations?.map((organisation, index) => ( diff --git a/frontend/tests/utils/tokens.test.ts b/frontend/tests/utils/tokens.test.ts new file mode 100644 index 000000000..8b07e3467 --- /dev/null +++ b/frontend/tests/utils/tokens.test.ts @@ -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()}.` + ) + }) +}) diff --git a/frontend/tests/utils/webAuth.test.ts b/frontend/tests/utils/webAuth.test.ts new file mode 100644 index 000000000..79306cb21 --- /dev/null +++ b/frontend/tests/utils/webAuth.test.ts @@ -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) + }) +}) diff --git a/frontend/utils/tokens.ts b/frontend/utils/tokens.ts index 99af6a77f..b46d5ee95 100644 --- a/frontend/utils/tokens.ts +++ b/frontend/utils/tokens.ts @@ -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() } diff --git a/frontend/utils/webAuth.ts b/frontend/utils/webAuth.ts new file mode 100644 index 000000000..fde36a206 --- /dev/null +++ b/frontend/utils/webAuth.ts @@ -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