Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

OIDC backend logout #444

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
103 changes: 103 additions & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,109 @@ When using an OP that only supports statically registered clients, see the
and make sure to provide the redirect URI, constructed as described in the
section about Google configuration below, in the static registration.

### Logout in OIDC Backend

The OpenID Connect backend supports Global Logout proces, which means terminating a user’s session on the IdP and all
RPs that the user currently has an active session for. At the time of authentication, SATOSA maintains the following
session information as part of its storage:

#### Backend Session
This contains the following:

1. SID: Retrieved from the user claims, gotten from the OP after a successful authentication flow.
2. Issuer: The Issuer URL of the OP

Since SID and Issuer uniquely identify a Backend Session, after persisting Backend Session, a new ID (auto-incremented)
is generated, called `backend_sid` which uniquely identifies a Backend Session.

#### Frontend Session
This contains the following:

1. Frontend name: Name of the Frontend, which could be of any type, for example OIDC, SAML, etc.
2. Requester: Only stored in the case of OIDC Frontend, and is required at the time of logout for the creation of
Logout Token when logging out an RP.
3. Subject ID: Only stored in the case of OIDC Frontend, and is required at the time of logout for the creation of
Logout Token when logging out an RP.
4. Frontend SID: The value of SATOSA context state `session_id` which uniquely identified the Frontend Session.

#### Session Map
Since at the time of logout, SATOSA needs to know the information about the Frontend Session associated with the
Backend Session, a Session Map is required to be maintained. This enables SATOSA to logout all the connected Frontend,
irrespective of the type, OIDC, SAML, etc. when the OIDC Backend receives a logout request by an OP.

This contains the following:

1. Backend Session ID
2. Frontend Session ID

In order to enable the OIDC Backend logout functionality, the following flag is required to be set in the
`proxy.conf.yaml` file.

````yaml
LOGOUT_ENABLED: True
````

The default value of `LOGOUT_ENABLED` flag is False. When the logout is not enabled, all the calls to the storage are
mocked and hence none of the session information is persisted in the storage.

SATOSA provides two types of storage adapters out of the box:

1. In Memory
2. PostgreSQL

If the logout is enabled and storage configuration is not defined, by default In Memory storage is used.

The storage configurations are defined in the `proxy_conf.yaml` file. An example configuration for the storage looks as below:

```yaml
STORAGE:
type: satosa.storage.StoragePostgreSQL
host: 127.0.0.1
port: 5432
db_name: satosa
user: postgres
password: secret
```

The following diagram illustrates at what point each of the discussed session storages are being persisted in an
OIDC to OIDC authentication flow.

### Session Storage in OpenID Connect Frontend <-> Idpy OIDC Backend

![](images/session-storage.png "Session Storage")

The OIDC backend supports the following flows of Global Logout:

1. OP Initiated Logout with Front-Channel Communication
2. OP Initiated Logout with Back-Channel Communication

In the OIDC Backend configuration, depending on which logout to support, the following needs to be defined for the client:

````yaml
front_channel_logout_uri: <base_url>/<name>/front-channel-logout
back_channel_logout_uri: <base_url>/<name>/back-channel-logout
````

The same URL is required to be defined in the OP when registering this client.

When the OIDC Backend receives the Front-Channel logout request, after validating the request, it calls the
`logout_callback_func` defined in `SATOSABase` which retrieves all the frontend sessions, and triggers the logout
for them one by one. If the Frontend logout is successful, the Frontend is responsible to delete the Frontend Session
entry. Once all the Frontend sessions are successfully logged out, the Session Map entry is deleted as well, and at
last, the Backend Session is deleted as well by the `_backend_logout_req_finish` in `SATOSABase`.

The following diagram illustrates a high level flow of OIDC Backend Back-channel logout.

### OIDC Backend Back-channel Logout

![](images/oidc-be-backchannel-logout.png "OIDC Backend Back-channel Logout")

The following diagram illustrates a high level flow of OIDC Backend Front-channel logout.

### OIDC Backend Front-channel Logout

![](images/oidc-be-frontchannel-logout.png "OIDC Backend Front-channel Logout")

### Social login plugins

The social login plugins can be used as backends for the proxy, allowing the
Expand Down
Binary file added doc/images/oidc-be-backchannel-logout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/images/oidc-be-frontchannel-logout.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added doc/images/session-storage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"pyop_mongo": ["pyop[mongo]"],
"pyop_redis": ["pyop[redis]"],
"idpy_oidc_backend": ["idpyoidc >= 2.1.0"],
"storage_postgresql": ["SQLAlchemy", "psycopg2-binary"],
},
zip_safe=False,
classifiers=[
Expand Down
104 changes: 104 additions & 0 deletions src/satosa/backends/apple.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,110 @@
class AppleBackend(OpenIDConnectBackend):
"""Sign in with Apple backend"""

def __init__(self, auth_callback_func, internal_attributes, config, base_url, name, storage,
logout_callback_func):
"""
Sign in with Apple backend module.
:param auth_callback_func: Callback should be called by the module after the authorization
in the backend is done.
:param internal_attributes: Mapping dictionary between SATOSA internal attribute names and
the names returned by underlying IdP's/OP's as well as what attributes the calling SP's and
RP's expects namevice.
:param config: Configuration parameters for the module.
:param base_url: base url of the service
:param name: name of the plugin
:param storage: storage to hold the backend session information
:param logout_callback_func: Callback should be called by the module after the logout
in the backend is done. This may trigger log out flow for all the frontends associated
with the backend session

:type auth_callback_func:
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
:type internal_attributes: dict[string, dict[str, str | list[str]]]
:type config: dict[str, dict[str, str] | list[str]]
:type base_url: str
:type name: str
:type storage: satosa.storage.Storage
:type logout_callback_func: str
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
"""
super().__init__(auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func)
self.auth_callback_func = auth_callback_func
self.config = config
self.client = _create_client(
config["provider_metadata"],
config["client"]["client_metadata"],
config["client"].get("verify_ssl", True),
)
if "scope" not in config["client"]["auth_req_params"]:
config["auth_req_params"]["scope"] = "openid"
if "response_type" not in config["client"]["auth_req_params"]:
config["auth_req_params"]["response_type"] = "code"

def start_auth(self, context, request_info):
"""
See super class method satosa.backends.base#start_auth
:type context: satosa.context.Context
:type request_info: satosa.internal.InternalData
"""
oidc_nonce = rndstr()
oidc_state = rndstr()
state_data = {NONCE_KEY: oidc_nonce, STATE_KEY: oidc_state}
context.state[self.name] = state_data

args = {
"scope": self.config["client"]["auth_req_params"]["scope"],
"response_type": self.config["client"]["auth_req_params"]["response_type"],
"client_id": self.client.client_id,
"redirect_uri": self.client.registration_response["redirect_uris"][0],
"state": oidc_state,
"nonce": oidc_nonce,
}
args.update(self.config["client"]["auth_req_params"])
auth_req = self.client.construct_AuthorizationRequest(request_args=args)
login_url = auth_req.request(self.client.authorization_endpoint)
return Redirect(login_url)

def register_endpoints(self):
"""
Creates a list of all the endpoints this backend module needs to listen to. In this case
it's the authentication response from the underlying OP that is redirected from the OP to
the proxy.
:rtype: Sequence[(str, Callable[[satosa.context.Context], satosa.response.Response]]
:return: A list that can be used to map the request to SATOSA to this endpoint.
"""
url_map = []
redirect_path = urlparse(
self.config["client"]["client_metadata"]["redirect_uris"][0]
).path
if not redirect_path:
raise SATOSAError("Missing path in redirect uri")

url_map.append(("^%s$" % redirect_path.lstrip("/"), self.response_endpoint))
return url_map

def _verify_nonce(self, nonce, context):
"""
Verify the received OIDC 'nonce' from the ID Token.
:param nonce: OIDC nonce
:type nonce: str
:param context: current request context
:type context: satosa.context.Context
:raise SATOSAAuthenticationError: if the nonce is incorrect
"""
backend_state = context.state[self.name]
if nonce != backend_state[NONCE_KEY]:
msg = "Missing or invalid nonce in authn response for state: {}".format(
backend_state
)
logline = lu.LOG_FMT.format(
id=lu.get_session_id(context.state), message=msg
)
logger.debug(logline)
raise SATOSAAuthenticationError(
context.state, "Missing or invalid nonce in authn response"
)

def _get_tokens(self, authn_response, context):
"""
:param authn_response: authentication response from OP
Expand Down
12 changes: 11 additions & 1 deletion src/satosa/backends/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ class BackendModule(object):
Base class for a backend module.
"""

def __init__(self, auth_callback_func, internal_attributes, base_url, name):
def __init__(self, auth_callback_func, internal_attributes, base_url, name, storage, logout_callback_func):
"""
:type auth_callback_func:
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
:type internal_attributes: dict[string, dict[str, str | list[str]]]
:type base_url: str
:type name: str
:type storage: satosa.storage.Storage
:type logout_callback_func: str
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response


:param auth_callback_func: Callback should be called by the module after
the authorization in the backend is done.
Expand All @@ -25,12 +29,18 @@ def __init__(self, auth_callback_func, internal_attributes, base_url, name):
RP's expects namevice.
:param base_url: base url of the service
:param name: name of the plugin
:param storage: storage to hold the backend session information
:param logout_callback_func: Callback should be called by the module after the logout
in the backend is done. This may trigger log out flow for all the frontends associated
with the backend session
"""
self.auth_callback_func = auth_callback_func
self.internal_attributes = internal_attributes
self.converter = AttributeMapper(internal_attributes)
self.base_url = base_url
self.name = name
self.storage = storage
self.logout_callback_func = logout_callback_func

def start_auth(self, context, internal_request):
"""
Expand Down
14 changes: 11 additions & 3 deletions src/satosa/backends/bitbucket.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class BitBucketBackend(_OAuthBackend):

logprefix = "BitBucket Backend:"

def __init__(self, outgoing, internal_attributes, config, base_url, name):
def __init__(self, outgoing, internal_attributes, config, base_url, name, storage, logout_callback_func):
"""BitBucket backend constructor
:param outgoing: Callback should be called by the module after the
authorization in the backend is done.
Expand All @@ -29,18 +29,26 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
:param config: configuration parameters for the module.
:param base_url: base url of the service
:param name: name of the plugin
:param storage: storage to hold the backend session information
:param logout_callback_func: Callback should be called by the module after the logout
in the backend is done. This may trigger log out flow for all the frontends associated
with the backend session

:type outgoing:
(satosa.context.Context, satosa.internal.InternalData) ->
satosa.response.Response
:type internal_attributes: dict[string, dict[str, str | list[str]]]
:type config: dict[str, dict[str, str] | list[str] | str]
:type base_url: str
:type name: str
:type storage: satosa.storage.Storage
:type logout_callback_func: str
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
"""
config.setdefault('response_type', 'code')
config['verify_accesstoken_state'] = False
super().__init__(outgoing, internal_attributes, config, base_url,
name, 'bitbucket', 'account_id')
super().__init__(outgoing, internal_attributes, config, base_url, name, 'bitbucket', 'account_id',
storage, logout_callback_func)

def get_request_args(self, get_state=stateID):
request_args = super().get_request_args(get_state=get_state)
Expand Down
16 changes: 12 additions & 4 deletions src/satosa/backends/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
class GitHubBackend(_OAuthBackend):
"""GitHub OAuth 2.0 backend"""

def __init__(self, outgoing, internal_attributes, config, base_url, name):
def __init__(self, outgoing, internal_attributes, config, base_url, name, storage,
logout_callback_func):
"""GitHub backend constructor
:param outgoing: Callback should be called by the module after the
authorization in the backend is done.
Expand All @@ -31,19 +32,26 @@ def __init__(self, outgoing, internal_attributes, config, base_url, name):
:param config: configuration parameters for the module.
:param base_url: base url of the service
:param name: name of the plugin
:param storage: storage to hold the backend session information
:param logout_callback_func: Callback should be called by the module after the logout
in the backend is done. This may trigger log out flow for all the frontends associated
with the backend session

:type outgoing:
(satosa.context.Context, satosa.internal.InternalData) ->
satosa.response.Response
:type internal_attributes: dict[string, dict[str, str | list[str]]]
:type config: dict[str, dict[str, str] | list[str] | str]
:type base_url: str
:type name: str
:type storage: satosa.storage.Storage
:type logout_callback_func: str
(satosa.context.Context, satosa.internal.InternalData) -> satosa.response.Response
"""
config.setdefault('response_type', 'code')
config['verify_accesstoken_state'] = False
super().__init__(
outgoing, internal_attributes, config, base_url, name, 'github',
'id')
super().__init__(outgoing, internal_attributes, config, base_url, name, 'github', 'id',
storage, logout_callback_func)

def start_auth(self, context, internal_request, get_state=stateID):
"""
Expand Down
Loading