Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 36 additions & 13 deletions .github/workflows/ci-swift.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ permissions:
on:
push:
paths:
- 'packages/contracts/**'
- 'packages/sdk-platforms/rust/**'
- 'packages/sdk-platforms/swift/**'
- '.github/workflows/ci-swift.yml'
- "packages/contracts/**"
- "packages/sdk-platforms/rust/**"
- "packages/sdk-platforms/swift/**"
- ".github/workflows/ci-swift.yml"
pull_request:
paths:
- 'packages/contracts/**'
- 'packages/sdk-platforms/rust/**'
- 'packages/sdk-platforms/swift/**'
- '.github/workflows/ci-swift.yml'
- "packages/contracts/**"
- "packages/sdk-platforms/rust/**"
- "packages/sdk-platforms/swift/**"
- ".github/workflows/ci-swift.yml"

jobs:
swift-sdk:
Expand All @@ -28,7 +28,7 @@ jobs:
- uses: actions/checkout@v4
with:
submodules: recursive

- name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.4

Expand Down Expand Up @@ -83,22 +83,45 @@ jobs:
- name: Select Xcode 16.3
run: sudo xcode-select -s /Applications/Xcode_16.3.app

- name: Install iOS Simulator Runtime
run: |
# List available runtimes
echo "Available runtimes:"
xcrun simctl runtime list
# Check what iOS runtimes are available with Xcode 16.3
# Download and install iOS 18 runtime if not available
xcodebuild -downloadPlatform iOS || true

- name: Select Simulator
run: |
UDID=$(xcrun simctl list devices | awk '/-- iOS 18.4 --/{flag=1; next} /--/{flag=0} flag' | grep "iPhone 16 Pro" | awk -F '[()]' '{print $2}' | head -1)
# List available simulators and boot one
xcrun simctl list devices available

# Get any available iPhone simulator (preferably newer models)
UDID=$(xcrun simctl list devices available | grep -E "iPhone (1[6-9]|[2-9][0-9])" | head -1 | grep -o '[0-9A-F]\{8\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{12\}')
if [ -z "$UDID" ]; then
echo "No suitable iPhone simulator found!" >&2
# Fallback: try to get any iPhone simulator
UDID=$(xcrun simctl list devices available | grep "iPhone" | head -1 | grep -o '[0-9A-F]\{8\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{4\}-[0-9A-F]\{12\}')
fi
if [ -z "$UDID" ]; then
echo "Simulator not found!" >&2
echo "No iPhone simulator found at all!" >&2
exit 1
fi
echo "Simulator UDID: $UDID"

# Boot the simulator
xcrun simctl boot "$UDID" || echo "Simulator may already be booted"
sleep 3

echo "SIMULATOR_UDID=$UDID" >> $GITHUB_ENV

- name: Install swiftformat
run: brew install swiftformat

- name: Build bindings
run: sh packages/sdk-platforms/rust/zksync-sso/crates/ffi/build-swift-framework-ios-ci.sh

- name: Build & test Swift SDK
run: |
xcodebuild test \
Expand Down
175 changes: 175 additions & 0 deletions examples/demo-app/tests/create-account.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,3 +460,178 @@ test("Create account with session, create session via paymaster, and send ETH",

console.log("Session created successfully with balance:", sessionStartBalance, "ETH");
});

test("Create session and verify it appears in auth-server sessions list", async ({ page }) => {
test.setTimeout(120000);
console.log("\n=== Session Display in Sessions List Test ===\n");

// Step 1: Create account
console.log("Step 1: Creating account...");
await page.getByRole("button", { name: "Connect", exact: true }).click();
await page.waitForTimeout(2000);

const popup = page.context().pages()[1];
await expect(popup.getByText("Connect to")).toBeVisible();

// Setup WebAuthn
const client = await popup.context().newCDPSession(popup);
await client.send("WebAuthn.enable");
let newCredential: WebAuthnCredential | null = null;
client.on("WebAuthn.credentialAdded", (credentialAdded) => {
newCredential = credentialAdded.credential;
});
await client.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});

// Complete signup
await popup.getByTestId("signup").click();
await expect(popup.getByText("Connect to ZKsync SSO Demo")).toBeVisible();
await popup.getByTestId("connect").click();
await page.waitForTimeout(2000);
await expect(page.getByText("Disconnect")).toBeVisible();

// Capture the account address from the page
const demoPageContent = await page.textContent("body");
const accountMatch = demoPageContent?.match(/0x[a-fA-F0-9]{40}/);
const demoAccountAddress = accountMatch ? accountMatch[0] : "unknown";
console.log(`✓ Account created: ${demoAccountAddress}`);

// Step 2: Create session
console.log("\nStep 2: Creating session...");
await page.getByRole("button", { name: "Disconnect", exact: true }).click();
await expect(page.getByRole("button", { name: "Connect with Session", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Connect with Session", exact: true }).click();
await page.waitForTimeout(2000);

const sessionPopup = page.context().pages()[1];
await expect(sessionPopup.getByText("Act on your behalf")).toBeVisible();

// Setup WebAuthn with existing credential
const sessionClient = await sessionPopup.context().newCDPSession(sessionPopup);
await sessionClient.send("WebAuthn.enable");
const sessionAuthenticator = await sessionClient.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
await expect(newCredential).not.toBeNull();
await sessionClient.send("WebAuthn.addCredential", {
authenticatorId: sessionAuthenticator.authenticatorId,
credential: newCredential!,
});

// Authorize session
await expect(sessionPopup.getByText("Authorize ZKsync SSO Demo")).toBeVisible();
await sessionPopup.getByTestId("connect").click();
await page.waitForTimeout(3000);
await expect(page.getByText("Disconnect")).toBeVisible();
console.log("✓ Session created");

// Step 3: Navigate to auth-server sessions page to verify
console.log("\nStep 3: Verifying session appears in auth-server...");

const authPage = await page.context().newPage();
await authPage.goto("http://localhost:3002");
await authPage.waitForTimeout(1000);

// Check if logged in
const isLoggedIn = await authPage.locator("[data-testid='account-address']").isVisible({ timeout: 2000 }).catch(() => false);

if (!isLoggedIn) {
console.log("Logging into auth-server...");
// Already on auth-server homepage, just click login
await authPage.getByTestId("login").click();
await authPage.waitForTimeout(1000);

// Setup WebAuthn for login
const authClient = await authPage.context().newCDPSession(authPage);
await authClient.send("WebAuthn.enable");
const authAuthenticator = await authClient.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: true,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation: true,
},
});
await authClient.send("WebAuthn.addCredential", {
authenticatorId: authAuthenticator.authenticatorId,
credential: newCredential!,
});

await authPage.waitForURL("**/dashboard", { timeout: 15000 });
console.log("✓ Logged into auth-server");
}

// Navigate to sessions page
await authPage.goto("http://localhost:3002/dashboard/sessions");
await authPage.waitForLoadState("domcontentloaded");

// Listen for console logs from the sessions page
authPage.on("console", (msg) => {
if (msg.text().includes("[sessions.vue]")) {
console.log(` Auth-server: ${msg.text()}`);
}
});

console.log("✓ Navigated to sessions page");
console.log(` Demo account (created session): ${demoAccountAddress}`);

// Verify sessions page content
const header = authPage.locator("header").getByText("Sessions");
await expect(header).toBeVisible();
console.log("✓ Sessions page loaded");

// Wait for sessions data to load - look for either session rows or the table/list container
// The sessions are loaded via WASM asynchronously, so we need to wait
try {
// Wait for the sessions list container or session rows to appear
await authPage.waitForSelector("table tbody tr, [role='list'] > div, [data-testid*='session']", {
timeout: 15000,
state: "attached",
});
console.log("✓ Sessions data container loaded");
} catch (e) {
console.log("⚠ No sessions container appeared within 15s", e);
}

// Additional wait to ensure console logs are captured
await authPage.waitForTimeout(2000);

// Log page content for debugging
const pageContent = await authPage.locator("main").textContent();
console.log(`Page content: ${pageContent?.substring(0, 500)}`);

// Verify at least one session is displayed
// The session rows use class="session-row" in the SessionRow component
const sessionRows = authPage.locator(".session-row");
const sessionCount = await sessionRows.count();
console.log(`Found ${sessionCount} session row(s)`);

expect(sessionCount, "At least one session should be displayed").toBeGreaterThan(0);
console.log(`✓ Found ${sessionCount} session(s) displayed`);

// Verify empty state message is NOT shown
const emptyState = authPage.getByText(/no active sessions/i);
const emptyVisible = await emptyState.isVisible({ timeout: 1000 }).catch(() => false);
expect(emptyVisible, "Empty state should NOT be visible when sessions exist").toBe(false);
console.log("✓ Empty state correctly hidden");

await authPage.close();
console.log("\n=== Session Display Test Complete ===\n");
});
9 changes: 6 additions & 3 deletions packages/auth-server/components/session/row/Expiry.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const props = defineProps<{
status: SessionStatus;
isExpired: boolean;
now: number;
createdAt: number;
expiresAt: number;
maxExpiresAt: number;
}>();

const expiresIn = useTimeAgo(props.expiresAt, { showSecond: true, updateInterval: 1000 });
Expand All @@ -54,7 +54,10 @@ const sessionExpiry = computed(() => {
});
});
const timeLeft = computed<number>(() => Math.max(0, props.expiresAt - props.now));
const timeTotal = computed<number>(() => Math.max(0, props.expiresAt - props.createdAt));
const timeLeftPercentage = computed<number>(() => Math.min(100, (timeLeft.value / timeTotal.value) * 100));
const maxTimeLeft = computed<number>(() => Math.max(0, props.maxExpiresAt - props.now));
const timeLeftPercentage = computed<number>(() => {
if (maxTimeLeft.value === 0) return 0;
return Math.min(100, (timeLeft.value / maxTimeLeft.value) * 100);
});
const isRevoked = computed(() => props.status === SessionStatus.Closed);
</script>
47 changes: 22 additions & 25 deletions packages/auth-server/components/session/row/Row.vue
Original file line number Diff line number Diff line change
@@ -1,32 +1,34 @@
<template>
<div class="session-row">
<div class="session-id-container">
<div :title="sessionId">
#{{ index }}
<div
v-if="sessionHash"
:title="sessionHash"
class="truncate"
>
{{ sessionHash.slice(0, 10) }}...{{ sessionHash.slice(-8) }}
</div>
<a
class="session-created-time-ago"
:title="fullCreatedAtDate || ''"
:href="`${defaultChain.blockExplorers?.native.url}/tx/${transactionHash}`"
target="_blank"
<div
v-if="sessionSpec?.signer"
class="session-signer text-xs text-neutral-500"
>
{{ createdTimeAgo }}
</a>
Signer: {{ sessionSpec.signer.slice(0, 6) }}...{{ sessionSpec.signer.slice(-4) }}
</div>
</div>
<div class="session-expiry-container">
<SessionRowExpiry
v-if="sessionState"
:status="sessionState.status"
:is-expired="isExpired"
:created-at="timestamp"
:expires-at="expiresAt"
:now="now"
:max-expires-at="maxExpiresAt"
/>
</div>
<div class="session-spend-limit-container">
<SessionRowSpendLimit
v-if="sessionState"
:config="session"
:config="sessionSpec"
:state="sessionState"
:now="now"
:is-inactive="isInactive"
Expand Down Expand Up @@ -54,24 +56,19 @@

<script setup lang="ts">
import { HandRaisedIcon } from "@heroicons/vue/24/outline";
import type { Hash } from "viem";
import type { Hex } from "viem";
import { SessionKeyValidatorAbi } from "zksync-sso-4337/abi";
import { type SessionConfig, type SessionState, SessionStatus } from "zksync-sso-4337/client";

const props = defineProps<{
session: SessionConfig;
index: number;
sessionId: Hash;
transactionHash: Hash;
blockNumber: bigint;
timestamp: number;
sessionHash: Hex;
sessionSpec: SessionConfig;
maxExpiresAt: number;
}>();

const _now = useNow({ interval: 1000 });
const now = computed(() => _now.value.getTime());
const createdTimeAgo = useTimeAgo(props.timestamp);
const fullCreatedAtDate = computed(() => new Date(props.timestamp).toLocaleString());
const expiresAt = computed<number>(() => bigintDateToDate(props.session.expiresAt).getTime());
const expiresAt = computed<number>(() => bigintDateToDate(props.sessionSpec.expiresAt).getTime());
const timeLeft = computed<number>(() => Math.max(0, expiresAt.value - now.value));
const isExpired = computed(() => timeLeft.value <= 0);

Expand All @@ -85,7 +82,7 @@ const {
const client = getClient({ chainId: defaultChain.id });
const paymasterAddress = contractsByChain[defaultChain.id].accountPaymaster;
await client.revokeSession({
sessionId: props.sessionId,
sessionId: props.sessionHash,
paymaster: {
address: paymasterAddress,
},
Expand All @@ -99,10 +96,10 @@ const {
} = useAsync(async () => {
const client = getPublicClient({ chainId: defaultChain.id });
const res = await client.readContract({
address: contractsByChain[defaultChain.id].session,
address: contractsByChain[defaultChain.id].sessionValidator,
abi: SessionKeyValidatorAbi,
functionName: "sessionState",
args: [address.value!, props.session],
args: [address.value!, props.sessionSpec],
});
return res as SessionState;
});
Expand All @@ -117,7 +114,7 @@ fetchSessionState();
.session-row {
@apply grid px-4 items-center text-sm;
@apply grid-cols-2 gap-y-2 py-4 gap-x-8;
@apply md:grid-cols-[6rem_1fr_1fr_45px] md:py-7 md:h-[100px];
@apply md:grid-cols-[10rem_1fr_1fr_45px] md:py-7 md:h-[100px];

grid-template-areas:
"session-id-container session-buttons-container"
Expand Down
Loading
Loading