Gå til hovedinnhold

Migrering fra basic auth til token-autentisering

Pilotfase

Denne siden beskriver en løsning som er under innføring og i pilot. Det er foreløpig ikke satt noen dato for når basic auth fases ut. Dette vil bli kommunisert i god tid før en eventuell avvikling, når løsningen har gått ut av pilotfasen.

I pilotfasen gjelder følgende anbefalinger:

  • Eksterne konsumenter: Bruk Maskinporten.
  • Interne Kartverket-konsumenter: Bruk utveksling av token med brukernavn/passord.

Eksterne virksomheter som ønsker å delta i pilot kan ta kontakt med anders.dahl@kartverket.no.

Dagens løsning for maskinbrukere (M2M) benytter basic auth, der brukernavn og passord sendes med i hver enkelt forespørsel. Det langsiktige målet er å flytte alle integrasjoner over til Maskinporten som nasjonal fellesløsning for virksomhetsautentisering.

Som et første steg tilbyr Matrikkelen nå et eget token-endepunkt, slik at maskinbrukere kan autentisere seg én gang og deretter bruke kortlevde tokens (JWT) i stedet for å sende brukernavn og passord i hver forespørsel.


Fase 1 – Token fra Matrikkelen (for interne Kartverk konsumenter)​

Matrikkelen tilbyr token-endepunkt i henhold til OpenID Connect-standarden. Token-endepunktet finnes via well-known URL for det aktuelle miljøet, og tokenet brukes som Bearer-token i alle videre kall mot Matrikkelen.

Konfigurasjon​

Klient-ID er den samme på tvers av alle miljøer:

ParameterVerdi
client_idmatrikkel-token-exchange

Well-known URL per miljø:

MiljøURL
devhttps://kc-test.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration
betatesthttps://kc-betatest.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration
prodtesthttps://kc-prodtest.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration
prodhttps://auth.matrikkel.no/auth/realms/matrikkelen-prod/.well-known/openid-configuration

Hente token​

Token-endepunktet (token_endpoint) bør hentes dynamisk fra well-known URL-en for det aktuelle miljøet, fremfor å hardkodes. Dette sikrer at integrasjonen din automatisk plukker opp eventuelle endringer i endepunktadressen.

Send en POST-forespørsel mot token_endpoint med brukernavn og passord for å få utstedt et token:

POST {{token_endpoint}}
Content-Type: application/x-www-form-urlencoded

grant_type=password&
client_id=matrikkel-token-exchange&
username={{username}}&
password={{password}}

Responsen vil inneholde et access_token og et refresh_token:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIi[...]wia2lYkisFKY3jZPBwTSPwSMcHyuMt22B0BOwZ4Z0CQ",
"expires_in": 300,
"refresh_expires_in": 1800,
"refresh_token": "eyJhbGciOiJIUzUxMiIsInR5cCIg[...]pA9J5wO9tYbiMHZV_E6biD4bItcg_yO1iV6eF9cZAEUTvApKDT30srcSw",
"token_type": "Bearer",
"not-before-policy": 1619516791,
"session_state": "PE4[...]AxLUH4b",
"scope": "openid microprofile-jwt"
}

Bruk access_token som Authorization: Bearer <access_token> i alle kall til Matrikkelen.

Fornye token​

Når access tokenet er utløpt, kan det fornyes med refresh_token – uten å sende brukernavn og passord på nytt:

POST {{token_endpoint}}
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
refresh_token=eyJhbGciOiJIUzUxMiIsInR5cCIg[...]pA9J5wO9tYbiMHZV_E6biD4bItcg_yO1iV6eF9cZAEUTvApKDT30srcSw

Eksempel - OkHttp​

Vi tar utgangspunkt i eksempel-koden nederst på siden.

/**
* Fase 0 - Basic auth (eksisterende løsning)
*/
fun basicAuth(username: String, password: String): Interceptor {
return HeadersInterceptor {
mapOf("Authorization" to "Basic ${base64("${username}:${password}")}")
}
}

/**
* Fase 1 - Token fra Matrikkelen
*/
fun matrikkelToken(username: String, password: String, wellKnownUrl: String): Interceptor {
val matrikkelTokenClient = MatrikkelTokenClient(wellKnownUrl) // Se eksempel-kode nederst på siden
return HeadersInterceptor {
mapOf("Authorization" to "Bearer ${tokenClient.getToken(username, password)}")
}
}

fun createMatrikkelKlient() {
OkHttpClient.Builder()
// Fase 0 – bytt ut med fase 1 under
.addInterceptor(basicAuth(username, password))
// Fase 1
// .addInterceptor(matrikkelToken(username, password, wellKnownUrl))
.build()
}

Fase 2 – Maskinporten (for eksterne konsumenter som er med i pilot)​

For generell dokumentasjon av maskinporten og bruk av maskinporten anbefaler vi

digdir sin dokumentasjon. Den siden beskriver hvordan man tar i bruk token autentisering, for informasjon om delegering av tilganger via altinn se overordnet informasjon.

Tilganger tildeles av kartverkets kundesenter om din organisasjon selv trenger tilgang, eller bli delegert videre fra en organisasjon som allerede har tilgang om dere skal opptre på vegne av dem.

For at tidligere roller og tilganger i matrikkelen fortsatt skal kunne brukes kreves det at det er etablert en kobling mellom brukeren man ønsker å representere og organisasjonen som eier brukeren. Dette gjøres av kartverkets kundesenter, og må søkes om av eieren av brukeren.

Konfigurasjon​

Matrikkel-Brukernavn må spesifiseres som en egen http-header X-Matrikkel-Brukernavn for at knyttningen mellom organisasjons og bruker kan verifiseres.

Scope for kall til matrikkelen er kartverk:matrikkel:brukernavn.

Audience for matrikkelen er basert på hvilket miljø man skal koble seg til. Dette oppnåes ved å sette claimet resource: <audience>. (

Digdir dokumentasjon.)
Audience per miljø:

MiljøResource/Audience
Kurshttps://kurs.matrikkel.no
Test (betatest, prodtest etc)https://test.matrikkel.no
Produksjon*https://matrikkel.no

* Dette gjelder for alle produksjons-url'er, inkludert innsyn.matrikkel.no og registrering.matrikkel.no.

Delegering brukes når man gjør kall til matrikkelen på vegne av andre. Når dette skal brukes må man inkludere claimet consumer_org: <orgnr man jobber på vegne av>. Det er dette organisasjonsnummeret X-Matrikkel-Brukernavn må eies av, og sjekkes mot.

Om man skal representere sin egen organisasjon så trenger man ikke spesifisere consumer_org, og maskinporten vil da sette dette for dere.

Hente token​

Henting av token fra maskinporten krever kall til maskinporten med en signert clientAssertion.

/**
* Fase 2 - Token fra Matrikkelen
*/
fun maskinportenTokenClient(
maskinportenTokenClient: MaskinportenTokenClient,
username: String,
onBehalfOfOrg: String?,
): Interceptor {
return HeadersInterceptor {
val token = maskinportenTokenClient.getToken(
scope = "kartverk:matrikkel:brukernavn",
resource = "https://test.matrikkel.no",
onBehalfOfOrg = onBehalfOfOrg
)
mapOf(
"Authorization" to "Bearer $token",
"X-Matrikkel-Brukernavn" to username,
)
}
}

fun createMatrikkelKlient() {
OkHttpClient.Builder()
.addInterceptor(
maskinportenTokenClient(maskinportenTokenClient, username, onBehalfOfOrg)
)
.build()
}

Eksempel kode - Kotlin​

Eksempelet bruker

Nimbus OAuth 2.0 SDK. for OIDC-discovery og token-håndtering, samt Nimbus JOSE+JWT for å lese utløpstidspunktet fra JWT-en. Både MatrikkelTokenClient og MaskinportenTokenClient benytter seg av en felles abstrakt klasse, denne er inkludert nedenfor og håndterer refresh og mellomlagring av token.

Det finnes etablerte bibliotek som kan brukes for henting av tokens fra Maskinporten levert av KS:

fiks-maskinporten

MatrikkelTokenClient​

class MatrikkelTokenClient(
wellKnownUrl: String,
) : AbstractTokenClient(
wellKnownUrl = wellKnownUrl,
clientId = ClientID("matrikkel-token-exchange"),
cacheLeeway = 30.seconds
) {
fun getToken(username: String, password: String): String {
return getFromCacheOrFetchToken(
cacheKey = username,
grant = ResourceOwnerPasswordCredentialsGrant(
username,
Secret(password)
)
)
}
}

MaskinportenTokenClient​

class MaskinportenTokenClient(
private val clientId: String,
private val privateKey: PrivateKey,
private val keyId: String,
wellKnownUrl: String,
) : AbstractTokenClient(
wellKnownUrl = wellKnownUrl,
clientId = null,
cacheLeeway = 10.seconds
) {
private val assertionSigner: JWSSigner = RSASSASigner(privateKey)

fun getToken(
scope: String, // f.eks kartverk:matrikkel:brukernavn
resource: String, // f.eks https://matrikkel.no
onBehalfOfOrg: String?, // f.eks 123456789
): String {
return getFromCacheOrFetchToken(
cacheKey = "$scope||$resource||$onBehalfOfOrg",
grant = JWTBearerGrant(
buildClientAssertion(scope, resource, onBehalfOfOrg)
)
)
}

private fun buildClientAssertion(
scope: String,
resource: String,
onBehalfOfOrg: String?,
): SignedJWT {
val issuedAt = Instant.now()
val expiresAt = issuedAt.plusSeconds(120)

val claims = JWTClaimsSet.Builder()
.issuer(clientId)
.audience(providerMetadata.issuer.value)
.issueTime(Date.from(issuedAt))
.expirationTime(Date.from(expiresAt))
.jwtID(UUID.randomUUID().toString())
.claim("scope", scope)
.claim("resource", resource)
.apply {
if (onBehalfOfOrg != null) {
claim("consumer_org", onBehalfOfOrg)
}
}
.build()

val header = JWSHeader.Builder(JWSAlgorithm.RS256)
.keyID(keyId)
.build()

return SignedJWT(header, claims)
.apply {
sign(assertionSigner)
}
}
}

Felles Token Klient​

abstract class AbstractTokenClient(
private val wellKnownUrl: String,
private val clientId: ClientID? = null,
private val cacheLeeway: Duration,
) {
data class Tokens(
val accessToken: String,
val refreshToken: String?
)

private val cache = ConcurrentHashMap<String, Tokens>()
protected val providerMetadata: AuthorizationServerMetadata by lazy {
AuthorizationServerMetadata.parse(URI(wellKnownUrl).toURL().readText())
}

protected fun getFromCacheOrFetchToken(
cacheKey: String,
grant: AuthorizationGrant
): String {
return requireNotNull(
cache.compute(cacheKey) { _, existingToken ->
if (existingToken?.accessToken != null && !isExpired(existingToken.accessToken)) {
existingToken
} else if (existingToken?.refreshToken != null && !isExpired(existingToken.refreshToken)) {
val refreshGrant = RefreshTokenGrant(RefreshToken(existingToken.refreshToken))
fetchToken(refreshGrant)
} else {
fetchToken(grant)
}
}
).accessToken
}

private fun fetchToken(grant: AuthorizationGrant): Tokens {
val request = when {
clientId == null -> TokenRequest.Builder(providerMetadata.tokenEndpointURI, grant)
else -> TokenRequest.Builder(providerMetadata.tokenEndpointURI, clientId,grant)
}.build()

val response = request.toHTTPRequest().send()
val tokens = TokenResponse.parse(response)
.toSuccessResponse()
.tokens

return Tokens(
accessToken = requireNotNull(tokens.accessToken.value),
refreshToken = tokens.refreshToken?.value,
)
}

fun isExpired(jwt: String): Boolean {
// KeyCloak RefreshToken is parseable, but this is not guaranteed by spec.
return runCatching {
val expirationTime = SignedJWT.parse(jwt).jwtClaimsSet.expirationTime ?: Date(0L)
val now = Instant.now().plusMillis(cacheLeeway.inWholeMilliseconds)

expirationTime.before(Date.from(now))
}.getOrDefault(true )
}
}