OpenID Connect Identity Provider
This section describes how you can configure the Ceptor Gateway to act as an OpenID Connect Provider. (please note that this requires minimum Ceptor v5.66)
Introduction
There exist numerous ways of using Ceptor as an OpenID Connect Identity Provider. In general, it requires that you expose authorize, token, userinfo etc. endpoints - and this can be done with the help of a login application (see the Demo Application included within the Ceptor distribution for examples of this) or you can do most of it within the Ceptor Gateway configuration so your applications do not need to bother with it.
The configuration shown here provides the following endpoints:
- /oauth2/auth or /oauth2/authorize - OpenID Connect Auth endpoint (the two are just different names for the same).
- /oauth2/token - OpenID Connect Token endpoint.
- /oauth2/userinfo - OpenID Connect Userinfo endpoint - requires previously issued access-token as bearer token to call it.
- /oauth2/jwks.json . OpenID Connect JWK key list endpoint.
- /oauth2/confirm - Called to confirm the user's consent to redirect back to the resource provider.
- /oauth2/reject - Called if the user rejects consent, and will result in the user being redirected back to the resource provider with an error message instead of his id_token.
- /oauth2/introspect - OAuth2 introspection endpoint - see https://tools.ietf.org/html/rfc7662 for info. (Requires v5.70.3 or higher).
- /oauth2/revoke - OAuth2 token revocation endpoint - see https://tools.ietf.org/html/rfc7009 for info. (Requires v5.70.3 or higher).
The endpoints depend on configuration for JWT/OpenID Connect tokens, which is documented here: JWT / OpenID Connect
Example JSON Configuration for OpenID Connect Discovery
This Gateway Location provides OpenID Connect discovery information - you might need to edit it to change URLs, or supported claims / scopes etc.
You can cut'n paste it into the Gateway Configuration as a complete location.
{ "response.contenttype": "application/json;charset=UTF8", "location.enabled": true, "content.preload": false, "description": "Discovery URL for OpenID Connect configuration", "session.override": false, "response.reason": "OK", "cookiesnapper": {}, "plugin": {}, "valid.methods": "GET", "session.needed": false, "response.compress": false, "name": "OpenID Connect IDP Configuration", "conditions.type": "and", "action": "respond", "conditions": [ { "deny": false, "values": ["/.well-known/openid-configuration"], "type": "path" }, { "deny": false, "values": ["https"], "type": "scheme" }, { "deny": false, "values": ["GET"], "type": "method" } ], "response.status": 200, "response.content": "{\r\n \"issuer\": \"https://test.portalprotect.dk\",\r\n \"authorization_endpoint\": \"https://%{HTTP_HOST}/oauth2/auth\",\r\n \"token_endpoint\": \"https://%{HTTP_HOST}/oauth2/token\",\r\n \"userinfo_endpoint\": \"https://%{HTTP_HOST}/oauth2/userinfo\",\r\n \"end_session_endpoint\": \"https://%{HTTP_HOST}/oauth2/logout\",\r\n \"jwks_uri\": \"https://%{HTTP_HOST}/oauth2/jwks.json\",\r\n \"scopes_supported\": [\"openid\", \"profile\", \"email\", \"address\", \"phone\"],\r\n \"response_types_supported\": [\"code\", \"code id_token\", \"id_token\", \"token id_token\"],\r\n \"subject_types_supported\": [\"public\", \"pairwise\"],\r\n \"userinfo_signing_alg_values_supported\": [\"RS256\", \"ES256\", \"HS256\"],\r\n \"id_token_signing_alg_values_supported\": [\"RS256\", \"ES256\", \"HS256\"],\r\n \"claims_supported\": [\"sub\", \"iss\", \"auth_time\", \"acr\", \"name\", \"given_name\", \"family_name\", \"nickname\", \"profile\", \"picture\", \"website\", \"email\", \"email_verified\", \"locale\"],\r\n \"grant_types_supported\": [\"authorization_code\", \"implicit\", \"refresh_token\"],\r\n \"token_endpoint_auth_methods_supported\": [\"client_secret_post\", \"client_secret_basic\"]\r\n}" },
Example JSON Configuration for OAuth2 Locations
Below, is an example of a location configuration you can cut'n paste into the Gateway Configuration. This is provided as a location with several nested locations - all capable of handling the various OAuth2 / OpenID Connect endpoints without requiring separate applications.
Please note that the example below does point to a login page, and a confirm page where you can provider your own implementation to login and prompt the user for acceptance.
{ "location.enabled": true, "content.preload": false, "description": "Contains handling of OAuth2 requests, from auth to tokens/userinfo etc.", "session.override": false, "cookiesnapper": {}, "plugin": {}, "session.needed": false, "response.compress": false, "name": "OAuth2 / OpenID Connect", "conditions.type": "and", "action": "continue", "locations": [ { "location.enabled": true, "content.preload": true, "plugin": {}, "session.needed": false, "response.compress": false, "name": "userinfo", "description": "Handles requests for the userinfo", "session.override": false, "locations": [{ "location.enabled": true, "content.preload": true, "session": { "resolvers": [ "io.ceptor.session.SessionResolverBearerToken", "io.ceptor.session.SessionResolverScript" ], "resolve.script": "%{script}var token = context.getQueryOrPostParam('access_token');\ncontext.trace.trace(\"access_token: \" + token);\n\nif (token !== null) {\n var id = context.agent.getSessionFromTicket(48, token, context.config.gateway.segmentId, context.config.gateway.clusterId, context.gateway.getClientSourceIP(context));\n context.trace.trace(\"Got session ID: \" + id + \" from access token\");\n if (id !== null) {\n var UniqueId = Java.type(\"dk.itp.security.utils.UniqueId\");\n \n context.sessionId = new UniqueId(id);\n context.trace.trace(\"Session ID on context is now: \" + context.sessionId);\n }\n} else {\n context.trace.trace(\"access_token was null, so no token found in body\");\n}", "cookie.samesite": "none", "cookie.use.httponly": false, "sessionfixation.addcookie": false, "sessionfixation.defense": false, "cookie.obfuscate": false, "cookie.use.domain": false }, "description": "Handles requests for the userinfo", "session.override": true, "cookiesnapper": {}, "plugin": {}, "session.needed": true, "response.compress": true, "name": "process userinfo request", "conditions.type": "and", "conditions": [{ "deny": false, "values": ["{uripath}/*/userinfo"], "type": "path" }], "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n\n if (state.agent.getTicketFromSession(state.id) === null) {\n helper.handleError(\"invalid_request\", \"Session is not a bearer token\");\n return 'RESPOND';\n }\n\n try {\n var responseProperties = helper.handleUserinfoRequest();\n var userinfo = responseProperties.getProperty(\"userinfo\");\n if (userinfo === null)\n helper.handleError(\"invalid_request\", \"No userinfo available\");\n else\n state.gateway.sendResponse(state, 200, \"OK\", \"application/json;charset=UTF-8\", userinfo);\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }], "cookiesnapper": {}, "conditions": [{ "deny": false, "values": ["{uripath}/*/userinfo"], "type": "path" }] }, { "location.enabled": true, "content.preload": true, "plugin": {}, "session.needed": false, "response.compress": false, "name": "introspect", "description": "Handles requests for token introspection", "session.override": false, "locations": [ { "location.enabled": true, "content.preload": true, "plugin": {}, "session": { "cookie.not.for.uri": "*", "resolvers": [ "io.ceptor.session.SessionResolverBearerToken", "io.ceptor.session.SessionResolverCookie" ], "cookie.samesite": "none", "cookie.use.httponly": false, "sessionfixation.addcookie": false, "sessionfixation.defense": false, "cookie.obfuscate": false, "cookie.use.domain": false }, "session.needed": true, "response.compress": true, "name": "introspect bearer or basic auth", "description": "Sets up basic auth for introspect, if not already authenticated with bearer token", "session.override": true, "action": "continue", "cookiesnapper": {}, "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorBasicAuth"], "basicauth": { "authenticationplugin": 48, "required": true } } }, { "location.enabled": true, "content.preload": true, "plugin": {}, "session.needed": true, "response.compress": true, "name": "process introspect request", "description": "Handles requests for the introspect", "session.override": false, "cookiesnapper": {}, "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n\n try {\n helper.handleIntrospectRequest();\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n }\n return 'RESPOND';\n}" } } } ], "cookiesnapper": {}, "conditions": [{ "deny": false, "values": ["{uripath}/*/introspect"], "type": "path" }] }, { "location.enabled": true, "content.preload": true, "plugin": {}, "session.needed": false, "response.compress": false, "name": "revoke", "description": "Handles requests for token revokation", "session.override": false, "locations": [ { "location.enabled": true, "content.preload": true, "plugin": {}, "session": { "cookie.not.for.uri": "*", "resolvers": [ "io.ceptor.session.SessionResolverBearerToken", "io.ceptor.session.SessionResolverCookie" ], "cookie.samesite": "none", "cookie.use.httponly": false, "sessionfixation.addcookie": false, "sessionfixation.defense": false, "cookie.obfuscate": false, "cookie.use.domain": false }, "session.needed": true, "response.compress": true, "name": "Revoke bearer or basic auth", "description": "Sets up basic auth for revokation, if not already authenticated with bearer token", "session.override": true, "action": "continue", "cookiesnapper": {}, "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorBasicAuth"], "basicauth": { "authenticationplugin": 48, "required": true } } }, { "location.enabled": true, "content.preload": true, "plugin": {}, "session.needed": true, "response.compress": true, "name": "process revoke request", "description": "Handles requests for the revocation", "session.override": false, "cookiesnapper": {}, "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n\n try {\n helper.handleRevokeRequest();\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n }\n return 'RESPOND';\n}" } } } ], "cookiesnapper": {}, "conditions": [{ "deny": false, "values": ["{uripath}/*/revoke"], "type": "path" }] }, { "name": "auth", "description": "Handles authenticate requests", "conditions": [{ "deny": false, "values": [ "{uripath}/*/auth", "{uripath}/*/authorize" ], "type": "path" }], "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n \n try {\n helper.validateAuthRequest();\n \n // Save request in session for later use\n helper.saveAuthRequest();\n \n var input = helper.getInput();\n var isLoggedOn = context.agent.isLoggedOn(context.id);\n \n var shouldAlwaysPrompt = \"login\".equals(input.getProperty(\"prompt\"));\n var maxAgeExpired = false;\n \n // Verify max_age, if present\n if (isLoggedOn) {\n var maxAge = input.getProperty(\"max_age\");\n if (maxAge !== null) {\n var loginTimestamp = context.getSessionVariable(\"pp_login_timestamp\");\n if (maxAge < ((Java.type(\"java.lang.System\").currentTimeMillis() - loginTimestamp) / 1000)) {\n maxAgeExpired = true;\n context.trace.trace(\"max_age set to \" + maxAge + \" which is expired, so reauthentication is needed.\");\n }\n }\n }\n if (!isLoggedOn || shouldAlwaysPrompt || maxAgeExpired) {\n // Not logged on, so redirect to login page\n \n if (isLoggedOn) {\n context.trace.trace(\"Already logged in, so we should always prompt or max_age is expired so logging off\");\n context.agent.logoff(context.id);\n }\n \n context.agent.setStateVariable(context.id, \"oauth2.requested.scope\", input.getProperty(\"scope\"), false);\n context.agent.setStateVariable(context.id, \"oauth2.requested.clientid\", input.getProperty(\"client_id\"), false);\n \n if (\"none\".equals(input.getProperty(\"prompt\"))) {\n // Fail if not already logged in, and prompt=none\n helper.handleError(\"login_required\", \"No user authenticated\");\n return 'RESPOND';\n }\n var url = \"/demologin.jsp?originalUrl=/confirm_oauth.jsp\";\n var login_hint = input.getProperty(\"login_hint\");\n if (login_hint !== null)\n url = url + \"&hint=\"+Java.type(\"java.net.URLEncoder\").encode(login_hint);\n context.gateway.sendRedirect(context, 302, url)\n return 'RESPOND';\n }\n \n context.agent.setStateVariable(context.id, \"oauth2.requested.scope\", input.getProperty(\"scope\"), false);\n context.agent.setStateVariable(context.id, \"oauth2.requested.clientid\", input.getProperty(\"client_id\"), false);\n \n context.gateway.sendRedirect(context, 302, \"/confirm_oauth.jsp\");\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }, { "name": "confirm", "description": "Handles when user has confirmed a previous oauth2 request, allowing redirect to proceed", "conditions": [{ "deny": false, "values": ["{uripath}/*/confirm"], "type": "path" }], "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n \n try {\n helper.processSavedAuthRequest(true);\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }, { "name": "reject", "description": "Handles when user has rejected the request", "conditions": [{ "deny": false, "values": ["{uripath}/*/reject"], "type": "path" }], "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": false, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n \n try {\n helper.processSavedAuthRequest(false);\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }, { "content.preload": true, "plugin": {}, "valid.methods": "OPTIONS|POST", "session.needed": true, "response.compress": true, "name": "token", "description": "Handles the token url, capable of e.g. mapping from authorization code to a token", "conditions": [{ "deny": false, "values": ["{uripath}/*/token"], "type": "path" }], "cookiesnapper": {}, "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": true, "authentication.script": "%{script}auth();\n\nfunction auth() {\n // Now, oauth2.input is a properties object containing all request attributes\n // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(state);\n \n try {\n var responseProperties = helper.handleTokenRequest();\n\n var json = {\n// You can add your own properties here\n// test: false\n };\n \n for each(var name in responseProperties.keySet()) {\n var val = responseProperties.getProperty(name);\n if (name === \"expires_in\")\n json[name] = val*1; // Integer\n else\n json[name] = val;\n }\n \n state.gateway.sendResponse(state, 200, \"OK\", \"application/json;charset=UTF-8\", JSON.stringify(json));\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleTokenError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }, { "response.contenttype": "application/json;charset=UTF-8", "content.preload": false, "plugin": {}, "session.needed": true, "response.compress": true, "name": "jwks", "action": "respond", "response.reason": "OK", "conditions": [{ "deny": false, "values": ["{uripath}/*/jwks.json"], "type": "path" }], "response.status": 200, "response.content": "%{script}getjwks();\n\nfunction getjwks() {\n return state.agent.executeAuthpluginCommand(state.id, 48, \"jwks\", state.httpExchange.getHostAndPort());\n}", "cookiesnapper": {} }, { "location.enabled": true, "response.contenttype": "text/html", "content.preload": false, "description": "Logoff the user", "session.override": false, "cookiesnapper": {}, "plugin": {}, "session.needed": true, "response.compress": true, "name": "logout", "conditions.type": "and", "action": "respond", "conditions": [{ "deny": false, "values": ["{uripath}/*/logout"], "type": "path" }], "response.status": 200, "response.content": "<html>\n<head><title>Logged off<\/title><\/head>\n<body>You are now logged off.<\/body>\n<\/html>", "authentication": { "noauthentication.for.options": false, "plugins": ["io.ceptor.authentication.AuthenticatorScript"], "script": { "content.preload": true, "authentication.script": "%{script}auth();\n\nfunction auth() {\n var OAuth2Helper = Java.type(\"io.ceptor.gateway.oauth2.Helper\");\n var helper = new OAuth2Helper(context);\n \n try {\n var result = helper.validateLogoutRequest();\n \n if (context.agent.isLoggedOn(context.id))\n context.agent.logoff(context.id);\n \n var url = result.getProperty(\"logouturl\");\n if (url !== null) {\n context.trace.trace(\"Redirecting to logouturl: \" + url);\n context.gateway.sendRedirect(context, 302, url);\n return 'RESPOND';\n } else {\n return 'CONTINUE';\n }\n } catch(err if err instanceof java.lang.Exception) {\n helper.handleError(err);\n return 'RESPOND';\n }\n return 'RESPOND';\n}" } } }, { "location.enabled": true, "content.preload": false, "response.name": "Access Denied", "description": "Handles any sub-urls to the /oauth2 by denying them", "session.override": false, "cookiesnapper": {}, "plugin": {}, "session.needed": false, "response.compress": true, "name": "Default - deny access", "action": "respond", "conditions": [], "response.status": 403 } ], "conditions": [ { "deny": false, "values": ["/oauth2/*"], "type": "path" }, { "deny": true, "values": ["*.jsp"], "type": "path" } ] },
If you look at it in the Gateway Configuration in the Ceptor Console, you should get a screen similar to this:
Here, you have one location, called "OAuth2" and several nested configurations on it.
The top one simply picks up any requests to /oauth2/* and leaves further processing to the nested locations - then there is one configuration for each sub URL.
If we take a look at "auth", go to Authentication→Authentication Script and you will see the following:
auth(); function auth() { // Now, oauth2.input is a properties object containing all request attributes // and oauth2.helper points to an OAuth2Helper object that will help with the validation and call to session controller var OAuth2Helper = Java.type("io.ceptor.gateway.oauth2.Helper"); var helper = new OAuth2Helper(state); try { helper.validateAuthRequest(); // Save request in session for later use helper.saveAuthRequest(); var input = helper.getInput(); var shouldAlwaysPrompt = "prompt".equals(input.getProperty("login")); if (!state.agent.isLoggedOn(state.id) || shouldAlwaysPrompt) { // Not logged on, so redirect to login page if ("none".equals(input.getProperty("prompt"))) { // Fail if not already logged in, and prompt=none helper.handleError("login_required", "No user authenticated"); return 'RESPOND'; } var url = "/demologin.jsp?originalUrl=/confirm_oauth.jsp"; var login_hint = input.getProperty("login_hint"); if (login_hint !== null) url = url + "&hint="+java.net.URLEncoder.encode(login_hint); state.gateway.sendRedirect(state, 302, url) return 'RESPOND'; } state.agent.setStateVariable(state.id, "oauth2.requested.scope", input.getProperty("scope"), false); state.agent.setStateVariable(state.id, "oauth2.requested.clientid", input.getProperty("client_id"), false); state.gateway.sendRedirect(state, 302, "/confirm_oauth.jsp"); } catch(err if err instanceof java.lang.Exception) { helper.handleError(err); return 'RESPOND'; } return 'RESPOND'; }
This takes care of the various nitty-gritty oauth2/openid connect details by using the io.ceptor.gateway.oauth2.Helper class to do the dirty work of parsing input parameters, returning error messages to client, and communicating with the JWT/OpenID Connect authentication plugin installed in the Session Controller.
Note that the script references som URLs - you will need to change them to fit your setup with your particular web pages for authenticating the users.
© Ceptor ApS. All Rights Reserved.