Add link settings to add oidc to an account

This commit is contained in:
2026-03-25 17:21:46 +01:00
parent 1e385ff911
commit 271375bfec
15 changed files with 193 additions and 158 deletions
+2
View File
@@ -15,6 +15,7 @@ require (
github.com/swaggo/echo-swagger v1.5.0
github.com/swaggo/swag v1.16.6
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
@@ -31,6 +32,7 @@ require (
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
+41 -4
View File
@@ -217,6 +217,35 @@ func (h *Handler) TokenToJwt(next echo.HandlerFunc) echo.HandlerFunc {
}
}
func (h *Handler) OptionalAuthToJwt(next echo.HandlerFunc) echo.HandlerFunc {
return func(c *echo.Context) error {
ctx := c.Request().Context()
auth := c.Request().Header.Get("Authorization")
if auth == "" {
return next(c)
}
if !strings.HasPrefix(auth, "Bearer ") {
return echo.NewHTTPError(http.StatusForbidden, "Invalid bearer format")
}
token := auth[len("Bearer "):]
// this is only used to check if it is a session token or a jwt
_, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return next(c)
}
jwt, err := h.createJwt(ctx, token)
if err != nil {
return err
}
c.Request().Header.Set("Authorization", fmt.Sprintf("Bearer %s", jwt))
return next(c)
}
}
// @title Keibi - Kyoo's auth
// @version 1.0
// @description Auth system made for kyoo.
@@ -348,15 +377,23 @@ func main() {
g.POST("/users", h.Register)
g.POST("/sessions", h.Login)
g.GET("/oidc/login/:provider", h.OidcLogin)
g.GET("/oidc/logged/:provider", h.OidcLogged)
g.GET("/oidc/callback/:provider", h.OidcCallback)
r.GET("/sessions", h.ListMySessions)
r.DELETE("/sessions", h.Logout)
r.DELETE("/sessions/:id", h.Logout)
r.DELETE("/oidc/login/:provider", h.OidcUnlink)
r.GET("/users/:id/sessions", h.ListUserSessions)
g.GET("/oidc/login/:provider", h.OidcLogin)
r.DELETE("/oidc/login/:provider", h.OidcUnlink)
g.GET("/oidc/logged/:provider", h.OidcLogged)
or := e.Group("/auth")
or.Use(h.OptionalAuthToJwt)
or.Use(echojwt.WithConfig(echojwt.Config{
SigningMethod: "RS256",
SigningKey: h.config.JwtPublicKey,
}))
or.GET("/oidc/callback/:provider", h.OidcCallback)
r.GET("/keys", h.ListApiKey)
r.POST("/keys", h.CreateApiKey)
r.DELETE("/keys/:id", h.DeleteApiKey)
+7
View File
@@ -6,6 +6,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
@@ -241,15 +242,18 @@ func (h *Handler) exchangeOidcCode(c *echo.Context, provider OidcProviderConfig,
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Error("Error calling oidc token endpoint: %v", "err", err)
return Token{}, echo.NewHTTPError(http.StatusBadGateway, "Could not reach OIDC token endpoint")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
slog.Error("Error on oidc token endpoint: %v", "err", err)
return Token{}, echo.NewHTTPError(http.StatusBadGateway, "OIDC token exchange failed")
}
var ret Token
if err := json.NewDecoder(resp.Body).Decode(&ret); err != nil {
slog.Error("Couldn't decode token: %v", "err", err)
return Token{}, echo.NewHTTPError(http.StatusBadGateway, "Invalid OIDC token response")
}
return ret, nil
@@ -286,15 +290,18 @@ func (h *Handler) fetchOidcProfile(c *echo.Context, provider OidcProviderConfig,
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Error("Error calling oidc profile endpoint: %v", "err", err)
return Profile{}, echo.NewHTTPError(http.StatusInternalServerError, "Could not reach OIDC profile endpoint")
}
defer resp.Body.Close()
var profile RawProfile
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
slog.Error("Error on oidc profile endpoint: %v", "err", err)
return Profile{}, echo.NewHTTPError(http.StatusInternalServerError, "Could not fetch OIDC profile")
}
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
slog.Error("Error parsing oidc profile: %v", "err", err)
return Profile{}, echo.NewHTTPError(http.StatusInternalServerError, "Invalid OIDC profile response")
}
sub := cmp.Or(profile.Sub, profile.Uid, profile.Id, profile.Guid)
+3
View File
@@ -4,11 +4,13 @@ import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"strings"
echootel "github.com/labstack/echo-opentelemetry"
"github.com/labstack/echo/v5"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp"
@@ -135,6 +137,7 @@ func setupOtel(ctx context.Context) (func(context.Context) error, error) {
logotelglobal.SetLoggerProvider(lp)
otel.SetMeterProvider(mp)
otel.SetTracerProvider(tp)
http.DefaultTransport = otelhttp.NewTransport(http.DefaultTransport)
// configure shutting down
// noop providers do not have a Shudown method
+10 -12
View File
@@ -2,8 +2,6 @@ import { z } from "zod/v4";
export const User = z
.object({
// // keep a default for older versions of the api
// .default({}),
id: z.string(),
username: z.string(),
email: z.string(),
@@ -33,17 +31,17 @@ export const User = z
audioLanguage: "default",
subtitleLanguage: null,
}),
// externalId: z
// .record(
// z.string(),
// z.object({
// id: z.string(),
// username: z.string().nullable().default(""),
// profileUrl: z.string().nullable(),
// }),
// )
// .default({}),
}),
oidc: z
.record(
z.string(),
z.object({
id: z.string(),
username: z.string().nullable().default(""),
profileUrl: z.string().nullable(),
}),
)
.default({}),
})
.transform((x) => ({
...x,
-3
View File
@@ -12,7 +12,6 @@ export const Chip = ({
label,
href,
replace,
target,
className,
...props
}: {
@@ -21,7 +20,6 @@ export const Chip = ({
label: string;
href: string | null;
replace?: boolean;
target?: string;
onPress?: (e: GestureResponderEvent) => void;
className?: string;
}) => {
@@ -29,7 +27,6 @@ export const Chip = ({
<Link
href={href}
replace={replace}
target={target}
className={cn(
"group justify-center overflow-hidden rounded-4xl border border-accent outline-0",
size === "small" && "px-2.5 py-1",
-2
View File
@@ -51,7 +51,6 @@ export const A = ({
...props
}: TextProps & {
href?: string | null;
target?: string;
replace?: boolean;
children: ReactNode;
}) => {
@@ -102,7 +101,6 @@ export const Link = ({
href?: string | null;
replace?: boolean;
download?: boolean;
target?: string;
} & PressableProps) => {
const linkProps = useLinkTo({ href, replace });
-2
View File
@@ -67,8 +67,6 @@ const SearchResultItem = ({
icon={OpenInNew}
as={Link}
href={externalHref}
target="_blank"
className="absolute top-1 right-1 bg-gray-800/70 hover:bg-gray-800 focus:bg-gray-800"
iconClassName="h-5 w-5 fill-slate-200 dark:fill-slate-200"
/>
)}
-3
View File
@@ -121,7 +121,6 @@ const ButtonList = ({
icon={Theaters}
as={Link}
href={trailerUrl}
target="_blank"
iconClassName={iconsClassName}
{...tooltip(t("show.trailer"))}
/>
@@ -340,7 +339,6 @@ const ExternalIdChip = ({
<Chip
label={name}
href={withLinks.length === 1 ? withLinks[0].link : null}
target="_blank"
size="small"
outline
className="m-1"
@@ -357,7 +355,6 @@ const ExternalIdChip = ({
<A
key={x.dataId}
href={x.link!}
target="_blank"
className="rounded p-4 hover:bg-popover"
>
{x.label ?? x.link}
+13 -9
View File
@@ -55,22 +55,26 @@ export const login = async (
export const oidcLogin = async (
provider: string,
code: string,
linkToToken: string | null,
apiUrl?: string,
) => {
apiUrl ??= defaultApiUrl;
try {
const { token } = await queryFn({
const ret = await queryFn({
method: "GET",
url: `${apiUrl}/auth/oidc/callback/${provider}?token=${code}`,
authToken: null,
parser: z.object({ token: z.string() }),
});
const user = await queryFn({
method: "GET",
url: `${apiUrl}/auth/users/me`,
authToken: token,
parser: User,
authToken: linkToToken,
parser: linkToToken ? z.object({ token: z.string() }) : User,
});
const token = linkToToken ?? (ret as { token: string }).token;
const user = linkToToken
? (ret as User)
: await queryFn({
method: "GET",
url: `${apiUrl}/auth/users/me`,
authToken: token,
parser: User,
});
const account: Account = { ...user, apiUrl, token, selected: true };
addAccount(account);
return { ok: true, value: account };
+13 -8
View File
@@ -1,33 +1,38 @@
import { useRouter } from "expo-router";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { P } from "~/primitives";
import { useToken } from "~/providers/account-context";
import { useQueryState } from "~/utils";
import { oidcLogin } from "./logic";
export const OidcCallbackPage = () => {
const { authToken } = useToken();
const [apiUrl] = useQueryState("apiUrl", undefined!);
const [provider] = useQueryState("provider", undefined!);
const [code] = useQueryState("token", undefined!);
const [error] = useQueryState("error", undefined!);
const [link] = useQueryState("link", undefined!);
const hasRun = useRef(false);
const router = useRouter();
// biome-ignore lint/correctness/useExhaustiveDependencies: useMountEffect
useEffect(() => {
if (hasRun.current) return;
hasRun.current = true;
function onError(error: string) {
router.replace({ pathname: "/login", params: { error, apiUrl } });
}
async function run() {
const { error: loginError } = await oidcLogin(provider, code, apiUrl);
const { error: loginError } = await oidcLogin(
provider,
code,
link ? authToken : null,
apiUrl,
);
if (loginError) onError(loginError);
else router.replace("/");
else router.replace(link ? "/settings" : "/");
}
if (error) onError(error);
else run();
}, [provider, code, apiUrl, router, error]);
}, []);
return <P>{"Loading"}</P>;
};
+4 -3
View File
@@ -37,7 +37,7 @@ export const OidcLogin = ({
<Button
as={Link}
key={id}
href={provider.link}
href={provider.connect}
replace
className="w-full sm:w-3/4"
left={
@@ -95,7 +95,7 @@ const AuthInfo = z
),
})
.transform((x) => {
const baseUrl = Platform.OS === "web" ? x.publicUrl : "kyoo://";
const redirect = `${Platform.OS === "web" ? x.publicUrl : "kyoo://"}/oidc-callback?apiUrl=${x.publicUrl}`;
return {
...x,
oidc: Object.fromEntries(
@@ -103,7 +103,8 @@ const AuthInfo = z
provider,
{
...info,
link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${baseUrl}/oidc-callback?apiUrl=${x.publicUrl}`,
connect: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(redirect)}`,
link: `${x.publicUrl}/auth/oidc/login/${provider}?redirectUrl=${encodeURIComponent(`${redirect}&link=true`)}`,
},
]),
),
+2 -5
View File
@@ -55,17 +55,14 @@ export const About = () => {
return (
<SettingsContainer title={t("settings.about.label")}>
<Link
href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk"
target="_blank"
>
<Link href="https://github.com/zoriya/kyoo/releases/latest/download/kyoo.apk">
<Preference
icon={Android}
label={t("settings.about.android-app.label")}
description={t("settings.about.android-app.description")}
/>
</Link>
<Link href="https://github.com/zoriya/kyoo" target="_blank">
<Link href="https://github.com/zoriya/kyoo">
<Preference
icon={Public}
label={t("settings.about.git.label")}
+2 -2
View File
@@ -2,7 +2,7 @@ import { ScrollView } from "react-native";
import { useAccount } from "~/providers/account-context";
import { AccountSettings } from "./account";
import { About, GeneralSettings } from "./general";
// import { OidcSettings } from "./oidc";
import { OidcSettings } from "./oidc";
import { PlaybackSettings } from "./playback";
export const SettingsPage = () => {
@@ -12,7 +12,7 @@ export const SettingsPage = () => {
<GeneralSettings />
{account && <PlaybackSettings />}
{account && <AccountSettings />}
{/* {account && <OidcSettings />} */}
{account && <OidcSettings />}
<About />
</ScrollView>
);
+96 -105
View File
@@ -1,105 +1,96 @@
// import {
// type QueryIdentifier,
// type ServerInfo,
// ServerInfoP,
// queryFn,
// useAccount,
// useFetch,
// } from "@kyoo/models";
// import { Button, IconButton, Link, Skeleton, tooltip, ts } from "@kyoo/primitives";
// import { useTranslation } from "react-i18next";
// import { ImageBackground } from "react-native";
// import { Preference, SettingsContainer } from "./base";
//
// import Badge from "@material-symbols/svg-400/outlined/badge.svg";
// import Remove from "@material-symbols/svg-400/outlined/close.svg";
// import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
// import { useMutation, useQueryClient } from "@tanstack/react-query";
//
// export const OidcSettings = () => {
// const account = useAccount()!;
// const { t } = useTranslation();
// const { data, error } = useFetch(OidcSettings.query());
// const queryClient = useQueryClient();
// const { mutateAsync: unlinkAccount } = useMutation({
// mutationFn: async (provider: string) =>
// await queryFn({
// path: ["auth", "login", provider],
// method: "DELETE",
// }),
// onSettled: async () => await queryClient.invalidateQueries({ queryKey: ["auth", "me"] }),
// });
//
// return (
// <SettingsContainer title={t("settings.oidc.label")}>
// {error ? (
// <ErrorView error={error} />
// ) : data ? (
// Object.entries(data.oidc).map(([id, x]) => {
// const acc = account.externalId[id];
// return (
// <Preference
// key={x.displayName}
// icon={Badge}
// label={x.displayName}
// description={
// acc
// ? t("settings.oidc.connected", { username: acc.username })
// : t("settings.oidc.not-connected")
// }
// customIcon={
// x.logoUrl != null && (
// <ImageBackground
// source={{ uri: x.logoUrl }}
// {...css({ width: ts(3), height: ts(3), marginRight: ts(2) })}
// />
// )
// }
// >
// {acc ? (
// <>
// {acc.profileUrl && (
// <IconButton
// icon={OpenProfile}
// as={Link}
// href={acc.profileUrl}
// target="_blank"
// {...tooltip(t("settings.oidc.open-profile", { provider: x.displayName }))}
// />
// )}
// <IconButton
// icon={Remove}
// onPress={() => unlinkAccount(id)}
// {...tooltip(t("settings.oidc.delete", { provider: x.displayName }))}
// />
// </>
// ) : (
// <Button
// text={t("settings.oidc.link")}
// as={Link}
// href={x.link}
// {...css({ minWidth: rem(6) })}
// />
// )}
// </Preference>
// );
// })
// ) : (
// [...Array(3)].map((_, i) => (
// <Preference
// key={i}
// customIcon={<Skeleton {...css({ width: ts(3), height: ts(3) })} />}
// icon={null!}
// label={<Skeleton {...css({ width: rem(6) })} />}
// description={<Skeleton {...css({ width: rem(7), height: rem(0.8) })} />}
// />
// ))
// )}
// </SettingsContainer>
// );
// };
//
// OidcSettings.query = (): QueryIdentifier<ServerInfo> => ({
// path: ["info"],
// parser: ServerInfoP,
// });
import Badge from "@material-symbols/svg-400/outlined/badge.svg";
import Remove from "@material-symbols/svg-400/outlined/close.svg";
import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
import { useTranslation } from "react-i18next";
import { Image } from "react-native";
import { Button, IconButton, Link, P, Skeleton, tooltip } from "~/primitives";
import { type QueryIdentifier, useFetch, useMutation } from "~/query";
import { Preference, SettingsContainer } from "./base";
import { OidcLogin } from "../login/oidc";
import { User } from "~/models";
export const OidcSettings = () => {
const { t } = useTranslation();
const { data } = useFetch(OidcLogin.query());
const { data: user } = useFetch(OidcSettings.query());
const { mutateAsync: unlinkAccount } = useMutation({
method: "DELETE",
compute: (provider: string) => ({
path: ["auth", "oidc", "login", provider],
}),
invalidate: ["auth", "users", "me"],
});
return (
<SettingsContainer title={t("settings.oidc.label")}>
{data && user
? Object.entries(data.oidc).map(([id, x]) => {
const acc = user.oidc[id];
return (
<Preference
key={id}
icon={Badge}
label={x.name}
description={
acc
? t("settings.oidc.connected", { username: acc.username })
: t("settings.oidc.not-connected")
}
customIcon={
x.logo != null && (
<Image
source={{ uri: x.logo }}
className="mr-4 h-6 w-6"
resizeMode="contain"
/>
)
}
>
{acc ? (
<>
{acc.profileUrl && (
<IconButton
icon={OpenProfile}
as={Link}
href={acc.profileUrl}
{...tooltip(
t("settings.oidc.open-profile", { provider: x.name }),
)}
/>
)}
<IconButton
icon={Remove}
onPress={() => unlinkAccount(id)}
{...tooltip(
t("settings.oidc.delete", { provider: x.name }),
)}
/>
</>
) : (
<Button
text={t("settings.oidc.link")}
as={Link}
href={x.link}
replace
/>
)}
</Preference>
);
})
: [...Array(3)].map((_, i) => (
<Preference
key={i}
customIcon={<Skeleton className="h-6 w-6" />}
icon={null!}
label={<Skeleton className="w-24" />}
description={<Skeleton className="h-4 w-28" />}
/>
))}
</SettingsContainer>
);
};
OidcSettings.query = (): QueryIdentifier<User> => ({
path: ["auth", "users", "me"],
parser: User,
});