Azure / Azure/data-api-builder

[Enh]: Custom JWT paths for Claims

Open
#3,078 2 comments 2 reactions 2 assignees Claimed by @JerryNixon View on GitHub
2.x auth enhancement has-pr
Dominant language
C#
Stars
1.5k
Forks
370
Avg merge
3d 17h
Merged PRs (30d)
8

Description

See also: [https://github.com/Azure/data-api-builder/issues/3313](https://github.com/Azure/data-api-builder/issues/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:

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

```json
{
"realm_access": {
"roles": [ "admin", "reader" ]
}
}
```

```json
{
"scope": "admin reader"
}
```

```json
{
"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:

```json
"provider": "Custom"
```

This feature does not apply to:

```text
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:

```text
runtime.host.authentication.jwt
```

Properties:

```text
roles-path
roles-format
roles-delimiter
```

Example:

```json
{
"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:

```json
{
"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:

```json
{
"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:

```json
{
"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.

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

Default configuration behavior:

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

## Property rules

### roles-path

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

It supports:

```text
literal string
@env()
@akv()
```

Valid examples:

```json
"roles-path": "roles"
```

```json
"roles-path": "realm_access.roles"
```

```json
"roles-path": "['https://schemas.example.com/roles']"
```

```json
"roles-path": "resource_access['dab-api'].roles"
```

```json
"roles-path": "@env('JWT_ROLES_PATH')"
```

```json
"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:

```text
array
string
delimited-string
```

Valid examples:

```json
"roles-format": "array"
```

```json
"roles-format": "string"
```

```json
"roles-format": "delimited-string"
```

Invalid examples:

```json
"roles-format": "@env('JWT_ROLES_FORMAT')"
```

```json
"roles-format": "@akv('jwt-roles-format')"
```

### roles-delimiter

`roles-delimiter` is used only when:

```json
"roles-format": "delimited-string"
```

It supports:

```text
literal string
@env()
@akv()
```

Valid examples:

```json
"roles-delimiter": " "
```

```json
"roles-delimiter": ","
```

```json
"roles-delimiter": " | "
```

```json
"roles-delimiter": "@env('JWT_ROLES_DELIMITER')"
```

```json
"roles-delimiter": "@akv('jwt-roles-delimiter')"
```

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

Validation:

```text
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.

```text
realm_access.roles
```

It supports bracket notation for literal claim names.

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

It supports mixed dot and bracket notation.

```text
resource_access['dab-api'].roles
```

It does not support array indexing.

Valid:

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

Invalid:

```text
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:

```text
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:

```text
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:

```text
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:

```text
X-MS-API-ROLE: admin
extracted roles: admin, reader
result: active role admin
```

```text
X-MS-API-ROLE: admin
extracted roles: Admin, reader
result: 403
```

## Format behavior

The resolved claim value must match `roles-format` exactly.

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

No convenience coercion.

Valid for `array`:

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

Invalid for `array`:

```json
{
"roles": "admin"
}
```

Valid for `string`:

```json
{
"role": "admin"
}
```

Invalid for `string`:

```json
{
"role": [ "admin" ]
}
```

Valid for `delimited-string`:

```json
{
"scope": "admin reader"
}
```

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

```text
object
number
boolean
null
array with non-string values
```

## Role normalization

After extraction, DAB must:

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

Example:

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

Effective extracted roles:

```text
admin
reader
Admin
```

## Missing role claim behavior

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

```text
roles-path is not resolved
request uses authenticated role
```

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

```text
403 Forbidden
```

Reason:

```text
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:

```text
401 Unauthorized
```

Examples:

```text
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:

```text
403 Forbidden
```

Examples:

```text
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:

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

Log does not include:

```text
JWT token
raw claim value
authorization header
secret values
```

Requested role mismatch should also be logged.

Log includes:

```text
provider
roles-path
requested role
correlation ID
reason
```

Log does not include:

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

## Validation

`dab validate` and runtime startup must validate configuration.

Validation rules:

```text
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`.

```json
"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"
}
```

```json
"roles-format": {
"description": "Format of the JWT claim that contains custom roles.",
"type": "string",
"enum": [ "array", "string", "delimited-string" ],
"default": "array"
}
```

```json
"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:

```text
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:

```text
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:

```text
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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.