Skip to content
Draft
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
4 changes: 4 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 Expand Up @@ -194,6 +196,8 @@ TINYAUTH_OIDC_CLIENTS_name_CLIENTSECRET=
TINYAUTH_OIDC_CLIENTS_name_CLIENTSECRETFILE=
# List of trusted redirect URIs.
TINYAUTH_OIDC_CLIENTS_name_TRUSTEDREDIRECTURIS=
# List of trusted post-logout redirect URIs.
TINYAUTH_OIDC_CLIENTS_name_TRUSTEDPOSTLOGOUTREDIRECTURIS=
# Client name in UI.
TINYAUTH_OIDC_CLIENTS_name_NAME=

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
20 changes: 17 additions & 3 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,15 +126,25 @@ 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;
redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`);
if (typeof redirectUrl === "string" && redirectUrl.length > 0) {
window.location.replace(redirectUrl);
} else {
window.location.replace(`/login${compiledParams}`);
}
}, 500);
},
onError: () => {
Expand Down
20 changes: 17 additions & 3 deletions frontend/src/pages/logout-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,17 +36,31 @@ 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;
redirectTimer.current = window.setTimeout(() => {
window.location.replace(`/login${compiledParams}`);
if (typeof redirectUrl === "string" && redirectUrl.length > 0) {
window.location.replace(redirectUrl);
} else {
window.location.replace(`/login${compiledParams}`);
}
}, 500);
},
onError: () => {
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
77 changes: 77 additions & 0 deletions internal/controller/oidc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type authorizeErrorParams struct {
type OIDCController struct {
log *logger.Logger
oidc *service.OIDCService
auth *service.AuthService
runtime *model.RuntimeConfig
}

Expand Down Expand Up @@ -88,6 +89,7 @@ type OIDCControllerInput struct {

Log *logger.Logger
OIDCService *service.OIDCService
AuthService *service.AuthService
RuntimeConfig *model.RuntimeConfig
RouterGroup *gin.RouterGroup `name:"apiRouterGroup"`
MainRouter *gin.RouterGroup `name:"mainRouterGroup"`
Expand All @@ -97,6 +99,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
controller := &OIDCController{
log: i.Log,
oidc: i.OIDCService,
auth: i.AuthService,
runtime: i.RuntimeConfig,
}

Expand All @@ -105,13 +108,87 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {

oidcGroup := i.RouterGroup.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.GET("/end-session", controller.endSession)
oidcGroup.POST("/end-session", controller.endSession)
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo)

return controller
}

func (controller *OIDCController) endSession(c *gin.Context) {
if controller.oidc == nil {
c.JSON(http.StatusNotFound, gin.H{
"status": http.StatusNotFound,
"message": "OIDC service not configured",
})
return
}

req := service.EndSessionRequest{}
err := c.ShouldBind(&req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "Bad Request",
})
return
}

userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
userContext = nil
}

redirectURI, err := controller.oidc.ValidateEndSessionRequest(c, req, userContext)
if err != nil {
if errors.Is(err, service.ErrEndSessionConfirmationNeeded) {
c.Redirect(http.StatusFound, controller.runtime.AppURL+"/logout")
return
}
controller.log.App.Warn().Err(err).Msg("Rejected OIDC end-session request")
c.JSON(http.StatusBadRequest, gin.H{
"status": http.StatusBadRequest,
"message": "Invalid end-session request",
})
return
}

sessionID, err := c.Cookie(controller.runtime.SessionCookieName)
if err != nil && !errors.Is(err, http.ErrNoCookie) {
controller.log.App.Error().Err(err).Msg("Error retrieving session cookie on OIDC logout")
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": "Internal Server Error",
})
return
}

callbackTicket := controller.auth.CreateLogoutCallbackTicket(redirectURI)
result, err := controller.auth.Logout(c, service.LogoutRequest{
SessionID: sessionID,
UserContext: userContext,
ClientIP: c.ClientIP(),
RedirectURI: redirectURI,
ProviderCallbackURL: controller.runtime.AppURL + "/api/user/logout/callback",
ProviderState: callbackTicket,
})
if err != nil {
controller.log.App.Error().Err(err).Msg("Error deleting session on OIDC logout")
c.JSON(http.StatusInternalServerError, gin.H{
"status": http.StatusInternalServerError,
"message": "Internal Server Error",
})
return
}
if result.Cookie != nil {
http.SetCookie(c.Writer, result.Cookie)
}

c.Redirect(http.StatusFound, result.RedirectURL)
}

// This endpoint does **not** return a code, it handles param validation, ticket creation
// and then redirects to the frontend to handle the consent screen. It performs no destructive
// actions (like logging out an existing session)
Expand Down
Loading