Migrering fra basic auth til token-autentisering
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:
| Parameter | Verdi |
|---|---|
client_id | matrikkel-token-exchange |
Well-known URL per miljø:
| Miljø | URL |
|---|---|
| dev | https://kc-test.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration |
| betatest | https://kc-betatest.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration |
| prodtest | https://kc-prodtest.matrikkel.no/auth/realms/matrikkelen-test/.well-known/openid-configuration |
| prod | https://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>. (
Audience per miljø:
| Miljø | Resource/Audience |
|---|---|
| Kurs | https://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ådeMatrikkelTokenClient 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-maskinportenMatrikkelTokenClient
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 )
}
}