This project is a small OpenID Connect style provider built with Express, Drizzle, PostgreSQL, and signed JWTs.
It supports:
- User sign-in and consent
- Authorization code exchange
- Access tokens and refresh tokens
- JWKS publishing for token verification
- User info lookup
If you are new to OIDC, think of it like this:
- Your client app sends the user to this provider to sign in.
- The user enters credentials and approves consent.
- The provider sends the user back to your client with an authorization code.
- Your client backend exchanges that code for tokens.
- Your client backend stores the session or tokens securely.
- When the access token expires, your backend uses the refresh token to get a new one.
The important rule is: the browser should never hold the client secret.
Before your other project can log in through this provider, it must be registered here:
POST /admin/apps/new
Example:
{
"displayName": "My App",
"appUrl": "http://localhost:3000",
"redirectUri": "http://localhost:3000/auth/callback",
"scopes": ["openid", "email", "profile"],
"grantTypes": ["authorization_code", "refresh_token"]
}The response contains:
client_idclient_secretlive_link
Store the client_id and client_secret in your client backend environment variables.
Your client app should redirect the user to this provider:
res.redirect(
`http://localhost:7000/o/authenticate?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent("openid email profile")}&state=${state}`
);Notes:
client_idmust match the registered app.redirect_urimust match exactly what you registered.stateshould be generated by your client app and saved in the client session.- The provider will return the same
stateback to your client.
The provider does two things:
- It checks the user credentials.
- It asks the user to approve the requested scopes.
After approval, the provider redirects the browser back to your client callback URL with:
codestate
Your client backend should receive the callback and exchange the code for tokens:
const tokenRes = await fetch("http://localhost:7000/o/tokeninfo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uri: "http://localhost:3000/auth/callback",
}),
});
const tokens = await tokenRes.json();The response contains:
access_tokenrefresh_tokentoken_typeexpires_inscope
For an Express client app, store tokens in the server session or another secure server-side store:
req.session.userId = user.id;
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.accessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;Do not store the client secret in the browser. Do not store long-lived tokens in localStorage if you can avoid it.
To call protected provider APIs, send the access token as a bearer token:
const userRes = await fetch("http://localhost:7000/o/userinfo", {
headers: {
Authorization: `Bearer ${req.session.accessToken}`,
},
});If the access token is expired, your backend should use the refresh token:
const refreshRes = await fetch("http://localhost:7000/o/tokeninfo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
refresh_token: req.session.refreshToken,
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
}),
});If refresh succeeds, replace the stored access token with the new one. If refresh fails, clear the session and send the user back to login.
Use these rules when registering a client app:
- Use the exact callback URL your client app will receive.
- Use a full absolute URL such as
http://localhost:3000/auth/callback. - Do not use a partial URL or a path without scheme.
- Register only the scopes your client really needs.
- Keep
client_secretonly on the backend. - Save the
client_idandclient_secretas environment variables. - Make sure the
redirect_uriused during token exchange matches the one registered in the database. - Generate and verify
statein the client session to protect against CSRF.
GET /o/authenticate- shows the sign-in pagePOST /o/authenticate/sign-in- validates the user and creates session statePOST /o/consent- saves user consent and creates an authorization codePOST /o/tokeninfo- exchanges authorization code or refresh token for access tokensGET /.well-known/openid-configuration- discovery documentGET /.well-known/jwks.json- public signing keys for JWT verificationPOST /o/userinfo- returns user profile information for a valid access token
Your client backend should fetch the discovery document first:
const discoveryRes = await fetch("http://localhost:7000/.well-known/openid-configuration");
const discovery = await discoveryRes.json();Then read jwks_uri from that response:
const jwksRes = await fetch(discovery.jwks_uri);
const jwks = await jwksRes.json();Use the JWKS public key to verify the JWT signature and check:
issexpsubclient_id
This lets your client know the token really came from this provider.
- Using the wrong
redirect_uri - Not preserving
statebetween login and callback - Trying to use
client_secretin the browser - Forgetting to send
credentials: "include"on session-based requests - Treating an expired access token as a hard logout when a refresh token is still valid
app.get("/login", (req, res) => {
const state = crypto.randomBytes(16).toString("hex");
req.session.oauthState = state;
const redirectUri = "http://localhost:3000/auth/callback";
res.redirect(
`http://localhost:7000/o/authenticate?client_id=${process.env.OIDC_CLIENT_ID}&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent("openid email profile")}&state=${state}`
);
});
app.get("/auth/callback", async (req, res) => {
const code = typeof req.query.code === "string" ? req.query.code : "";
const state = typeof req.query.state === "string" ? req.query.state : "";
if (!code || state !== req.session.oauthState) {
return res.redirect("/login");
}
const tokenRes = await fetch("http://localhost:7000/o/tokeninfo", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
code,
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uri: "http://localhost:3000/auth/callback",
}),
});
if (!tokenRes.ok) {
return res.redirect("/login");
}
const tokens = await tokenRes.json();
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
req.session.accessTokenExpiresAt = Date.now() + tokens.expires_in * 1000;
return res.redirect("/dashboard");
});- Register the app with
POST /admin/apps/new - Use the exact registered
redirectUri - Keep
client_secreton the backend only - Preserve and verify
state - Exchange
codefor tokens on the server - Refresh the access token with
refresh_tokenwhen it expires - Use
/.well-known/jwks.jsonto verify JWT signatures