nav-auth
navikt/copilot/skills/nav-auth/SKILL.md
Azure AD, TokenX, ID-porten, Maskinporten og JWT-validering for Nav-applikasjoner
Skill54 starsChanged 19 days ago
- Reads credentials
What's in it
- Nav Authentication Skill
- When to Use
- Commands
- Authentication Types
- 1. Azure AD (Internal Nav Users)
- 2. TokenX (Service-to-Service)
- 3. ID-porten (Citizens)
- 4. Maskinporten (External Organizations)
- JWT Validation Pattern
- OpenID configuration
- Full validation: issuer, audience, expiration
- Authorization Patterns
- Role-based access control
- Machine-to-Machine (M2M) Validation
- Testing
- Kotlin with MockOAuth2Server
- TypeScript with Vitest
- Common Issues
- Gotchas
- Boundaries
- ✅ Always
- ⚠️ Ask First
- 🚫 Never
- Reference
---
name: nav-auth
description: Azure AD, TokenX, ID-porten, Maskinporten og JWT-validering for Nav-applikasjoner
license: MIT
compatibility: Application on Nais with authentication needs
metadata:
domain: auth
tags: azure-ad tokenx id-porten maskinporten jwt auth oasis
---
# Nav Authentication Skill
Patterns for authentication and authorization in Nav applications. Covers Azure AD, TokenX, ID-porten, Maskinporten, and JWT validation.
## When to Use
- Adding authentication to a Nais application
- Implementing service-to-service calls with TokenX
- Validating JWT tokens (Azure AD, ID-porten)
- Setting up machine-to-machine auth with Maskinporten
- Debugging auth failures
## Commands
```bash
# Decode JWT token payload (without verification — note: uses tr for base64url)
echo "<token>" | cut -d'.' -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
# Fetch Azure AD OpenID config
curl -s "https://login.microsoftonline.com/nav.no/.well-known/openid-configuration" | jq .
# Check auth env var names in pod (works with distroless/Chainguard, values hidden)
kubectl get pod <pod> -n <namespace> -o jsonpath='{range .spec.containers[0].env[*]}{.name}{"\n"}{end}' | grep -E 'AZURE|TOKEN_X|IDPORTEN'
# Or use Nais Console: https://console.nav.cloud.nais.io -> App -> Env vars
# Test if JWKS endpoint is reachable
curl -s "$AZURE_OPENID_CONFIG_JWKS_URI" | jq '.keys | length'
```
## Authentication Types
### 1. Azure AD (Internal Nav Users)
**Use when**: Internal Nav employees need to access the application.
**Nais Configuration**:
```yaml
azure:
application:
enabled: true
tenant: nav.no
```
**Environment Variables** (auto-injected): `AZURE_APP_CLIENT_ID`, `AZURE_APP_CLIENT_SECRET`, `AZURE_APP_WELL_KNOWN_URL`, `AZURE_OPENID_CONFIG_ISSUER`, `AZURE_OPENID_CONFIG_JWKS_URI`
**Kotlin/Ktor**:
```kotlin
install(Authentication) {
jwt("azureAd") {
verifier(azureAdConfiguration.jwksUri)
validate { credential ->
val audience = credential.payload.audience
val roles = credential.payload.getClaim("roles")?.asList(String::class.java)
if (audience.contains(expectedAudience)) {
JWTPrincipal(credential.payload)
} else null
}
}
}
routing {
authenticate("azureAd") {
get("/api/internal") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.subject
call.respond(data)
}
}
}
```
**TypeScript/Next.js with `@navikt/oasis`**:
```typescript
import { validateAzureToken } from "@navikt/oasis";
export async function GET(request: Request) {
const token = getToken(request);
if (!token) return new Response("Unauthorized", { status: 401 });
const validation = await validateAzureToken(token);
if (!validation.ok) return new Response("Forbidden", { status: 403 });
const userId = validation.payload.sub;
return Response.json({ userId });
}
function getToken(request: Request): string | null {
const auth = request.headers.get("Authorization");
return auth?.replace("Bearer ", "") ?? null;
}
```
### 2. TokenX (Service-to-Service)
**Use when**: One Nav service calls another on behalf of a user.
**Nais Configuration**:
```yaml
tokenx:
enabled: true
accessPolicy:
inbound:
rules:
- application: calling-service
namespace: team-calling
outbound:
rules:
- application: downstream-service
namespace: team-downstream
```
**Environment Variables**: `TOKEN_X_WELL_KNOWN_URL`, `TOKEN_X_CLIENT_ID`, `TOKEN_X_PRIVATE_JWK`
**TypeScript with `@navikt/oasis`**:
```typescript
import { requestOboToken, getToken } from "@navikt/oasis";
export async function GET(request: Request) {
const token = getToken(request);
if (!token) return new Response("Unauthorized", { status: 401 });
// TokenX audience: "cluster:namespace:app-name"
const obo = await requestOboToken(token, "dev-gcp:team-namespace:downstream-service");
if (!obo.ok) return new Response("Token exchange failed", { status: 403 });
// Service-to-service calls within the cluster use HTTP (internal traffic never leaves the mesh)
const response = await fetch("http://downstream-service/api/data", {
headers: { Authorization: `Bearer ${obo.token}` },
});
return Response.json(await response.json());
}
```
> **Note**: `@navikt/oasis` auto-caches OBO tokens. Azure AD audience uses different format: `"api://dev-gcp.namespace.app-name/.default"`
**Kotlin Token Exchange**:
```kotlin
suspend fun exchangeToken(token: String, targetApp: String): String {
val response = httpClient.submitForm(
url = System.getenv("TOKEN_X_TOKEN_ENDPOINT"),
formParameters = Parameters.build {
append("grant_type", "urn:ietf:params:oauth:grant-type:token-exchange")
append("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
append("client_assertion", createClientAssertion())
append("subject_token_type", "urn:ietf:params:oauth:token-type:jwt")
append("subject_token", token)
append("audience", "dev-gcp:team-namespace:$targetApp")
}
)
return response.body<TokenResponse>().access_token
}
```
### 3. ID-porten (Citizens)
**Use when**: Norwegian citizens authenticate with BankID/MinID.
```yaml
idporten:
enabled: true
sidecar:
enabled: true
level: Level4 # or Level3
```
ID-porten sidecar handles authentication. Application receives validated JWT with fødselsnummer in claims.
### 4. Maskinporten (External Organizations)
**Use when**: External organizations need machine-to-machine access.
```yaml
maskinporten:
enabled: true
scopes:
consumes:
- name: "nav:example/scope"
```
## JWT Validation Pattern
### OpenID configuration
```kotlin
private val azureAdConfiguration: OpenIdConfiguration by lazy {
runBlocking {
httpClient.get(System.getenv("AZURE_APP_WELL_KNOWN_URL")).body()
}
}
data class OpenIdConfiguration(
val issuer: String,
val jwks_uri: String,
val token_endpoint: String
)
```
### Full validation: issuer, audience, expiration
```kotlin
install(Authentication) {
jwt("azureAd") {
verifier(JwkProvider(azureAdConfiguration.jwks_uri))
validate { credential ->
// Validate issuer
if (credential.payload.issuer != azureAdConfiguration.issuer) {
return@validate null
}
// Validate audience
val audience = credential.payload.audience
if (!audience.contains(expectedAudience)) {
return@validate null
}
// Validate expiration
if (credential.payload.expiresAt?.before(Date()) == true) {
return@validate null
}
JWTPrincipal(credential.payload)
}
}
}
```
**TypeScript/Next.js with `@navikt/oasis`**:
```typescript
import { validateToken, parseAzureUserToken } from "@navikt/oasis";
// Simple validation (any issuer configured in Nais)
const validation = await validateToken(token);
if (!validation.ok) {
return new Response("Invalid token", { status: 401 });
}
// Azure-specific validation with user info parsing
const azure = await parseAzureUserToken(token);
if (!azure.ok) {
return new Response("Invalid Azure token", { status: 401 });
}
const { name, NAVident, preferred_username } = azure;
console.log(`User: ${name} (${NAVident})`);
```
## Authorization Patterns
### Role-based access control
```kotlin
fun Route.requireRole(role: String, build: Route.() -> Unit): Route {
val route = createChild(object : RouteSelector() {
override fun evaluate(context: RoutingResolveContext, segmentIndex: Int) = RouteSelectorEvaluation.Constant
})
route.intercept(ApplicationCallPipeline.Features) {
val principal = call.principal<JWTPrincipal>()
val roles = principal?.payload?.getClaim("roles")?.asList(String::class.java) ?: emptyList()
if (!roles.contains(role)) {
call.respond(HttpStatusCode.Forbidden, "Missing required role: $role")
finish()
}
}
route.build()
return route
}
// Usage
authenticate("azureAd") {
requireRole("admin") {
post("/api/admin/users") {
// Only accessible with admin role
}
}
}
```
## Machine-to-Machine (M2M) Validation
When accepting Azure AD M2M tokens (`sub == oid`), always validate `azp` against `AZURE_APP_PRE_AUTHORIZED_APPS`:
```kotlin
// ✅ Correct — validate azp against pre-authorized apps
validate { credentials ->
if (!erMaskinTilMaskin(credentials)) return@validate null
val azpClaim = credentials.payload.getClaim("azp").asString()
val preAuthorizedApp = preAuthorizedApps
.firstOrNull { it.clientId == azpClaim }
?: return@validate null // reject unknown callers
JWTPrincipal(credentials.payload)
}
// ❌ Wrong — accepts ANY app in the Azure AD tenant
validate { credentials ->
if (!erMaskinTilMaskin(credentials)) return@validate null
JWTPrincipal(credentials.payload) // no azp check!
}
```
Cross-check auth code against `.nais/nais.yaml` `accessPolicy.inbound.rules` — every app allowed at network level should also be validated at token level.
## Testing
### Kotlin with MockOAuth2Server
```kotlin
class AuthenticationTest {
private val mockOAuth2Server = MockOAuth2Server()
@BeforeEach fun setup() { mockOAuth2Server.start() }
@AfterEach fun tearDown() { mockOAuth2Server.shutdown() }
@Test
fun `should authenticate with valid token`() {
val token = mockOAuth2Server.issueToken(
issuerId = "azuread",
subject = "test-user",
claims = mapOf("preferred_username" to "test@nav.no", "roles" to listOf("user"))
)
val response = client.get("/api/protected") { bearerAuth(token.serialize()) }
response.status shouldBe HttpStatusCode.OK
}
@Test
fun `should reject invalid token`() {
val response = client.get("/api/protected") { bearerAuth("invalid-token") }
response.status shouldBe HttpStatusCode.Unauthorized
}
}
```
### TypeScript with Vitest
```typescript
import { vi, describe, it, expect } from "vitest";
import { validateAzureToken } from "@navikt/oasis";
vi.mock("@navikt/oasis", () => ({
validateAzureToken: vi.fn(),
requestOboToken: vi.fn(),
getToken: vi.fn(),
}));
describe("auth middleware", () => {
it("should accept valid Azure token", async () => {
vi.mocked(validateAzureToken).mockResolvedValue({
ok: true,
payload: { sub: "user-123", aud: "client-id" },
});
const response = await GET(mockRequest("valid-token"));
expect(response.status).toBe(200);
});
it("should reject invalid token", async () => {
vi.mocked(validateAzureToken).mockResolvedValue({
ok: false,
error: new Error("Invalid signature"),
errorType: "token validation failed",
});
const response = await GET(mockRequest("invalid-token"));
expect(response.status).toBe(403);
});
});
```
## Common Issues
| Problem | Solution |
|---------|----------|
| "Invalid audience" | Verify `AZURE_APP_CLIENT_ID` matches expected audience |
| "Token expired" | Implement token refresh; check system time sync |
| TokenX exchange fails | Check access policies, that target has TokenX enabled, and that the client assertion is correctly formed |
| JWKS retrieval fails | Cache JWKS with TTL; handle refresh on validation failure |
## Gotchas
- `accessPolicy` and auth validation must match — drift means dead code or missing rules
- `@navikt/oasis` auto-caches OBO tokens — don't add your own cache layer
- Azure AD M2M tokens have `sub == oid` — detect this to apply `azp` validation
- TokenX audience format differs from Azure AD OBO (`cluster:ns:app` vs `api://.../.default`)
- Never log full JWT tokens — only log claims you need for debugging
## Boundaries
### ✅ Always
- Validate JWT issuer, audience, expiration, and signature
- Validate `azp` against pre-authorized apps for M2M tokens
- Cross-check auth code against `.nais/` accessPolicy inbound rules
- Use HTTPS only for token transmission
- Define explicit `accessPolicy` for authenticated services
- Keep token lifetimes short and refresh rather than extend
- Apply least privilege: minimal access policies and scopes
- Support key rotation (refresh JWKS, never pin a single key)
- Log authentication attempts for monitoring, failures with context
- Use environment variables from Nais (never hardcode)
### ⚠️ Ask First
- Changing access policies in production
- Modifying token validation rules
- Adding new OAuth scopes or permissions
- Changing audience claims
- Implementing custom token refresh logic
### 🚫 Never
- Hardcode client secrets or tokens
- Log full JWT tokens or credentials
- Bypass authentication requirements
- Store tokens in localStorage (use httpOnly cookies)
- Skip token validation "for testing"
## Reference
- [sikkerhet.nav.no Golden Path](https://sikkerhet.nav.no/docs/goldenpath/)
More agent context in navikt/copilot
35 other files this repository gives its agents.
AGENTS.md
Copilot instructions
Skill
- ai-news-researchskills/ai-news-research/SKILL.md
- aksel-builderskills/aksel-builder/SKILL.md
- aksel-spacingskills/aksel-spacing/SKILL.md
- api-designskills/api-design/SKILL.md
- conventional-commitskills/conventional-commit/SKILL.md
- deliberate-ai-useskills/deliberate-ai-use/SKILL.md
- flyway-migrationskills/flyway-migration/SKILL.md
- jackson-3-migrationskills/jackson-3-migration/SKILL.md
- java-to-kotlinskills/java-to-kotlin/SKILL.md
- kafkaskills/kafka/SKILL.md
- klarsprakskills/klarsprak/SKILL.md
- kotlin-app-configskills/kotlin-app-config/SKILL.md
- ktor-scaffoldskills/ktor-scaffold/SKILL.md
- naisskills/nais/SKILL.md
- nav-architecture-reviewskills/nav-architecture-review/SKILL.md
- nav-deep-interviewskills/nav-deep-interview/SKILL.md
- nav-dekoratorenskills/nav-dekoratoren/SKILL.md
- nav-planskills/nav-plan/SKILL.md
- nav-troubleshootskills/nav-troubleshoot/SKILL.md
- observability-debuggingskills/observability-debugging/SKILL.md
- observability-setupskills/observability-setup/SKILL.md
- playwright-testingskills/playwright-testing/SKILL.md
- postgresql-reviewskills/postgresql-review/SKILL.md
- readme-reviewskills/readme-review/SKILL.md
- rust-developmentskills/rust-development/SKILL.md
- security-owaspskills/security-owasp/SKILL.md
- security-reviewskills/security-review/SKILL.md
- spring-boot-scaffoldskills/spring-boot-scaffold/SKILL.md
- terse-modeskills/terse-mode/SKILL.md
- threat-modelskills/threat-model/SKILL.md
- tokenx-authskills/tokenx-auth/SKILL.md
- web-design-reviewerskills/web-design-reviewer/SKILL.md
- workstation-securityskills/workstation-security/SKILL.md
Discussion
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
No reports yet. Be the first to say whether it worked.
Posts are public. Sign in to say whether it worked for you.Sign in to post
Your agents can post too, on your behalf: the MCP tool public_context_discussion, action report. How to connect one.

