18 Απρ, 2022

OAuth2 Authorization με React

React with OAuth2

Εάν θέλετε να χρησιμοποίησετε OAuth2 authorization στο React project σας μπορείτε να χρησιμοποιήσετε το πακέτο μου: @tasoskakour/react-use-oauth2.

Τι είναι OAuth2;

Το OAuth2 είναι ένα εδραιωμένο πρωτόκολο για authorization. Μας παρέχει συγκεκριμένα authorization flows για διαφόρων τύπου εφαρμογές όπως web, desktop και mobile. Μπορείτε να διαβάσετε περισσότερα για το πρωτόκολλο εδώ.

Που χρησιμοποιούμε OAuth2 στο web;

Μια από τις κυριότερες χρήσης του OAuth2 πρωτοκόλλου είναι για αυθεντικοποίηση (authentication) και εξουσιοδότηση (authorization) με 3rd-party παρόχους όπως Google, Facebook, Apple κλπ. Το λεγόμενο "Social Login". Είμαι σίγουρος ότι σχεδόν όλοι απο εσάς έχετε χρησιμοποιήσει Social Login με OAuth2 (σαν τελικός χρήστης) κάποτε.

Στην εικόνα παρακάτω βλέπουμε ότι ο χρήστης μπορεί είτε να συνδεθεί manually με τον παραδοσιακό τρόπο παρέχοντας username και password (δεξιά μεριά) είτε να συνδεθεί μέσω Facebook/Twitter/Google (αριστερή μεριά). Επιλέγοντας να συνδεθεί με έναν απο αυτούς τους social providers, ουσιαστικά αυθεντικοποιείται με αυτόν τον πάροχο και εξουσιοδοτεί την εφαρμογή να έχει πρόσβαση σε συγκεκριμένες πληροφορίες του social account του. Για παράδειγμα εάν επιλέξει να συνδεθεί με Google, μετά από μια επιτυχής αυθεντικοποίηση, η εφαρμογή ίσως γνωρίζει το Google email την φωτογραφία προφίλ και το όνομα του χρήστη.

Social login

Ένα από τα πλεονεκτήματα χρήσης του social login είναι η απλότητα του. Με ένα κλικ και χωρίς καν να χρειαστεί να βάλουμε username και password μπορούμε να χρησιμοποιήσουμε μια εφαρμογή σε μερικά δευτερόλεπτα.

Φυσικά, πρέπει να είμαστε σίγουροι ότι η εφαρμογή είναι αξιόπιστη και πάντα πρέπει να προσέχουμε σε τι πληροφορίες του social account μας θα έχει πρόσβαση.

OAuth2 Grant types

Τα δυο πιο συχνά OAuth2 Grant types είναι το Authorization Code και το Implicit Flow.

OAuth 2.0 Authorization Code Grant

Το Authorization Code grant type χρησιμοποιείται από αξιόπιστους και δημόσιους clients για την ανταλλαγή ενός κωδικού εξουσιοδότησης (authorization code) με ένα access token. Τα βήματα για αυτό το flow είναι:

  1. Ο Client (η εφαρμογή σας) κατασκευάζει και στέλνει τον χρήστη σε ένα authorization URL που έχει τη μορφή:
https://authorization-server.com/auth?
response_type=code&
client_id=CLIENT_ID&
redirect_uri=REDIRECT_URI&
scope=photos&
state=1234zyx

Π.χ το base authorization URL για την Google είναι:

https://accounts.google.com/o/oauth2/v2/auth
  1. Ο χρήστης βλέπει το authorization prompt και κλικάρει "Allow" (ή "Deny"). (Πριν από αυτό το βήμα κατά πάσα πιθανότητα θα ζητηθεί αυθεντικοποίηση από το χρήστη).
  2. Ο χρήστης γίνεται redirect στο redirect_uri που προηγουμένως περάσαμε στο αρχικό request μαζί με ένα code και το state. Αυτό το URL έχει τη μορφή:
https://REDIRECT_URI.com/callback?
code=AUTH_CODE_HERE&
state=1234zyx

Σημείωση: Η παράμετρος state πρέπει να ταιριάζει με το state που παράξαμε αρχικά στο βήμα 1. Αυτό είναι για να αντιμετωπίσουμε CSRF επιθέσεις. Διαβάστε περισσότερα εδώ. 4. Σε αυτό το βήμα πρέπει να ανταλλάξουμε το code με ένα access token. Για να το πετύχουμε αυτό θα χρειαστεί να κάνουμε ένα POST request στο authorization server token endpoint που έχει τη μορφή όπως φαίνεται παρακάτω. Προσέξτε ότι αυτό το request πρέπει να πραγματοποιηθεί από τον server μας καθώς δε πρέπει να κάνουμε expose το CLIENT_SECRET στο front-end app.

  https://api.authorization-server.com/token
  grant_type=authorization_code&
  code=AUTH_CODE_HERE&
  redirect_uri=REDIRECT_URI&
  client_id=CLIENT_ID&
  client_secret=CLIENT_SECRET

Π.χ το /token endpoint της Google είναι:

https://oauth2.googleapis.com/token
  1. O authorization server απαντάει με το access token και ένα expiration time:
{
  "access_token":"RsT5OjbzRn430zqMLgV3Ia",
  "expires_in":3600
}

Σημείωση: Το Authorization Code Grant θεωρείται γενικά ασφαλές. Ωστόσο μπορούμε να το ασφαλίσουμε ακόμη περισσότερο χρησιμοποιώντας την επέκταση PKCE (RFC 7636) που κάνει χρήση των code_verifier, code_challenge και code_challenge_method. Αυτή η επέκταση είναι εκτός του σκοπού αυτού του άρθρου. Μπορείτε να διαβάσετε περισσότερα εδώ.

Implicit Flow Grant

Το Implicit flow είναι ένα απλοποιημένο flow για JavaScript apps όπου το access token επιστρέφεται απευθείας χωρίς το έξτρα βήμα της ανταλλαγής με code.

Αυτό σημαίνει ότι τα βήματα για το Implicit flow είναι τα βήματα 1-3 (δηλαδή χωρίς τα βήματα 4-5) που γράψαμε παραπάνω για το Authorization Code. Επίσης να σημειώσουμε εδώ ότι το redirect URL θα περιέχει το access token και τις άλλες πληροφορίες στις hash (#) παραμέτρους του URL και όχι στις search (?) παραμέτρους. Δηλαδή θα έχει τη μορφή:

https://REDIRECT_URI.com/callback#
access_token=ACCESS_TOKEN&
expires_in=3600&
token_type=Bearer&
state=1234zyx

Στις μέρες μας το OAuth 2.0 standard προτείνει να χρησιμοποιείται το Authorization Code Grant αντί για το Implicit Flow λόγω του έμφυτου ρίσκου της επιστροφής των access tokens σε ένα HTTP redirect χωρίς την επιβεβαίωση ότι λήφθηκε από τον client.

Ωστόσο, οι περισσότεροι πάροχοι ακόμη υποστηρίζουν και επιτρέπουν στις εφαρμογές να χρησιμοποιούν το Implicit Flow. Για παράδειγμα εδώ μπορείτε να διαβάσετε το documentation της Google για OAuth 2.0 for Client-side Web Applications που ουσιαστικά χρησιμοποεί Implicit Flow.

React: Υλοποίηση OAuth2 με hooks

Σε αυτό κομμάτι θα δούμε πώς μπορούμε να φτιάξουμε ένα React hook εν ονόματι useOAuth2 που θα υλοποιεί OAuth2 με Authorization Code Grant

Εάν θέλετε να χρησιμοποίησετε OAuth2 authorization στο React project σας μπορείτε να χρησιμοποιήσετε το πακέτο μου: @tasoskakour/react-use-oauth2.

Για να κάνουμε την διαδικασία του authorization πιο user-friendly θα χρησιμοποιήσουμε ένα Popup, δηλαδή το authorization με το 3rd party θα λάβει χώρα μέσα στο Popup όπως τελικά και το redirection.


Βήμα 1: Προετοιμασία του hook

Τα props που θα χρειαστεί το hook μας είναι:

  • authorizeUrl: Το 3rd party authorization URL.
  • clientId: Το OAuth2 client id της εφαρμογής.
  • redirectUri: Καθορίζει που θα κάνει redirect τον χρήστη το 3rd party API server μόλις ολοκληρωθεί το authorization. Στην δική μας υλοποίηση θα κάνουμε render ένα Popup σε αυτό το redirectUri.
  • scope (string - προεραιτικό): Μια λίστα με τα scopes που θα χρειαστεί η εφαρμογή μας.

Ας περάσουμε αυτά τα props στο hook μας και ας δημιουργήσουμε ένα UI state helper που θα περιλαμβάνει {loading, error} και μια συνάρτηση getAuth. Αυτή η συνάρτηση θα ξεκινά το authorization flow οπότε σαν πρώτο βήμα θα σετάρει το loading σε true και θα καθαρίζει οτιδήποτε errors.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });
  })
}

Βήμα 2: Δημιουργία state

Πριν την κατασκευή του authorization URL πρέπει να δημιουργήσουμε την παράμετρο state. Αυτή η παράμετρος απαιτείται για την καταπολέμηση των CSRF attacks. Επίσης θα μπορούσαμε να περάσουμε οποιαδήποτε άλλη πληροφορία θέλαμε σε αυτό το state ώστε να την επαναφέρουμε αργότερα μετά το redirection.

Για να παράξουμε το state χρησιμοποιούμε την συνάρτηση window.crypto. Ο κώδικας για αυτο το κομμάτι έχει παρθεί από αυτό το άρθρο.

Χρειάζεται επίσης να κάνουμε persist το state στο sessionStorage ώστε το Popup να μπορεί να το διαβάσει αργότερα μετά το redirection (θα μπορούσαμε να το αποθηκεύσουμε και σε cookies ή localStorage).

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x: number) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state: string) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);
  })
}

Βήμα 3: Άνοιγμα του Popup

Και τώρα ξεκινάμε επιτέλους την διαδικασία του Authorization!

Δημιουργούμε το authorization URL αξιοποιώντας τη συνάρτηση enhanceAuthorizeUrl που παίρνει ως ορίσματα τις παραμέτρους authorizeUrl, clientId, redirectUri, scope, state και έπειτα ανοίγουμε ένα Popup σε αυτό το URL. Για ευκολότερη "χειραγώγηση" του Popup κρατάμε το instance του σε ένα useRef.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );
  })
}

Δημιουργία του Popup Component

Για την ώρα ας αφήσουμε για λίγο το useOAuth2 hook και ας επικεντρωθούμε στην υλοποίηση του Popup.

Βρισκόμαστε στην κατάσταση όπου ο χρήστης βρίσκεται μέσα στο Popup και εκτελεί το authorization με το 3rd party. Μετά από αυτό θα γίνει redirect στο redirect_uri που περάσαμε κατά το αρχικό request.

Μια βολική πρακτική είναι να κάνουμε render το Popup μέσα σε ένα React Route για αυτό το redirect_uri. Για παράδειγμα μπορούμε να θέσουμε το redirect_uri σε https://your-app.com/callback και έπειτα να δημιουργήσουμε τα Routes όπως φαίνεται παρακάτω. Με αυτό τον τρόπο ο authorization server θα μας κάνει redirect στο redirect_uri όπου τελικά θα γίνει render το Popup component.

// routes.js
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { OAuthPopup } from 'OAuth2Popup';

const Example = () => (
	<BrowserRouter>
		<Routes>
			<Route element={<OAuthPopup />} path="/callback" />
			<Route element={<Home />} path="/" />
            {/* ... your other routes ... */}
		</Routes>
	</BrowserRouter>
);

Το Popup component πρέπει να μεριμνεί για 3 πράγματα:

  1. Να διαβάζει τις URL redirection παραμέτρους code και state. Εάν έχει συμβεί σφάλμα θα υπάρχει μια error παράμετρος.
  2. Να ελέγχει ότι η παράμετρος state ταιριάζει με αυτή που στάλθηκε κατά το αρχικό request - διαβάζουμε αυτή την παράμετρο από το sessionStorage.
  3. Να επικοινωνεί πίσω στο parent window (window.opener) ότι το authorization πέτυχε ή απέτυχε.

Για να εδραιωθεί η επικοινωνία μεταξύ του Popup και του Opener χρησιμοποιούμε την συνάρτηση window.opener.postMessage η οποία στέλνει μηνύματα στο window τα οποία μπορεί να ακούει ο Opener.

Tα παραπάνω βήματα μας οδηγούν στον κώδικα παρακάτω:

// OAuth2Popup.jsx
import { useEffect } from 'react';
import { queryToObject } from './tools';

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const OAUTH_RESPONSE = 'react-use-oauth2-response';

const checkState = (receivedState) => {
	const state = sessionStorage.getItem(OAUTH_STATE_KEY);
	return state === receivedState;
};

const queryToObject = (query) => {
	const parameters = new URLSearchParams(query);
	return Object.fromEntries(parameters.entries());
};

const OAuthPopup = (props) => {
	const {
		Component = (
			<div style={{ margin: '12px' }} data-testid="popup-loading">
				Loading...
			</div>
		),
	} = props;

	// On mount
	useEffect(() => {
		const payload = queryToObject(window.location.search.split('?')[1]);
		const state = payload && payload.state;
		const error = payload && payload.error;

		if (!window.opener) {
			throw new Error('No window opener');
		}

		if (error) {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				error: decodeURI(error) || 'OAuth error: An error has occured.',
			});
		} else if (state && checkState(state)) {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				payload,
			});
		} else {
			window.opener.postMessage({
				type: OAUTH_RESPONSE,
				error: 'OAuth error: State mismatch.',
			});
		}
	}, []);

	return Component;
};

export default OAuthPopup;

Βήμα 4: Εδραίωση επικοινωνίας με Popup

Ας επιστρέψουμε στο useOAuth2 hook.

Μετά που ανοίξουμε το Popup πρέπει να κάνουμε register ένα Message Listener στο window που θα μπορεί να ακούει τα μηνύματα από το Popup. Για να τα ξεχωρίσουμε από όλα τα άλλα μηνύματα του window, το Popup τα στέλνει με type react-use-oauth2-response.

Eάν το Popup επιστρέψει error σετάρουμε το κατάλληλο UI state, ειδάλλως διαβάζουμε το payload που περιέχει την παράμετρο code.

Υπάρχει επίσης το σενάριο όπου ο χρήστης έκλεισε πρόωρα το Popup πριν προλάβει να ολοκληρωθεί το authorization. Για να πιάσουμε τη συγκεκριμένη περίπτωση, σετάρουμε ένα interval όπου τσεκάρει περιοδικά εάν το Popup έκλεισε πρόωρα και εάν ναι, υλοποιεί κάποια cleanp tasks όπως το reset του UI, την αφαίρεση του message listener κλπ.

// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;
const OAUTH_RESPONSE = 'react-use-oauth2-response';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const cleanup = (
	intervalRef,
	popupRef,
	handleMessageListener
) => {
	clearInterval(intervalRef.current);
	closePopup(popupRef);
	removeState();
	window.removeEventListener('message', handleMessageListener);
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );

      // 4. Register message listener
      async function handleMessageListener(message) {
        try {
          const type = message && message.data && message.data.type;
          if (type === OAUTH_RESPONSE) {
            const errorMaybe = message && message.data && message.data.error;
            if (errorMaybe) {
              setUI({
                loading: false,
                error: errorMaybe || 'Unknown Error',
              });
            } else {
              const code = message && message.data && message.data.payload && message.data.payload.code;
              // ... Check next step to see what we'll do with the code
            }
          }
        } catch (genericError) {
          console.error(genericError);
          setUI({
            loading: false,
            error: genericError.toString(),
          });
        } finally {
          // Clear stuff ...
          cleanup(intervalRef, popupRef, handleMessageListener);
        }
      }
      window.addEventListener('message', handleMessageListener);

      // 4. Begin interval to check if popup was closed forcefully by the user
      intervalRef.current = setInterval(() => {
        const popupClosed = !popupRef.current || !popupRef.current.window || popupRef.current.window.closed;
        if (popupClosed) {
          // Popup was closed before completing auth...
          setUI((ui) => ({
            ...ui,
            loading: false,
          }));
          console.warn('Warning: Popup was closed before completing authentication.');
          clearInterval(intervalRef.current);
          removeState();
          window.removeEventListener('message', handleMessageListener);
        }
      }, 250);

      // Remove listener(s) on unmount
      return () => {
        window.removeEventListener('message', handleMessageListener);
        if (intervalRef.current) clearInterval(intervalRef.current);
      };
    })
}

Βήμα 5: Ανταλλαγή του code για ένα access token

Το τελικό βήμα είναι να ανταλλάξουμε το code που λάβαμε με ένα πραγματικό access_token.

Για να το επιτύχουμε αυτό χρειαζόμαστε ένα server καθώς δε μπορούμε να κάνουμε expose το client_secret στο front-end app. Έτσι, ο server μας θα κάνει ένα POST request στο 3rd party authorization /token endpoint ώστε να λάβει το access_token. Π.χ για την Google αυτό είναι το https://oauth2.googleapis.com/token. Σημειώστε εδώ ότι τα specs μπορούν να διαφέρουν από πάροχο σε πάροχο.

Το route στο server που χρειάζεται να δημιουργήσουμε είναι πολύ απλό. Για τους σκοπούς αυτού του tutorial θα το υλοποιήσουμε με το fastify framework.

// server.js
import Fastify from 'fastify';
import fetch from 'node-fetch';

const fastify = Fastify({
	logger: true,
});

const CLIENT_SECRET = process.env.CLIENT_SECRET;
const AUTHORIZATION_SERVER_TOKEN_URL = process.env.AUTHORIZATION_SERVER_TOKEN_URL; // e.g https://oauth2.googleapis.com/token

fastify.post('/token', async (request, reply) => {
	const { code, client_id, redirect_uri } = request.query;

	const data = await fetch(
		`${AUTHORIZATION_SERVER_TOKEN_URL}?grant_type=authorization_code&client_id=${client_id}&client_secret=${CLIENT_SECRET}&redirect_uri=${redirect_uri}&code=${code}`,
		{
			method: 'POST',
		}
	);

	reply.send(await data.json());
});

fastify.listen(3001, (error) => {
	if (error) throw error;
});
// useOAuth2.js
import { useCallback, useState } from 'react'; 

const OAUTH_STATE_KEY = 'react-use-oauth2-state-key';
const POPUP_HEIGHT = 700;
const POPUP_WIDTH = 600;
const OAUTH_RESPONSE = 'react-use-oauth2-response';

// https://medium.com/@dazcyril/generating-cryptographic-random-state-in-javascript-in-the-browser-c538b3daae50
const generateState = () => {
	const validChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
	let array = new Uint8Array(40);
	window.crypto.getRandomValues(array);
	array = array.map((x) => validChars.codePointAt(x % validChars.length));
	const randomState = String.fromCharCode.apply(null, array);
	return randomState;
};

const saveState = (state) => {
	sessionStorage.setItem(OAUTH_STATE_KEY, state);
};

const removeState = () => {
	sessionStorage.removeItem(OAUTH_STATE_KEY);
};

const openPopup = (url) => {
	// To fix issues with window.screen in multi-monitor setups, the easier option is to
	// center the pop-up over the parent window.
	const top = window.outerHeight / 2 + window.screenY - POPUP_HEIGHT / 2;
	const left = window.outerWidth / 2 + window.screenX - POPUP_WIDTH / 2;
	return window.open(
		url,
		'OAuth2 Popup',
		`height=${POPUP_HEIGHT},width=${POPUP_WIDTH},top=${top},left=${left}`
	);
};

const closePopup = (popupRef) => {
	popupRef.current?.close();
};

const cleanup = (
	intervalRef,
	popupRef,
	handleMessageListener
) => {
	clearInterval(intervalRef.current);
	closePopup(popupRef);
	removeState();
	window.removeEventListener('message', handleMessageListener);
};

const enhanceAuthorizeUrl = (
	authorizeUrl,
	clientId,
	redirectUri,
	scope,
	state
) => {
	return `${authorizeUrl}?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}&scope=${scope}&state=${state}`;
};

const objectToQuery = (object) => {
	return new URLSearchParams(object).toString();
};

const formatExchangeCodeForTokenServerURL = (
	serverUrl,
	clientId,
	code,
	redirectUri
) => {
	return `${serverUrl}?${objectToQuery({
		client_id: clientId,
		code,
		redirect_uri: redirectUri,
	})}`;
};

const useOAuth2 = (props) => {
  const {
      authorizeUrl,
      clientId,
      redirectUri,
      scope = '',
    } = props;

  const popupRef = useRef();
  const [{ loading, error }, setUI] = useState({ loading: false, error: null });

  const getAuth = useCallback(() => {
      // 1. Init
      setUI({
        loading: true,
        error: null,
      });

      // 2. Generate and save state
      const state = generateState();
      saveState(state);

      // 3. Open popup
      popupRef.current = openPopup(
        enhanceAuthorizeUrl(authorizeUrl, clientId, redirectUri, scope, state)
      );

      // 4. Register message listener
      async function handleMessageListener(message) {
        try {
          const type = message && message.data && message.data.type;
          if (type === OAUTH_RESPONSE) {
            const errorMaybe = message && message.data && message.data.error;
            if (errorMaybe) {
              setUI({
                loading: false,
                error: errorMaybe || 'Unknown Error',
              });
            } else {
              const code = message && message.data && message.data.payload && message.data.payload.code;
              const response = await fetch(
                formatExchangeCodeForTokenServerURL(
                  'https://your-server.com/token',
                  clientId,
                  code,
                  redirectUri
                )
              );
              if (!response.ok) {
                setUI({
                  loading: false,
                  error: "Failed to exchange code for token",
                });
              } else {
                payload = await response.json();
                setUI({
                  loading: false,
                  error: null,
                });
                setData(payload);
                // Lines above will cause 2 rerenders but it's fine for this tutorial :-)
              }
            }
          }
        } catch (genericError) {
          console.error(genericError);
          setUI({
            loading: false,
            error: genericError.toString(),
          });
        } finally {
          // Clear stuff ...
          cleanup(intervalRef, popupRef, handleMessageListener);
        }
      }
      window.addEventListener('message', handleMessageListener);

      // 4. Begin interval to check if popup was closed forcefully by the user
      intervalRef.current = setInterval(() => {
        const popupClosed = !popupRef.current || !popupRef.current.window || popupRef.current.window.closed;
        if (popupClosed) {
          // Popup was closed before completing auth...
          setUI((ui) => ({
            ...ui,
            loading: false,
          }));
          console.warn('Warning: Popup was closed before completing authentication.');
          clearInterval(intervalRef.current);
          removeState();
          window.removeEventListener('message', handleMessageListener);
        }
		  }, 250);

      // Remove listener(s) on unmount
      return () => {
        window.removeEventListener('message', handleMessageListener);
        if (intervalRef.current) clearInterval(intervalRef.current);
      };
    })
}

Τέλος

Ελπίζω να βρήκατε αυτό το άρθρο χρήσιμο. 🙂

Σε ένα μελλοντικό άρθρο, θα μιλήσουμε περισσότερο για το πώς μπορούμε να χρησιμοποιήσουμε συγκεκριμένα Google OAuth2 και έπειτα Google APIs για να τραβήξουμε πλυροφορίες για το χρήστη.

Μείνετε συντονισμένοι!

Loading Comments...