Skip to content

Commit

Permalink
Merge pull request #230 from ray66rus/ray66rus/issue-229-pass-decrypt…
Browse files Browse the repository at this point in the history
…ed-token-to-jwt-javascript-backend

Add option for sending decrypted claims to JWT Javascript backend
  • Loading branch information
iegomez committed Jun 16, 2022
2 parents d904546 + 55321df commit 9468bed
Show file tree
Hide file tree
Showing 6 changed files with 334 additions and 81 deletions.
34 changes: 21 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -880,22 +880,27 @@ The `jwt` backend is for auth with a JWT remote API, a local DB, a JavaScript VM

The following `auth_opt_` options are supported by the `jwt` backend when remote is set to true:

| Option | default | Mandatory | Meaning |
| ----------------- | --------- | :-------: | ---------------------------------- |
| jwt_host | | Y | API server host name or ip |
| jwt_port | | Y | TCP port number |
| jwt_getuser_uri | | Y | URI for check username/password |
| jwt_superuser_uri | | N | URI for check superuser |
| jwt_aclcheck_uri | | Y | URI for check acl |
| jwt_with_tls | false | N | Use TLS on connect |
| jwt_verify_peer | false | N | Whether to verify peer for tls |
| jwt_response_mode | status | N | Response type (status, json, text) |
| jwt_params_mode | json | N | Data type (json, form) |
| jwt_user_agent | mosquitto | N | User agent for requests |
| jwt_http_method | POST | N | Http method used (POST, GET, PUT) |
| Option | default | Mandatory | Meaning |
| ------------------ | --------- | :-------: | ------------------------------------------------------------- |
| jwt_host | | Y/N | API server host name or ip |
| jwt_port | | Y | TCP port number |
| jwt_getuser_uri | | Y | URI for check username/password |
| jwt_superuser_uri | | N | URI for check superuser |
| jwt_aclcheck_uri | | Y | URI for check acl |
| jwt_with_tls | false | N | Use TLS on connect |
| jwt_verify_peer | false | N | Whether to verify peer for tls |
| jwt_response_mode | status | N | Response type (status, json, text) |
| jwt_params_mode | json | N | Data type (json, form) |
| jwt_user_agent | mosquitto | N | User agent for requests |
| jwt_http_method | POST | N | Http method used (POST, GET, PUT) |
| jwt_host_whitelist | | Y/N | List of hosts that are eligible to be an authoritative server |

URIs (like jwt_getuser_uri) are expected to be in the form `/path`. For example, if jwt_with_tls is `false`, jwt_host is `localhost`, jwt_port `3000` and jwt_getuser_uri is `/user`, mosquitto will send a http request to `http:https://localhost:3000/user` to get a response to check against. How data is sent (either json encoded or as form values) and received (as a simple http status code, a json encoded response or plain text), is given by options jwt_response_mode and jwt_params_mode.

if the option `jwt_parse_token` is set to `true`, `jwt_host` can be omitted and the host will be taken from the `Issuer` (`iss` field) claim of the JWT token. In this case the option `jwt_host_whitelist` is mandatory and must contain
either a comma-separated list of the valid hostnames/ip addresses (with or without `:<port>` part) or the `*` (asterisk) symbol. If the `Issuer` claim is not contained in this list of valid hosts, the authorization will fail. Special
value `*` means "any host" and is intended for testing/development purposes only - NEVER use this in production!

If the option `jwt_superuser_uri` is not set then `superuser` checks are disabled for this mode.

For all URIs, the backend will send a request with the `Authorization` header set to `Bearer token`, where token should be a correct JWT token and corresponds to the `username` received from Mosquitto.
Expand Down Expand Up @@ -1488,13 +1493,16 @@ The `javascript` backend allows to run a JavaScript interpreter VM to conduct ch
| js_user_script_path | | Y | Relative or absolute path to user check script |
| js_superuser_script_path | | Y | Relative or absolute path to superuser check script |
| js_acl_script_path | | Y | Relative or absolute path to ACL check script |
| js_pass_claims | false | N | Pass all claims extracted from the token to check scripts |

This backend expects the user to define JS scripts that return a boolean result to the check in question.

The backend will pass `mosquitto` provided arguments along, that is:
- `username`, `password` and `clientid` for `user` checks.
- `username` for `superuser` checks.
- `username`, `topic`, `clientid` and `acc` for `ACL` checks.
If `js_pass_claims` option is set, an additional argument `claims` containing the claims data extracted
from the JWT token is passed to all checks.


This is a valid, albeit pretty useless, example script for ACL checks (see `test-files/jwt` dir for test scripts):
Expand Down
78 changes: 53 additions & 25 deletions backends/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type tokenOptions struct {
skipUserExpiration bool
skipACLExpiration bool
secret string
userField string
userFieldKey string
}

type jwtChecker interface {
Expand All @@ -27,19 +27,14 @@ type jwtChecker interface {
Halt()
}

// Claims defines the struct containing the token claims.
// StandardClaim's Subject field should contain the username, unless an opt is set to support Username field.
type Claims struct {
jwtGo.StandardClaims
// If set, Username defines the identity of the user.
Username string `json:"username"`
}

const (
remoteMode = "remote"
localMode = "local"
jsMode = "js"
filesMode = "files"
remoteMode = "remote"
localMode = "local"
jsMode = "js"
filesMode = "files"
claimsSubjectKey = "sub"
claimsUsernameKey = "username"
claimsIssKey = "iss"
)

func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashComparer, version string) (*JWT, error) {
Expand Down Expand Up @@ -69,9 +64,9 @@ func NewJWT(authOpts map[string]string, logLevel log.Level, hasher hashing.HashC
}

if userField, ok := authOpts["jwt_userfield"]; ok && userField == "Username" {
options.userField = userField
options.userFieldKey = claimsUsernameKey
} else {
options.userField = "Subject"
options.userFieldKey = claimsSubjectKey
}

switch authOpts["jwt_mode"] {
Expand Down Expand Up @@ -125,9 +120,9 @@ func (o *JWT) Halt() {
o.checker.Halt()
}

func getJWTClaims(secret string, tokenStr string, skipExpiration bool) (*Claims, error) {
func getJWTClaims(secret string, tokenStr string, skipExpiration bool) (*jwtGo.MapClaims, error) {

jwtToken, err := jwtGo.ParseWithClaims(tokenStr, &Claims{}, func(token *jwtGo.Token) (interface{}, error) {
jwtToken, err := jwtGo.ParseWithClaims(tokenStr, &jwtGo.MapClaims{}, func(token *jwtGo.Token) (interface{}, error) {
return []byte(secret), nil
})

Expand All @@ -147,29 +142,62 @@ func getJWTClaims(secret string, tokenStr string, skipExpiration bool) (*Claims,
return nil, errors.New("jwt invalid token")
}

claims, ok := jwtToken.Claims.(*Claims)
claims, ok := jwtToken.Claims.(*jwtGo.MapClaims)
if !ok {
log.Debugf("jwt error: expected *Claims, got %T", jwtToken.Claims)
log.Debugf("jwt error: expected *MapClaims, got %T", jwtToken.Claims)
return nil, errors.New("got strange claims")
}

return claims, nil
}

func getUsernameFromClaims(options tokenOptions, claims *Claims) string {
if options.userField == "Username" {
return claims.Username
func getUsernameForToken(options tokenOptions, tokenStr string, skipExpiration bool) (string, error) {
claims, err := getJWTClaims(options.secret, tokenStr, skipExpiration)

if err != nil {
return "", err
}

username, found := (*claims)[options.userFieldKey]
if !found {
return "", nil
}

usernameString, ok := username.(string)
if !ok {
log.Debugf("jwt error: username expected to be string, got %T", username)
return "", errors.New("got strange username")
}

return usernameString, nil
}

func getClaimsForToken(options tokenOptions, tokenStr string, skipExpiration bool) (map[string]interface{}, error) {
claims, err := getJWTClaims(options.secret, tokenStr, skipExpiration)
if err != nil {
return make(map[string]interface{}), err
}

return claims.Subject
return map[string]interface{}(*claims), nil
}

func getUsernameForToken(options tokenOptions, tokenStr string, skipExpiration bool) (string, error) {
func getIssForToken(options tokenOptions, tokenStr string, skipExpiration bool) (string, error) {
claims, err := getJWTClaims(options.secret, tokenStr, skipExpiration)

if err != nil {
return "", err
}

return getUsernameFromClaims(options, claims), nil
iss, found := (*claims)[claimsIssKey]
if !found {
return "", nil
}

issString, ok := iss.(string)
if !ok {
log.Debugf("jwt error: iss expected to be string, got %T", iss)
return "", errors.New("got strange iss")
}

return issString, nil
}
51 changes: 33 additions & 18 deletions backends/jwt_javascript.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ type jsJWTChecker struct {
superuserScript string
aclScript string

passClaims bool

options tokenOptions

runner *js.Runner
Expand Down Expand Up @@ -79,6 +81,10 @@ func NewJsJWTChecker(authOpts map[string]string, options tokenOptions) (jwtCheck
return nil, errors.New("missing jwt_js_acl_script_path")
}

if passClaims, ok := authOpts["jwt_js_pass_claims"]; ok && passClaims == "true" {
checker.passClaims = true
}

checker.runner = js.NewRunner(checker.stackDepthLimit, checker.msMaxDuration)

return checker, nil
Expand All @@ -90,14 +96,10 @@ func (o *jsJWTChecker) GetUser(token string) (bool, error) {
}

if o.options.parseToken {
username, err := getUsernameForToken(o.options, token, o.options.skipUserExpiration)

if err != nil {
log.Printf("jwt get user error: %s", err)
var err error
if params, err = o.addDataFromJWT(params, token, o.options.skipUserExpiration); err != nil {
return false, err
}

params["username"] = username
}

granted, err := o.runner.RunScript(o.userScript, params)
Expand All @@ -108,20 +110,37 @@ func (o *jsJWTChecker) GetUser(token string) (bool, error) {
return granted, err
}

func (o *jsJWTChecker) addDataFromJWT(params map[string]interface{}, token string, skipExpiration bool) (map[string]interface{}, error) {
claims, err := getClaimsForToken(o.options, token, skipExpiration)

if err != nil {
log.Printf("jwt get claims error: %s", err)
return nil, err
}

if o.passClaims {
params["claims"] = claims
}

if username, found := claims[o.options.userFieldKey]; found {
params["username"] = username.(string)
} else {
params["username"] = ""
}

return params, nil
}

func (o *jsJWTChecker) GetSuperuser(token string) (bool, error) {
params := map[string]interface{}{
"token": token,
}

if o.options.parseToken {
username, err := getUsernameForToken(o.options, token, o.options.skipUserExpiration)

if err != nil {
log.Printf("jwt get user error: %s", err)
var err error
if params, err = o.addDataFromJWT(params, token, o.options.skipUserExpiration); err != nil {
return false, err
}

params["username"] = username
}

granted, err := o.runner.RunScript(o.superuserScript, params)
Expand All @@ -141,14 +160,10 @@ func (o *jsJWTChecker) CheckAcl(token, topic, clientid string, acc int32) (bool,
}

if o.options.parseToken {
username, err := getUsernameForToken(o.options, token, o.options.skipACLExpiration)

if err != nil {
log.Printf("jwt get user error: %s", err)
var err error
if params, err = o.addDataFromJWT(params, token, o.options.skipACLExpiration); err != nil {
return false, err
}

params["username"] = username
}

granted, err := o.runner.RunScript(o.aclScript, params)
Expand Down

0 comments on commit 9468bed

Please sign in to comment.