Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add JWE support #440

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ reporting bugs, providing fixes, suggesting useful features or other:
Eduardo Gonçalves <https://github.com/Dudssource>
Thorsten Fleischmann <https://github.com/thorstenfleischmann>
Tilmann Hars <https://github.com/usysrc>
Chris Frodo <https://github.com/chrisFrodo>
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,115 @@ http {
}
```

## Sample Configuration for Keycloak OpenID Connect authentication with encrypted tokens

Sample `nginx.conf` configuration to authenticate using OpenID Connect against a Keycloak 18.0 Authorization Server.
With this configuration, the authorization server returns encrypted tokens for each OIDC tokens.

```nginx
events {
worker_connections 128;
}

http {

lua_package_path '~/lua/?.lua;;';

resolver 8.8.8.8;

lua_ssl_trusted_certificate /opt/local/etc/openssl/cert.pem;
lua_ssl_verify_depth 5;

# cache for validation results
lua_shared_dict introspection 10m;

lua_package_cpath "/usr/local/include/lua/?.so;;";
lua_package_path "/usr/local/openresty/luajit/share/lua/?.lua;/usr/local/lib/lua/?.lua;/usr/local/share/lua/5.1/?.lua;";
lua_shared_dict discovery 1m;
lua_shared_dict jwks 1m;

server {
# General settings
listen 443 ssl;
root /var/www/html;
resolver 127.0.0.1:5353;

# Logs settings
access_log /etc/nginx/log/app-access.log;
error_log /etc/nginx/log/app-error.log;

# SSL Settings
ssl_certificate /etc/nginx/keys/tls.crt;
ssl_certificate_key /etc/nginx/keys/tls.key;
ssl_verify_depth 2;
ssl_trusted_certificate /etc/nginx/ca/caBundle.crt;

location = /favicon.ico {
log_not_found off;
}

# Publish a statically generated JWKS to be used by the OP
location /public/jwks.json {
alias /etc/nginx/public/jwks.json;

}

location /secure {
access_by_lua_block {
local opts = {
redirect_uri_path = "/secure/redirect_uri",
discovery = "https://<openid-service-domain>/realms/<realm>/.well-known/openid-configuration",
client_id = "<client-id>",
-- Set the client to use JWS as an authentication method
token_endpoint_auth_method = "private_key_jwt",
client_rsa_private_key =[[
MIIEogIBAAKCAQEAiThmpvXBYdur716D2q7fYKirKxzZIU5QrkBGDvUOwg5izcTv
[...]
h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY=
]],
client_rsa_private_key_id = "265tDmmRsigvKPz8oygR0GcNdGX_naMP2cEGXR9Ueo0",


-- Encryption settings
-- client_rsa_private_enc_key is the RSA private key to be used to decrypt the JWE access_token / id_token generated by OP to authenticate the lua-resty-openidc RP.
-- client_rsa_private_enc_key_id is the key id to be set in the JWE header to identify which public key the OP has use to encrypt the access_token / id_token.
client_rsa_private_enc_key = [[
-----BEGIN PRIVATE KEY-----
MIIJKgIBAAKCAgEA1jhYqRlY7WiW36fzdFo4dxkwQXQhouhDlqJSu5MRiaPpwVLn
[...]
5zgUDtKKOXrDePay6pcaqjLKRc2nB8ljeNpYGsrQHAiK20EckOjHJZoH+1dy0Q==
-----END PRIVATE KEY-----
]],
client_rsa_private_enc_key_id = "AAAtDmmRsigvKPz8oygR0GcNdGX_naMP2cEGXR9Ueo0",
-- end encryption settings


redirect_uri_scheme = "https",
session_contents = {id_token=true, user=true, enc_id_token=true, access_token=true},
token_signing_alg_values_expected = {"RS256"},
ssl_verify = "yes",
scope = "openid email profile",

logout_path = "/secure/logout",
redirect_after_logout_uri = "https://<openid-service-domain>/realms/<realm>/protocol/openid-connect/logout",
redirect_after_logout_with_id_token_hint = false,
post_logout_redirect_uri = "https://<website-domain>/"

}
local openidc = require("resty.openidc")
local res, err = openidc.authenticate(opts)
if err then
ngx.status = 403
ngx.say(err)
ngx.exit(ngx.HTTP_FORBIDDEN)
end
ngx.req.set_header('REMOTE_USER', res.id_token.email)
}
}
}
}
```

## Logging

Logging can be customized, including using custom logger and remapping OpenIDC's
Expand Down
95 changes: 89 additions & 6 deletions lib/resty/openidc.lua
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ local function openidc_parse_json_response(response, ignore_body_on_success)

if not res then
err = "JSON decoding failed"

end
end

Expand Down Expand Up @@ -637,7 +638,20 @@ function openidc.call_userinfo_endpoint(opts, access_token)
log(DEBUG, "userinfo response: ", res.body)

-- parse the response from the user info endpoint
return openidc_parse_json_response(res)
local json
json, err = openidc_parse_json_response(res)

-- If err, try to decode as jwt
if not json and err then
local r_jwt = require("resty.jwt")
local jwt_obj = r_jwt:load_jwt(res.body, nil)
if jwt_obj.valid then
json = jwt_obj.payload
err = nil
end
end

return json, err
end

local function can_use_token_auth_method(method, opts)
Expand Down Expand Up @@ -959,20 +973,88 @@ end
local function openidc_load_jwt_and_verify_crypto(opts, jwt_string, asymmetric_secret,
symmetric_secret, expected_algs, ...)
local r_jwt = require("resty.jwt")
local enc_hdr, enc_payload, enc_sign = string.match(jwt_string, '^(.+)%.(.+)%.(.*)$')
if enc_payload and (not enc_sign or enc_sign == "") then
local jwt = openidc_load_jwt_none_alg(enc_hdr, enc_payload)
local jwt_obj

-- Expect a JWT encoded as a JWE, extracting parts
local part1, part2, part3, part4, part5 = string.match(jwt_string, '^(.+)%.(.+)%.(.+)%.(.+)%.(.*)$')

-- No parts extracted, try to extract parts of a JWS encoded JWT
if not part1 and not part2 then
part1, part2, part3= string.match(jwt_string, '^(.+)%.(.+)%.(.*)$')
part4, part5 = nil
end

log(DEBUG, "part 1 : ",part1, ", part 2 : ",part2, ", part 3 : ",part3, ", part 4 : ",part4, ", part 5 : ",part5)
-- Determine type of JWT (simple JWT, JWS, JWE) :

-- Case : is a simple JWT
if part1 and not part2 and not part3 and not part4 and not part5 then
return nil, "token is not secured, it's a simple JWT."

-- Case : is an unsigned JWS
elseif part1 and part2 and (not part3 or part3 == "") and not part4 and not part5 then
-- part1 = JOSE Header, part2 = Payload, part3 = Signature, others are unused
local jwt = openidc_load_jwt_none_alg(part1, part2)
if jwt then
if opts.accept_none_alg then
log(DEBUG, "accept JWT with alg \"none\" and no signature")
return jwt
else
return jwt, "token uses \"none\" alg but accept_none_alg is not enabled"
end
end -- otherwise the JWT is invalid and load_jwt produces an error
else
-- Return error when token look like a JWT or the token is unsigned but shouldn't (alg other than \"none\"")
return nil, "invalid unsigned jwt"
end

-- Case : is a signed JWS
elseif part1 and part2 and part3 and not part4 and not part5 then
-- part1 = JOSE Header, part2 = Payload, part3 = Signature, others are unused
jwt_obj = r_jwt:load_jwt(jwt_string, nil)

-- Case : is a JWE, without or with preshared key
elseif (part1 and part2 and part3 and part4 and not part5) or
(part1 and part2 and part3 and part4 and part5) then
-- part1 = JOSE Header, part2 = Initialization Vector, part3 = Cyphertext, part4 = Authentication Tag , others are unused
-- or
-- part1 = JOSE Header, part2 = Pre-shared key, part3 = Initialization Vector, part4 = Cyphertext, part5 = Authentication Tag
local jwe_header = cjson.decode(unb64(part1))
Copy link
Collaborator

Choose a reason for hiding this comment

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

shouldn't that be urlsafe base64 decoding, i.e. openidc_base64_url_decode? Also it may be good to use cjson_s and check for an error.

local jwe_obj = nil


-- Limiration imposed by lua-resty-jwt v0.2.3 :
-- the "alg" must be either "RSA-OAEP-256" or "DIR" (function parse_jwe in lib jwt.lua, line 256 )
if not jwe_header.alg then
return nil, "jwe_header is missing the \"alg\" parameter"
elseif jwe_header.alg == "RSA-OAEP-256" then
if not opts.client_rsa_private_enc_key then
return nil, "OIDC config is missing a private RSA key"
elseif not opts.client_rsa_private_enc_key_id then
return nil, "OIDC config is missing a private RSA kid"
end

if jwe_header.kid == opts.client_rsa_private_enc_key_id then
jwe_obj = r_jwt:load_jwt(jwt_string, opts.client_rsa_private_enc_key)
-- Test if JWE payload exist or not
if jwe_obj.payload == nil and jwe_obj.internal ~= nil then
jwt_obj = r_jwt:load_jwt(jwe_obj.internal.json_payload, nil)
elseif type(jwe_obj.payload) == 'string' then
jwt_obj = r_jwt:load_jwt(jwe_obj.payload, nil)
elseif type(jwe_obj.payload) == 'table' then
return nil, "jwe_payload must be signed before beeing encrypted"
else
return nil, "jwe token cannot be decrypted"
end
else
return nil, "jwe_header.kid not matching client_rsa_private_enc_key_id"
end
else
return nil, "jwe_header.alg not supported by the jwt.lua library"
end
else
return nil, "invalid jwt"
end

local jwt_obj = r_jwt:load_jwt(jwt_string, nil)
if not jwt_obj.valid then
local reason = "invalid jwt"
if jwt_obj.reason then
Expand Down Expand Up @@ -1168,6 +1250,7 @@ local function openidc_authorization_response(opts, session)
log(ERROR, "error calling userinfo endpoint: " .. err)
elseif user then
if id_token.sub ~= user.sub then

err = "\"sub\" claim in id_token (\"" .. (id_token.sub or "null") .. "\") is not equal to the \"sub\" claim returned from the userinfo endpoint (\"" .. (user.sub or "null") .. "\")"
log(ERROR, err)
else
Expand Down
Loading