Repository navigation
feat(cli): create organization access tokens through the dashboard #15409
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
66484ca
feat(cli): create organization access tokens through the dashboard
pieterbeulque d5c47cf
feat(cli): write the token's environment and add --stdout to auth token
pieterbeulque 77b200c
fix(cli): harden the loopback callback and env file writes
pieterbeulque b97685e
feat(cli): default auth token to .env.local and warn when git tracks it
pieterbeulque 1c84132
refactor(cli): make auth token text-only instead of supporting --json
pieterbeulque 9d8c494
refactor(cli): print the auth token to stdout instead of writing an e…
pieterbeulque bc6deba
refactor(cli): print only the token from auth token
pieterbeulque a039fdc
refactor(cli): drop the redundant state check from parseHandoff
pieterbeulque File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
184 changes: 184 additions & 0 deletions
184
clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/CLITokenPage.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ) | ||
| } |
66 changes: 66 additions & 0 deletions
66
clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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( | ||
| { | ||
| 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', | ||
| ) | ||
| }) | ||
| }) | ||
64 changes: 64 additions & 0 deletions
64
clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/handoff.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' }) |
10 changes: 10 additions & 0 deletions
10
clients/apps/web/src/app/(main)/dashboard/[organization]/cli/token/page.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 /> | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.