diff --git a/Net.Vatprc.Uniapi/Controllers/AuthController.cs b/Net.Vatprc.Uniapi/Controllers/AuthController.cs index bde5e60..0c59299 100644 --- a/Net.Vatprc.Uniapi/Controllers/AuthController.cs +++ b/Net.Vatprc.Uniapi/Controllers/AuthController.cs @@ -362,8 +362,9 @@ public async Task VatsimCallback(string? code, string? state, str } catch (FlurlHttpException e) { - Logger.LogError(e, "Failed to get token or user info since {Response}", await e.GetResponseStringAsync()); - SentrySdk.CaptureException(e); + var response = await e.GetResponseStringAsync(); + Logger.LogError(e, "Failed to get token or user info since {Response}", response); + SentrySdk.CaptureException(new Exception($"Failed to get token or user info: {response}", e)); return RenderCallbackUI("Error", "Internal error", "Please try again later.", Url.Action(nameof(Login))); } @@ -429,10 +430,9 @@ public record AccessTokenRequest { /// /// REQUIRED. Identifier of the grant type the client uses - /// with the particular token request. This specification defines the - /// values authorization_code, refresh_token, and client_credentials. - /// The grant type determines the further parameters required or - /// supported by the token request. + /// with the particular token request. This endpoint supports + /// `authorization_code` (authz code), `refresh_token` (refresh token) + /// and `urn:ietf:params:oauth:grant-type:device_code` (device code). /// /// [ModelBinder(Name = "grant_type")] @@ -447,31 +447,33 @@ public record AccessTokenRequest public string ClientId { get; set; } = string.Empty; /// - /// REQUIRED. The device verification code, "device_code" from the - /// device authorization response, defined in Section 3.2. + /// REQUIRED for device code grant. The device verification code, + /// "device_code" from the device authorization response, defined in + /// Section 3.2. /// /// [ModelBinder(Name = "device_code")] public string DeviceCode { get; set; } = string.Empty; /// - /// REQUIRED. The refresh token issued to the client. + /// REQUIRED for refresh token grant. The refresh token issued to the + /// client. /// /// [ModelBinder(Name = "refresh_token")] public string RefreshToken { get; set; } = string.Empty; /// - /// REQUIRED. The authorization code received from the authorization - /// server. + /// REQUIRED for authz code grant. The authorization code received from + /// the authorization server. /// [ModelBinder(Name = "code")] public string Code { get; set; } = string.Empty; /// - /// REQUIRED, if the code_challenge parameter was included in the - /// authorization request. MUST NOT be used otherwise. The original - /// code verifier string. + /// REQUIRED for authz code grant, if the code_challenge parameter was + /// included in the authorization request. MUST NOT be used otherwise. + /// The original code verifier string. /// [ModelBinder(Name = "code_verifier")] public string CodeVerifier { get; set; } = string.Empty; @@ -549,6 +551,11 @@ public record TokenErrorDto /// /// Get token /// + /// /// /// [HttpPost("token")] @@ -562,10 +569,14 @@ public async Task Token( { return await DeviceCodeGrant(req); } - if (req.GrantType == "refresh_token") + else if (req.GrantType == "refresh_token") { return await RefreshTokenGrant(req); } + else if (req.GrantType == "authorization_code") + { + return await AuthzCodeGrant(req); + } else { return BadRequest(new TokenErrorDto @@ -722,4 +733,37 @@ protected async Task RefreshTokenGrant(AccessTokenRequest req) Scope = scopes }); } + + protected async Task AuthzCodeGrant(AccessTokenRequest req) + { + if (string.IsNullOrEmpty(req.ClientId) || string.IsNullOrEmpty(req.Code)) + { + return BadRequest(new TokenErrorDto + { + Error = "invalid_grant", + ErrorDescription = "Missing client_id or code", + }); + } + // TODO: validate code_verifier + try + { + var session = await TokenService.GetRefreshTokenByCode(req.Code, req.ClientId) ?? + throw new ApiError.InvalidAuthorizationCode(); + var (token, jwt) = TokenService.IssueFirstParty(session.User, session); + var expires = jwt.Payload.Expiration ?? 0; + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var scopes = jwt.Payload.Claims.FirstOrDefault(x => x.Type == TokenService.JwtClaimNames.Scope)?.Value ?? ""; + return Ok(new TokenResponse + { + AccessToken = token, + ExpiresIn = (uint)(expires - now), + RefreshToken = session.Token.ToString(), + Scope = scopes + }); + } + catch (TokenService.InvalidClientIdOrRedirectUriException) + { + throw new ApiError.InvalidAuthorizationCode(); + } + } } diff --git a/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.Designer.cs b/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.Designer.cs new file mode 100644 index 0000000..8ecacbc --- /dev/null +++ b/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.Designer.cs @@ -0,0 +1,612 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Net.Vatprc.Uniapi; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Net.Vatprc.Uniapi.Migrations +{ + [DbContext(typeof(VATPRCContext))] + [Migration("20241031164159_RefreshTokenAddClientId")] + partial class RefreshTokenAddClientId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.DeviceAuthorization", b => + { + b.Property("DeviceCode") + .HasColumnType("uuid") + .HasColumnName("device_code"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("UserCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("user_code"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("DeviceCode") + .HasName("pk_device_authorization"); + + b.HasIndex("UserCode") + .IsUnique() + .HasDatabaseName("ix_device_authorization_user_code"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_device_authorization_user_id"); + + b.ToTable("device_authorization", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Event", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EndAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_at"); + + b.Property("EndBookingAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("end_booking_at"); + + b.Property("ImageUrl") + .HasColumnType("text") + .HasColumnName("image_url"); + + b.Property("StartAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_at"); + + b.Property("StartBookingAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("start_booking_at"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_event"); + + b.ToTable("event", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventAirspace", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("IcaoCodes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("icao_codes"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_event_airspace"); + + b.HasIndex("EventId") + .HasDatabaseName("ix_event_airspace_event_id"); + + b.ToTable("event_airspace", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventBooking", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EventSlotId") + .HasColumnType("uuid") + .HasColumnName("event_slot_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_event_booking"); + + b.HasIndex("EventSlotId") + .IsUnique() + .HasDatabaseName("ix_event_booking_event_slot_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_event_booking_user_id"); + + b.ToTable("event_booking", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventSlot", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AircraftTypeIcao") + .HasColumnType("text") + .HasColumnName("aircraft_type_icao"); + + b.Property("Callsign") + .HasColumnType("text") + .HasColumnName("callsign"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EnterAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("enter_at"); + + b.Property("EventAirspaceId") + .HasColumnType("uuid") + .HasColumnName("event_airspace_id"); + + b.Property("LeaveAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("leave_at"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_event_slot"); + + b.HasIndex("EventAirspaceId") + .HasDatabaseName("ix_event_slot_event_airspace_id"); + + b.ToTable("event_slot", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Notam", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("EffectiveFrom") + .HasColumnType("timestamp with time zone") + .HasColumnName("effective_from"); + + b.Property("ExpireAfter") + .HasColumnType("timestamp with time zone") + .HasColumnName("expire_after"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text") + .HasColumnName("title"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_notam"); + + b.ToTable("notam", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBinding", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Discriminator") + .IsRequired() + .HasMaxLength(34) + .HasColumnType("character varying(34)") + .HasColumnName("discriminator"); + + b.Property("NotamId") + .HasColumnType("uuid") + .HasColumnName("notam_id"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_notam_binding"); + + b.HasIndex("NotamId") + .HasDatabaseName("ix_notam_binding_notam_id"); + + b.ToTable("notam_binding", (string)null); + + b.HasDiscriminator("Discriminator").HasValue("NotamBinding"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Session", b => + { + b.Property("Token") + .HasColumnType("uuid") + .HasColumnName("token"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("Code") + .HasColumnType("uuid") + .HasColumnName("code"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_in"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("UserUpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("user_updated_at"); + + b.HasKey("Token") + .HasName("pk_session"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_session_user_id"); + + b.ToTable("session", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Cid") + .IsRequired() + .HasColumnType("text") + .HasColumnName("cid"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("FullName") + .IsRequired() + .HasColumnType("text") + .HasColumnName("full_name"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("roles"); + + b.Property("UpdatedAt") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.HasKey("Id") + .HasName("pk_user"); + + b.HasIndex("Cid") + .IsUnique() + .HasDatabaseName("ix_user_cid"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_user_email"); + + b.ToTable("user", (string)null); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBindingEvent", b => + { + b.HasBaseType("Net.Vatprc.Uniapi.Models.NotamBinding"); + + b.Property("EventId") + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.HasIndex("EventId") + .HasDatabaseName("ix_notam_binding_event_id"); + + b.ToTable("notam_binding", (string)null); + + b.HasDiscriminator().HasValue("NotamBindingEvent"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBindingEventAirspace", b => + { + b.HasBaseType("Net.Vatprc.Uniapi.Models.NotamBinding"); + + b.Property("EventAirspaceId") + .HasColumnType("uuid") + .HasColumnName("event_airspace_id"); + + b.HasIndex("EventAirspaceId") + .HasDatabaseName("ix_notam_binding_event_airspace_id"); + + b.ToTable("notam_binding", (string)null); + + b.HasDiscriminator().HasValue("NotamBindingEventAirspace"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBindingIcaoCode", b => + { + b.HasBaseType("Net.Vatprc.Uniapi.Models.NotamBinding"); + + b.Property("IcaoCode") + .IsRequired() + .HasColumnType("text") + .HasColumnName("icao_code"); + + b.HasIndex("IcaoCode") + .HasDatabaseName("ix_notam_binding_icao_code"); + + b.ToTable("notam_binding", (string)null); + + b.HasDiscriminator().HasValue("NotamBindingIcaoCode"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.DeviceAuthorization", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .HasConstraintName("fk_device_authorization_user_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventAirspace", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.Event", "Event") + .WithMany("Airspaces") + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_airspace_event_event_id"); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventBooking", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.EventSlot", "EventSlot") + .WithOne("Booking") + .HasForeignKey("Net.Vatprc.Uniapi.Models.EventBooking", "EventSlotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_booking_event_slot_event_slot_id"); + + b.HasOne("Net.Vatprc.Uniapi.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_booking_user_user_id"); + + b.Navigation("EventSlot"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventSlot", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.EventAirspace", "EventAirspace") + .WithMany("Slots") + .HasForeignKey("EventAirspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_event_slot_event_airspace_event_airspace_id"); + + b.Navigation("EventAirspace"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBinding", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.Notam", "Notam") + .WithMany("Bindings") + .HasForeignKey("NotamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notam_binding_notam_notam_id"); + + b.Navigation("Notam"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Session", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.User", "User") + .WithMany("Sessions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_session_user_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBindingEvent", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.Event", "Event") + .WithMany() + .HasForeignKey("EventId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notam_binding_event_event_id"); + + b.Navigation("Event"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.NotamBindingEventAirspace", b => + { + b.HasOne("Net.Vatprc.Uniapi.Models.EventAirspace", "EventAirspace") + .WithMany() + .HasForeignKey("EventAirspaceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_notam_binding_event_airspace_event_airspace_id"); + + b.Navigation("EventAirspace"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Event", b => + { + b.Navigation("Airspaces"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventAirspace", b => + { + b.Navigation("Slots"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.EventSlot", b => + { + b.Navigation("Booking"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.Notam", b => + { + b.Navigation("Bindings"); + }); + + modelBuilder.Entity("Net.Vatprc.Uniapi.Models.User", b => + { + b.Navigation("Sessions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.cs b/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.cs new file mode 100644 index 0000000..9b177d2 --- /dev/null +++ b/Net.Vatprc.Uniapi/Migrations/20241031164159_RefreshTokenAddClientId.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Net.Vatprc.Uniapi.Migrations +{ + /// + public partial class RefreshTokenAddClientId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "client_id", + table: "session", + type: "text", + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "client_id", + table: "session"); + } + } +} diff --git a/Net.Vatprc.Uniapi/Migrations/VATPRCContextModelSnapshot.cs b/Net.Vatprc.Uniapi/Migrations/VATPRCContextModelSnapshot.cs index b1fdcfe..44522cd 100644 --- a/Net.Vatprc.Uniapi/Migrations/VATPRCContextModelSnapshot.cs +++ b/Net.Vatprc.Uniapi/Migrations/VATPRCContextModelSnapshot.cs @@ -340,6 +340,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("uuid") .HasColumnName("token"); + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + b.Property("Code") .HasColumnType("uuid") .HasColumnName("code"); diff --git a/Net.Vatprc.Uniapi/Models/Session.cs b/Net.Vatprc.Uniapi/Models/Session.cs index 0a140ad..1ea4102 100644 --- a/Net.Vatprc.Uniapi/Models/Session.cs +++ b/Net.Vatprc.Uniapi/Models/Session.cs @@ -17,6 +17,11 @@ public class Session public Ulid? Code { get; set; } + public string ClientId { get; set; } = string.Empty; + + // TODO: public Ulid? GroupId { get; set; } + // Group id is used for detecting refresh token mis-reuse. + public class SessionConfiguration : IEntityTypeConfiguration { public void Configure(EntityTypeBuilder builder) diff --git a/Net.Vatprc.Uniapi/Services/TokenService.cs b/Net.Vatprc.Uniapi/Services/TokenService.cs index 862773a..e1c76b1 100644 --- a/Net.Vatprc.Uniapi/Services/TokenService.cs +++ b/Net.Vatprc.Uniapi/Services/TokenService.cs @@ -110,7 +110,7 @@ public class InvalidClientIdOrRedirectUriException : Exception public InvalidClientIdOrRedirectUriException() : base("invalid client_id or redirect_uri") { } } - public async Task GetRefreshTokenByCode(string code, string clientId, string redirectUri) + public async Task GetRefreshTokenByCode(string code, string clientId, string? redirectUri = null) { var claims = new JwtSecurityTokenHandler().ValidateToken(code, new TokenValidationParameters { @@ -123,7 +123,7 @@ public InvalidClientIdOrRedirectUriException() : base("invalid client_id or redi ValidateIssuerSigningKey = true, }, out var token); if (claims.FindFirstValue(JwtClaimNames.ClientId) != clientId || - claims.FindFirstValue(JwtClaimNames.RedirectUri) != redirectUri) + (!string.IsNullOrEmpty(redirectUri) && claims.FindFirstValue(JwtClaimNames.RedirectUri) != redirectUri)) { throw new InvalidClientIdOrRedirectUriException(); }