-
-
Notifications
You must be signed in to change notification settings - Fork 265
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
dfcf811
824b834
db9ed47
22a0e8e
61efe83
55ac9d4
424449c
860d889
0cf0dee
e5e5b27
0845208
24f3d82
32c4152
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 |
|---|---|---|
|
|
@@ -36,15 +36,30 @@ export const LogoutPage = () => { | |
| } | ||
| return ""; | ||
| })(); | ||
| const logoutParams = | ||
| screenParams.redirect_uri && screenParams.login_for !== "oidc" | ||
| ? { login_for: "app", redirect_uri: screenParams.redirect_uri } | ||
| : undefined; | ||
|
|
||
| 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: logoutParams, | ||
| }), | ||
| 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; | ||
| } | ||
|
|
||
|
Comment on lines
+57
to
+62
Author
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. Ditto, consider moving this into window.setTimeout? |
||
| 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()
...
Author
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've aligned with the code style to re-use
Author
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. Addressed in 22a0e8eb66fa, 860d8895057a, and 24f3d824703c: the logout flow now uses |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,13 +4,16 @@ import ( | |
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/tinyauthapp/tinyauth/internal/model" | ||
| "github.com/tinyauthapp/tinyauth/internal/repository" | ||
| "github.com/tinyauthapp/tinyauth/internal/service" | ||
| "github.com/tinyauthapp/tinyauth/internal/utils" | ||
| "github.com/tinyauthapp/tinyauth/internal/utils/logger" | ||
| "github.com/tinyauthapp/tinyauth/pkg/validators" | ||
| "go.uber.org/dig" | ||
|
|
||
| "github.com/gin-gonic/gin" | ||
|
|
@@ -28,6 +31,7 @@ type TotpRequest struct { | |
|
|
||
| type UserController struct { | ||
| log *logger.Logger | ||
| config *model.Config | ||
| runtime *model.RuntimeConfig | ||
| auth *service.AuthService | ||
| } | ||
|
|
@@ -36,6 +40,7 @@ type UserControllerInput struct { | |
| dig.In | ||
|
|
||
| Log *logger.Logger | ||
| StaticConfig *model.Config | ||
| RuntimeConfig *model.RuntimeConfig | ||
| RouterGroup *gin.RouterGroup `name:"apiRouterGroup"` | ||
| AuthService *service.AuthService | ||
|
|
@@ -44,13 +49,15 @@ type UserControllerInput struct { | |
| func NewUserController(i UserControllerInput) *UserController { | ||
| controller := &UserController{ | ||
| log: i.Log, | ||
| config: i.StaticConfig, | ||
| runtime: i.RuntimeConfig, | ||
| auth: i.AuthService, | ||
| } | ||
|
|
||
| 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 +234,168 @@ func (controller *UserController) loginHandler(c *gin.Context) { | |
| func (controller *UserController) logoutHandler(c *gin.Context) { | ||
| controller.log.App.Debug().Msg("Logout attempt") | ||
|
|
||
| uuid, err := c.Cookie(controller.runtime.SessionCookieName) | ||
| // 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 := "" | ||
| if c.Query("login_for") == "app" { | ||
| requestedRedirectURI = c.Query("redirect_uri") | ||
| } | ||
| redirectURI := controller.safeLogoutRedirect(requestedRedirectURI) | ||
|
|
||
| userContext, err := new(model.UserContext).NewFromGin(c) | ||
| 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", | ||
| userContext = nil | ||
| } | ||
|
|
||
| providerID := "" | ||
| idToken := "" | ||
| if userContext != nil && userContext.IsOAuth() { | ||
| providerID = userContext.OAuth.ID | ||
| idToken = userContext.OAuth.IDToken | ||
| } | ||
|
|
||
| uuid, err := c.Cookie(controller.runtime.SessionCookieName) | ||
| if err == nil { | ||
| cookie, err := controller.auth.DeleteSession(c, uuid) | ||
| if err != nil { | ||
| controller.log.App.Error().Err(err).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 userContext != nil { | ||
| controller.log.AuditLogout(userContext.GetUsername(), userContext.GetProviderID(), c.ClientIP()) | ||
| } else { | ||
| controller.log.App.Warn().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) | ||
| 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 := controller.runtime.AppURL + "/api/user/logout/callback" | ||
| logoutURL, err := buildOAuthLogoutURL(provider, callbackURL, idToken, redirectURI) | ||
| if err != nil { | ||
| controller.log.App.Warn().Err(err).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 (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
Author
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. Addressed in 61efe839398f. |
||
| fallback := controller.runtime.AppURL | ||
| if raw == "" { | ||
| 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 | ||
| } | ||
|
|
||
| context, err := new(model.UserContext).NewFromGin(c) | ||
| allowedSchemes := []string{"http", "https"} | ||
| if appURL.Scheme == "https" { | ||
| allowedSchemes = []string{"https"} | ||
| } | ||
|
|
||
| schemeValidator := validators.NewDomainValidator(validators.DomainValidatorOptions{ | ||
| WithScheme: true, | ||
| AllowedSchemes: allowedSchemes, | ||
| }) | ||
| hostname, err := schemeValidator.SafeHostname(raw) | ||
| if err != nil { | ||
| return fallback | ||
| } | ||
|
|
||
| domainValidator := validators.NewDomainValidator(validators.DomainValidatorOptions{ | ||
| WithPort: true, | ||
| }) | ||
| err = domainValidator.Validate(raw, controller.runtime.AppURL) | ||
| 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()) | ||
| return raw | ||
| } | ||
|
|
||
| http.SetCookie(c.Writer, cookie) | ||
| if !errors.Is(err, validators.ErrHostnameMismatch) || | ||
| controller.config == nil || | ||
| !controller.config.Auth.SubdomainsEnabled { | ||
| return fallback | ||
| } | ||
|
|
||
| c.JSON(200, gin.H{ | ||
| "status": 200, | ||
| "message": "Logout successful", | ||
| }) | ||
| cookieDomain := strings.ToLower(controller.runtime.CookieDomain) | ||
| if hostname == cookieDomain || strings.HasSuffix(hostname, "."+cookieDomain) { | ||
| return raw | ||
| } | ||
|
|
||
| return fallback | ||
| } | ||
|
|
||
| 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 != "https" { | ||
| return "", fmt.Errorf("unsupported logout URL scheme") | ||
| } | ||
|
|
||
| query := logoutURL.Query() | ||
| if provider.ClientID != "" { | ||
| query.Set("client_id", provider.ClientID) | ||
| } | ||
| if idToken != "" { | ||
| query.Set("id_token_hint", idToken) | ||
| } | ||
| 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.
Should we consider moving this into the function below with setTimeout()?