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
55 changes: 39 additions & 16 deletions frontend/src/pages/authorize-page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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 { useLocation } from "react-router";
import {
Expand All @@ -25,7 +25,8 @@ import {
searchParamsFromObject,
useScreenParams,
} from "@/lib/hooks/screen-params";
import { useEffect } from "react";
import {useEffect, useState} from "react";
import { z } from "zod";

type Scope = {
id: string;
Expand All @@ -34,6 +35,10 @@ type Scope = {
icon: React.ReactNode;
};

const skipConsentResponseSchema = z.object({
skipConsent: z.boolean(),
})

const scopeMapIconProps = {
className: "stroke-muted-foreground stroke-[1.75] h-4",
};
Expand Down Expand Up @@ -96,14 +101,8 @@ export const AuthorizePage = () => {
}
return "";
})();

// 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 [autoAuthorize, setAutoAuthorize] = useState(false);
const [skipConsentChecked, setSkipConsentChecked] = useState(false);

const { mutate: authorizeMutate, isPending: authorizePending } = useMutation({
mutationFn: () => {
Expand All @@ -126,10 +125,34 @@ export const AuthorizePage = () => {
});

useEffect(() => {
if (shouldAutoAuthorize) {
authorizeMutate();
}
}, [shouldAutoAuthorize, authorizeMutate]);
let active = true;
const controller = new AbortController();

const checkSkipConsent = async () => {
try {
const res = await fetch(
`/api/oidc/skip-consent?oidc_ticket=${encodeURIComponent( screenParams.oidc_ticket ?? "")}`,
{ signal: controller.signal },
);
if (!res.ok) return;
const parsed = skipConsentResponseSchema.safeParse(await res.json());
if (!active || !parsed.success || !parsed.data.skipConsent) return;
setAutoAuthorize(true);
authorizeMutate();
} catch {
// Fall back to manual consent on any failure (including abort).
} finally {
if (active) setSkipConsentChecked(true);
}
};

checkSkipConsent();

return () => {
active = false;
controller.abort();
};
}, [authorizeMutate, screenParams.oidc_ticket]);

if (!isOidc || !screenParams.oidc_ticket || !screenParams.oidc_scope) {
return (
Expand Down Expand Up @@ -190,13 +213,13 @@ export const AuthorizePage = () => {
<CardFooter className="flex flex-col items-stretch gap-3">
<Button
onClick={() => authorizeMutate()}
loading={authorizePending || shouldAutoAuthorize}
loading={authorizePending || autoAuthorize || !skipConsentChecked}
>
{t("authorizeTitle")}
</Button>
<Button
onClick={() => navigate(`/logout${compiledParams}`)}
disabled={authorizePending || shouldAutoAuthorize}
disabled={authorizePending || autoAuthorize}
variant="outline"
>
{t("cancelTitle")}
Expand Down
2 changes: 1 addition & 1 deletion frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default defineConfig({
plugins: [react(), tailwindcss(), visualizer()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@": path.resolve(import.meta.dirname, "./src"),
},
},
build: {
Expand Down
5 changes: 5 additions & 0 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ type RedirectQuery struct {
RedirectURI string `url:"redirect_uri"`
LoginFor FrontendLoginFor `url:"login_for"`
}

type SimpleResponse struct {
Status int `json:"status"`
Message string `json:"message"`
}
113 changes: 96 additions & 17 deletions internal/controller/oidc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,15 +62,19 @@ type ErrorScreen struct {
Error string `url:"error"`
}

type ClientRequest struct {
ClientID string `uri:"id" binding:"required"`
}

type ClientCredentials struct {
ClientID string
ClientSecret string
}

type SkipConsentRequest struct {
OIDCTicket string `form:"oidc_ticket" binding:"required"`
}

type SkipConsentResponse struct {
SkipConsent bool `json:"skipConsent"`
}

type AuthorizeScreenParams struct {
LoginFor FrontendLoginFor `url:"login_for"`
OIDCTicket string `url:"oidc_ticket"`
Expand Down Expand Up @@ -105,6 +109,7 @@ func NewOIDCController(i OIDCControllerInput) *OIDCController {

oidcGroup := i.RouterGroup.Group("/oidc")
oidcGroup.POST("/authorize-complete", controller.authorizeComplete)
oidcGroup.GET("/skip-consent", controller.skipConsent)
oidcGroup.POST("/token", controller.Token)
oidcGroup.GET("/userinfo", controller.Userinfo)
oidcGroup.POST("/userinfo", controller.Userinfo)
Expand Down Expand Up @@ -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)

if err != nil {
Expand All @@ -270,6 +265,90 @@ func (controller *OIDCController) authorize(c *gin.Context) {
c.Redirect(http.StatusFound, redirectUrl)
}

func (controller *OIDCController) skipConsent(c *gin.Context) {
c.Header("cache-control", "no-store")
c.Header("pragma", "no-cache")

if controller.oidc == nil {
c.JSON(500, SimpleResponse{
Status: 500,
Message: "OIDC not configured",
})
return
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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.OIDCPromptLogin.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,
})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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.
func (controller *OIDCController) authorizeComplete(c *gin.Context) {
Expand Down Expand Up @@ -330,6 +409,9 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}

// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)

// Get the client
client, ok := controller.oidc.GetClient(authorizeReq.ClientID)

Expand All @@ -343,9 +425,6 @@ func (controller *OIDCController) authorizeComplete(c *gin.Context) {
return
}

// We no longer need the ticket
controller.oidc.DeleteAuthorizeRequestTicket(req.Ticket)

// Create the sub to find and delete old sessions
sub := controller.oidc.CreateSub(*userContext, authorizeReq.ClientID)

Expand Down
Loading