Skip to content

Commit 1751ba2

Browse files
authored
internal(assistant): Add Assistant authentication (#1136)
1 parent 7715df4 commit 1751ba2

14 files changed

Lines changed: 1229 additions & 77 deletions

File tree

src/components/Layout/ActivityBar/Profile.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { css } from '@emotion/react'
22
import { Dialog, Flex, IconButton, Tooltip } from '@radix-ui/themes'
33
import { UserRoundIcon, XIcon } from 'lucide-react'
4-
import { useState } from 'react'
54

65
import { Profile as ProfileContent } from '@/components/Profile'
6+
import { useStudioUIStore } from '@/store/ui'
77

88
function ProfileDialog({
99
open,
@@ -36,7 +36,11 @@ function ProfileDialog({
3636
}
3737

3838
export function Profile() {
39-
const [open, setOpen] = useState(false)
39+
const isOpen = useStudioUIStore((state) => state.isProfileDialogOpen)
40+
const openProfileDialog = useStudioUIStore((state) => state.openProfileDialog)
41+
const closeProfileDialog = useStudioUIStore(
42+
(state) => state.closeProfileDialog
43+
)
4044

4145
return (
4246
<>
@@ -45,15 +49,22 @@ export function Profile() {
4549
area-label="Profile"
4650
color="gray"
4751
variant="ghost"
48-
onClick={() => setOpen(true)}
52+
onClick={openProfileDialog}
4953
css={css`
5054
font-size: 24px;
5155
`}
5256
>
5357
<UserRoundIcon />
5458
</IconButton>
5559
</Tooltip>
56-
<ProfileDialog open={open} onOpenChange={setOpen} />
60+
<ProfileDialog
61+
open={isOpen}
62+
onOpenChange={(open) => {
63+
if (!open) {
64+
closeProfileDialog()
65+
}
66+
}}
67+
/>
5768
</>
5869
)
5970
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { app, ipcMain, shell } from 'electron'
2+
import log from 'electron-log/main'
3+
4+
import { getProfileData } from '@/handlers/auth/fs'
5+
import { isEncryptionAvailable } from '@/main/encryption'
6+
import {
7+
buildAssistantAuthUrl,
8+
exchangeAssistantCode,
9+
generatePKCE,
10+
generateState,
11+
startCallbackServer,
12+
} from '@/services/grafana/assistantAuth'
13+
14+
import { AssistantAuthHandler } from '../types'
15+
16+
import {
17+
clearAssistantTokens,
18+
hasAssistantTokens,
19+
saveAssistantTokens,
20+
} from './tokenStore'
21+
22+
const PREFIX = '[GrafanaAssistant]'
23+
24+
export type AssistantAuthResult =
25+
| { type: 'authenticated' }
26+
| { type: 'error'; error: string }
27+
| { type: 'aborted' }
28+
29+
export interface AssistantAuthStatus {
30+
authenticated: boolean
31+
stackId: string | null
32+
stackName: string | null
33+
}
34+
35+
function isAllowedEndpoint(endpoint: string, stackUrl: string): boolean {
36+
try {
37+
const endpointHost = new URL(endpoint).hostname
38+
const stackHost = new URL(stackUrl).hostname
39+
40+
return (
41+
endpointHost === stackHost ||
42+
endpointHost.endsWith('.grafana.net') ||
43+
endpointHost.endsWith('.grafana-dev.net')
44+
)
45+
} catch {
46+
return false
47+
}
48+
}
49+
50+
let pendingAbortController: AbortController | null = null
51+
52+
async function performSignIn(
53+
stackId: string,
54+
stackUrl: string
55+
): Promise<AssistantAuthResult> {
56+
const abortController = new AbortController()
57+
pendingAbortController = abortController
58+
59+
try {
60+
const { codeVerifier, codeChallenge } = generatePKCE()
61+
const state = generateState()
62+
63+
const { port, result } = await startCallbackServer(abortController.signal)
64+
65+
const authUrl = buildAssistantAuthUrl(stackUrl, codeChallenge, state, port)
66+
67+
log.info(
68+
PREFIX,
69+
'Initiating assistant auth for stack',
70+
stackId,
71+
'on port',
72+
port
73+
)
74+
void shell.openExternal(authUrl)
75+
76+
const callback = await result
77+
app.focus({ steal: true })
78+
79+
if (callback.state !== state) {
80+
log.error(PREFIX, 'State mismatch in assistant auth callback')
81+
return {
82+
type: 'error',
83+
error: 'State mismatch — possible CSRF attack. Please try again.',
84+
}
85+
}
86+
87+
if (!callback.endpoint) {
88+
return {
89+
type: 'error',
90+
error: 'No API endpoint received from auth callback.',
91+
}
92+
}
93+
94+
if (!isAllowedEndpoint(callback.endpoint, stackUrl)) {
95+
log.error(
96+
PREFIX,
97+
'Callback endpoint does not match expected stack URL:',
98+
callback.endpoint
99+
)
100+
return {
101+
type: 'error',
102+
error: 'Unexpected API endpoint received from auth callback.',
103+
}
104+
}
105+
106+
const tokenResponse = await exchangeAssistantCode(
107+
callback.endpoint,
108+
callback.code,
109+
codeVerifier,
110+
abortController.signal
111+
)
112+
113+
if (abortController.signal.aborted) {
114+
return { type: 'aborted' }
115+
}
116+
117+
await saveAssistantTokens(stackId, {
118+
accessToken: tokenResponse.token,
119+
refreshToken: tokenResponse.refresh_token,
120+
apiEndpoint: tokenResponse.api_endpoint,
121+
expiresAt: new Date(tokenResponse.expires_at).getTime(),
122+
refreshExpiresAt: new Date(tokenResponse.refresh_expires_at).getTime(),
123+
})
124+
125+
log.info(PREFIX, 'Assistant auth completed for stack', stackId)
126+
return { type: 'authenticated' }
127+
} catch (error) {
128+
if (abortController.signal.aborted) {
129+
return { type: 'aborted' }
130+
}
131+
132+
log.error(PREFIX, 'Assistant auth failed:', error)
133+
return {
134+
type: 'error',
135+
error: error instanceof Error ? error.message : 'Authentication failed',
136+
}
137+
} finally {
138+
if (pendingAbortController === abortController) {
139+
pendingAbortController = null
140+
}
141+
}
142+
}
143+
144+
export function initialize() {
145+
ipcMain.handle(
146+
AssistantAuthHandler.SignIn,
147+
async (): Promise<AssistantAuthResult> => {
148+
const profile = await getProfileData()
149+
const stackId = profile.profiles.currentStack
150+
151+
if (!stackId) {
152+
return { type: 'error', error: 'No Grafana Cloud stack selected.' }
153+
}
154+
155+
const stack = profile.profiles.stacks[stackId]
156+
157+
if (!stack) {
158+
return { type: 'error', error: 'Current stack not found in profile.' }
159+
}
160+
161+
if (!isEncryptionAvailable()) {
162+
return {
163+
type: 'error',
164+
error:
165+
'Encryption is not available on this system. Assistant authentication requires secure storage for tokens.',
166+
}
167+
}
168+
169+
if (pendingAbortController) {
170+
pendingAbortController.abort()
171+
}
172+
173+
return performSignIn(stackId, stack.url)
174+
}
175+
)
176+
177+
ipcMain.handle(
178+
AssistantAuthHandler.GetStatus,
179+
async (): Promise<AssistantAuthStatus> => {
180+
const profile = await getProfileData()
181+
const stackId = profile.profiles.currentStack
182+
183+
if (!stackId) {
184+
return { authenticated: false, stackId: null, stackName: null }
185+
}
186+
187+
const stack = profile.profiles.stacks[stackId]
188+
const authenticated = await hasAssistantTokens(stackId)
189+
190+
return {
191+
authenticated,
192+
stackId,
193+
stackName: stack?.name ?? null,
194+
}
195+
}
196+
)
197+
198+
ipcMain.handle(AssistantAuthHandler.CancelSignIn, () => {
199+
if (pendingAbortController) {
200+
pendingAbortController.abort()
201+
pendingAbortController = null
202+
}
203+
})
204+
205+
ipcMain.handle(AssistantAuthHandler.SignOut, async (): Promise<void> => {
206+
const profile = await getProfileData()
207+
const stackId = profile.profiles.currentStack
208+
209+
if (stackId) {
210+
await clearAssistantTokens(stackId)
211+
log.info(PREFIX, 'Cleared assistant tokens for stack', stackId)
212+
}
213+
})
214+
}

src/handlers/ai/a2a/preload.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { ipcRenderer } from 'electron'
2+
3+
import { AssistantAuthHandler } from '../types'
4+
5+
import type { AssistantAuthResult, AssistantAuthStatus } from './assistantAuth'
6+
7+
export function assistantSignIn() {
8+
return ipcRenderer.invoke(
9+
AssistantAuthHandler.SignIn
10+
) as Promise<AssistantAuthResult>
11+
}
12+
13+
export function assistantCancelSignIn() {
14+
return ipcRenderer.invoke(AssistantAuthHandler.CancelSignIn) as Promise<void>
15+
}
16+
17+
export function assistantGetStatus() {
18+
return ipcRenderer.invoke(
19+
AssistantAuthHandler.GetStatus
20+
) as Promise<AssistantAuthStatus>
21+
}
22+
23+
export function assistantSignOut() {
24+
return ipcRenderer.invoke(AssistantAuthHandler.SignOut) as Promise<void>
25+
}

0 commit comments

Comments
 (0)