Identity

Sign in through OpenID Connect

Register your web app or browser extension as an OpenID Connect client of your sign-in gate and get session tokens your backend verifies.

Your web application or browser extension can sign end users in through your sign-in gate as an OpenID Connect client. It sends the end user to the hosted sign-in page and gets back an access token that is an ordinary session token: your backend verifies it with verifyToken(), and the Public API accepts it, as it accepts the token of a sign-in on your own pages.

Use it when your application needs a session token but you do not want to build sign-in pages. A hosted sign-in that only sends the end user back to your app hands your application no token (see Where each flow ends); a sign-in your application starts through OpenID Connect does. Google and GitHub sign-in stay available, on the hosted sign-in page.

Turn on the provider

The provider is served on one brand domain of your gate, which must be verified first; see Gates and brand domains. In the operator console, under Settings, Project, Sign-in clients, choose the brand domain and Turn on. Only owners and admins can.

The page then shows the provider's Issuer, https://<brand domain>/oidc, and its Discovery URL. The same is PUT /mgt/v1/identities/{identityId}/oidc-provider with { "host": "<brand domain>" } in the Management API; a host that is not a verified brand domain of the gate is refused with 400 OIDC_ISSUER_INVALID. Turning the provider off keeps the registered clients, which work again once it is on.

Register your application

On the same page, Create client registers one application. The same is POST /mgt/v1/identities/{identityId}/relying-parties:

Request body
{
  "name": "Web console",
  "client_type": "public",
  "redirect_uris": ["https://console.example.com/auth/callback", "https://console.example.com/auth/silent"],
  "post_logout_redirect_uris": ["https://console.example.com/signed-out"]
}
FieldWhat to set
name1 to 100 characters, shown in the console
client_typepublic for a browser application or an extension: no secret, the code is protected by PKCE. confidential for a server that keeps a secret
redirect_uris1 to 10 callback addresses
post_logout_redirect_urisOptional, 0 to 10 addresses that sign-out may return to

Each address must be absolute, use https (plain http only on localhost, 127.0.0.1 or [::1]), carry no fragment, wildcard or user name, and be at most 512 characters. The provider compares them character for character, so register exactly what your application sends. Anything else is refused with 400 VALIDATION_ERROR.

The answer carries the client_id. A confidential client also gets its client_secret, shown once; a public client's is null.

The endpoints

All of them are under the issuer, https://<brand domain>/oidc, and listed in its discovery document:

EndpointPath
Discovery/oidc/.well-known/openid-configuration
Authorization/oidc/auth
Token/oidc/token
Keys/oidc/jwks
Sign-out/oidc/session/end

The provider offers the authorization code flow only. There is no refresh token, no userinfo endpoint and no dynamic client registration.

Send the end user to sign in

Redirect the browser to the authorization endpoint with these parameters:

ParameterValue
response_typecode
client_idYour client_id
redirect_uriOne of the registered addresses, exactly
scopeopenid public; add email for the email claims in the ID token
code_challengeThe base64url SHA-256 of a random code_verifier that you keep
code_challenge_methodS256, the only method
nonceA random value; required
stateA random value that you check on the callback
promptOptional: none renews without showing a page; see Renew the token

Ask for the public scope

public in scope is what makes the access token a session token. A token issued without it is refused by verifyToken() with audience_mismatch, and by the Public API with 401.

The end user signs in on the hosted page, and the browser comes back to redirect_uri with code and your state. A request without nonce comes back with error=invalid_request and no code. An unknown client or an unregistered redirect_uri stops on the provider's error page and never reaches your application.

Exchange the code

Send the code to the token endpoint as a form:

Token request
curl -X POST https://auth.example.com/oidc/token \
  -d grant_type=authorization_code -d client_id=<client_id> -d code=<code> \
  -d code_verifier=<code_verifier> -d redirect_uri=https://console.example.com/auth/callback

A confidential client authenticates with HTTP Basic, its client_id and client_secret, instead of sending client_id. A code is valid for 60 seconds and can be used once. From a browser, the token endpoint accepts a public client's request only from the origin of one of its registered redirect addresses.

Field of the answerValue
access_tokenThe session token for your backend and the Public API
token_typeBearer
expires_in900, that is 15 minutes
scopepublic
id_tokenThe ID token, for your application to read who signed in

Use the access token

Send the access token to your backend as Authorization: Bearer <access_token>. verifyToken() accepts it like any other session token, and its claims name the account, the buyer organization and your application; see How your backend knows who is calling. The Public API accepts it too, so the React components can use it as their getAccessToken().

Do not send the ID token to your backend. Its audience is your client_id, and verifyToken() refuses it with audience_mismatch.

Renew the token

There is no refresh token. Renew by sending the authorization request again with prompt=none. While the end user's sign-in on the provider is still valid, for at most 7 days, the browser comes back to your redirect_uri with a new code and no page is shown. When there is no sign-in to reuse, it comes back with error=login_required and no code; then send the end user through the normal request.

  • A web application can load the prompt=none request in a hidden iframe whose callback, such as /auth/silent above, is registered too and passes the callback address back to your page. The provider's cookie is SameSite=Lax, so the iframe carries it only when your application and the brand domain are the same site, for example console.example.com and auth.example.com. Otherwise, renew with a full-page redirect.
  • A browser extension renews as in the next section.

A browser extension

Use chrome.identity.launchWebAuthFlow with the extension's redirect address, https://<extension ID>.chromiumapp.org/, which chrome.identity.getRedirectURL() returns. Register it like any other https address. A chrome-extension:// address is refused, because redirect addresses must use https.

The token endpoint refuses requests sent from the extension itself: they come from the origin chrome-extension://<extension ID>, which is not the origin of a registered redirect address. Let your backend exchange the code instead. The extension passes it, with its code verifier, to an endpoint of yours:

extension/auth.ts
const base64url = (bytes: Uint8Array) =>
  btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');

export async function signIn(interactive: boolean): Promise<{ access_token: string; expires_in: number }> {
  const redirectUri = chrome.identity.getRedirectURL(); // https://<extension ID>.chromiumapp.org/
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const challenge = base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))));
  const state = crypto.randomUUID();
  const params = new URLSearchParams({
    client_id: CLIENT_ID,
    response_type: 'code',
    redirect_uri: redirectUri,
    scope: 'openid public',
    code_challenge: challenge,
    code_challenge_method: 'S256',
    state,
    nonce: crypto.randomUUID(),
  });
  if (!interactive) params.set('prompt', 'none');

  const returned = new URL(await chrome.identity.launchWebAuthFlow({ url: `${ISSUER}/auth?${params}`, interactive }));
  if (returned.searchParams.get('state') !== state) throw new Error('state mismatch');
  const error = returned.searchParams.get('error');
  if (error) throw new Error(error); // login_required: no sign-in to reuse; call signIn(true)

  // The token endpoint refuses requests from the extension's own origin: your backend exchanges the code.
  const response = await fetch(`${API}/auth/extension/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code: returned.searchParams.get('code'), code_verifier: verifier }),
  });
  if (!response.ok) throw new Error(`sign-in failed: ${response.status}`);
  return response.json();
}

Your backend exchanges the code for the registered extension address only, and verifies the token before it hands it back:

backend/extension-token.ts
const ISSUER = process.env.SHIPREV_OIDC_ISSUER!; // https://auth.example.com/oidc
const CLIENT_ID = process.env.SHIPREV_OIDC_CLIENT_ID!;
const EXTENSION_REDIRECT_URI = process.env.EXTENSION_REDIRECT_URI!; // https://<extension ID>.chromiumapp.org/

export async function exchangeExtensionCode(code: string, codeVerifier: string) {
  const response = await fetch(`${ISSUER}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: CLIENT_ID,
      code,
      code_verifier: codeVerifier,
      redirect_uri: EXTENSION_REDIRECT_URI,
    }),
  });
  if (!response.ok) throw new Error(`token request failed: ${response.status}`);
  const { access_token, expires_in } = (await response.json()) as { access_token: string; expires_in: number };
  await shiprev.verifyToken(access_token); // throws unless it is a session token of your application
  return { access_token, expires_in };
}

To renew, call signIn(false) before the token expires. It returns a new token without opening a window while the end user's sign-in on the provider is still valid and the browser presents it to the authorization window, and throws login_required otherwise; then call signIn(true), which shows the hosted sign-in page.

Sign out

Send the browser to the sign-out endpoint with id_token_hint, the ID token, and a registered post_logout_redirect_uri. It ends the end user's sign-in on the provider, so the next prompt=none request comes back with login_required. Access tokens already issued keep verifying until they expire, at most 15 minutes later.

Next

© 2026 ShipRev