SID and Host Verification
Problem
GitHub issue https://github.com/lightapi/portal-service/issues/39 reports that
config-server accepted a valid JWT whose service identity did not match the
requested service configuration.
The reported token contains:
{
"iss": "urn:com:networknt:oauth2:v1",
"aud": "urn:com.networknt",
"cid": "019e2825-146d-7a00-b0e8-3671158bb32a",
"scp": ["portal.r", "portal.w"],
"host": "01964b05-552a-7c4b-9184-6857e7f3dc5f",
"sid": "com.networknt.light-gateway-1.0.0"
}
The request used a different service id:
serviceId=com.networknt.ai.gateway-1.0.0
The token is cryptographically valid, but it must not authorize access to a
different service’s configuration. Signature, issuer, audience, and scope
validation prove that the token is valid; they do not prove that the token is
valid for the requested host and serviceId.
The security contract for runtime service tokens is now:
token.host == requested host context
token.sid == requested service context
token.env == requested environment context, when envTag is present
For config-server, the requested host context is the host query parameter.
For controller registry registration, the requested host context is the
controller’s configured hostId, because service/register does not carry a
separate hostId.
Original Gaps
Rust portal-service/apps/config-server
The Rust config server verifies the bearer token through JwtVerifier and binds
the decoded claims into each handler:
#![allow(unused)]
fn main() {
async fn get_configs(
State(state): State<AppState>,
_claims: Claims,
Query(query): Query<ConfigQuery>,
) -> Response
}
The handlers then read host and service_id from the query and call the read
model. They did not compare token host with query.host, and did not compare
token sid with query.service_id.
Affected endpoints:
GET /config-server/configs
GET /config-server/certs
GET /config-server/files
Java light-config-server
The Java config server routes the same three endpoints through the default
chain, which includes JwtVerifyHandler. The Light-4j security handler stores
the verified JwtClaims in AUDIT_INFO under Constants.SUBJECT_CLAIMS.
The business handlers then read host and serviceId from query parameters
and call the database helpers. They did not compare token host with request
host, and did not compare token sid with request serviceId.
Affected handlers:
ConfigsGetHandler
CertsGetHandler
FilesGetHandler
Related Controller Registry Behavior
The controller registry paths already perform service identity binding during
runtime registration, but they must use the same strict sid and host
contract as config-server.
The registry request carries serviceId in the service/register payload. It
does not carry a separate hostId; registry writes are scoped to the
controller’s configured host id. The integration token must carry both sid
and host. The registration must be rejected before the runtime instance is
stored when the token has no sid, a blank sid, or a sid that differs from
the requested serviceId. It must also be rejected when the token has no
host, a blank host, or a host that differs from the configured controller
host id.
sub is not an acceptable fallback for registry authorization. It can still be
used by other OAuth flows as the subject, but the registry authorization check
must bind the explicit service authorization claim:
token.sid == register.params.serviceId
token.host == controller.config.hostId
In controller-rs, this rule belongs in ServiceJwtVerifier::validate, before
handle_socket persists the runtime instance. In Java light-controller, it
belongs in ServiceJwtValidator.validateServiceToken, called by
MicroserviceEndpoint.register with the requested serviceId.
The controller implementations should check env when the request provides
envTag, but that check is an additional constraint. It does not replace the
mandatory sid to serviceId comparison or the mandatory host to controller
hostId comparison.
Security Requirement
For any controller registry request or config-server request that asks for a
service-scoped resource with serviceId, the token must contain a sid claim
equal to that requested serviceId.
For any config-server request with a host query parameter, the token must
contain a host claim equal to that requested host.
For any controller registry service/register request, the token must contain
a host claim equal to the controller’s configured hostId.
For any config-server request or controller registry service/register request
with a non-blank envTag, the token must contain an env claim equal to that
requested envTag.
The request should be rejected when:
serviceIdis present and non-blank, but tokensidis missing.serviceIdis present and non-blank, but tokensidis blank.serviceIdis present and non-blank, but tokensiddiffers from it.hostis present and non-blank, but tokenhostis missing.hostis present and non-blank, but tokenhostis blank.hostis present and non-blank, but tokenhostdiffers from it.envTagis present and non-blank, but tokenenvis missing.envTagis present and non-blank, but tokenenvis blank.envTagis present and non-blank, but tokenenvdiffers from it.
The request may continue to use the existing product-level path when no
serviceId is supplied. Product-level requests are not service-scoped and
should not be forced to match sid until the product-level authorization model
is explicitly designed.
The same exception does not apply to controller registry registration because
service/register always carries a requested serviceId.
Goals
- Prevent one service token from downloading another service’s config, certs, or files.
- Prevent one service token from registering a runtime instance for another service id.
- Implement the same authorization rule in Rust
config-serverand Javalight-config-server. - Implement the same authorization rule in Rust
controller-rsand Javalight-controller. - Keep existing JWT signature, issuer, audience, and scope checks unchanged.
- Keep the rule local to config-server handlers, because only they know the
requested
hostandserviceId. - Return a clear authorization failure before any database lookup is executed.
Implemented Behavior
The implementation applies the same authorization contract in all four runtime paths:
token.host == requested host context
token.sid == requested service context
The implemented paths are:
controller-rs/src/auth.rs
light-controller/src/main/java/com/networknt/controller/auth/ServiceJwtValidator.java
portal-service/crates/internal-auth/src/lib.rs
portal-service/apps/config-server/src/main.rs
light-config-server/src/main/java/com/networknt/configserver/util/ServiceIdAuthorizationUtil.java
For Rust controller-rs, ServiceJwtVerifier::validate now requires a
non-blank sid and compares it to the registration serviceId. It also
requires a non-blank host and compares it to the controller hostId. When
registration includes envTag, it requires token env and compares it to that
envTag.
For Java light-controller, ServiceJwtValidator.validateServiceToken applies
the same checks when MicroserviceEndpoint.register passes the requested
serviceId.
For Rust config-server, internal-auth::Claims now exposes explicit optional
sid, host, and env fields. The configs, certs, and files handlers
call authorize_request_context before invoking the read model.
For Java light-config-server, ServiceIdAuthorizationUtil extracts verified
claims from AUDIT_INFO and applies the same host and SID checks before
ConfigsGetHandler, CertsGetHandler, or FilesGetHandler calls the database
helper.
The focused implementation tests cover missing and mismatched host, missing
and mismatched sid, missing and mismatched env when envTag is requested,
blank values, whitespace trimming, and case-sensitive identifier comparison.
Non-Goals
- Do not replace JWT verification middleware.
- Do not redesign OAuth token issuance.
- Do not require
sidfor product-level requests that do not carryserviceId. - Do not require
envwhen the request omitsenvTag. - Do not trust request headers such as
X-Service-Idas a substitute for the JWT claim. - Do not use
subas a fallback for service-scoped controller registry or config-server authorization.
Token Contract
Trusted service tokens used for config-server startup access should include:
{
"host": "<host-id>",
"sid": "<service-id>",
"env": "<optional-environment>"
}
sid is the runtime service id that the token is allowed to bootstrap. For
example:
{
"sid": "com.networknt.ai.gateway-1.0.0"
}
sid must be treated as a reserved authorization claim. It should be generated
from trusted client configuration or a trusted token request path, not from
unvalidated caller input.
Request Contract
For service-scoped requests:
GET /config-server/configs?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev
GET /config-server/certs?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev
GET /config-server/files?host=...&serviceId=com.networknt.ai.gateway-1.0.0&envTag=dev
The authorization rule is:
token.host == request.host
token.sid == request.serviceId
The comparisons should trim surrounding whitespace but should otherwise be exact and case-sensitive. Host ids and service ids are identifiers, not display names.
For requests without serviceId, the SID rule is not applied:
GET /config-server/configs?host=...&productId=lg&productVersion=1.5.1&envTag=dev
Those requests should continue through the existing product-level behavior, but the host binding rule still applies:
token.host == request.host
For any request with a non-blank envTag, including product-level requests,
the environment binding rule also applies:
token.env == request.envTag
Recommended Design
Add a small config-server authorization helper in both implementations, and
tighten the controller registry validators to use the same sid binding rule.
The helper should accept the decoded JWT claims and the parsed query object, and return either:
- success when there is no service-scoped request or the
sidmatches - an authorization response when the request is service-scoped and invalid
Pseudo logic:
requestedHost = trim(query.host)
tokenHost = trim(claim.host)
if tokenHost is empty:
reject 403
if tokenHost != requestedHost:
reject 403
requestedServiceId = trim(query.serviceId)
if requestedServiceId is not empty:
tokenServiceId = trim(claim.sid)
if tokenServiceId is empty:
reject 403
if tokenServiceId != requestedServiceId:
reject 403
requestedEnvTag = trim(query.envTag)
if requestedEnvTag is not empty:
tokenEnv = trim(claim.env)
if tokenEnv is empty:
reject 403
if tokenEnv != requestedEnvTag:
reject 403
allow
Run this check before getSnapshotConfigs, getSnapshotCerts,
getSnapshotFiles, or any live config query helper.
For controller registry registration, serviceId is not optional and the
expected host is the controller’s configured hostId. The same comparisons
should run after signature, issuer, and audience validation and before any
runtime instance lookup or persistence. A valid sub with a missing sid must
still be rejected, and a token with no host must also be rejected.
Response Status
Use 403 Forbidden for SID or host-binding failures.
The JWT has already passed authentication. The failure is authorization: the token is valid but not allowed to access the requested host or service configuration or environment.
Suggested response body:
Token sid does not match requested serviceId
Token host does not match requested host
Token env does not match requested envTag
Avoid echoing the full token or all claims in the response. Logging the
requested host, token host, requested serviceId, and token sid at warn
level is useful for operations. When envTag is present, also log requested
envTag and token env.
Controller Implementation
Rust controller-rs
ServiceJwtVerifier::validate now makes service registration read only
claims.sid, trims it, rejects blank or missing values, and compares it with
ServiceRegistrationParams.service_id.
The same validation path requires claims.host, trims it, and compares
it with Settings.host_id. Do not fall back to claims.sub for registry
authorization.
When ServiceRegistrationParams.env_tag is present and non-blank, the same
validation path requires a non-blank claims.env and compares it with the
requested envTag.
The WebSocket registration tests cover:
- a token with
sidand nosubstill registers - a token with matching
subbut missingsidis rejected - a token with matching
subbut mismatchedsidis rejected - a token with missing or mismatched
hostis rejected - a request with
envTagand missing or mismatched tokenenvis rejected
Java light-controller
ServiceJwtValidator.validateServiceToken now requires sid when
MicroserviceEndpoint.register passes a requested serviceId, and compares it
with that serviceId.
The validator also requires host and compares it with
ControllerRuntimeConfig.hostId. Do not fall back to JwtClaims.getSubject()
for registry authorization.
When envTag is present and non-blank, the validator also requires env and
compares it with that envTag.
The registration test token builders now include sid and host for normal
service JWTs. Regression tests cover missing and mismatched sid, plus missing
and mismatched host, plus missing and mismatched env when envTag is
requested.
Rust Config-Server Implementation
Claims
internal-auth::Claims now exposes sid and host as explicit optional
fields:
#![allow(unused)]
fn main() {
pub sid: Option<String>,
pub host: Option<String>,
pub env: Option<String>,
}
This keeps the authorization path readable and avoids treating sid and
host as generic extension claims. They are first-class authorization claims
for config-server and controller runtime access.
Handler Flow
Each handler uses claims rather than _claims:
#![allow(unused)]
fn main() {
async fn get_configs(
State(state): State<AppState>,
claims: Claims,
Query(query): Query<ConfigQuery>,
) -> Response {
if let Err(response) = authorize_request_context(
&claims,
&query.host,
query.service_id.as_deref(),
query.env_tag.as_deref(),
) {
return response;
}
...
}
}
The shared helper is:
#![allow(unused)]
fn main() {
fn authorize_request_context(
claims: &Claims,
requested_host: &str,
requested_service_id: Option<&str>,
requested_env_tag: Option<&str>,
) -> Result<(), Response>
}
Apply the helper to:
get_configs
get_certs
get_files
Rust Tests
The helper tests cover:
- allows a matching
sid - allows a matching
host - allows an absent
serviceId - rejects missing
host - rejects mismatched
host - rejects missing
sidwhenserviceIdis present - rejects mismatched
sid - allows absent
envTag - rejects missing
envwhenenvTagis present - rejects mismatched
env - trims surrounding whitespace
- preserves case-sensitive matching
If the handlers are tested directly, add endpoint-level regressions that prove
mismatched host or sid returns 403 before the read model is called.
Java Config-Server Implementation
Claims Source
The Light-4j JwtVerifyHandler places the verified claims in:
Map<String, Object> auditInfo =
exchange.getAttachment(AttachmentConstants.AUDIT_INFO);
JwtClaims claims =
(JwtClaims)auditInfo.get(Constants.SUBJECT_CLAIMS);
The shared helper in light-config-server is:
com.networknt.configserver.util.ServiceIdAuthorizationUtil
Implemented API:
public static String authorizeRequestContext(
HttpServerExchange exchange,
String requestedHost,
String requestedServiceId,
String requestedEnvTag
)
public static String authorizeRequestContext(
JwtClaims claims,
String requestedHost,
String requestedServiceId,
String requestedEnvTag
)
The exchange overload extracts verified claims from AUDIT_INFO. The claims
overload is used by focused unit tests. Both methods return null on success
or a short error message when the request must be rejected with 403.
Handler Flow
At the top of each handler, after reading query parameters and before calling the DB helper:
String authorizationError =
ServiceIdAuthorizationUtil.authorizeRequestContext(exchange, host, serviceId, envTag);
if (authorizationError != null) {
exchange.setStatusCode(StatusCodes.FORBIDDEN);
exchange.getResponseSender().send(authorizationError);
return;
}
Apply the helper to:
ConfigsGetHandler
CertsGetHandler
FilesGetHandler
Java Tests
Focused unit tests cover:
- allows matching
sid - allows matching
host - allows blank
serviceId - rejects missing claims when
hostis present - rejects missing
host - rejects mismatched
host - rejects missing claims when
serviceIdis present - rejects missing
sid - rejects mismatched
sid - allows blank
envTag - rejects missing
envwhenenvTagis present - rejects mismatched
env - trims surrounding whitespace
- remains case-sensitive
Handler-level coverage can be added later if the test harness can cheaply inject
AUDIT_INFO. The first implementation relies on focused helper tests plus the
existing handler request coverage.
Token Issuance Check
This change depends on runtime service tokens carrying sid for service-scoped
startup access and controller registry registration. Before deploying the
authorization check broadly, verify the Light OAuth token path used by runtime
services.
For long-lived or trusted client_credentials runtime tokens:
- token custom claims should include
host - token custom claims should include
sid - token custom claims may include
env, but must includeenvfor runtimes that call config-server or controller withenvTag
If a runtime cannot mint a token with host and sid, it should fail early
during token setup rather than be allowed to call config-server or register
with controller using a broader token.
Backward Compatibility
This is a security-tightening change. It can break clients that currently call
config-server with a serviceId or register with controller while using a
token that has no host or sid. It can also break clients that pass
envTag while using a token with no matching env.
Recommended rollout for deployments that do not already mint service tokens
with host and sid:
- Verify runtime token issuance includes
hostandsid. Verifyenvis included whenever the runtime sendsenvTag. - Enable the rule by default in Rust and Java, because config-server returns sensitive config and cert material and controller registry defines runtime service identity.
- For one release, monitor explicit warning logs on host or SID failures.
- Update local and enterprise runtime token setup docs so service tokens carry
hostandsid.
If a temporary compatibility switch is required, make it explicit and narrow:
enforceSidHostMatch: true
Do not silently ignore mismatches in production deployments.
Error Handling
Use 403 Forbidden for:
- missing
sidwith requestedserviceId - mismatched
sid - missing
hostwith requestedhostor controllerhostId - mismatched
host - missing
envwith requestedenvTag - mismatched
env - missing decoded claims in Java after the security chain has supposedly run
Use existing 401 Unauthorized behavior for:
- missing Authorization header
- invalid token signature
- invalid issuer or audience
- expired token
This keeps authentication failures separate from service authorization failures.
Observability
On rejection, log:
requestedServiceId
tokenSid
requestedHost
tokenHost
envTag
tokenEnv
endpoint
Do not log the full JWT.
The log should make the exact issue visible:
Token sid com.networknt.light-gateway-1.0.0 does not match requested serviceId com.networknt.ai.gateway-1.0.0
Token host 01964b05-552a-7c4b-9184-6857e7f3dc5f does not match requested host 01964b05-552a-7c4b-9184-6857e7f3dc5e
Token env dev does not match requested envTag prod
Validation Checklist
After implementation, validate these cases against Rust and Java config-server:
sid=A, serviceId=A => 200
sid=A, serviceId=B => 403
sid missing, serviceId=A => 403
host=H1, request host=H1 => 200
host=H1, request host=H2 => 403
host missing, request host=H1 => 403
sid=A, serviceId omitted, productId/productVersion supplied, host matches => existing behavior
env=dev, envTag=dev => 200
env=dev, envTag=prod => 403
env missing, envTag=dev => 403
env missing, envTag omitted => existing behavior
invalid JWT => 401
missing JWT => 401
Also verify the three endpoint families:
/config-server/configs
/config-server/certs
/config-server/files
Validate the same service identity cases against Rust and Java controller registry registration:
token sid=A, register serviceId=A => registered
token sid=A, register serviceId=B => registration rejected
token sid missing, register serviceId=A, token sub=A => registration rejected
token sid blank, register serviceId=A => registration rejected
token host=H1, controller hostId=H1 => registered
token host=H1, controller hostId=H2 => registration rejected
token host missing, controller hostId=H1 => registration rejected
token env=dev, register envTag=dev => registered
token env=dev, register envTag=prod => registration rejected
token env missing, register envTag=dev => registration rejected
token env missing, register envTag omitted => existing behavior
invalid JWT => registration rejected
Focused verification commands used during implementation:
cargo test -p config-server authorize_request_context
cargo test microservice_registration_rejects
cargo test microservice_registration_uses_jwt_env_when_request_omits_env_tag
mvn -q -Dtest=ControllerWebSocketIntegrationTest#rejectsMicroserviceJwtWhenHostClaimIsMissing+rejectsMicroserviceJwtWhenHostClaimDiffersFromControllerHostId+rejectsMicroserviceJwtWhenSidIsMissing+rejectsMicroserviceJwtWhenSidDiffersFromServiceId+rejectsMicroserviceJwtWhenEnvClaimIsMissingAndEnvTagIsRequested+rejectsMicroserviceJwtWhenEnvClaimDiffersFromEnvTag+registersMicroserviceWhenEnvTagAndEnvClaimAreOmitted test
mvn -q -Dtest=ServiceIdAuthorizationUtilTest test
Open Questions
No open questions for SID, host, and environment binding in this phase.