diff --git a/.gitignore b/.gitignore index 2038bb8ca..6b692d286 100644 --- a/.gitignore +++ b/.gitignore @@ -361,3 +361,9 @@ Resource.designer.cs 10.0/AI/ChatClientWithMobile/src/ChatMobile.Api/appsettings.json 10.0/AI/ChatClientWithMobile/src/ChatMobile.Api/appsettings.Development.json .vscode/settings.json + +# .NET 11 preview SDK and local Passkeys sample configuration +11.0/.dotnet/ +11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/Passkeys.Local.props +11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/Platforms/iOS/Entitlements.Local.plist +11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/Platforms/MacCatalyst/Entitlements.Local.plist diff --git a/11.0/PlatformIntegration/Passkeys/Configure-Passkeys.ps1 b/11.0/PlatformIntegration/Passkeys/Configure-Passkeys.ps1 new file mode 100644 index 000000000..24b845867 --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/Configure-Passkeys.ps1 @@ -0,0 +1,629 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Configures (and optionally hosts a dev tunnel for) the .NET MAUI Passkeys sample, + which is served by the src/Passkeys.Server relying-party web app. + +.DESCRIPTION + Passkeys are bound to a domain (the RP ID), so `localhost` will not work from a + real device. This script provisions a dev tunnel with a *persistent* tunnel id — so the public + domain stays the same every time — and writes all developer-specific values into files that are + NEVER committed: the SERVER's user-secrets, and the MAUI app's git-ignored Passkeys.Local.props + (imported by the app csproj). No committed file is edited. + + Into the server user-secrets: + - the passkeys relying-party domain + web origin, + - the Android package name (read from the sample app's project) plus the debug-signing-key + SHA-256 fingerprint and `android:apk-key-hash:` origin (so Digital Asset Links validate), and + - on macOS (unless -NoApple), the Apple app-id `.` for the App Site Association. + + Into the git-ignored src/Passkeys.Client/Passkeys.Local.props (and, for Apple, + src/Passkeys.Client/Platforms/iOS/ + Entitlements.Local.plist): + - the default relying-party server URL (baked into the app via AssemblyMetadata), and + - on macOS (unless -NoApple), the associated-domains entitlement plus the auto-detected Mac Catalyst + signing identity + provisioning profile. See README.md (Apple section) for the App ID + registration + profile steps that only you can do in your Apple Developer account. + + It does NOT run the web server — that is a separate `dotnet run` (see the printed next steps). + + You run this once. After that, the same domain is reused on every run. + + Cross-platform: run with PowerShell 7+ (`pwsh`) on macOS, Windows, or Linux. + +.PARAMETER TunnelId + The dev tunnel id/name to create or reuse. Defaults to 'maui-samples-passkeys'. Keep it constant to + keep the same public domain. + +.PARAMETER Port + The local HTTP port the server listens on. Defaults to 5177 (matches the project's + launchSettings.json "http" profile). + +.PARAMETER ApplicationId + The app's application id (bundle id) shared by all platforms. Defaults to the value from an existing + generated Passkeys.Local.props, then to the sample app's . This keeps the package/bundle + id stable across reruns. It's used for Android assetlinks and the Apple app-id + `.`. + +.PARAMETER AndroidKeystore + Path to the Android keystore whose signing-certificate SHA-256 goes into the Digital Asset Links + (assetlinks.json). Defaults to the debug keystore .NET for Android signs debug builds with: + /Xamarin/Mono for Android/debug.keystore (e.g. on macOS + ~/Library/Application Support/Xamarin/Mono for Android/debug.keystore). This is NOT + ~/.android/debug.keystore. + +.PARAMETER NoApple + Skip Apple (iOS / iPadOS / Mac Catalyst) setup. On macOS the script configures Apple by default, + auto-detecting your Team ID (from the "Apple Development" signing certificate), signing identity, + and provisioning profile. Apple setup is skipped automatically outside macOS because Apple targets + require a Mac to build and sign. Pass AppleTeamId explicitly to prepare server trust and entitlements + outside macOS for a subsequent build on a Mac. + +.PARAMETER NoAndroid + Skip Android setup. By default the script writes the Android debug-key SHA-256 fingerprint + + apk-key-hash origin. If Android is not skipped but the debug key can't be read, the script FAILS — + pass -NoAndroid to opt out. + +.PARAMETER AppleTeamId + Your 10-character Apple Developer Team ID (developer.apple.com -> Membership). Optional — it is + auto-detected from your "Apple Development" signing certificate; pass this only to override the + detected value. The Apple app-id `.` is written into the server's App Site + Association config, and the git-ignored Apple entitlements/signing are generated. + +.PARAMETER NoStartHost + Skip hosting the tunnel. By default the script hosts the tunnel (blocking) at the end; pass this to + just (re)configure and print the host command instead. + +.EXAMPLE + ./Configure-Passkeys.ps1 + # Configures Android and, on macOS, Apple; writes user-secrets and hosts the tunnel. + +.EXAMPLE + ./Configure-Passkeys.ps1 -NoApple + # Android-only: skip Apple setup (e.g. on a machine with no Apple signing certificate). + +.EXAMPLE + ./Configure-Passkeys.ps1 -AppleTeamId ABCDE12345 + # Override the auto-detected Apple Team ID with an explicit one. + +.EXAMPLE + ./Configure-Passkeys.ps1 -NoStartHost + # Configures without starting the blocking tunnel host (prints the host command instead). +#> +[CmdletBinding()] +param( + [string]$TunnelId = 'maui-samples-passkeys', + [int]$Port = 5177, + [string]$ApplicationId, + [string]$AndroidKeystore, + [string]$AppleTeamId, + [string]$AppleSigningIdentity, + [string]$AppleProvisioningProfile, + [switch]$NoApple, + [switch]$NoAndroid, + [switch]$NoStartHost +) + +$ErrorActionPreference = 'Stop' +$here = Split-Path -Parent $MyInvocation.MyCommand.Path +$project = Join-Path $here 'src' 'Passkeys.Server' 'Passkeys.Server.csproj' +$appleSkippedForPlatform = $false + +if (-not $IsMacOS -and -not $NoApple -and -not $AppleTeamId) { + $NoApple = $true + $appleSkippedForPlatform = $true +} + +function Get-ConfiguredApplicationId($localProps, $appCsproj) { + # Prefer the previously generated local value. Android credentials and Apple App IDs are bound to + # this identifier, so silently reverting to the project placeholder on a rerun would break trust. + if (Test-Path $localProps) { + $m = [regex]::Match((Get-Content -Raw $localProps), '\s*([^<]+?)\s*') + if ($m.Success -and -not [string]::IsNullOrWhiteSpace($m.Groups[1].Value)) { + return $m.Groups[1].Value.Trim() + } + } + + if (Test-Path $appCsproj) { + $m = [regex]::Match((Get-Content -Raw $appCsproj), '\s*([^<]+?)\s*') + if ($m.Success -and -not [string]::IsNullOrWhiteSpace($m.Groups[1].Value)) { + return $m.Groups[1].Value.Trim() + } + } + + throw "Could not read from '$localProps' or from '$appCsproj'. Pass -ApplicationId explicitly." +} + +$appDir = Join-Path $here 'src' 'Passkeys.Client' +$appCsproj = Join-Path $appDir 'Passkeys.Client.csproj' +$localProps = Join-Path $appDir 'Passkeys.Local.props' +if (-not $ApplicationId) { + $ApplicationId = Get-ConfiguredApplicationId $localProps $appCsproj +} + +# Default to the .NET for Android debug keystore — the key the build actually signs the APK with. +# .NET Android resolves this as /Xamarin/Mono for Android/debug.keystore, which +# maps per-OS to: +# macOS : ~/Library/Application Support/Xamarin/Mono for Android/debug.keystore +# Windows : %LOCALAPPDATA%\Xamarin\Mono for Android\debug.keystore +# Linux : ~/.local/share/Xamarin/Mono for Android/debug.keystore +# This is deliberately NOT ~/.android/debug.keystore — that is Android Studio's key and does NOT sign +# the .NET MAUI app. Reading the wrong keystore makes assetlinks.json advertise a fingerprint the APK +# isn't signed with, and passkey creation then fails on-device with +# "the incoming request could not be validated". +if (-not $AndroidKeystore) { + $localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) + $AndroidKeystore = Join-Path $localAppData 'Xamarin' 'Mono for Android' 'debug.keystore' +} + +# Computes the Android signing-key fingerprints needed for passkeys: the colon-hex SHA-256 +# (for assetlinks.json) and the "android:apk-key-hash:" origin (for ValidateOrigin). +# Returns $null if keytool or the keystore is unavailable (e.g. before the first Android build). +function Get-AndroidKeyInfo($keystore) { + if (-not (Get-Command 'keytool' -ErrorAction SilentlyContinue)) { + Write-Warning "keytool not found (install a JDK) — can't compute the Android signing fingerprint." + return $null + } + if (-not (Test-Path $keystore)) { + Write-Warning "Android keystore not found at '$keystore' (build the Android app once to create it)." + return $null + } + $out = & keytool -list -v -keystore $keystore -alias androiddebugkey -storepass android -keypass android 2>$null + $line = $out | Where-Object { $_ -match 'SHA256:' } | Select-Object -First 1 + if (-not $line) { Write-Warning "Could not read SHA-256 from the keystore '$keystore'."; return $null } + $hex = ($line -replace '.*SHA256:\s*', '').Trim() + $bytes = [byte[]]($hex.Split(':') | ForEach-Object { [Convert]::ToByte($_, 16) }) + $b64url = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_') + return [pscustomobject]@{ Hex = $hex; Origin = "android:apk-key-hash:$b64url" } +} + +# Generates a git-ignored Entitlements.Local.plist next to the committed Entitlements.plist: a copy of +# the base entitlements plus the webcredentials associated-domains entry for the relying-party domain. +# The committed Entitlements.plist is never modified. Pure XmlDocument (cross-platform, no external +# tools): XmlResolver is nulled so the plist DTD is never fetched, and the output is written with a +# fixed Apple-style header so it stays byte-clean (no empty-DOCTYPE-subset artifact). +function New-LocalEntitlements($basePlist, $outPlist, $domain) { + if (-not (Test-Path $basePlist)) { + Write-Warning "Base entitlements not found at '$basePlist'. Skipping Apple entitlements." + return $false + } + $entry = "webcredentials:$domain" + try { + $xml = New-Object System.Xml.XmlDocument + $xml.XmlResolver = $null + $xml.Load($basePlist) + + $dict = $xml.SelectSingleNode('/plist/dict') + $existing = $dict.SelectNodes('key') | Where-Object { $_.InnerText -eq 'com.apple.developer.associated-domains' } | Select-Object -First 1 + if ($existing) { + $arr = $existing.NextSibling + [void]$arr.RemoveAll() + } + else { + $k = $xml.CreateElement('key'); $k.InnerText = 'com.apple.developer.associated-domains'; [void]$dict.AppendChild($k) + $arr = $xml.CreateElement('array'); [void]$dict.AppendChild($arr) + } + $s = $xml.CreateElement('string'); $s.InnerText = $entry; [void]$arr.AppendChild($s) + + # Serialize just the element (skipping the DOCTYPE node) with tab indentation, then + # prepend the canonical Apple header, so the file matches the committed plist's format exactly. + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Indent = $true + $settings.IndentChars = "`t" + $settings.OmitXmlDeclaration = $true + $settings.NewLineChars = "`n" + $sb = New-Object System.Text.StringBuilder + $sw = New-Object System.IO.StringWriter($sb) + $writer = [System.Xml.XmlWriter]::Create($sw, $settings) + try { $xml.DocumentElement.WriteTo($writer) } finally { $writer.Dispose() } + + $header = "`n`n" + [System.IO.File]::WriteAllText($outPlist, $header + $sb.ToString() + "`n", (New-Object System.Text.UTF8Encoding($false))) + return $true + } + catch { + Write-Warning "Could not write local entitlements '$outPlist': $($_.Exception.Message)" + return $false + } +} + +function Require-Command($name, $hint) { + if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { + throw "'$name' is not installed. $hint" + } +} + +function Assert-NativeSuccess($operation) { + if ($LASTEXITCODE -ne 0) { + throw "$operation failed with exit code $LASTEXITCODE." + } +} + +# --- Apple signing auto-detection (macOS only) ------------------------------------------------ +# These make Configure a one-stop shop: they find the Apple Development identity in the keychain and +# the provisioning profile that already matches this app-id + associated-domains, so you don't have to +# copy names by hand. Everything is written to a LOCAL, git-ignored Passkeys.Local.props (never committed). + +# Returns the first "Apple Development" codesigning identity name, or $null. +function Get-AppleSigningIdentity { + if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null } + $out = & security find-identity -v -p codesigning 2>$null + $matches = @($out | Where-Object { $_ -match '"(Apple Development:[^"]+)"' } | ForEach-Object { + [regex]::Match($_, '"(Apple Development:[^"]+)"').Groups[1].Value + } | Select-Object -Unique) + if ($matches.Count -eq 0) { return $null } + return $matches[0] +} + +# Derives the 10-char Apple Developer Team ID so it needn't be passed by hand: it's the Organizational +# Unit (OU) of the "Apple Development" signing certificate, and also a provisioning profile's +# TeamIdentifier. Returns the Team ID, or $null. +function Get-AppleTeamId { + if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null } + + # Preferred: the OU of the Apple Development signing certificate. + if (Get-Command 'openssl' -ErrorAction SilentlyContinue) { + $subject = (& security find-certificate -a -c 'Apple Develop' -p 2>$null | & openssl x509 -noout -subject 2>$null) -join "`n" + $m = [regex]::Match($subject, 'OU\s*=\s*([A-Z0-9]{10})') + if ($m.Success) { return $m.Groups[1].Value } + } + + # Fallback: a provisioning profile's TeamIdentifier. + foreach ($f in Get-AppleProvisioningProfileFiles) { + $xml = (& security cms -D -i $f.FullName 2>$null) -join "`n" + $m = [regex]::Match($xml, 'TeamIdentifier\s*\s*([A-Z0-9]{10})') + if ($m.Success) { return $m.Groups[1].Value } + } + + return $null +} + +function Get-AppleProvisioningProfileFiles { + # Xcode 16+ writes profiles under UserData. Keep the legacy MobileDevice location for profiles + # installed by older Xcode versions or manually. New Xcode profiles take precedence. + $directories = @( + (Join-Path $HOME 'Library' 'Developer' 'Xcode' 'UserData' 'Provisioning Profiles'), + (Join-Path $HOME 'Library' 'MobileDevice' 'Provisioning Profiles') + ) + foreach ($dir in $directories | Select-Object -Unique) { + if (Test-Path $dir) { + Get-ChildItem -Path (Join-Path $dir '*') -Include '*.provisionprofile', '*.mobileprovision' -File -ErrorAction SilentlyContinue + } + } +} + +# Scans installed macOS provisioning profiles for a non-expired profile whose application-identifier +# equals (explicit) and that carries the associated-domains entitlement. Returns its Name. +function Find-AppleProvisioningProfile($appId) { + if (-not (Get-Command 'security' -ErrorAction SilentlyContinue)) { return $null } + + foreach ($f in Get-AppleProvisioningProfileFiles) { + $xml = (& security cms -D -i $f.FullName 2>$null) -join "`n" + if (-not $xml) { continue } + if ($xml -notmatch 'com\.apple\.developer\.associated-domains') { continue } + $platform = [regex]::Match($xml, 'Platform\s*(.*?)', [System.Text.RegularExpressions.RegexOptions]::Singleline) + if (-not $platform.Success -or $platform.Groups[1].Value -notmatch '(OSX|macOS)') { continue } + $expiration = [regex]::Match($xml, 'ExpirationDate\s*([^<]+)') + if ($expiration.Success -and [DateTime]::Parse($expiration.Groups[1].Value).ToUniversalTime() -le [DateTime]::UtcNow) { continue } + # application-identifier looks like "."; match the explicit app id. + $m = [regex]::Match($xml, 'application-identifier\s*([^<]+)') + if (-not $m.Success) { + $m = [regex]::Match($xml, 'com\.apple\.application-identifier\s*([^<]+)') + } + if ($m.Success -and $m.Groups[1].Value -eq $appId) { + $n = [regex]::Match($xml, 'Name\s*([^<]+)') + if ($n.Success) { return $n.Groups[1].Value } + } + } + return $null +} + +# Writes the git-ignored src/Passkeys.Client/Passkeys.Local.props by loading the committed +# Passkeys.Local.in.props +# template and filling in the values via XML — so the template is the single source of truth for the +# file's shape and comments (tweak the .in file, not this script). Always sets the default server URL +# (baked into the app via AssemblyMetadata). When Apple signing was resolved it also fills the entitlements +# path and Mac Catalyst signing; otherwise it strips the Apple-only PropertyGroups. +function Write-PasskeysLocalProps($appDir, $serverUrl, $applicationId, $iosEntitlementsRel, $macEntitlementsRel, $identity, $profileName) { + $template = Join-Path $appDir 'Passkeys.Local.in.props' + $path = Join-Path $appDir 'Passkeys.Local.props' + if (-not (Test-Path $template)) { + throw "Template not found at '$template'. It should be committed alongside the app project." + } + + $xml = New-Object System.Xml.XmlDocument + $xml.PreserveWhitespace = $false + $xml.Load($template) + $project = $xml.DocumentElement + + # Replace the template's top-of-file comment(s) with a generated-file banner. + foreach ($node in @($xml.ChildNodes)) { + if ($node.NodeType -eq [System.Xml.XmlNodeType]::Comment) { [void]$xml.RemoveChild($node) } + } + $banner = $xml.CreateComment(" AUTO-GENERATED by Configure-Passkeys.ps1 from Passkeys.Local.in.props. DO NOT COMMIT (git-ignored).`n Re-run Configure-Passkeys.ps1 to refresh; edit Passkeys.Local.in.props to change the file's shape. ") + [void]$xml.InsertBefore($banner, $project) + + # Server URL (all platforms) always. + foreach ($n in @($project.GetElementsByTagName('PasskeysServerUrl'))) { $n.InnerText = $serverUrl } + foreach ($n in @($project.GetElementsByTagName('PasskeysApplicationId'))) { $n.InnerText = $applicationId } + + foreach ($pg in @($project.GetElementsByTagName('PropertyGroup'))) { + $entitlements = $pg.GetElementsByTagName('CodesignEntitlements') + if ($entitlements.Count -gt 0) { + $isMacCatalyst = $pg.GetAttribute('Condition') -match 'maccatalyst' + $entitlementsRel = if ($isMacCatalyst) { $macEntitlementsRel } else { $iosEntitlementsRel } + if ($entitlementsRel) { + foreach ($n in @($entitlements)) { $n.InnerText = $entitlementsRel } + } + else { + $prev = $pg.PreviousSibling + [void]$project.RemoveChild($pg) + if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Comment) { [void]$project.RemoveChild($prev) } + } + } + } + + if ($identity -and $profileName) { + foreach ($n in @($project.GetElementsByTagName('CodesignKey'))) { $n.InnerText = $identity } + foreach ($n in @($project.GetElementsByTagName('CodesignProvision'))) { $n.InnerText = $profileName } + } + else { + # No Apple signing: keep simulator entitlements, but drop the device/Mac Catalyst signing group. + $appleProps = @('CodesignKey', 'CodesignProvision', 'MtouchLink') + foreach ($pg in @($project.GetElementsByTagName('PropertyGroup'))) { + $isApple = $false + foreach ($child in $pg.ChildNodes) { + if ($child.NodeType -eq [System.Xml.XmlNodeType]::Element -and $appleProps -contains $child.Name) { $isApple = $true; break } + } + if ($isApple) { + $prev = $pg.PreviousSibling + [void]$project.RemoveChild($pg) + if ($prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Comment) { [void]$project.RemoveChild($prev) } + } + } + } + + $settings = New-Object System.Xml.XmlWriterSettings + $settings.Indent = $true + $settings.IndentChars = ' ' + $settings.OmitXmlDeclaration = $true + $settings.NewLineChars = "`n" + $sw = New-Object System.IO.StringWriter + $writer = [System.Xml.XmlWriter]::Create($sw, $settings) + try { $xml.Save($writer) } finally { $writer.Dispose() } + [System.IO.File]::WriteAllText($path, $sw.ToString() + "`n", (New-Object System.Text.UTF8Encoding($false))) + return $path +} +# --------------------------------------------------------------------------------------------- + +Require-Command 'devtunnel' @' +Install the dev tunnels CLI: + macOS: brew install --cask devtunnel + Windows: winget install Microsoft.devtunnel + Linux: https://aka.ms/devtunnels/download +'@ +Require-Command 'dotnet' 'Install the .NET SDK from https://dotnet.microsoft.com/download.' + +$loggedInUser = $null +try { + $u = devtunnel user show --json 2>$null | ConvertFrom-Json + if ($u.status -eq 'Logged in') { $loggedInUser = $u.username } +} +catch { } + +if ($loggedInUser) { + Write-Host "==> Already signed in to dev tunnels as $loggedInUser." -ForegroundColor Cyan +} +else { + Write-Host "==> Signing in to dev tunnels (a browser window may open)…" -ForegroundColor Cyan + devtunnel user login | Out-Host + Assert-NativeSuccess 'Dev tunnel login' +} + +Write-Host "==> Ensuring tunnel '$TunnelId' exists…" -ForegroundColor Cyan +# IMPORTANT: `devtunnel create` always makes a NEW tunnel — running it when the tunnel already +# exists creates a duplicate (in another cluster). So only create when `show` can't find it. +$tunnelJson = devtunnel show $TunnelId --json 2>$null | ConvertFrom-Json +if (-not $tunnelJson.tunnel) { + devtunnel create $TunnelId --allow-anonymous | Out-Host + Assert-NativeSuccess "Creating dev tunnel '$TunnelId'" + $tunnelJson = devtunnel show $TunnelId --json 2>$null | ConvertFrom-Json +} +else { + # Apple and Android fetch association documents without a tunnel login. Existing tunnels might + # predate this sample or have private defaults, so verify their access instead of assuming it. + $accessJson = (devtunnel access list $TunnelId --json 2>$null) -join "`n" + if ($accessJson -notmatch '(?i)anonymous|"\*"') { + Write-Host " Enabling anonymous client access for platform association checks…" -ForegroundColor DarkGray + devtunnel access create $TunnelId --anonymous | Out-Host + Assert-NativeSuccess "Enabling anonymous access on '$TunnelId'" + } +} +if (-not ($tunnelJson.tunnel.ports | Where-Object { $_.portNumber -eq $Port })) { + devtunnel port create $TunnelId -p $Port --protocol http | Out-Host + Assert-NativeSuccess "Creating port $Port on '$TunnelId'" +} + +Write-Host "==> Resolving the public tunnel URL…" -ForegroundColor Cyan + +# Use the tunnel's own per-tunnel public URL (`portUri`), which is unique to this tunnel. We do NOT +# use the tunnel-id-derived URL (https://-..devtunnels.ms): although it's nicer, +# the tunnel name is a shared/global resource, so hardcoding it would collide across developers and +# machines. The random-looking portUri is stable for the life of the tunnel and safe for everyone. +# +# `portUri` is only assigned after the tunnel has been hosted once (then it persists), so on a brand +# new tunnel we briefly host it in the background to materialize the URL, then re-read it. +function Get-PortUri($tunnelId, $port) { + try { + $json = devtunnel show $tunnelId --json 2>$null | ConvertFrom-Json + $p = $json.tunnel.ports | Where-Object { $_.portNumber -eq $port } | Select-Object -First 1 + if ($p -and $p.portUri) { return ([string]$p.portUri).TrimEnd('/') } + } + catch { } + return $null +} + +$uri = Get-PortUri $TunnelId $Port +if (-not $uri) { + Write-Host " New tunnel — starting a brief host session to obtain the URL…" -ForegroundColor DarkGray + $job = Start-Job -ScriptBlock { param($t) devtunnel host $t } -ArgumentList $TunnelId + try { + for ($i = 0; $i -lt 15 -and -not $uri; $i++) { + Start-Sleep -Seconds 2 + $uri = Get-PortUri $TunnelId $Port + } + } + finally { + Stop-Job $job -ErrorAction SilentlyContinue + Remove-Job $job -Force -ErrorAction SilentlyContinue + } +} + +if (-not $uri) { + throw @" +Could not resolve the public dev tunnel URL for '$TunnelId'. + +A public HTTPS domain is REQUIRED — there is no localhost fallback. Passkeys are bound to a domain: the +native authenticators need the relying party's well-known files (Android assetlinks.json, Apple AASA) +served over public HTTPS, so 'localhost' cannot work on Android, iOS, or Mac Catalyst. + +Host the tunnel once to materialize its URL, then re-run this script: + devtunnel host $TunnelId +"@ +} + +$domain = ([Uri]$uri).Host +Write-Host " Public URL : $uri" -ForegroundColor Green +Write-Host " RP ID/host : $domain" -ForegroundColor Green + +Write-Host "==> Writing server user-secrets (passkeys ServerDomain + web origin)…" -ForegroundColor Cyan +# Reset every script-owned collection entry first. This makes -NoAndroid/-NoApple deterministic and +# prevents values from an earlier run from remaining trusted or published by the server. +$managedSecretKeys = @( + 'Passkeys:AllowedOrigins:0', + 'Passkeys:AllowedOrigins:1', + 'Passkeys:Android:PackageName', + 'Passkeys:Android:Sha256CertFingerprints:0', + 'Passkeys:Apple:AppIds:0' +) +foreach ($key in $managedSecretKeys) { + dotnet user-secrets --project $project remove $key 2>$null | Out-Null + Assert-NativeSuccess "Removing stale user-secret '$key'" +} +dotnet user-secrets --project $project set 'Passkeys:ServerDomain' $domain | Out-Null +Assert-NativeSuccess 'Writing Passkeys:ServerDomain' +dotnet user-secrets --project $project set 'Passkeys:AllowedOrigins:0' $uri | Out-Null +Assert-NativeSuccess 'Writing the web allowed origin' +Write-Host " Done. The passkeys RP ID is '$domain'." -ForegroundColor Green + +# Android: compute + write the debug-key fingerprint (assetlinks) and apk-key-hash origin. Configured +# by default; fails if the debug key can't be read (pass -NoAndroid to skip Android instead). +if ($NoAndroid) { + Write-Host " Android: skipped (-NoAndroid)." -ForegroundColor DarkGray +} +else { + $android = Get-AndroidKeyInfo $AndroidKeystore + if (-not $android) { + throw "Could not read the Android debug-signing key (needed for the Digital Asset Links fingerprint). Build the Android app once to generate the debug keystore, pass -AndroidKeystore , or pass -NoAndroid to skip Android." + } + dotnet user-secrets --project $project set 'Passkeys:Android:PackageName' $ApplicationId | Out-Null + Assert-NativeSuccess 'Writing the Android package name' + dotnet user-secrets --project $project set 'Passkeys:Android:Sha256CertFingerprints:0' $android.Hex | Out-Null + Assert-NativeSuccess 'Writing the Android certificate fingerprint' + dotnet user-secrets --project $project set 'Passkeys:AllowedOrigins:1' $android.Origin | Out-Null + Assert-NativeSuccess 'Writing the Android allowed origin' + Write-Host " Android configured: package '$ApplicationId'" -ForegroundColor Green + Write-Host " SHA-256 : $($android.Hex)" -ForegroundColor DarkGray + Write-Host " origin : $($android.Origin)" -ForegroundColor DarkGray +} +# Compose the git-ignored Passkeys.Local.props for the MAUI app: the default server URL always, plus +# the Apple entitlements/signing (configured by default on macOS; skipped with -NoApple or automatically +# on other operating systems). The committed files are never edited. +$iosEntitlementsRel = $null +$macEntitlementsRel = $null +$resolvedIdentity = $null +$resolvedProfile = $null + +if (-not $NoApple -and -not $AppleTeamId) { + $AppleTeamId = Get-AppleTeamId + if ($AppleTeamId) { + Write-Host " Apple Team ID auto-detected from your signing cert: $AppleTeamId" -ForegroundColor DarkGray + } +} + +if ($NoApple) { + if ($appleSkippedForPlatform) { + Write-Host " Apple: skipped automatically (Apple targets require macOS to build and sign)." -ForegroundColor DarkGray + } + else { + Write-Host " Apple: skipped (-NoApple)." -ForegroundColor DarkGray + } +} +elseif (-not $AppleTeamId) { + throw "Could not determine your Apple Team ID: no 'Apple Development' signing certificate found. Install one (Xcode -> Settings -> Accounts -> Manage Certificates), pass -AppleTeamId , or pass -NoApple to skip Apple." +} +else { + $appleAppId = "$AppleTeamId.$ApplicationId" + dotnet user-secrets --project $project set 'Passkeys:Apple:AppIds:0' $appleAppId | Out-Null + Assert-NativeSuccess 'Writing the Apple app ID' + Write-Host " Apple configured: app-id '$appleAppId'" -ForegroundColor Green + + # Generate the git-ignored local entitlements (the committed Entitlements.plist is never touched). + $iosBaseEnt = Join-Path $appDir 'Platforms' 'iOS' 'Entitlements.plist' + $iosLocalEnt = Join-Path $appDir 'Platforms' 'iOS' 'Entitlements.Local.plist' + if (New-LocalEntitlements $iosBaseEnt $iosLocalEnt $domain) { + $iosEntitlementsRel = 'Platforms/iOS/Entitlements.Local.plist' + } + $macBaseEnt = Join-Path $appDir 'Platforms' 'MacCatalyst' 'Entitlements.plist' + $macLocalEnt = Join-Path $appDir 'Platforms' 'MacCatalyst' 'Entitlements.Local.plist' + if (New-LocalEntitlements $macBaseEnt $macLocalEnt $domain) { + $macEntitlementsRel = 'Platforms/MacCatalyst/Entitlements.Local.plist' + } + Write-Host " entitlement: webcredentials:$domain (platform-local entitlements — git-ignored)" -ForegroundColor DarkGray + + # Resolve the signing identity and provisioning profile (params win; otherwise auto-detect). + if (-not $AppleSigningIdentity) { $AppleSigningIdentity = Get-AppleSigningIdentity } + if (-not $AppleProvisioningProfile) { $AppleProvisioningProfile = Find-AppleProvisioningProfile $appleAppId } + if ($AppleSigningIdentity -and $AppleProvisioningProfile) { + $resolvedIdentity = $AppleSigningIdentity + $resolvedProfile = $AppleProvisioningProfile + Write-Host " signing identity : $AppleSigningIdentity" -ForegroundColor DarkGray + Write-Host " provisioning : $AppleProvisioningProfile" -ForegroundColor DarkGray + } + else { + if ($IsMacOS) { + if (-not $AppleSigningIdentity) { + Write-Host " No 'Apple Development' signing identity found in the keychain." -ForegroundColor Yellow + } + if (-not $AppleProvisioningProfile) { + Write-Host " No installed provisioning profile matches '$appleAppId' with Associated Domains." -ForegroundColor Yellow + } + Write-Host " iOS Simulator still works. For Mac Catalyst / iOS device, create the profile and re-run" -ForegroundColor DarkGray + Write-Host " (or pass -AppleSigningIdentity / -AppleProvisioningProfile) — see README.md (Apple section)." -ForegroundColor DarkGray + } + else { + Write-Host " Server trust and entitlements generated. Build and sign the Apple app on a Mac." -ForegroundColor DarkGray + } + } +} + +$propsPath = Write-PasskeysLocalProps $appDir $uri $ApplicationId $iosEntitlementsRel $macEntitlementsRel $resolvedIdentity $resolvedProfile +Write-Host " Wrote $([IO.Path]::GetFileName($propsPath)) (git-ignored): the app defaults to $uri." -ForegroundColor Green + +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Yellow +if ($NoStartHost) { + Write-Host " 1) In THIS terminal, host the tunnel: devtunnel host $TunnelId" + Write-Host " 2) In ANOTHER terminal, run the server: dotnet run --project `"$project`" --launch-profile http" +} +else { + Write-Host " In ANOTHER terminal, run the server: dotnet run --project `"$project`" --launch-profile http" + Write-Host " (this terminal is about to host the tunnel — pass -NoStartHost to skip that)" +} +Write-Host " Then build/run the sample — its Passkeys page defaults to $uri." + +if (-not $NoStartHost) { + Write-Host "" + Write-Host "==> Hosting tunnel '$TunnelId' (Ctrl+C to stop). Run the server in another terminal." -ForegroundColor Cyan + devtunnel host $TunnelId +} diff --git a/11.0/PlatformIntegration/Passkeys/Passkeys.sln b/11.0/PlatformIntegration/Passkeys/Passkeys.sln new file mode 100644 index 000000000..88ed7cac8 --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/Passkeys.sln @@ -0,0 +1,24 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Passkeys.Client", "src\Passkeys.Client\Passkeys.Client.csproj", "{BB97F7C3-BCF0-4D5A-8A41-98D11131320E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Passkeys.Server", "src\Passkeys.Server\Passkeys.Server.csproj", "{338478B2-DC9E-494C-8823-66E5C152C4EA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BB97F7C3-BCF0-4D5A-8A41-98D11131320E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BB97F7C3-BCF0-4D5A-8A41-98D11131320E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BB97F7C3-BCF0-4D5A-8A41-98D11131320E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BB97F7C3-BCF0-4D5A-8A41-98D11131320E}.Release|Any CPU.Build.0 = Release|Any CPU + {338478B2-DC9E-494C-8823-66E5C152C4EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {338478B2-DC9E-494C-8823-66E5C152C4EA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {338478B2-DC9E-494C-8823-66E5C152C4EA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {338478B2-DC9E-494C-8823-66E5C152C4EA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/11.0/PlatformIntegration/Passkeys/README.md b/11.0/PlatformIntegration/Passkeys/README.md new file mode 100644 index 000000000..25e51d94d --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/README.md @@ -0,0 +1,241 @@ +--- +name: .NET MAUI - Passkeys +description: A native .NET MAUI 11 passkeys client with an ASP.NET Core WebAuthn relying-party server. +page_type: sample +languages: +- csharp +- xaml +- powershell +products: +- dotnet-maui +urlFragment: platformintegration-passkeys +--- + +# Passkeys + +This sample demonstrates passkey registration and passwordless sign-in with the .NET MAUI 11 +`Microsoft.Maui.Authentication.Passkeys` API. It includes: + +- `src/Passkeys.Client`, a native MAUI app for Android, iOS, Mac Catalyst, and Windows. +- `src/Passkeys.Server`, a minimal ASP.NET Core Identity relying-party (RP) server. +- `Configure-Passkeys.ps1`, deterministic dev tunnel and platform-association setup. + +The server uses an in-memory SQLite database. Accounts and passkeys are sample data and disappear when +the server stops. + +## Security boundary + +The native client does **not** validate WebAuthn attestation or assertion responses. It requests +creation/request JSON from the RP server, gives that JSON to the platform authenticator, and returns the +authenticator response. The ASP.NET Core server validates the challenge, RP ID, origin, attestation, and +assertion before it stores a credential or establishes an authenticated session. + +This is a development sample, not a production identity service. It intentionally disables email +confirmation, uses an in-memory database, and accepts only origins configured in user-secrets. + +## Prerequisites + +- The .NET 11 Preview 7 SDK from [`11.0/global.json`](../../global.json). +- The MAUI Android, iOS, and Mac Catalyst workloads needed by your host. +- [PowerShell 7](https://learn.microsoft.com/powershell/scripting/install/installing-powershell). +- [Dev tunnels CLI](https://learn.microsoft.com/azure/developer/dev-tunnels/get-started): + - macOS: `brew install --cask devtunnel` + - Windows: `winget install Microsoft.devtunnel` +- A JDK `keytool` for Android fingerprint discovery. +- Platform requirements listed below. + +If the Preview 7 SDK is not installed, use the repository-local workflow from +[`11.0/README.md`](../../README.md). From `11.0/`: + +```bash +curl -fsSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh +chmod +x dotnet-install.sh +./dotnet-install.sh --version 11.0.100-preview.7.26381.103 --install-dir ./.dotnet +``` + +The local `.dotnet/` directory is ignored by Git and isolated by `11.0/global.json`. + +## Configure the relying party + +Passkeys are scoped to an RP domain. A public HTTPS dev tunnel supplies a stable domain that devices and +the Apple/Android association services can reach. + +1. Build Android once if you plan to test Android. This creates the debug keystore actually used by + .NET for Android: + + ```bash + dotnet build src/Passkeys.Client/Passkeys.Client.csproj -f net11.0-android + ``` + +2. Run setup from this directory: + + ```bash + pwsh ./Configure-Passkeys.ps1 + ``` + + The default tunnel ID is `maui-samples-passkeys`. Use a unique, stable ID if needed: + + ```bash + pwsh ./Configure-Passkeys.ps1 -TunnelId + ``` + + On macOS, setup attempts both Android and Apple configuration. Use `-NoApple` when you do not have + Apple signing configured, or `-NoAndroid` when you are not testing Android. Use `-NoStartHost` to + configure without starting the blocking tunnel host. + +3. In another terminal, start the RP server: + + ```bash + dotnet run --project src/Passkeys.Server --launch-profile http + ``` + +4. Verify the public URL printed by the script: + + ```bash + curl -fsS https:///health + curl -fsS https:///.well-known/apple-app-site-association + curl -fsS https:///.well-known/assetlinks.json + ``` + +The script writes only local data: + +- RP configuration goes to ASP.NET Core user-secrets. +- `src/Passkeys.Client/Passkeys.Local.props` contains the server URL, local application ID, and optional + signing settings. +- Platform-specific `Entitlements.Local.plist` files under `Platforms/iOS` and + `Platforms/MacCatalyst` contain the local Associated Domains entitlement while preserving each + platform's base entitlements. + +Both generated files are ignored by Git. The repository contains no tunnel credentials, fingerprints, +generated entitlements, signing identities, provisioning profiles, or developer-specific bundle IDs. + +## Apple setup + +Passkeys require all three of these values to match: + +1. The app entitlement contains `webcredentials:`. +2. `https:///.well-known/apple-app-site-association` contains + `.`. +3. The app is signed by that Apple Developer team with a provisioning profile that has the Associated + Domains capability. + +### Requirements + +- iOS 16+ or Mac Catalyst 17+. The native passkey API exists on Mac Catalyst 16+, but the .NET 11 + Mac Catalyst toolchain has a minimum deployment target of 17.0. +- macOS and Xcode. +- A paid Apple Developer account. Personal/free teams cannot provision Associated Domains. +- An explicit App ID registered to your team with Associated Domains enabled. +- An Apple Development certificate and a matching provisioning profile for device or Mac Catalyst use. + +The committed `com.companyname.mauipasskeys` ID is only a placeholder. Choose your own globally unique +reverse-DNS ID and pass it to setup without editing the project: + +```bash +pwsh ./Configure-Passkeys.ps1 \ + -ApplicationId com.example.mauipasskeys \ + -AppleTeamId ABCDE12345 +``` + +The generated local properties retain that ID. Later runs can omit `-ApplicationId`; setup reuses +`PasskeysApplicationId` from `Passkeys.Local.props` before considering the committed project placeholder. +Pass `-ApplicationId` again only when you intentionally want to configure a different app identity. + +On macOS, `AppleTeamId`, signing identity, and profile are auto-detected when possible. You can provide +`-AppleSigningIdentity` and `-AppleProvisioningProfile` explicitly. The iOS Simulator needs the generated +Associated Domains entitlement but does not need device signing. Mac Catalyst and physical iOS devices +need a profile carrying Associated Domains. + +Apple fetches and caches the AASA file. Before launching, confirm it is public JSON and that its app ID +matches the signed app: + +```bash +curl -i https:///.well-known/apple-app-site-association +``` + +## Android setup + +### Requirements + +- Android 14 / API 34 or newer. +- A Google Play system image, not an AOSP-only image. +- A Google account signed in on the emulator. +- A secure screen lock. + +Android binds the RP to both the package name and signing certificate. Setup reads `ApplicationId` (or +the `-ApplicationId` value), finds the .NET for Android debug keystore, and calculates: + +- The colon-delimited SHA-256 certificate fingerprint served by Digital Asset Links. +- The matching `android:apk-key-hash:` native origin accepted by the server. + +The default .NET for Android debug keystore is under the OS local application-data directory at +`Xamarin/Mono for Android/debug.keystore`. It is not Android Studio's `~/.android/debug.keystore`. + +Confirm that the served document exactly matches the installed app: + +```bash +curl -i https:///.well-known/assetlinks.json +``` + +No Android intent filter is needed. Digital Asset Links credential delegation is separate from Android +App Links. + +## Windows setup + +Use Windows 10 version 1903 or newer with: + +- The Windows WebAuthn API. +- Windows Hello configured, or a compatible FIDO2 security key. +- Network access to the HTTPS RP URL. + +Windows trusts the HTTPS RP origin directly, so it does not use AASA or Digital Asset Links. + +## Run + +Keep the server and tunnel running, then use the target appropriate for your host: + +```bash +dotnet build -t:Run src/Passkeys.Client/Passkeys.Client.csproj -f net11.0-android +dotnet build -t:Run src/Passkeys.Client/Passkeys.Client.csproj -f net11.0-ios +dotnet build -t:Run src/Passkeys.Client/Passkeys.Client.csproj -f net11.0-maccatalyst +dotnet build -t:Run src/Passkeys.Client/Passkeys.Client.csproj -f net11.0-windows10.0.19041.0 +``` + +In the app: + +1. Create a test account or sign in with its password. +2. Select **Create a passkey** and approve the platform prompt. +3. Sign out. +4. Select **Sign in with a passkey** and choose the credential. + +## Key files + +| File | Purpose | +| --- | --- | +| `src/Passkeys.Client/ViewModels/PasskeysViewModel.cs` | Calls the native Passkeys API and transports WebAuthn JSON. | +| `src/Passkeys.Server/PasskeyEndpoints.cs` | Creates options and verifies/stores attestation and assertions. | +| `src/Passkeys.Server/Program.cs` | Configures Identity, in-memory storage, RP ID, and allowed origins. | +| `Configure-Passkeys.ps1` | Creates/reuses the tunnel and writes local-only trust configuration. | +| `src/Passkeys.Client/Passkeys.Local.in.props` | Template for generated URL, application ID, entitlements, and signing settings. | + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Placeholder tunnel URL appears in the app | Re-run `Configure-Passkeys.ps1`, then rebuild the app. | +| A rerun uses the wrong package or bundle ID | Check `Passkeys.Local.props`. Setup preserves its `PasskeysApplicationId`; pass `-ApplicationId` explicitly to intentionally replace it. | +| Android reports no credential provider/create options | Use API 34+ with Google Play services, a signed-in Google account, and screen lock. | +| Android request cannot be validated | Package name, installed APK signing certificate, assetlinks fingerprint, and `android:apk-key-hash` origin must match. | +| Apple says the domain is not associated | Compare the signed Team ID/bundle ID, generated entitlement, and AASA response exactly. | +| AASA or assetlinks returns HTML | The tunnel interstitial is responding. Allow anonymous access and ensure the port is configured as HTTP. | +| `/finish` reports no ceremony in progress | Preserve the cookie from `/begin`; the sample uses one `HttpClient` with a `CookieContainer`. | +| Windows reports unsupported | Use Windows 10 1903+ and configure Windows Hello or a FIDO2 authenticator. | + +## Source and release references + +This sample adapts the implementation merged in +[dotnet/maui#36837](https://github.com/dotnet/maui/pull/36837) from the +[`release/11.0.1xx-preview7`](https://github.com/dotnet/maui/tree/release/11.0.1xx-preview7) +branch. See the [.NET 11 preview release notes](https://github.com/dotnet/core/tree/main/release-notes/11.0/preview) +and [.NET MAUI releases](https://github.com/dotnet/maui/releases) for the corresponding Preview 7 release +notes when published. diff --git a/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml new file mode 100644 index 000000000..2d9d9bd0d --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml.cs b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml.cs new file mode 100644 index 000000000..56366119d --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/App.xaml.cs @@ -0,0 +1,15 @@ +namespace Passkeys.Client; + +public partial class App : Application +{ + readonly MainPage mainPage; + + public App(MainPage mainPage) + { + InitializeComponent(); + this.mainPage = mainPage; + } + + protected override Window CreateWindow(IActivationState? activationState) => + new(mainPage) { Title = "Passkeys" }; +} diff --git a/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/MainPage.xaml b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/MainPage.xaml new file mode 100644 index 000000000..5ba994fb0 --- /dev/null +++ b/11.0/PlatformIntegration/Passkeys/src/Passkeys.Client/MainPage.xaml @@ -0,0 +1,107 @@ + + + + + +