This article defines identity providers (IdPs) and demonstrates how they function by constructing an OpenID connect (OIDC) from scratch. In addition, identify common potential weaknesses that might happen.

To avoid confusion, it is best to mention a little something about terminology:

Authentication: when user sign in without any privileges
Authorization: we assign the user privileges (write/read)
Client: usually is referred to an API or website that is trying to use the IDP

Keep in mind that my code does not have to be the best of the best, and you may be able to do better. I demonstrated how to easily integrate this technology into your tech stack.

if you are just looking for the full code:
https://github.com/BitR13x/idp_provider

So what is that?

Have you ever heard of Single Sign-On (SSO), which uses Google, GitHub, and other services? That is essentially what OAuth and OpenID Connect are.

OAuth is an industry standard for authorizing a user to access third-party software. In other words, the user does not have to sign in several times with the same password to utilize each service.

Simplified flow of authentication/authorization

This diagram depicts how the login is done in a basic manner; we are attempting to redirect authentication to (identity) provider.

Difference between OAuth vs OIDC

OAuth 2.0 (Authorization): Handles “What can you do?”

  • You do not care about the user it self, you only care about what they can do.

OIDC (Authentication): Handles “Who are you?”

  • OIDC is extended OAuth, you care about the user, who is it?
  • Authentication + Authorization

How it really works?

We will start from the login button:

1) Redirect

  • The process begins when a user clicks “Login.”
  • Your application (client) doesn’t ask for a password. Instead, it redirects the user’s browser away from site to the OpenID Connect Provider (IdP).
  • User will login as usual

2) Callback

  • Once the user successfully logs in and approves the request
  • The IdP redirects the browser to a specific URL on your server — usually defined as /callback.
  • Attached to this URL is a short-lived (This code is not a key)

3) Code for Token

  • The application (client) sends code to the IdP and IdP sends back access token.

4) Access Token

  • With this token you can access the API’s
Diagram showing described flow

Perhaps you are wondering why we do not return the access token right away?

The difficulty is that you need to notify the program (client) that you have completed login, which is why the callback is required.

Why not append the access token directly to the URL? It is possible, but it is considered a security risk because it can be spoof, which is why an AuthCode is issued that is only valid for a certain time.

Options for implementation

A few words on current options:

  • SaaS (Auth0, Okta, AWS Cognito)
  • Open Source (Keycloak, Ory Hydra)

Access tokens:

Opaque Tokens — “Reference Tokens”

  • The string is simply a pointer to a record in the database.

Most providers give JWTs (JSON Web Tokens), which is why we will concentrate on them.

Here is an example of the token_id sent to the client:

{
"iss": "https://idp.example.com",
"sub": "user_id",
"aud": "client",
"exp": 1735689600,
"iat": 1735686000,
"nbf": 1735686000,
"jti/nonce": "e9c1a2f4"
}

Jti, or nonce, protects against replay attacks (re-use of the same token); nonce is used to identify each token and is commonly used as a “kill switch”.

  • exp (expiration)
  • iat (issued at)
  • nbf (not valid before)

Login Page

One option for smaller infrastructures is to build a login directly on the client and use the IdP as a “Auth provider.” This is less scalable than the standard approach of building a single login page for the IdP and redirecting the client to it.

Implementation of IdP in elysia

We will be focusing mostly on IdP endpoints because there are numerous methods for implementing database systems or deployments, among other things.

To begin, it is good practice to make explanation route:

    .get('/.well-known/openid-configuration', () => {
const issuer = config.oidc.issuer;
return {
issuer: issuer,
authorization_endpoint: `${issuer}/authorize`,
token_endpoint: `${issuer}/token`,
userinfo_endpoint: `${issuer}/userinfo`,
jwks_uri: `${issuer}/.well-known/jwks.json`,
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'default']
};
})

This will introduce our routes, letting the client know where to authorize and obtain tokens.

Our goal will be implement these routes.

We’ll need to begin with:

Token implementation

JWT tokens use a variety of transportation algorithms, most notably HS256 and RS256.

HS256 is symmetrical and uses a simple secret to verify and decode, making it perfect for application authentication (which we will use later).

RS256, on the other hand, uses public/private keys, which are required for cross-application verification; we simply generate these keys and disclose the public key.

# generating private/public key
openssl genpkey -algorithm RSA -out private.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private.pem -out public.pem
import * as jose from 'jose';
import { readFileSync } from 'fs';
import { join } from 'path';
import { config } from '../config';

let privateKey: jose.KeyObject;
let publicKey: jose.KeyObject;
let jwks: { keys: jose.JWK[] };

export async const initializeKeys = () => {
const privateKeyPath = join(import.meta.dir, '../config/private.pem');
const publicKeyPath = join(import.meta.dir, '../config/public.pem');

const privateKeyPEM = readFileSync(privateKeyPath, 'utf8');
const publicKeyPEM = readFileSync(publicKeyPath, 'utf8');

privateKey = await jose.importPKCS8(privateKeyPEM, 'RS256');
publicKey = await jose.importSPKI(publicKeyPEM, 'RS256');

const jwk = await jose.exportJWK(publicKey);
jwk.kid = config.oidc.jwt.kid;
jwk.use = 'sig';
jwk.alg = 'RS256';

jwks = {
keys: [jwk]
};
};

export const getPrivateKey = () => {
return privateKey;
};

export const getJWKS = () => {
return jwks;
};
    .get('/.well-known/jwks.json', () => {
return getJWKS();
})

With that done we need to implement the authorize route:

Authorize

Here we need to define our database tables:

Database tables

There isn’t much to surprise here. All we need to do for the client is declare the redirect URLs; otherwise, another vulnerability known as open redirect would occur.

This route must generate the AuthCode and return it to the callback, but we must ensure that everything we receive is correct.

Client:

        const client = await clientRepo.findOneBy({ id: client_id });

if (!client) {
return status(400, "Invalid client_id");
}
if (!client.redirect_uris.includes(redirect_uri)) {
return status(400, "Invalid redirect_uri");
}

User and scope

        const requestedScopes = (scope || '').split(' ');
const allowed = requestedScopes.every(s => client.allowed_scopes.includes(s));
if (!allowed) return status(400, "Invalid scope");

if (!user) {
const currentUrl = request.url;
return redirect(`/login?return_to=${encodeURIComponent(currentUrl)}`);
};

And define model:

    .get('/authorize', async ({ query: { client_id, redirect_uri, scope, state, nonce }, redirect, status, user, request }) => {
const clientRepo = AppDataSource.getRepository(Client);
...

const code = uuidv4();
await AuthCode.create({...}).save();
...

// constructing url
const redirectUrl = new URL(redirect_uri);
redirectUrl.searchParams.set('code', code);
if (state) redirectUrl.searchParams.set('state', state);

return redirect(redirectUrl.toString());
}, {
query: t.Object({
client_id: t.String(),
redirect_uri: t.String(),
scope: t.Optional(t.String()),
state: t.Optional(t.String()),
nonce: t.Optional(t.String())
})
})

Models in Elysia are checkers (specify what we require for that specific route) and can be simply connected to Swagger or Openapi (api documentation).

The state guards against Cross-Site Request Forgery (CSRF) attacks.

The client will transmit it to the IdP and then verify that it is correct.
And now that we have virtually everything, all we need to do is generate tokens.

Token generation

This route will also need to check for the proper client (client id, secret) and code that we received (if it exists, expiration date, and if matching client id).

        // ID Token (OIDC)
const idToken = await new jose.SignJWT({
sub: authCode.user.id,
name: authCode.user.username,
email: authCode.user.email,
nonce: authCode.nonce,
aud: client_id,
iss: issuer,
})
.setProtectedHeader({ alg: 'RS256', kid: config.oidc.jwt.kid })
.setIssuedAt()
.setExpirationTime(config.oidc.jwt.idTokenExpiration)
.sign(privateKey);

// Access Token (OAuth2)
const accessToken = await new jose.SignJWT({
sub: authCode.user.id,
scope: authCode.scope,
aud: api_aud,
iss: issuer
})
.setProtectedHeader({ alg: 'RS256', kid: config.oidc.jwt.kid })
.setIssuedAt()
.setExpirationTime(config.oidc.jwt.accessTokenExpiration)
.sign(privateKey);

This code is the main element of the route. It generates the tokens that the API will check using our public key, therefore we must sign it with our private key and set the appropriate parameters.

The ID token is intended for the front-end, whereas the access token grants us access to APIs.

However, you may have noticed that we only received a single aud for the access token, and we would need to repeat the authentication process, you are right.

We have several approaches that we can take:

  • Super Token (listed as aud)
  • Separate Tokens (using the refresh token)

The verification methodology for a refresh token is the same as for code; the refresh token is generated during the phase in which you get correct code.

if (code) {
... // handle code
remove code
} else if (refresh_token) {
... // handle refresh_token
remove refresh_token
} else {
return status(400, { error: "unsupported_grant_type" });
};

// generate access_token + token_id + refresh_token

For sake of simplicity we will not focus on the this problem.

And instead use this version:

    .post('/token', async ({ body: { client_id, client_secret, code }, error, request }) => {
// Validate Client
...

// Validate Code
const authCode = await AuthCode.findOne({
where: { code },
relations: ["user", "client"]
});
...

// Generate Tokens
...
await codeRepo.remove(authCode);

return {
access_token: accessToken,
id_token: idToken,
token_type: 'Bearer',
expires_in: 7200
};
}, {
body: t.Object({
client_id: t.String(),
client_secret: t.String(),
code: t.String(),
api_aud: t.String()
})
})

We have now received a token on our client and may interact with the API.

The following path ‘/userinfo’, however it simply provides information about the logged user from the access_token, as the name implies.

There is still so-called “database garbage”. This is addressed via Expiration Management.

DELETE FROM auth_code WHERE expiresAt < NOW

API side of IdP

We do not need to handle login/registration anymore; we can just focus on the tokens. We need to verify that the token is coming from the right source and that the signature is correct:

import { createRemoteJWKSet, jwtVerify } from 'jose'

export async function verifyAccessToken(token: string) {
const JWKS = createRemoteJWKSet(
new URL(`${ISSUER}/.well-known/jwks.json`)
);

const { payload } = await jwtVerify(token, JWKS, {
issuer: ISSUER,
audience: 'API-A'
})

return payload
};

We have now available this token:

{
sub: user.id,
scope: scopes,
aud: api_aud,
iss: issuer
}

Where issuer and audience is validated from “jose” library.

If you do not have access to these libraries, you must check the issuer and audience, and remember to get the public-key for signature verification from:

const iss_url = `${ISSUER}/.well-known/jwks.json`

We also set the protected-header during the token creation. This header contains:

  • alg: check protects against “alg: None” algorithm confusion attacks.
  • kid: Token is using an anticipated key.
  • type: token is the expected format.

We now have the user’s ID, which we may manage as we see fit, as well as scope, which allows us to control what the user can do.

Front-end side of IdP (React example)

You must implement the ‘/callback’ that will receive the code and then send it to IdP via the ‘/token’ route.

With that, the login is completed.

It is quite basic. The only difficulty is that react uses DOM rendering, thus you need to use:

window.location.pathname === '/callback'

To decide if the current page is callback.
Redirect function that will be assigned on SSO button:

export const loginWithRedirect = () => {
const redirectUri = window.location.origin + '/callback';
const clientId = client_id;
const audience = 'test_audience'; // needs to match with API
const scope = 'default';
const responseType = 'code';

const authUrl = `${IDP_VHOST}/authorize?` +
`client_id=${encodeURIComponent(clientId)}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=${encodeURIComponent(responseType)}&` +
`scope=${encodeURIComponent(scope)}&` +
`audience=${encodeURIComponent(audience)}`;

window.location.href = authUrl;
}

Main logic in the callback page will be just:

const code = urlParams.get('code');
const response = await axios.post(`${IDP_VHOST}/token`, {
client_id: client_id,
client_secret: client_secret,
code,
api_aud: 'test_audience'
});
const { access_token, id_token, refresh_token } = response.data;

Benefits

  • Scalable infrastructure (you can make several APIs operate together in harmony, leveraging the capabilities of frameworks)
  • Single Sign-On (SSO)

Disadvantages

  • Permissions require additional maintenance
  • Incorrect implementation may lead to vulnerabilities.

Attack surface in OAuth/OIDC

Just a quick word on the topic of attack surface.

This topic is huge at it’s own we gone through a few of options already.

Identifying the attack surface is critical for assessing risk and determining where to hunt for vulnerabilities.

Diagram that shows part of attack surface

IdP can create a complex system where can be made a mistake pretty easily and it is good to know where to look for them.

Conclusion

Identity Providers built on OpenID standards play a huge role in modern digital ecosystems by delivering secure, interoperable, and user-centric authentication. By decoupling identity from individual applications.

OpenID-based IdPs reduce security risks in larger infrastucture, simplify access management, and enable seamless single sign-on across platforms. As organizations continue to adopt cloud services, APIs, and distributed architectures.

With everything said, you should be able to create your own IdP that can be used to integrate several applications and query them with a single login.

On top of that you know the most common vulnerabilies and why they can occure.

If you enjoyed this article, clap and follow me! Thanks for reading, and I wish you the best luck on your journey👋