Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js
{"title": "Building an OAuth2 and OpenID Connect Provider from Scratch in Node.
{“title”: “Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js”, “summary”: “Demystify OAuth2 and OpenID Connect specifications by implementing authorization code flows, token generation, and user info endpoints manually in Node.js.”, “tags”: [“Node.js”, “Security”, “Authentication”, “API Design”, “Backend”], “body”: “# Building an OAuth2 and OpenID Connect Provider from Scratch in Node.js
Modern web applications rely heavily on centralized authentication systems. Whether you are using Auth0, Okta, Keycloak, or Google Identity, protocols like OAuth 2.0 and OpenID Connect (OIDC) power the way users log in and grant application access.
While using pre-built identity providers is standard practice in production, the specifications themselves can feel like black boxes. Terms like authorization_code, id_token, JWKS, and PKCE often get thrown around, but how do they actually work under the hood?
In this post, we are going to pull back the curtain. We will build a lightweight, fully compliant OAuth2 and OIDC provider from scratch in Node.js using Express and jsonwebtoken, implementing the Authorization Code Flow with PKCE, an Authorization Endpoint, a Token Endpoint, and a UserInfo Endpoint.
1. The Architecture of Auth2 and OIDC
Before writing code, let’s ground ourselves in the core concepts.
- OAuth 2.0 is an authorization framework. It allows a client application to access resources on behalf of a resource owner (the user). It issues Access Tokens.
- OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. It standardizes identity by introducing ID Tokens (JSON Web Tokens containing user profile data) and the
/userinfoendpoint.
The Authorization Code Flow (with PKCE)
Our provider will support the Authorization Code Flow with Proof Key for Code Exchange (PKCE), which is the gold standard for secure authentication in Single Page Apps (SPAs), Mobile Apps, and secure Server-Side Apps alike.
- Authorization Request: The client redirects the user to our
/authorizeendpoint with a code challenge, client ID, and redirect URI. - User Authentication: Our provider authenticates the user (via a login form or session) and asks them to consent.
- Authorization Code: Upon consent, our provider redirects the user back to the client with a short-lived
authorization_code. - Token Exchange: The client sends the
authorization_codeand the PKCEcode_verifierdirectly to our/tokenendpoint. - Token Issuance: We validate the code and verifier, then return an
access_tokenand anid_Token.
2. Setting Up the Project
Let’s initialize our Node.js environment. Create a new directory and install our core dependencies: Express for routing, jsonwebtoken for signing tokens, and uuid for generating unique identifiers.
mkdir oidc-provider-scratch
cd oidc-provider-scratch
npm init -y
npm install express jsonwebtoken uuid
Create a file named server.js. We will build our entire in-memory identity provider inside this single file for clarity.
const express = require('express');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const PORT = 3000;
// Mock Database
const USERS = [
{ sub: 'user-123', email: 'alice@example.com', name: 'Alice Smith', password: 'password123' }
];
const CLIENTS = [
{
client_id: 'my-spa-client',
client_secret: 'super-secret-key', // Optional for public clients using PKCE
redirect_uris: ['http://localhost:8080/callback']
}
];
// In-memory stores for runtime state
const authorizationCodes = new Map(); // code -> { client_id, redirect_uri, sub, code_challenge }
const refreshTokens = new Map(); // refresh_token -> sub
// Cryptographic Keys for Signing JWTs (In production, use private/public RSA keys)
const JWT_SECRET = 'YOUR_SUPER_SECRET_HMAC_KEY';
const ISSUER = `http://localhost:${PORT}`;
3. Implementing the Authorization Endpoint
The authorization endpoint is where the user interacts with the provider. When a client application redirects a user here, we verify the parameters, render a simple login screen, and capture user consent.
app.get('/authorize', (req, res) => {
const { client_id, redirect_uri, response_type, scope, code_challenge, code_challenge_method, state } = req.query;
// 1. Validate Client
const client = CLIENTS.find(c => c.client_id === client_id);
if (!client || !client.redirect_uris.includes(redirect_uri)) {
return res.status(400).send('Invalid client_id or redirect_uri');
}
if (response_type !== 'code') {
return res.status(400).send('Unsupported response_type. Only "code" is supported.');
}
// 2. Render a simple login form (In a real app, check user session first)
res.send(`
<html>
<head><title>Sign In</title></head>
<body style="font-family: sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh;">
<form method="POST" action="/authorize" style="border: 1px solid #ccc; padding: 2rem; border-radius: 8px;">
<h2>Sign In to Identity Provider</h2>
<input type="hidden" name="client_id" value="${client_id}" />
<input type="hidden" name="redirect_uri" value="${redirect_uri}" />
<input type="hidden" name="code_challenge" value="${code_challenge || ''}" />
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ''}" />
<input type="hidden" name="state" value="${state || ''}" />
<div style="margin-bottom: 1rem;">
<label>Email:</label><br/>
<input type="email" name="email" value="alice@example.com" style="width: 100%; padding: 0.5rem;" />
</div>
<div style="margin-bottom: 1rem;">
<label>Password:</label><br/>
<input type="password" name="password" value="password123" style="width: 100%; padding: 0.5rem;" />
</div>
<button type="submit" style="width: 100%; padding: 0.75rem; background: #007bff; color: white; border: none; border-radius: 4px;">Authorize</button>
</form>
</body>
</html>
`);
});
When the user submits their credentials, our provider validates them, generates a single-use authorization code, and redirects the user back to the client application.
app.post('/authorize', (req, res) => {
const { email, password, client_id, redirect_uri, code_challenge, code_challenge_method, state } = req.body;
// 1. Authenticate User
const user = USERS.find(u => u.email === email && u.password === password);
if (!user) {
return res.status(401).send('Invalid username or password');
}
// 2. Generate Authorization Code (expires in 60 seconds)
const authCode = uuidv4();
authorizationCodes.set(authCode, {
client_id,
redirect_uri,
sub: user.sub,
code_challenge,
code_challenge_method,
expiresAt: Date.now() + 60000
});
// 3. Redirect back to client with code and state
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.append('code', authCode);
if (state) redirectUrl.searchParams.append('state', state);
res.redirect(redirectUrl.toString());
});
4. Implementing PKCE Verification
Before writing the token endpoint, let’s write a quick helper function to verify PKCE code challenges. PKCE protects public clients from authorization code interception attacks by requiring the client to send a code_verifier that hashes to the code_challenge sent earlier.
function verifyPKCE(verifier, challenge, method) {
if (!challenge) return true; // If no challenge was sent, bypass (though not recommended for public clients)
if (method === 'S256') {
const computedHash = crypto
.createHash('sha256')
.update(verifier)
.digest('base62') // standard base64url encoding
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
return computedHash === challenge;
}
// Fallback for 'plain'
return verifier === challenge;
}
5. Implementing the Token Endpoint
The token endpoint handles the backend exchange of the authorization_code for tokens. This is where OIDC shines by issuing an id_token alongside the OAuth2 access_token.
app.post('/token', (req, res) => {
const { grant_type, client_id, code, redirect_uri, code_verifier } = req.body;
if (grant_type !== 'authorization_code') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// 1. Validate Authorization Code
const authData = authorizationCodes.get(code);
if (!authData || authData.expiresAt < Date.now()) {
authorizationCodes.delete(code);
return res.status(400).json({ error: 'invalid_grant', error_description: 'Code is invalid or expired' });
}
if (authData.client_id !== client_id || authData.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Client or redirect URI mismatch' });
}
// 2. Verify PKCE Code Verifier
if (authData.code_challenge) {
const isValidPKCE = verifyPKCE(code_verifier, authData.code_challenge, authData.code_challenge_method);
if (!isValidPKCE) {
return res.status(400).json({ error: 'invalid_grant', error_description: 'Failed PKCE verification' });
}
}
// Consume code (one-time use)
authorizationCodes.delete(code);
// 3. Find User
const user = USERS.find(u => u.sub === authData.sub);
// 4. Issue Access Token (OAuth2)
const accessToken = jwt.sign(
{ sub: user.sub, client_id, scope: 'openid profile email' },
JWT_SECRET,
{ expiresIn: '1h' }
);
// 5. Issue ID Token (OIDC) - Contains assertions about the authentication of a user
const idToken = jwt.sign(
{
iss: ISSUER,
sub: user.sub,
aud: client_id,
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
auth_time: Math.floor(Date.now() / 1000),
email: user.email,
name: user.name
},
JWT_SECRET
);
// Return tokens to client
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
id_token: idToken
});
});
6. Implementing the UserInfo Endpoint
In OpenID Connect, the client can use the Access Token to query the /userinfo endpoint to fetch standard claims about the currently authenticated user.
app.get('/userinfo', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'invalid_token', error_description: 'Missing or malformed token' });
}
const token = authHeader.split(' ')[1];
try {
// Verify Access Token
const payload = jwt.verify(token, JWT_SECRET);
const user = USERS.find(u => u.sub === payload.sub);
if (!user) {
return res.status(404).json({ error: 'user_not_found' });
}
// Return standard OIDC claims
res.json({
sub: user.sub,
email: user.email,
email_verified: true,
name: user.name
});
} catch (err) {
return res.status(401).json({ error: 'invalid_token', error_description: err.message });
}
});
7. Adding OIDC Discovery Metadata (.well-known)
OIDC providers publish a configuration document so client libraries can auto-configure endpoints. Let’s add the standard OpenID configuration discovery route.
app.get('/.well-known/openid-configuration', (req, res) => {
res.json({
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
token_endpoint: `${ISSUER}/token`,
userinfo_endpoint: `${ISSUER}/userinfo`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
code_challenge_methods_supported: ['S256', 'plain']
});
});
app.listen(PORT, () => {
console.log(`Identity Provider running at ${ISSUER}`);
});
8. Testing Your Provider
Start your server:
node server.js
You can now point any OIDC-compliant client library (like oidc-client-js or Passport.js strategies) or construct manual browser requests to http://localhost:3000/.well-known/openid-configuration to watch your custom identity provider spring to life.
Summary of What We Built:
- The
/authorizeendpoint handles user authentication and consent, spitting out a temporary authorization code. - PKCE security logic ensures that even public clients are protected against code interception attacks.
- The
/tokenendpoint safely exchanges codes for cryptographic Access Tokens and ID Tokens. - The
/userinfoendpoint provides normalized claims using the Bearer token standard.
Building this from scratch strips away the complexity of enterprise tools and reveals how simple the underlying RFC specifications for OAuth2 and OIDC really are.”}