Add simkl oidc

This commit is contained in:
Zoe Roux 2024-03-18 21:55:08 +01:00
parent 115b9fa4b3
commit 44bb88910f
No known key found for this signature in database
5 changed files with 97 additions and 32 deletions

View File

@ -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. # 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 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 # 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_CLIENTID=
OIDC_DISCORD_SECRET= OIDC_DISCORD_SECRET=

View File

@ -22,6 +22,7 @@ using System.ComponentModel.DataAnnotations;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text; using System.Text;
using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers; using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models; using Kyoo.Abstractions.Models;
@ -46,21 +47,18 @@ public class OidcController(
Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}") Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}")
); );
client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}"); client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}");
Dictionary<string, string> data =
HttpResponseMessage resp = await client.PostAsync( new()
prov.TokenUrl, {
new FormUrlEncodedContent( ["code"] = code,
new Dictionary<string, string>() ["client_id"] = prov.ClientId,
{ ["client_secret"] = prov.Secret,
["code"] = code, ["redirect_uri"] = $"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}",
["client_id"] = prov.ClientId, ["grant_type"] = "authorization_code",
["client_secret"] = prov.Secret, };
["redirect_uri"] = HttpResponseMessage resp = prov.TokenUseJsonBody
$"{options.PublicUrl.TrimEnd('/')}/api/auth/logged/{provider}", ? await client.PostAsJsonAsync(prov.TokenUrl, data)
["grant_type"] = "authorization_code", : await client.PostAsync(prov.TokenUrl, new FormUrlEncodedContent(data));
}
)
);
if (!resp.IsSuccessStatusCode) if (!resp.IsSuccessStatusCode)
throw new ValidationException( throw new ValidationException(
$"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}" $"Invalid code or configuration. {resp.StatusCode}: {await resp.Content.ReadAsStringAsync()}"
@ -71,22 +69,36 @@ public class OidcController(
client.DefaultRequestHeaders.Remove("Authorization"); client.DefaultRequestHeaders.Remove("Authorization");
client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); client.DefaultRequestHeaders.Add("Authorization", $"{token.TokenType} {token.AccessToken}");
Dictionary<string, string>? 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<JwtProfile>(prov.ProfileUrl); JwtProfile? profile = await client.GetFromJsonAsync<JwtProfile>(prov.ProfileUrl);
if (profile is null || profile.Sub is null) if (profile is null || profile.Sub is null)
throw new ValidationException("Missing sub on user object"); throw new ValidationException(
ExternalToken extToken = new() { Id = profile.Sub, Token = token, }; $"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(); User newUser = new();
if (profile.Email is not null) if (profile.Email is not null)
newUser.Email = profile.Email; newUser.Email = profile.Email;
string? username = profile.Username ?? profile.Name; if (profile.Username is null)
if (username is null)
{ {
throw new ValidationException( throw new ValidationException(
$"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}" $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}"
); );
} }
extToken.Username = username; extToken.Username = profile.Username;
newUser.Username = username; newUser.Username = profile.Username;
newUser.Slug = Utils.Utility.ToSlug(newUser.Username); newUser.Slug = Utils.Utility.ToSlug(newUser.Username);
newUser.ExternalId.Add(provider, extToken); newUser.ExternalId.Add(provider, extToken);
return (newUser, extToken); return (newUser, extToken);

View File

@ -17,30 +17,57 @@
// along with Kyoo. If not, see <https://www.gnu.org/licenses/>. // along with Kyoo. If not, see <https://www.gnu.org/licenses/>.
using System.Collections.Generic; using System.Collections.Generic;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace Kyoo.Authentication.Models.DTO; namespace Kyoo.Authentication.Models.DTO;
public class JwtProfile public class JwtProfile
{ {
public string Sub { get; set; } public string? Sub { get; set; }
public string Uid 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? Username { get; set; }
public string? Name
{
set => Username ??= value;
}
public string? Email { get; set; } 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] [JsonExtensionData]
public Dictionary<string, object> Extra { get; set; } public Dictionary<string, object> Extra { get; set; }
} }

View File

@ -20,6 +20,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using Kyoo.Abstractions.Models.Permissions; using Kyoo.Abstractions.Models.Permissions;
using Kyoo.Authentication.Models.DTO;
namespace Kyoo.Authentication.Models; namespace Kyoo.Authentication.Models;
@ -72,11 +73,20 @@ public class OidcProvider
public string? LogoUrl { get; set; } public string? LogoUrl { get; set; }
public string AuthorizationUrl { get; set; } public string AuthorizationUrl { get; set; }
public string TokenUrl { get; set; } public string TokenUrl { get; set; }
/// <summary>
/// Some token endpoints do net respect the spec and require a json body instead of a form url encoded.
/// </summary>
public bool TokenUseJsonBody { get; set; }
public string ProfileUrl { get; set; } public string ProfileUrl { get; set; }
public string? Scope { get; set; } public string? Scope { get; set; }
public string ClientId { get; set; } public string ClientId { get; set; }
public string Secret { get; set; } public string Secret { get; set; }
public Func<JwtProfile, string?>? GetProfileUrl { get; init; }
public Func<OidcProvider, Dictionary<string, string>>? GetExtraHeaders { get; init; }
public bool Enabled => public bool Enabled =>
AuthorizationUrl != null AuthorizationUrl != null
&& TokenUrl != null && TokenUrl != null
@ -97,6 +107,9 @@ public class OidcProvider
Scope = KnownProviders[provider].Scope; Scope = KnownProviders[provider].Scope;
ClientId = KnownProviders[provider].ClientId; ClientId = KnownProviders[provider].ClientId;
Secret = KnownProviders[provider].Secret; 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", TokenUrl = "https://discord.com/api/oauth2/token",
ProfileUrl = "https://discord.com/api/users/@me", ProfileUrl = "https://discord.com/api/users/@me",
Scope = "email+identify", 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 },
},
}; };
} }

View File

@ -101,7 +101,6 @@ export const OidcSettings = () => {
text={t("settings.oidc.link")} text={t("settings.oidc.link")}
as={Link} as={Link}
href={x.link} href={x.link}
target="_blank"
{...css({ minWidth: rem(6) })} {...css({ minWidth: rem(6) })}
/> />
)} )}