-
Notifications
You must be signed in to change notification settings - Fork 1.6k
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
[Feature Request] Backchannel sign-out endpoint to invalidate session #1224
Comments
This seems to depend on: |
Hi, I've read in the other issues that single logout is an issue that would be nice to have, but requires thought to implement in a generic way compatible with the identity provider plugin system and not just coupled to OIDC. May I ask for thoughts about the following? I'm no identity expert so please excuse and correct where I get it wrong. As I wrote in the original issue, single logout can only work when using a backing store (redis being the only one implemented) rather than cookie store, because the backchannel logout response cannot clear the user's cookies. oauth2-proxy (the RP, Relying Party in OIDC speak) would then need to clear sessions in redis based on information extracted from the logout token from Keycloak (the OP, OpenID Provider). This (draft) OpenID spec (https://openid.net/specs/openid-connect-backchannel-1_0.html) defines the logout token. The pull-request I linked previously adds validation for the logout token similar to the ID token, handling verifying signatures and parsing the json in to a struct. The interesting line as far as connecting the logout token to the redis store is: "A Logout Token MUST contain either a sub or a sid Claim, and MAY contain both. If a sid Claim is not present, the intent is that all sessions at the RP for the End-User identified by the iss and sub Claims be logged out." To dig in to the sub / sid distinction and its implications, I should expand my original example to include the following: Example (pt. 2)The user has a single identity (subject) with an identity provider (keycloak.example.com), but in addition to securing two applications (app1.example.com and app2.example.com), the user also has two distinct user agents (browser1 and browser2). The user first visits app1.example.com with browser1. They hit oauth2-proxy (RP), and since they are unauthenticated (no _oauth2_proxy cookie) they are redirected to keycloak.example.com (OP). They provide their username and password, keycloak maps to a subject, creates a session on its side, sets cookies for the OP in browser1, redirects back to RP with an authorization code, which RP trades with OP for access/id/refresh tokens, and then RP creates a session for app1, creates a ticket for that session, stores it in app1's redis, and sets _oauth2_cookie in browser1 for app1. The user next visits app2.example.com with browser1. They hit oauth2-proxy (RP) in app2 but same as the original example, they don't have the _oauth2_proxy in app2 and so are redirected to keycloak.example.com (OP). The user does have cookies for OP in browser 1, so they are automatically logged in by keycloak, sent back to oauth2-proxy, and automatically logged in. Oauth2-proxy (RP) for app2.example.com sees things exactly the same in this case as RP for app1.example.com did: the user was unauthenticated, sent to the OP, came back with an authorization code, traded for tokens, and RP creates a session for app2, creates a ticket for that session, stores it in app2's redis, and sets _oauth2_cookie in browser1 for app2. It's OP's stored session that enabled the user to stay logged in and not have to authenticate again while hitting app2.example.com for the first time. In this expanded example, now the user uses browser2 to visit app1.example.com. Browser2 doesn't have any cookies, so the RP sends them to the OP. Because browser2 doesn't have OPs cookies either, keycloak shows a login page. The user provides their username and password. Now OP maps the user to the same subject as has already been logged in, creates a new session for the user in browser2, sets cookies on browser2 and redirects back to RP with an authorization code. RP creates a new session for app1 for the same user/subject, creates a new ticket for that session, stores it in app1's redis alongside the existing session, and sets _oauth2_cookie in browser2 for the ticket for this new session. The user now signs out using browser2. In browser2 they visit app1.example.com/oauth2/sign_out?rd=https%3A%2F%2Fkeycloak.example.com%2Fauth%2Frealms%2Frealm%2Fprotocol%2Fopenid-connect%2Flogout%3Fredirect_uri%3Dhttps%3A%2F%2Fapp1.example.com%2F. oauth2-proxy clears its cookies for app1 / browser2, redirects to keycloak, and keycloak clears its session and redirects back. Expected BehaviorThe identity provider should be able to choose to log users out of all apps on all user agents. If the user visits app1.example.com with browser1 after logging out using browser2, the identity provider should be able to specify that the user is logged out in browser1 as well. Current BehaviorWithout single logout, the user continues to be logged in for every combination of app / user-agent they previously signed on with, explicitly or implicitly, and have not explicitly signed off with. Possible SolutionIf an identity provider is allowed to perform a backchannel signout, and to support OIDC's ability to sign out a single user session (using the sid claim in the logout token) or all of a user's sessions across all apps and user agents (using the sub claim in the logout token), then something between the SessionStore interface and the redis Client interface needs to be updated. The SessionStore interface looks like it can still work, since it contains Save, Load, and Clear methods that all take in the raw HTTP request as a parameter. That raw HTTP request would include the ID and/or Logout token in addition to the oauth2_proxy cookies. It seems lots of people continuously want and ask for redis to support multiple keys for a value, see redis/redis#2668 , but that is not implemented. Instead a new index would need to be maintained in redis between subjects and sessions so that all sessions for a subject can be cleared. The index has to be maintained when:
It looks like the place to put this would be in the persistence manager. Right now the persistence manager translates between tickets and sessions between the HTTP requests and responses. If the persistence manager knew which identity provider was in use, it could ask the identity provider to create a ticket and subject based on the request. In the OIDC case, the ticket could be based on the user's sid claim. The persistence manager would then use the subject to maintain the index of subjects to tickets. The persistence manager's clear method could also ask the identity provider if the request should just clear that session, or should clear all sessions for the subject. I can see why this isn't trivial, but it would be a really useful improvement. Any feedback is appreciated and I'll keep looking at it, thank you. |
I think this is an interesting use case, but I don't know how popular of a use case it is. Because of the complexity of the feature, I would like to see a reasonable amount of backing from different users to show that this really is a feature we should invest in maintaining. I think your analysis is good, and very much appreciated. You are right, we have no way currently to inspect the tickets to understand which to clear. I think this is where we would have the most complexity added as you've already suggested. This kind of feature and mapping really sounds like we need a relational database rather than redis, implementing this relationship on top of key/values is definitely not going to be fun.
In some providers, creating a new session for the same OAuth2/OIDC client, clears any existing session already on their side(eg Google), do you think this would cause issues in the model you've described here? |
I need this exact feature as well. The only workaround to this problem are short expiries and "manual" cookie clrearing. Very annoying.. I think this is a very common case for clusters hosting multiple apps under certain subdomains + Nginx-Ingress. Implementing this feature might be helpful to improve further adoption. |
As I think it might be of interest, this is a hacky workaround in case of using nignx-ingress (one ingress clearing another ones cookie as well during sign_out). This will work for two subdomains of example.com (each using a different client). Pretty nasty:
Part of oauth2-proxy nr. 1 ingress nginx.ingress.kubernetes.io/configuration-snippet: |
set $xheader "";
if ( $request_uri = "oauth2/sign_out" ){
set $xheader https://example.com/auth/realms/myrealm/protocol/openid-connect/logout;
more_set_headers "Set-Cookie: nr2_oauth2_cookie=; Path=/; Domain=.example.com;";
}
proxy_set_header X-Auth-Request-Redirect $xheader; Part of oauth2-proxy nr. 2 ingress nginx.ingress.kubernetes.io/configuration-snippet: |
set $xheader "";
if ( $request_uri = "oauth2/sign_out" ){
set $xheader https://example.com/auth/realms/myrealm/protocol/openid-connect/logout;
more_set_headers "Set-Cookie: nr1_oauth2_cookie=; Path=/; Domain=.example.com;";
}
proxy_set_header X-Auth-Request-Redirect $xheader; This kind of works, but is really ugly. Is there a better way? This could be avoided by beeing able to invalidate the redis session. Edit: user more_set_headers instead of add_header |
I'm also interested in this |
Thank you @JoelSpeed and others for having a look. I used to think writing this on key/values was a tragedy, but now I realize, it's a comedy. I'm using this: The abstraction I used on the oauth2-proxy side is 'sign out keys.' Basically, a provider parses sign out keys from the token when creating the session. For OIDC, the spec specifies 'sub' and 'sid' claims in the ID and Logout tokens. I think if an IDP signed out all previous sessions when a subject creates a new session, the IDP would use the previous session's sid claim in its Logout Token. In this abstraction the provider is also responsible for parsing which sign out key to use from the backchannel request. For OIDC the sid claim if present, or sub otherwise. I don't think you support any SAML providers but this seems like it would work with SAML Single Logout too. In redis, I did all of the index stuff in lua scripts. That seems to be the redis way, each eval is atomic. It's the only way outside external locks, in redis transactions an output of one command can't be used as an input to another. The interface stays mostly stable to redis_store, so it should be easy to swap for a relational DB if you add one in addition or replacement to redis. The nice thing about redis is you get that automatic expiry though which I tried to keep for the indexes. A couple other design considerations - I made a new endpoint instead of reusing sign_out because the flow through the endpoint, the client that hits it, the use case is all different. If I would have used the same one, the beginning of the endpoint handler would just be trying to figure out how it was called and performing completely different handling in either case. I also considered carying the sign_out_keys as a separate parameter alongside SessionState instead of inside it. The tradeoff here is that the sign out keys have three copies in the database: the original claims they came from, the SignOutKeys field in SessionState, and the s2t: index keys in redis. The copy in SessionState isn't really necessary, but there's a ton of pollution to the API to treat them differently. So they go along for the ride and get pulled out in the persistance manager. Also I treat them as "claims" in the SessionState, but I'm not really sure what claims means in that context, so maybe it'd be better to have a different getter method. You asked about Google, but I don't think they support single sign out with a backchannel request. They seem to have their Javascript client they expect you to include that does their googly long-poll. If you implemented a polling client in the Google provider, perhaps sign out keys could be helpful if you can dedup those clients together for multiple sessions. I'm using Keycloak which is how I came about this and Azure supports it too. I saw in your other issues you want to have Azure use the OIDC provider and that should work with this too. As I mentioned I'm using this with Keycloak, and specifically so that I can have multiple applications on multiple servers each using their own oauth2-proxy against the same Keycloak instance and have both single sign-on and single logout working on all of them. To configure this on my Keycloak instance, I just set "Backchannel Logout URL" to "/oauth2/backchannel_sign_out" and then turn "Backchannel Logout Session Required" to Off (so it doesn't include the sid claim in the logout token, since I'm not sending the sid claim in the ID token and I don't want to discriminate between sessions for single logout in my case). Thanks again and any feedback appreciated. |
Just to add a voice to this - we're also very interested in this. We're using Keycloak to secure multiple applications and we're looking at this exact scenario. |
We are interested in this as well! |
I've had a look through the patch and for the most part it looks good. Couple of questions and bits I'd like some clarification on.
Otherwise I think it looks pretty good, I think the decision to create a separate backchannel sign out endpoint makes sense |
Hi, The go-oidc patch is not merged, it is in PR at coreos/go-oidc#251 . There hasn't been discussion on the PR about timeline for merging, but it's possible they are waiting for "OpenID Connect Back-Channel Logout 1.0 - draft 06" to turn in to final form. I can sit on this patch and use it locally for now until that happens or I can ask around for a timeline for the dependencies if you'd like. The reason I'm using Lua scripts to maintain the indexes in Redis is for atomicity. As it stands, multiple instances of oauth2-proxy can connect to and share the same Redis database. Without atomicity, the indexes could become inconsistent if multiple requests occurred simultaneously, or even if a single request was in-progress when the oauth2-proxy process failed or was killed. Redis transactions, which are surfaced in the Go library, cannot work for this maintenance because of a limitation where an output from a Redis command inside a transaction cannot be used as the input to a subsequent command in the same transaction. I need that to, for example, update the sign out key indexes when a ticket is being cleared because I need to use the ticket id to look up the sign out keys the ticket is a member of, and then access those sign out keys to remove the ticket id. Even if I built a separate semaphore system to prevent multiple instances of oauth2-proxy from accessing the database at the same time, I would still need to handle the case of a client dying in the middle of index maintenance. Lua scripts in Redis EVAL operate completely atomically and block other commands. It's important to pay attention to algorithmic complexity of the operations in Redis Lua scripts, but there shouldn't be much overhead caused by using EVAL itself. There are a couple of things I'd like to do to the patch before turning it in to a PR:
Thanks again. |
This issue #1346 is also relevant to the question of when to bring this change in. It might make sense to bring it in on a major version with a call-out that older instances of oauth2-proxy should not use the same Redis database as instances after this change is introduced. |
I too would like to see the backchannel logout feature implemented 😄 By the way, I have come up with a couple of ways to implement with different approaches to ghuser0/oauth2-proxy@f16f9f9. I'd love to hear your thoughts on this.
Approach ACurrently,
Approach BInstead of immediately deleting the corresponding data from Redis upon receiving a backchannel logout request, store it in Redis as a block list.
Then, when oauth2-proxy receives a request from a browser, it will check if it corresponds to the block list.
Approach CHybrid of approaches A and B. Use approach A for logout with
As a PoC, I've tried to implement Approach C. I would appreciate your feedback. Thanks! |
Nice to see this is already an issue. We also have this demand. We deploy a proxy as sidecar to most of our services. A single log out using backchannel logout would be a huge improvement. |
Thanks all, and thank you @wadahiro for the other approach and PoC! The blocklist is an interesting idea and if it can avoid the Redis Lua it could be easier to maintain. I think there is a danger in comparing the session's CreatedAt value to the logout token's IssuedAt claim at https://github.com/openstandia/oauth2-proxy/blob/800cd16402c12364fdc36744d0656b25f8d6f762/pkg/middleware/stored_session.go#L105 . If the clocks of the OP and RP aren't perfectly synchronized, the OP might issue a backchannel sign out with an IssuedAt claim that is before the CreatedAt time of the stored session. The backchannel sign out would then complete successfully with 200, but the session would still continue to validate. If the clocks were off by a minute, the user signed on, did quick work, signed off, the sign out could appear to succeed but the session would continue to validate in subsequent requests. |
Could that be replaced with a monotonic counter in the redis database? That could give you ordering between session creations and sign outs. I think you could share a single counter that is incremented for every created session and every created signed out user in the blocklist. |
I would also like to express need for this feature. |
Hey, I was also looking for some functionality as described here and in 1368. I am not an expert in Nginx, and @SeWieland solution wasn't working for me. That config didn't make any change. However, it did help to create the following solution below. My setup uses the OAuth2 proxy in Kubernetes, so I just added the following to my ingress:
Note: The $1 may be unnecessary functionality. It just takes whatever was written after Essentially, this solution is creating a new endpoint that constructs the oauth2 signout endpoint with custom logic. You can even go one step further and add the redirect_uri from your logout provider and complete the cycle. In my case it was Keycloak, and I wanted to show the original app and restart the login process. But you can change that and add a logout confirmation page or similar.
One step further: I am using Helm to deploy all of this, so I am constructing the URL dynamically with the values.yaml file:
One more side note: I believe that there are multiple ways of creating this new endpoint. I looked into using something like
But I didn't know which 30X code would be more appropriate nor wasn't sure it would be better, so I just went with the initial approach. Hope this helps. |
Actually, I talked too fast. There seems to be a small caveat. EDIT: |
This issue has been inactive for 60 days. If the issue is still relevant please comment to re-activate the issue. If no action is taken within 7 days, the issue will be marked closed. |
Un-stale please |
@alexisgaziello I tried yet another way and it works indeed quite well for me! I'm adding the following annotations to my oauth2-proxy ingress: ingress:
enabled: true
path: "{{ .Values.oauth2.ingressPath }}"
hosts:
- "{{ .Values.domainName }}"
- "{{ .Values.subDomain1 }}.{{ .Values.domainName }}"
- "{{ .Values.subDomain2 }}.{{ .Values.domainName }}"
- "{{ .Values.subDomain3 }}.{{ .Values.domainName }}"
annotations:
kubernetes.io/ingress.class: "nginx"
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
nginx.ingress.kubernetes.io/enable-global-auth: "false"
nginx.ingress.kubernetes.io/configuration-snippet: |
set $xheader "";
if ( $request_uri = "{{ .Values.oauth2.ingressPath }}/sign_out" ){
set $xheader https://{{ .Values.domainName }}/auth/realms/{{ .Values.keycloak.realm }}/protocol/openid-connect/logout;
}
proxy_set_header X-Auth-Request-Redirect $xheader; ect.. It does not overwrite the whole location block, but just adds the correct redirect header on sign out. Actually I gave up on maintaining 2 separate proxies and am just using a single one now. |
I think this issue is linked to #884 as well. |
We are also very interested in this. As mentioned above, the solution doesn't work with KC > 18 anymore. |
This issue has been inactive for 60 days. If the issue is still relevant please comment to re-activate the issue. If no action is taken within 7 days, the issue will be marked closed. |
This issue has been inactive for 60 days. If the issue is still relevant please comment to re-activate the issue. If no action is taken within 7 days, the issue will be marked closed. |
This is still desired |
This is still desired |
We are also very interested in this partikular feature. Is using redis a possible tmp solution for this? |
Interested as well. Logging using the /oauth2/sign_out with a redirect to keycloak signout page works partially fine. When going back app1.example.com, i am able to connect and only get intercepted by the login page after refreshing the page. |
Would also really like to see this feature. |
Looking forward for this feature as well! |
This issue has been inactive for 60 days. If the issue is still relevant please comment to re-activate the issue. If no action is taken within 7 days, the issue will be marked closed. |
Issue is still relevant. |
This issue has been inactive for 60 days. If the issue is still relevant please comment to re-activate the issue. If no action is taken within 7 days, the issue will be marked closed. |
This issue is still relevant |
@JoelSpeed : should this issue be reopened to signal that Backchannel sign-out still is a possibility? |
Isn't #1876 the solution ? |
No, that's the URL that oauth2-proxy would call to perform the back channel sign-out on the IdP, but this issue is about providing an endpoint in oauth2-proxy that the IdP would call to invalidate the user session in oauth2-proxy. |
Hummmm sorry for the misunderstanding ;) |
I would just like to add some more context. Due to cross domain cookie restrictions in Firefox and Safari it is becoming as good as impossible to provide customers with a multi client logout experience. Most IDP's are advising to do back channel logout as per the spec. invalidating session (access and refresh tokens attached to a session) in a backend store. The backchannel logout is has become a official spec in 2023 no longer a draft specification references: kind regards, Roland |
Hi,
To support single-sign-out for Keycloak, in the Keycloak client registration it is possible to specify a backchannel logout URL. If a user authenticated in a realm signs out using any client, keycloak will call this backchannel logout URL for all clients in the realm.
Example
A user has two apps app1.example.com and app2.example.com using oauth2-proxy to secure access using a single Keycloak realm at keycloak.example.com.
The user first visits app1.example.com. They are redirected to keycloak.example.com to sign in. The user signs in and keycloak sets a session cookie, as well as redirects back to app1.example.com/oauth2/callback with an authorization code. oauth2-proxy on app1.example.com creates a session for the user in redis, and sets the oauth2 client cookie to index the redis session.
The user then visits app2.example.com. They are redirected to keycloak.example.com, where they already have a keycloak session cookie. Keycloak keeps the user signed in and automatically redirects back to app2.example.com/oauth2/callback with an authorization code. oauth2-proxy on app2.example.com creates a session for the user in redis, sets the oauth2 client cookie, and the user is automatically signed in after a bunch of redirects with the magic of oauth SSO.
The user now wants to sign out. The user visits app1.example.com/oauth2/sign_out?rd=https%3A%2F%2Fkeycloak.example.com%2Fauth%2Frealms%2Frealm%2Fprotocol%2Fopenid-connect%2Flogout%3Fredirect_uri%3Dhttps%3A%2F%2Fapp1.example.com%2F. app1's oauth2-proxy clears its session, then redirects to keycloak which clears its session, and then redirects back to app1 which is now logged out.
Expected Behavior
When the keycloak logout endpoint is hit, it should use the backchannel logout url of all of its clients to invalidate their sessions. That is, keycloak.example.com should make a backchannel request to app2.example.com to invalidate the user's session. After logging out of app1.example.com, if the user visits app2.example.com, they should be logged out of app2 as well.
Current Behavior
Without a backchannel logout URL in oauth2-proxy, when the user signs out of app1.example.com, the sessions of app1.example.com and keycloak.example.com are cleared, but app2.example.com isn't touched. As a result, if the user later visits app2.example.com, they continue to be logged in.
Possible Solution
A backchannel logout endpoint, that receives a logout token and clears the associated session in redis. This would only work for redis sessions, not cookie sessions, since a backchannel request can't reach in to the user's user-agent to clear cookies.
Your Environment
oauth2-proxy v7.1.3
Keycloak v13.0.1
The text was updated successfully, but these errors were encountered: