(region: Region)
| 33 | * user code, and polls /oauth2/token until the user approves. |
| 34 | */ |
| 35 | export async function deviceCodeLogin(region: Region): Promise<OAuthCredentials> { |
| 36 | const host = OAUTH_HOSTS[region]; |
| 37 | |
| 38 | const { randomBytes, createHash } = await import('crypto'); |
| 39 | const codeVerifier = randomBytes(32).toString('base64url'); |
| 40 | const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url'); |
| 41 | const state = randomBytes(16).toString('base64url'); |
| 42 | |
| 43 | const codeRes = await fetch(`${host}/oauth2/device/code`, { |
| 44 | method: 'POST', |
| 45 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 46 | body: new URLSearchParams({ |
| 47 | client_id: CLIENT_ID, |
| 48 | scope: SCOPES.join(' '), |
| 49 | code_challenge: codeChallenge, |
| 50 | code_challenge_method: 'S256', |
| 51 | state, |
| 52 | }), |
| 53 | }); |
| 54 | |
| 55 | if (!codeRes.ok) { |
| 56 | const body = await codeRes.text().catch(() => ''); |
| 57 | throw new CLIError( |
| 58 | `Failed to start device-code flow (HTTP ${codeRes.status}).`, |
| 59 | ExitCode.AUTH, |
| 60 | body || `URL: ${host}/oauth2/device/code`, |
| 61 | ); |
| 62 | } |
| 63 | |
| 64 | const data = (await codeRes.json()) as DeviceCodeResponse; |
| 65 | if (data.state !== state) { |
| 66 | throw new CLIError('OAuth state mismatch.', ExitCode.AUTH); |
| 67 | } |
| 68 | |
| 69 | openBrowser(data.verification_uri); |
| 70 | process.stderr.write(`\nOpened: ${data.verification_uri}\n`); |
| 71 | process.stderr.write(`Code: ${data.user_code}\n`); |
| 72 | process.stderr.write(`Client: ${CLIENT_NAME}\n`); |
| 73 | process.stderr.write('Waiting for authorization...\n'); |
| 74 | |
| 75 | const deadline = data.expired_in; |
| 76 | const intervalMs = data.interval || 3000; |
| 77 | |
| 78 | while (Date.now() < deadline) { |
| 79 | await sleep(intervalMs); |
| 80 | |
| 81 | const tokRes = await fetch(`${host}/oauth2/token`, { |
| 82 | method: 'POST', |
| 83 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 84 | body: new URLSearchParams({ |
| 85 | grant_type: 'urn:ietf:params:oauth:grant-type:device_code', |
| 86 | client_id: CLIENT_ID, |
| 87 | user_code: data.user_code, |
| 88 | code_verifier: codeVerifier, |
| 89 | }), |
| 90 | }); |
| 91 | |
| 92 | if (!tokRes.ok) { |
no test coverage detected