Skip to content

Commit fa95a13

Browse files
committed
Login with oauth
1 parent 8bee4c1 commit fa95a13

File tree

4 files changed

+276
-0
lines changed

4 files changed

+276
-0
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,5 @@ package-lock.json
3434
tsconfig.tsbuildinfo
3535
tsconfig.build.tsbuildinfo
3636
*storybook.log
37+
38+
AGENTS.md

bin/cli.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,17 @@ import fs from 'fs/promises'
44
import packageJson from '../package.json' with { type: 'json' }
55
import { chat } from './chat.js'
66
import { serve } from './serve.js'
7+
import { login } from './login.js'
78

89
const updateCheck = checkForUpdates()
910

1011
const arg = process.argv[2]
1112
if (arg === 'chat') {
1213
await updateCheck // wait for update check to finish before chat
1314
chat()
15+
} else if (arg === 'login') {
16+
await updateCheck
17+
await login()
1418
} else if (arg === '--help' || arg === '-H' || arg === '-h') {
1519
console.log('Usage:')
1620
console.log(' hyperparam [path] start hyperparam webapp. "path" is a directory or a URL.')

bin/login.js

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import fs from 'fs/promises'
2+
import os from 'os'
3+
import path from 'path'
4+
5+
/** @import {DeviceCodeResponse, DeviceTokenResponse, DeviceTokenErrorResponse} from './types.d.ts' */
6+
7+
const DEVICE_CODE_URL = 'https://oauth2.googleapis.com/device/code'
8+
const TOKEN_URL = 'https://hyperparam.app/api/auth/token'
9+
const scope = 'openid email profile'
10+
const DEFAULT_POLL_INTERVAL_MS = 5000
11+
const clientId = '87924894949-ligc0177ofrjubu7mhsh9m60h11stas6.apps.googleusercontent.com'
12+
13+
/**
14+
* Guides the user through Google device login and persists the refresh token.
15+
*/
16+
export async function login() {
17+
const deviceCode = await requestDeviceCode({ clientId, scope })
18+
19+
const verificationLink = deviceCode.verification_url_complete
20+
?? `${deviceCode.verification_url}?user_code=${encodeURIComponent(deviceCode.user_code)}`
21+
22+
console.log('To finish signing in:')
23+
console.log(` 1. Visit ${verificationLink}`)
24+
console.log(` 2. If prompted, enter code: ${deviceCode.user_code}`)
25+
console.log('Waiting for you to authorize in the browser...')
26+
27+
/** @type {DeviceTokenResponse} */
28+
const tokens = await pollForTokens({
29+
clientId,
30+
deviceCode,
31+
})
32+
33+
if (!tokens.refresh_token) {
34+
console.error('Error: Google did not return a refresh token. Try removing access for the app and re-running "hyperparam login".')
35+
process.exit(1)
36+
}
37+
38+
const credentialsPath = await writeCredentials({
39+
clientId,
40+
scope,
41+
refreshToken: tokens.refresh_token,
42+
tokenResponse: tokens,
43+
})
44+
45+
console.log(`Login successful. Credentials saved to ${credentialsPath}`)
46+
}
47+
48+
/**
49+
* Requests a device code for the configured client and scope.
50+
* @param {{ clientId: string, scope: string }} params
51+
* @returns {Promise<DeviceCodeResponse>}
52+
*/
53+
async function requestDeviceCode({ clientId, scope }) {
54+
const body = new URLSearchParams({
55+
client_id: clientId,
56+
scope,
57+
})
58+
59+
let response
60+
try {
61+
response = await fetch(DEVICE_CODE_URL, {
62+
method: 'POST',
63+
headers: {
64+
'Content-Type': 'application/x-www-form-urlencoded',
65+
},
66+
body,
67+
})
68+
} catch (error) {
69+
const message = error instanceof Error ? error.message : String(error)
70+
console.error('Error contacting Google OAuth endpoint:', message)
71+
process.exit(1)
72+
}
73+
74+
if (!response.ok) {
75+
const details = await readResponseBody(response)
76+
console.error('Error requesting device code from Google:', details)
77+
process.exit(1)
78+
}
79+
80+
const payload = await response.json()
81+
if (!payload || typeof payload !== 'object') {
82+
console.error('Unexpected response from Google during login:', payload)
83+
process.exit(1)
84+
}
85+
86+
// eslint-disable-next-line no-extra-parens
87+
const deviceCode = /** @type {DeviceCodeResponse} */ (/** @type {unknown} */ (payload))
88+
if (typeof deviceCode.device_code !== 'string'
89+
|| typeof deviceCode.user_code !== 'string'
90+
|| typeof deviceCode.verification_url !== 'string'
91+
|| typeof deviceCode.expires_in !== 'number') {
92+
console.error('Unexpected response from Google during login:', payload)
93+
process.exit(1)
94+
}
95+
96+
return deviceCode
97+
}
98+
99+
/**
100+
* Polls Google's device endpoint until tokens arrive or the flow times out.
101+
* @param {{ clientId: string, deviceCode: DeviceCodeResponse }} params
102+
* @returns {Promise<DeviceTokenResponse>}
103+
*/
104+
async function pollForTokens({ clientId, deviceCode }) {
105+
const expiresAt = Date.now() + (deviceCode.expires_in ?? 900) * 1000
106+
let intervalMs = (deviceCode.interval ?? DEFAULT_POLL_INTERVAL_MS / 1000) * 1000
107+
108+
while (Date.now() < expiresAt) {
109+
await delay(intervalMs)
110+
111+
const body = new URLSearchParams({
112+
client_id: clientId,
113+
device_code: deviceCode.device_code,
114+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
115+
})
116+
117+
const response = await fetch(TOKEN_URL, {
118+
method: 'POST',
119+
headers: {
120+
'Content-Type': 'application/x-www-form-urlencoded',
121+
},
122+
body,
123+
})
124+
125+
const payload = await readResponseBody(response)
126+
127+
if (response.ok) {
128+
if (!payload || typeof payload !== 'object') {
129+
console.error('Unexpected response from Google during login:', payload)
130+
process.exit(1)
131+
}
132+
// eslint-disable-next-line no-extra-parens
133+
const tokenPayload = /** @type {DeviceTokenResponse} */ (/** @type {unknown} */ (payload))
134+
if (typeof tokenPayload.access_token !== 'string'
135+
|| typeof tokenPayload.token_type !== 'string'
136+
|| typeof tokenPayload.expires_in !== 'number') {
137+
console.error('Unexpected response from Google during login:', payload)
138+
process.exit(1)
139+
}
140+
return tokenPayload
141+
}
142+
143+
if (!payload || typeof payload !== 'object') {
144+
console.error('Unexpected error response from Google during login:', payload)
145+
process.exit(1)
146+
}
147+
148+
// eslint-disable-next-line no-extra-parens
149+
const errorPayload = /** @type {DeviceTokenErrorResponse} */ (/** @type {unknown} */ (payload))
150+
if (typeof errorPayload.error !== 'string') {
151+
console.error('Unexpected error response from Google during login:', payload)
152+
process.exit(1)
153+
}
154+
155+
if (errorPayload.error === 'authorization_pending') {
156+
continue
157+
}
158+
159+
if (errorPayload.error === 'slow_down') {
160+
intervalMs += 5000
161+
continue
162+
}
163+
164+
if (errorPayload.error === 'access_denied') {
165+
console.error('Login cancelled in browser.')
166+
process.exit(1)
167+
}
168+
169+
if (errorPayload.error === 'expired_token') {
170+
console.error('Login request expired before approval. Run "hyperparam login" again.')
171+
process.exit(1)
172+
}
173+
174+
const description = errorPayload.error_description ? ` (${errorPayload.error_description})` : ''
175+
console.error(`Google returned an error while completing login: ${errorPayload.error}${description}`)
176+
process.exit(1)
177+
}
178+
179+
console.error('Login timed out before approval. Run "hyperparam login" again.')
180+
process.exit(1)
181+
}
182+
183+
/**
184+
* Writes the OAuth credentials file with restrictive permissions.
185+
* @param {{ clientId: string, scope: string, refreshToken: string, tokenResponse: DeviceTokenResponse }} params
186+
* @returns {Promise<string>}
187+
*/
188+
async function writeCredentials({ clientId, scope, refreshToken, tokenResponse }) {
189+
const dir = path.join(os.homedir(), '.hyp')
190+
const file = path.join(dir, 'credentials.json')
191+
192+
await fs.mkdir(dir, { recursive: true, mode: 0o700 })
193+
194+
/** @type {{
195+
* provider: 'google',
196+
* client_id: string,
197+
* scope: string,
198+
* refresh_token: string,
199+
* obtained_at: string,
200+
* token_endpoint: string,
201+
* id_token?: string
202+
* }}
203+
*/
204+
const payload = {
205+
provider: 'google',
206+
client_id: clientId,
207+
scope,
208+
refresh_token: refreshToken,
209+
obtained_at: new Date().toISOString(),
210+
token_endpoint: TOKEN_URL,
211+
}
212+
213+
if (tokenResponse.id_token) {
214+
payload.id_token = tokenResponse.id_token
215+
}
216+
217+
await fs.writeFile(file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
218+
await fs.chmod(file, 0o600)
219+
220+
return file
221+
}
222+
223+
/**
224+
* Reads and parses a fetch Response body if present.
225+
* @param {Response} response
226+
* @returns {Promise<object | string | undefined>}
227+
*/
228+
async function readResponseBody(response) {
229+
const text = await response.text()
230+
if (!text) {
231+
return undefined
232+
}
233+
234+
try {
235+
return JSON.parse(text)
236+
} catch {
237+
return text
238+
}
239+
}
240+
241+
/**
242+
* @param {number} ms
243+
*/
244+
function delay(ms) {
245+
return new Promise(resolve => setTimeout(resolve, ms))
246+
}

bin/types.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,27 @@ interface ArrayToolProperty extends BaseToolProperty {
120120
items: ToolProperty
121121
}
122122
export type ToolProperty = StringToolProperty | NumberToolProperty | ArrayToolProperty | BooleanToolProperty
123+
124+
// Device Auth types
125+
export interface DeviceCodeResponse {
126+
device_code: string
127+
user_code: string
128+
verification_url: string
129+
verification_url_complete?: string
130+
expires_in: number
131+
interval?: number
132+
}
133+
134+
export interface DeviceTokenResponse {
135+
access_token: string
136+
refresh_token?: string
137+
id_token?: string
138+
expires_in: number
139+
token_type: string
140+
scope?: string
141+
}
142+
143+
export interface DeviceTokenErrorResponse {
144+
error: string
145+
error_description?: string
146+
}

0 commit comments

Comments
 (0)