├── nodejs_example.js └── README.md /nodejs_example.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Standalone Node.js example using Express to handle OAuth 2.0 login with Google, storing user data in a SQLite database 3 | */ 4 | 5 | const express = require("express"); 6 | const axios = require("axios"); 7 | const sqlite3 = require("sqlite3").verbose(); 8 | const crypto = require("crypto"); 9 | const jwt = require("jsonwebtoken"); 10 | const jwksClient = require("jwks-rsa"); 11 | 12 | const app = express(); 13 | const db = new sqlite3.Database(":memory:"); 14 | 15 | // Initialize database 16 | db.serialize(() => { 17 | db.run( 18 | "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" 19 | ); 20 | db.run( 21 | "CREATE TABLE federated_credentials (user_id INTEGER, provider TEXT, subject TEXT, PRIMARY KEY (provider, subject))" 22 | ); 23 | }); 24 | 25 | // Configuration 26 | const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; 27 | const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; 28 | const REDIRECT_URI = "https://example.com/oauth2/callback"; 29 | const SCOPE = "openid profile email"; 30 | 31 | // JWKS client to fetch Google's public keys 32 | const jwks = jwksClient({ 33 | jwksUri: "https://www.googleapis.com/oauth2/v3/certs", 34 | }); 35 | 36 | // Function to verify JWT 37 | async function verifyIdToken(idToken) { 38 | return new Promise((resolve, reject) => { 39 | jwt.verify( 40 | idToken, 41 | (header, callback) => { 42 | jwks.getSigningKey(header.kid, (err, key) => { 43 | callback(null, key.getPublicKey()); 44 | }); 45 | }, 46 | { 47 | audience: CLIENT_ID, 48 | issuer: "https://accounts.google.com", 49 | }, 50 | (err, decoded) => { 51 | if (err) return reject(err); 52 | resolve(decoded); 53 | } 54 | ); 55 | }); 56 | } 57 | 58 | // Generate a random state for CSRF protection 59 | app.get("/login", (req, res) => { 60 | const state = crypto.randomBytes(16).toString("hex"); 61 | req.session.state = state; // Store state in session 62 | const authUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=${SCOPE}&response_type=code&state=${state}`; 63 | res.redirect(authUrl); 64 | }); 65 | 66 | // OAuth callback 67 | app.get("/oauth2/callback", async (req, res) => { 68 | const { code, state } = req.query; 69 | 70 | // Verify state to prevent CSRF 71 | if (state !== req.session.state) { 72 | return res.status(403).send("Invalid state parameter"); 73 | } 74 | 75 | try { 76 | // Exchange code for tokens 77 | const tokenResponse = await axios.post( 78 | "https://oauth2.googleapis.com/token", 79 | { 80 | code, 81 | client_id: CLIENT_ID, 82 | client_secret: CLIENT_SECRET, 83 | redirect_uri: REDIRECT_URI, 84 | grant_type: "authorization_code", 85 | } 86 | ); 87 | 88 | const { id_token } = tokenResponse.data; 89 | 90 | // Verify ID token (JWT) 91 | const decoded = await verifyIdToken(id_token); 92 | const { sub: subject, name, email } = decoded; 93 | 94 | // Check if user exists in federated_credentials 95 | db.get( 96 | "SELECT * FROM federated_credentials WHERE provider = ? AND subject = ?", 97 | ["https://accounts.google.com", subject], 98 | (err, cred) => { 99 | if (err) return res.status(500).send("Database error"); 100 | 101 | if (!cred) { 102 | // New user: create account 103 | db.run( 104 | "INSERT INTO users (name, email) VALUES (?, ?)", 105 | [name, email], 106 | function (err) { 107 | if (err) return res.status(500).send("Database error"); 108 | 109 | const userId = this.lastID; 110 | db.run( 111 | "INSERT INTO federated_credentials (user_id, provider, subject) VALUES (?, ?, ?)", 112 | [userId, "https://accounts.google.com", subject], 113 | (err) => { 114 | if (err) return res.status(500).send("Database error"); 115 | res.send(`Logged in as ${name} (${email})`); 116 | } 117 | ); 118 | } 119 | ); 120 | } else { 121 | // Existing user: fetch and log in 122 | db.get( 123 | "SELECT * FROM users WHERE id = ?", 124 | [cred.user_id], 125 | (err, user) => { 126 | if (err || !user) return res.status(500).send("Database error"); 127 | res.send(`Logged in as ${user.name} (${user.email})`); 128 | } 129 | ); 130 | } 131 | } 132 | ); 133 | } catch (error) { 134 | res.status(500).send("OAuth or JWT verification error"); 135 | } 136 | }); 137 | 138 | app.listen(3000, () => console.log("Server running on port 3000")); 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OAuth Explained 2 | 3 | ## The Basic Idea 4 | 5 | Let's say LinkedIn wants to let users import their Google contacts. 6 | 7 | One obvious (but terrible) option would be to just ask users to enter their Gmail email and password directly into LinkedIn. But giving away your actual login credentials to another app is a huge security risk. 8 | 9 | OAuth was designed to solve exactly this kind of problem. 10 | 11 | Note: So OAuth solves an authorization problem! Not an authentication problem. See [here][ref1] for the difference. OAuth is an abbreviation for Open Authorization. 12 | 13 | ## Super Short Summary 14 | 15 | - User clicks “Import Google Contacts” on LinkedIn 16 | - LinkedIn redirects user to Google's OAuth consent page 17 | - User logs in and approves access 18 | - Google redirects back to LinkedIn with a one-time code 19 | - LinkedIn uses that code to get an access token from Google 20 | - LinkedIn uses the access token to call Google's API and fetch contacts 21 | 22 | ## More Detailed Summary 23 | 24 | Suppose LinkedIn wants to import a user's contacts from their Google account. 25 | 26 | 1. LinkedIn sets up a Google API account and receives a client_id and a client_secret 27 | - So Google knows this client id is LinkedIn 28 | 2. A user visits LinkedIn and clicks "Import Google Contacts" 29 | 3. LinkedIn redirects the user to Google's authorization endpoint: 30 | https://accounts.google.com/o/oauth2/auth?client_id=12345&redirect_uri=https://linkedin.com/oauth/callback&scope=contacts 31 | 32 | - client_id is the before mentioned client id, so Google knows it's LinkedIn 33 | - redirect_uri is very important. It's used in step 6 34 | - in scope LinkedIn tells Google how much it wants to have access to, in this case the contacts of the user 35 | 36 | 4. The user will have to log in at Google 37 | 5. Google displays a consent screen: "LinkedIn wants to access your Google contacts. Allow?" The user clicks "Allow" 38 | 6. Google generates a one-time authorization code and redirects to the URI we specified: redirect_uri. **It appends the one-time code as a URL parameter**. 39 | - So the URL could be https://linkedin.com/oauth/callback?code=one_time_code_xyz 40 | 7. Now, LinkedIn makes a server-to-server request (not a redirect) to Google's token endpoint and receive an access token (and ideally a refresh token) 41 | 8. **Finished**. Now LinkedIn can use this access token to access the user's Google contacts via Google's API 42 | 43 | --- 44 | 45 | **Question:** 46 | _Why not already send the access token in step 6?_ 47 | 48 | **Answer:** To make sure that the requester is actually LinkedIn. So far, all requests to Google have come from the user's browser, with only the client_id identifying LinkedIn. Since the client_id isn't secret and could be guessed by an attacker, Google can't know for sure that it's actually LinkedIn behind this. 49 | 50 | Authorization servers (Google in this example) use predefined URIs. So LinkedIn needs to specify predefined URIs when setting up their Google API. And if the given redirect_uri is not among the predefined ones, then Google rejects the request. See here: https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.2 51 | 52 | Additionally, LinkedIn includes the client_secret in the server-to-server request. This, however, is mainly intended to protect against the case that somehow intercepted the one time code, so he can't use it. 53 | 54 | ## Security Note: Encryption 55 | 56 | OAuth 2.0 does **not** handle encryption itself. It relies on HTTPS (SSL/TLS) to secure sensitive data like the client_secret and access tokens during transmission. 57 | 58 | ## Security Addendum: The state Parameter 59 | 60 | The state parameter is critical to prevent cross-site request forgery (CSRF) attacks. It's a unique, random value generated by the third-party app (e.g., LinkedIn) and included in the authorization request. Google returns it unchanged in the callback. LinkedIn verifies the state matches the original to ensure the request came from the user, not an attacker. 61 | 62 | For an example of how a CSRF attack would work without the state param, see [here][csrf-ref]. 63 | 64 | ## OAuth 1.0 vs OAuth 2.0 Addendum: 65 | 66 | OAuth 1.0 required clients to cryptographically sign every request, which was more secure but also much more complicated. OAuth 2.0 made things simpler by relying on HTTPS to protect data in transit, and using bearer tokens instead of signed requests. 67 | 68 | ## OIDC 69 | 70 | If you want to use OAuth for authentication, you should use OIDC. It's a protocol that builds on top of OAuth 2.0. I wrote a very similar guide about it here: https://github.com/LukasNiessen/oidc-explained 71 | 72 | ## Code Example: OAuth 2.0 Login Implementation 73 | 74 | Below is a standalone Node.js example using Express to handle OAuth 2.0 login with Google, storing user data in a SQLite database. 75 | 76 | ```javascript 77 | const express = require("express"); 78 | const axios = require("axios"); 79 | const sqlite3 = require("sqlite3").verbose(); 80 | const crypto = require("crypto"); 81 | const jwt = require("jsonwebtoken"); 82 | const jwksClient = require("jwks-rsa"); 83 | 84 | const app = express(); 85 | const db = new sqlite3.Database(":memory:"); 86 | 87 | // Initialize database 88 | db.serialize(() => { 89 | db.run( 90 | "CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, email TEXT)" 91 | ); 92 | db.run( 93 | "CREATE TABLE federated_credentials (user_id INTEGER, provider TEXT, subject TEXT, PRIMARY KEY (provider, subject))" 94 | ); 95 | }); 96 | 97 | // Configuration 98 | const CLIENT_ID = process.env.GOOGLE_CLIENT_ID; 99 | const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET; 100 | const REDIRECT_URI = "https://example.com/oauth2/callback"; 101 | const SCOPE = "openid profile email"; 102 | 103 | // JWKS client to fetch Google's public keys 104 | const jwks = jwksClient({ 105 | jwksUri: "https://www.googleapis.com/oauth2/v3/certs", 106 | }); 107 | 108 | // Function to verify JWT 109 | async function verifyIdToken(idToken) { 110 | return new Promise((resolve, reject) => { 111 | jwt.verify( 112 | idToken, 113 | (header, callback) => { 114 | jwks.getSigningKey(header.kid, (err, key) => { 115 | callback(null, key.getPublicKey()); 116 | }); 117 | }, 118 | { 119 | audience: CLIENT_ID, 120 | issuer: "https://accounts.google.com", 121 | }, 122 | (err, decoded) => { 123 | if (err) return reject(err); 124 | resolve(decoded); 125 | } 126 | ); 127 | }); 128 | } 129 | 130 | // Generate a random state for CSRF protection 131 | app.get("/login", (req, res) => { 132 | const state = crypto.randomBytes(16).toString("hex"); 133 | req.session.state = state; // Store state in session 134 | const authUrl = `https://accounts.google.com/o/oauth2/auth?client_id=${CLIENT_ID}&redirect_uri=${REDIRECT_URI}&scope=${SCOPE}&response_type=code&state=${state}`; 135 | res.redirect(authUrl); 136 | }); 137 | 138 | // OAuth callback 139 | app.get("/oauth2/callback", async (req, res) => { 140 | const { code, state } = req.query; 141 | 142 | // Verify state to prevent CSRF 143 | if (state !== req.session.state) { 144 | return res.status(403).send("Invalid state parameter"); 145 | } 146 | 147 | try { 148 | // Exchange code for tokens 149 | const tokenResponse = await axios.post( 150 | "https://oauth2.googleapis.com/token", 151 | { 152 | code, 153 | client_id: CLIENT_ID, 154 | client_secret: CLIENT_SECRET, 155 | redirect_uri: REDIRECT_URI, 156 | grant_type: "authorization_code", 157 | } 158 | ); 159 | 160 | const { id_token } = tokenResponse.data; 161 | 162 | // Verify ID token (JWT) 163 | const decoded = await verifyIdToken(id_token); 164 | const { sub: subject, name, email } = decoded; 165 | 166 | // Check if user exists in federated_credentials 167 | db.get( 168 | "SELECT * FROM federated_credentials WHERE provider = ? AND subject = ?", 169 | ["https://accounts.google.com", subject], 170 | (err, cred) => { 171 | if (err) return res.status(500).send("Database error"); 172 | 173 | if (!cred) { 174 | // New user: create account 175 | db.run( 176 | "INSERT INTO users (name, email) VALUES (?, ?)", 177 | [name, email], 178 | function (err) { 179 | if (err) return res.status(500).send("Database error"); 180 | 181 | const userId = this.lastID; 182 | db.run( 183 | "INSERT INTO federated_credentials (user_id, provider, subject) VALUES (?, ?, ?)", 184 | [userId, "https://accounts.google.com", subject], 185 | (err) => { 186 | if (err) return res.status(500).send("Database error"); 187 | res.send(`Logged in as ${name} (${email})`); 188 | } 189 | ); 190 | } 191 | ); 192 | } else { 193 | // Existing user: fetch and log in 194 | db.get( 195 | "SELECT * FROM users WHERE id = ?", 196 | [cred.user_id], 197 | (err, user) => { 198 | if (err || !user) return res.status(500).send("Database error"); 199 | res.send(`Logged in as ${user.name} (${user.email})`); 200 | } 201 | ); 202 | } 203 | } 204 | ); 205 | } catch (error) { 206 | res.status(500).send("OAuth or JWT verification error"); 207 | } 208 | }); 209 | 210 | app.listen(3000, () => console.log("Server running on port 3000")); 211 | ``` 212 | 213 | # Feedback ⌨️😊 214 | 215 | Feel free to contribute by submitting a PR or creating an issue. 216 | **If this was helpful, you can show support by giving this repository a star! 🌟** 217 | 218 | # License 219 | 220 | MIT 221 | 222 | [ref1]: https://stackoverflow.com/questions/6556522/authentication-versus-authorization 223 | [csrf-ref]: https://stackoverflow.com/questions/35985551/how-does-csrf-work-without-state-parameter-in-oauth2-0 224 | --------------------------------------------------------------------------------