File size: 4,280 Bytes
88c4c60 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | import open from "open";
import { startLocalServer } from "../utils/server.js";
import { generatePKCE } from "../utils/pkce.js";
import { spinner as createSpinner } from "../utils/ui.js";
import { OAUTH_TIMEOUT } from "../constants/oauth.js";
/**
* Generic OAuth Authorization Code Flow with PKCE
*/
export class OAuthService {
constructor(config) {
this.config = config;
}
/**
* Build authorization URL
*/
buildAuthUrl(redirectUri, state, codeChallenge, extraParams = {}) {
const params = new URLSearchParams({
client_id: this.config.clientId,
response_type: "code",
redirect_uri: redirectUri,
state: state,
code_challenge: codeChallenge,
code_challenge_method: this.config.codeChallengeMethod,
...extraParams,
});
return `${this.config.authorizeUrl}?${params.toString()}`;
}
/**
* Start local server and wait for callback
*/
async startAuthFlow(authUrl, providerName) {
const spinner = createSpinner("Starting local server...").start();
// Start local server for callback
let callbackParams = null;
const { port, close } = await startLocalServer((params) => {
callbackParams = params;
});
const redirectUri = `http://localhost:${port}/callback`;
spinner.succeed(`Local server started on port ${port}`);
return {
redirectUri,
port,
close,
waitForCallback: async () => {
spinner.start(`Waiting for ${providerName} authorization...`);
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Authentication timeout (5 minutes)"));
}, OAUTH_TIMEOUT);
const checkInterval = setInterval(() => {
if (callbackParams) {
clearInterval(checkInterval);
clearTimeout(timeout);
resolve();
}
}, 100);
});
spinner.stop();
close();
if (callbackParams.error) {
throw new Error(callbackParams.error_description || callbackParams.error);
}
if (!callbackParams.code) {
throw new Error("No authorization code received");
}
return callbackParams;
},
};
}
/**
* Exchange authorization code for tokens
*/
async exchangeCode(code, redirectUri, codeVerifier, contentType = "application/x-www-form-urlencoded") {
const body =
contentType === "application/json"
? JSON.stringify({
grant_type: "authorization_code",
client_id: this.config.clientId,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
})
: new URLSearchParams({
grant_type: "authorization_code",
client_id: this.config.clientId,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
const response = await fetch(this.config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": contentType,
Accept: "application/json",
},
body: body,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
}
/**
* Complete OAuth flow
*/
async authenticate(providerName, buildAuthUrlFn) {
// Generate PKCE
const { codeVerifier, codeChallenge, state } = generatePKCE();
// Start local server and get redirect URI
const { redirectUri, waitForCallback } = await this.startAuthFlow(null, providerName);
// Build authorization URL
const authUrl = buildAuthUrlFn(redirectUri, state, codeChallenge);
console.log(`\nOpening browser for ${providerName} authentication...`);
console.log(`If browser doesn't open, visit:\n${authUrl}\n`);
// Open browser
await open(authUrl);
// Wait for callback
const callbackParams = await waitForCallback();
// Validate state
if (callbackParams.state !== state) {
throw new Error("Invalid state parameter");
}
return {
code: callbackParams.code,
state: callbackParams.state,
codeVerifier,
redirectUri,
};
}
}
|