diff --git a/Api.dockerfile b/Api.dockerfile index 426f84e3a..8e412eb07 100644 --- a/Api.dockerfile +++ b/Api.dockerfile @@ -1,6 +1,6 @@ # ** Build -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build # Expose the target architecture set by the `docker build --platform` option, so that # we can build the assembly for the correct platform. @@ -27,7 +27,7 @@ RUN dotnet publish src/Api/ \ # ** Run # Use `runtime-deps` instead of `runtime` because we have a self-contained assembly -FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:8.0 AS run +FROM --platform=$TARGETPLATFORM mcr.microsoft.com/dotnet/runtime-deps:9.0 AS run LABEL org.opencontainers.image.title="Passwordless API Test Server" LABEL org.opencontainers.image.description="Docker image of the Passwordless API, intended solely for development and integration testing purposes." diff --git a/global.json b/global.json index a24b7440e..15b954efd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "9.0.0", + "version": "9.0.101", "rollForward": "latestFeature" } } \ No newline at end of file diff --git a/self-host/Dockerfile b/self-host/Dockerfile index 109237181..45b9610df 100644 --- a/self-host/Dockerfile +++ b/self-host/Dockerfile @@ -2,7 +2,7 @@ ############################################### # Build stage # ############################################### -FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:8.0-bookworm-slim AS build +FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/sdk:9.0-bookworm-slim AS build # Docker buildx supplies the value for this arg ARG TARGETPLATFORM @@ -59,7 +59,7 @@ RUN . /tmp/rid.txt && dotnet publish -c release -o /app/Api --no-restore --no-se ############################################### # App stage # ############################################### -FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim +FROM mcr.microsoft.com/dotnet/aspnet:9.0-bookworm-slim ARG TARGETPLATFORM LABEL com.bitwarden.product="bitwarden" LABEL com.bitwarden.project="passwordless" diff --git a/src/AdminConsole/AdminConsole.csproj b/src/AdminConsole/AdminConsole.csproj index 8da188682..ff0aad437 100644 --- a/src/AdminConsole/AdminConsole.csproj +++ b/src/AdminConsole/AdminConsole.csproj @@ -5,7 +5,7 @@ - + @@ -69,6 +69,7 @@ + diff --git a/src/AdminConsole/package.json b/src/AdminConsole/package.json index 19e114594..37fdd37b2 100644 --- a/src/AdminConsole/package.json +++ b/src/AdminConsole/package.json @@ -5,7 +5,7 @@ "main": "index.js", "scripts": { "tw:watch": "tailwindcss -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css --watch", - "build": "tailwindcss -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css" + "build": "npx tailwindcss -i ./Styles/tailwind.css -o ./wwwroot/css/tailwind.css" }, "keywords": [], "author": "", diff --git a/src/Api/Program.cs b/src/Api/Program.cs index 8574db00f..7a50ed011 100644 --- a/src/Api/Program.cs +++ b/src/Api/Program.cs @@ -30,7 +30,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args); builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddOpenApi(); +builder.Services.AddPasswordlessOpenApi(); if (builder.Configuration.IsSelfHosted()) { diff --git a/src/Common/Common.csproj b/src/Common/Common.csproj index f2ca25904..9c15586d3 100644 --- a/src/Common/Common.csproj +++ b/src/Common/Common.csproj @@ -5,20 +5,20 @@ - - + + - + - + - + diff --git a/src/Service/Fido2Service.cs b/src/Service/Fido2Service.cs index 16b33ce33..b2b57841c 100644 --- a/src/Service/Fido2Service.cs +++ b/src/Service/Fido2Service.cs @@ -1,4 +1,5 @@ -using System.Collections.Immutable; +using System.Buffers.Text; +using System.Collections.Immutable; using System.Diagnostics; using System.Security.Cryptography; using System.Text; @@ -142,15 +143,19 @@ public async Task> RegisterBeginAsync(F var attestation = token.Attestation.ToEnum(); - var options = fido2.RequestNewCredential( - user, - keyIds, - authenticatorSelection, - attestation, - new AuthenticationExtensionsClientInputs + var requestNewCredentialParameters = new RequestNewCredentialParams + { + User = user, + AttestationPreference = attestation, + AuthenticatorSelection = authenticatorSelection, + ExcludeCredentials = keyIds, + Extensions = new AuthenticationExtensionsClientInputs { CredProps = true - }); + } + }; + + var options = fido2.RequestNewCredential(requestNewCredentialParameters); options.Hints = token.Hints; @@ -381,20 +386,18 @@ public async Task SignInCompleteAsync(SignInCompleteDTO request, var credential = await _storage.GetCredential(request.Response.Id); if (credential == null) { - throw new UnknownCredentialException(Base64Url.Encode(request.Response.Id)); + throw new UnknownCredentialException(Base64Url.EncodeToString(request.Response.Id)); } // Create callback to check if userhandle owns the credentialId IsUserHandleOwnerOfCredentialIdAsync callback = (args, _) => Task.FromResult(credential.UserHandle.SequenceEqual(args.UserHandle)); // Make the assertion - var storedCredentials = (await _storage.GetCredentialsByUserIdAsync(request.Session)).Select(c => c.PublicKey).ToList(); var makeAssertionParams = new MakeAssertionParams { AssertionResponse = request.Response, OriginalOptions = authenticationSessionConfiguration.Options, StoredPublicKey = credential.PublicKey, - StoredDevicePublicKeys = storedCredentials, StoredSignatureCounter = credential.SignatureCounter, IsUserHandleOwnerOfCredentialIdCallback = callback }; diff --git a/src/Service/Service.csproj b/src/Service/Service.csproj index 38083a651..e19c46667 100644 --- a/src/Service/Service.csproj +++ b/src/Service/Service.csproj @@ -1,12 +1,12 @@  - - - + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Service/Storage/Ef/EfTenantStorage.cs b/src/Service/Storage/Ef/EfTenantStorage.cs index 9b11f9eb7..b309782a8 100644 --- a/src/Service/Storage/Ef/EfTenantStorage.cs +++ b/src/Service/Storage/Ef/EfTenantStorage.cs @@ -177,7 +177,9 @@ await db.AppFeatures.ExecuteUpdateAsync(x => x existing => features.EnableMagicLinks ?? existing.IsMagicLinksEnabled ) .SetProperty(f => f.EventLoggingRetentionPeriod, - existing => features.EventLoggingRetentionPeriod ?? existing.EventLoggingRetentionPeriod + existing => features.EventLoggingRetentionPeriod.HasValue + ? features.EventLoggingRetentionPeriod.Value + : existing.EventLoggingRetentionPeriod ) ); diff --git a/src/Service/TokenService.cs b/src/Service/TokenService.cs index 26302ac4e..5afce1db7 100644 --- a/src/Service/TokenService.cs +++ b/src/Service/TokenService.cs @@ -1,6 +1,6 @@ -using System.Security.Cryptography; +using System.Buffers.Text; +using System.Security.Cryptography; using System.Text; -using Fido2NetLib; using MessagePack; using MessagePack.Resolvers; using Microsoft.Extensions.Configuration; @@ -80,7 +80,7 @@ public async Task DecodeTokenAsync(string token, string prefix, bool contr MacEnvelope envelope; try { - var envelopeBytes = Base64Url.Decode(token); + var envelopeBytes = Base64Url.DecodeFromChars(token); envelope = MessagePackSerializer.Deserialize(envelopeBytes); } // Can happen if the token starts with the right prefix, but is otherwise syntactically incorrect @@ -160,7 +160,7 @@ public async Task EncodeTokenAsync(T token, string prefix, bool contr var envelope = new MacEnvelope { Mac = mac, Token = msgpack, KeyId = keyId }; var envelopeBinary = MessagePackSerializer.Serialize(envelope); - var envelopeBinaryB64 = Base64Url.Encode(envelopeBinary); + var envelopeBinaryB64 = Base64Url.EncodeToString(envelopeBinary); if (!string.IsNullOrEmpty(prefix)) { diff --git a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj index f52477df4..f39ba882e 100644 --- a/tests/AdminConsole.Tests/AdminConsole.Tests.csproj +++ b/tests/AdminConsole.Tests/AdminConsole.Tests.csproj @@ -2,9 +2,9 @@ - + - + diff --git a/tests/Api.IntegrationTests/Api.IntegrationTests.csproj b/tests/Api.IntegrationTests/Api.IntegrationTests.csproj index 2770ea55f..f31f6ce1e 100644 --- a/tests/Api.IntegrationTests/Api.IntegrationTests.csproj +++ b/tests/Api.IntegrationTests/Api.IntegrationTests.csproj @@ -3,12 +3,12 @@ - + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Api.IntegrationTests/AuthorizationTests.cs b/tests/Api.IntegrationTests/AuthorizationTests.cs index e1dcd107d..f393f8b88 100644 --- a/tests/Api.IntegrationTests/AuthorizationTests.cs +++ b/tests/Api.IntegrationTests/AuthorizationTests.cs @@ -1,4 +1,6 @@ using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing.Patterns; using Microsoft.Extensions.DependencyInjection; @@ -79,17 +81,11 @@ public async Task ValidateThatMissingApiSecretThrowsAsync() using var response = await client.GetAsync("/credentials/list?userId=1"); // Assert - var body = await response.Content.ReadAsStringAsync(); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#ApiSecret", actual?.Type); + Assert.Equal("A valid 'ApiSecret' header is required.", actual?.Title); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - AssertHelper.AssertEqualJson(""" - { - "type": "https://docs.passwordless.dev/guide/errors.html#ApiSecret", - "title": "A valid 'ApiSecret' header is required.", - "status": 401, - "detail": "A valid 'ApiSecret' header is required." - } - """, body); + Assert.Equal("A valid 'ApiSecret' header is required.", actual?.Detail); } [Fact] @@ -110,17 +106,11 @@ public async Task ValidateThatInvalidApiSecretThrowsAsync() using var response = await client.GetAsync("/credentials/list?userId=1"); // Assert - var body = await response.Content.ReadAsStringAsync(); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#ApiSecret", actual?.Type); + Assert.Equal("A valid 'ApiSecret' header is required.", actual?.Title); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - AssertHelper.AssertEqualJson(""" - { - "type": "https://docs.passwordless.dev/guide/errors.html#ApiSecret", - "title": "A valid 'ApiSecret' header is required.", - "status": 401, - "detail": "The value of your 'ApiSecret' is not valid." - } - """, body); + Assert.Equal("The value of your 'ApiSecret' is not valid.", actual?.Detail); } [Theory] @@ -155,17 +145,11 @@ public async Task ApiSecretGivesHelpfulAdviceAsync(string input, string details) using var response = await client.SendAsync(request); // Assert - var body = await response.Content.ReadAsStringAsync(); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#ApiSecret", actual?.Type); + Assert.Equal("A valid 'ApiSecret' header is required.", actual?.Title); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - AssertHelper.AssertEqualJson($$""" - { - "type": "https://docs.passwordless.dev/guide/errors.html#ApiSecret", - "title": "A valid 'ApiSecret' header is required.", - "status": 401, - "detail": "{{details}}" - } - """, body); + Assert.Equal(details, actual?.Detail); } [Theory] @@ -201,17 +185,11 @@ public async Task ApiPublicGivesHelpfulAdviceAsync(string input, string details) using var response = await client.SendAsync(request); // Assert - var body = await response.Content.ReadAsStringAsync(); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#ApiKey", actual?.Type); + Assert.Equal("A valid 'ApiKey' header is required.", actual?.Title); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - - AssertHelper.AssertEqualJson($$""" - { - "type": "https://docs.passwordless.dev/guide/errors.html#ApiKey", - "title": "A valid 'ApiKey' header is required.", - "status": 401, - "detail": "{{details}}" - } - """, body); + Assert.Equal(details, actual?.Detail); } private static string? CreateRoute(RoutePattern pattern) diff --git a/tests/Api.IntegrationTests/Endpoints/Register/RegisterTokenTests.cs b/tests/Api.IntegrationTests/Endpoints/Register/RegisterTokenTests.cs index cbaef234a..c323cf16e 100644 --- a/tests/Api.IntegrationTests/Endpoints/Register/RegisterTokenTests.cs +++ b/tests/Api.IntegrationTests/Endpoints/Register/RegisterTokenTests.cs @@ -2,6 +2,7 @@ using System.Net.Http.Json; using Bogus; using FluentAssertions; +using Microsoft.AspNetCore.Mvc; using Passwordless.Api.IntegrationTests.Helpers; using Passwordless.Api.IntegrationTests.Helpers.App; using Passwordless.Service.Models; @@ -64,11 +65,12 @@ public async Task InvalidUserIdReturnsError(string? userid) Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - var body = await response.Content.ReadAsStringAsync(); - - AssertHelper.AssertEqualJson( - // lang=json - """{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"userId":["The UserId field is required."]}}""", body); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(actual); + Assert.Equal("https://tools.ietf.org/html/rfc9110#section-15.5.1", actual!.Type); + Assert.Equal("One or more validation errors occurred.", actual.Title); + Assert.Equal(400, actual.Status); + Assert.Equal("{\"userId\":[\"The UserId field is required.\"]}", actual.Extensions["errors"]!.ToString()); } [Theory] @@ -97,11 +99,12 @@ public async Task InvalidUsernameReturnsError(string? input) // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - var body = await response.Content.ReadAsStringAsync(); - - AssertHelper.AssertEqualJson( - // lang=json - """{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"username":["The Username field is required."]}}""", body); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(actual); + Assert.Equal("https://tools.ietf.org/html/rfc9110#section-15.5.1", actual!.Type); + Assert.Equal("One or more validation errors occurred.", actual.Title); + Assert.Equal(400, actual.Status); + Assert.Equal("{\"username\":[\"The Username field is required.\"]}", actual.Extensions["errors"]!.ToString()); } [Theory] @@ -127,18 +130,12 @@ public async Task OtherAssertionIsNotAccepted(string attestation) // Assert response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - var body = await response.Content.ReadAsStringAsync(); - - AssertHelper.AssertEqualJson( - // lang=json - """ - { - "type": "https://docs.passwordless.dev/guide/errors.html#invalid_attestation", - "title": "Attestation type not supported", - "status": 400, - "errorCode": "invalid_attestation" - } - """, body); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(actual); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#invalid_attestation", actual!.Type); + Assert.Equal("Attestation type not supported", actual.Title); + Assert.Equal(400, actual.Status); + Assert.Equal("invalid_attestation", actual.Extensions["errorCode"]!.ToString()); } [Theory] diff --git a/tests/Api.IntegrationTests/Endpoints/SignIn/SignInTests.cs b/tests/Api.IntegrationTests/Endpoints/SignIn/SignInTests.cs index bed3a1869..2cdf25909 100644 --- a/tests/Api.IntegrationTests/Endpoints/SignIn/SignInTests.cs +++ b/tests/Api.IntegrationTests/Endpoints/SignIn/SignInTests.cs @@ -161,18 +161,12 @@ public async Task I_receive_an_error_message_when_sending_an_unrecognized_passke // Assert completeResponse.StatusCode.Should().Be(HttpStatusCode.BadRequest); - var body = await completeResponse.Content.ReadAsStringAsync(); - AssertHelper.AssertEqualJson( - // lang=json - """ - { - "type": "https://docs.passwordless.dev/guide/errors.html#unknown_credential", - "title": "We don't recognize the passkey you sent us.", - "status": 400, - "credentialId": "LcVLKA2QkfwzvuSTxIIyFVTJ9IopE57xTYvJ_0Nx9nk", - "errorCode": "unknown_credential" - } - """, body); + var actual = await completeResponse.Content.ReadFromJsonAsync(); + Assert.Equal("https://docs.passwordless.dev/guide/errors.html#unknown_credential", actual?.Type); + Assert.Equal("We don't recognize the passkey you sent us.", actual?.Title); + Assert.Equal(HttpStatusCode.BadRequest, completeResponse.StatusCode); + Assert.Equal("unknown_credential", actual?.Extensions["errorCode"]?.ToString()); + Assert.Equal("LcVLKA2QkfwzvuSTxIIyFVTJ9IopE57xTYvJ_0Nx9nk", actual?.Extensions["credentialId"]?.ToString()); } [Fact] diff --git a/tests/Api.Tests/Api.Tests.csproj b/tests/Api.Tests/Api.Tests.csproj index b17848c18..9d863a72d 100644 --- a/tests/Api.Tests/Api.Tests.csproj +++ b/tests/Api.Tests/Api.Tests.csproj @@ -2,7 +2,7 @@ - + diff --git a/tests/Common.Tests/Common.Tests.csproj b/tests/Common.Tests/Common.Tests.csproj index 45005cf36..b30429a30 100644 --- a/tests/Common.Tests/Common.Tests.csproj +++ b/tests/Common.Tests/Common.Tests.csproj @@ -3,7 +3,7 @@ - + diff --git a/tests/Service.Tests/Service.Tests.csproj b/tests/Service.Tests/Service.Tests.csproj index 4f531d0a4..d55cc481a 100644 --- a/tests/Service.Tests/Service.Tests.csproj +++ b/tests/Service.Tests/Service.Tests.csproj @@ -4,7 +4,7 @@ - +