Sep 1, 2026OIDCSSOSitecore OrderCloudNext.jsSitecoreAI
Bridging Legacy Identity to Modern SSO: An OIDC Bridge Pattern for Headless Commerce
Update (Sep 1, 2026): A reader pointed out that an earlier version of this post misstated how OrderCloud's role-based pricing actually works, describing thexpblock on a Buyer User as what directly feeds price differentiation. That's not accurate. OrderCloud's price differentiation comes from its Price Scheduling feature, which assigns a Price Schedule to a product per Buyer or UserGroup;xpis inert custom metadata the pricing engine never reads. The post below has been corrected accordingly.
Most legacy modernization projects hit the same wall eventually. There's a decades-old identity system,
perhaps a SOAP service sitting in front of a relational database, that every downstream system still has to
trust, and nobody wants to touch it directly. New applications keep getting built on top of it anyway,
because rebuilding identity from scratch means migrating passwords, breaking existing integrations, and
asking a whole membership or customer base to reset their accounts. That's rarely on the table.
This post walks through a pattern I built to solve that problem for a headless commerce project: a single,
standards-compliant OpenID Connect (OIDC) provider that sits in front of a legacy SOAP-based user repository
and becomes the only thing in the entire architecture allowed to call it. Everything else (a headless
Next.js storefront, Sitecore OrderCloud, and eventually other consumers) authenticates against that bridge
instead, using an ordinary, well-understood OIDC flow.
The starting point
- A legacy User Repository: a SOAP web service backed by a relational database, exposing operations like
ValidateUser,GetUser,GetRolesForUser. It's the system of record for every user, and it's staying. Replacing it wasn't in scope. - A new headless storefront, built on Next.js, with no authentication of its own yet.
- Sitecore OrderCloud, a hosted commerce-as-a-service platform, with its own native SSO integration mechanism: an OpenID Connect-based "login federation" feature it ships out of the box.
The constraint that shaped everything: no second password store, anywhere. Every candidate design that
would have meant caching credentials, syncing passwords, or minting long-lived service tokens against the
User Repository was rejected up front. The only acceptable shape was one where every login re-validates
against the User Repository, live, every time.
The pattern: one bridge, standards on both sides
The answer was a small, purpose-built service (call it the Identity Bridge) that speaks two "languages"
at once:
- On one side, it's the only client of the legacy User Repository's SOAP API. Nothing else in the new architecture is allowed to call it directly.
- On the other side, it's a fully standards-compliant OpenID Connect provider, built on
OpenIddict rather than a hand-rolled implementation. The protocol
details (PKCE verification, JWT signing and rotation, authorization-code replay protection) are exactly
the kind of security-critical code a mature, actively maintained library gets right far more reliably than
a bespoke one. It speaks the same protocol every modern auth library (Auth0, Okta, Better-Auth,
NextAuth/Auth.js, OrderCloud's own SSO feature) already knows how to consume, and exposes the normal
endpoints:
/authorize,/token,/userinfo,/jwks, and/.well-known/openid-configuration.
The legacy system doesn't get any easier to work with. It just only has to be worked with in one place, and
every downstream system gets a standard, well-documented OIDC integration instead of a bespoke one.
┌─────────────┐ OIDC ┌────────────────┐ SOAP ┌───────────────────┐
│ Storefront │ ───────────────────▶ │ Identity Bridge │ ─────────────────▶ │ User Repository │
│ (Next.js) │ ◀─────────────────── │ (OpenID │ ◀───────────────── │ (legacy SOAP + │
└─────────────┘ authorize/token │ Connect │ ValidateUser, │ relational DB) │
│ │ provider) │ GetUser, roles └───────────────────┘
│ chained, silent redirect ▲
▼ │ same OIDC provider,
┌─────────────┐ │ reused for a second,
│ Sitecore │ ────────────────────────────┘ independent client
│ OrderCloud │ OIDC (its own SSO feature)
└─────────────┘
Why OrderCloud needs its own copy of the user at all
"Just federate login" undersells what's actually happening here. OrderCloud isn't only checking whether a
person is allowed in. It's a full commerce engine with its own user storage (Buyer Users), and it needs a
real, first-class user record of its own to do its job: role-based catalog visibility, and critically,
price differentiation by role (a wholesale-vs-retail buyer, a member-vs-non-member rate, whatever the
role taxonomy is). OrderCloud's feature for this is Price Scheduling: the same product can carry more
than one Price Schedule, each assigned to a different Buyer or, more granularly, to a specific UserGroup
within a Buyer, and OrderCloud resolves which schedule (and therefore which price) applies off that
Buyer/UserGroup assignment, not off a Security Profile or a raw role claim. None of that works off a bare
login event, though. To put a shopper into the right UserGroup for their current membership tier, OrderCloud
still needs a durable, first-class user record of its own, kept current with whatever the legacy system says
about that person right now, not a claim buried in a token that expires in an hour.
That's the real reason this is a sync, not just a login. A one-time "create the account" call would
be enough if roles never changed. They do: a membership tier changes, a role gets added or revoked in the
legacy system, and OrderCloud's pricing and catalog behavior needs to reflect that on the next login, not
whenever someone remembers to run a batch job. So every sign-in re-derives the user's current roles from the
source of truth and pushes them into OrderCloud's own Buyer User record. The legacy system stays the system
of record; OrderCloud just gets a current copy of what it needs.
Why two chained OIDC flows, not one
OrderCloud ships its own OIDC-based SSO integration: point it at any standards-compliant provider, and it'll
redirect a signed-in user through that provider, mint its own session, and call a webhook to create or
update its own copy of the user record. Since the Identity Bridge is a standards-compliant OIDC provider,
the obvious move was to point OrderCloud at it directly.
Two simpler alternatives were seriously considered first, and both were rejected:
- Drop the storefront's own login flow and rely only on OrderCloud's SSO redirect. Rejected because the storefront needs its own session immediately, independent of OrderCloud, and needs full control over the profile-mapping logic (custom claims like membership roles) that only runs when the storefront's own auth library performs the OIDC exchange itself.
- Mint an OrderCloud token server-side, using an admin/impersonation credential, with no second redirect at all. Rejected because that path is gated behind OrderCloud's Impersonation feature, which carries different semantics (an admin acting as a user, not a native login), and still wouldn't satisfy the "fresh data on every login" requirement without extra machinery bolted on anyway.
So instead, the flow is genuinely two independent OIDC exchanges, chained:
- The storefront signs the user in against the Identity Bridge, a normal Authorization Code + PKCE flow, public client, no secret.
- The instant that completes, the browser is bounced once, silently, through OrderCloud's own OIDC login entry point, which redirects back to the same Identity Bridge, this time as a second, confidential client. Because the browser still carries the bridge's own session cookie from step 1, this completes with no second password prompt.
- OrderCloud's webhook lands back on the bridge asking "who is this," calling a create endpoint if it has never seen this user before, or a sync endpoint if it already has a Buyer User record for them. Either way, the bridge answers with a live profile, and OrderCloud creates or updates its Buyer User record accordingly.
From the user's perspective it's one login. Under the hood it's two OIDC flows and one webhook call.
What the endpoints actually look like
Stripped down to their essential shape, this is the surface area involved. None of this is copy-paste-ready
production code; it's the skeleton that matters.
The bridge's own OIDC endpoints (ASP.NET Core + OpenIddict)
// The bridge is the *only* caller of the legacy repository's SOAP client.
public interface IUserRepositoryAuthService
{
Task<AuthResult> ValidateCredentialsAsync(string username, string password);
Task<UserProfile> GetProfileAsync(string userId);
Task<IReadOnlyList<string>> GetRolesAsync(string userId);
}
// /authorize mints an authorization code if a session cookie already exists,
// otherwise redirects to a login page. Refreshes the profile from the User
// Repository on every hit (see "Freshness over caching" below) rather than
// trusting whatever was cached at the original login.
app.MapGet("/authorize", async (HttpContext ctx, IOpenIddictServerFeature oidc,
IUserRepositoryAuthService repo) =>
{
var principal = await ctx.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme);
if (!principal.Succeeded)
return Results.Redirect("/login" + ctx.Request.QueryString);
var username = principal.Principal.FindFirstValue("preferred_username");
var freshProfile = await repo.GetProfileAsync(username); // never trust the cookie alone
var claims = ClaimsResolutionService.Map(freshProfile);
return Results.SignIn(BuildPrincipal(claims), authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
});
// /token, /userinfo, /jwks, /.well-known/openid-configuration are all
// standard OpenIddict middleware; no custom code needed beyond
// declaring which claims land in the id_token vs. are userinfo-only.
The claims-mapping boundary
Keeping the legacy-to-claims mapping in its own framework-agnostic layer is what makes it reusable. The same
mapping can later back a SAML assertion, a webhook payload, or a completely different protocol without
re-deriving it:
public record ResolvedIdentity(
string Subject, // stable legacy user ID, never username/email
string Email,
string GivenName,
string FamilyName,
IReadOnlyList<string> Roles);
public static class ClaimsResolutionService
{
public static ResolvedIdentity Map(UserProfile profile) =>
new(profile.Id, profile.Email, profile.FirstName, profile.LastName, profile.Roles);
}
OrderCloud's create/sync webhooks
OrderCloud's OIDC integration calls one of two webhook endpoints depending on whether it already has a
Buyer User record for this person:
/createuser the first time it ever sees them, /syncuser on every
login after that. Both carry the same payload shape and both end up doing the same upsert against the
legacy repository's live data. The only real difference is which one OrderCloud chooses to call, so both
route to a shared handler:// Every request from OrderCloud is HMAC-signed over the raw body; verify
// before trusting anything in it.
app.MapPost("/ordercloud/createuser", HandleCreateOrSyncAsync);
app.MapPost("/ordercloud/syncuser", HandleCreateOrSyncAsync);
async Task<IResult> HandleCreateOrSyncAsync(HttpRequest req, IUserRepositoryAuthService repo)
{
var raw = await new StreamReader(req.Body).ReadToEndAsync();
if (!VerifyHmac(req.Headers["x-oc-hash"], raw, hashKey))
return Results.Ok(new { ErrorMessage = "Invalid signature" }); // see note below
var payload = JsonSerializer.Deserialize<SyncUserRequest>(raw);
var profile = await repo.GetProfileAsync(payload.Sub);
// PUT is inherently create-or-replace against OrderCloud's Buyer Users
// resource. Whether this becomes a new record or an update depends
// entirely on which endpoint OrderCloud called, not on branching here.
await orderCloud.UpsertBuyerUserAsync(payload.BuyerId, MapToBuyerUser(profile));
return Results.Ok(new { Success = true });
}
That last comment matters: OrderCloud's webhook contract expects HTTP 200 unconditionally, with business
failures reported inside the response body instead. Not every webhook-driven integration works that way
(Stripe and GitHub, for example, use the HTTP status itself to signal failure or trigger a retry). Confirm
the actual contract for the platform in question before assuming either convention; getting it backwards
means a 4xx/5xx that was meant to signal "this login failed" instead reads as "your webhook is broken."
What a synced Buyer User actually looks like
After the create/sync webhook runs, this is what
GET /v1/buyers/{buyerID}/users returns from OrderCloud
itself, standard OrderCloud paging envelope included. The xp block is worth pausing on: a brand-new account
(created but never yet re-synced with a membership role) sits next to one that's been through a sync and
picked up a real role. It's tempting to read this as the thing driving the pricing use case described
earlier, but it isn't: xp is just arbitrary, application-defined metadata that OrderCloud stores and
returns verbatim, and its own pricing engine never reads it. What actually feeds OrderCloud's price
differentiation is Price Scheduling, keyed off Buyer/UserGroup assignment (see above), not off any xp
property. What Roles/PriceRoles are for is upstream of that: they're the durable record of what the
legacy system currently says about this user, which is the input the sync (or a later step) uses to decide
which UserGroup a Buyer User belongs to. Getting that UserGroup placement right, not the xp values
themselves, is what makes the price differentiation happen:{
"Meta": {
"Page": 1,
"PageSize": 20,
"TotalCount": 2,
"TotalPages": 1,
"ItemRange": [1, 2],
"NextPageKey": null
},
"Items": [
{
"FailedLoginAttempts": 0,
"ID": "1000001",
"CompanyID": "storefront-buyer",
"Username": "test.account1@example.com",
"Password": null,
"FirstName": "Test",
"LastName": "Account",
"Email": "test.account1@example.com",
"Phone": null,
"TermsAccepted": null,
"Active": true,
"xp": {
"LegacyUserID": "1000001",
"Roles": [],
"PriceRoles": []
},
"AvailableRoles": ["Shopper"],
"Locale": null,
"DateCreated": "2026-08-21T16:15:21.14+00:00",
"LastActive": "2026-08-21T17:03:42.81+00:00",
"PasswordLastSetDate": null
},
{
"FailedLoginAttempts": 0,
"ID": "1000002",
"CompanyID": "storefront-buyer",
"Username": "jordan.riley@example.com",
"Password": null,
"FirstName": "Jordan",
"LastName": "Riley",
"Email": "jordan.riley@example.com",
"Phone": null,
"TermsAccepted": null,
"Active": true,
"xp": {
"LegacyUserID": "1000002",
"Roles": ["Member"],
"PriceRoles": ["Member"]
},
"AvailableRoles": ["Shopper"],
"Locale": null,
"DateCreated": "2026-08-20T21:57:56.793+00:00",
"LastActive": "2026-09-01T12:57:04.177+00:00",
"PasswordLastSetDate": null
}
]
}
LegacyUserID is ResolvedIdentity.Subject from the claims-mapping stub above, carried through untouched;
it's what ties this OrderCloud record back to the one authoritative record in the legacy repository.
Roles/PriceRoles are exactly the values ClaimsResolutionService.Map produced on the most recent sync,
which is why the first record (a test account that's never re-authenticated since creation) still shows
empty arrays while the second shows a real, current role. AvailableRoles is a different thing entirely: an
OrderCloud-native field driven by the Security Profile assignment on the Buyer, not by anything from the
legacy repository.Prerequisites: OrderCloud objects that need to exist first
The Integration Event and OpenID Connect config below aren't the first things to exist in an OrderCloud
marketplace. They reference a few other objects that need to be there first:
- A Buyer: the company or organization every synced Buyer User belongs to. Its
IDis what the webhook handler'sUpsertBuyerUserAsynccall targets (/buyers/{BuyerId}/users/{id}). - A Security Profile assignment on that Buyer: grants the baseline OrderCloud permissions every synced user gets. Nothing from the legacy repository's roles feeds into this directly; it's a flat, buyer-level grant, separate from whatever role or price data rides along as extra properties on the user record.
- At least one API Client with
AllowSeller: truesomewhere in the marketplace. It isn't referenced by ID anywhere else, but its mere existence is a prerequisite for Integration Events to mint the elevated-role webhook tokens described below. Easy to miss, and the error message you get without it doesn't point at the real cause. - A buyer-facing API Client: either flagged
AllowAnyBuyer: trueor explicitly assigned to the Buyer above. ItsIDis thecidquery parameter on/ocrploginin the sequence below.
Standing up a marketplace from nothing also has a one-time chicken-and-egg bootstrap step (there's no
default admin user or API Client on a blank marketplace, so the first few objects have to be created through
OrderCloud's Portal UI rather than the REST API) that's genuinely outside this post's scope. OrderCloud's own
documentation covers all of this in more depth than makes sense to duplicate here: the
Getting Started tutorial for the
account-creation, marketplace-bootstrap walkthrough from a blank slate, the
OrderCloud knowledge base for concepts and setup guides beyond that,
and the API reference for the exact request/response shape of every
object mentioned above.
Wiring OrderCloud up to actually call them
None of the above fires on its own. OrderCloud has to be configured, on its own side, to know these
endpoints exist. Two OrderCloud objects do that:
An Integration Event tells OrderCloud where the webhook lives and how to authenticate to it:
POST /v1/integrationEvents
{
"ID": "identity-bridge-sync",
"EventType": "OpenIDConnect",
"CustomImplementationUrl": "https://<bridge-host>/ordercloud",
"HashKey": "<shared secret, must match the bridge's own HMAC key exactly>",
"ElevatedRoles": ["BuyerUserAdmin"]
}
CustomImplementationUrlis a base path. OrderCloud appends/createuseror/syncuseritself when it actually calls out, so the bridge's two routes above just need to live under that same base.ElevatedRolesis what makes the webhook payload'sOrderCloudAccessTokenactually usable for the write: OrderCloud hands the webhook a token pre-scoped with these roles, and the handler'sUpsertBuyerUserAsynccall uses that token, not a separately held service credential, to write back to OrderCloud. WithoutBuyerUserAdminhere, that call would 403.HashKeyis the shared secret both sides sign with. It's whatVerifyHmacchecks the incomingx-oc-hashheader against in the handler above.
An OpenID Connect config tells OrderCloud how to run the actual login handshake against the bridge:
POST /v1/openidconnects
{
"ID": "identity-bridge-sso",
"OrderCloudApiClientID": "<the buyer-facing API client's ID>",
"ConnectClientID": "ordercloud",
"ConnectClientSecret": "<must match the bridge's own confidential-client secret>",
"AuthorizationEndpoint": "https://<bridge-host>/authorize",
"TokenEndpoint": "https://<bridge-host>/token",
"AppStartUrl": "https://<storefront-host>/api/auth/ordercloud-callback?token={0}&ocRefreshToken={3}",
"IntegrationEventID": "identity-bridge-sync"
}
AuthorizationEndpoint/TokenEndpoint here are exactly the same OIDC endpoints the storefront's own
Better-Auth flow talks to. OrderCloud is just a second, independent client of them, using the confidential
ConnectClientID/ConnectClientSecret credentials instead of the storefront's public PKCE client.The end-to-end call sequence, once both objects exist:
- After the storefront's own sign-in completes, the browser is redirected to
{OrderCloud host}/ocrplogin?id=identity-bridge-sso&cid=<buyer-facing client ID>&roles=Shopper. This is OrderCloud's own fixed entry point; it isn't something the bridge or storefront implements. - OrderCloud redirects the browser to the
AuthorizationEndpointfrom the config above (the bridge's own/authorize) as theConnectClientIDclient. The browser still carries the bridge's session cookie from the storefront's earlier sign-in, so this completes with no second login screen. - OrderCloud exchanges the resulting code at
TokenEndpoint, server-to-server, usingConnectClientSecret(not PKCE; this is a confidential client, not a public one like the storefront's). - OrderCloud looks up the Integration Event referenced by
IntegrationEventID, and, server-to-server with no browser involved,POSTs toCustomImplementationUrlplus/createuseror/syncuser, HMAC-signed withHashKey, carrying an access token scoped byElevatedRoles. This is the call the two routes above actually receive. - Once that webhook call returns
200, OrderCloud finally redirects the browser toAppStartUrl, with its own access/refresh tokens filled into the{0}/{3}placeholders, landing the user back on the storefront already commerce-authenticated.
The storefront side (Next.js + a generic-OAuth-capable auth library)
export const auth = betterAuth({
// No database. The bridge is the only identity store this app trusts.
// Session state is a signed, stateless cookie instead.
session: {
cookieCache: { enabled: true, strategy: "jwt", refreshCache: true },
},
plugins: [
genericOAuth({
config: [
{
providerId: "identity-bridge",
discoveryUrl: `${process.env.BRIDGE_ISSUER}/.well-known/openid-configuration`,
pkce: true,
// Re-fetch the profile on every login instead of caching the first one.
overrideUserInfo: true,
getUserInfo: async (tokens) => {
const res = await fetch(`${process.env.BRIDGE_ISSUER}/userinfo`, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
return res.ok ? res.json() : null;
},
},
],
}),
],
});
Design decision: freshness over caching
The single most important non-obvious decision in this whole system: the bridge re-fetches the user's
profile from the legacy repository on every
/authorize call, not just at the original login.The bridge's own browser session is long-lived by design (multi-day, sliding expiration). That's what makes
the "silent second hop" into OrderCloud's SSO possible without re-prompting for a password days later. But a
long-lived session married to a cached profile is exactly how you end up quietly serving stale role or
permission data for days at a time. The fix is simple once you name the problem:
/authorize uses the
session cookie to know who is asking, but always goes back to the source of truth for what's currently true
about them. It only falls back to the last-known values if that refresh call itself fails. A transient
outage shouldn't block sign-in outright; it should just mean one login's data is a few seconds stale instead
of guaranteed fresh.That decision is what keeps OrderCloud's data current on every login, rather than current only until the
first role change that happens between sign-ins.
Sign-out has to end the SSO session too
Clearing the storefront's own session cookie was never the actual requirement. The same rule that shaped
everything else applies here too: the bridge is the one place session state lives, so signing out has to
actually end the session there, not just forget about it locally. If the bridge's own cookie session
survives a "sign-out," the next silent SSO hop into OrderCloud (or any future consumer built on the same
bridge) quietly re-authenticates the user without a prompt, which undoes the sign-out they just asked for.
The standard mechanism for this is RP-initiated logout: redirecting the browser to the provider's own
/logout endpoint with an id_token_hint, rather than only clearing a local cookie. Most auth libraries
build that redirect URL automatically, by looking up the account record they saved at sign-in. That lookup
usually assumes a real database behind the library. This system runs stateless on purpose (see "freshness
over caching" above), so that lookup can fail silently: the library reports "signed out" successfully while
never actually finding the record it needed to build the provider's logout URL.The fix doesn't require giving up the stateless design, just not depending on the library for this one
value. Capture the ID token directly, into its own dedicated cookie, at sign-in:
// idp-session.ts — a dedicated cookie for the one value RP-initiated logout
// needs, kept independent of whatever the auth library tracks internally.
import { cookies } from "next/headers";
const ID_TOKEN_COOKIE = "bridge_id_token";
export async function setBridgeIdToken(idToken: string) {
const store = await cookies();
store.set(ID_TOKEN_COOKIE, idToken, {
httpOnly: true,
sameSite: "lax",
maxAge: 14 * 24 * 60 * 60, // matches the bridge's own session lifetime
});
}
export async function getBridgeIdToken() {
const store = await cookies();
return store.get(ID_TOKEN_COOKIE)?.value ?? null;
}
export async function clearBridgeIdToken() {
const store = await cookies();
store.delete(ID_TOKEN_COOKIE);
}
The capture happens inside the same
getUserInfo hook already used to force a real /userinfo call (see
"What the endpoints actually look like" above), so no extra step is needed at sign-in time:getUserInfo: async (tokens) => {
if (tokens.idToken) await setBridgeIdToken(tokens.idToken);
const res = await fetch(`${process.env.BRIDGE_ISSUER}/userinfo`, {
headers: { Authorization: `Bearer ${tokens.accessToken}` },
});
return res.ok ? res.json() : null;
},
Then, instead of relying on the auth library to build the logout URL, a dedicated route builds it directly
from that cookie:
// app/api/auth/bridge-logout/route.ts
export async function POST() {
const idToken = await getBridgeIdToken();
await clearBridgeIdToken();
if (!idToken) return Response.json({ url: null });
const url = new URL("/logout", process.env.BRIDGE_ISSUER);
url.searchParams.set("id_token_hint", idToken);
url.searchParams.set(
"post_logout_redirect_uri",
process.env.POST_LOGOUT_REDIRECT!,
);
url.searchParams.set("client_id", process.env.BRIDGE_CLIENT_ID!);
return Response.json({ url: url.toString() });
}
And the storefront's own sign-out calls it before clearing its local session, so both ends of the SSO chain
actually end together:
export async function signOut() {
await fetch("/api/auth/ordercloud-signout", { method: "POST" }); // end the commerce session
const res = await fetch("/api/auth/bridge-logout", { method: "POST" });
const { url } = await res.json();
await authClient.signOut(); // still reliably clears the local storefront session
if (url) window.location.href = url; // end the bridge's own session
}
Summary
The core idea generalizes beyond this specific stack: make the legacy system a dependency of exactly one
service, and give every downstream consumer a standard OIDC integration instead of a bespoke one. Any new
consumer added later (another storefront, a mobile app, a partner integration) uses the same standard
handshake, and the legacy-integration logic stays written in one place.
Share this post