Implement a simple client-side OIDC SDK

Logto offers a variety of SDKs for different platforms. Apart from our official SDKs, we encourage developers from the community to create their own user-friendly SDKs. This article will guide you on building a basic client-side SDK for OIDC.
Simeng
SimengDeveloper
August 01, 202316 min read
Implement a simple client-side OIDC SDK

Introduction

Logto provides our developers and business groups a comprehensive Customer Identity and Access Management (CIAM) solution. We provide a wide range of ready-to-use SDKs for different platforms and application frameworks. Combined with our Logto cloud service, you can effortlessly establish a highly secure user authorization flow for your application in a matter of minutes. As a company that was born from the developer community, Logto embraces and values our community engagement. In addition to those officially developed Logto SDKs, we continuously encourage and warmly welcome developers from the community to contribute their expertise by creating more diverse and user-friendly SDKs, fulfilling the unique needs of various platforms and frameworks. In this article, we will briefly demonstrate how to implement an OIDC standard auth SDK step-by-step.

Context

The OpenID Connect (OIDC) flow is an authentication protocol that builds upon the OAuth 2.0 framework to provide identity verification and single sign-on capabilities. It allows users to authenticate themselves with an application and get authorized for further access to any private resources securely. Please refer to the OIDC specs for more details.

Workflow

A standard authorization-code flow includes the following steps:

Private ResourcesAuthorization ServerClient AppPrivate ResourcesAuthorization ServerClient AppEnd Useranonymous visitor sign-insend authentication requestredirect to the user sign-in pagesign-in with credentialsredirect back to the client app with authorization_codetoken exchange request with the authorization_codegrant id_token, access_token and refresh_tokenauthenticatedprivate api request with access_tokensync signing keytoken validation200 OKEnd User

Authentication flow

  1. User initiates the sign-in request: An anonymous user comes to your application from a public entrance. Attempts to get authenticated and maybe further request to access a protected resource on a third party application or service.
  2. User authentication: The client app generates an authentication URI and sends a request to the authorization server, which redirects the user to its sign-in page. The user interacts with the sign-in page using a wide range of sign-in methods and get authenticated by the authorization server.
  3. Handle sign-in callback: Upon a successful authentication, the user will be redirected back to your application with a granted authorization_code . This authorization_code contains all the relevant permissions linked to the authentication status and requested authorization data.
  4. Token exchange: Request for token exchange using the authorization_code extracted from the redirect address above. In return:
    • id_token: A digitally signed JWT that contains identity information about the authenticated user.
    • access_token: A opaque access_token that can be used to access the user basic info endpoint.
    • refresh_token: Credential token allowing the user to maintain continuous exchange for an access_token

Authorization flow

  1. Accessing user information: To access more user information, the application can make additional requests to the UserInfo endpoint, utilizing the opaque access_token obtained from the initial token exchange flow. This enables retrieval of additional details about the user, such as their email address or profile picture.
  2. Granting access to the protected resource: If necessary, the application can make additional requests to the token exchange endpoint, utilizing the refresh_token combined with resource and scope parameters, to obtain a dedicated access_token for the user to access the target resource. This process results in the issuance of a JWT formatted access_token containing all the necessary authorization information to access the protected resource.

Implementation

We will follow some design strategies within our @logto/client JavaScript SDK to demonstrate the process of implementing a simple SDK for your own client application. Please bear in mind that the detailed code structure may differ depending on the client framework you are working with. Feel free to choose any of the Logto official SDKs as an example for your own SDK project.

Preview

class LogtoClient {
  protected readonly logtoConfig: LogtoConfig;
  protected readonly storage: Storage;
  protected oidcConfig: OidcConfig;
  protected accessTokenMap: Map

  constructor(logtoConfig: LogtoConfig) {}

  get async idToken(){}

  private async getOidcConfig(){}
  private async getJwksFunction() {}
  private async verifyIdToken() {}
  private async saveTokens() {}
  private async setSignInSession() {}
  private async getSignInSession() {}
  private async getAccessTokenByRefreshToken() {}

  async signIn(redirectUri: string) {}
  async handlerSignInCallback (callbackUri: string) {}
  async getUserInfo() {}
  async getAccessToken(resource?: string, scopes?: string[]) {}
}

Constructor

The constructor should take a logtoConfig as its input. This provides all the necessary configs you will need to establish an auth connection through this SDK.

Depending on the platform or framework you are using for the SDK, you may pass in a persistent local storage instance to the constructor. This storage instance will be used to store all the authorization-related tokens and secrets.

class LogtoClient {
  constructor(logtoConfig: LogtoConfig, storage: Storage) {}
}

Init user authentication

Before generating an authentication request URL, it is essential to complete several preparation steps to ensure a secure process.

Get OIDC configs from the authorization server

Define a private method `getOidcConfigs`` to fetch OIDC configurations from the authorization server's discovery endpoint. The OIDC configurations response contains all the metadata information that the client can use to interact with the authorization server, including its endpoint locations and the server's capabilities. (Please refer to OAuth OAuth Authorization Server Metadata Specs for more details.)

const discoveryPath = "/oidc/.well-known/openid-configuration";

private async getOidcConfigs() {
  if (this.oidcConfig) {
    return this.oidcConfig;
  }

 const response = await fetch(`${this.logtoConfig.endpoint}${discoveryPath}`)
 this.oidcConfig = await response.json();

 return this.oidcConfig;
}

PKCE Generator

A PKCE(Proof Key for Code Exchange) validation flow is essential for all the public client authorization code exchange flows. It mitigates the risk of the authorization_code interception attack. Thus a code_challenge and code_verifier is required for all the public client applications(e.g. native app and SPA) authorization requests.

The implementation methods make vary depends on the languages and frameworks you are using. Please refer to the code_challenge and code_verifier spec for the detailed definitions.

If you are working on a confidential client or server rendered web applications like express or nextjs, jump directly to the next step.
For more information about PKCE and the difference between public clients and confidential clients please check our OAuth security recap.
class PKCE {
  readonly codeVerifier: string;
  readonly codeChallenge: string;

  constructor() {
    this.codeVerifier = this.generateCodeVerifier();
    this.codeChallenge = this.generateCodeChallenge(this.codeVerifier);
  }

  /**
   * Generates code verifier
   *
   * @link [Client Creates a Code Verifier](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)
   */
  private generateCodeVerifier() {
    return generateRandomString();
  }

  /**
   * Calculates the S256 PKCE code challenge for an arbitrary code verifier and encodes it in url safe base64
   *
   * @param {String} codeVerifier Code verifier to calculate the S256 code challenge for
   * @link [Client Creates the Code Challenge](https://datatracker.ietf.org/doc/html/rfc7636#section-4.2)
   */
  private async generateCodeChallenge(codeVerifier: string) {
    const encodedCodeVerifier = new TextEncoder().encode(codeVerifier);
    const codeChallenge = new Uint8Array(
      await crypto.subtle.digest('SHA-256', encodedCodeVerifier)
    );
  }
}

Generate state parameter

In the authorization flow, the state parameter is a randomly generated value that is included in the authorization request sent by the client. It acts as a security measure to prevent cross-site request forgery (CSRF) attacks.

const state = generateRandomString();

Store intermediate session info

There are several parameters that need to be preserved in the storage for validation purpose after the user get authenticated and redirected back to the client side. We are going to implement a method to set those intermediate parameters to the storage.

  protected async setSignInSession (data: {
    codeVerifier: string;
    redirectUri: string;
    state: string;
  }) {
    await this.storage.setItem('signInSession', data);
  }

Sign-in

Let's wrapping up everything implemented above, define a method generating a user sign-in URL and redirect the user to the authorization server to get authenticated.

async signIn(redirectUri: string) {
  const { appId, resources, scopes } = this.logtoConfig;
  const { authorizationEndpoint } = await this.getOidcConfig();

  // For public client applications only
  const pkce = new PKCE();

  const state = generateRandomString();

  const queryParameters = new URLSearchParams({
    client_id: appId,
    redirect_uri: redirectUri,
    code_challenge: pkce.codeChallenge,
    code_challenge_method: 'S256',
    state,
    response_type: 'code',
    prompt: 'consent',
    scopes: [...reservedScopes, scopes],
  });

  // Append all the resource indicators if any
  for (const resource of resources ?? []) {
    urlSearchParameters.append('resource', resource);
  }

  const signInUrl = new URL(`${authorizationEndpoint}?${urlSearchParameters.toString()}`);

  await this.setSignInSession({
    redirectUri,
    state,
    codeVerifier: pkce.codeVerifier
  });

  navigate(signInUrl);
}

Handle user sign-in callback

In the previous section, we created a sign-in method that generates a URL for user authentication. This URL contains all the required parameters needed to initiate an authentication flow from a client app. The method redirects the user to the authorization server’s sign-in page for authentication. After a successful sign-in, the end user will be redirected back to the redirect_uri location provided above. All the necessary parameters will be carried in the redirect_uri to complete the following token exchange flows.

Extract and verify the callback URL

This validation step is extremely important to prevent any forms of forged authorization callback attacks. The callback URL MUST be carefully verified before sending a further code exchange request to the authorization server. First of all, we need to retrieve the signInSession data we stored from the app storage.

protected async getSignInSession () {
  session = await this.storage.getItem('signInSession', data);

  if (!session) {
    throw Error('Session not found');
  }
}

Then verify the callback URL’s parameter before sending out the token exchange request.

  • Use the previously stored redirectUri to verify whether the callbackUri is the same as the one we sent out to the authorization server.
  • Use the previously stored state to verify whether the returned state is the same as the one we sent out to the authorization server.
  • Check if any error is returned by the authorization server
  • Check the existence of the returned authorization_code
verifyAndParseCodeFromCallbackUri(callbackUri: string, redirectUri: string, state: string) {
  const url = new URL(callbackUri)
  const parameters = url.searchParams;

  if (!callbackUri.startWith(redirectUri)) {
    throw new Error('redirect_uri_mismatched')
  }

  //Check the error message.
  // If the authentication fails, the user will be redirected back with error parameters attached
  const error = parameters.get('error');
  const errorDescription = parameters.get('error_description');

  if (error || errorDescription) {
    throw new Error(`${error}:${errorDescription}`)
  }

  // State verification
  const callbackState = parameter.get('state');
  if (callbackState !== state) {
    throw new Error('state_mismatch');
  }

  // Extract authorization code
  const code = parameters.get('code');
  if (!code) {
    throw new Error('authorization_code_not_found')
  }

  return code;
}

Send the code exchange request

As the final step of the user authentication flow, we will use the returned authorization_code to send a token exchange request and obtain the required authorization tokens. For more details on the definitions of the request parameters, please refer to the token exchange specification.

  • code: the authorization_code we received from the callback URI
  • clientId: the application ID
  • redirectUri: The same value is used when generating the sign-in URL for the user.
  • codeVerifier: PKCE code verifier. Similar to the redirectUri, the authorization server will compare this value with the one we previously sent, ensuring the validation of the incoming token exchange request.
const getTokenByAuthorizationCode = async (payload: {
  code: string;
  clientId: string;
  tokenEndpoint: string;
  redirectUri: string;
  codeVerifier: string;
  }) => {
  const parameters = new URLSearchParams({
    client_id: payload.clientId,
    code: payload.code,
    code_verifier: payload.codeVerifier,
    redirect_uri: payload.redirectUri,
    grant_type: 'authorization_code',
  });

  return fetch(tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    body: parameters,
  });
}

Handle sign-in callback

Wrapping up everything we have. Let’s build the signInCallback handle method:

private async saveTokens({refreshToken, idToken, accessToken}) {
  this.storage.setItem('refreshToken', refreshToken);
  this.storage.setItem('idToken', idToken);

  // opaque access token without resource indicator specification
  this.accessTokenMap.setItem('', accessToken)

}

async handleSignInCallback(callbackUri: string){
  const signInSession = await this.getSignInSession();

  if (!signInSession) {
    throw new Error('sign_in_session.not_found');
  }

  const { redirectUri, state, codeVerifier } = signInSession;
  const code = verifyAndParseCodeFromCallbackUri(callbackUri, redirectUri, state);
  const { appId } = this.logtoConfig;
  const { tokenEndpoint } = await this.getOidcConfig();

  const response = await getTokenByAuthorizationCode({
    code,
    clientId: appId,
    tokenEndpoint,
    redirectUri,
    codeVerifier,
  });

  await this.saveTokens(response);
}

As a result, the token exchange request will return the following tokens:

  • id_token: OIDC idToken, a JSON Web Token (JWT) that contains identity information about the authenticated user. id_token can also be used as the Single Source of Truth(SSOT) of a user’s authentication status.
  • access_token: The default authorization code returned by the authorization server. Can be used to call the user info endpoint and retrieve authenticated user information.
  • refresh_token: (if offline_access scope present in the authorization request): This refresh token allows the client application to obtain a new access token without requiring the user to re-authenticate, granting longer-term access to the resources.
  • expires_in: The duration of time, in seconds, for which the access token is valid before it expires.

ID token verification

Verifying and extract claims from the id_token is a crucial step in the authentication process to ensure the authenticity and integrity of the token. Here are the key steps involved in verifying an idToken.

  • Signature Verification: The id_token is digitally signed by the authorization server using its private key. The client application needs to validate the signature using the authorization server's public key. This ensures that the token has not been tampered with and was indeed issued by the legitimate authorization server.
  • Issuer Verification**:** Check that the "iss" (issuer) claim in the id_token matches the expected value, indicating that the token was issued by the correct authorization server.
  • Audience Verification: Ensure that the "aud" (audience) claim in the id_token matches the client ID of the client application, ensuring that the token is intended for the client's
  • Expiration Check: Verify that the "iat" (issued at) claim in the id_token has not passed the current time, ensuring that the token is still valid. Since there are network transaction costs we need to set a issued time tolerance when validating the received token iat claim.

The returned id_token is a standard JSON Web Token (JWT). Depending on the framework you are using, you can find various convenient JWT token validation plugins to assist in decoding and validating the token. For this example, we will utilize jose in our JavaScript SDK to facilitate token validation and decoding.

  npm install jose
  import {createRemoteJWKSet, jwtVerify} from 'jose';

  private async getJwksFunction() {
    const { jwksUri } = await this.getOidcConfig();

    return createRemoteJWKSet(new URL(jwksUri))();
  }

  private async verifyIdToken(
    idToken: string,
    clientId: string,
    issuer: string,
  ) {
    const jwks = await this.getJwksFunction();
    const result = await jwtVerify(idToken, jwks, { audience: clientId, issuer });

    if (Math.abs((result.payload.iat ?? 0) - Date.now() / 1000) > issuedAtTimeTolerance) {
      throw new Error('id_token.invalid_iat');
    }

    return result;
  };

Get user info

After successful user authentication, basic user information can be retrieved from the OIDC id_token issued by the authorization server. However, due to performance considerations, the content of the JWT token is limited. To obtain more detailed user profile information, OIDC-compliant authorization servers offer an out-of-the-box user info endpoint. This endpoint allows you to retrieve additional user profile data by requesting specific user profile scopes.

When calling a token exchange endpoint without indicating a specific API resource, the authorization server will, by default, issue an opaque type access_token. This access_token can only be used to access the user info endpoint, allowing retrieval of basic user profile data.

  async getUserInfo() {
    const { userinfoEndpoint } = await this.getOidcConfig();
    const accessToken = await this.getAccessToken();

    return fetch(userinfoEndpoint, {
      headers: { Authorization: `Bearer ${accessToken}` }
    });
  }

Get access token for protected resource authorization

In most cases, a client application not only requires user authentication but also needs user authorization to access certain protected resources or API endpoints. Here, we will use the refresh_token obtained during login to acquire access_token(s) specifically granted to manage particular resources. This allows us to obtain access to those protected APIs.

During user sign-in, it is crucial to provide all API resource indicators to the authorization server. After successful authentication, we can retrieve only the access_tokens for the pre-configured specified resources. To achieve this, pre-configure all required API resource indicators in the client app's configuration settings before generating the sign-in URL.
private async getAccessTokenByRefreshToken(resource?:string, scopes?: string[]){
  const { appId } = this.logtoConfig;
  const { tokenEndpoint } = await this.getOidcConfig();
  const { refreshToken } = await this.storage.getItem('refreshToken');

  const parameters = new URLSearchParams({
    'grant_type': 'refresh_token',
    'client_id': appId,
    'refresh_token':
  });

  if (resource) {
    parameters.append('resource', resource);
  }

  if (scopes?.length) {
    parameters.append('scope', scopes.join(' '));
  }

  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    grant_type: 'authorization_code',
    body: parameters,
  });

  // save and replace the latest returned refreshToken
  await this.storage.setItem('refreshToken', response.refreshToken);

  await this.accessTokenMap.setItem(`${resource}:${scopes.join(_)}`, response.accessToken);

  return response.accessToken;
}


async getAccessToken(resource?:string, scopes?: string[]) {
  if (!this.idToken) {
    throw new Error('User not authenticated');
  }

  const accessTokenInStorage = this.accessTokenMap.getItem(`${resource}:${scopes.join(_)}`);

  if (accessTokenInStorage && accessTokenInStorage.expiresAt > Date.now() / 1000) {
      return accessToken.token;
  }

  return await this.getAccessTokenByRefreshToken(resource, scopes);
}

Summary

We have provided essential method implementations for the client-side app's user authentication and authorization process. Depending on your specific scenario, you can organize and optimize the SDK logic accordingly. Please note that variations may exist due to different platforms and frameworks.

For additional details, explore the SDK packages offered by Logto. We encourage more users to join the development and engage in discussions with us. Your feedback and contributions are highly valued as we continue to enhance and expand the SDK's capabilities.

Appendix

Reserved scopes

Logto reserved scopes you will need to pass in during the initial auth request. Those scopes are either OIDC-reserved or Logto-reserved fundamental scopes to complete a successful authorization flow.

Scope nameDescription
openidRequired for granting id_token after a successful authentication.
offline-accessRequired for granting a refresh_token that allows your client application to exchange and renew an access_token off the screen.
profileRequired for getting access to the basic user info

Logto Config

Property nameTypeRequiredDescriptionDefault Value
appIdstringtrueThe unique application identifier. Generated by the authorization server to identify the client application.
appSecretstringThe application secret is used, along with the application ID, to verify the identity of the requester. It is required for confidential clients like Go web or Next.js web apps, and optional for public clients like native or single-page applications (SPAs)
endpointstringtrueYour authorization server root endpoint. This property will be widely used to generate authorization requests.endpoint.
scopesstring listIndicates all the necessary resource scopes the user may need to be granted in order to access any given protected resources.[reservedScopes]
resourcesstring listAll the protected resource indicators the user may request for access

Util methods

generateRandomString

const generateRandomString = (length = 64) =>
  fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true);