dev/OAuth2.md
2025-01-08 12:47:46 +02:00

374 lines
9.7 KiB
Markdown

### OAuth2 purpose
A way for the `user` to tell `google` to give an access to `myapp` app
<br>
### Flow:
| When | What | How |
|-------|--------------------------|--------------------------------------------------|
| 1 | Get Code | ` Front ⇢ Google ⇢ Back `|
| 2 | Exchange Code with Token | ` Back ⇢ Google ⇢ Back ⇢ Front `|
| 4 | Use Token | ` Front ⇢ Back ⇢ Google ⇢ Back ⇢ Front `|
<br><br>
### Details:
⭐️ 1. Get Authorization Code
1. Frontend **Navigate** to Google URL with a callback url
2. Google **Redirect** to Backend's callback url with the authorization code
⭐️ 2. Exchange Code with Token
1. Backend **POST** the `code` to Google
2. Google **Response** to Backend with an `access_token` and a `refresh token`
3. Backend **Redirect** to Frontend with the `access_token` in a `cookie`
⭐️ 3. Use Token
1. Frontend **GET** profile data from Backend using the `cookie`
2. Backend **GET** profile data from Google using the `access_token`
3. Google **Response** to Backend with profile data
4. Backend **Response** to Frontend with profile data
<br><br><br>
# ⭐️ 1. Get Authorization Code
1. Frontend **Navigate** to Google `https://accounts.google.com/o/oauth2` with a callback url
2. Google **Redirect** to Backend callback url `https://myapp/api/auth/callback` with authorization code
### ⚙️ 1. Front **GET** to Google
```sh
GET https://accounts.google.com/o/oauth2/v2/auth?
response_type=code& # This indicates you're using the "authorization code" flow.
client_id=ABC34JHS9D& # Your Google API client ID created on the google API console.
redirect_uri=https://myapp/api/auth/callback& # The URI Google will redirect to after the user consents.
scope=email%20profile& # The permissions you're requesting (e.g., email, profile).
state=xyz123 # A random string to protect against CSRF attacks.
```
### ⚙️ 2. Google **302** to Back
```bash
HTTP/1.1 302 Found
Location: https://myapp/api/auth/callback?code=4/0AX4XfWgyVyz-uT8k7WiyLg0Q&state=xyz123
Content-Type: text/html; charset=UTF-8
Content-Length: 0
```
*Security: the state string should be validated upon receiving the response from Google, as it ensures that the response corresponds to the request.*
<br><br><br>
# ⭐️ 2. Exchange Code with Token
1. Backend **POST** the `code` to Google `https://oauth2.googleapis.com/token`
2. Google **Response** to Backend with an `access_token` and a `refresh token`
3. Backend **Redirect** to Frontend with the `access_token` in a `cookie`
### ⚙️ 1. Backend **POST** the code to Google
```sh
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AAAABCX4XfWgyVyziyLg0QHHHHH&
client_id=ABC34JHS9D&
client_secret=PASS1234&
redirect_uri=https://myapp/callback
```
### ⚙️ 2. Google **Response** to Backend
```js
{
"access_token": "ya29.a0AfH6SMC8Op6zkVX-VoA",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "1//04d5lrjAqU4kS3vGdMvckw",
"scope": "email profile"
}
```
### ⚙️ 3. Backend **Redirect** to Frontend success page
This redirect will place a **cookie** in the browser that contains the **access token**
```bash
HTTP/1.1 302 Found
Location: http://localhost:3000/dashboard
Set-Cookie: access_token=ya29.a0AfH6SMC8Op6zkVX-VoA; HttpOnly; Secure; Max-Age=3600; Path=/;
```
<br><br>
### 💾 Backend code
Implements the endpoint /auth/google/callback
1. Recieves authorization code from Google
2. POST send the authorization code to https://oauth2.googleapis.com/token
3. POST response the access & refresh tokens
4. Redirect to Fronend success page with a cookie contains the access token
```js
app.get('/callback', async (req, res) => {
try {
//
// 1. Get the authorization code from Google's redirect
//
const { code } = req.query;
//
// 2. POST the authorization code to Google
//
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
redirect_uri: 'https://myapp/callback',
grant_type: 'authorization_code',
}),
});
//
// 3. Get Response tokens from Google
//
const { access_token, refresh_token } = await tokenResponse.json();
//
// 4. Redirect to Fronend success page with a cookie contains the access token
//
res.cookie('access', access_token, {
httpOnly: true, // Cannot be accessed by client-side JavaScript
secure: true, // Only sent over HTTPS
sameSite: 'strict', // CSRF protection
maxAge: 24 * 60 * 60 * 1000, // 24 hours
});
res.redirect('https://myapp/dashboard');
} catch (error) {
res.redirect('https://myapp/login');
}
});
```
The `redirect_uri` in the token exchange `POST` request needs to match EXACTLY the same `redirect_uri` that was used in the initial authorization request to Google. It's part of OAuth2 security verification.
</details>
<br><br><br>
# ⭐️ 3. Use Token
### ⚙️ 1. Frontend **GET** profile data from Backend
```bash
curl -X GET https://myapp/api/auth/profile \
-H "Cookie: access_token=ya29.a0AfH6SMC8Op6zkVX-VoA" \
-H "Accept: application/json"
```
### ⚙️ 2. Backend **GET** profile data from Google
```bash
curl -X GET "https://www.googleapis.com/oauth2/v3/userinfo" \
-H "Authorization: Bearer {access_token}" \
-H "Accept: application/json"
```
### ⚙️ 3. Google **response** to Backend with profile data
```
{
"sub": "1234567890",
"name": "John Doe",
"given_name": "John",
"family_name": "Doe",
"profile": "https://plus.google.com/1234567890",
"picture": "https://lh3.googleusercontent.com/a-/AOh14GgIXXl5JXzW0c1Szbl-e1Jch1vhl5rHhH65vlK6J5g5PqkGjj1O0p3t8bgVEOykQ6ykFSQ=s96",
"email": "john.doe@example.com",
"email_verified": true,
"locale": "en"
}
```
### ⚙️ 4. Backend **response** to Front with profile data
```
{
"sub": "1234567890",
"name": "John Doe",
"picture": "https://lh3.googleusercontent.com/8bgVEOykQ6ykFSQ=s96",
"email": "john.doe@example.com",
}
```
<br><br><br>
### 💾 Frontend Code
The browser sends the cookie in every request
```js
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
function DashboardPage() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(true);
const [dashboardData, setDashboardData] = useState(null);
useEffect(() => {
async function loadDashboard() {
try {
//
// Single request that both verifies auth and gets data
//
const response = await fetch('https://myapp/api/dashboard-data', {
credentials: 'include' // Sends the auth cookie
});
if (!response.ok) {
//
// If auth is invalid, the backend will return 401
//
if (response.status === 401) {
navigate('/login');
return;
}
throw new Error('Failed to load dashboard');
}
const data = await response.json();
setDashboardData(data);
setIsLoading(false);
} catch (err) {
navigate('/login');
}
}
loadDashboard();
}, [navigate]);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<div>
<h1>Welcome to Dashboard</h1>
</div>
);
}
```
<br><br><br>
### 💾 Backend Dashboard
If the frontend token is valid, the backend will response to the request.
If the frontend token is **not** valid, the backend will:
1. Get new tokens using refresh token
2. Set new cookie with new access token
3. Continue with the original request using new token
or login again
```js
app.get('/api/dashboard-data', async (req, res) => {
const authToken = req.cookies.auth_token;
const refreshToken = await getRefreshTokenFromDB(); // Get from your DB
try {
//
// If the token is still valid
//
const userData = verifyToken(authToken);
const dashboardData = await getDashboardData(userData);
res.json(dashboardData);
} catch (err) {
if (err.name === 'TokenExpiredError') {
try {
//
// 1. Get new tokens using refresh token
//
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: JSON.stringify({
refresh_token: refreshToken,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
grant_type: 'refresh_token'
})
});
const { access_token } = await response.json();
//
// 2. Set new cookie with new access token
//
res.cookie('auth_token', access_token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
//
// 3. Continue with the original request using new token
//
const userData = verifyToken(access_token);
const dashboardData = await getDashboardData(userData);
res.json(dashboardData);
} catch (refreshError) {
//
// If refresh fails, user needs to login again
//
res.status(401).json({ error: 'Session expired' });
}
} else {
res.status(401).json({ error: 'Invalid token' });
}
}
});
```