Skip to content

[Enh]: Custom JWT paths for Claims #3078

@JerryNixon

Description

@JerryNixon

See also: #3313

What

Add configurable custom role extraction for provider: "Custom" JWT authentication.

DAB currently expects custom roles in a fixed roles claim. This feature lets a developer configure where DAB reads custom roles from a JWT.

This supports common identity provider shapes such as:

{
  "roles": [ "admin", "reader" ]
}
{
  "realm_access": {
    "roles": [ "admin", "reader" ]
  }
}
{
  "scope": "admin reader"
}
{
  "https://schemas.example.com/roles": [ "admin", "reader" ]
}

Why

Different identity providers emit roles in different claim shapes.

DAB should not require users to rewrite tokens just to emit a top-level roles array.

The goal is to keep DAB authorization behavior unchanged while making custom JWT role extraction configurable.

Scope

This feature applies only when:

"provider": "Custom"

This feature does not apply to:

EntraID
AzureAD
StaticWebApps
AppService
Simulator
Unauthenticated

Non-goals

This feature does not change JWT validation.

This feature does not change issuer validation.

This feature does not change audience validation.

This feature does not change DAB permission evaluation.

This feature does not change X-MS-API-ROLE semantics.

This feature does not support multiple active roles per request.

This feature does not support array indexes in claim paths.

This feature does not add role aliasing.

This feature does not make role matching case-insensitive.

Configuration

Add three optional properties under:

runtime.host.authentication.jwt

Properties:

roles-path
roles-format
roles-delimiter

Example:

{
  "runtime": {
    "host": {
      "authentication": {
        "provider": "Custom",
        "jwt": {
          "issuer": "https://issuer.example.com",
          "audience": "dab-api",
          "roles-path": "realm_access.roles",
          "roles-format": "array"
        }
      }
    }
  }
}

Delimited string example:

{
  "runtime": {
    "host": {
      "authentication": {
        "provider": "Custom",
        "jwt": {
          "issuer": "https://issuer.example.com",
          "audience": "dab-api",
          "roles-path": "scope",
          "roles-format": "delimited-string",
          "roles-delimiter": " "
        }
      }
    }
  }
}

Namespaced claim example:

{
  "runtime": {
    "host": {
      "authentication": {
        "provider": "Custom",
        "jwt": {
          "issuer": "https://issuer.example.com",
          "audience": "dab-api",
          "roles-path": "['https://schemas.example.com/roles']",
          "roles-format": "array"
        }
      }
    }
  }
}

Mixed path example:

{
  "runtime": {
    "host": {
      "authentication": {
        "provider": "Custom",
        "jwt": {
          "issuer": "https://issuer.example.com",
          "audience": "dab-api",
          "roles-path": "resource_access['dab-api'].roles",
          "roles-format": "array"
        }
      }
    }
  }
}

Defaults

Defaults must preserve current DAB behavior.

roles-path: roles
roles-format: array
roles-delimiter: " "

Default configuration behavior:

{
  "roles": [ "admin", "reader" ]
}

Property rules

roles-path

roles-path identifies the JWT claim that contains custom roles.

It supports:

literal string
@env()
@akv()

Valid examples:

"roles-path": "roles"
"roles-path": "realm_access.roles"
"roles-path": "['https://schemas.example.com/roles']"
"roles-path": "resource_access['dab-api'].roles"
"roles-path": "@env('JWT_ROLES_PATH')"
"roles-path": "@akv('jwt-roles-path')"

roles-format

roles-format defines how the resolved claim value is parsed.

It supports enum values only.

Valid values:

array
string
delimited-string

Valid examples:

"roles-format": "array"
"roles-format": "string"
"roles-format": "delimited-string"

Invalid examples:

"roles-format": "@env('JWT_ROLES_FORMAT')"
"roles-format": "@akv('jwt-roles-format')"

roles-delimiter

roles-delimiter is used only when:

"roles-format": "delimited-string"

It supports:

literal string
@env()
@akv()

Valid examples:

"roles-delimiter": " "
"roles-delimiter": ","
"roles-delimiter": " | "
"roles-delimiter": "@env('JWT_ROLES_DELIMITER')"
"roles-delimiter": "@akv('jwt-roles-delimiter')"

roles-delimiter is valid only when roles-format is delimited-string.

Validation:

roles-format array + roles-delimiter present -> configuration error
roles-format string + roles-delimiter present -> configuration error
roles-format delimited-string + roles-delimiter missing -> valid, default to " "
roles-format delimited-string + roles-delimiter present -> valid

Path syntax

roles-path supports dot notation for nested objects.

realm_access.roles

It supports bracket notation for literal claim names.

['https://schemas.example.com/roles']

It supports mixed dot and bracket notation.

resource_access['dab-api'].roles

It does not support array indexing.

Valid:

roles
realm_access.roles
['https://schemas.example.com/roles']
resource_access['dab-api'].roles

Invalid:

realm_access..roles
resource_access['dab-api'.roles
groups[0]
resource_access.clients[1].roles
[]

Malformed paths fail dab validate and startup validation.

Extraction timing

DAB resolves roles only after normal JWT validation succeeds.

Order:

Validate JWT signature
Validate issuer
Validate audience
Validate lifetime
Authenticate identity
If X-MS-API-ROLE is present, resolve roles-path
Parse roles using roles-format
Compare requested role to extracted roles
Set active role or fail request

Custom role extraction runs only when X-MS-API-ROLE is present.

If X-MS-API-ROLE is not present, role extraction does not run and the active role is authenticated.

Active role behavior

A valid JWT establishes identity.

Without X-MS-API-ROLE, the active role is:

authenticated

With X-MS-API-ROLE, DAB uses the header to select the active custom role.

The JWT roles prove the caller belongs to that role.

Both must agree.

Behavior:

valid JWT + no X-MS-API-ROLE -> active role is authenticated
valid JWT + X-MS-API-ROLE present -> extract roles from configured roles-path
valid JWT + X-MS-API-ROLE matches extracted role -> active role is requested custom role
valid JWT + X-MS-API-ROLE does not match extracted role -> 403

Role matching is case-sensitive.

Example:

X-MS-API-ROLE: admin
extracted roles: admin, reader
result: active role admin
X-MS-API-ROLE: admin
extracted roles: Admin, reader
result: 403

Format behavior

The resolved claim value must match roles-format exactly.

array -> JSON array of strings
string -> JSON string
delimited-string -> JSON string split by roles-delimiter

No convenience coercion.

Valid for array:

{
  "roles": [ "admin", "reader" ]
}

Invalid for array:

{
  "roles": "admin"
}

Valid for string:

{
  "role": "admin"
}

Invalid for string:

{
  "role": [ "admin" ]
}

Valid for delimited-string:

{
  "scope": "admin reader"
}

Invalid resolved values fail authentication with 401 and log an error:

object
number
boolean
null
array with non-string values

Role normalization

After extraction, DAB must:

trim each role
drop empty roles
deduplicate roles
preserve case
match case-sensitively

Example:

{
  "roles": [ " admin ", "reader", "admin", "", "Admin" ]
}

Effective extracted roles:

admin
reader
Admin

Missing role claim behavior

If X-MS-API-ROLE is not present:

roles-path is not resolved
request uses authenticated role

If X-MS-API-ROLE is present and roles-path is missing from the token:

403 Forbidden

Reason:

The caller is authenticated, but the requested role cannot be proven from the token.

Error behavior

JWT validation failure returns the existing authentication failure behavior.

Role extraction format failure returns:

401 Unauthorized

Examples:

roles-format array + value is string -> 401
roles-format string + value is array -> 401
roles-format delimited-string + value is array -> 401

Requested role mismatch returns:

403 Forbidden

Examples:

roles-path missing while X-MS-API-ROLE is present -> 403
roles extracted but requested role not found -> 403
case mismatch -> 403

Logging

Role extraction failures are logged as errors.

Log includes:

provider
roles-path
roles-format
failure reason
requested role
correlation ID

Log does not include:

JWT token
raw claim value
authorization header
secret values

Requested role mismatch should also be logged.

Log includes:

provider
roles-path
requested role
correlation ID
reason

Log does not include:

JWT token
raw claim value
authorization header
secret values
full extracted role list

Validation

dab validate and runtime startup must validate configuration.

Validation rules:

roles-path must be valid path syntax
roles-format must be array, string, or delimited-string
roles-delimiter is valid only when roles-format is delimited-string
roles-delimiter defaults to " " when roles-format is delimited-string
roles-path supports literal string, @env(), and @akv()
roles-delimiter supports literal string, @env(), and @akv()
roles-format does not support @env() or @akv()
this feature is valid only when provider is Custom

Malformed roles-path fails validation.

Invalid roles-format fails validation.

roles-delimiter with array or string fails validation.

Schema change

Add these properties under runtime.host.authentication.jwt.

"roles-path": {
  "description": "Path to the JWT claim that contains custom roles. Supports dot notation and bracket notation. Defaults to the existing roles claim.",
  "type": "string",
  "default": "roles"
}
"roles-format": {
  "description": "Format of the JWT claim that contains custom roles.",
  "type": "string",
  "enum": [ "array", "string", "delimited-string" ],
  "default": "array"
}
"roles-delimiter": {
  "description": "Delimiter used when roles-format is delimited-string.",
  "type": "string",
  "default": " "
}

For roles-path and roles-delimiter, use the existing string pattern that supports literal strings, @env(), and @akv() if available in the schema.

Documentation

Update custom JWT authentication docs.

Document:

default roles claim behavior
roles-path
roles-format
roles-delimiter
nested claim paths
namespaced claim paths
delimited role strings
X-MS-API-ROLE relationship to extracted roles
401 vs 403 behavior
security logging guidance

Include examples for:

default roles array
Keycloak realm_access.roles
Keycloak resource_access['client-id'].roles
Auth0 namespaced role claim
OAuth scope-style delimited string
comma-delimited custom role claim

Tests

Tests should cover:

default roles claim still works
roles-path defaults to roles
roles-format defaults to array
valid array claim
valid string claim
valid delimited-string claim
custom delimiter
nested dot path
bracket literal path
mixed dot and bracket path
namespaced claim key
malformed roles-path fails validation
unsupported array index fails validation
roles-format rejects @env()
roles-format rejects @akv()
roles-path supports @env()
roles-path supports @akv()
roles-delimiter supports @env()
roles-delimiter supports @akv()
roles-delimiter rejected when roles-format is array
roles-delimiter rejected when roles-format is string
roles-path missing with no X-MS-API-ROLE does not fail request
roles-path missing with X-MS-API-ROLE returns 403
format mismatch returns 401
requested role mismatch returns 403
case mismatch returns 403
roles are trimmed
empty roles are dropped
duplicate roles are deduplicated
valid JWT without X-MS-API-ROLE resolves to authenticated
role extraction runs only when X-MS-API-ROLE is present
feature is ignored or rejected for non-Custom providers
logs do not include JWT token
logs do not include raw claim value
logs do not include authorization header

Acceptance criteria

This feature applies only to provider: "Custom".

roles-path is added under runtime.host.authentication.jwt.

roles-format is added under runtime.host.authentication.jwt.

roles-delimiter is added under runtime.host.authentication.jwt.

roles-path defaults to current DAB behavior.

roles-format defaults to current DAB behavior.

roles-delimiter defaults to " ".

roles-path supports dot notation.

roles-path supports bracket notation.

roles-path supports mixed dot and bracket notation.

roles-path does not support array indexing.

Malformed roles-path fails dab validate.

Malformed roles-path fails runtime startup.

roles-format supports only array, string, and delimited-string.

roles-format is enum-only and does not support @env() or @akv().

roles-path supports literal string, @env(), and @akv().

roles-delimiter supports literal string, @env(), and @akv().

roles-delimiter is valid only when roles-format is delimited-string.

DAB resolves roles only after JWT validation succeeds.

DAB extracts custom roles only when X-MS-API-ROLE is present.

A valid JWT without X-MS-API-ROLE resolves to the authenticated role.

A valid JWT with X-MS-API-ROLE must match an extracted custom role.

Role matching is case-sensitive.

Extracted roles are trimmed.

Empty extracted roles are dropped.

Duplicate extracted roles are deduplicated.

Missing roles-path with X-MS-API-ROLE returns 403.

Requested role mismatch returns 403.

Resolved claim value format mismatch returns 401.

Role extraction failures are logged as errors.

Logs do not include JWT tokens, raw claim values, authorization headers, or secrets.

Existing authorization behavior remains unchanged.

Metadata

Metadata

No fields configured for Feature.

Projects

Status

Todo

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions