Hopp til hovedinnhold
Fag i Bekk/Tilgangsstyring gjort ...Tilgangsstyring gjort enkelt med ...

Tilgangsstyring gjort enkelt med OBO-flyt

Publisert:2. august 2024

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.

OBO Flow plugin i Backstage

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.

Reisen til et API-kall

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:

  1. For OBO-flyten må brukeren sende med en Entra-token som har samme audience som Backstage-app-registeringen
  2. 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:

  • securityFilterChain
  • JwtToOidcConverter

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!

Del kunnskapen

Har du en kollega som også hadde dratt nytte av denne artikkelen?

Mer fra Fag i Bekk

Nå er du ved veis ende. Gå til forsiden hvis du vil ha mer faglig påfyll.

Til forsiden