Tilgangsstyring gjort enkelt med OBO-flyt
I dette blogginnlegget skal vi se på hvordan man kan oppnå granulær tilgangsstyring med OBO-flyt og Entra ID.
Hva er OBO-flyt, og hvorfor skal man bruke det?
I OBO-flyt henter man ut access tokens på vegne av en bruker. Dette kan være et kraftig verktøy i flerlagstjenester med tilgangsstyring.

Figuren over illustrerer et slikt system, hvor Client, API A og API B har hver sin app-registrering i Entra ID. Klienten skal bruke API A til å hente data fra API B. API A autentiserer requests med Token A, og API B autentiserer requests med Token B. I tillegg til dette har Client også en token (heretter Token FE) som har blitt hentet etter innlogging.
Hva er egentlig forskjellen mellom de ulike tokenene? Audience. Hver app-registrering har et eget audience, og fungerer som en ID for app-registreringen. Token A må ha audience til app-registreringen til API A, og det samme gjelder Token B. Og siden man kan ha ulike roller i hver app-registrering kan Token A og Token B for en bruker ha forskjellige tilganger i API A og API B.
Hvorfor OBO-flyt?
OBO-flyt åpner opp for granulær tilgangskontroll ved å spesifisere nøyaktig hvilke tillatelser og områder en tjeneste kan be om på vegne av brukeren. Dette sikrer at tjenesten kun har tilgang til nødvendige ressurser. I tillegg er OBO-flyt ideell for flerlagstjenester der en front-end tjeneste kan kalle en back-end tjeneste på vegne av brukeren.
Systemet vi skal implementere💻
Vi skal nå implementere et enkelt system som henter ut brukere fra et API. Systemet består av en Backstage-app (Client) og et Kotlin REST API (API A). I Backstage-pluginen skal man kunne velge en rolle og se alle brukere med den rollen.

En forutsetning er at både Entra ID og app-registreringene er satt opp. Ta en titt her for å se hvordan app-registeringene må settes opp for OBO-flyt.
Kildekoden til klienten og backend API-et er lenket under:
Backstage🎬
Backstage er en open source utviklerportal utviklet av Spotify. Utviklerportalen er bygd opp av en rekke plugins. Man kan tilpasse Backstage til eget bruk, ved å utvikle plugins. Det er to typer plugins: frontend og backend. Førstnevnte er ansvarlig for brukerinteraksjon, og sistnevnte tar for seg kommunikasjon med serveren.
Vi ønsker å abstrahere bort OBO-flyten fra klienten. Dette kan gjøres ved å lage en backend-plugin, som vil fungere som en proxy. En fordel med dette er at man aldri ser Token A i nettverksloggen. I Backstage slipper man også CORS-problematikk dersom man utfører kall gjennom backend-pluginen.
Fra en brukers perspektiv blir det gjort et autentisert kall til proxyen, og data blir returnert tilbake. I realiteten blir det gjort langt flere kall. Alle de stiplede linjene er operasjoner brukeren ikke ser.

Steg 0: Entra ID og backend plugin
Vi kommer ikke til å ta for oss hvordan Entra ID er satt opp i Backstage, og backend-pluginen settes opp, men README-filen viser hva som har blitt gjort for kodebasen. Woodgrove har en stegvis gjennomgang av hvordan man setter opp Entra ID for OBO-flyt.
Steg 1: Sette opp services for Entra ID og proxy i backend plugin
Microsoft sitt MSAL-bibliotek kan brukes til å hente ut en token på vegne av brukeren. Etter å ha satt opp en ConfidentialClientApplication kan man bruke metoden acquireTokenOnBehalfOf. Som parametere gir man da en assertion og et scope. Assertion er brukerens Token FE. Scopet vårt er <API-A-client-id>/.default hvor <API-A-client-id> forteller MSAL hvilken app-registrering token skal hentes fra, og .default forteller at den kun skal hente ut det som er definert som standard scope i API A sin app-registrering.
// plugins/obo-flow-plugin-backend/src/service/types.ts
export type EntraIdConfiguration = {
tenant_id: string;
client_id: string; // <Backstage-client-ID>
client_secret: string; // <Backstage-client-secret>
scope: string; // <API-A-audience>/.default
};
// plugins/obo-flow-plugin-backend/src/service/entraIdService.ts
export class EntraIdService {
private entraIdConfig: EntraIdConfiguration;
private clientApplication: ConfidentialClientApplication;
constructor(entraIdConfig: EntraIdConfiguration) {
this.entraIdConfig = entraIdConfig;
const msalConfig: Configuration = {
auth: {
clientId: this.entraIdConfig.client_id, // Backstage client ID
clientSecret: this.entraIdConfig.client_secret, // Backstage client secret
authority: `https://login.microsoftonline.com/${this.entraIdConfig.tenant_id}`,
},
};
this.clientApplication = new ConfidentialClientApplication(msalConfig);
}
async acquireTokenOnBehalfOfUser(token: string) {
const request: OnBehalfOfRequest = {
oboAssertion: token, // The assertion is the Token FE
scopes: [this.entraIdConfig.scope], // <API-A-audience>/.default
};
const response = await this.clientApplication.acquireTokenOnBehalfOf(
request,
);
return response?.accessToken; // The output is Token A
}
}I proxyen har vi definert en GET-metode, som gjennomfører OBO-flyten før kallet til API A.
// plugins/obo-flow-plugin-backend/src/service/entraIdService.ts
export class ProxyService {
private readonly baseUrlApiA: string;
private entraIdService: EntraIdService;
constructor(baseUrlApiA: string, entraIdService: EntraIdService) {
this.baseUrlApiA = baseUrlApiA;
this.entraIdService = entraIdService;
}
async getData(
logger: LoggerService,
clientToken: string,
endpoint: string,
): Promise<any> {
const tokenA = await this.entraIdService.acquireTokenOnBehalfOfUser(
clientToken,
);
logger.info(`Proxy made a GET request to ${this.baseUrlApiA}/${endpoint}`);
if (!tokenA) return null;
const response = await fetch(`${this.baseUrlApiA}/${endpoint}`, {
headers: {
Accept: 'application/json',
Authorization: `Bearer ${tokenA}`,
},
method: 'GET',
});
return response.json();
}
}Steg 2: Lage et proxy endepunkt
Nå som vi har definert services som håndterer OBO-flyten og proxy-kall, kan vi definere et endepunkt som kan nås av frontend-pluginen. Da backend-pluginen ble laget, skal en fil som heter router.ts ha blitt lagt til. Det er her man definerer API-et til pluginen. I metoden createRouter må vi først hente ut miljøvariabler fra app-config.yaml .
// plugins/obo-flow-plugin-backend/src/service/router.ts
const backendBaseUrl = config.getString('backend.backendBaseUrl');
const clientId = config.getString('msal.clientId');
const clientSecret = config.getString('msal.clientSecret');
const tenantId = config.getString('msal.tenantId');
const scope = `${config.getString('msal.clientIdApiA')}/.default`;
const entraIdConfiguration: EntraIdConfiguration = {
tenant_id: tenantId,
client_id: clientId,
client_secret: clientSecret,
scope: scope,
};
const entraIdService = new EntraIdService(entraIdConfiguration);
const proxyService = new ProxyService(backendBaseUrl, entraIdService);Man kan nå bruke proxy-servicen til å sette opp proxy-endepunktet.
// plugins/obo-flow-plugin-backend/src/service/router.ts
export const oboFlowPluginPlugin = createBackendPlugin({
pluginId: 'obo-flow-plugin',
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
logger: coreServices.logger,
config: coreServices.rootConfig,
},
async init({
httpRouter,
logger,
config,
}) {
httpRouter.use(
await createRouter({
logger,
config,
}),
);
httpRouter.addAuthPolicy({
path: '/proxy',
allow: 'unauthenticated',
});
},
});
},
});Vi gjør proxy-endepunktet åpent for alle ved å bruke metoden httpRouter.addAuthPolicy . Dette kan man gjøre av to grunner:
- For OBO-flyten må brukeren sende med en Entra-token som har samme audience som Backstage-app-registeringen
- For å kunne hente ut en slik token må brukeren være logget inn med sin Entra-bruker
// plugins/obo-flow-plugin-backend/src/plugin.ts
export const securityMetricBackendPlugin = createBackendPlugin({
pluginId: 'security-metric-backend',
register(env) {
env.registerInit({
deps: {
httpRouter: coreServices.httpRouter,
auth: coreServices.auth,
logger: coreServices.logger,
config: coreServices.rootConfig,
},
async init({ httpRouter, auth, logger, config }) {
httpRouter.use(
await createRouter({
auth,
logger,
config,
}),
);
httpRouter.addAuthPolicy({
path: '/proxy',
allow: 'unauthenticated',
});
},
});
},
});Disse kodeblokkene er det man trenger for å sette opp en proxy som gjennomfører OBO-flyten.
Steg 3: Kalle proxyen fra frontend pluginen
I API A har vi definert endepunktet api/users/{userRole} hvor userRole er et heltall som representerer vanlige brukere, moderatorer eller administratorer.
Endepunktet vi definerte kan bli nådd ved å gjøre kall til localhost:7007/api/obo-flow-plugin/proxy . Backstage backend kjører på port 7007 . Man kan gjøre kall til localhost:7007 fra localhost:3000uten å møte på CORS problematikk.
For å hente ut variabler fra app-config.yaml og Entra ID-token kan man henholdsvis bruke Backstages configApiRef og microsoftAuthApiRef .
const config = useApi(configApiRef);
const microsoftAuthApi = useApi(microsoftAuthApiRef);
const [userRole, setUserRle] = useState<UserRole>(UserRole.User);
const backendUrl = config.getString('backend.baseUrl');
const clientToken = await microsoftAuthApi.getAccessToken(
'backstage-client-id/.default',
);
const response = await fetch(`${baseUrl}/api/obo-flow-plugin/proxy`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
method: 'POST',
body: JSON.stringify({
endpoint: `${endpoint}/${userRole.valueOf()}`,
}),
});API A
Vi skal implementere et filter som autentiserer brukeren og konverterer et JWT til et OIDC token. Filteret vil trigges før alle requests til API A.
Steg 1: Legge til nødvendige dependencies
I build.gradle.kts må vi legge til følgende dependencies:
// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")Steg 2: Konfigurasjon av OAuth 2.0 resource server
I application.yaml må man definere hvilken issuer og hvilken audience (client ID) brukere skal autentiseres mot.
// src/main/resources/application.yaml
spring:
application:
name: medium-obo-flow-api-a
security:
oauth2:
resource-server:
jwt:
issuer-uri: https://sts.windows.net/<TENANT-ID>/
audiences: <CLIENT-ID-API-A>Dette er alt som skal til for at API A skal kunne snakke med Entra ID.
Trinn 3: Oppsett av security filter chain med JWT og OIDC⛓️💥
I filen SecurityConfiguration skal vi sette opp to ting:
securityFilterChainJwtToOidcConverter
Sistnevnte konverterer et JWT til et OIDC-token som videre kan brukes av Spring Boot.
// src/main/kotlin/com/example/mediumoboflowapia/configurations/SecurityConfiguration.kt
@Component
class JwtToOidcConverter : Converter<Jwt, AbstractAuthenticationToken> {
private val jwtGrantedAuthoritiesConverter = JwtGrantedAuthoritiesConverter()
override fun convert(jwt: Jwt): AbstractAuthenticationToken {
val authorities: Collection<GrantedAuthority> = jwtGrantedAuthoritiesConverter.convert(jwt) ?: emptyList()
val oidcUser: OidcUser = createOidcUser(jwt, authorities)
return OidcAuthenticationToken(oidcUser, authorities, jwt.tokenValue)
}
private fun createOidcUser(jwt: Jwt, authorities: Collection<GrantedAuthority>): OidcUser {
val claims = jwt.claims
val oidcIdToken = OidcIdToken(
jwt.tokenValue,
jwt.issuedAt ?: Instant.now(),
jwt.expiresAt ?: Instant.now().plusSeconds(3600),
claims
)
val oidcUserInfo = OidcUserInfo(claims)
return DefaultOidcUser(authorities, oidcIdToken, oidcUserInfo, "sub")
}
}
class OidcAuthenticationToken(
private val oidcUser: OidcUser,
authorities: Collection<GrantedAuthority>,
private val token: String
) : AbstractAuthenticationToken(authorities) {
init {
isAuthenticated = true
}
override fun getCredentials(): Any = token
override fun getPrincipal(): Any = oidcUser
}securityFilterChain er selve filteret, og bruker koden over. Her definerer vi regler for filteret. I koden nedenfor trenger man for eksempel ikke å være autentisert for å nå Swagger.
// src/main/kotlin/com/example/mediumoboflowapia/configurations/SecurityConfiguration.kt
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http.authorizeHttpRequests { requests ->
requests
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**")
.permitAll()
.anyRequest().authenticated()
}.oauth2ResourceServer { oauth2 ->
oauth2
.jwt { jwtConfigurer ->
jwtConfigurer.jwtAuthenticationConverter(jwtToOidcConverter)
}
}
return http.build()
}Avslutning👋
Du har nå satt opp en funksjonell OBO-flyt! Vi har kun sett på autentisering, men takket være OBO-flyten kan man ha granulær autorisasjon på brukernivå. Mulighetene er endeløse!
