-
-
Notifications
You must be signed in to change notification settings - Fork 264
feat: support OIDC RP-initiated logout #1094
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -38,13 +38,26 @@ export const LogoutPage = () => { | |
| })(); | ||
|
|
||
| const logoutMutation = useMutation({ | ||
| mutationFn: () => axios.post("/api/user/logout"), | ||
| // redirect_uri is Tinyauth's existing application-navigation parameter. | ||
| // It is not the OIDC RP-Initiated Logout post_logout_redirect_uri. | ||
| mutationFn: () => | ||
| axios.post("/api/user/logout", undefined, { | ||
| params: screenParams.redirect_uri | ||
| ? { redirect_uri: screenParams.redirect_uri } | ||
| : undefined, | ||
|
Comment on lines
+44
to
+47
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }), | ||
| mutationKey: ["logout"], | ||
| onSuccess: () => { | ||
| onSuccess: (response) => { | ||
| toast.success(t("logoutSuccessTitle"), { | ||
| description: t("logoutSuccessSubtitle"), | ||
| }); | ||
|
|
||
| const redirectUrl = response.data?.redirectUrl; | ||
| if (typeof redirectUrl === "string" && redirectUrl.length > 0) { | ||
| window.location.replace(redirectUrl); | ||
| return; | ||
| } | ||
|
|
||
| redirectTimer.current = window.setTimeout(() => { | ||
| window.location.replace(`/login${compiledParams}`); | ||
| }, 500); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ALTER TABLE "sessions" DROP COLUMN "oauth_id_token"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ALTER TABLE "sessions" ADD COLUMN "oauth_id_token" TEXT NOT NULL DEFAULT ''; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ALTER TABLE "sessions" DROP COLUMN "oauth_id_token"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ALTER TABLE "sessions" ADD COLUMN "oauth_id_token" TEXT NOT NULL DEFAULT ''; |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Throughout the entire file I notice that you use a separate variable for each error. There is no need for such thing. You can just do: err := doSomeAction()
if err != nil {
return err
}
err = doSomeOtherAction()
... |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import ( | |
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/tinyauthapp/tinyauth/internal/model" | ||
|
|
@@ -51,6 +53,7 @@ func NewUserController(i UserControllerInput) *UserController { | |
| userGroup := i.RouterGroup.Group("/user") | ||
| userGroup.POST("/login", controller.loginHandler) | ||
| userGroup.POST("/logout", controller.logoutHandler) | ||
| userGroup.GET("/logout/callback", controller.ssoLogoutCallbackHandler) | ||
| userGroup.POST("/totp", controller.totpHandler) | ||
| userGroup.POST("/tailscale", controller.tailscaleHandler) | ||
|
|
||
|
|
@@ -227,51 +230,183 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |
| func (controller *UserController) logoutHandler(c *gin.Context) { | ||
| controller.log.App.Debug().Msg("Logout attempt") | ||
|
|
||
| // redirect_uri is a Tinyauth UI/navigation parameter. It is not an | ||
| // OpenID Connect RP-Initiated Logout parameter. The standardized OP-facing | ||
| // parameters are added later by buildOAuthLogoutURL. | ||
| requestedRedirectURI := c.Query("redirect_uri") | ||
| redirectURI := controller.safeLogoutRedirect(requestedRedirectURI) | ||
|
|
||
| userContext, contextErr := new(model.UserContext).NewFromGin(c) | ||
| providerID := "" | ||
| if contextErr == nil && userContext.IsOAuth() { | ||
| providerID = userContext.GetProviderID() | ||
| } | ||
|
|
||
| idToken := "" | ||
| sessionProviderID := "" | ||
| uuid, err := c.Cookie(controller.runtime.SessionCookieName) | ||
| if err == nil { | ||
| session, sessionErr := controller.auth.GetSession(c, uuid) | ||
| if sessionErr != nil { | ||
| controller.log.App.Warn().Err(sessionErr).Msg("Failed to get session during logout, continuing without session-backed logout metadata") | ||
| } else { | ||
| idToken = session.OAuthIDToken | ||
| sessionProviderID = session.Provider | ||
| } | ||
|
Comment on lines
+249
to
+255
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is no reason to do a duplicate database lookup for the OAuth ID token. The context middleware can do that. Please move the ID token into a field in the OAuth-specific context (like the sub). |
||
|
|
||
| if err != nil { | ||
| if errors.Is(err, http.ErrNoCookie) { | ||
| controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout") | ||
| c.JSON(200, gin.H{ | ||
| "status": 200, | ||
| "message": "Logout successful", | ||
| cookie, deleteErr := controller.auth.DeleteSession(c, uuid) | ||
| if deleteErr != nil { | ||
| controller.log.App.Error().Err(deleteErr).Msg("Error deleting session on logout") | ||
| c.JSON(http.StatusInternalServerError, gin.H{ | ||
| "status": http.StatusInternalServerError, | ||
| "message": "Internal Server Error", | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| http.SetCookie(c.Writer, cookie) | ||
|
|
||
| if contextErr == nil { | ||
| controller.log.AuditLogout(userContext.GetUsername(), userContext.GetProviderID(), c.ClientIP()) | ||
| } else { | ||
| controller.log.App.Warn().Err(contextErr).Msg("Failed to get user context during logout, logging audit with unknown user") | ||
| controller.log.AuditLogout("unknown", "unknown", c.ClientIP()) | ||
| } | ||
| } else if errors.Is(err, http.ErrNoCookie) { | ||
| controller.log.App.Warn().Msg("Logout attempt without session cookie, treating as successful logout") | ||
| } else { | ||
| controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on logout") | ||
| c.JSON(500, gin.H{ | ||
| "status": 500, | ||
| c.JSON(http.StatusInternalServerError, gin.H{ | ||
| "status": http.StatusInternalServerError, | ||
| "message": "Internal Server Error", | ||
| }) | ||
| return | ||
| } | ||
|
|
||
| cookie, err := controller.auth.DeleteSession(c, uuid) | ||
| // If middleware context is missing, fall back to the just-loaded session | ||
| // provider. If there is no session metadata either, a deployment with exactly | ||
| // one OAuth provider can still terminate that provider's SSO session. | ||
| if providerID == "" && contextErr != nil && isSessionOAuthProvider(sessionProviderID) { | ||
| providerID = sessionProviderID | ||
| } | ||
|
Comment on lines
+289
to
+291
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to do this. The context middleware will always return the same session as the lookup. After https://github.com/tinyauthapp/tinyauth/pull/1094/changes#r3864349886, the session lookup won't even be needed. |
||
| if providerID == "" && contextErr != nil && sessionProviderID == "" && len(controller.runtime.OAuthProviders) == 1 { | ||
|
Comment on lines
+289
to
+292
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would check if the context is |
||
| for id := range controller.runtime.OAuthProviders { | ||
| providerID = id | ||
| } | ||
| } | ||
|
Comment on lines
+292
to
+296
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I am not a fan of this. It seems non-deterministic behavior. I would recommend just skipping the OAuth logout if we can't determine what OAuth provider triggered the logout. |
||
|
|
||
| response := gin.H{ | ||
| "status": http.StatusOK, | ||
| "message": "Logout successful", | ||
| } | ||
|
|
||
| provider, ok := controller.runtime.OAuthProviders[providerID] | ||
| if ok && provider.LogoutURL != "" { | ||
| // OpenID Connect RP-Initiated Logout 1.0: | ||
| // https://openid.net/specs/openid-connect-rpinitiated-1_0-final.html#RPLogout | ||
| // | ||
| // OP-facing standardized parameters: | ||
| // id_token_hint | ||
| // post_logout_redirect_uri | ||
| // state | ||
| callbackURL := strings.TrimRight(controller.runtime.AppURL, "/") + "/api/user/logout/callback" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No need to modify the runtime app URL in any way, it's already in the form of |
||
| logoutURL, buildErr := buildOAuthLogoutURL(provider, callbackURL, idToken, redirectURI) | ||
| if buildErr != nil { | ||
| controller.log.App.Warn().Err(buildErr).Str("provider", providerID).Msg("Invalid OAuth logout URL, skipping provider logout") | ||
| if requestedRedirectURI != "" { | ||
| response["redirectUrl"] = redirectURI | ||
| } | ||
| } else { | ||
| response["redirectUrl"] = logoutURL | ||
| } | ||
| } else if requestedRedirectURI != "" { | ||
| // Non-OIDC/local logout can still return to the validated application. | ||
| response["redirectUrl"] = redirectURI | ||
| } | ||
|
|
||
| c.JSON(http.StatusOK, response) | ||
| } | ||
|
|
||
| func (controller *UserController) ssoLogoutCallbackHandler(c *gin.Context) { | ||
| // state is defined by OpenID Connect RP-Initiated Logout 1.0 as an opaque | ||
| // RP value that the OP returns unchanged after logout. We use it to carry | ||
| // the already-validated Tinyauth application return URI across the OP hop. | ||
| redirectURI := controller.safeLogoutRedirect(c.Query("state")) | ||
| c.Redirect(http.StatusFound, redirectURI) | ||
| } | ||
|
|
||
| func isSessionOAuthProvider(providerID string) bool { | ||
| switch providerID { | ||
| case "", "local", "ldap", "tailscale": | ||
| return false | ||
| default: | ||
| return true | ||
| } | ||
| } | ||
|
|
||
| func (controller *UserController) safeLogoutRedirect(raw string) string { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please use the domain validator for any validating logic. See |
||
| fallback := controller.runtime.AppURL | ||
| if raw == "" { | ||
| return fallback | ||
| } | ||
|
|
||
| target, err := url.Parse(raw) | ||
| if err != nil || target.Host == "" || target.User != nil { | ||
| return fallback | ||
| } | ||
| if target.Scheme != "http" && target.Scheme != "https" { | ||
| return fallback | ||
| } | ||
|
|
||
| appURL, err := url.Parse(controller.runtime.AppURL) | ||
| if err != nil { | ||
| controller.log.App.Error().Err(err).Msg("Error deleting session on logout") | ||
| c.JSON(500, gin.H{ | ||
| "status": 500, | ||
| "message": "Internal Server Error", | ||
| }) | ||
| return | ||
| return fallback | ||
| } | ||
| if appURL.Scheme == "https" && target.Scheme != "https" { | ||
| return fallback | ||
| } | ||
|
|
||
| context, err := new(model.UserContext).NewFromGin(c) | ||
| targetHost := strings.ToLower(target.Hostname()) | ||
| appHost := strings.ToLower(appURL.Hostname()) | ||
| if targetHost == appHost { | ||
| return raw | ||
| } | ||
|
|
||
| if err == nil { | ||
| controller.log.AuditLogout(context.GetUsername(), context.GetProviderID(), c.ClientIP()) | ||
| } else { | ||
| controller.log.App.Warn().Err(err).Msg("Failed to get user context during logout, logging audit with unknown user") | ||
| controller.log.AuditLogout("unknown", "unknown", c.ClientIP()) | ||
| cookieDomain := strings.TrimPrefix(strings.ToLower(controller.runtime.CookieDomain), ".") | ||
| if cookieDomain != "" && | ||
| (targetHost == cookieDomain || strings.HasSuffix(targetHost, "."+cookieDomain)) { | ||
| return raw | ||
| } | ||
|
|
||
| http.SetCookie(c.Writer, cookie) | ||
| return fallback | ||
| } | ||
|
|
||
| c.JSON(200, gin.H{ | ||
| "status": 200, | ||
| "message": "Logout successful", | ||
| }) | ||
| func buildOAuthLogoutURL(provider model.OAuthServiceConfig, callbackURL, idToken, state string) (string, error) { | ||
| logoutURL, err := url.Parse(provider.LogoutURL) | ||
| if err != nil || logoutURL.Host == "" { | ||
| return "", fmt.Errorf("invalid logout URL") | ||
| } | ||
| if logoutURL.Scheme != "http" && logoutURL.Scheme != "https" { | ||
| return "", fmt.Errorf("unsupported logout URL scheme") | ||
| } | ||
| if logoutURL.Scheme == "http" && !provider.Insecure { | ||
| return "", fmt.Errorf("insecure logout URL requires insecure OAuth provider") | ||
| } | ||
|
Comment on lines
+392
to
+394
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not the case. Insecure just means trust the self-signed certificate, not run in HTTP. This check can be removed. |
||
|
|
||
| query := logoutURL.Query() | ||
| if provider.ClientID != "" { | ||
| query.Set("client_id", provider.ClientID) | ||
| } | ||
| if idToken != "" { | ||
| query.Set("id_token_hint", idToken) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| query.Set("post_logout_redirect_uri", callbackURL) | ||
| if state != "" { | ||
| query.Set("state", state) | ||
| } | ||
| logoutURL.RawQuery = query.Encode() | ||
|
|
||
| return logoutURL.String(), nil | ||
| } | ||
|
|
||
| func (controller *UserController) totpHandler(c *gin.Context) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here, we need to check if the parameters are from an OIDC request (
login_forwill beoidc) and if they are not, we need to also specifylogin_for=appto Tinyauth so it knows where it's redirecting to after the logout.