From 2454ba58ea44e2775101c80fe3e0c1341b681338 Mon Sep 17 00:00:00 2001 From: Stavros Date: Mon, 1 Jun 2026 17:04:08 +0300 Subject: [PATCH] refactor: use ticket approach for oidc flow --- frontend/src/lib/hooks/oidc.ts | 76 ------------------------- frontend/src/lib/hooks/screen-params.ts | 40 +++++++++++++ frontend/src/main.tsx | 5 +- frontend/src/pages/authorize-page.tsx | 72 ++++++++--------------- frontend/src/pages/login-page.tsx | 69 +++++++--------------- frontend/src/pages/totp-page.tsx | 18 +++--- frontend/src/schemas/oidc-schemas.ts | 5 -- internal/bootstrap/router_bootstrap.go | 2 +- internal/controller/oidc_controller.go | 4 +- 9 files changed, 99 insertions(+), 192 deletions(-) delete mode 100644 frontend/src/lib/hooks/oidc.ts create mode 100644 frontend/src/lib/hooks/screen-params.ts delete mode 100644 frontend/src/schemas/oidc-schemas.ts diff --git a/frontend/src/lib/hooks/oidc.ts b/frontend/src/lib/hooks/oidc.ts deleted file mode 100644 index 1341e8c2..00000000 --- a/frontend/src/lib/hooks/oidc.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { z } from "zod"; - -export const oidcParamsSchema = z.object({ - scope: z.string().min(1), - response_type: z.string().min(1), - client_id: z.string().min(1), - redirect_uri: z.string().min(1), - state: z.string().optional(), - nonce: z.string().optional(), - code_challenge: z.string().optional(), - code_challenge_method: z.string().optional(), -}); - -function b64urlDecode(s: string): string { - const base64 = s.replace(/-/g, "+").replace(/_/g, "/"); - return atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); -} - -function decodeRequestObject(jwt: string): Record { - try { - // Must have exactly 3 parts: header, payload, signature - const parts = jwt.split("."); - if (parts.length !== 3) return {}; - - // Header must specify "alg": "none" and signature must be empty string - const header = JSON.parse(b64urlDecode(parts[0])); - if (!header || typeof header !== "object" || header.alg !== "none" || parts[2] !== "") return {}; - - const payload = JSON.parse(b64urlDecode(parts[1])); - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return {}; - const result: Record = {}; - for (const [k, v] of Object.entries(payload)) { - if (typeof v === "string") result[k] = v; - } - return result; - } catch { - return {}; - } -} - -export const useOIDCParams = ( - params: URLSearchParams, -): { - values: z.infer; - issues: string[]; - isOidc: boolean; - compiled: string; -} => { - const obj = Object.fromEntries(params.entries()); - - // RFC 9101 / OIDC Core 6.1: if `request` param present, decode JWT payload - // and merge claims over top-level params (JWT claims take precedence) - const requestJwt = params.get("request"); - if (requestJwt) { - const claims = decodeRequestObject(requestJwt); - Object.assign(obj, claims); - } - - const parsed = oidcParamsSchema.safeParse(obj); - - if (parsed.success) { - return { - values: parsed.data, - issues: [], - isOidc: true, - compiled: new URLSearchParams(parsed.data).toString(), - }; - } - - return { - issues: parsed.error.issues.map((issue) => issue.path.toString()), - values: {} as z.infer, - isOidc: false, - compiled: "", - }; -}; diff --git a/frontend/src/lib/hooks/screen-params.ts b/frontend/src/lib/hooks/screen-params.ts new file mode 100644 index 00000000..bde309c7 --- /dev/null +++ b/frontend/src/lib/hooks/screen-params.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; + +type ScreenParams = { + login_for?: "oidc" | "app"; + redirect_url?: string; + oidc_ticket?: string; + oidc_scope?: string; + oidc_name?: string; +}; + +const zodScreenParams = z.object({ + login_for: z.enum(["oidc", "app"]).optional(), + redirect_url: z.string().optional(), + oidc_ticket: z.string().optional(), + oidc_scope: z.string().optional(), + oidc_name: z.string().optional(), +}); + +export function useScreenParams(params: URLSearchParams): ScreenParams { + const paramsObj = Object.fromEntries(params.entries()); + const parsed = zodScreenParams.safeParse(paramsObj); + if (!parsed.success) { + return {}; + } + return parsed.data; +} + +export function recompileScreenParams(params: ScreenParams): string { + const p = new URLSearchParams( + Object.fromEntries( + Object.entries(params).filter(([, v]) => v !== null), + ) as Record, + ).toString(); + + if (p.length > 0) { + return "?" + p; + } + + return ""; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 29b3e475..4af686d5 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -35,7 +35,10 @@ createRoot(document.getElementById("root")!).render( } errorElement={}> } /> } /> - } /> + } + /> } /> } /> } /> diff --git a/frontend/src/pages/authorize-page.tsx b/frontend/src/pages/authorize-page.tsx index 91f8f9c9..7f5c516c 100644 --- a/frontend/src/pages/authorize-page.tsx +++ b/frontend/src/pages/authorize-page.tsx @@ -1,5 +1,5 @@ import { useUserContext } from "@/context/user-context"; -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation } from "@tanstack/react-query"; import { Navigate, useNavigate } from "react-router"; import { useLocation } from "react-router"; import { @@ -10,11 +10,9 @@ import { CardFooter, CardContent, } from "@/components/ui/card"; -import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas"; import { Button } from "@/components/ui/button"; import axios from "axios"; import { toast } from "sonner"; -import { useOIDCParams } from "@/lib/hooks/oidc"; import { useTranslation } from "react-i18next"; import { TFunction } from "i18next"; import { Mail, MapPin, Phone, Shield, User, Users } from "lucide-react"; @@ -23,6 +21,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + recompileScreenParams, + useScreenParams, +} from "@/lib/hooks/screen-params"; type Scope = { id: string; @@ -84,27 +86,17 @@ export const AuthorizePage = () => { const scopeMap = createScopeMap(t); const searchParams = new URLSearchParams(search); - const oidcParams = useOIDCParams(searchParams); - - const getClientInfo = useQuery({ - queryKey: ["client", oidcParams.values.client_id], - queryFn: async () => { - const res = await fetch( - `/api/oidc/clients/${encodeURIComponent(oidcParams.values.client_id)}`, - ); - const data = await getOidcClientInfoSchema.parseAsync(await res.json()); - return data; - }, - enabled: oidcParams.isOidc, - }); + const screenParams = useScreenParams(searchParams); + const isOidc = screenParams.login_for === "oidc"; + const compiledParams = recompileScreenParams(screenParams); const authorizeMutation = useMutation({ mutationFn: () => { - return axios.post("/api/oidc/authorize", { - ...oidcParams.values, + return axios.post("/api/oidc/authorize-complete", { + ticket: screenParams.oidc_ticket, }); }, - mutationKey: ["authorize", oidcParams.values.client_id], + mutationKey: ["authorize", screenParams.oidc_ticket], onSuccess: (data) => { toast.info(t("authorizeSuccessTitle"), { description: t("authorizeSuccessSubtitle"), @@ -118,56 +110,38 @@ export const AuthorizePage = () => { }, }); - if (oidcParams.issues.length > 0) { + if ( + !isOidc || + screenParams.oidc_ticket === undefined || + screenParams.oidc_scope === undefined + ) { return ( ); } if (!auth.authenticated) { - return ; - } - - if (getClientInfo.isLoading) { - return ( - - - - {t("authorizeLoadingTitle")} - - - - {t("authorizeLoadingSubtitle")} - - - ); - } - - if (getClientInfo.isError) { - return ( - - ); + return ; } const scopes = - oidcParams.values.scope.split(" ").filter((s) => s.trim() !== "") || []; + screenParams.oidc_scope.split(" ").filter((s) => s.trim() !== "") || []; return (
- {getClientInfo.data?.name.slice(0, 1) || "U"} + {screenParams.oidc_name !== undefined + ? screenParams.oidc_name.slice(0, 1) + : "U"}
{t("authorizeCardTitle", { - app: getClientInfo.data?.name || "Unknown", + app: screenParams.oidc_name || "Unknown", })} diff --git a/frontend/src/pages/login-page.tsx b/frontend/src/pages/login-page.tsx index 3295a7ed..b46ac998 100644 --- a/frontend/src/pages/login-page.tsx +++ b/frontend/src/pages/login-page.tsx @@ -18,7 +18,6 @@ import { OAuthButton } from "@/components/ui/oauth-button"; import { SeperatorWithChildren } from "@/components/ui/separator"; import { useAppContext } from "@/context/app-context"; import { useUserContext } from "@/context/user-context"; -import { useOIDCParams } from "@/lib/hooks/oidc"; import { LoginSchema } from "@/schemas/login-schema"; import { useMutation } from "@tanstack/react-query"; import axios, { AxiosError } from "axios"; @@ -26,6 +25,10 @@ import { useEffect, useId, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; +import { + recompileScreenParams, + useScreenParams, +} from "@/lib/hooks/screen-params"; const iconMap: Record = { google: , @@ -46,7 +49,9 @@ export const LoginPage = () => { const { t } = useTranslation(); const [showRedirectButton, setShowRedirectButton] = useState(false); - const [useTailscale, setUseTailscale] = useState(tailscale.nodeName !== undefined); + const [useTailscale, setUseTailscale] = useState( + tailscale.nodeName !== undefined, + ); const hasAutoRedirectedRef = useRef(false); @@ -56,17 +61,19 @@ export const LoginPage = () => { const formId = useId(); const searchParams = new URLSearchParams(search); - const redirectUri = searchParams.get("redirect_uri") || undefined; - const oidcParams = useOIDCParams(searchParams); + const screenParams = useScreenParams(searchParams); + const isOidc = screenParams.login_for === "oidc"; + const compiledParams = recompileScreenParams(screenParams); const [isOauthAutoRedirect, setIsOauthAutoRedirect] = useState( providers.find((provider) => provider.id === oauth.autoRedirect) !== - undefined && redirectUri !== undefined, + undefined && screenParams.redirect_url !== undefined, ); const oauthProviders = providers.filter( (provider) => provider.id !== "local" && provider.id !== "ldap", ); + const userAuthConfigured = providers.find( (provider) => provider.id === "local" || provider.id === "ldap", @@ -79,16 +86,7 @@ export const LoginPage = () => { variables: oauthVariables, } = useMutation({ mutationFn: (provider: string) => { - const getParams = function (): string { - if (oidcParams.isOidc) { - return `?${oidcParams.compiled}`; - } - if (redirectUri) { - return `?redirect_uri=${encodeURIComponent(redirectUri)}`; - } - return ""; - }; - return axios.get(`/api/oauth/url/${provider}${getParams()}`); + return axios.get(`/api/oauth/url/${provider}${compiledParams}`); }, mutationKey: ["oauth"], onSuccess: (data) => { @@ -119,13 +117,7 @@ export const LoginPage = () => { mutationKey: ["login"], onSuccess: (data) => { if (data.data.totpPending) { - if (oidcParams.isOidc) { - window.location.replace(`/totp?${oidcParams.compiled}`); - return; - } - window.location.replace( - `/totp${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`, - ); + window.location.replace(`/totp${compiledParams}`); return; } @@ -134,13 +126,7 @@ export const LoginPage = () => { }); redirectTimer.current = window.setTimeout(() => { - if (oidcParams.isOidc) { - window.location.replace(`/authorize?${oidcParams.compiled}`); - return; - } - window.location.replace( - `/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`, - ); + window.location.replace(`/continue${compiledParams}`); }, 500); }, onError: (error: AxiosError) => { @@ -163,13 +149,7 @@ export const LoginPage = () => { }); redirectTimer.current = window.setTimeout(() => { - if (oidcParams.isOidc) { - window.location.replace(`/authorize?${oidcParams.compiled}`); - return; - } - window.location.replace( - `/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`, - ); + window.location.replace(`/continue${compiledParams}`); }, 500); }, onError: () => { @@ -184,7 +164,7 @@ export const LoginPage = () => { !auth.authenticated && isOauthAutoRedirect && !hasAutoRedirectedRef.current && - redirectUri !== undefined + screenParams.redirect_url !== undefined ) { hasAutoRedirectedRef.current = true; oauthMutate(oauth.autoRedirect); @@ -195,7 +175,7 @@ export const LoginPage = () => { hasAutoRedirectedRef, oauth.autoRedirect, isOauthAutoRedirect, - redirectUri, + screenParams.redirect_url, ]); useEffect(() => { @@ -210,17 +190,12 @@ export const LoginPage = () => { }; }, [redirectTimer, redirectButtonTimer]); - if (auth.authenticated && oidcParams.isOidc) { - return ; + if (auth.authenticated && isOidc) { + return ; } - if (auth.authenticated && redirectUri !== undefined) { - return ( - - ); + if (auth.authenticated && screenParams.redirect_url !== undefined) { + return ; } if (auth.authenticated) { diff --git a/frontend/src/pages/totp-page.tsx b/frontend/src/pages/totp-page.tsx index 984cb8db..3b16d615 100644 --- a/frontend/src/pages/totp-page.tsx +++ b/frontend/src/pages/totp-page.tsx @@ -16,7 +16,10 @@ import { useEffect, useId, useRef } from "react"; import { useTranslation } from "react-i18next"; import { Navigate, useLocation } from "react-router"; import { toast } from "sonner"; -import { useOIDCParams } from "@/lib/hooks/oidc"; +import { + recompileScreenParams, + useScreenParams, +} from "@/lib/hooks/screen-params"; export const TotpPage = () => { const { totp } = useUserContext(); @@ -27,8 +30,8 @@ export const TotpPage = () => { const redirectTimer = useRef(null); const searchParams = new URLSearchParams(search); - const redirectUri = searchParams.get("redirect_uri") || undefined; - const oidcParams = useOIDCParams(searchParams); + const screenParams = useScreenParams(searchParams); + const compiledParams = recompileScreenParams(screenParams); const totpMutation = useMutation({ mutationFn: (values: TotpSchema) => axios.post("/api/user/totp", values), @@ -39,14 +42,7 @@ export const TotpPage = () => { }); redirectTimer.current = window.setTimeout(() => { - if (oidcParams.isOidc) { - window.location.replace(`/authorize?${oidcParams.compiled}`); - return; - } - - window.location.replace( - `/continue${redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""}`, - ); + window.location.replace(`/continue${compiledParams}`); }, 500); }, onError: () => { diff --git a/frontend/src/schemas/oidc-schemas.ts b/frontend/src/schemas/oidc-schemas.ts deleted file mode 100644 index 022bdfbf..00000000 --- a/frontend/src/schemas/oidc-schemas.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { z } from "zod"; - -export const getOidcClientInfoSchema = z.object({ - name: z.string(), -}); diff --git a/internal/bootstrap/router_bootstrap.go b/internal/bootstrap/router_bootstrap.go index a89c8fc2..5244ab20 100644 --- a/internal/bootstrap/router_bootstrap.go +++ b/internal/bootstrap/router_bootstrap.go @@ -59,7 +59,7 @@ func (app *BootstrapApp) setupRouter() error { controller.NewContextController(app.log, app.config, app.runtime, apiRouter) controller.NewOAuthController(app.log, app.config, app.runtime, apiRouter, app.services.authService) - controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &app.router.RouterGroup) + controller.NewOIDCController(app.log, app.services.oidcService, app.runtime, apiRouter, &engine.RouterGroup) controller.NewProxyController(app.log, app.runtime, apiRouter, app.services.accessControlService, app.services.authService, app.services.policyEngine) controller.NewUserController(app.log, app.runtime, apiRouter, app.services.authService) controller.NewResourcesController(app.config, &engine.RouterGroup) diff --git a/internal/controller/oidc_controller.go b/internal/controller/oidc_controller.go index 50f28f52..e6c3562b 100644 --- a/internal/controller/oidc_controller.go +++ b/internal/controller/oidc_controller.go @@ -74,7 +74,7 @@ type AuthorizeScreenParams struct { } type AuthorizeCompleteRequest struct { - Ticket string `json:"oidc_ticket" binding:"required"` + Ticket string `json:"ticket" binding:"required"` } func NewOIDCController( @@ -166,7 +166,7 @@ func (controller *OIDCController) authorize(c *gin.Context) { ticket := controller.oidc.CreateAuthorizeRequestTicket(req) queries, err := query.Values(AuthorizeScreenParams{ - LoginFor: req.ClientID, + LoginFor: "oidc", OIDCTicket: ticket, OIDCScope: req.Scope, OIDCName: client.Name,