diff --git a/.env.example b/.env.example index 53e60d07..a1089c44 100644 --- a/.env.example +++ b/.env.example @@ -41,7 +41,7 @@ THEMOVIEDB_APIKEY= # The url you can use to reach your kyoo instance. This is used during oidc to redirect users to your instance. PUBLIC_URL=http://localhost:5000 -# Use a builtin oidc service (google or discord): +# Use a builtin oidc service (google, discord, or simkl): # When you create a client_id, secret combo you may be asked for a redirect url. You need to specify https://YOUR-PUBLIC-URL/api/auth/logged/YOUR-SERVICE-NAME OIDC_DISCORD_CLIENTID= OIDC_DISCORD_SECRET= diff --git a/back/src/Kyoo.Authentication/Controllers/OidcController.cs b/back/src/Kyoo.Authentication/Controllers/OidcController.cs index 8e9b9eec..d5909256 100644 --- a/back/src/Kyoo.Authentication/Controllers/OidcController.cs +++ b/back/src/Kyoo.Authentication/Controllers/OidcController.cs @@ -22,6 +22,7 @@ using System.ComponentModel.DataAnnotations; using System.Net.Http; using System.Net.Http.Json; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Models; @@ -46,21 +47,18 @@ public class OidcController( Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") ); client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); - - HttpResponseMessage resp = await client.PostAsync( - prov.TokenUrl, - new FormUrlEncodedContent( - new Dictionary() - { - ["code"] = code, - ["client_id"] = prov.ClientId, - ["client_secret"] = prov.Secret, - ["redirect_uri"] = - $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", - ["grant_type"] = "authorization_code", - } - ) - ); + Dictionary data = + new() + { + ["code"] = code, + ["client_id"] = prov.ClientId, + ["client_secret"] = prov.Secret, + ["redirect_uri"] = $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", + ["grant_type"] = "authorization_code", + }; + HttpResponseMessage resp = prov.TokenUseJsonBody + ? await client.PostAsJsonAsync(prov.TokenUrl, data) + : await client.PostAsync(prov.TokenUrl, new FormUrlEncodedContent(data)); if (!resp.IsSuccessStatusCode) throw new ValidationException( $"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}" @@ -71,22 +69,36 @@ public class OidcController( client.DefaultRequestHeaders.Remove("Authorization"); client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); + Dictionary? extraHeaders = prov.GetExtraHeaders?.Invoke(prov); + if (extraHeaders is not null) + { + foreach ((string key, string value) in extraHeaders) + client.DefaultRequestHeaders.Add(key, value); + } + JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl); if (profile is null || profile.Sub is null) - throw new ValidationException("Missing sub on user object"); - ExternalToken extToken = new() { Id = profile.Sub, Token = token, }; + throw new ValidationException( + $"Missing sub on user object. Got: {JsonSerializer.Serialize(profile)}" + ); + ExternalToken extToken = + new() + { + Id = profile.Sub, + Token = token, + ProfileUrl = prov.GetProfileUrl?.Invoke(profile), + }; User newUser = new(); if (profile.Email is not null) newUser.Email = profile.Email; - string? username = profile.Username ?? profile.Name; - if (username is null) + if (profile.Username is null) { throw new ValidationException( $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}" ); } - extToken.Username = username; - newUser.Username = username; + extToken.Username = profile.Username; + newUser.Username = profile.Username; newUser.Slug = Utils.Utility.ToSlug(newUser.Username); newUser.ExternalId.Add(provider, extToken); return (newUser, extToken); diff --git a/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs index cba47a5e..85272f8b 100644 --- a/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs +++ b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs @@ -17,30 +17,57 @@ // along with Kyoo. If not, see . using System.Collections.Generic; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; namespace Kyoo.Authentication.Models.DTO; public class JwtProfile { - public string Sub { get; set; } - public string Uid + public string? Sub { get; set; } + public string? Uid { - set => Sub = value; + set => Sub ??= value; } - public string Id + public string? Id { - set => Sub = value; + set => Sub ??= value; } - public string Guid + public string? Guid { - set => Sub = value; + set => Sub ??= value; } - public string? Name { get; set; } public string? Username { get; set; } + public string? Name + { + set => Username ??= value; + } + public string? Email { get; set; } + public JsonObject? Account + { + set + { + if (value is null) + return; + // simkl store their ids there. + Sub ??= value["id"]?.ToString(); + } + } + + public JsonObject? User + { + set + { + if (value is null) + return; + // simkl store their name there. + Username ??= value["name"]?.ToString(); + } + } + [JsonExtensionData] public Dictionary Extra { get; set; } } diff --git a/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs b/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs index 5dd06ecd..cd41c0bb 100644 --- a/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs +++ b/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs @@ -20,6 +20,7 @@ using System; using System.Collections.Generic; using System.Linq; using Kyoo.Abstractions.Models.Permissions; +using Kyoo.Authentication.Models.DTO; namespace Kyoo.Authentication.Models; @@ -72,11 +73,20 @@ public class OidcProvider public string? LogoUrl { get; set; } public string AuthorizationUrl { get; set; } public string TokenUrl { get; set; } + + /// + /// Some token endpoints do net respect the spec and require a json body instead of a form url encoded. + /// + public bool TokenUseJsonBody { get; set; } + public string ProfileUrl { get; set; } public string? Scope { get; set; } public string ClientId { get; set; } public string Secret { get; set; } + public Func? GetProfileUrl { get; init; } + public Func>? GetExtraHeaders { get; init; } + public bool Enabled => AuthorizationUrl != null && TokenUrl != null @@ -97,6 +107,9 @@ public class OidcProvider Scope = KnownProviders[provider].Scope; ClientId = KnownProviders[provider].ClientId; Secret = KnownProviders[provider].Secret; + TokenUseJsonBody = KnownProviders[provider].TokenUseJsonBody; + GetProfileUrl = KnownProviders[provider].GetProfileUrl; + GetExtraHeaders = KnownProviders[provider].GetExtraHeaders; } } @@ -120,6 +133,20 @@ public class OidcProvider TokenUrl = "https://discord.com/api/oauth2/token", ProfileUrl = "https://discord.com/api/users/@me", Scope = "email+identify", - } + }, + ["simkl"] = new("simkl") + { + DisplayName = "Simkl", + LogoUrl = "https://logo.clearbit.com/simkl.com", + AuthorizationUrl = "https://simkl.com/oauth/authorize", + TokenUrl = "https://api.simkl.com/oauth/token", + ProfileUrl = "https://api.simkl.com/users/settings", + // does not seems to have scopes + Scope = null, + TokenUseJsonBody = true, + GetProfileUrl = (profile) => $"https://simkl.com/{profile.Sub}/dashboard/", + GetExtraHeaders = (OidcProvider self) => + new() { ["simkl-api-key"] = self.ClientId }, + }, }; } diff --git a/front/packages/ui/src/settings/oidc.tsx b/front/packages/ui/src/settings/oidc.tsx index 8ee5bbd0..7d75180a 100644 --- a/front/packages/ui/src/settings/oidc.tsx +++ b/front/packages/ui/src/settings/oidc.tsx @@ -101,7 +101,6 @@ export const OidcSettings = () => { text={t("settings.oidc.link")} as={Link} href={x.link} - target="_blank" {...css({ minWidth: rem(6) })} /> )}