diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs index d3a264d3..1dd0e1b5 100644 --- a/back/src/Kyoo.Authentication/Views/AuthApi.cs +++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs @@ -84,12 +84,16 @@ namespace Kyoo.Authentication.Views /// /// Login via a registered oauth provider. /// + /// The provider code. + /// + /// A url where you will be redirected with the query params provider, code and error. It can be a deep link. + /// /// A redirect to the provider's login page. /// The provider is not register with this instance of kyoo. [HttpGet("login/{provider}")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))] - public ActionResult LoginVia(string provider) + public ActionResult LoginVia(string provider, [FromQuery] string redirectUrl) { if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) { @@ -108,15 +112,16 @@ namespace Kyoo.Authentication.Views ["response_type"] = "code", ["client_id"] = prov.ClientId, ["redirect_uri"] = - $"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}", + $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", ["scope"] = prov.Scope, + ["state"] = redirectUrl, } ) ); } /// - /// Oauth Login Callback. + /// Oauth Code Redirect. /// /// /// This route is not meant to be called manually, the user should be redirected automatically here @@ -124,9 +129,39 @@ namespace Kyoo.Authentication.Views /// /// A redirect to the provider's login page. /// The provider gave an error. - [HttpGet("callback/{provider}")] + [HttpGet("logged/{provider}")] + [ProducesResponseType(StatusCodes.Status302Found)] + public ActionResult OauthCodeRedirect( + string provider, + string code, + string state, + string? error + ) + { + return Redirect( + _BuildUrl( + state, + new() + { + ["provider"] = provider, + ["code"] = code, + ["error"] = error, + } + ) + ); + } + + /// + /// Oauth callback + /// + /// + /// This route should be manually called by the page that got redirected to after a call to /login/{provider}. + /// + /// A jwt token + /// Bad provider or code + [HttpPost("callback/{provider}")] [ProducesResponseType(StatusCodes.Status200OK)] - [ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))] + [ProducesResponseType(StatusCodes.Status400BadRequest, Type = typeof(RequestError))] public async Task> OauthCallback(string provider, string code) { if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled) @@ -157,7 +192,7 @@ namespace Kyoo.Authentication.Views ["client_id"] = prov.ClientId, ["client_secret"] = prov.Secret, ["redirect_uri"] = - $"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}", + $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", ["grant_type"] = "authorization_code", } ), diff --git a/front/apps/web/src/pages/login/callback.tsx b/front/apps/web/src/pages/login/callback.tsx new file mode 100644 index 00000000..bc5c8c55 --- /dev/null +++ b/front/apps/web/src/pages/login/callback.tsx @@ -0,0 +1,25 @@ +/* + * Kyoo - A portable and vast media library solution. + * Copyright (c) Kyoo. + * + * See AUTHORS.md and LICENSE file in the project root for full license information. + * + * Kyoo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * any later version. + * + * Kyoo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Kyoo. If not, see . + */ + +import { OidcCallbackPage } from "@kyoo/ui"; +import { withRoute } from "~/router"; + +export default withRoute(OidcCallbackPage); + diff --git a/front/packages/models/src/login.ts b/front/packages/models/src/login.ts index 6ae89d6e..80815b42 100644 --- a/front/packages/models/src/login.ts +++ b/front/packages/models/src/login.ts @@ -68,6 +68,31 @@ export const login = async ( } }; +export const oidcLogin = async (provider: string, code: string, apiUrl?: string) => { + try { + const token = await queryFn( + { + path: ["auth", "callback", provider, `?code=${code}`], + method: "POST", + authenticated: false, + apiUrl, + }, + TokenP, + ); + const user = await queryFn( + { path: ["auth", "me"], method: "GET", apiUrl }, + UserP, + `Bearer ${token.access_token}`, + ); + const account: Account = { ...user, apiUrl: apiUrl ?? "/api", token, selected: true }; + addAccount(account); + return { ok: true, value: account }; + } catch (e) { + console.error("oidcLogin", e); + return { ok: false, error: (e as KyooErrors).errors[0] }; + } +}; + let running: ReturnType | null = null; export const getTokenWJ = async (account?: Account | null): ReturnType => { diff --git a/front/packages/models/src/resources/server-info.ts b/front/packages/models/src/resources/server-info.ts index 598f21c0..b2749e51 100644 --- a/front/packages/models/src/resources/server-info.ts +++ b/front/packages/models/src/resources/server-info.ts @@ -19,7 +19,7 @@ */ import { z } from "zod"; -import { imageFn } from ".."; +import { baseAppUrl, imageFn } from ".."; export const OidcInfoP = z.object({ /* @@ -42,7 +42,7 @@ export const ServerInfoP = z.object({ Object.fromEntries( Object.entries(x).map(([provider, info]) => [ provider, - { ...info, link: imageFn(`/auth/login/${provider}`) }, + { ...info, link: imageFn(`/auth/login/${provider}?redirectUrl=${baseAppUrl()}/login/callback`) }, ]), ), ), diff --git a/front/packages/models/src/traits/images.ts b/front/packages/models/src/traits/images.ts index f7978d8f..47667ab6 100644 --- a/front/packages/models/src/traits/images.ts +++ b/front/packages/models/src/traits/images.ts @@ -24,6 +24,8 @@ import { kyooApiUrl } from ".."; export const imageFn = (url: string) => (Platform.OS === "web" ? `/api${url}` : kyooApiUrl + url); +export const baseAppUrl = () => Platform.OS === "web" ? window.location.origin : "kyoo://"; + export const Img = z.object({ source: z.string(), blurhash: z.string(), diff --git a/front/packages/ui/src/index.ts b/front/packages/ui/src/index.ts index 3fae9f53..e1823fca 100644 --- a/front/packages/ui/src/index.ts +++ b/front/packages/ui/src/index.ts @@ -25,7 +25,7 @@ export { MovieDetails, ShowDetails } from "./details"; export { CollectionPage } from "./collection"; export { Player } from "./player"; export { SearchPage } from "./search"; -export { LoginPage, RegisterPage } from "./login"; +export { LoginPage, RegisterPage, OidcCallbackPage } from "./login"; export { DownloadPage, DownloadProvider } from "./downloads"; export { SettingsPage } from "./settings"; export { AdminPage } from "./admin"; diff --git a/front/packages/ui/src/login/index.ts b/front/packages/ui/src/login/index.ts index e36fb536..c4cfd5ea 100644 --- a/front/packages/ui/src/login/index.ts +++ b/front/packages/ui/src/login/index.ts @@ -20,3 +20,4 @@ export { LoginPage } from "./login"; export { RegisterPage } from "./register"; +export { OidcCallbackPage } from "./oidc"; diff --git a/front/packages/ui/src/login/oidc.tsx b/front/packages/ui/src/login/oidc.tsx index 618ddfca..299bc7e8 100644 --- a/front/packages/ui/src/login/oidc.tsx +++ b/front/packages/ui/src/login/oidc.tsx @@ -18,11 +18,20 @@ * along with Kyoo. If not, see . */ -import { QueryIdentifier, ServerInfo, ServerInfoP, useFetch } from "@kyoo/models"; +import { + QueryIdentifier, + QueryPage, + ServerInfo, + ServerInfoP, + oidcLogin, + useFetch, +} from "@kyoo/models"; import { Button, HR, P, Skeleton, ts } from "@kyoo/primitives"; import { View, ImageBackground } from "react-native"; import { percent, rem, useYoshiki } from "yoshiki/native"; import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { useRouter } from "solito/router"; import { ErrorView } from "../errors"; export const OidcLogin = () => { @@ -80,3 +89,29 @@ OidcLogin.query = (): QueryIdentifier => ({ path: ["info"], parser: ServerInfoP, }); + +export const OidcCallbackPage: QueryPage<{ provider: string; code: string; error?: string }> = ({ + provider, + code, + error, +}) => { + const [err, setErr] = useState(); + const router = useRouter(); + + useEffect(() => { + async function run() { + if (error) { + setErr(error); + return; + } + const { error: loginError } = await oidcLogin(provider, code); + setErr(loginError); + if (loginError) return; + router.replace("/", undefined, { + experimental: { nativeBehavior: "stack-replace", isNestedNavigator: false }, + }); + } + run(); + }, [provider, code, router, error]); + return

{err ?? "Loading"}

; +};