diff --git a/back/src/Kyoo.Authentication/AuthenticationModule.cs b/back/src/Kyoo.Authentication/AuthenticationModule.cs
index 6750b1d9..72bbfff5 100644
--- a/back/src/Kyoo.Authentication/AuthenticationModule.cs
+++ b/back/src/Kyoo.Authentication/AuthenticationModule.cs
@@ -89,14 +89,14 @@ namespace Kyoo.Authentication
return acc;
if (val.Key.Split("_") is not ["OIDC", string provider, string key])
{
- logger.LogError("Invalid oidc config value: {}", val.Key);
+ logger.LogError("Invalid oidc config value: {Key}", val.Key);
return acc;
}
provider = provider.ToLowerInvariant();
key = key.ToLowerInvariant();
if (!acc.ContainsKey(provider))
- acc.Add(provider, new());
+ acc.Add(provider, new(provider));
switch (key)
{
case "clientid":
@@ -111,11 +111,15 @@ namespace Kyoo.Authentication
case "authorization":
acc[provider].AuthorizationUrl = val.Value;
break;
+ case "token":
+ acc[provider].TokenUrl = val.Value;
+ break;
case "userinfo":
- acc[provider].UserinfoUrl = val.Value;
+ case "profile":
+ acc[provider].ProfileUrl = val.Value;
break;
default:
- logger.LogError("Invalid oidc config value: {}", key);
+ logger.LogError("Invalid oidc config value: {Key}", key);
return acc;
}
return acc;
diff --git a/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs
new file mode 100644
index 00000000..22bd442d
--- /dev/null
+++ b/back/src/Kyoo.Authentication/Models/DTO/JwtProfile.cs
@@ -0,0 +1,33 @@
+// 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 .
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Kyoo.Authentication.Models.DTO;
+
+public class JwtProfile
+{
+ public string Sub { get; set; }
+ public string? Name { get; set; }
+ public string? Username { get; set; }
+ public string? Email { get; set; }
+
+ [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 8d314057..eb7205f2 100644
--- a/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs
+++ b/back/src/Kyoo.Authentication/Models/Options/PermissionOption.cs
@@ -64,8 +64,31 @@ public class PermissionOption
public class OidcProvider
{
public string AuthorizationUrl { get; set; }
- public string UserinfoUrl { get; set; }
+ public string TokenUrl { get; set; }
+ public string ProfileUrl { get; set; }
public string ClientId { get; set; }
public string Secret { get; set; }
public string? Scope { get; set; }
+
+ public bool Enabled =>
+ AuthorizationUrl != null
+ && TokenUrl != null
+ && ProfileUrl != null
+ && ClientId != null
+ && Secret != null;
+
+ public OidcProvider(string provider)
+ {
+ switch (provider)
+ {
+ case "google":
+ AuthorizationUrl = "https://accounts.google.com/o/oauth2/v2/auth";
+ TokenUrl = "https://oauth2.googleapis.com/token";
+ ProfileUrl = "https://openidconnect.googleapis.com/v1/userinfo";
+ Scope = "email profile";
+ break;
+ default:
+ break;
+ }
+ }
}
diff --git a/back/src/Kyoo.Authentication/Views/AuthApi.cs b/back/src/Kyoo.Authentication/Views/AuthApi.cs
index dfca90e4..caf7a5f8 100644
--- a/back/src/Kyoo.Authentication/Views/AuthApi.cs
+++ b/back/src/Kyoo.Authentication/Views/AuthApi.cs
@@ -17,7 +17,12 @@
// along with Kyoo. If not, see .
using System;
+using System.Collections.Generic;
using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text;
using System.Threading.Tasks;
using Kyoo.Abstractions.Controllers;
using Kyoo.Abstractions.Models;
@@ -31,6 +36,9 @@ using Kyoo.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
using static Kyoo.Abstractions.Models.Utils.Constants;
using BCryptNet = BCrypt.Net.BCrypt;
@@ -46,6 +54,7 @@ namespace Kyoo.Authentication.Views
IRepository users,
ITokenController tokenController,
IThumbnailsManager thumbs,
+ IHttpClientFactory clientFactory,
PermissionOption options
) : ControllerBase
{
@@ -59,6 +68,19 @@ namespace Kyoo.Authentication.Views
return new ObjectResult(value) { StatusCode = StatusCodes.Status403Forbidden };
}
+ private static string _BuildUrl(string baseUrl, Dictionary queryParams)
+ {
+ char querySep = baseUrl.Contains('?') ? '&' : '?';
+ foreach ((string key, string? val) in queryParams)
+ {
+ if (val is null)
+ continue;
+ baseUrl += $"{querySep}{key}={val}";
+ querySep = '&';
+ }
+ return baseUrl;
+ }
+
///
/// Oauth Login.
///
@@ -67,12 +89,12 @@ namespace Kyoo.Authentication.Views
///
/// A redirect to the provider's login page.
/// The provider is not register with this instance of kyoo.
- [HttpPost("login/{provider}")]
+ [HttpGet("login/{provider}")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status404NotFound, Type = typeof(RequestError))]
public ActionResult LoginVia(string provider)
{
- if (!options.OIDC.ContainsKey(provider))
+ if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
{
return NotFound(
new RequestError(
@@ -81,13 +103,19 @@ namespace Kyoo.Authentication.Views
);
}
OidcProvider prov = options.OIDC[provider];
- char querySep = prov.AuthorizationUrl.Contains('?') ? '&' : '?';
- string url = $"{prov.AuthorizationUrl}{querySep}response_type=code";
- url += $"&client_id={prov.ClientId}";
- url += $"&redirect_uri={options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}";
- if (prov.Scope is not null)
- url += $"&scope={prov.Scope}";
- return Redirect(url);
+ return Redirect(
+ _BuildUrl(
+ prov.AuthorizationUrl,
+ new()
+ {
+ ["response_type"] = "code",
+ ["client_id"] = prov.ClientId,
+ ["redirect_uri"] =
+ $"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}",
+ ["scope"] = prov.Scope,
+ }
+ )
+ );
}
///
@@ -99,22 +127,88 @@ namespace Kyoo.Authentication.Views
///
/// A redirect to the provider's login page.
/// The provider gave an error.
- [HttpPost("callback/{provider}")]
+ [HttpGet("callback/{provider}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status403Forbidden, Type = typeof(RequestError))]
- public async Task> OauthCallback(string provider, dynamic val)
+ public async Task> OauthCallback(string provider, string code)
{
- throw new NotImplementedException();
- // User? user = await users.GetOrDefault(
- // new Filter.Lambda(x => x.ExternalId[provider].Id == val.Id)
- // );
- // if (user == null)
- // user = await users.Create(val);
- // return new JwtToken(
- // tokenController.CreateAccessToken(user, out TimeSpan expireIn),
- // await tokenController.CreateRefreshToken(user),
- // expireIn
- // );
+ if (!options.OIDC.ContainsKey(provider) || !options.OIDC[provider].Enabled)
+ {
+ return NotFound(
+ new RequestError(
+ $"Invalid provider. {provider} is not registered no this instance of kyoo."
+ )
+ );
+ }
+ if (code == null)
+ return BadRequest(new RequestError("Invalid code."));
+ OidcProvider prov = options.OIDC[provider];
+
+ HttpClient client = clientFactory.CreateClient();
+
+ string auth = Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{prov.ClientId}:{prov.Secret}")
+ );
+ client.DefaultRequestHeaders.Add("Authorization", $"Basic {auth}");
+
+ HttpResponseMessage resp = await client.PostAsync(
+ _BuildUrl(
+ prov.TokenUrl,
+ new()
+ {
+ ["code"] = code,
+ ["client_id"] = prov.ClientId,
+ ["client_secret"] = prov.Secret,
+ ["redirect_uri"] =
+ $"{options.PublicUrl.TrimEnd('/')}/api/auth/callback/{provider}",
+ ["grant_type"] = "authorization_code",
+ }
+ ),
+ null
+ );
+ if (!resp.IsSuccessStatusCode)
+ return BadRequest("Invalid code or configuration.");
+ JwtToken? token = await resp.Content.ReadFromJsonAsync();
+ if (token is null)
+ return BadRequest("Could not retrive token.");
+
+ client.DefaultRequestHeaders.Remove("Authorization");
+ client.DefaultRequestHeaders.Add(
+ "Authorization",
+ $"{token.TokenType} {token.AccessToken}"
+ );
+ JwtProfile? profile = await client.GetFromJsonAsync(prov.ProfileUrl);
+ if (profile is null || profile.Sub is null)
+ return BadRequest("Missing sub on user object");
+ ExternalToken extToken = new() { Id = profile.Sub, Token = token, };
+ User newUser = new();
+ if (profile.Email is not null)
+ newUser.Email = profile.Email;
+ if (profile.Username is not null)
+ newUser.Username = profile.Username;
+ else if (profile.Name is not null)
+ newUser.Username = profile.Name;
+ else
+ {
+ return BadRequest(
+ new RequestError(
+ $"Could not find a username for the user. You may need to add more scopes. Fields: {string.Join(',', profile.Extra)}"
+ )
+ );
+ }
+ newUser.Slug = Utils.Utility.ToSlug(newUser.Username);
+ newUser.ExternalId.Add(provider, extToken);
+
+ User? user = await users.GetOrDefault(
+ new Filter.Lambda(x => x.ExternalId[provider].Id == extToken.Id)
+ );
+ if (user == null)
+ user = await users.Create(newUser);
+ return new JwtToken(
+ tokenController.CreateAccessToken(user, out TimeSpan expireIn),
+ await tokenController.CreateRefreshToken(user),
+ expireIn
+ );
}
///