fix: consent screen skip status should get checked after auth

This commit is contained in:
Stavros
2026-08-27 17:08:08 +03:00
parent 4bb766997b
commit 766a7287e3
4 changed files with 134 additions and 33 deletions
+25 -16
View File
@@ -1,5 +1,5 @@
import { useUserContext } from "@/context/user-context"; import { useUserContext } from "@/context/user-context";
import { useMutation } from "@tanstack/react-query"; import {useMutation} from "@tanstack/react-query";
import { Navigate, useNavigate } from "react-router"; import { Navigate, useNavigate } from "react-router";
import { useLocation } from "react-router"; import { useLocation } from "react-router";
import { import {
@@ -25,7 +25,8 @@ import {
searchParamsFromObject, searchParamsFromObject,
useScreenParams, useScreenParams,
} from "@/lib/hooks/screen-params"; } from "@/lib/hooks/screen-params";
import { useEffect } from "react"; import {useEffect, useState} from "react";
import { z } from "zod";
type Scope = { type Scope = {
id: string; id: string;
@@ -34,6 +35,10 @@ type Scope = {
icon: React.ReactNode; icon: React.ReactNode;
}; };
const skipConsentResponseSchema = z.object({
skipConsent: z.boolean(),
})
const scopeMapIconProps = { const scopeMapIconProps = {
className: "stroke-muted-foreground stroke-[1.75] h-4", className: "stroke-muted-foreground stroke-[1.75] h-4",
}; };
@@ -96,14 +101,7 @@ export const AuthorizePage = () => {
} }
return ""; return "";
})(); })();
const [autoAuthorize, setAutoAuthorize] = useState(false);
// TODO: maybe a better way to do this
const shouldAutoAuthorize =
auth.authenticated &&
isOidc &&
screenParams.oidc_ticket !== undefined &&
screenParams.oidc_scope !== undefined &&
screenParams.oidc_prompt === "none";
const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({ const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({
mutationFn: () => { mutationFn: () => {
@@ -126,10 +124,21 @@ export const AuthorizePage = () => {
}); });
useEffect(() => { useEffect(() => {
if (shouldAutoAuthorize) { const checkSkipConsent = async () => {
authorizeMutate(); try {
} const res = await fetch(
}, [shouldAutoAuthorize, authorizeMutate]); `/api/oidc/skip-consent?oidc_ticket=${encodeURIComponent(screenParams.oidc_ticket ?? "")}`,
);
if (!res.ok) return;
const parsed = skipConsentResponseSchema.safeParse(await res.json());
if (!parsed.success || !parsed.data.skipConsent) return;
setAutoAuthorize(true);
authorizeMutate();
} catch {}
};
checkSkipConsent();
}, []);
if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) { if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) {
return ( return (
@@ -190,13 +199,13 @@ export const AuthorizePage = () => {
<CardFooter className="flex flex-col items-stretch gap-3"> <CardFooter className="flex flex-col items-stretch gap-3">
<Button <Button
onClick={() => authorizeMutate()} onClick={() => authorizeMutate()}
loading={authorizePending || shouldAutoAuthorize} loading={authorizePending || autoAuthorize}
> >
{t("authorizeTitle")} {t("authorizeTitle")}
</Button> </Button>
<Button <Button
onClick={() => navigate(`/logout${compiledParams}`)} onClick={() => navigate(`/logout${compiledParams}`)}
disabled={authorizePending || shouldAutoAuthorize} disabled={authorizePending || autoAuthorize}
variant="outline" variant="outline"
> >
{t("cancelTitle")} {t("cancelTitle")}
+5
View File
@@ -18,3 +18,8 @@ type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"` RedirectURI string `url:"redirect_uri"`
LoginFor FrontendLoginFor `url:"login_for"` LoginFor FrontendLoginFor `url:"login_for"`
} }
type SimpleResponse struct {
Status int `json:"status"`
Message string `json:"message"`
}
+93 -17
View File
@@ -62,15 +62,19 @@ type ErrorScreen struct {
Error string `url:"error"` Error string `url:"error"`
} }
type ClientRequest struct {
ClientID string `uri:"id" binding:"required"`
}
type ClientCredentials struct { type ClientCredentials struct {
ClientID string ClientID string
ClientSecret string ClientSecret string
} }
type SkipConsentRequest struct {
OIDCTicket string `form:"oidc_ticket" binding:"required"`
}
type SkipConsentResponse struct {
SkipConsent bool `json:"skipConsent"`
}
type AuthorizeScreenParams struct { type AuthorizeScreenParams struct {
LoginFor FrontendLoginFor `url:"login_for"` LoginFor FrontendLoginFor `url:"login_for"`
OIDCTicket string `url:"oidc_ticket"` OIDCTicket string `url:"oidc_ticket"`
@@ -105,6 +109,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {
oidcGroup := i.RouterGroup.Group("/oidc") oidcGroup := i.RouterGroup.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete) oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.GET("/skip-consent", controller.skipConsent)
oidcGroup.POST("/token", controller.Token) oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo) oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo) oidcGroup.POST("/userinfo", controller.Userinfo)
@@ -242,16 +247,6 @@ func (controller *OIDCController) authorize(c *gin.Context) {
} }
} }
if userContext != nil && userContext.Authenticated && values.OIDCPrompt != service.OIDCPromptLogin {
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), req.ClientID)
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
} else if consent != nil && scopesGranted(consent.Scope, req.Scope) {
values.OIDCPrompt = service.OIDCPromptNone
}
}
queries, err := query.Values(values) queries, err := query.Values(values)
if err != nil { if err != nil {
@@ -270,6 +265,87 @@ func (controller *OIDCController) authorize(c *gin.Context) {
c.Redirect(http.StatusFound, redirectUrl) c.Redirect(http.StatusFound, redirectUrl)
} }
func (controller *OIDCController) skipConsent(c *gin.Context) {
if controller.oidc == nil {
c.JSON(500, SimpleResponse{
Status: 500,
Message: "OIDC not configured",
})
return
}
userContext, err := new(model.UserContext).NewFromGin(c)
if err != nil {
if !errors.Is(err, model.ErrUserContextNotFound) {
controller.log.App.Warn().Err(err).Msg("Failed to get user context")
}
}
if err != nil || !userContext.Authenticated {
c.JSON(401, SimpleResponse{
Status: 401,
Message: "User not logged in",
})
return
}
var req SkipConsentRequest
err = c.BindQuery(&req)
if err != nil {
c.JSON(400, SimpleResponse{
Status: 400,
Message: "Bad request",
})
return
}
controller.log.App.Debug().Interface("req", req).Msg("Received skip consent request")
authorizeReq, ok := controller.oidc.GetAuthorizeRequestByTicket(req.OIDCTicket)
if !ok {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
controller.log.App.Debug().Str("client", authorizeReq.ClientID).Str("user", userContext.GetUsername()).Msg("User consented to OIDC")
if authorizeReq.Prompt == service.OIDCPromptNone.String() {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
consent, err := controller.oidc.GetOIDCConsent(c, userContext.GetUsername(), authorizeReq.ClientID)
if err != nil || consent == nil {
if err != nil {
controller.log.App.Warn().Err(err).Msg("Failed to get OIDC consent")
}
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
if !scopesGranted(consent.Scope, authorizeReq.Scope) {
c.JSON(200, SkipConsentResponse{
SkipConsent: false,
})
return
}
c.JSON(200, SkipConsentResponse{
SkipConsent: true,
})
}
// The actual **internal** endpoint that actually creates the code and session. // The actual **internal** endpoint that actually creates the code and session.
// It is called by the frontend after the user has logged in and given consent. // It is called by the frontend after the user has logged in and given consent.
func (controller *OIDCController) authorizeComplete(c *gin.Context) { func (controller *OIDCController) authorizeComplete(c *gin.Context) {
@@ -330,6 +406,9 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return return
} }
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Get the client // Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID) client, ok := controller.oidc.GetClient(authorizeReq.ClientID)
@@ -343,9 +422,6 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return return
} }
// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)
// Create the sub to find and delete old sessions // Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID) sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)
+11
View File
@@ -53,6 +53,17 @@ const (
OIDCPromptNone OIDCPrompt = "none" OIDCPromptNone OIDCPrompt = "none"
) )
func (p OIDCPrompt) String() string {
switch p {
case OIDCPromptLogin:
return "login"
case OIDCPromptNone:
return "none"
default:
return "login"
}
}
var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)} var SupportedPrompts = []string{string(OIDCPromptLogin), string(OIDCPromptNone)}
// This is not spec-compliant, the ID token SHOULD NOT contain user info claims but, // This is not spec-compliant, the ID token SHOULD NOT contain user info claims but,