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 + ); } ///