Secure OAuth2 State Handling with HTTP-Only Cookies

November 29, 2024 ยท View on GitHub

human (Nov 29, 2024, 08:36 AM)

export default {
  async fetch(request, env) {
    const generateHTML = ({ title, content, isError = false }) => {
      return new Response(`
        <!DOCTYPE html>
        <html>
          <head>
            <title>${title}</title>
            <style>
              body {
                font-family: -apple-system, system-ui, sans-serif;
                padding: 2rem;
                max-width: 600px;
                margin: 0 auto;
                text-align: center;
              }
              .message {
                padding: 1rem;
                margin: 1rem 0;
                border-radius: 4px;
                background-color: ${isError ? '#ffebee' : '#e8f5e9'};
                border: 1px solid ${isError ? '#ffcdd2' : '#c8e6c9'};
                color: ${isError ? '#b71c1c' : '#2e7d32'};
              }
            </style>
          </head>
          <body>
            <div class="message">
              ${content}
            </div>
            ${isError ? '<p>Please close this window and try again. If the problem persists, contact support.</p>' : ''}
          </body>
        </html>
      `, {
        headers: { 'Content-Type': 'text/html' },
        status: isError ? 400 : 200
      });
    };

    try {
      const url = new URL(request.url);
      const clientId = env.GITHUB_CLIENT_ID;
      const clientSecret = env.GITHUB_CLIENT_SECRET;
      const redirectUri = env.GITHUB_REDIRECT_URI;
      
      if (!url.searchParams.has('code')) {
        const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
        githubAuthUrl.searchParams.set('client_id', clientId);
        githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
        githubAuthUrl.searchParams.set('scope', 'gist');
        githubAuthUrl.searchParams.set('state', crypto.randomUUID());
        return Response.redirect(githubAuthUrl.toString(), 302);
      }

      const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify({
          client_id: clientId,
          client_secret: clientSecret,
          code: url.searchParams.get('code'),
          redirect_uri: redirectUri
        })
      });
      
      const tokenData = await tokenResponse.json();
      
      if (tokenData.error) {
        return generateHTML({
          title: 'GitHub OAuth Error',
          content: `
            <h3>Authentication Error</h3>
            <p>Error: ${tokenData.error}</p>
            ${tokenData.error_description ? `<p>Description: ${tokenData.error_description}</p>` : ''}
          `,
          isError: true
        });
      }
      
      return generateHTML({
        title: 'GitHub OAuth Success',
        content: `
          <h2>Authentication successful!</h2>
          <p>You can close this window.</p>
          <script>
            try {
              localStorage.setItem('github_token', '${tokenData.access_token}');
            } catch (err) {
              document.body.innerHTML += '<p style="color: #c62828;">Warning: Unable to store token in localStorage</p>';
            }
          </script>
        `
      });

    } catch (error) {
      return generateHTML({
        title: 'Unexpected Error',
        content: `
          <h3>Unexpected Error</h3>
          <p>An unexpected error occurred during authentication.</p>
          <p>Details: ${error.message}</p>
        `,
        isError: true
      });
    }
  }
};

Modify this to store the state= parameter in an HTTP only session cookie called github_auth_state and then compare that when the user comes back and show an error if they do not match, otherwise unset the cookie and complete the operation

assistant (Nov 29, 2024, 08:36 AM)

I'll help modify the code to implement secure state parameter handling using an HTTP-only cookie.

create GitHub OAuth Worker with State Validation

export default {
  async fetch(request, env) {
    const generateHTML = ({ title, content, isError = false }) => {
      return new Response(`
        <!DOCTYPE html>
        <html>
          <head>
            <title>${title}</title>
            <style>
              body {
                font-family: -apple-system, system-ui, sans-serif;
                padding: 2rem;
                max-width: 600px;
                margin: 0 auto;
                text-align: center;
              }
              .message {
                padding: 1rem;
                margin: 1rem 0;
                border-radius: 4px;
                background-color: ${isError ? '#ffebee' : '#e8f5e9'};
                border: 1px solid ${isError ? '#ffcdd2' : '#c8e6c9'};
                color: ${isError ? '#b71c1c' : '#2e7d32'};
              }
            </style>
          </head>
          <body>
            <div class="message">
              ${content}
            </div>
            ${isError ? '<p>Please close this window and try again. If the problem persists, contact support.</p>' : ''}
          </body>
        </html>
      `, {
        headers: { 'Content-Type': 'text/html' },
        status: isError ? 400 : 200
      });
    };

    try {
      const url = new URL(request.url);
      const clientId = env.GITHUB_CLIENT_ID;
      const clientSecret = env.GITHUB_CLIENT_SECRET;
      const redirectUri = env.GITHUB_REDIRECT_URI;
      
      if (!url.searchParams.has('code')) {
        // Initial authorization request
        const state = crypto.randomUUID();
        const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
        githubAuthUrl.searchParams.set('client_id', clientId);
        githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
        githubAuthUrl.searchParams.set('scope', 'gist');
        githubAuthUrl.searchParams.set('state', state);

        // Create response with redirect and set the state cookie
        const response = Response.redirect(githubAuthUrl.toString(), 302);
        response.headers.set('Set-Cookie', `github_auth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`);
        return response;
      }

      // Callback handling
      const returnedState = url.searchParams.get('state');
      const cookies = request.headers.get('Cookie') || '';
      const stateCookie = cookies.split(';')
        .map(cookie => cookie.trim())
        .find(cookie => cookie.startsWith('github_auth_state='));
      const savedState = stateCookie ? stateCookie.split('=')[1] : null;

      // Validate state parameter
      if (!savedState || savedState !== returnedState) {
        // Clear the invalid state cookie
        const response = generateHTML({
          title: 'Invalid State Parameter',
          content: `
            <h3>Security Error</h3>
            <p>Invalid state parameter detected. This could indicate a CSRF attempt.</p>
          `,
          isError: true
        });
        response.headers.set('Set-Cookie', 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0');
        return response;
      }

      const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify({
          client_id: clientId,
          client_secret: clientSecret,
          code: url.searchParams.get('code'),
          redirect_uri: redirectUri,
          state: returnedState
        })
      });
      
      const tokenData = await tokenResponse.json();
      
      if (tokenData.error) {
        const response = generateHTML({
          title: 'GitHub OAuth Error',
          content: `
            <h3>Authentication Error</h3>
            <p>Error: ${tokenData.error}</p>
            ${tokenData.error_description ? `<p>Description: ${tokenData.error_description}</p>` : ''}
          `,
          isError: true
        });
        response.headers.set('Set-Cookie', 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0');
        return response;
      }
      
      // Success response with cookie cleanup
      const response = generateHTML({
        title: 'GitHub OAuth Success',
        content: `
          <h2>Authentication successful!</h2>
          <p>You can close this window.</p>
          <script>
            try {
              localStorage.setItem('github_token', '${tokenData.access_token}');
            } catch (err) {
              document.body.innerHTML += '<p style="color: #c62828;">Warning: Unable to store token in localStorage</p>';
            }
          </script>
        `
      });
      response.headers.set('Set-Cookie', 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0');
      return response;

    } catch (error) {
      // Error response with cookie cleanup
      const response = generateHTML({
        title: 'Unexpected Error',
        content: `
          <h3>Unexpected Error</h3>
          <p>An unexpected error occurred during authentication.</p>
          <p>Details: ${error.message}</p>
        `,
        isError: true
      });
      response.headers.set('Set-Cookie', 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0');
      return response;
    }
  }
};

I've modified the code to implement secure state parameter handling. Here are the key changes:

  1. When setting the initial state:

    • Created an HTTP-only, secure cookie with the state parameter
    • Added SameSite=Lax for additional security
    • Set a 1-hour expiration (Max-Age=3600)
  2. When handling the callback:

    • Added cookie parsing to extract the saved state
    • Implemented state parameter validation
    • Added cookie cleanup (setting Max-Age=0) in all response paths
  3. Security improvements:

    • The state cookie is HttpOnly to prevent JavaScript access
    • Added Secure flag to ensure cookie is only sent over HTTPS
    • Added SameSite=Lax to prevent CSRF attacks
    • Implemented proper cookie cleanup in all scenarios (success, error, and invalid state)

The code now offers better protection against CSRF attacks by validating the state parameter properly. If the states don't match or if the state cookie is missing, it will show an error message and clear the cookie.

Let me know if you'd like me to explain any part of the implementation in more detail!

human (Nov 29, 2024, 08:40 AM)

Unexpected Error An unexpected error occurred during authentication. Details: Can't modify immutable headers.

assistant (Nov 29, 2024, 08:40 AM)

Ah, I see the issue. In Cloudflare Workers, we need to set the headers before creating the Response object since the headers become immutable after creation. Let me fix that.

rewrite Untitled

export default {
  async fetch(request, env) {
    const generateHTML = ({ title, content, isError = false, headers = {} }) => {
      return new Response(`
        <!DOCTYPE html>
        <html>
          <head>
            <title>${title}</title>
            <style>
              body {
                font-family: -apple-system, system-ui, sans-serif;
                padding: 2rem;
                max-width: 600px;
                margin: 0 auto;
                text-align: center;
              }
              .message {
                padding: 1rem;
                margin: 1rem 0;
                border-radius: 4px;
                background-color: ${isError ? '#ffebee' : '#e8f5e9'};
                border: 1px solid ${isError ? '#ffcdd2' : '#c8e6c9'};
                color: ${isError ? '#b71c1c' : '#2e7d32'};
              }
            </style>
          </head>
          <body>
            <div class="message">
              ${content}
            </div>
            ${isError ? '<p>Please close this window and try again. If the problem persists, contact support.</p>' : ''}
          </body>
        </html>
      `, {
        headers: {
          'Content-Type': 'text/html',
          ...headers
        },
        status: isError ? 400 : 200
      });
    };

    try {
      const url = new URL(request.url);
      const clientId = env.GITHUB_CLIENT_ID;
      const clientSecret = env.GITHUB_CLIENT_SECRET;
      const redirectUri = env.GITHUB_REDIRECT_URI;
      
      if (!url.searchParams.has('code')) {
        // Initial authorization request
        const state = crypto.randomUUID();
        const githubAuthUrl = new URL('https://github.com/login/oauth/authorize');
        githubAuthUrl.searchParams.set('client_id', clientId);
        githubAuthUrl.searchParams.set('redirect_uri', redirectUri);
        githubAuthUrl.searchParams.set('scope', 'gist');
        githubAuthUrl.searchParams.set('state', state);

        // Create headers object with the state cookie
        const headers = new Headers({
          'Location': githubAuthUrl.toString(),
          'Set-Cookie': `github_auth_state=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=3600`
        });

        return new Response(null, {
          status: 302,
          headers
        });
      }

      // Callback handling
      const returnedState = url.searchParams.get('state');
      const cookies = request.headers.get('Cookie') || '';
      const stateCookie = cookies.split(';')
        .map(cookie => cookie.trim())
        .find(cookie => cookie.startsWith('github_auth_state='));
      const savedState = stateCookie ? stateCookie.split('=')[1] : null;

      // Cookie cleanup header
      const clearStateCookie = {
        'Set-Cookie': 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
      };

      // Validate state parameter
      if (!savedState || savedState !== returnedState) {
        return generateHTML({
          title: 'Invalid State Parameter',
          content: `
            <h3>Security Error</h3>
            <p>Invalid state parameter detected. This could indicate a CSRF attempt.</p>
          `,
          isError: true,
          headers: clearStateCookie
        });
      }

      const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json'
        },
        body: JSON.stringify({
          client_id: clientId,
          client_secret: clientSecret,
          code: url.searchParams.get('code'),
          redirect_uri: redirectUri,
          state: returnedState
        })
      });
      
      const tokenData = await tokenResponse.json();
      
      if (tokenData.error) {
        return generateHTML({
          title: 'GitHub OAuth Error',
          content: `
            <h3>Authentication Error</h3>
            <p>Error: ${tokenData.error}</p>
            ${tokenData.error_description ? `<p>Description: ${tokenData.error_description}</p>` : ''}
          `,
          isError: true,
          headers: clearStateCookie
        });
      }
      
      // Success response with cookie cleanup
      return generateHTML({
        title: 'GitHub OAuth Success',
        content: `
          <h2>Authentication successful!</h2>
          <p>You can close this window.</p>
          <script>
            try {
              localStorage.setItem('github_token', '${tokenData.access_token}');
            } catch (err) {
              document.body.innerHTML += '<p style="color: #c62828;">Warning: Unable to store token in localStorage</p>';
            }
          </script>
        `,
        headers: clearStateCookie
      });

    } catch (error) {
      // Error response with cookie cleanup
      return generateHTML({
        title: 'Unexpected Error',
        content: `
          <h3>Unexpected Error</h3>
          <p>An unexpected error occurred during authentication.</p>
          <p>Details: ${error.message}</p>
        `,
        isError: true,
        headers: {
          'Set-Cookie': 'github_auth_state=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0'
        }
      });
    }
  }
};

I've made the following changes to fix the header immutability issue:

  1. Modified the generateHTML function to accept a headers parameter that gets merged with the default headers
  2. Created all Response objects with their complete headers upfront
  3. For the redirect response, created the Headers object explicitly before creating the Response
  4. Created a clearStateCookie object to reuse the cookie cleanup header
  5. Passed headers through the generateHTML function instead of trying to modify them after Response creation

The code should now work without the "Can't modify immutable headers" error while maintaining all the security features we implemented. Let me know if you encounter any other issues!