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
19 changes: 17 additions & 2 deletions frontend/src/components/quick-actions/quick-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export const QuickActions = () => {
}
return "";
})();
const logoutParams =
screenParams.redirect_uri && screenParams.login_for !== "oidc"
? { login_for: "app", redirect_uri: screenParams.redirect_uri }
: undefined;

const [isOpen, setIsOpen] = useState(false);

Expand Down Expand Up @@ -122,13 +126,24 @@ 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: 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 +141 to +145

Copy link
Copy Markdown
Author

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()?


redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`);
}, 500);
Expand Down
19 changes: 17 additions & 2 deletions frontend/src/pages/logout-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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);
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
174 changes: 149 additions & 25 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()
...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I've aligned with the code style to re-use err between blocks.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 22a0e8eb66fa, 860d8895057a, and 24f3d824703c: the logout flow now uses err instead of separate sessionErr, deleteErr, contextErr, and buildErr variables. The final commit is limited to the buildErrerr rename.

Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -28,6 +31,7 @@ type TotpRequest struct {

type UserController struct {
log *logger.Logger
config *model.Config
runtime *model.RuntimeConfig
auth *service.AuthService
}
Expand All @@ -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
Expand All @@ -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)

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

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.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in 61efe839398f. safeLogoutRedirect now uses the shared domain validator for scheme, hostname, and port validation, following the OAuth controller’s approach and respecting the subdomain setting.

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) {
Expand Down
Loading