Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
'use client'

import { OnboardingLayout } from '@/components/Layout/OnboardingLayout'
import { PolarLogotype } from '@/components/Layout/Public/PolarLogotype'
import {
AccessTokenForm,
type AccessTokenCreate,
} from '@/components/Settings/OrganizationAccessTokensSettings'
import { useResumeOrganizationAccessTokenCreation } from '@/components/Settings/useResumeOrganizationAccessTokenCreation'
import { useSubmitOrganizationAccessToken } from '@/components/Settings/useSubmitOrganizationAccessToken'
import { toast } from '@/components/Toast/use-toast'
import { useAuth } from '@/hooks/auth'
import { OrganizationContext } from '@/providers/maintainerOrganization'
import { schemas } from '@polar-sh/client'
import { Alert, Avatar, Button, Text } from '@polar-sh/orbit'
import { Box } from '@polar-sh/orbit/Box'
import CopyToClipboardInput from '@polar-sh/ui/components/atoms/CopyToClipboardInput'
import { Form } from '@polar-sh/ui/components/ui/form'
import { useSearchParams } from 'next/navigation'
import { useCallback, useContext, useMemo, useState } from 'react'
import { useForm } from 'react-hook-form'
import {
getCLICancelURL,
getCLIHandoff,
getCLITokenURL,
getRequestedScopes,
} from './handoff'

const FORM_ID = 'cli-access-token'

type Step =
| { name: 'form' }
| { name: 'sending' }
| { name: 'created'; token: string }

export default function CLITokenPage() {
const { organization } = useContext(OrganizationContext)
const { currentUser } = useAuth()
const searchParams = useSearchParams()
const handoff = useMemo(() => getCLIHandoff(searchParams), [searchParams])
const [step, setStep] = useState<Step>({ name: 'form' })
const { submit, isPending } = useSubmitOrganizationAccessToken(
organization.id,
)

const form = useForm<AccessTokenCreate>({
defaultValues: {
comment: searchParams.get('name') ?? '',
expires_in: 'P30D',
scopes: getRequestedScopes(searchParams),
},
})

const deliver = useCallback(
async (created: schemas['OrganizationAccessTokenCreateResponse']) => {
if (!handoff) {
setStep({ name: 'created', token: created.token })
return
}
setStep({ name: 'sending' })
try {
window.location.assign(await getCLITokenURL(handoff, created))
} catch {
toast({
title: 'Could not send the token to the CLI',
description:
'The CLI link is invalid. Revoke the token in your settings and run polar auth token again.',
})
setStep({ name: 'created', token: created.token })
}
},
[handoff],
)

useResumeOrganizationAccessTokenCreation({
organizationId: organization.id,
onSuccess: deliver,
})

const onSubmit = useCallback(
async (data: AccessTokenCreate) => {
const created = await submit(data)
if (created) await deliver(created)
},
[deliver, submit],
)

const actions =
step.name === 'form' ? (
<Box gap="m">
{handoff && (
<Button
variant="ghost"
type="button"
onClick={() => window.location.assign(getCLICancelURL(handoff))}
>
Cancel
</Button>
)}
<Button form={FORM_ID} type="submit" loading={isPending}>
{handoff ? 'Create and send to CLI' : 'Create token'}
</Button>
</Box>
) : undefined

return (
<OnboardingLayout
branding={
<Box color="text-primary">
<PolarLogotype logoVariant="logotype" logoClassName="ml-0" />
</Box>
}
introduction={
<Text variant="body">
Create an organization access token for{' '}
<span className="font-semibold text-gray-900 dark:text-white">
{organization.name}
</span>{' '}
to use in your app.
</Text>
}
footer={
currentUser && (
<Box alignItems="center" gap="s">
<Avatar
className="h-8 w-8"
avatar_url={currentUser.avatar_url}
name={currentUser.email}
/>
<Box flexDirection="column">
<Text variant="caption" color="muted">
Signed in as
</Text>
<Text variant="label">{currentUser.email}</Text>
</Box>
</Box>
)
}
title={
step.name === 'created'
? 'Copy your access token'
: 'Create access token'
}
subtitle={
step.name === 'sending'
? 'Sending the token to the Polar CLI…'
: step.name === 'created'
? 'Store it somewhere safe, like your .env file. You won’t be able to see it again.'
: handoff
? 'Pick the permissions your app needs. The token is encrypted and sent straight to the Polar CLI on this computer.'
: 'Pick the permissions your app needs.'
}
actions={actions}
>
{step.name === 'form' && (
<Form {...form}>
<Box
as="form"
id={FORM_ID}
flexDirection="column"
rowGap="2xl"
onSubmit={form.handleSubmit(onSubmit)}
>
<AccessTokenForm />
</Box>
</Form>
)}
{step.name === 'created' && (
<Box flexDirection="column" rowGap="l">
<CopyToClipboardInput
value={step.token}
variant="mono"
ariaLabel="Organization access token"
/>
<Alert
variant="warning"
title="Keep this token secret"
description="Anyone with this token can act on behalf of your organization within its scopes."
/>
</Box>
)}
</OnboardingLayout>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import {
encryptForCLI,
getCLICancelURL,
getCLIHandoff,
getRequestedScopes,
} from './handoff'

const generateCLIKeys = async () => {
const { publicKey, privateKey } = await crypto.subtle.generateKey(
Comment thread
pieterbeulque marked this conversation as resolved.
Resolved
{
name: 'RSA-OAEP',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256',
},
true,
['encrypt', 'decrypt'],
)
const spki = await crypto.subtle.exportKey('spki', publicKey)
return { privateKey, publicKey: Buffer.from(spki).toString('base64url') }
}

describe('getCLIHandoff', () => {
it('requires both the state and the public key', () => {
expect(getCLIHandoff(new URLSearchParams('state=s'))).toBeNull()
expect(getCLIHandoff(new URLSearchParams('public_key=k'))).toBeNull()
expect(getCLIHandoff(new URLSearchParams('state=s&public_key=k'))).toEqual({
state: 's',
publicKey: 'k',
})
})
})

describe('getRequestedScopes', () => {
it('keeps only scopes an organization token can have', () => {
expect(
getRequestedScopes(
new URLSearchParams('scopes=products:read,web:write,nope'),
),
).toEqual(['products:read'])
})
})

describe('encryptForCLI', () => {
it('encrypts the token so only the CLI private key can read it', async () => {
const { privateKey, publicKey } = await generateCLIKeys()
const encrypted = await encryptForCLI(publicKey, 'polar_oat_secret')

expect(encrypted).not.toContain('polar_oat_secret')
const decrypted = await crypto.subtle.decrypt(
{ name: 'RSA-OAEP' },
privateKey,
Buffer.from(encrypted, 'base64url'),
)
expect(new TextDecoder().decode(decrypted)).toBe('polar_oat_secret')
})
})

describe('getCLICancelURL', () => {
it('only ever targets the local CLI', () => {
expect(getCLICancelURL({ state: 's', publicKey: 'k' })).toBe(
'http://127.0.0.1:3333/token/callback?state=s&error=access_denied',
)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { enums, type schemas } from '@polar-sh/client'

const CLI_CALLBACK_URL = 'http://127.0.0.1:3333/token/callback'

export interface CLIHandoff {
state: string
publicKey: string
}

export const getCLIHandoff = (
searchParams: URLSearchParams,
): CLIHandoff | null => {
const state = searchParams.get('state')
const publicKey = searchParams.get('public_key')
return state && publicKey ? { state, publicKey } : null
}

export const getRequestedScopes = (searchParams: URLSearchParams) => {
const requested = new Set(searchParams.get('scopes')?.split(',') ?? [])
return enums.availableScopeValues.filter((scope) => requested.has(scope))
}

const fromBase64Url = (value: string) =>
Uint8Array.from(
atob(value.replace(/-/g, '+').replace(/_/g, '/')),
(character) => character.charCodeAt(0),
)

const toBase64Url = (bytes: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(bytes)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')

export const encryptForCLI = async (publicKey: string, token: string) => {
const key = await crypto.subtle.importKey(
'spki',
fromBase64Url(publicKey),
{ name: 'RSA-OAEP', hash: 'SHA-256' },
false,
['encrypt'],
)
const encrypted = await crypto.subtle.encrypt(
{ name: 'RSA-OAEP' },
key,
new TextEncoder().encode(token),
)
return toBase64Url(encrypted)
}

const callbackURL = (params: Record<string, string>) =>
`${CLI_CALLBACK_URL}?${new URLSearchParams(params).toString()}`

export const getCLITokenURL = async (
{ state, publicKey }: CLIHandoff,
created: schemas['OrganizationAccessTokenCreateResponse'],
) =>
callbackURL({
state,
encrypted_token: await encryptForCLI(publicKey, created.token),
})

export const getCLICancelURL = ({ state }: CLIHandoff) =>
callbackURL({ state, error: 'access_denied' })
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Metadata } from 'next'
import CLITokenPage from './CLITokenPage'

export const metadata: Metadata = {
title: 'Create access token',
}

export default function Page() {
return <CLITokenPage />
}
Loading
Loading