Skip to content

Commit 671343f

Browse files
authored
feat: oidc (#605)
* chore: add oidc base config * wip: authorize page * feat: implement basic oidc functionality * refactor: implement oidc following tinyauth patterns * feat: adapt frontend to oidc flow * fix: review comments * fix: oidc review comments * feat: refresh token grant type support * feat: cleanup expired oidc sessions * feat: frontend i18n * fix: fix typo in error screen * tests: add basic testing * fix: more review comments * refactor: rework oidc error messages * feat: openid discovery endpoint * feat: jwk endpoint * i18n: fix typo * fix: more rabbit nitpicks * fix: final review comments * i18n: authorize page error messages
1 parent 252ba10 commit 671343f

38 files changed

Lines changed: 2573 additions & 64 deletions

Makefile

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ deps:
1818
bun install --cwd frontend
1919
go mod download
2020

21+
# Clean data
22+
clean-data:
23+
rm -rf data/
24+
2125
# Clean web UI build
2226
clean-webui:
2327
rm -rf internal/assets/dist
@@ -57,11 +61,11 @@ test:
5761

5862
# Development
5963
develop:
60-
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
64+
docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
6165

6266
# Development - Infisical
6367
develop-infisical:
64-
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans
68+
infisical run --env=dev -- docker compose -f $(DEV_COMPOSE) up --force-recreate --pull=always --remove-orphans --build
6569

6670
# Production
6771
prod:

cmd/tinyauth/tinyauth.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,10 @@ func NewTinyauthCmdConfiguration() *config.Config {
5454
},
5555
},
5656
},
57+
OIDC: config.OIDCConfig{
58+
PrivateKeyPath: "./tinyauth_oidc_key",
59+
PublicKeyPath: "./tinyauth_oidc_key.pub",
60+
},
5761
Experimental: config.ExperimentalConfig{
5862
ConfigFile: "",
5963
},

frontend/src/index.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@ code {
159159
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold break-all;
160160
}
161161

162+
pre {
163+
@apply bg-accent border border-border rounded-md p-2;
164+
}
165+
162166
.lead {
163167
@apply text-xl text-muted-foreground;
164168
}

frontend/src/lib/hooks/oidc.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export type OIDCValues = {
2+
scope: string;
3+
response_type: string;
4+
client_id: string;
5+
redirect_uri: string;
6+
state: string;
7+
};
8+
9+
interface IuseOIDCParams {
10+
values: OIDCValues;
11+
compiled: string;
12+
isOidc: boolean;
13+
missingParams: string[];
14+
}
15+
16+
const optionalParams: string[] = ["state"];
17+
18+
export function useOIDCParams(params: URLSearchParams): IuseOIDCParams {
19+
let compiled: string = "";
20+
let isOidc = false;
21+
const missingParams: string[] = [];
22+
23+
const values: OIDCValues = {
24+
scope: params.get("scope") ?? "",
25+
response_type: params.get("response_type") ?? "",
26+
client_id: params.get("client_id") ?? "",
27+
redirect_uri: params.get("redirect_uri") ?? "",
28+
state: params.get("state") ?? "",
29+
};
30+
31+
for (const key of Object.keys(values)) {
32+
if (!values[key as keyof OIDCValues]) {
33+
if (!optionalParams.includes(key)) {
34+
missingParams.push(key);
35+
}
36+
}
37+
}
38+
39+
if (missingParams.length === 0) {
40+
isOidc = true;
41+
}
42+
43+
if (isOidc) {
44+
compiled = new URLSearchParams(values).toString();
45+
}
46+
47+
return {
48+
values,
49+
compiled,
50+
isOidc,
51+
missingParams,
52+
};
53+
}

frontend/src/lib/i18n/locales/en-US.json

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,31 @@
5151
"forgotPasswordTitle": "Forgot your password?",
5252
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
5353
"errorTitle": "An error occurred",
54-
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
54+
"errorSubtitleInfo": "The following error occurred while processing your request:",
55+
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
5556
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
5657
"fieldRequired": "This field is required",
5758
"invalidInput": "Invalid input",
5859
"domainWarningTitle": "Invalid Domain",
5960
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
6061
"ignoreTitle": "Ignore",
61-
"goToCorrectDomainTitle": "Go to correct domain"
62-
}
62+
"goToCorrectDomainTitle": "Go to correct domain",
63+
"authorizeTitle": "Authorize",
64+
"authorizeCardTitle": "Continue to {{app}}?",
65+
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
66+
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
67+
"authorizeLoadingTitle": "Loading...",
68+
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
69+
"authorizeSuccessTitle": "Authorized",
70+
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
71+
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
72+
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
73+
"openidScopeName": "OpenID Connect",
74+
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
75+
"emailScopeName": "Email",
76+
"emailScopeDescription": "Allows the app to access your email address.",
77+
"profileScopeName": "Profile",
78+
"profileScopeDescription": "Allows the app to access your profile information.",
79+
"groupsScopeName": "Groups",
80+
"groupsScopeDescription": "Allows the app to access your group information."
81+
}

frontend/src/lib/i18n/locales/en.json

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,31 @@
5151
"forgotPasswordTitle": "Forgot your password?",
5252
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
5353
"errorTitle": "An error occurred",
54-
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information.",
54+
"errorSubtitleInfo": "The following error occurred while processing your request:",
55+
"errorSubtitle": "An error occurred while trying to perform this action. Please check your browser console or the app logs for more information.",
5556
"forgotPasswordMessage": "You can reset your password by changing the `USERS` environment variable.",
5657
"fieldRequired": "This field is required",
5758
"invalidInput": "Invalid input",
5859
"domainWarningTitle": "Invalid Domain",
5960
"domainWarningSubtitle": "This instance is configured to be accessed from <code>{{appUrl}}</code>, but <code>{{currentUrl}}</code> is being used. If you proceed, you may encounter issues with authentication.",
6061
"ignoreTitle": "Ignore",
61-
"goToCorrectDomainTitle": "Go to correct domain"
62-
}
62+
"goToCorrectDomainTitle": "Go to correct domain",
63+
"authorizeTitle": "Authorize",
64+
"authorizeCardTitle": "Continue to {{app}}?",
65+
"authorizeSubtitle": "Would you like to continue to this app? Please carefully review the permissions requested by the app.",
66+
"authorizeSubtitleOAuth": "Would you like to continue to this app?",
67+
"authorizeLoadingTitle": "Loading...",
68+
"authorizeLoadingSubtitle": "Please wait while we load the client information.",
69+
"authorizeSuccessTitle": "Authorized",
70+
"authorizeSuccessSubtitle": "You will be redirected to the app in a few seconds.",
71+
"authorizeErrorClientInfo": "An error occurred while loading the client information. Please try again later.",
72+
"authorizeErrorMissingParams": "The following parameters are missing: {{missingParams}}",
73+
"openidScopeName": "OpenID Connect",
74+
"openidScopeDescription": "Allows the app to access your OpenID Connect information.",
75+
"emailScopeName": "Email",
76+
"emailScopeDescription": "Allows the app to access your email address.",
77+
"profileScopeName": "Profile",
78+
"profileScopeDescription": "Allows the app to access your profile information.",
79+
"groupsScopeName": "Groups",
80+
"groupsScopeDescription": "Allows the app to access your group information."
81+
}

frontend/src/main.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { AppContextProvider } from "./context/app-context.tsx";
1717
import { UserContextProvider } from "./context/user-context.tsx";
1818
import { Toaster } from "@/components/ui/sonner";
1919
import { ThemeProvider } from "./components/providers/theme-provider.tsx";
20+
import { AuthorizePage } from "./pages/authorize-page.tsx";
2021

2122
const queryClient = new QueryClient();
2223

@@ -31,6 +32,7 @@ createRoot(document.getElementById("root")!).render(
3132
<Route element={<Layout />} errorElement={<ErrorPage />}>
3233
<Route path="/" element={<App />} />
3334
<Route path="/login" element={<LoginPage />} />
35+
<Route path="/authorize" element={<AuthorizePage />} />
3436
<Route path="/logout" element={<LogoutPage />} />
3537
<Route path="/continue" element={<ContinuePage />} />
3638
<Route path="/totp" element={<TotpPage />} />
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import { useUserContext } from "@/context/user-context";
2+
import { useMutation, useQuery } from "@tanstack/react-query";
3+
import { Navigate, useNavigate } from "react-router";
4+
import { useLocation } from "react-router";
5+
import {
6+
Card,
7+
CardHeader,
8+
CardTitle,
9+
CardDescription,
10+
CardFooter,
11+
CardContent,
12+
} from "@/components/ui/card";
13+
import { getOidcClientInfoSchema } from "@/schemas/oidc-schemas";
14+
import { Button } from "@/components/ui/button";
15+
import axios from "axios";
16+
import { toast } from "sonner";
17+
import { useOIDCParams } from "@/lib/hooks/oidc";
18+
import { useTranslation } from "react-i18next";
19+
import { TFunction } from "i18next";
20+
import { Mail, Shield, User, Users } from "lucide-react";
21+
22+
type Scope = {
23+
id: string;
24+
name: string;
25+
description: string;
26+
icon: React.ReactNode;
27+
};
28+
29+
const scopeMapIconProps = {
30+
className: "stroke-card stroke-2.5",
31+
};
32+
33+
const createScopeMap = (t: TFunction<"translation", undefined>): Scope[] => {
34+
return [
35+
{
36+
id: "openid",
37+
name: t("openidScopeName"),
38+
description: t("openidScopeDescription"),
39+
icon: <Shield {...scopeMapIconProps} />,
40+
},
41+
{
42+
id: "email",
43+
name: t("emailScopeName"),
44+
description: t("emailScopeDescription"),
45+
icon: <Mail {...scopeMapIconProps} />,
46+
},
47+
{
48+
id: "profile",
49+
name: t("profileScopeName"),
50+
description: t("profileScopeDescription"),
51+
icon: <User {...scopeMapIconProps} />,
52+
},
53+
{
54+
id: "groups",
55+
name: t("groupsScopeName"),
56+
description: t("groupsScopeDescription"),
57+
icon: <Users {...scopeMapIconProps} />,
58+
},
59+
];
60+
};
61+
62+
export const AuthorizePage = () => {
63+
const { isLoggedIn } = useUserContext();
64+
const { search } = useLocation();
65+
const { t } = useTranslation();
66+
const navigate = useNavigate();
67+
const scopeMap = createScopeMap(t);
68+
69+
const searchParams = new URLSearchParams(search);
70+
const {
71+
values: props,
72+
missingParams,
73+
isOidc,
74+
compiled: compiledOIDCParams,
75+
} = useOIDCParams(searchParams);
76+
const scopes = props.scope ? props.scope.split(" ").filter(Boolean) : [];
77+
78+
const getClientInfo = useQuery({
79+
queryKey: ["client", props.client_id],
80+
queryFn: async () => {
81+
const res = await fetch(`/api/oidc/clients/${props.client_id}`);
82+
const data = await getOidcClientInfoSchema.parseAsync(await res.json());
83+
return data;
84+
},
85+
enabled: isOidc,
86+
});
87+
88+
const authorizeMutation = useMutation({
89+
mutationFn: () => {
90+
return axios.post("/api/oidc/authorize", {
91+
scope: props.scope,
92+
response_type: props.response_type,
93+
client_id: props.client_id,
94+
redirect_uri: props.redirect_uri,
95+
state: props.state,
96+
});
97+
},
98+
mutationKey: ["authorize", props.client_id],
99+
onSuccess: (data) => {
100+
toast.info(t("authorizeSuccessTitle"), {
101+
description: t("authorizeSuccessSubtitle"),
102+
});
103+
window.location.replace(data.data.redirect_uri);
104+
},
105+
onError: (error) => {
106+
window.location.replace(
107+
`/error?error=${encodeURIComponent(error.message)}`,
108+
);
109+
},
110+
});
111+
112+
if (missingParams.length > 0) {
113+
return (
114+
<Navigate
115+
to={`/error?error=${encodeURIComponent(t("authorizeErrorMissingParams", { missingParams: missingParams.join(", ") }))}`}
116+
replace
117+
/>
118+
);
119+
}
120+
121+
if (!isLoggedIn) {
122+
return <Navigate to={`/login?${compiledOIDCParams}`} replace />;
123+
}
124+
125+
if (getClientInfo.isLoading) {
126+
return (
127+
<Card className="min-w-xs sm:min-w-sm">
128+
<CardHeader>
129+
<CardTitle className="text-3xl">
130+
{t("authorizeLoadingTitle")}
131+
</CardTitle>
132+
<CardDescription>{t("authorizeLoadingSubtitle")}</CardDescription>
133+
</CardHeader>
134+
</Card>
135+
);
136+
}
137+
138+
if (getClientInfo.isError) {
139+
return (
140+
<Navigate
141+
to={`/error?error=${encodeURIComponent(t("authorizeErrorClientInfo"))}`}
142+
replace
143+
/>
144+
);
145+
}
146+
147+
return (
148+
<Card className="min-w-xs sm:min-w-sm mx-4">
149+
<CardHeader>
150+
<CardTitle className="text-3xl">
151+
{t("authorizeCardTitle", {
152+
app: getClientInfo.data?.name || "Unknown",
153+
})}
154+
</CardTitle>
155+
<CardDescription>
156+
{scopes.includes("openid")
157+
? t("authorizeSubtitle")
158+
: t("authorizeSubtitleOAuth")}
159+
</CardDescription>
160+
</CardHeader>
161+
{scopes.includes("openid") && (
162+
<CardContent className="flex flex-col gap-4">
163+
{scopes.map((id) => {
164+
const scope = scopeMap.find((s) => s.id === id);
165+
if (!scope) return null;
166+
return (
167+
<div key={scope.id} className="flex flex-row items-center gap-3">
168+
<div className="p-2 flex flex-col items-center justify-center bg-card-foreground rounded-md">
169+
{scope.icon}
170+
</div>
171+
<div className="flex flex-col gap-0.5">
172+
<div className="text-md">{scope.name}</div>
173+
<div className="text-sm text-muted-foreground">
174+
{scope.description}
175+
</div>
176+
</div>
177+
</div>
178+
);
179+
})}
180+
</CardContent>
181+
)}
182+
<CardFooter className="flex flex-col items-stretch gap-2">
183+
<Button
184+
onClick={() => authorizeMutation.mutate()}
185+
loading={authorizeMutation.isPending}
186+
>
187+
{t("authorizeTitle")}
188+
</Button>
189+
<Button
190+
onClick={() => navigate("/")}
191+
disabled={authorizeMutation.isPending}
192+
variant="outline"
193+
>
194+
{t("cancelTitle")}
195+
</Button>
196+
</CardFooter>
197+
</Card>
198+
);
199+
};

frontend/src/pages/continue-page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export const ContinuePage = () => {
8080
clearTimeout(auto);
8181
clearTimeout(reveal);
8282
};
83-
}, []);
83+
});
8484

8585
if (!isLoggedIn) {
8686
return (

0 commit comments

Comments
 (0)