Skip to content

Repository files navigation

OIDC OAuth Demo

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

How the flow works

If you are new to OIDC, think of it like this:

  1. Your client app sends the user to this provider to sign in.
  2. The user enters credentials and approves consent.
  3. The provider sends the user back to your client with an authorization code.
  4. Your client backend exchanges that code for tokens.
  5. Your client backend stores the session or tokens securely.
  6. 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.

End-to-end flow

1. Register the client app

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_id
  • client_secret
  • live_link

Store the client_id and client_secret in your client backend environment variables.

2. Send the user to the login page

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_id must match the registered app.
  • redirect_uri must match exactly what you registered.
  • state should be generated by your client app and saved in the client session.
  • The provider will return the same state back to your client.

3. User signs in and approves consent

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:

  • code
  • state

4. Exchange the authorization code for tokens

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_token
  • refresh_token
  • token_type
  • expires_in
  • scope

5. Store the session securely

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.

6. Use the access token

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}`,
  },
});

7. Refresh when the access token expires

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.

Client registration guidelines

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_secret only on the backend.
  • Save the client_id and client_secret as environment variables.
  • Make sure the redirect_uri used during token exchange matches the one registered in the database.
  • Generate and verify state in the client session to protect against CSRF.

Beginner-friendly summary of the main endpoints

  • GET /o/authenticate - shows the sign-in page
  • POST /o/authenticate/sign-in - validates the user and creates session state
  • POST /o/consent - saves user consent and creates an authorization code
  • POST /o/tokeninfo - exchanges authorization code or refresh token for access tokens
  • GET /.well-known/openid-configuration - discovery document
  • GET /.well-known/jwks.json - public signing keys for JWT verification
  • POST /o/userinfo - returns user profile information for a valid access token

How the client verifies tokens

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:

  • iss
  • exp
  • sub
  • client_id

This lets your client know the token really came from this provider.

Common mistakes

  • Using the wrong redirect_uri
  • Not preserving state between login and callback
  • Trying to use client_secret in 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

Minimal client flow example

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");
});

If you want the shortest rule set

  • Register the app with POST /admin/apps/new
  • Use the exact registered redirectUri
  • Keep client_secret on the backend only
  • Preserve and verify state
  • Exchange code for tokens on the server
  • Refresh the access token with refresh_token when it expires
  • Use /.well-known/jwks.json to verify JWT signatures

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages