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,
    };
  }
}