Server Admin Access Via JWT Claims vs Explicit Configuration
Introduction
I have implemented JWT authentication. I noticed that:
(1) Users configured as server administrators are not granted server administrator privileges when authenticating via JWT unless "_admin" is included in the JWT payload roles parameter.
(2) JWT authenticated users having "_admin" included in the JWT payload roles parameter are allowed server admin privileges even though this user, or sub in JWT parlance, is not configured as a server admin.
When I say "configured as a server admin" I mean that the user will be listed in CouchDB's response to the /_node/{node-name}/_config/admins endpoint.
Abstract
Server admin privileges should be subject to highest security measures. Double checking a JWT claim of a users _admin role against the actual configured system admins seems worth consideration. Perhaps an even better solution is to ignore JWT claims of _admin altogether.
Requirements Language
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in RFC 2119.
Terminology
JWT, JSON Web Token
Server admin, user is listed in response to the _node/_local/_config/admins/ endpoint
Detailed Description
I can think of two possible enhancements:
- CouchDB SHALL validate any JWT claim of
subhavingroleincluding_adminagainst configured system administrators. - CouchDB SHALL ignore JWT claims of
role:["_admin"]but grant server administrator privileges if the user,sub, is a configured server administrator. This solution seems best since JWT payloads can be viewed by anyone and tokens including admin access are particularly interesting to attackers.
Advantages and Disadvantages
The advantage of this additional security check is that a compromised JWT, while not probable, would disallow an attacker server admin status.
Key Changes
This existing behavior of trusting _admin role claims is not explicitly documented therefore users may not notice the change. A meaningful HTTP error message such as User is not configured as a server admin. would enable adoption.
Applications and Modules affected
[chttpd]
[admins]
HTTP API additions
A meaningful HTTP error message such as User is not configured as a server admin.
HTTP API deprecations
None.
Security Considerations
CouchDB security would be enhanced by implementing this additional validation of _admin claims, or even better by ignoring (and perhaps discouraging via documentation) this JWT role claim altogether.
References
https://docs.couchdb.org/en/stable/api/server/authn.html#jwt-authentication https://docs.couchdb.org/en/stable/intro/security.html#creating-a-new-admin-user
Acknowledgements
Thanks to the CouchDB team for creating a great product!
The JWT handler (and the much older proxy handler) exists to externalise authentication/authorization decisions, including whether a user has admin privileges. The CouchDB administrator needs to make several privileged changes to enable this behaviour. Enabling the handler itself, and then populating [jwt_keys] they trust to issue JWT tokens.
I'd be happy to merge an enhancement to the handler that rejects JWT tokens with the _admin role as an option (off by default for backward compatibility reasons), if you were so minded.
I think what won't work is 'validating' the token against the [admins] in the .ini file as that only contains a (salted) password hash; the JWT obviously doesn't include the password that we'd hash to compare it against. Perhaps you have some other idea in mind for this double-check? (though it can't be solely on the name of the admin).
There is value in validating that the sub is configured as a server admin. The particular server admin password can exist even if not used/read for JWT authentication.
As an aside, understood regarding the provenance of the JWT handler. Authorization, or role based access, is configured on CouchDB no matter what, e.g. each database's _security endpoint, or permissions. Mapping users to CouchDB roles seems like it should always exist on CouchDB and developers can architect in this fashion as-is via design documents as far as I understand.
There is value in validating that the sub is configured as a server admin
How do you propose doing so? (usernames are not secrets).
I do not understand your aside.
Server admin usernames are only available to server admins.
Upon HTTP request,
- read/validate JWT
- if
subroleexists, splice/remove_adminfrom roles - for each configured server admin
3a. If
subequals userID push_admintosubrolesarray
hm, nope, I can't go for that. usernames are not secrets (even if the list of usernames is access controlled).
That leaves us with an option to simply reject JWT tokens with the _admin role as an option.
The JWT signature acts as the password. Couch verifying that the sub is configured as an _admin does not make a username a secret or a password but adds additional security in that server admins must be explicitly configured.
The option to reject _admin claims is valuable.
we added a admin_roles config. feel free to use/adapt the below (needs rebase)
diff --git a/src/couch/src/couch_httpd_auth.erl b/src/couch/src/couch_httpd_auth.erl
index 24a0c15ed..07d4df4f3 100644
--- a/src/couch/src/couch_httpd_auth.erl
+++ b/src/couch/src/couch_httpd_auth.erl
@@ -230,21 +230,10 @@ jwt_authentication_handler(Req) ->
case lists:keyfind(<<"sub">>, 1, Claims) of
false ->
throw({unauthorized, <<"Token missing sub claim.">>});
- {_, User} ->
- Req#httpd{
- user_ctx = #user_ctx{
- name = User,
- roles = couch_util:get_value(
- ?l2b(
- config:get(
- "jwt_auth", "roles_claim_name", "_couchdb.roles"
- )
- ),
- Claims,
- []
- )
- }
- }
+ {_, User} -> Req#httpd{user_ctx=#user_ctx{
+ name = User,
+ roles = jwt_roles(Claims)
+ }}
end;
{error, Reason} ->
throw(Reason)
@@ -253,6 +242,31 @@ jwt_authentication_handler(Req) ->
Req
end.
+jwt_roles(Claims) ->
+ RolesClaimName = ?l2b(config:get("jwt_auth", "roles_claim_name", "_couchdb.roles")),
+ JWTRoles = couch_util:get_value(RolesClaimName, Claims, []),
+ case jwt_admin_roles() of
+ [] -> lists:usort(JWTRoles);
+ Roles -> jwt_verify_admin_roles(JWTRoles, Roles)
+ end.
+
+jwt_verify_admin_roles(JWTRoles, Roles) ->
+ VerifyFun = fun(JWTRole) -> lists:member(JWTRole, Roles) end,
+ case lists:any(VerifyFun, JWTRoles) of
+ true -> lists:usort([<<"_admin">> | JWTRoles]);
+ false -> lists:usort(JWTRoles)
+ end.
+
+jwt_admin_roles() ->
+ AdminRoles = string:split(config:get("jwt_auth", "admin_roles", ""), ",", all),
+ lists:filtermap(fun jwt_filter_map_admin_role/1, AdminRoles).
+
+jwt_filter_map_admin_role(Role) ->
+ case string:is_empty(Role) of
+ true -> false;
+ false -> {true, ?l2b(Role)}
+ end.
+
get_configured_claims() ->
Claims = config:get("jwt_auth", "required_claims", ""),
Re = "((?<key1>[a-z]+)|{(?<key2>[a-z]+)\s*,\s*\"(?<val>[^\"]+)\"})",
another alternative would be to use JMESPath to evaluate the _admins https://github.com/thehangedman/jmespath-erlang
the below was added to a POC of grafana
admin_attribute_path = contains(groups[*], 'myawesomeorg:admins')
role_attribute_path = contains(groups[*], 'myawesomeorg:admins') && 'Admin' || contains(groups[*], 'myawesomeorg:eks') && 'Editor' || 'Viewer'