Architecture on the Chopping Block: The OIDC Dependency We Didn’t Know We Had
DEV Community

Architecture on the Chopping Block: The OIDC Dependency We Didn’t Know We Had

Background

Before joining Cloud.ru, I worked at a company that built and deployed enterprise IT solutions for large customers. Its main product was a packaged platform: a corporate CRM with a much broader set of modules. It included modules for working with data and reference data, documents, reports, business processes, maps, access rights, and integrations with customer systems. The platform was not a monolith. It was a set of microservices that we deployed using a traditional on-premises delivery model: the customer provided several servers, we deployed the services on them, and the platform then continued to operate almost autonomously.

Most deployments followed a similar pattern. In one project, however, the customer asked us to deploy the platform in their cloud and integrate it into their existing infrastructure. This became a stress test for us: some parts of the platform adapted without any problems, but authentication ran into a requirement to replace our usual OIDC flow with Kerberos. In this article, I explain why the cloud deployment exposed an old dependency on OIDC, why replacing it with Kerberos required us to revisit the architecture, and where we ultimately drew the boundary between access management and business logic.

We Had OIDC, but the Customer Wanted Kerberos

By the time this project began, the platform was already in use by several customers. Its foundation remained the same, while only some modules and workflows were changed for each specific deployment. For one customer, we might significantly customize the mapping features; for another, connect document modules; for a third, integrate a custom role model for data access. Most of the platform was usually ready, and we only had to adapt it to the requirements of a particular customer.

We were used to keeping almost all of the logic inside our platform and rarely had to rely on capabilities provided by the customer's infrastructure. This project was different. In addition to deploying the platform in the customer's cloud, we had to give up some of our own functionality and use the customer's mechanisms instead. One of those requirements was replacing our familiar OIDC setup with Kerberos.

Kerberos was mandatory. Users were already working in a corporate domain environment: they signed in to their computers with their own accounts and were not supposed to enter their passwords again in every system. This approach also reduced the extent to which security depended on human behavior.

We misjudged the scope. We thought we were changing only the login mechanism, but we ended up touching the architecture. The task was considered small for too long, so active development started late. We therefore needed a solution that could be integrated into the existing code with minimal effort, would not require a large-scale rewrite of the services, and would preserve a consistent approach across the platform.

Why a Simple Replacement Did Not Work

If the platform had been a monolith, the task would most likely have been completed much faster because the changes would have affected far less code. But ours was a microservice platform that had to be integrated with the customer's external IAM environment. Incoming requests were relatively straightforward; propagating user context between services was much harder.

Before the Kerberos integration, the simplified flow looked like this:

%%{init: {"theme":"base","themeCSS":"* { font-family: Arial, sans-serif !important; } text { font-weight: 600 !important; }","themeVariables":{"background":"#222222","fontFamily":"Arial, sans-serif","fontSize":"25px","textColor":"#F7F7F7","lineColor":"#8A8A8A","edgeLabelBackground":"#222222","primaryColor":"#2A2A2A","primaryTextColor":"#F7F7F7","primaryBorderColor":"#2BD879","secondaryColor":"#2BD879","secondaryTextColor":"#101510","secondaryBorderColor":"#65EFA3","tertiaryColor":"#545454","tertiaryTextColor":"#F7F7F7","tertiaryBorderColor":"#8A8A8A","clusterBkg":"#262626","clusterBorder":"#4A4A4A","mainBkg":"#2A2A2A","nodeBorder":"#2BD879","nodeTextColor":"#F7F7F7","labelTextColor":"#F7F7F7","titleColor":"#F7F7F7"},"flowchart":{"htmlLabels":false}}}%%
flowchart LR
classDef service fill:#2A2A2A,stroke:#2BD879,stroke-width:2px,color:#F7F7F7;
classDef accent fill:#2BD879,stroke:#65EFA3,stroke-width:2px,color:#101510;
classDef muted fill:#545454,stroke:#8A8A8A,stroke-width:1px,color:#F7F7F7;
subgraph DIAGRAM[" "] direction LR
A["Client"]:::accent
B["Orders Microservice"]:::service
C["File Storage Microservice"]:::service
A -->|User token| B
B -->|The same token| C
end
style DIAGRAM fill:#222222,stroke:#222222,stroke-width:1px,color:#222222;

In this flow, the Orders Microservice called the File Storage Microservice not simply under its own identity, but in the context of the original user. This is not a Client Credentials flow, in which services communicate under their own technical identities and the downstream service does not need information about the original user. In our model, the downstream service had to know on whose behalf the action was being performed.

With OIDC, this problem was barely noticeable. The access token effectively acted as a portable artifact: a service received it with an incoming request and forwarded it when necessary, while the next service reconstructed the user context from the token. This was not an ideal approach from an OAuth 2.0/OIDC perspective, but the platform had historically been built around that assumption. That assumption no longer held with Kerberos.

We could not simply replace the access token from the old OIDC flow with a Kerberos service ticket everywhere and consider the task complete. The ticket is intended only for the service to which the client presents it. It cannot be forwarded to the next microservice like a bearer token. That requires a separate delegation flow. The task was therefore no longer just a matter of changing a request header. We had to build a mechanism that could securely propagate user context between services.

Delegation: The Workable Option

Delegation became the workable option for us. In simplified terms, if the Orders Microservice needed to call the File Storage Microservice on behalf of the original user, it first obtained a Kerberos service ticket for the File Storage Microservice. The call was then made using that ticket.

%%{init: {"theme":"base","themeCSS":"* { font-family: Arial, sans-serif !important; } text { font-weight: 600 !important; }","themeVariables":{"background":"#222222","fontFamily":"Arial, sans-serif","fontSize":"25px","textColor":"#F7F7F7","lineColor":"#8A8A8A","edgeLabelBackground":"#222222","primaryColor":"#2A2A2A","primaryTextColor":"#F7F7F7","primaryBorderColor":"#2BD879","secondaryColor":"#2BD879","secondaryTextColor":"#101510","secondaryBorderColor":"#65EFA3","tertiaryColor":"#545454","tertiaryTextColor":"#F7F7F7","tertiaryBorderColor":"#8A8A8A","clusterBkg":"#262626","clusterBorder":"#4A4A4A","mainBkg":"#2A2A2A","nodeBorder":"#2BD879","nodeTextColor":"#F7F7F7","labelTextColor":"#F7F7F7","titleColor":"#F7F7F7"},"flowchart":{"htmlLabels":false}}}%%
flowchart LR
classDef service fill:#2A2A2A,stroke:#2BD879,stroke-width:2px,color:#F7F7F7;
classDef accent fill:#2BD879,stroke:#65EFA3,stroke-width:2px,color:#101510;
classDef muted fill:#545454,stroke:#8A8A8A,stroke-width:1px,color:#F7F7F7;
subgraph DIAGRAM[" "] direction LR
A["Client"]:::accent
B["Orders Microservice"]:::service
C["File Storage Microservice"]:::service
A -->|Original Kerberos user| B
B -->|Delegated Kerberos service ticket for the original user| C
end
style DIAGRAM fill:#222222,stroke:#222222,stroke-width:1px,color:#222222;

How Deeply OIDC Had Reached into the Architecture

At first, we believed OIDC was concentrated in only a few places: authentication filters, current-user resolution, and a few parts of inter-service communication. In practice, the dependency ran much deeper.

The authentication layer was implemented differently across microservices. In some services it only verified the user; in others it already contained application-level checks and custom workflows. Services also obtained current-user data in different ways. Some application code depended directly on data created by the authentication layer, while other parts already knew about OIDC and JWT details. Some services assembled the user object directly from the authentication context.

Inter-service calls depended on the familiar token-based model, and incoming requests from neighboring services were handled inconsistently. For example, some services extracted individual fields from the token and converted the login to their own format. Others independently decided which headers to forward. In a few places, a custom internal contract had already emerged between authentication and the application code.

Once we had mapped all of this, it became clear that replacing one protocol with another would not be enough. We first had to separate application logic from the authentication mechanism. Otherwise, we would merely replace a dependency on OIDC with a dependency on Kerberos.

A Typical Example of the Same Problem

I tried to describe this mess with a diagram, but it quickly became unreadable. So I will show the same problem using a simpler example: what we expected to find and what we actually found.

Imagine an application that, like many others, uses caching for optimization. There are no common rules for caching, so everyone implements it however they find convenient. Time passes. You analyze the project and realize that a huge number of engineering hours go not only into fixing cache-related bugs, but also into continually maintaining the surrounding functionality. You finally decide to bring the situation under control. But you are the project manager and do not know exactly what is happening inside the code. You think at a more abstract level and look for a solution that can fix the problem with minimal effort.

The most obvious solution is to move to Managed Redis. Cloud.ru, for example, provides a ready-to-use managed service for this scenario: most infrastructure tasks are already handled, so from the outside it appears that all you need to do is replace the cache implementation. You are convinced that the code looks like this:

%%{init: {"theme":"base","themeCSS":"* { font-family: Arial, sans-serif !important; } text { font-weight: 600 !important; }","themeVariables":{"background":"#222222","fontFamily":"Arial, sans-serif","fontSize":"25px","textColor":"#F7F7F7","lineColor":"#8A8A8A","edgeLabelBackground":"#222222","primaryColor":"#2A2A2A","primaryTextColor":"#F7F7F7","primaryBorderColor":"#2BD879","secondaryColor":"#2BD879","secondaryTextColor":"#101510","secondaryBorderColor":"#65EFA3","tertiaryColor":"#545454","tertiaryTextColor":"#F7F7F7","tertiaryBorderColor":"#8A8A8A","clusterBkg":"#262626","clusterBorder":"#4A4A4A","mainBkg":"#2A2A2A","nodeBorder":"#2BD879","nodeTextColor":"#F7F7F7","labelTextColor":"#F7F7F7","titleColor":"#F7F7F7"},"flowchart":{"htmlLabels":false}}}%%
graph LR
classDef service fill:#2A2A2A,stroke:#2BD879,stroke-width:2px,color:#F7F7F7;
classDef accent fill:#2BD879,stroke:#65EFA3,stroke-width:2px,color:#101510;
classDef muted fill:#545454,stroke:#8A8A8A,stroke-width:1px,color:#F7F7F7;
subgraph EXPECTED[" "]
P["Request"]
P --> U["User Service"]
P --> Z["Order Service"]
P --> T["Product Service"]
P --> O["Reporting Service"]
subgraph USERS["Users"]
U1["GetUser()"]
U1 --> U2["GetFromCache()"]
U2 --> U3{"Found?"}
U3 -->|No| U4["ReadFromDatabase()"]
U4 --> U5["SaveToCache()"]
end
subgraph ORDERS["Orders"]
Z1["GetOrders()"]
Z1 --> Z2["GetFromCache()"]
Z2 --> Z3{"Found?"}
Z3 -->|No| Z4["ExecuteQuery()"]
Z4 --> Z5["SaveToCache()"]
Z6["CreateOrder()"]
Z6 --> Z7["WriteToDatabase()"]
Z7 --> Z8["RemoveFromCache()"]
end
subgraph PRODUCTS["Products"]
T1["GetProducts()"]
T1 --> T2["GetFromCache()"]
T2 --> T3{"Found?"}
T3 -->|No| T4["ReadFromDatabase()"]
T4 --> T5["SaveToCache()"]
end
subgraph REPORTS["Reports"]
O1["GetReport()"]
O1 --> O2["GetFromCache()"]
O2 --> O3{"Found?"}
O3 -->|No| O4["GenerateReport()"]
O4 --> O5["SaveToCache()"]
end
U --> U1
Z --> Z1
Z --> Z6
T --> T1
O --> O1
end
class P accent;
class U,Z,T,O service;
class U1,U3,U4,Z1,Z3,Z4,Z6,Z7,T1,T3,T4,O1,O3,O4 muted;
class U2,U5,Z2,Z5,Z8,T2,T5,O2,O5 accent;
style EXPECTED fill:#262626,stroke:#4A4A4A,stroke-width:1px,color:#F7F7F7;
style USERS fill:#222222,stroke:#4A4A4A,stroke-width:1px,color:#F7F7F7;
style ORDERS fill:#222222,stroke:#4A4A4A,stroke-width:1px,color:#F7F7F7;
style PRODUCTS fill:#222222,stroke:#4A4A4A,stroke-width:1px
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.