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.