Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ TINYAUTH_OAUTH_PROVIDERS_name_AUTHURL=
TINYAUTH_OAUTH_PROVIDERS_name_TOKENURL=
# OAuth userinfo URL.
TINYAUTH_OAUTH_PROVIDERS_name_USERINFOURL=
# OpenID Connect RP-Initiated Logout end_session_endpoint URL.
TINYAUTH_OAUTH_PROVIDERS_name_LOGOUTURL=
# Allow insecure OAuth connections.
TINYAUTH_OAUTH_PROVIDERS_name_INSECURE=false
# Provider name in UI.
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ services:
labels:
traefik.enable: true
traefik.http.routers.whoami.rule: Host(`whoami.127.0.0.1.sslip.io`)
traefik.http.routers.whoami.entrypoints: websecure
traefik.http.routers.whoami.tls: true
traefik.http.routers.whoami.middlewares: tinyauth

tinyauth-frontend:
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/components/quick-actions/quick-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,13 +122,26 @@ export const QuickActions = () => {
})();

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 }

Copy link
Copy Markdown
Member

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_for will be oidc) and if they are not, we need to also specify login_for=app to Tinyauth so it knows where it's redirecting to after the logout.

: undefined,
}),
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);
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/pages/logout-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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);
Expand Down
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 '';
5 changes: 4 additions & 1 deletion internal/controller/oauth_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
}

code := c.Query("code")
_, err = controller.auth.GetOAuthToken(sessionIdCookie, code)
token, err := controller.auth.GetOAuthToken(sessionIdCookie, code)

if err != nil {
controller.log.App.Error().Err(err).Msg("Failed to exchange code for token")
Expand Down Expand Up @@ -235,6 +235,9 @@ func (controller *OAuthController) oauthCallbackHandler(c *gin.Context) {
OAuthName: svc.Name(),
OAuthSub: user.Sub,
}
if idToken, ok := token.Extra("id_token").(string); ok {
sessionCookie.OAuthIDToken = idToken
}

controller.log.App.Debug().Msg("Creating session cookie for user")

Expand Down
187 changes: 161 additions & 26 deletions internal/controller/user_controller.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/tinyauthapp/tinyauth/internal/model"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would check if the context is nil here instead of checking the errors.

for id := range controller.runtime.OAuthProviders {
providerID = id
}
}
Comment on lines +292 to +296

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 scheme://host:port, it does not include a path.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use the domain validator for any validating logic. See isRedirectSafe in the OAuth controller.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)
}
Comment thread
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) {
Expand Down
Loading