diff --git a/.github/issue_template.md b/.github/issue_template.md index 63c9a494..09a74299 100644 --- a/.github/issue_template.md +++ b/.github/issue_template.md @@ -7,7 +7,7 @@ A new issue about a bug should be verified with a minimized example. ###### Environment -- lua-resty-openidc version (e.g. 1.7.3) +- lua-resty-openidc version (e.g. 1.8.0) - OpenID Connect provider (e.g. Keycloak, Azure AD) ###### Expected behaviour diff --git a/.github/workflows/docker-ci.yml b/.github/workflows/docker-ci.yml new file mode 100644 index 00000000..1d20581d --- /dev/null +++ b/.github/workflows/docker-ci.yml @@ -0,0 +1,16 @@ +name: CI + +on: [push, pull_request] + +jobs: + + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Build + run: docker build . -f tests/Dockerfile -t lua-resty-openidc/test + - name: Run + run: docker run -t --rm lua-resty-openidc/test:latest diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index faa95426..00000000 --- a/.travis.yml +++ /dev/null @@ -1,37 +0,0 @@ -language: c - -sudo: required - -services: - - docker - -env: - global: - - VERSION=1.7.3-1 - - NAME=lua-resty-openidc - - ROCKSPEC=$NAME-$VERSION.rockspec - - LUAROCKS=2.3.0 - matrix: - - LUA=lua5.1 - -before_install: - - docker build -f tests/Dockerfile . -t lua-resty-openidc/test - - source .travis/setenv_lua.sh - -install: - - luarocks install Lua-cURL --server=https://luarocks.org/dev - - luarocks install lunitx - - luarocks install JSON4Lua - -script: - - luarocks make --pack-binary-rock $ROCKSPEC CFLAGS="-O2 -fPIC -fprofile-arcs" LIBFLAG="-shared" - - docker run -it --rm lua-resty-openidc/test:latest - -# The API key comes from an environment variable configured on https://travis-ci.org -after_success: - - luarocks upload --api-key=$API_KEY $ROCKSPEC - -notifications: - email: - on_success: change - on_failure: always diff --git a/.travis/platform.sh b/.travis/platform.sh deleted file mode 100644 index 0ade2010..00000000 --- a/.travis/platform.sh +++ /dev/null @@ -1,15 +0,0 @@ -if [ -z "${PLATFORM:-}" ]; then - PLATFORM=$TRAVIS_OS_NAME; -fi - -if [ "$PLATFORM" == "osx" ]; then - PLATFORM="macosx"; -fi - -if [ -z "$PLATFORM" ]; then - if [ "$(uname)" == "Linux" ]; then - PLATFORM="linux"; - else - PLATFORM="macosx"; - fi; -fi \ No newline at end of file diff --git a/.travis/setenv_lua.sh b/.travis/setenv_lua.sh deleted file mode 100644 index a5a3e73f..00000000 --- a/.travis/setenv_lua.sh +++ /dev/null @@ -1,3 +0,0 @@ -export PATH=${PATH}:$HOME/.lua:$HOME/.local/bin:${TRAVIS_BUILD_DIR}/install/luarocks/bin -bash .travis/setup_lua.sh -eval `$HOME/.lua/luarocks path` \ No newline at end of file diff --git a/.travis/setup_lua.sh b/.travis/setup_lua.sh deleted file mode 100644 index 389c4276..00000000 --- a/.travis/setup_lua.sh +++ /dev/null @@ -1,122 +0,0 @@ -#! /bin/bash - -# A script for setting up environment for travis-ci testing. -# Sets up Lua and Luarocks. -# LUA must be "lua5.1", "lua5.2" or "luajit". -# luajit2.0 - master v2.0 -# luajit2.1 - master v2.1 - -set -eufo pipefail - -LUAJIT_VERSION="2.0.4" -LUAJIT_BASE="LuaJIT-$LUAJIT_VERSION" - -source .travis/platform.sh - -LUA_HOME_DIR=$TRAVIS_BUILD_DIR/install/lua - -LR_HOME_DIR=$TRAVIS_BUILD_DIR/install/luarocks - -mkdir $HOME/.lua - -LUAJIT="no" - -if [ "$PLATFORM" == "macosx" ]; then - if [ "$LUA" == "luajit" ]; then - LUAJIT="yes"; - fi - if [ "$LUA" == "luajit2.0" ]; then - LUAJIT="yes"; - fi - if [ "$LUA" == "luajit2.1" ]; then - LUAJIT="yes"; - fi; -elif [ "$(expr substr $LUA 1 6)" == "luajit" ]; then - LUAJIT="yes"; -fi - -mkdir -p "$LUA_HOME_DIR" - -if [ "$LUAJIT" == "yes" ]; then - - if [ "$LUA" == "luajit" ]; then - curl --location https://github.com/LuaJIT/LuaJIT/archive/v$LUAJIT_VERSION.tar.gz | tar xz; - else - git clone https://github.com/LuaJIT/LuaJIT.git $LUAJIT_BASE; - fi - - cd $LUAJIT_BASE - - if [ "$LUA" == "luajit2.1" ]; then - git checkout v2.1; - # force the INSTALL_TNAME to be luajit - perl -i -pe 's/INSTALL_TNAME=.+/INSTALL_TNAME= luajit/' Makefile - fi - - make && make install PREFIX="$LUA_HOME_DIR" - - ln -s $LUA_HOME_DIR/bin/luajit $HOME/.lua/luajit - ln -s $LUA_HOME_DIR/bin/luajit $HOME/.lua/lua; - -else - - if [ "$LUA" == "lua5.1" ]; then - curl http://www.lua.org/ftp/lua-5.1.5.tar.gz | tar xz - cd lua-5.1.5; - elif [ "$LUA" == "lua5.2" ]; then - curl http://www.lua.org/ftp/lua-5.2.4.tar.gz | tar xz - cd lua-5.2.4; - elif [ "$LUA" == "lua5.3" ]; then - curl http://www.lua.org/ftp/lua-5.3.2.tar.gz | tar xz - cd lua-5.3.2; - fi - - # Build Lua without backwards compatibility for testing - perl -i -pe 's/-DLUA_COMPAT_(ALL|5_2)//' src/Makefile - make $PLATFORM - make INSTALL_TOP="$LUA_HOME_DIR" install; - - ln -s $LUA_HOME_DIR/bin/lua $HOME/.lua/lua - ln -s $LUA_HOME_DIR/bin/luac $HOME/.lua/luac; - -fi - -cd $TRAVIS_BUILD_DIR - -lua -v - -LUAROCKS_BASE=luarocks-$LUAROCKS - -curl --location http://luarocks.org/releases/$LUAROCKS_BASE.tar.gz | tar xz - -cd $LUAROCKS_BASE - -if [ "$LUA" == "luajit" ]; then - ./configure --lua-suffix=jit --with-lua-include="$LUA_HOME_DIR/include/luajit-2.0" --prefix="$LR_HOME_DIR"; -elif [ "$LUA" == "luajit2.0" ]; then - ./configure --lua-suffix=jit --with-lua-include="$LUA_HOME_DIR/include/luajit-2.0" --prefix="$LR_HOME_DIR"; -elif [ "$LUA" == "luajit2.1" ]; then - ./configure --lua-suffix=jit --with-lua-include="$LUA_HOME_DIR/include/luajit-2.1" --prefix="$LR_HOME_DIR"; -else - ./configure --with-lua="$LUA_HOME_DIR" --prefix="$LR_HOME_DIR" -fi - -make build && make install - -ln -s $LR_HOME_DIR/bin/luarocks $HOME/.lua/luarocks - -cd $TRAVIS_BUILD_DIR - -luarocks --version - -rm -rf $LUAROCKS_BASE - -if [ "$LUAJIT" == "yes" ]; then - rm -rf $LUAJIT_BASE; -elif [ "$LUA" == "lua5.1" ]; then - rm -rf lua-5.1.5; -elif [ "$LUA" == "lua5.2" ]; then - rm -rf lua-5.2.4; -elif [ "$LUA" == "lua5.3" ]; then - rm -rf lua-5.3.2; -fi \ No newline at end of file diff --git a/AUTHORS b/AUTHORS index a848049b..1ab9e9de 100644 --- a/AUTHORS +++ b/AUTHORS @@ -2,6 +2,7 @@ The primary authors of lua-resty-openidc are: Hans Zandbelt Stefan Bodewig + Oldřich Jedlička Thanks to the following people for contributing to lua-resty-openidc by reporting bugs, providing fixes, suggesting useful features or other: @@ -34,3 +35,11 @@ reporting bugs, providing fixes, suggesting useful features or other: Joshua Erney Nick Wiedenbrueck Eduardo Gonçalves + Thorsten Fleischmann + Tilmann Hars + Junlong Li + Nate + Balaji Vijayakumar + + + diff --git a/ChangeLog b/ChangeLog index 2d064f89..bfbd6c43 100644 --- a/ChangeLog +++ b/ChangeLog @@ -1,3 +1,102 @@ +09/13/204 +- cross-tenant requests are fixed with lua-resty session 4.0.x; closes #526 +- release 1.8.0 + +09/09/2024 +- merge support for lua-resty-session 4.x; see #489; closes #464 #480 #503; thanks @oldium @balajiv113 +- add @oldium to the primary AUTHORS + +08/25/2024 +- don't return a zero-pixel image in logout for Firefox 128 and later + see #521 + +03/11/2024 +- handle the userinfo response as JWT; closes ##345; thanks @NatePlumm + +03/10/2023 +- when looking for a bearer token an exception occured if the + Authorization header didn't contain any space character; + see #473 + +02/03/2023 +- release 1.7.6-3 of luarock pinning lua-resty-session dependency to + not go beyond 3.1ß + +30/01/2023 +- release 1.7.6 + +01/13/2023 +- when parsing JWKs with an x5c claim the claim was wronly assumed to + be base64url encoded instead of base64 encoded; + see #460 + +11/06/2022 +- a new option local_redirect_path can be used is situations where the + redirect_uri as is visible to lua-resty-openidc is not simply the path + segment of the configured redirect_uri but something more + complex. This is needed for example if a reverse proxy in front of + your server adds a prefix of rewrites URIs in a more complex way; + see #453 + +03/05/2022 +- improved error message when expecting a Bearer token header and the + header doesn't contain a space character; see #421 + +01/04/2022 +- added support for OAuth 2.0 Form Post Response Mode. + +12/23/2021 +- use Github actions for docker-based CI; delete Travis files + +12/21/2021 +- release 1.7.5 + +12/17/2021 +- added id_token and the token endpoint response as additional + arguments to the on_authenticated lifecycle hook; see #413 + +11/19/2021 +- added opts.discovery_expires_in in order to make cache expiry of + OpenID Connect Discovery responses configurable. + +11/06/2021 +- added public functions that allow tokens to be revoked without + destroying the current session; see #402; thanks to + @thorstenfleischmann + +- when the x5c claim of a JWK is an empty array it will be ignored + rather than cause an error; see #406 + +- `authenticate`'s last parameter can now be an existing session + rather than options for starting a new one: see #405; thanks to + @thorstenfleischmann + +09/23/2021 +- if lifecyle handlers return truthy values they cause the operation + they are handlers of to fail; see #384; thanks to @arcivanov + +- added opts.cache_segment as option to shard the cache used by token + introspection or JWT verification; see #399 + +09/22/2021 +- made jwt_verify() and bearer_jwt_verify() use a separate cache named + "jwt_verification" and introduced opts.jwt_verification_cache_ignore + to disable caching completely; see #399 + +12/05/2020 +- fixed a session leak in access_token() and for a very unlikely + code-path in authenticate(); authenticate will still normally not + close the session as users may want to use it after the method + returns; see + https://github.com/zmartzone/lua-resty-openidc#sessions-and-locking + see #374 + +11/17/2020 +- changed dependency on lua-resty-jwt to allow newer versions in + luarocks packaging; see #363, #366, #362; + thanks to @Darguelles and @kayano +- release 1.7.4 + 09/20/2020 - release 1.7.3 diff --git a/DISCLAIMER b/DISCLAIMER index 44407015..d67cda09 100644 --- a/DISCLAIMER +++ b/DISCLAIMER @@ -1,12 +1,11 @@ /*************************************************************************** - * Copyright (C) 2014-2017 Ping Identity Corporation + * Copyright (C) 2017-2023 ZmartZone Holding B.V. * All rights reserved. + * + * ZmartZone IAM + * https://www.zmartzone.eu * - * Ping Identity Corporation - * 1099 18th St Suite 2950 - * Denver, CO 80202 - * 303.468.2900 - * http://www.pingidentity.com + * Copyright (C) 2014-2017 Ping Identity Corporation * * DISCLAIMER OF WARRANTIES: * diff --git a/README.md b/README.md index 5a5e83c5..cc066bb3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Build Status](https://travis-ci.org/zmartzone/lua-resty-openidc.svg?branch=master)](https://travis-ci.org/zmartzone/lua-resty-openidc) +[![CI Status](https://github.com/zmartzone/lua-resty-openidc/actions/workflows/docker-ci.yml/badge.svg)](https://github.com/zmartzone/lua-resty-openidc/actions/workflows/docker-ci.yml) [OpenID Certification](https://openid.net/certification) # lua-resty-openidc @@ -49,30 +49,16 @@ introspection for access token validation. ## Installation -If you're using `opm` execute the following: - - opm install zmartzone/lua-resty-openidc - -If you're using `luarocks` execute the following: +Using `luarocks` execute the following: luarocks install lua-resty-openidc Otherwise copy `openidc.lua` somewhere in your `lua_package_path` under a directory named `resty`. If you are using [OpenResty](http://openresty.org/), the default location would be `/usr/local/openresty/lualib/resty`. +Older versions of lua-resty-openidc could also be installed using opm +but this is no longer supported. -## Support - -#### Community Support - -For generic questions, see the Wiki pages with Frequently Asked Questions at: -[https://github.com/zmartzone/lua-resty-openidc/wiki](https://github.com/zmartzone/lua-resty-openidc/wiki) -Any questions/issues should go to issues tracker. - -#### Commercial Services - -For commercial Support contracts, Professional Services, Training and use-case specific support you can contact: -[sales@zmartzone.eu](mailto:sales@zmartzone.eu) ## Sample Configuration for Google+ Signin @@ -146,6 +132,7 @@ h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= -- Connection keepalive with the OP can be enabled ("yes") or disabled ("no"). --keepalive = "no", + --response_mode=form_post can be used to make lua-resty-openidc use the [OAuth 2.0 Form Post Response Mode](https://openid.net/specs/oauth-v2-form-post-response-mode-1_0.html). *Note* for modern browsers you will need to set [`$session_cookie_samesite`](https://github.com/bungle/lua-resty-session#string-sessioncookiesamesite) to `None` with form_post unless your OpenID Connect Provider and Relying Party share the same domain. --authorization_params = { hd="zmartzone.eu" }, --scope = "openid email profile", -- Refresh the users id_token after 900 seconds without requiring re-authentication @@ -219,12 +206,15 @@ h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= -- } -- -- where `handle_created`, `handle_authenticated`, `handle_regenerated` and `handle_logout` are callables - -- accepting a single argument `session` + -- accepting argument `session`. `handle_created` accepts also second argument `params` which is a table + -- containing the query parameters of the authorization request used to redirect the user to the OpenID + -- Connect provider endpoint. -- -- -- `on_created` hook is invoked *after* a session has been created in -- `openidc_authorize` immediately prior to saving the session -- -- `on_authenticated` hook is invoked *after* receiving authorization response in -- `openidc_authorization_response` immediately prior to saving the session + -- Starting with lua-resty-openidc 1.7.5 this receives the decoded id_token as second and the response of the token endpoint as third argument -- -- `on_regenerated` is invoked immediately after the a new access token has been obtained via token refresh and is called with the regenerated session table @@ -232,6 +222,7 @@ h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= -- `openidc_logout` -- -- Any, all or none of the hooks may be used. Empty `lifecycle` does nothing. + -- A hook that returns a truthy value causes the lifecycle action they are taking part of to fail. -- Optional : add decorator for HTTP request that is -- applied when lua-resty-openidc talks to the OpenID Connect @@ -284,6 +275,45 @@ h2JHukolz9xf6qN61QMLSd83+kwoBr2drp6xg3eGDLIkQCQLrkY= } ``` +## About `redirect_uri` + +The so called `redirect_uri` is an URI that is part of the OpenID +Connect protocoll. The redirect URI is registered with your OpenID +Connect provider and is the URI your provider will redirect the users +to after successful login. This URI then is handelled by +lua-resty-openidc where it obtains tokens and performs some checks and +only after that the browser is redirected to where your user wanted to +go initially. + +The `redirect_uri` is not expected to be handelled by your appication +code at all. It must be an URI wthat lua-resty-openidc is responsible +for so it must be in a `location` protected by lua-resty-openidc. + +You configure the `redirect_uri` on the lua-resty-openidc side via the +`opts.redirect_uri` parameter (which defaults to `/redirect_uri`). If +it starts with a `/` then lua-resty-openidc will prepend the protocoll +and current hostname to it when sending the URI to the OpenID Connect +provider (taking `Forwarded` and `X-Forwarded-*` HTTP headers into +account). But you can also specify an absolute URI containing host and +protocoll yourself. + +Before version 1.6.1 `opts.redirect_uri_path` has been the way to +configure the `redirect_uri` without any option to take control over +the protocoll and host parts. + +Whenever lua-resty-openidc "sees" a local path navigated that matches +the path of `opts.redirect_uri` (or `opts.redirect_uri_path`) it will +intercept the request and handle it itself. + +This works for most cases but sometimes the externally visible +`redirect_uri` has a different path than the one locally visible to +the server. This may happen if a reverse proxy in front of your server +rewrites URIs before forwarding the requests. Therefore version 1.7.6 +introduced a new option `opts.local_redirect_uri_path`. If it is set +lua-resty-opendic will intercepts requests to this path rather than +the path of `opts.redirect_uri`. + + ## Check authentication only ```lua @@ -300,9 +330,9 @@ local res, err = require("resty.openidc").authenticate(opts, nil, "deny") ## Sessions and Locking -The `authenicate` function returns the current session object as its +The `authenticate` function returns the current session object as its forth return argument. If you have configured lua-resty-session to use -a server side storade backend that uses locking, the session may still +a server side storage backend that uses locking, the session may still be locked when it is returned. In this case you may want to close it explicitly @@ -311,6 +341,55 @@ local res, err, target, session = require("resty.openidc").authenticate(opts) session:close() ``` +## Caching + +lua-resty-openidc can use [shared memory +caches](https://github.com/openresty/lua-nginx-module/#lua_shared_dict) +for several things. If you want it to use the caches, you must use +`lua_shared_dict` in your `nginx.conf` file. + +Currently up to four caches are used + +* the cache named `discovery` stores the OpenID Connect Disovery + metadata of your OpenID Connect Provider. Cache items expire after + 24 hours unless overriden by `opts.discovery_expires_in` (a value + given in seconds) . This cache will store one item per issuer URI + and you can look up the discovery document yourself to get an + estimate for the size required - usually a few kB per OpenID Connect + Provider. +* the cache named `jwks` stores the key material of your OpenID + Connect Provider if it is provided via the JWKS endpoint. Cache + items expire after 24 hours unless overriden by + `opts.jwks_expires_in`. This cache will store one item per JWKS URI + and you can look up the jwks yourself to get an estimate for the + size required - usually a few kB per OpenID Connect Provider. +* the cache named `introspection` stores the result of OAuth2 token + introspection. Cache items expire when the corresponding token + expires. Tokens with unknown expiry are not cached at all. This + cache will contain one entry per introspected access token - usually + this will be a few kB per token. +* the cache named `jwt_verification` stores the result of JWT + verification. Cache items expire when the corresponding token + expires. Tokens with unknown expiry are not cached for two + minutes. This cache will contain one entry per verified JWT - + usually this will be a few kB per token. + +## Caching of Introspection and JWT Verification Results + +Note the `jwt_verification` and `introspection` caches are shared +between all configured locations. If you are using locations with +different `opts` configuration the shared cache may allow a token that +is valid for only one location to be accepted by another if it is read +from the cache. In order to avoid cache confusion it is recommended to +set `opts.cache_segment` to unique strings for each set of related +locations. + +## Revoke tokens + +The `revoke_tokens(opts, session)` function revokes the current refresh and access token. In contrast to a full logout, the session cookie will not be destroyed and the endsession endpoint will not be called. The function returns `true` if both tokens were revoked successfully. This function might be helpful in scenarios where you want to destroy/remove a session from the server side. + +With `revoke_token(opts, token_type_hint, token)` it is also possible to revoke a specific token. `token_type_hint` can usually be `refresh_token` or `access_token`. + ## Sample Configuration for OAuth 2.0 JWT Token Validation Sample `nginx.conf` configuration for verifying Bearer JWT Access Tokens against a pre-configured secret/key. @@ -328,7 +407,7 @@ http { resolver 8.8.8.8; # cache for JWT verification results - lua_shared_dict introspection 10m; + lua_shared_dict jwt_verification 10m; server { listen 8080; @@ -388,6 +467,13 @@ lAc5Csj0o5Q+oEhPUAVBIF07m4rd0OvAVPOCQ2NJhQSL1oWASbf+fg== -- the expiration time in seconds for jwk cache, default is 1 day. --jwk_expires_in = 24 * 60 * 60 + -- It may be necessary to force verification for a bearer token and ignore the existing cached + -- verification results. If so you need to set set the jwt_verification_cache_ignore option to true. + -- jwt_verification_cache_ignore = true + + -- optional name of a cache-segment if you need separate + -- caches for differently configured locations + -- cache_segment = 'api' } -- call bearer_jwt_verify for OAuth 2.0 JWT validation @@ -456,6 +542,10 @@ http { -- Defaults to "exp" - Controls the TTL of the introspection cache -- https://tools.ietf.org/html/rfc7662#section-2.2 -- introspection_expiry_claim = "exp" + + -- optional name of a cache-segment if you need separate + -- caches for differently configured locations + -- cache_segment = 'api' } -- call introspect for OAuth 2.0 Bearer Access Token validation @@ -556,6 +646,10 @@ http { -- It may be necessary to force an introspection call for an access_token and ignore the existing cached -- introspection results. If so you need to set set the introspection_cache_ignore option to true. -- introspection_cache_ignore = true + + -- optional name of a cache-segment if you need separate + -- caches for differently configured locations + -- cache_segment = 'api' } -- call introspect for OAuth 2.0 Bearer Access Token validation @@ -615,8 +709,15 @@ $ docker run -it --rm -e coverage=t lua-resty-openidc/test:latest as the second command -Disclaimer ----------- +## Support + +For generic questions, see the Wiki pages with Frequently Asked Questions at: +[https://github.com/zmartzone/lua-resty-openidc/wiki](https://github.com/zmartzone/lua-resty-openidc/wiki) +Any questions/issues should go to the Github Discussons or Issues tracker. + + +## Disclaimer -*This software is open sourced by ZmartZone IAM. For commercial support -you can contact [ZmartZone IAM](https://www.zmartzone.eu) as described above in the [Support](#support) section.* +*This software is open sourced by ZmartZone IAM but not supported commercially as such. +Any questions/issues should go to the Github Discussions or Issues tracker. +See also the DISCLAIMER file in this directory.* diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..cee386aa --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,9 @@ +1. add release entry to ChangeLog +2. modify lua-resty-openidc-*.rockspec filename and version/tag contents +3. (optional) modify .github/issue_template.md to point to the latest version +4. modify lib/resty/openidc.lua to update _VERSION +5. commit and push to Github +6. create a new release on the Github project, summarizing the ChangeLog +7. run "luarocks build" and "luarocks upload lua-resty-openidc-1.7.5-1.rockspec" + (make sure to get a luarocks.org upload key and configure ~/.luarocks/upload_config.lua) +8. run "opm build" and "opm upload" (possibly after modifying dist.ini) and "opm clean dist" diff --git a/dist.ini b/dist.ini index 592dc39b..d85b0ecf 100644 --- a/dist.ini +++ b/dist.ini @@ -1,6 +1,6 @@ name = lua-resty-openidc abstract = A library for NGINX implementing the OpenID Connect Relying Party (RP) and the OAuth 2.0 Resource Server (RS) functionality -author = Hans Zandbelt (@zandbelt) +author = Hans Zandbelt (@zandbelt), Stefan Bodewig (@bodewig) is_original = yes license = apache2 repo_link = https://github.com/zmartzone/lua-resty-openidc diff --git a/lib/resty/openidc.lua b/lib/resty/openidc.lua index 9bd4551e..3ac7f20e 100644 --- a/lib/resty/openidc.lua +++ b/lib/resty/openidc.lua @@ -17,18 +17,10 @@ specific language governing permissions and limitations under the License. *************************************************************************** -Copyright (C) 2017-2019 ZmartZone IAM +Copyright (C) 2017-2023 ZmartZone Holding B.V. Copyright (C) 2015-2017 Ping Identity Corporation All rights reserved. -For further information please contact: - - Ping Identity Corporation - 1099 18th St Suite 2950 - Denver, CO 80202 - 303.468.2900 - http://www.pingidentity.com - DISCLAIMER OF WARRANTIES: THE SOFTWARE PROVIDED HEREUNDER IS PROVIDED ON AN "AS IS" BASIS, WITHOUT @@ -54,12 +46,14 @@ local cjson_s = require("cjson.safe") local http = require("resty.http") local r_session = require("resty.session") local string = string +local table = table local ipairs = ipairs local pairs = pairs local type = type local ngx = ngx local b64 = ngx.encode_base64 -local unb64 = ngx.decode_base64 +local b64url = require("ngx.base64").encode_base64url +local unb64url = require("ngx.base64").decode_base64url local log = ngx.log local DEBUG = ngx.DEBUG @@ -84,7 +78,7 @@ local supported_token_auth_methods = { } local openidc = { - _VERSION = "1.7.3" + _VERSION = "1.8.0" } openidc.__index = openidc @@ -97,6 +91,14 @@ local function store_in_session(opts, feature) return opts.session_contents[feature] end +local function is_session(o) + return o ~= nil and o.save and type(o.save) == "function" +end + +local function is_session_present(session) + return session ~= nil and next(session:get_data()) ~= nil +end + -- set value in server-wide cache if available local function openidc_cache_set(type, key, value, exp) local dict = ngx.shared[type] @@ -132,6 +134,7 @@ function openidc.invalidate_caches() openidc_cache_invalidate("discovery") openidc_cache_invalidate("jwks") openidc_cache_invalidate("introspection") + openidc_cache_invalidate("jwt_verification") end -- validate the contents of and id_token @@ -265,7 +268,7 @@ local function get_host_name(headers) end -- assemble the redirect_uri -local function openidc_get_redirect_uri(opts) +local function openidc_get_redirect_uri(opts, session) local path = opts.redirect_uri_path if opts.redirect_uri then if opts.redirect_uri:sub(1, 1) == '/' then @@ -279,28 +282,12 @@ local function openidc_get_redirect_uri(opts) local host = get_host_name(headers) if not host then -- possibly HTTP 1.0 and no Host header + if session then session:close() end ngx.exit(ngx.HTTP_BAD_REQUEST) end return scheme .. "://" .. host .. path end --- perform base64url decoding -local function openidc_base64_url_decode(input) - local reminder = #input % 4 - if reminder > 0 then - local padlen = 4 - reminder - input = input .. string.rep('=', padlen) - end - input = input:gsub('%-', '+'):gsub('_', '/') - return unb64(input) -end - --- perform base64url encoding -local function openidc_base64_url_encode(input) - local output = b64(input, true) - return output:gsub('%+', '-'):gsub('/', '_') -end - local function openidc_combine_uri(uri, params) if params == nil or next(params) == nil then return uri @@ -316,29 +303,32 @@ local function decorate_request(http_request_decorator, req) return http_request_decorator and http_request_decorator(req) or req end +local sha256 = (require 'resty.sha256'):new() local function openidc_s256(verifier) - local sha256 = (require 'resty.sha256'):new() sha256:update(verifier) - return openidc_base64_url_encode(sha256:final()) + local s256 = b64url(sha256:final()) + sha256:reset() + return s256 end -- send the browser of to the OP's authorization endpoint local function openidc_authorize(opts, session, target_url, prompt) local resty_random = require("resty.random") local resty_string = require("resty.string") + local err -- generate state and nonce local state = resty_string.to_hex(resty_random.bytes(16)) local nonce = (opts.use_nonce == nil or opts.use_nonce) and resty_string.to_hex(resty_random.bytes(16)) - local code_verifier = opts.use_pkce and openidc_base64_url_encode(resty_random.bytes(32)) + local code_verifier = opts.use_pkce and b64url(resty_random.bytes(32)) -- assemble the parameters to the authentication request local params = { client_id = opts.client_id, response_type = "code", scope = opts.scope and opts.scope or "openid email profile", - redirect_uri = openidc_get_redirect_uri(opts), + redirect_uri = openidc_get_redirect_uri(opts, session), state = state, } @@ -359,23 +349,35 @@ local function openidc_authorize(opts, session, target_url, prompt) params.code_challenge = openidc_s256(code_verifier) end + if opts.response_mode then + params.response_mode = opts.response_mode + end + -- merge any provided extra parameters if opts.authorization_params then for k, v in pairs(opts.authorization_params) do params[k] = v end end -- store state in the session - session.data.original_url = target_url - session.data.state = state - session.data.nonce = nonce - session.data.code_verifier = code_verifier - session.data.last_authenticated = ngx.time() + session:set("original_url", target_url) + session:set("state", state) + session:set("nonce", nonce) + session:set("code_verifier", code_verifier) + session:set("last_authenticated", ngx.time()) if opts.lifecycle and opts.lifecycle.on_created then - opts.lifecycle.on_created(session) + err = opts.lifecycle.on_created(session, params) + if err then + log(WARN, "failed in `on_created` handler: " .. err) + return err + end end - session:save() + local res + res, err = session:save() + if err then + log(WARN, "unable to save session: " .. err) + end -- redirect to the /authorization endpoint ngx.header["Cache-Control"] = "no-cache, no-store, max-age=0" @@ -534,8 +536,8 @@ local function openidc_access_token_expires_in(opts, expires_in) end local function openidc_load_jwt_none_alg(enc_hdr, enc_payload) - local header = cjson_s.decode(openidc_base64_url_decode(enc_hdr)) - local payload = cjson_s.decode(openidc_base64_url_decode(enc_payload)) + local header = cjson_s.decode(unb64url(enc_hdr)) + local payload = cjson_s.decode(unb64url(enc_payload)) if header and payload and header.alg == "none" then return { raw_header = enc_hdr, @@ -591,7 +593,7 @@ local function openidc_ensure_discovered_data(opts) local err if type(opts.discovery) == "string" then local discovery - discovery, err = openidc_discover(opts.discovery, opts.ssl_verify, opts.keepalive, opts.timeout, opts.jwk_expires_in, opts.proxy_opts, + discovery, err = openidc_discover(opts.discovery, opts.ssl_verify, opts.keepalive, opts.timeout, opts.discovery_expires_in, opts.proxy_opts, opts.http_request_decorator) if not err then opts.discovery = discovery @@ -633,6 +635,17 @@ function openidc.call_userinfo_endpoint(opts, access_token) log(DEBUG, "userinfo response: ", res.body) + -- handle if the response type is a jwt/signed payload + local responseType = string.lower(res.headers["Content-Type"]) + if string.find(responseType, "application/jwt") then + local json, err = openidc.jwt_verify(res.body, opts) + if err then + err = "userinfo jwt could not be verified: " .. err + return nil, err + end + return json + end + -- parse the response from the user info endpoint return openidc_parse_json_response(res) end @@ -840,9 +853,8 @@ local function encode_bit_string(array) end local function openidc_pem_from_x5c(x5c) - -- TODO check x5c length log(DEBUG, "Found x5c, getting PEM public key from x5c entry of json public key") - local chunks = split_by_chunk(b64(openidc_base64_url_decode(x5c[1])), 64) + local chunks = split_by_chunk(x5c[1], 64) local pem = "-----BEGIN CERTIFICATE-----\n" .. table.concat(chunks, "\n") .. "\n-----END CERTIFICATE-----" @@ -854,7 +866,7 @@ local function openidc_pem_from_rsa_n_and_e(n, e) log(DEBUG, "getting PEM public key from n and e parameters of json public key") local der_key = { - openidc_base64_url_decode(n), openidc_base64_url_decode(e) + unb64url(n), unb64url(e) } local encoded_key = encode_sequence_of_integer(der_key) local pem = der2pem(encode_sequence({ @@ -905,10 +917,19 @@ local function openidc_pem_from_jwk(opts, kid) return nil, err end + local x5c = jwk.x5c + if x5c and type(x5c) ~= 'table' then + log(WARN, "Found invalid JWK with x5c claim not being an array but a " .. type(x5c)) + x5c = nil + end + if x5c and #(jwk.x5c) == 0 then + log(WARN, "Found invalid JWK with empty x5c array, ignoring x5c claim") + x5c = nil + end + local pem - -- TODO check x5c length - if jwk.x5c then - pem = openidc_pem_from_x5c(jwk.x5c) + if x5c then + pem = openidc_pem_from_x5c(x5c) elseif jwk.kty == "RSA" and jwk.n and jwk.e then pem = openidc_pem_from_rsa_n_and_e(jwk.n, jwk.e) else @@ -938,8 +959,9 @@ local function is_algorithm_expected(jwt_header, expected_algs) return true end if type(expected_algs) == 'string' then - expected_algs = { expected_algs } + return expected_algs == jwt_header.alg end + for _, alg in ipairs(expected_algs) do if alg == jwt_header.alg then return true @@ -1062,7 +1084,7 @@ local function openidc_load_and_validate_jwt_id_token(opts, jwt_id_token, sessio log(DEBUG, "id_token payload: ", cjson.encode(jwt_obj.payload)) -- validate the id_token contents - if openidc_validate_id_token(opts, id_token, session.data.nonce) == false then + if openidc_validate_id_token(opts, id_token, session:get("nonce")) == false then err = "id_token validation failed" log(ERROR, err) return nil, err @@ -1073,26 +1095,33 @@ end -- handle a "code" authorization response from the OP local function openidc_authorization_response(opts, session) - local args = ngx.req.get_uri_args() - local err, log_err, client_err + local args, err, log_err, client_err + + if opts.response_mode and opts.response_mode == "form_post" then + ngx.req.read_body() + args = ngx.req.get_post_args() + else + args = ngx.req.get_uri_args() + end if not args.code or not args.state then err = "unhandled request to the redirect_uri: " .. ngx.var.request_uri log(ERROR, err) - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end -- check that the state returned in the response against the session; prevents CSRF - if args.state ~= session.data.state then - log_err = "state from argument: " .. (args.state and args.state or "nil") .. " does not match state restored from session: " .. (session.data.state and session.data.state or "nil") + local state = session:get("state") + if args.state ~= state then + log_err = "state from argument: " .. (args.state and args.state or "nil") .. " does not match state restored from session: " .. (state and state or "nil") client_err = "state from argument does not match state restored from session" log(ERROR, log_err) - return nil, client_err, session.data.original_url, session + return nil, client_err, session:get("original_url"), session end err = ensure_config(opts) if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end -- check the iss if returned from the OP @@ -1100,7 +1129,7 @@ local function openidc_authorization_response(opts, session) log_err = "iss from argument: " .. args.iss .. " does not match expected issuer: " .. opts.discovery.issuer client_err = "iss from argument does not match expected issuer" log(ERROR, log_err) - return nil, client_err, session.data.original_url, session + return nil, client_err, session:get("original_url"), session end -- check the client_id if returned from the OP @@ -1108,16 +1137,16 @@ local function openidc_authorization_response(opts, session) log_err = "client_id from argument: " .. args.client_id .. " does not match expected client_id: " .. opts.client_id client_err = "client_id from argument does not match expected client_id" log(ERROR, log_err) - return nil, client_err, session.data.original_url, session + return nil, client_err, session:get("original_url"), session end -- assemble the parameters to the token endpoint local body = { grant_type = "authorization_code", code = args.code, - redirect_uri = openidc_get_redirect_uri(opts), - state = session.data.state, - code_verifier = session.data.code_verifier + redirect_uri = openidc_get_redirect_uri(opts, session), + state = state, + code_verifier = session:get("code_verifier") } log(DEBUG, "Authentication with OP done -> Calling OP Token Endpoint to obtain tokens") @@ -1127,22 +1156,22 @@ local function openidc_authorization_response(opts, session) local json json, err = openidc.call_token_endpoint(opts, opts.discovery.token_endpoint, body, opts.token_endpoint_auth_method) if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end local id_token, err = openidc_load_and_validate_jwt_id_token(opts, json.id_token, session); if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end -- mark this sessions as authenticated - session.data.authenticated = true - -- clear state, nonce and code_verifier to protect against potential misuse - session.data.nonce = nil - session.data.state = nil - session.data.code_verifier = nil + session:set("authenticated", true) + session:set("nonce", nil) + session:set("state", nil) + session:set("code_verifier", nil) + if store_in_session(opts, 'id_token') then - session.data.id_token = id_token + session:set("id_token", id_token) end if store_in_session(opts, 'user') then @@ -1158,35 +1187,44 @@ local function openidc_authorization_response(opts, session) 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 - session.data.user = user + session:set("user", user) end end end if store_in_session(opts, 'enc_id_token') then - session.data.enc_id_token = json.id_token + session:set("enc_id_token", json.id_token) end if store_in_session(opts, 'access_token') then - session.data.access_token = json.access_token - session.data.access_token_expiration = current_time - + openidc_access_token_expires_in(opts, json.expires_in) + session:set("access_token", json.access_token) + session:set("access_token_expiration", current_time + openidc_access_token_expires_in(opts, json.expires_in)) + if json.refresh_token ~= nil then - session.data.refresh_token = json.refresh_token + session:set("refresh_token", json.refresh_token) end end if opts.lifecycle and opts.lifecycle.on_authenticated then - opts.lifecycle.on_authenticated(session) + err = opts.lifecycle.on_authenticated(session, id_token, json) + if err then + log(WARN, "failed in `on_authenticated` handler: " .. err) + return nil, err, session:get("original_url"), session + end end -- save the session with the obtained id_token - session:save() + local res + res, err = session:save() + if err then + log(WARN, "unable to save session: " .. err) + end -- redirect to the URL that was accessed originally - log(DEBUG, "OIDC Authorization Code Flow completed -> Redirecting to original URL (" .. session.data.original_url .. ")") - ngx.redirect(session.data.original_url) - return nil, nil, session.data.original_url, session + local original_url = session:get("original_url") + log(DEBUG, "OIDC Authorization Code Flow completed -> Redirecting to original URL (" .. original_url .. ")") + ngx.redirect(original_url) + return nil, nil, original_url, session end -- token revocation (RFC 7009) @@ -1203,11 +1241,12 @@ local function openidc_revoke_token(opts, token_type_hint, token) if token_type_hint then body['token_type_hint'] = token_type_hint end + local token_type_log = token_type_hint or 'token' -- ensure revocation endpoint auth method is properly discovered local err = ensure_config(opts) if err then - log(ERROR, "revocation of " .. token_type_hint .. " unsuccessful: " .. err) + log(ERROR, "revocation of " .. token_type_log .. " unsuccessful: " .. err) return false end @@ -1215,31 +1254,100 @@ local function openidc_revoke_token(opts, token_type_hint, token) local _ _, err = openidc.call_token_endpoint(opts, opts.discovery.revocation_endpoint, body, opts.token_endpoint_auth_method, "revocation", true) if err then - log(ERROR, "revocation of " .. token_type_hint .. " unsuccessful: " .. err) + log(ERROR, "revocation of " .. token_type_log .. " unsuccessful: " .. err) return false else - log(DEBUG, "revocation of " .. token_type_hint .. " successful") + log(DEBUG, "revocation of " .. token_type_log .. " successful") return true end end +function openidc.revoke_token(opts, token_type_hint, token) + local err = openidc_ensure_discovered_data(opts) + if err then + log(ERROR, "revocation of " .. (token_type_hint or "token (no type specified)") .. " unsuccessful: " .. err) + return false + end + + return openidc_revoke_token(opts, token_type_hint, token) +end + +function openidc.revoke_tokens(opts, session) + local err = openidc_ensure_discovered_data(opts) + if err then + log(ERROR, "revocation of tokens unsuccessful: " .. err) + return false + end + + local access_token = session:get("access_token") + local refresh_token = session:get("refresh_token") + + local access_token_revoke, refresh_token_revoke + if refresh_token then + access_token_revoke = openidc_revoke_token(opts, "refresh_token", refresh_token) + end + if access_token then + refresh_token_revoke = openidc_revoke_token(opts, "access_token", access_token) + end + return access_token_revoke and refresh_token_revoke +end + local openidc_transparent_pixel = "\137\080\078\071\013\010\026\010\000\000\000\013\073\072\068\082" .. "\000\000\000\001\000\000\000\001\008\004\000\000\000\181\028\012" .. "\002\000\000\000\011\073\068\065\084\120\156\099\250\207\000\000" .. "\002\007\001\002\154\028\049\113\000\000\000\000\073\069\078\068" .. "\174\066\096\130" +local function request_prefers_png_over_html() + local headers = ngx.req.get_headers() + local header = get_first(headers['Accept']) + if not header then return false end + + -- https://httpwg.org/specs/rfc9110.html#field.accept + local accepted = {} + local function append_accepted_type(media_range_and_quality) + local media_range, quality = media_range_and_quality:match("(.+)%s*;%s*q=%s*([^%s]+)") + if media_range and quality then + accepted[#accepted + 1] = {media_range=media_range, quality=tonumber(quality)} + else + accepted[#accepted + 1] = {media_range=media_range_and_quality, quality=1} + end + end + header:gsub("[^,]+", append_accepted_type) + + table.sort(accepted, function(a1, a2) + return a1.quality > a2.quality + end) + + for _, a in ipairs(accepted) do + if a.media_range:find("text/html") or a.media_range:find("application/xhtml%+xml") then + return false + end + if a.media_range:find("image/png") then + return true + end + end + return false +end + -- handle logout local function openidc_logout(opts, session) - local session_token = session.data.enc_id_token - local access_token = session.data.access_token - local refresh_token = session.data.refresh_token + local session_token = session:get("enc_id_token") + local access_token = session:get("access_token") + local refresh_token = session:get("refresh_token") + local err if opts.lifecycle and opts.lifecycle.on_logout then - opts.lifecycle.on_logout(session) + err = opts.lifecycle.on_logout(session) + if err then + log(WARN, "failed in `on_logout` handler: " .. err) + return err + end end - session:destroy() + if is_session_present(session) then + session:destroy() + end if opts.revoke_tokens_on_logout then log(DEBUG, "revoke_tokens_on_logout is enabled. " .. @@ -1252,9 +1360,8 @@ local function openidc_logout(opts, session) end end - local headers = ngx.req.get_headers() - local header = get_first(headers['Accept']) - if header and header:find("image/png") then + if request_prefers_png_over_html() then + -- support for Ping Federate's proprietary logout protocol ngx.header["Cache-Control"] = "no-cache, no-store" ngx.header["Pragma"] = "no-cache" ngx.header["P3P"] = "CAO PSA OUR" @@ -1297,21 +1404,26 @@ local function openidc_access_token(opts, session, try_to_renew) local err - if session.data.access_token == nil then + local access_token = session:get("access_token") + if access_token == nil then return nil, err end + local current_time = ngx.time() - if current_time < session.data.access_token_expiration then - return session.data.access_token, err + if current_time < session:get("access_token_expiration") then + return access_token, err end + if not try_to_renew then return nil, "token expired" end - if session.data.refresh_token == nil then + + local refresh_token = session:get("refresh_token") + if refresh_token == nil then return nil, "token expired and no refresh token available" end - log(DEBUG, "refreshing expired access_token: ", session.data.access_token, " with: ", session.data.refresh_token) + log(DEBUG, "refreshing expired access_token: ", access_token, " with: ", refresh_token) -- retrieve token endpoint URL from discovery endpoint if necessary err = ensure_config(opts) @@ -1322,7 +1434,7 @@ local function openidc_access_token(opts, session, try_to_renew) -- assemble the parameters to the token endpoint local body = { grant_type = "refresh_token", - refresh_token = session.data.refresh_token, + refresh_token = refresh_token, scope = opts.scope and opts.scope or "openid email profile" } @@ -1341,35 +1453,40 @@ local function openidc_access_token(opts, session, try_to_renew) end log(DEBUG, "access_token refreshed: ", json.access_token, " updated refresh_token: ", json.refresh_token) - session.data.access_token = json.access_token - session.data.access_token_expiration = current_time + openidc_access_token_expires_in(opts, json.expires_in) + session:set("access_token", json.access_token) + session:set("access_token_expiration", current_time + openidc_access_token_expires_in(opts, json.expires_in)) if json.refresh_token then - session.data.refresh_token = json.refresh_token + session:set("refresh_token", json.refresh_token) end if json.id_token and (store_in_session(opts, 'enc_id_token') or store_in_session(opts, 'id_token')) then log(DEBUG, "id_token refreshed: ", json.id_token) if store_in_session(opts, 'enc_id_token') then - session.data.enc_id_token = json.id_token + session:set("enc_id_token", json.id_token) end if store_in_session(opts, 'id_token') then - session.data.id_token = id_token + session:set("id_token", id_token) end end - -- save the session with the new access_token and optionally the new refresh_token and id_token using a new sessionid - local regenerated - regenerated, err = session:regenerate() + if opts.lifecycle and opts.lifecycle.on_regenerated then + err = opts.lifecycle.on_regenerated(session) + if err then + log(WARN, "failed in `on_regenerated` handler: " .. err) + return nil, err + end + end + + -- save the session with the new access_token and optionally the new refresh_token and id_token + local res + res, err = session:save() if err then - log(ERROR, "failed to regenerate session: " .. err) + log(ERROR, "failed to save session: " .. err) return nil, err end - if opts.lifecycle and opts.lifecycle.on_regenerated then - opts.lifecycle.on_regenerated(session) - end - return session.data.access_token, err + return session:get("access_token"), err end local function openidc_get_path(uri) @@ -1378,11 +1495,14 @@ local function openidc_get_path(uri) end local function openidc_get_redirect_uri_path(opts) + if opts.local_redirect_uri_path then + return opts.local_redirect_uri_path + end return opts.redirect_uri and openidc_get_path(opts.redirect_uri) or opts.redirect_uri_path end -- main routine for OpenID Connect user authentication -function openidc.authenticate(opts, target_url, unauth_action, session_opts) +function openidc.authenticate(opts, target_url, unauth_action, session_or_opts) if opts.redirect_uri_path then log(WARN, "using deprecated option `opts.redirect_uri_path`; switch to using an absolute URI and `opts.redirect_uri` instead") @@ -1390,12 +1510,20 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) local err - local session, session_error = r_session.start(session_opts) - if session == nil then - log(ERROR, "Error starting session: " .. session_error) - return nil, session_error, target_url, session + local session + if is_session(session_or_opts) then + session = session_or_opts + else + local session_error + session, session_error = r_session.start(session_or_opts) + if session == nil then + log(ERROR, "Error starting session: " .. session_error) + return nil, session_error, target_url, session + end end + local session_present = is_session_present(session) + target_url = target_url or ngx.var.request_uri local access_token @@ -1405,7 +1533,7 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) if path == openidc_get_redirect_uri_path(opts) then log(DEBUG, "Redirect URI path (" .. path .. ") is currently navigated -> Processing authorization response coming from OP") - if not session.present then + if not session_present then err = "request to the redirect_uri path but there's no session state found" log(ERROR, err) return nil, err, target_url, session @@ -1420,7 +1548,7 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) err = ensure_config(opts) if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end openidc_logout(opts, session) @@ -1429,7 +1557,9 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) local token_expired = false local try_to_renew = opts.renew_access_token_on_expiry == nil or opts.renew_access_token_on_expiry - if session.present and session.data.authenticated + local authenticated = session:get("authenticated") + + if session_present and authenticated and store_in_session(opts, 'access_token') then -- refresh access_token if necessary @@ -1443,10 +1573,12 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) end end + local id_token = session:get("id_token") + log(DEBUG, - "session.present=", session.present, - ", session.data.id_token=", session.data.id_token ~= nil, - ", session.data.authenticated=", session.data.authenticated, + "session_present=", session_present, + ", session.data.id_token=", id_token ~= nil, + ", session.data.authenticated=", authenticated, ", opts.force_reauthorize=", opts.force_reauthorize, ", opts.renew_access_token_on_expiry=", opts.renew_access_token_on_expiry, ", try_to_renew=", try_to_renew, @@ -1454,13 +1586,13 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) -- if we are not authenticated then redirect to the OP for authentication -- the presence of the id_token is check for backwards compatibility - if not session.present - or not (session.data.id_token or session.data.authenticated) + if not session_present + or not (id_token or authenticated) or opts.force_reauthorize or (try_to_renew and token_expired) then if unauth_action == "pass" then if token_expired then - session.data.authenticated = false + session:set("authenticated", false) return nil, 'token refresh failed', target_url, session end return nil, err, target_url, session @@ -1471,7 +1603,7 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) err = ensure_config(opts) if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end log(DEBUG, "Authentication is required - Redirecting to OP Authorization endpoint") @@ -1481,10 +1613,11 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) -- silently reauthenticate if necessary (mainly used for session refresh/getting updated id_token data) if opts.refresh_session_interval ~= nil then - if session.data.last_authenticated == nil or (session.data.last_authenticated + opts.refresh_session_interval) < ngx.time() then + local last_authenticated = session:get("last_authenticated") + if last_authenticated == nil or (last_authenticated + opts.refresh_session_interval) < ngx.time() then err = ensure_config(opts) if err then - return nil, err, session.data.original_url, session + return nil, err, session:get("original_url"), session end log(DEBUG, "Silent authentication is required - Redirecting to OP Authorization endpoint") @@ -1495,15 +1628,15 @@ function openidc.authenticate(opts, target_url, unauth_action, session_opts) if store_in_session(opts, 'id_token') then -- log id_token contents - log(DEBUG, "id_token=", cjson.encode(session.data.id_token)) + log(DEBUG, "id_token=", cjson.encode(id_token)) end -- return the id_token to the caller Lua script for access control purposes return { - id_token = session.data.id_token, + id_token = id_token, access_token = access_token, - user = session.data.user + user = session:get("user") }, err, target_url, @@ -1514,8 +1647,9 @@ end function openidc.access_token(opts, session_opts) local session = r_session.start(session_opts) - - return openidc_access_token(opts, session, true) + local token, err = openidc_access_token(opts, session, true) + session:close() + return token, err end @@ -1569,14 +1703,14 @@ local function openidc_get_bearer_access_token(opts) local header_name = opts.auth_accept_token_as_header_name or "Authorization" local header = get_first(headers[header_name]) - if header == nil or header:find(" ") == nil then + if header == nil then err = "no Authorization header found" log(ERROR, err) return nil, err end local divider = header:find(' ') - if string.lower(header:sub(0, divider - 1)) ~= string.lower("Bearer") then + if divider == nil or divider == 0 or string.lower(header:sub(0, divider - 1)) ~= string.lower("Bearer") then err = "no Bearer authorization header value found" log(ERROR, err) return nil, err @@ -1592,6 +1726,45 @@ local function openidc_get_bearer_access_token(opts) return access_token, err end +local function get_introspection_endpoint(opts) + local introspection_endpoint = opts.introspection_endpoint + if not introspection_endpoint then + local err = openidc_ensure_discovered_data(opts) + if err then + return nil, "opts.introspection_endpoint not said and " .. err + end + local endpoint = opts.discovery and opts.discovery.introspection_endpoint + if endpoint then + return endpoint + end + end + return introspection_endpoint +end + +local function get_introspection_cache_prefix(opts) + return (opts.cache_segment and opts.cache_segment:gsub(',', '_') or 'DEFAULT') .. ',' + .. (get_introspection_endpoint(opts) or 'nil-endpoint') .. ',' + .. (opts.client_id or 'no-client_id') .. ',' + .. (opts.client_secret and 'secret' or 'no-client_secret') .. ':' +end + +local function get_cached_introspection(opts, access_token) + local introspection_cache_ignore = opts.introspection_cache_ignore or false + if not introspection_cache_ignore then + return openidc_cache_get("introspection", + get_introspection_cache_prefix(opts) .. access_token) + end +end + +local function set_cached_introspection(opts, access_token, encoded_json, ttl) + local introspection_cache_ignore = opts.introspection_cache_ignore or false + if not introspection_cache_ignore then + openidc_cache_set("introspection", + get_introspection_cache_prefix(opts) .. access_token, + encoded_json, ttl) + end +end + -- main routine for OAuth 2.0 token introspection function openidc.introspect(opts) @@ -1603,12 +1776,7 @@ function openidc.introspect(opts) -- see if we've previously cached the introspection result for this access token local json - local v - local introspection_cache_ignore = opts.introspection_cache_ignore or false - - if not introspection_cache_ignore then - v = openidc_cache_get("introspection", access_token) - end + local v = get_cached_introspection(opts, access_token) if v then json = cjson.decode(v) @@ -1635,16 +1803,10 @@ function openidc.introspect(opts) end -- call the introspection endpoint - local introspection_endpoint = opts.introspection_endpoint - if not introspection_endpoint then - err = openidc_ensure_discovered_data(opts) - if err then - return nil, "opts.introspection_endpoint not said and " .. err - end - local endpoint = opts.discovery and opts.discovery.introspection_endpoint - if endpoint then - introspection_endpoint = endpoint - end + local introspection_endpoint + introspection_endpoint, err = get_introspection_endpoint(opts) + if err then + return nil, err end json, err = openidc.call_token_endpoint(opts, introspection_endpoint, body, opts.introspection_endpoint_auth_method, "introspection") @@ -1659,10 +1821,11 @@ function openidc.introspect(opts) end -- cache the results + local introspection_cache_ignore = opts.introspection_cache_ignore or false local expiry_claim = opts.introspection_expiry_claim or "exp" - local introspection_interval = opts.introspection_interval or 0 if not introspection_cache_ignore and json[expiry_claim] then + local introspection_interval = opts.introspection_interval or 0 local ttl = json[expiry_claim] if expiry_claim == "exp" then --https://tools.ietf.org/html/rfc7662#section-2.2 ttl = ttl - ngx.time() @@ -1673,33 +1836,64 @@ function openidc.introspect(opts) end end log(DEBUG, "cache token ttl: " .. ttl) - openidc_cache_set("introspection", access_token, cjson.encode(json), ttl) - + set_cached_introspection(opts, access_token, cjson.encode(json), ttl) end return json, err end +local function get_jwt_verification_cache_prefix(opts) + local signing_alg_values_expected = (opts.accept_none_alg and 'none' or 'no-none') + local expected_algs = opts.token_signing_alg_values_expected or {} + if type(expected_algs) == 'string' then + expected_algs = { expected_algs } + end + for _, alg in ipairs(expected_algs) do + signing_alg_values_expected = signing_alg_values_expected .. ',' .. alg + end + return (opts.cache_segment and opts.cache_segment:gsub(',', '_') or 'DEFAULT') .. ',' + .. (opts.public_key or 'no-pubkey') .. ',' + .. (opts.symmetric_key or 'no-symkey') .. ',' + .. signing_alg_values_expected .. ':' +end + +local function get_cached_jwt_verification(opts, access_token) + local jwt_verification_cache_ignore = opts.jwt_verification_cache_ignore or false + if not jwt_verification_cache_ignore then + return openidc_cache_get("jwt_verification", + get_jwt_verification_cache_prefix(opts) .. access_token) + end +end + +local function set_cached_jwt_verification(opts, access_token, encoded_json, ttl) + local jwt_verification_cache_ignore = opts.jwt_verification_cache_ignore or false + if not jwt_verification_cache_ignore then + openidc_cache_set("jwt_verification", + get_jwt_verification_cache_prefix(opts) .. access_token, + encoded_json, ttl) + end +end + -- main routine for OAuth 2.0 JWT token validation -- optional args are claim specs, see jwt-validators in resty.jwt function openidc.jwt_verify(access_token, opts, ...) local err local json + local v = get_cached_jwt_verification(opts, access_token) local slack = opts.iat_slack and opts.iat_slack or 120 - -- see if we've previously cached the validation result for this access token - local v = openidc_cache_get("introspection", access_token) if not v then local jwt_obj jwt_obj, err = openidc_load_jwt_and_verify_crypto(opts, access_token, opts.public_key, opts.symmetric_key, opts.token_signing_alg_values_expected, ...) if not err then json = jwt_obj.payload - log(DEBUG, "jwt: ", cjson.encode(json)) + local encoded_json = cjson.encode(json) + log(DEBUG, "jwt: ", encoded_json) - local ttl = json.exp and json.exp - ngx.time() or 120 - openidc_cache_set("introspection", access_token, cjson.encode(json), ttl) + set_cached_jwt_verification(opts, access_token, encoded_json, + json.exp and json.exp - ngx.time() or 120) end else diff --git a/lua-resty-openidc-1.7.3-1.rockspec b/lua-resty-openidc-1.8.0-1.rockspec similarity index 91% rename from lua-resty-openidc-1.7.3-1.rockspec rename to lua-resty-openidc-1.8.0-1.rockspec index 3e431a51..b6fc3c9b 100644 --- a/lua-resty-openidc-1.7.3-1.rockspec +++ b/lua-resty-openidc-1.8.0-1.rockspec @@ -1,8 +1,8 @@ package = "lua-resty-openidc" -version = "1.7.3-1" +version = "1.8.0-1" source = { - url = "git://github.com/zmartzone/lua-resty-openidc", - tag = "v1.7.3", + url = "git+https://github.com/zmartzone/lua-resty-openidc", + tag = "v1.8.0", dir = "lua-resty-openidc" } description = { @@ -24,8 +24,8 @@ description = { dependencies = { "lua >= 5.1", "lua-resty-http >= 0.08", - "lua-resty-session >= 2.8", - "lua-resty-jwt == 0.2.0" + "lua-resty-session >= 4.0.3", + "lua-resty-jwt >= 0.2.0" } build = { type = "builtin", diff --git a/tests/Dockerfile b/tests/Dockerfile index caaa3856..a287dc19 100644 --- a/tests/Dockerfile +++ b/tests/Dockerfile @@ -1,7 +1,7 @@ -FROM openresty/openresty:xenial +FROM openresty/openresty:focal # install dependencies -RUN ["luarocks", "install", "lua-resty-session"] +RUN ["luarocks", "install", "lua-resty-session", "4.0.3"] RUN ["luarocks", "install", "lua-resty-http"] RUN ["luarocks", "install", "lua-resty-jwt"] diff --git a/tests/spec/access_token_access_spec.lua b/tests/spec/access_token_access_spec.lua index 738a3aec..bd2347b4 100644 --- a/tests/spec/access_token_access_spec.lua +++ b/tests/spec/access_token_access_spec.lua @@ -200,8 +200,9 @@ end) describe("when token endpoint is not reachable", function() test_support.start_server({ access_token_opts = { + timeout = 40000, discovery = { - token_endpoint = "http://192.0.2.1/" + token_endpoint = "http://127.1.2.3/" } }, token_response_expires_in = 0 @@ -218,7 +219,7 @@ describe("when token endpoint is not reachable", function() assert.are.equals(401, status) end) it("an error has been logged", function() - assert.error_log_contains("access_token error: accessing token endpoint.*%(http://192.0.2.1/%) failed") + assert.error_log_contains("access_token error: accessing token endpoint.*%(http://127.1.2.3/%) failed") end) end) diff --git a/tests/spec/bearer_token_verification_spec.lua b/tests/spec/bearer_token_verification_spec.lua index 57370f97..6d1c67d3 100644 --- a/tests/spec/bearer_token_verification_spec.lua +++ b/tests/spec/bearer_token_verification_spec.lua @@ -147,6 +147,19 @@ describe("when using a RSA key from a JWK that doesn't contain the x5c claim", f base_checks() end) +describe("when using a RSA key from a JWK that contains an empty x5c claim but n and e", function() + test_support.start_server({ + verify_opts = { + discovery = { + jwks_uri = "http://127.0.0.1/jwk", + } + }, + jwk = test_support.load("/spec/rsa_key_jwk_with_n_and_e_and_empty_x5c.json") + }) + teardown(test_support.stop_server) + base_checks() +end) + describe("when the JWK specifies a kid and the JWKS contains multiple keys", function() test_support.start_server({ verify_opts = { @@ -190,6 +203,30 @@ describe("when the JWK specifies a kid and the JWKS does not contain a key with end) +describe("when the JWKS contains a broken x5c which is not an array", function() + test_support.start_server({ + verify_opts = { + discovery = { + jwks_uri = "http://127.0.0.1/jwk", + } + }, + jwk = test_support.load("/spec/rsa_key_jwk_with_broken_x5c.json"), + }) + teardown(test_support.stop_server) + local jwt = test_support.trim(http.request("http://127.0.0.1/jwt")) + local _, status = http.request({ + url = "http://127.0.0.1/verify_bearer_token", + headers = { authorization = "Bearer " .. jwt } + }) + it("the token is invalid", function() + assert.are.equals(401, status) + end) + it("an error is logged", function() + assert.error_log_contains("Found invalid JWK with x5c claim not being an array but a string") + end) + +end) + describe("when the JWK specifies no kid and the JWKS contains multiple keys", function() test_support.start_server({ verify_opts = { @@ -425,8 +462,9 @@ end) describe("when jwks endpoint is not reachable", function() test_support.start_server({ verify_opts = { + timeout = 40000, discovery = { - jwks_uri = "http://192.0.2.1/" + jwks_uri = "http://127.1.2.3/" } }, }) @@ -440,7 +478,7 @@ describe("when jwks endpoint is not reachable", function() assert.are.equals(401, status) end) it("an error has been logged", function() - assert.error_log_contains("Invalid token: accessing jwks url.*%(http://192.0.2.1/%) failed") + assert.error_log_contains("Invalid token: accessing jwks url.*%(http://127.1.2.3/%) failed") end) end) diff --git a/tests/spec/form_post_spec.lua b/tests/spec/form_post_spec.lua new file mode 100644 index 00000000..d9989b0d --- /dev/null +++ b/tests/spec/form_post_spec.lua @@ -0,0 +1,96 @@ +local http = require("socket.http") +local test_support = require("test_support") +local ltn12 = require("ltn12") +require 'busted.runner'() + +describe("when response_mode is form_post", function() + test_support.start_server({oidc_opts = {response_mode = "form_post"}}) + teardown(test_support.stop_server) + local _, status, headers = http.request({ + url = "http://127.0.0.1/default/t", + redirect = false + }) + it("then it is included", function() + assert.truthy(string.match(headers["location"], ".*response_mode=form_post.*")) + end) +end) + +local function do_post(cookie_header, body) + local x, y, z = http.request({ + method = "POST", + url = "http://localhost/default/redirect_uri", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded", + ["Content-Length"] = string.len(body), + cookie = cookie_header, + }, + source = ltn12.source.string(body), + redirect = false + }) + return x, y, z +end + +describe("when a form_post is received", function() + test_support.start_server({oidc_opts = {response_mode = "form_post"}}) + teardown(test_support.stop_server) + local _, _, headers = http.request({ + url = "http://localhost/default/t", + redirect = false + }) + local state = test_support.grab(headers, 'state') + test_support.register_nonce(headers) + local cookie_header = test_support.extract_cookies(headers) + describe("without an active user session", function() + local body = "code=foo&state=" .. state + local _, redirStatus = http.request({ + method = 'POST', + url = "http://localhost/default/redirect_uri", + headers = { + ["Content-Type"] = "application/x-www-form-urlencoded", + ["Content-Length"] = string.len(body), + }, + source = ltn12.source.string(body) + }) + it("should be rejected", function() + assert.are.equals(401, redirStatus) + end) + it("will log an error message", function() + assert.error_log_contains("but there's no session state found") + end) + end) + describe("with bad state", function() + local _, redirStatus = do_post(cookie_header, "code=foo&state=X" .. state) + it("should be rejected", function() + assert.are.equals(401, redirStatus) + end) + it("will log an error message", function() + assert.error_log_contains("does not match state restored from session") + end) + end) + describe("without state", function() + local _, redirStatus = do_post(cookie_header, "code=foo") + it("should be rejected", function() + assert.are.equals(401, redirStatus) + end) + it("will log an error message", function() + assert.error_log_contains("unhandled request to the redirect_uri") + end) + end) + describe("without code", function() + local _, redirStatus = do_post(cookie_header, "state=" .. state) + it("should be rejected", function() + assert.are.equals(401, redirStatus) + end) + it("will log an error message", function() + assert.error_log_contains("unhandled request to the redirect_uri") + end) + end) + describe("with all things set", function() + local _, redirStatus, h = do_post(cookie_header, "code=foo&state=" .. state) + it("redirects to the original URI", function() + assert.are.equals(302, redirStatus) + assert.are.equals("/default/t", h.location) + end) + end) +end) + diff --git a/tests/spec/introspection_spec.lua b/tests/spec/introspection_spec.lua index a9efd8b4..a22a3b89 100644 --- a/tests/spec/introspection_spec.lua +++ b/tests/spec/introspection_spec.lua @@ -395,7 +395,8 @@ end) describe("when introspection endpoint is not reachable", function() test_support.start_server({ introspection_opts = { - introspection_endpoint = "http://192.0.2.1/" + timeout = 40000, + introspection_endpoint = "http://127.1.2.3/" }, }) teardown(test_support.stop_server) @@ -408,7 +409,7 @@ describe("when introspection endpoint is not reachable", function() assert.are.equals(401, status) end) it("an error has been logged", function() - assert.error_log_contains("Introspection error:.*accessing introspection endpoint %(http://192.0.2.1/%) failed") + assert.error_log_contains("Introspection error:.*accessing introspection endpoint %(http://127.1.2.3/%) failed") end) end) diff --git a/tests/spec/logout_spec.lua b/tests/spec/logout_spec.lua index 7a0fd7d3..6c7eaad3 100644 --- a/tests/spec/logout_spec.lua +++ b/tests/spec/logout_spec.lua @@ -18,7 +18,27 @@ describe("when the configured logout uri is invoked with a non-image request", f end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) + end) +end) + +describe("when the configured logout uri is invoked with Firefox 128's default Accept", function() + test_support.start_server() + teardown(test_support.stop_server) + local _, _, cookie = test_support.login() + local _, status, headers = http.request({ + url = "http://127.0.0.1/default/logout", + headers = { cookie = cookie, accept = "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8" }, + redirect = false + }) + it("the response contains a default HTML-page", function() + assert.are.equals(200, status) + assert.are.equals("text/html", headers["content-type"]) + -- TODO should there be a Cache-Control header? + end) + it("the session cookie has been revoked", function() + assert.truthy(string.match(headers["set-cookie"], + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -38,14 +58,14 @@ describe("when the configured logout uri is invoked with a png request", functio headers = { cookie = cookie, accept = "image/png" }, redirect = false }) - it("the response contains a default HTML-page", function() + it("the response contains a default PNG image", function() assert.are.equals(200, status) assert.are.equals("image/png", headers["content-type"]) assert.are.equals("no-cache, no-store", headers["cache-control"]) end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -76,7 +96,7 @@ describe("when logout is invoked and a callback with hint has been configured", end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -107,7 +127,7 @@ describe("when logout is invoked and a callback with hint has been configured - end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -141,7 +161,7 @@ describe("when logout is invoked and a callback with hint has been configured bu end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -171,7 +191,7 @@ describe("when logout is invoked and a callback without hint has been configured end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -200,7 +220,7 @@ describe("when logout is invoked and discovery contains end_session_endpoint and end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -232,7 +252,7 @@ describe("when logout is invoked and discovery contains end_session_endpoint and end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -257,7 +277,7 @@ describe("when logout is invoked and discovery contains ping_end_session_endpoin end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -294,7 +314,7 @@ describe("when logout is invoked and a callback with hint and a post_logout_uri end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -330,7 +350,7 @@ describe("when logout is invoked and discovery contains end_session_endpoint and end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -361,7 +381,7 @@ describe("when logout is invoked and discovery contains ping_end_session_endpoin end) it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) end) @@ -390,7 +410,7 @@ describe("when revoke_tokens_on_logout is enabled and a valid revocation endpoin it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) it("authorization credentials have not been passed on as post parameters to the revocation endpoint", function() @@ -436,7 +456,7 @@ describe("when revoke_tokens_on_logout is enabled and a valid revocation endpoin it("the session cookie has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) it("authorization header has not been passed on to the revocation endpoint", function() @@ -480,7 +500,7 @@ describe("when revoke_tokens_on_logout is enabled and an invalid revocation endp it("the session cookie still has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) it("error messages concerning unseccussful revocation have been logged", function() @@ -512,7 +532,7 @@ describe("when revoke_tokens_on_logout is enabled but no revocation endpoint is it("the session cookie still has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) it("debug messages concerning unseccussful revocation have been logged", function() @@ -544,7 +564,7 @@ describe("when revoke_tokens_on_logout is not defined and a revocation_endpoint it("the session cookie still has been revoked", function() assert.truthy(string.match(headers["set-cookie"], - "session=; Expires=Thu, 01 Jan 1970 00:00:01 GMT.*")) + "session=; Path=/; SameSite=Lax; HttpOnly; Expires=Thu, 01 Jan 1970 00:00:01 GMT; .*")) end) it("no messages concerning revocation have been logged", function() @@ -552,3 +572,20 @@ describe("when revoke_tokens_on_logout is not defined and a revocation_endpoint assert.is_not.error_log_contains("revoke") end) end) + +describe("when the configured logout uri is invoked with no active session", function() + test_support.start_server() + teardown(test_support.stop_server) + local _, status, headers = http.request({ + url = "http://127.0.0.1/default/logout", + redirect = false + }) + it("the response contains a default HTML-page", function() + assert.are.equals(200, status) + assert.are.equals("text/html", headers["content-type"]) + -- TODO should there be a Cache-Control header? + end) + it("the session cookie has been revoked", function() + assert.is.Nil(headers["set-cookie"]) + end) +end) diff --git a/tests/spec/longer_rsa_key_jwk_with_n_and_e.json b/tests/spec/longer_rsa_key_jwk_with_n_and_e.json index f5b4b376..4e4d71ed 100644 --- a/tests/spec/longer_rsa_key_jwk_with_n_and_e.json +++ b/tests/spec/longer_rsa_key_jwk_with_n_and_e.json @@ -5,7 +5,7 @@ "use": "sig", "kid": "abcd", "e": "AQAB", - "n":"xRycpPkv96kQol6yONWtwwN9GQwEDtt9/Tb+U2hMWwRDfWlvBxXDM80D1yil8DQydQMeTgfoGaMqVLsZhTROluVDvopz308uq0/LbshyockOTRYI+ennVRAKMFnr64/OY/cVkLfgZxGJ9OZknulf/uRuVHhPnSl/2ZSBHXcuWxVwnfsA5CBMJq2JwUPTlay7QVEzXQyo7kUEjP+gkiccvl91Jl5Zk8khEVUg07rxPK8viRORC1DQQY3NAsKn+/BOcoOc7LwHPiE8/5vna5wWe+jllTkuwKOi+W/lMZH/ZlBwWKsnCgZx+N+r8vAstMsdIgkif0kV6Egjb2H15J4s1iaEyavq+QwpyEjOSIVsllXopHKgxNuVUvPSsMsrIhl36ruoGRBHcWlKeYOPD5mIgcH0fm9/Og+gQPx8UqpGsj6EiuWOuwFRJJQfFIFSKzsAkixuqwzIv2s4OgxAYqXaQHflSN4kKBPgWNS+Q86S98vsaekAkuXRfL83NlNZwsBk9gpGuZkgJLWWx3H5I5q39jpG9/v82SrPvTdhZh2UKHfKqBCEeonRu49s5bQswW3WlpZuBW/jx4YO34aKf3HHBeFn3PZDjuUNdxbUmUrRaNB/hqBT0HKmTJ/cX2pV4liZ3nJ0ScwChiuBUogc4Ix/FyZx1ssk+mmyKMznpt1Mnyk=" + "n":"xRycpPkv96kQol6yONWtwwN9GQwEDtt9_Tb-U2hMWwRDfWlvBxXDM80D1yil8DQydQMeTgfoGaMqVLsZhTROluVDvopz308uq0_LbshyockOTRYI-ennVRAKMFnr64_OY_cVkLfgZxGJ9OZknulf_uRuVHhPnSl_2ZSBHXcuWxVwnfsA5CBMJq2JwUPTlay7QVEzXQyo7kUEjP-gkiccvl91Jl5Zk8khEVUg07rxPK8viRORC1DQQY3NAsKn-_BOcoOc7LwHPiE8_5vna5wWe-jllTkuwKOi-W_lMZH_ZlBwWKsnCgZx-N-r8vAstMsdIgkif0kV6Egjb2H15J4s1iaEyavq-QwpyEjOSIVsllXopHKgxNuVUvPSsMsrIhl36ruoGRBHcWlKeYOPD5mIgcH0fm9_Og-gQPx8UqpGsj6EiuWOuwFRJJQfFIFSKzsAkixuqwzIv2s4OgxAYqXaQHflSN4kKBPgWNS-Q86S98vsaekAkuXRfL83NlNZwsBk9gpGuZkgJLWWx3H5I5q39jpG9_v82SrPvTdhZh2UKHfKqBCEeonRu49s5bQswW3WlpZuBW_jx4YO34aKf3HHBeFn3PZDjuUNdxbUmUrRaNB_hqBT0HKmTJ_cX2pV4liZ3nJ0ScwChiuBUogc4Ix_FyZx1ssk-mmyKMznpt1Mnyk" } ] } diff --git a/tests/spec/redirect_from_op_spec.lua b/tests/spec/redirect_from_op_spec.lua index 8da7158f..126a488d 100644 --- a/tests/spec/redirect_from_op_spec.lua +++ b/tests/spec/redirect_from_op_spec.lua @@ -179,3 +179,31 @@ describe("when the redirect_uri and target-uri are specified as absolute URIs", end) end) end) + +describe("when redirect_uri and local_redirect_uri_path are specified", function() + test_support.start_server({ + oidc_opts = { + redirect_uri = 'https://example.com/foo/default-absolute/redirect_uri', + local_redirect_uri_path = '/default-absolute/redirect_uri', + }, + }) + teardown(test_support.stop_server) + local _, _, headers = http.request({ + url = "http://localhost/default-absolute/t", + redirect = false + }) + local state = test_support.grab(headers, 'state') + test_support.register_nonce(headers) + local cookie_header = test_support.extract_cookies(headers) + describe("accessing the redirect_uri path with good parameters", function() + local _, redirStatus, h = http.request({ + url = "http://localhost/default-absolute/redirect_uri?code=foo&state=" .. state, + headers = { cookie = cookie_header }, + redirect = false + }) + it("redirects to the original URI", function() + assert.are.equals(302, redirStatus) + assert.are.equals("http://localhost/default-absolute/t", h.location) + end) + end) +end) diff --git a/tests/spec/redirect_to_op_spec.lua b/tests/spec/redirect_to_op_spec.lua index 97fc3986..05257826 100644 --- a/tests/spec/redirect_to_op_spec.lua +++ b/tests/spec/redirect_to_op_spec.lua @@ -135,7 +135,8 @@ end) describe("when discovery endpoint is not reachable", function() test_support.start_server({ oidc_opts = { - discovery = "http://192.0.2.1/" + timeout = 40000, + discovery = "http://127.1.2.3/" }, }) teardown(test_support.stop_server) @@ -147,7 +148,7 @@ describe("when discovery endpoint is not reachable", function() assert.are.equals(401, status) end) it("an error has been logged", function() - assert.error_log_contains("authenticate failed: accessing discovery url.*%(http://192.0.2.1/%) failed") + assert.error_log_contains("authenticate failed: accessing discovery url.*%(http://127.1.2.3/%) failed") end) end) diff --git a/tests/spec/revoke_tokens_spec.lua b/tests/spec/revoke_tokens_spec.lua new file mode 100644 index 00000000..5706e344 --- /dev/null +++ b/tests/spec/revoke_tokens_spec.lua @@ -0,0 +1,31 @@ +local http = require('socket.http') +local test_support = require('test_support') +require 'busted.runner'() + +describe('when revoke_tokens is successful', function() + test_support.start_server({ + oidc_opts = { + discovery = { + revocation_endpoint = "http://127.0.0.1/revocation", + } + } + }) + teardown(test_support.stop_server) + local _, _, cookies = test_support.login() + local content_table = {} + http.request({ + url = "http://localhost/revoke_tokens", + headers = { cookie = cookies }, + sink = ltn12.sink.table(content_table) + }) + + it('should return true', function() + assert.are.equals("revoke-result: true\n", table.concat(content_table)) + end) + + it('should have logged the revocation', function() + assert.error_log_contains("revocation of refresh_token successful") + assert.error_log_contains("revocation of access_token successful") + end) + +end) diff --git a/tests/spec/rsa_key_jwk_with_broken_x5c.json b/tests/spec/rsa_key_jwk_with_broken_x5c.json new file mode 100644 index 00000000..f7863b5e --- /dev/null +++ b/tests/spec/rsa_key_jwk_with_broken_x5c.json @@ -0,0 +1,10 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "abcd", + "x5c": "MIIC+zCCAeOgAwIBAgIJAOlUwkUgtiAjMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNVBAMMCTEyNy4wLjAuMTAeFw0xNzEwMjAwODAyMzBaFw0yNzEwMTgwODAyMzBaMBQxEjAQBgNVBAMMCTEyNy4wLjAuMTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMLGOeOLW3uZr6jbrkE4i+Cp9LcvfWhOcff8D68qmcWTwfYSDWTGKPbME83EuFwrpVzBZqIV2VaR7L5uX3+5MZij2DeG52Gdv+QnP0JlyHAxRWnNWSYu+ZURlwhDBi7g+rxTq/3/8DeFZQJtf6/pcWxLidwe6fQpQK0XkbRfxi3os0+YAEDedSyVzsIP9buz2KkjtHJ+RWxTGC61J/vJY7ruSVBDkPLBbMHFkTRUqATZ76B/DDtn5lyctbTUZKUUGljiwvu8zK2thp5CjMnP8DcCP0e8ZT5IUBN73VYksvf3Die8+axKUuFAyYRjv8mKbWXRFwJyUelC72R6pvcTBzcCAwEAAaNQME4wHQYDVR0OBBYEFEp61+QOEp6ZR/GpTood068poIf3MB8GA1UdIwQYMBaAFEp61+QOEp6ZR/GpTood068poIf3MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBADqM/3yygJOF+k8AXPk2AGOfF7EMtm8GYVETUBwunYqgPHMoZj/+/IfHYg0BNx4T5Uh7yABvICO8KCpIrog0boopCNBlrkKs1L8WGnK9L8EQinA2JM+rL1UBAuPVFLGdT9LEjhwoJsRrRet9Pt79+iHP4kS2priuwKHOzsGwmj7Ptstx4lf6dOwElgGlxNDI8YnG4a4NlOQ0HMGtaS/bvB8hMPGRb8uCmnlrPVOQFOkUCwTTA5D1DjWmXg+aCwGf313MyH8y0TQ8aNkxJkcHHGTZELXmS+t9BnYvpNm+sQkqfVkwxgnP/R8wPH36rnID6QSH6/mFhz6S21qK5p3vVys=" + } + ] +} diff --git a/tests/spec/rsa_key_jwk_with_n_and_e_and_empty_x5c.json b/tests/spec/rsa_key_jwk_with_n_and_e_and_empty_x5c.json new file mode 100644 index 00000000..607db767 --- /dev/null +++ b/tests/spec/rsa_key_jwk_with_n_and_e_and_empty_x5c.json @@ -0,0 +1,12 @@ +{ + "keys": [ + { + "kty": "RSA", + "use": "sig", + "kid": "abcd", + "x5c": [], + "e": "AQAB", + "n": "AMLGOeOLW3uZr6jbrkE4i-Cp9LcvfWhOcff8D68qmcWTwfYSDWTGKPbME83EuFwrpVzBZqIV2VaR7L5uX3-5MZij2DeG52Gdv-QnP0JlyHAxRWnNWSYu-ZURlwhDBi7g-rxTq_3_8DeFZQJtf6_pcWxLidwe6fQpQK0XkbRfxi3os0-YAEDedSyVzsIP9buz2KkjtHJ-RWxTGC61J_vJY7ruSVBDkPLBbMHFkTRUqATZ76B_DDtn5lyctbTUZKUUGljiwvu8zK2thp5CjMnP8DcCP0e8ZT5IUBN73VYksvf3Die8-axKUuFAyYRjv8mKbWXRFwJyUelC72R6pvcTBzc" + } + ] +} diff --git a/tests/spec/test_support.lua b/tests/spec/test_support.lua index 475c0ee7..688a3a91 100644 --- a/tests/spec/test_support.lua +++ b/tests/spec/test_support.lua @@ -90,6 +90,54 @@ local DEFAULT_UNAUTH_ACTION = "nil" local DEFAULT_DELAY_RESPONSE = "0" +local DEFAULT_INIT_TEMPLATE = [[ +local test_globals = {} +local sign_secret = [=[ +JWT_SIGN_SECRET]=] + +if os.getenv('coverage') then + require("luacov.runner")("/spec/luacov/settings.luacov") +end +test_globals.oidc = require "resty.openidc" +test_globals.cjson = require "cjson" +test_globals.delay = function(delay_response) + if delay_response > 0 then + ngx.sleep(delay_response / 1000) + end +end +test_globals.b64url = function(s) + return ngx.encode_base64(test_globals.cjson.encode(s)):gsub('+','-'):gsub('/','_') +end +test_globals.create_jwt = function(payload, fake_signature) + if not fake_signature then + local jwt_content = { + header = TOKEN_HEADER, + payload = payload + } + local jwt = require "resty.jwt" + return jwt:sign(sign_secret, jwt_content) + else + local header = test_globals.b64url({ + typ = "JWT", + alg = "AB256" + }) + return header .. "." .. test_globals.b64url(payload) .. ".NOT_A_VALID_SIGNATURE" + end +end +test_globals.query_decorator = function(req) + req.query = "foo=bar" + return req +end +test_globals.body_decorator = function(req) + local body = ngx.decode_args(req.body) + body.foo = "bar" + req.body = ngx.encode_args(body) + return req +end +test_globals.jwks = [=[JWK]=] +return test_globals +]] + local DEFAULT_CONFIG_TEMPLATE = [[ worker_processes 1; pid /tmp/server/logs/nginx.pid; @@ -101,51 +149,10 @@ events { http { access_log /tmp/server/logs/access.log; - lua_package_path '~/lua/?.lua;;'; + lua_package_path '~/lua/?.lua;/tmp/server/conf/?.lua;;'; lua_shared_dict discovery 1m; init_by_lua_block { - sign_secret = [=[ -JWT_SIGN_SECRET]=] - if os.getenv('coverage') then - require("luacov.runner")("/spec/luacov/settings.luacov") - end - oidc = require "resty.openidc" - cjson = require "cjson" - delay = function(delay_response) - if delay_response > 0 then - ngx.sleep(delay_response / 1000) - end - end - b64url = function(s) - return ngx.encode_base64(cjson.encode(s)):gsub('+','-'):gsub('/','_') - end - create_jwt = function(payload, fake_signature) - if not fake_signature then - local jwt_content = { - header = TOKEN_HEADER, - payload = payload - } - local jwt = require "resty.jwt" - return jwt:sign(sign_secret, jwt_content) - else - local header = b64url({ - typ = "JWT", - alg = "AB256" - }) - return header .. "." .. b64url(payload) .. ".NOT_A_VALID_SIGNATURE" - end - end - query_decorator = function(req) - req.query = "foo=bar" - return req - end - body_decorator = function(req) - local body = ngx.decode_args(req.body) - body.foo = "bar" - req.body = ngx.encode_args(body) - return req - end - jwks = [=[JWK]=] + test_globals = require("test_globals") } resolver 8.8.8.8; @@ -153,14 +160,14 @@ JWT_SIGN_SECRET]=] server { log_subrequest on; - listen 80; + listen 127.0.0.1:80; #listen 443 ssl; #ssl_certificate certificate-chain.crt; #ssl_certificate_key private.key; location /jwt { content_by_lua_block { - local jwt_token = create_jwt(ACCESS_TOKEN, FAKE_ACCESS_TOKEN_SIGNATURE) + local jwt_token = test_globals.create_jwt(ACCESS_TOKEN, FAKE_ACCESS_TOKEN_SIGNATURE) ngx.header.content_type = 'text/plain' ngx.say(jwt_token) } @@ -168,10 +175,10 @@ JWT_SIGN_SECRET]=] location /jwk { content_by_lua_block { - ngx.log(ngx.ERR, "jwk uri_args: " .. cjson.encode(ngx.req.get_uri_args())) + ngx.log(ngx.ERR, "jwk uri_args: " .. test_globals.cjson.encode(ngx.req.get_uri_args())) ngx.header.content_type = 'application/json;charset=UTF-8' - delay(JWK_DELAY_RESPONSE) - ngx.say(jwks) + test_globals.delay(JWK_DELAY_RESPONSE) + ngx.say(test_globals.jwks) } } @@ -183,9 +190,9 @@ JWT_SIGN_SECRET]=] access_by_lua_block { local opts = OIDC_CONFIG if opts.decorate then - opts.http_request_decorator = opts.decorate == "body" and body_decorator or query_decorator + opts.http_request_decorator = opts.decorate == "body" and test_globals.body_decorator or test_globals.query_decorator end - local res, err, target, session = oidc.authenticate(opts, nil, UNAUTH_ACTION) + local res, err, target, session = test_globals.oidc.authenticate(opts, nil, UNAUTH_ACTION) if err then ngx.status = 401 ngx.log(ngx.ERR, "authenticate failed: " .. err) @@ -204,10 +211,10 @@ JWT_SIGN_SECRET]=] access_by_lua_block { local opts = OIDC_CONFIG if opts.decorate then - opts.http_request_decorator = opts.decorate == "body" and body_decorator or query_decorator + opts.http_request_decorator = opts.decorate == "body" and test_globals.body_decorator or test_globals.query_decorator end local uri = ngx.var.scheme .. "://" .. ngx.var.host .. ngx.var.request_uri - local res, err, target, session = oidc.authenticate(opts, uri, UNAUTH_ACTION) + local res, err, target, session = test_globals.oidc.authenticate(opts, uri, UNAUTH_ACTION) if err then ngx.status = 401 ngx.log(ngx.ERR, "authenticate failed: " .. err) @@ -253,13 +260,13 @@ JWT_SIGN_SECRET]=] end local jwt_token if NONE_ALG_ID_TOKEN_SIGNATURE then - local header = b64url({ + local header = test_globals.b64url({ typ = "JWT", alg = "none" }) - jwt_token = header .. "." .. b64url(id_token) .. "." + jwt_token = header .. "." .. test_globals.b64url(id_token) .. "." else - jwt_token = create_jwt(id_token, FAKE_ID_TOKEN_SIGNATURE) + jwt_token = test_globals.create_jwt(id_token, FAKE_ID_TOKEN_SIGNATURE) if BREAK_ID_TOKEN_SIGNATURE then jwt_token = jwt_token:sub(1, -6) .. "XXXXX" end @@ -272,8 +279,8 @@ JWT_SIGN_SECRET]=] if args.grant_type == "authorization_code" or REFRESH_RESPONSE_CONTAINS_ID_TOKEN then token_response.id_token = jwt_token end - delay(TOKEN_DELAY_RESPONSE) - ngx.say(cjson.encode(token_response)) + test_globals.delay(TOKEN_DELAY_RESPONSE) + ngx.say(test_globals.cjson.encode(token_response)) } } @@ -281,24 +288,24 @@ JWT_SIGN_SECRET]=] content_by_lua_block { local opts = VERIFY_OPTS if opts.decorate then - opts.http_request_decorator = query_decorator + opts.http_request_decorator = test_globals.query_decorator end - local json, err, token = oidc.bearer_jwt_verify(opts) + local json, err, token = test_globals.oidc.bearer_jwt_verify(opts) if err then ngx.status = 401 ngx.log(ngx.ERR, "Invalid token: " .. err) else ngx.status = 204 - ngx.log(ngx.ERR, "Valid token: " .. cjson.encode(json)) + ngx.log(ngx.ERR, "Valid token: " .. test_globals.cjson.encode(json)) end } } location /discovery { content_by_lua_block { - ngx.log(ngx.ERR, "discovery uri_args: " .. cjson.encode(ngx.req.get_uri_args())) + ngx.log(ngx.ERR, "discovery uri_args: " .. test_globals.cjson.encode(ngx.req.get_uri_args())) ngx.header.content_type = 'application/json;charset=UTF-8' - delay(DISCOVERY_DELAY_RESPONSE) + test_globals.delay(DISCOVERY_DELAY_RESPONSE) ngx.say([=[{ "authorization_endpoint": "http://127.0.0.1/authorize", "token_endpoint": "http://127.0.0.1/token", @@ -311,11 +318,20 @@ JWT_SIGN_SECRET]=] location /user-info { content_by_lua_block { - delay(USERINFO_DELAY_RESPONSE) + test_globals.delay(USERINFO_DELAY_RESPONSE) local auth = ngx.req.get_headers()["Authorization"] ngx.log(ngx.ERR, "userinfo authorization header: " .. (auth and auth or "")) ngx.header.content_type = 'application/json;charset=UTF-8' - ngx.say(cjson.encode(USERINFO)) + ngx.say(test_globals.cjson.encode(USERINFO)) + } + } + + location /user-info-signed { + content_by_lua_block { + local auth = ngx.req.get_headers()["Authorization"] + ngx.header.content_type = 'application/jwt;charset=UTF-8' + local signed_userinfo = test_globals.create_jwt(USERINFO) + ngx.print(signed_userinfo) } } @@ -337,8 +353,8 @@ JWT_SIGN_SECRET]=] ngx.log(ngx.ERR, "no cookie in introspection call") end ngx.header.content_type = 'application/json;charset=UTF-8' - delay(INTROSPECTION_DELAY_RESPONSE) - ngx.say(cjson.encode(INTROSPECTION_RESPONSE)) + test_globals.delay(INTROSPECTION_DELAY_RESPONSE) + ngx.say(test_globals.cjson.encode(INTROSPECTION_RESPONSE)) } } @@ -346,22 +362,22 @@ JWT_SIGN_SECRET]=] content_by_lua_block { local opts = INTROSPECTION_OPTS if opts.decorate then - opts.http_request_decorator = body_decorator + opts.http_request_decorator = test_globals.body_decorator end - local json, err = oidc.introspect(opts) + local json, err = test_globals.oidc.introspect(opts) if err then ngx.status = 401 ngx.log(ngx.ERR, "Introspection error: " .. err) else ngx.header.content_type = 'application/json;charset=UTF-8' - ngx.say(cjson.encode(json)) + ngx.say(test_globals.cjson.encode(json)) end } } location /access_token { content_by_lua_block { - local access_token, err = oidc.access_token(ACCESS_TOKEN_OPTS) + local access_token, err = test_globals.oidc.access_token(ACCESS_TOKEN_OPTS) if not access_token then ngx.status = 401 ngx.log(ngx.ERR, "access_token error: " .. (err or 'no message')) @@ -372,6 +388,16 @@ JWT_SIGN_SECRET]=] } } + location /revoke_tokens { + content_by_lua_block { + local opts = OIDC_CONFIG + local res, err, target, session = test_globals.oidc.authenticate(opts, nil, UNAUTH_ACTION) + local r = test_globals.oidc.revoke_tokens(opts, session) + ngx.header.content_type = 'text/plain' + ngx.say('revoke-result: ' .. tostring(r)) + } + } + location /revocation { content_by_lua_block { ngx.req.read_body() @@ -383,7 +409,7 @@ JWT_SIGN_SECRET]=] ngx.log(ngx.ERR, "no cookie in introspection call") end ngx.header.content_type = 'application/json;charset=UTF-8' - delay(REVOCATION_DELAY_RESPONSE) + test_globals.delay(REVOCATION_DELAY_RESPONSE) ngx.status = 200 ngx.say('INVALID JSON.') } @@ -414,7 +440,7 @@ end local DEFAULT_INTROSPECTION_RESPONSE = merge({active=true}, DEFAULT_ACCESS_TOKEN) -local function write_config(out, custom_config) +local function write_template(out, template, custom_config) custom_config = custom_config or {} local oidc_config = merge(merge({}, DEFAULT_OIDC_CONFIG), custom_config["oidc_opts"] or {}) local id_token = merge(merge({}, DEFAULT_ID_TOKEN), custom_config["id_token"] or {}) @@ -454,7 +480,7 @@ local function write_config(out, custom_config) for _, k in ipairs(custom_config["remove_introspection_config_keys"] or {}) do introspection_opts[k] = nil end - local config = DEFAULT_CONFIG_TEMPLATE + local content = template :gsub("OIDC_CONFIG", serpent.block(oidc_config, {comment = false })) :gsub("TOKEN_HEADER", serpent.block(token_header, {comment = false })) :gsub("JWT_SIGN_SECRET", custom_config["jwt_sign_secret"] or DEFAULT_JWT_SIGN_SECRET) @@ -482,7 +508,7 @@ local function write_config(out, custom_config) :gsub("ID_TOKEN", serpent.block(id_token, {comment = false })) :gsub("ACCESS_TOKEN", serpent.block(access_token, {comment = false })) :gsub("UNAUTH_ACTION", custom_config["unauth_action"] and ('"' .. custom_config["unauth_action"] .. '"') or DEFAULT_UNAUTH_ACTION) - out:write(config) + out:write(content) end -- starts a server instance with some customizations of the configuration. @@ -525,8 +551,11 @@ function test_support.start_server(custom_config) assert(os.execute("rm -rf /tmp/server"), "failed to remove old server dir") assert(os.execute("mkdir -p /tmp/server/conf"), "failed to create server dir") assert(os.execute("mkdir -p /tmp/server/logs"), "failed to create log dir") - local out = assert(io.open("/tmp/server/conf/nginx.conf", "w")) - write_config(out, custom_config) + local out = assert(io.open("/tmp/server/conf/test_globals.lua", "w")) + write_template(out, DEFAULT_INIT_TEMPLATE, custom_config) + assert(out:close()) + out = assert(io.open("/tmp/server/conf/nginx.conf", "w")) + write_template(out, DEFAULT_CONFIG_TEMPLATE, custom_config) assert(out:close()) assert(os.execute("openresty -c /tmp/server/conf/nginx.conf > /dev/null"), "failed to start nginx") end @@ -537,7 +566,7 @@ local function kill(pid, signal) else signal = "-" .. signal .. " " end - return os.execute("/bin/kill " .. signal .. pid) + return os.execute("/bin/sh -c '/bin/kill " .. signal .. pid .. "' 2>/dev/null") end local function is_running(pid) @@ -582,7 +611,8 @@ end -- returns a Cookie header value based on all cookies requested via -- Set-Cookie inside headers function test_support.extract_cookies(headers) - local pair = headers["set-cookie"] or '' + local h = headers or {} + local pair = h["set-cookie"] or '' local semi = pair:find(";") if semi then pair = pair:sub(1, semi - 1) diff --git a/tests/spec/token_request_spec.lua b/tests/spec/token_request_spec.lua index 320f4109..c1839aec 100644 --- a/tests/spec/token_request_spec.lua +++ b/tests/spec/token_request_spec.lua @@ -151,8 +151,9 @@ end) describe("if token endpoint is not reachable", function() test_support.start_server({ oidc_opts = { + timeout = 40000, discovery = { - token_endpoint = "http://192.0.2.1/" + token_endpoint = "http://127.1.2.3/" } }, }) @@ -162,7 +163,7 @@ describe("if token endpoint is not reachable", function() assert.are.equals(401, status) end) it("an error has been logged", function() - assert.error_log_contains("authenticate failed:.*accessing token endpoint %(http://192.0.2.1/%) failed") + assert.error_log_contains("authenticate failed:.*accessing token endpoint %(http://127.1.2.3/%) failed") end) end) @@ -266,7 +267,8 @@ local function extract_jwt_from_error_log() local enc_hdr, enc_payload, enc_sign = string.match(encoded_jwt, '^(.+)%.(.+)%.(.*)$') local base64_url_decode = function(s) local mime = require "mime" - return mime.unb64(s:gsub('-','+'):gsub('_','/')) + local padding = (4 - #s % 4) % 4 + return mime.unb64(s:gsub('-','+'):gsub('_','/') .. string.rep("=", padding)) end local dkjson = require "dkjson" return { diff --git a/tests/spec/userinfo_spec.lua b/tests/spec/userinfo_spec.lua index da30b887..78f79c06 100644 --- a/tests/spec/userinfo_spec.lua +++ b/tests/spec/userinfo_spec.lua @@ -73,8 +73,9 @@ end) describe("when userinfo endpoint is not reachable", function() test_support.start_server({ oidc_opts = { + timeout = 40000, discovery = { - userinfo_endpoint = "http://192.0.2.1/" + userinfo_endpoint = "http://127.1.2.3/" } }, }) @@ -84,7 +85,7 @@ describe("when userinfo endpoint is not reachable", function() assert.are.equals(302, status) end) it("an error has been logged", function() - assert.error_log_contains(".*error calling userinfo endpoint: accessing %(http://192.0.2.1/%) failed") + assert.error_log_contains(".*error calling userinfo endpoint: accessing %(http://127.1.2.3/%) failed") end) end) @@ -168,3 +169,26 @@ describe("when userinfo endpoint doesn't return proper JSON", function() assert.error_log_contains("JSON decoding failed") end) end) + +describe("when userinfo endpoint returns a JWT", function() + test_support.start_server({ + oidc_opts = { + discovery = { + userinfo_endpoint = "http://127.0.0.1/user-info-signed", + token_endpoint_auth_methods_supported = { "private_key_jwt" }, + }, + token_endpoint_auth_method = "private_key_jwt", + client_rsa_private_key = test_support.load("/spec/private_rsa_key.pem"), + public_key = test_support.load("/spec/public_rsa_key.pem"), + }, + }) + teardown(test_support.stop_server) + local _, status = test_support.login() + it("login succeeds", function() + assert.are.equals(302, status) + end) + it("an error has not been logged", function() + assert.is_not.error_log_contains("JSON decoding failed") + assert.is_not.error_log_contains("userinfo jwt could not be verified") + end) +end)