diff --git a/.github/workflows/identitylab-image.yml b/.github/workflows/identitylab-image.yml index 1e98f4c1..7f430f30 100644 --- a/.github/workflows/identitylab-image.yml +++ b/.github/workflows/identitylab-image.yml @@ -25,6 +25,10 @@ permissions: contents: read packages: write +concurrency: + group: identitylab-deploy + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -37,8 +41,8 @@ jobs: uses: docker/setup-buildx-action@v3 - name: Log in to GHCR - uses: docker/login-action@v3 if: github.event_name != 'pull_request' + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -53,3 +57,83 @@ jobs: tags: ghcr.io/codebeamorg/ultimateauth-identitylab:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max + + deploy: + name: Deploy Identity Lab + needs: build + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/lab' + runs-on: ubuntu-latest + environment: identitylab + + steps: + - name: Deploy to production server + uses: appleboy/ssh-action@v1.0.3 + env: + IMAGE_TAG: ghcr.io/codebeamorg/ultimateauth-identitylab:${{ github.sha }} + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + key: ${{ secrets.SSH_PRIVATE_KEY }} + envs: IMAGE_TAG + command_timeout: 10m + script: | + set -eu + + cd /opt/identitylab + + ENV_FILE=/opt/identitylab/.env.identitylab + COMPOSE_FILE=/opt/identitylab/docker-compose.yml + + sudo -n test -f "$ENV_FILE" + sudo -n test -f "$COMPOSE_FILE" + + # Preserve current deployment configuration. + sudo -n cp "$ENV_FILE" "$ENV_FILE.rollback" + + rollback() { + echo "Deployment failed. Restoring previous image." + sudo -n cp "$ENV_FILE.rollback" "$ENV_FILE" + sudo -n docker compose \ + --env-file "$ENV_FILE" \ + -f "$COMPOSE_FILE" \ + up -d --remove-orphans + } + + trap rollback EXIT + + # Update only the image reference. + sudo -n sed -i \ + "s|^IDENTITYLAB_IMAGE=.*|IDENTITYLAB_IMAGE=$IMAGE_TAG|" \ + "$ENV_FILE" + + sudo -n docker compose \ + --env-file "$ENV_FILE" \ + -f "$COMPOSE_FILE" \ + pull identitylab + + sudo -n docker compose \ + --env-file "$ENV_FILE" \ + -f "$COMPOSE_FILE" \ + up -d --remove-orphans + + # Wait for the application to respond. + READY=0 + for attempt in $(seq 1 30); do + if curl -fsSL --max-time 10 \ + https://lab.ultimateauth.com/login \ + -o /dev/null; then + READY=1 + break + fi + sleep 5 + done + + if [ "$READY" -ne 1 ]; then + echo "Identity Lab readiness check failed." + exit 1 + fi + + trap - EXIT + sudo -n rm -f "$ENV_FILE.rollback" + + echo "Identity Lab deployed: $IMAGE_TAG" diff --git a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs index 8d4aa3cb..eee577e8 100644 --- a/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs +++ b/src/client/CodeBeam.UltimateAuth.Client.Blazor/Infrastructure/UAuthRequestClient.cs @@ -149,10 +149,12 @@ public async Task TryAndCommitAsync(string tryEndpoint, { var basePath = _options.Endpoints.BasePath; - if (Uri.TryCreate(basePath, UriKind.Absolute, out var uri)) - return uri.GetLeftPart(UriPartial.Authority); + if (!Uri.TryCreate(basePath, UriKind.Absolute, out var uri)) + return null; - // Relative BasePath: JS automatically uses the current origin. - return null; + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + return null; + + return uri.GetLeftPart(UriPartial.Authority); } } diff --git a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs index d2257ae3..821df048 100644 --- a/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs +++ b/tests/CodeBeam.UltimateAuth.Tests.Unit/Client/Blazor/UAuthRequestClientTests.cs @@ -24,14 +24,17 @@ public UAuthRequestClientTests() .Returns(Task.CompletedTask); } - private UAuthRequestClient CreateSut(UAuthClientProfile profile = UAuthClientProfile.BlazorWasm) + private UAuthRequestClient CreateSut(UAuthClientProfile profile = UAuthClientProfile.BlazorWasm, string? basePath = null) { - var options = Options.Create(new UAuthClientOptions + var options = new UAuthClientOptions { ClientProfile = profile - }); + }; + + if (basePath is not null) + options.Endpoints.BasePath = basePath; - return new UAuthRequestClient(JSInterop.JSRuntime, _bootstrapper.Object, options); + return new UAuthRequestClient(JSInterop.JSRuntime, _bootstrapper.Object, Options.Create(options)); } [Fact] @@ -178,6 +181,36 @@ public async Task ObservedLogoutReturnsHttpRejectionRatherThanSubmittingAnUnobse Assert.Equal("logout", GetProperty(invocation.Arguments.Single()!, "mode")); } + [Theory] + [InlineData("/auth", null)] + [InlineData("auth", null)] + [InlineData("https://example.com/auth", "https://example.com")] + [InlineData("http://localhost:5000/auth", "http://localhost:5000")] + public async Task SendFormAsync_ResolvesTrustedOriginCorrectly(string basePath, string? expectedOrigin) + { + // Arrange + JSInterop + .Setup("uauth.post", _ => true) + .SetResult(CreateTransportResult(200)); + + var sut = CreateSut(basePath: basePath); + + // Act + await sut.SendFormAsync("/auth/validate"); + + // Assert + var invocation = JSInterop.Invocations + .Single(x => x.Identifier == "uauth.post"); + + var options = invocation.Arguments.Single()!; + + GetProperty(options, "url") + .Should().Be("/auth/validate"); + + GetProperty(options, "trustedOrigin") + .Should().Be(expectedOrigin); + } + private sealed class TestTryResult { }