Skip to content

Add Custom JWT role path, format, and delimiter support#3549

Open
Copilot wants to merge 9 commits into
mainfrom
copilot/add-custom-jwt-paths
Open

Add Custom JWT role path, format, and delimiter support#3549
Copilot wants to merge 9 commits into
mainfrom
copilot/add-custom-jwt-paths

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 13, 2026

Why make this change?

Custom JWT providers often place roles outside DAB’s expected top-level roles array. This adds configurable role extraction so providers like Keycloak, Auth0, Okta, Cognito, and custom issuers can work without token rewriting while preserving existing authorization behavior.

What is this change?

  • Configuration

    • Adds runtime.host.authentication.jwt.roles-path
    • Adds runtime.host.authentication.jwt.roles-format
    • Adds runtime.host.authentication.jwt.roles-delimiter
    • Runtime and schema defaults remain roles-path: "roles", roles-format: "array", and roles-delimiter: " " for Custom JWT authentication.
    • Rejects blank or whitespace-only roles-path values.
    • Rejects roles-delimiter unless roles-format is delimited-string.
  • Custom JWT role extraction

    • Resolves roles from the raw JWT payload JSON only after normal JWT validation succeeds.
    • Runs custom role extraction only when X-MS-API-ROLE is present.
    • Supports literal keys, URL keys, bracket literals, dot-path traversal, and mixed dot/bracket paths such as resource_access['dab-api'].roles.
    • Supports array, string, and delimited-string formats.
    • Supports configurable delimiters for delimited role strings.
    • Normalizes roles by trimming, removing empty values, ordinal de-duping, and preserving casing.
    • Injects normalized roles as DAB "roles" claims for requested custom role evaluation.
  • Validation and runtime behavior

    • Rejects roles-path / roles-format / roles-delimiter for non-Custom providers.
    • Rejects malformed role paths, array indexes, blank role paths, invalid role formats, and invalid role delimiter usage at startup.
    • Applies the same provider/path/format/delimiter validation when using dab configure before writing config changes.
    • Returns 401 for unsupported claim value types or role format mismatches.
    • Returns 403 when X-MS-API-ROLE is present but the configured role claim is missing or the requested role does not match extracted roles.
    • Preserves existing X-MS-API-ROLE, User.IsInRole(), @claims.roles, and system Authenticated role behavior.
  • Schema and CLI

    • Updates JSON schema for the Custom JWT role settings.
    • Adds conditional schema defaults for roles-path, roles-format, and roles-delimiter only when the provider is Custom.
    • Prevents schema-valid non-Custom JWT configs from using Custom JWT role settings.
    • Adds CLI init/configure support for the new options.
"authentication": {
  "provider": "Custom",
  "jwt": {
    "issuer": "https://keycloak.example.com/realms/myrealm",
    "audience": "dab-api",
    "roles-path": "realm_access.roles",
    "roles-format": "array"
  }
}

How was this tested?

  • Integration Tests
  • Unit Tests

Targeted authentication/configuration/CLI coverage was added for role path resolution, role formats, custom delimiters, invalid inputs, startup validation, dab configure validation, X-MS-API-ROLE, User.IsInRole(), @claims.roles, schema-default behavior, exact logging for missing claims, non-Custom provider default behavior, mixed dot/bracket paths, missing role claim 403 behavior, and extraction only when X-MS-API-ROLE is present.

Validation run:

  • dotnet format src/Azure.DataApiBuilder.sln
  • dotnet build src/Azure.DataApiBuilder.sln --no-restore
  • Targeted CLI JWT role settings tests
  • Targeted service authentication/configuration tests

Sample Request(s)

dab init \
  --database-type mssql \
  --connection-string "@env('connection_string')" \
  --auth.provider Custom \
  --auth.audience dab-api \
  --auth.issuer https://keycloak.example.com/realms/myrealm \
  --auth.roles-path "resource_access['dab-api'].roles" \
  --auth.roles-format array
dab init \
  --database-type mssql \
  --connection-string "@env('connection_string')" \
  --auth.provider Custom \
  --auth.audience dab-api \
  --auth.issuer https://issuer.example.com \
  --auth.roles-path scope \
  --auth.roles-format delimited-string \
  --auth.roles-delimiter " "
dab configure \
  --runtime.host.authentication.provider Custom \
  --runtime.host.authentication.jwt.audience dab-api \
  --runtime.host.authentication.jwt.issuer https://keycloak.example.com/realms/myrealm \
  --runtime.host.authentication.jwt.roles-path "realm_access.roles" \
  --runtime.host.authentication.jwt.roles-format array
curl \
  -H "Authorization: ******" \
  -H "X-MS-API-ROLE: editor" \
  https://localhost:5001/api/books

Copilot AI linked an issue May 13, 2026 that may be closed by this pull request
Copilot AI and others added 2 commits May 13, 2026 23:16
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/46abd1d4-486d-40f9-a907-f91714efb3ad

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/46abd1d4-486d-40f9-a907-f91714efb3ad

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Copilot AI changed the title [WIP] Add custom JWT paths for roles in authentication Add Custom JWT role path and format support May 13, 2026
Copilot AI requested a review from JerryNixon May 13, 2026 23:27
@JerryNixon JerryNixon marked this pull request as ready for review May 13, 2026 23:34
Copilot AI review requested due to automatic review settings May 13, 2026 23:34
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds configurable role extraction for Custom JWT authentication so DAB can map provider-specific role claims into its standard roles flow for REST/GraphQL authorization and claims policies.

Changes:

  • Adds rolesPath / rolesFormat config model, schema, CLI, and validation support.
  • Adds Custom JWT role extraction and normalization during bearer token validation.
  • Adds unit tests covering extraction formats, authorization behavior, and config validation.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
src/Service/Startup.cs Registers the Custom JWT scheme and hot-reload JWT options.
src/Service/JwtBearerOptionsChangeTokenSource.cs Supports named JWT bearer option reload tokens.
src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs Adds validation tests for Custom JWT role settings.
src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs Adds authentication/authorization tests for extracted roles.
src/Service.Tests/Authentication/CustomJwtRoleClaimExtractorUnitTests.cs Adds unit coverage for role path and format parsing.
src/Core/Configurations/RuntimeConfigValidator.cs Validates role path/format configuration.
src/Core/AuthenticationHelpers/CustomJwtRoleClaimExtractor.cs Implements role extraction, parsing, normalization, and claim replacement.
src/Core/AuthenticationHelpers/ConfigureJwtBearerOptions.cs Wires extraction into dynamic JWT bearer configuration.
src/Config/ObjectModel/JwtOptions.cs Adds role path/format JWT config fields and defaults.
src/Config/ObjectModel/AuthenticationOptions.cs Adds Custom provider helper.
src/Cli/ConfigGenerator.cs Adds init/configure handling for new JWT role settings.
src/Cli/Commands/InitOptions.cs Adds init CLI flags for roles path/format.
src/Cli/Commands/ConfigureOptions.cs Adds configure CLI flags for roles path/format.
schemas/dab.draft.schema.json Adds schema entries for JWT role settings.
Comments suppressed due to low confidence (1)

src/Cli/ConfigGenerator.cs:1807

  • The configure path writes rolesFormat directly without validating that it is one of the supported formats or that the provider is Custom. This lets dab configure --runtime.host.authentication.jwt.rolesFormat ... create a config that will fail runtime validation later; validate these values before updating the file.
                // Runtime.Host.Authentication.Jwt.RolesFormat
                updatedValue = options?.RuntimeHostAuthenticationJwtRolesFormat;
                if (updatedValue != null)
                {
                    JwtOptions jwtOptions;
                    AuthenticationOptions AuthOptions;
                    if (updatedHostOptions?.Authentication == null || updatedHostOptions.Authentication?.Jwt == null)
                    {
                        jwtOptions = new(null, null, RolesFormat: (string)updatedValue);
                    }
                    else
                    {
                        jwtOptions = updatedHostOptions.Authentication.Jwt with { RolesFormat = (string)updatedValue };
                    }

Comment on lines +1766 to +1779
// Runtime.Host.Authentication.Jwt.RolesPath
updatedValue = options?.RuntimeHostAuthenticationJwtRolesPath;
if (updatedValue != null)
{
JwtOptions jwtOptions;
AuthenticationOptions AuthOptions;
if (updatedHostOptions?.Authentication == null || updatedHostOptions.Authentication?.Jwt == null)
{
jwtOptions = new(null, null, RolesPath: (string)updatedValue);
}
else
{
jwtOptions = updatedHostOptions.Authentication.Jwt with { RolesPath = (string)updatedValue };
}
public const string CUSTOM_JWT_ROLE_SETTINGS_PROVIDER_ERROR = "jwt.rolesPath and jwt.rolesFormat are only supported when authentication.provider is Custom.";

public static bool IsValidRolesPath(string rolesPath)
{
Comment thread schemas/dab.draft.schema.json Outdated
Comment on lines +461 to +475
"rolesPath": {
"type": "string",
"description": "Path to the roles claim in the raw JWT payload JSON. Supported for Custom authentication.",
"default": "roles"
},
"rolesFormat": {
"type": "string",
"description": "Format used to parse the roles claim. Supported for Custom authentication.",
"enum": [
"array",
"string",
"space-delimited",
"comma-delimited"
],
"default": "array"
@JerryNixon
Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Copilot AI and others added 2 commits May 14, 2026 15:40
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/0569f33b-b1aa-4b03-b259-bd56b4753cec

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/0569f33b-b1aa-4b03-b259-bd56b4753cec

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 14, 2026

@copilot apply changes based on the comments in this thread

Applied the review feedback in f2b321e: dab configure now validates Custom JWT role settings before writing, blank rolesPath values are rejected, and the schema no longer advertises shared defaults for non-Custom providers.

@JerryNixon
Copy link
Copy Markdown
Contributor

@copilot apply changes based on the comments in this thread

Also Gaps / concerns

  1. JSON schema omits the spec’s defaults
    Issue [Enh]: Custom JWT paths for Claims #3078 explicitly specifies schema defaults for:

rolesPath: "roles"
rolesFormat: "array"
The schema adds both properties and the enum, but no default values are present: schemas/dab.draft.schema.json:461-474.

This may be intentional to satisfy the review-bot comment about not advertising shared defaults for non-Custom providers, but strictly against the issue spec, schema defaults are missing.

  1. Test coverage is good but not all 30 spec cases are explicitly covered
    Covered well:

Defaults, omitted path/format, nested path, URL, colon, bracket literal, array/string/space/comma, empty array/string, non-string array, object/number/boolean/null, missing claim, duplicates, trimming, empty normalized roles: src/Service.Tests/Authentication/CustomJwtRoleClaimExtractorUnitTests.cs:15-76
X-MS-API-ROLE selected/absent, IsInRole, @claims.roles, 401 on missing/wrong type: src/Service.Tests/Authentication/JwtTokenAuthenticationUnitTests.cs:116-228
Provider/validation failures: src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs:92-175
CLI configure validation: src/Cli.Tests/ConfigureOptionsTests.cs:747-788
Not explicitly covered from the 30/spec edge cases:

rolesFormat: "string" with a value containing spaces/commas to prove it does not split.
space-delimited with no whitespace and comma-delimited with no comma.
Bracket literal for URL key $['https://example.com/roles'].
Exact logging message/level assertions.
Explicit “header omitted and no extracted roles” behavior.
Explicit “existing default behavior unchanged when rolesPath/rolesFormat omitted” for non-Custom/AzureAD/EntraID.
3. Potential X-MS-API-ROLE spec edge: system role can still be granted
For authenticated requests, the middleware adds the system Authenticated role if the selected client role is a system role and the user is not already in it: src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs:142-157.

Authorization then accepts any header value for which User.IsInRole(...) is true: src/Core/Authorization/ClientRoleHeaderAuthorizationMiddleware.cs:63-66.

This means a Custom JWT request with no extracted token role can still use X-MS-API-ROLE: Authenticated. That is probably existing DAB behavior, but it is worth explicitly documenting/testing because the issue says the header “must not grant a role that is absent from the token.”

  1. CLI option naming differs between init and configure
    init uses the prompt/spec-style flags --auth.roles-path and --auth.roles-format: src/Cli/Commands/InitOptions.cs:112-116.

configure uses full config-path camelCase flags instead: --runtime.host.authentication.jwt.rolesPath and --runtime.host.authentication.jwt.rolesFormat: src/Cli/Commands/ConfigureOptions.cs:373-377.

This may match existing configure conventions, but if the intended user-facing requirement was literally dab configure --auth.roles-path, that is not implemented.

Copilot AI and others added 2 commits May 14, 2026 21:33
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/0680c0c9-a2d8-4982-84e2-67d88eb52b18

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/0680c0c9-a2d8-4982-84e2-67d88eb52b18

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 14, 2026

@copilot apply changes based on the comments in this thread

Also Gaps / concerns
...

Applied the follow-up changes in a233d35: schema defaults are now restored conditionally for Custom JWT, additional role parsing/client-role/default-behavior tests were added, and the CLI validation message was generalized. dab configure keeps the existing full config-path option naming convention rather than adding init aliases.

@JerryNixon
Copy link
Copy Markdown
Contributor

@copilot review #3078 and ensure spec is completely followed

Copilot AI and others added 2 commits May 19, 2026 00:47
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/8aac18ae-11b1-4e98-9851-10b605264663

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Agent-Logs-Url: https://github.com/Azure/data-api-builder/sessions/8aac18ae-11b1-4e98-9851-10b605264663

Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 19, 2026

@copilot review #3078 and ensure spec is completely followed

Reviewed issue #3078 and aligned the PR with the spec in cbb9148: added kebab-case config/schema/CLI options, roles-delimiter, delimited-string, mixed bracket paths, extraction only when X-MS-API-ROLE is present, 403 behavior for missing requested role claims, and targeted coverage for those cases.

Copilot AI changed the title Add Custom JWT role path and format support Add Custom JWT role path, format, and delimiter support May 19, 2026
@JerryNixon JerryNixon requested a review from Copilot May 19, 2026 16:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 3 comments.

Comment thread src/Service/Startup.cs
Comment on lines +998 to +1000
string jwtAuthenticationScheme = authOptions.IsCustomAuthenticationProvider()
? GenericOAuthDefaults.AUTHENTICATIONSCHEME
: JwtBearerDefaults.AuthenticationScheme;
Comment on lines +85 to +97
if (extractionResult != RoleExtractionResult.Success)
{
LogRoleExtractionFailure(context, authOptions, requestedRole[0]!, extractionResult, logger);
if (extractionResult == RoleExtractionResult.InvalidClaimValue)
{
context.Fail("Unable to extract configured JWT roles.");
}

return;
}

ReplaceRoleClaims(context.Principal, roles);
};
roleString => roleString.Split(rolesDelimiter, StringSplitOptions.None));

default:
logger.LogError("Roles claim at '{path}' has unsupported type '{type}'. Expected {expected}.", rolesPath, rolesFormat, "array, string, or delimited-string");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enh]: Custom JWT paths for Claims

3 participants