diff --git a/README.md b/README.md index 513a5c4..03d9474 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,116 @@ When using standard [ASP.NET cookie authentication](https://docs.microsoft.com/e } ``` +- Similarly, the `MobileAuthInitController` generates a challenge nonce and returns the mobile deep-link for starting the Web eID Mobile authentication flow, and the `MobileAuthLoginController` handles the mobile login request by validating the returned authentication token and creating the authentication cookie. + ```cs + using System; + using System.Text; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Options; + using System.Text.Json; + using System.Text.Json.Serialization; + using Options; + using Security.Challenge; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthInitController( + IChallengeNonceGenerator nonceGenerator, + IOptions mobileOptions + ) : ControllerBase + { + private const string WebEidMobileAuthPath = "auth"; + private const string MobileLoginPath = "/auth/mobile/login"; + + [HttpPost("init")] + public IActionResult Init() + { + var challenge = nonceGenerator.GenerateAndStoreNonce(TimeSpan.FromMinutes(5)); + var challengeBase64 = challenge.Base64EncodedNonce; + + var loginUri = $"{Request.Scheme}://{Request.Host}{MobileLoginPath}"; + + var payload = new AuthPayload + { + Challenge = challengeBase64, + LoginUri = loginUri, + GetSigningCertificate = mobileOptions.Value.RequestSigningCert ? true : null + }; + + var json = JsonSerializer.Serialize(payload); + var encodedPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + var authUri = BuildAuthUri(encodedPayload); + + return Ok(new AuthUri + { + AuthUriValue = authUri + }); + } + ``` + + ```cs + using Microsoft.AspNetCore.Mvc; + using System.Text.Json; + using Dto; + using Security.Challenge; + using Security.Validator; + using System.Security.Claims; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + using Security.Util; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthLoginController( + IAuthTokenValidator authTokenValidator, + IChallengeNonceStore challengeNonceStore + ) : ControllerBase + { + [HttpPost("login")] + public async Task MobileLogin([FromBody] AuthenticateRequestDto dto) + { + if (dto?.AuthToken == null) + { + return BadRequest(new { error = "Missing auth_token" }); + } + + var parsedToken = dto.AuthToken; + var certificate = await authTokenValidator.Validate( + parsedToken, + challengeNonceStore.GetAndRemove().Base64EncodedNonce); + + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + + identity.AddClaim(new Claim(ClaimTypes.GivenName, certificate.GetSubjectGivenName())); + identity.AddClaim(new Claim(ClaimTypes.Surname, certificate.GetSubjectSurname())); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, certificate.GetSubjectIdCode())); + identity.AddClaim(new Claim(ClaimTypes.Name, certificate.GetSubjectCn())); + + if (!string.IsNullOrEmpty(parsedToken.UnverifiedSigningCertificate)) + { + identity.AddClaim(new Claim("signingCertificate", parsedToken.UnverifiedSigningCertificate)); + } + + if (parsedToken.SupportedSignatureAlgorithms != null) + { + identity.AddClaim(new Claim( + "supportedSignatureAlgorithms", + JsonSerializer.Serialize(parsedToken.SupportedSignatureAlgorithms))); + } + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = false }); + + return Ok(new { redirect = "/welcome" }); + } + } + ``` + + # Table of contents * [Introduction](#introduction) diff --git a/example/README.md b/example/README.md index 24cf3fe..83d046d 100644 --- a/example/README.md +++ b/example/README.md @@ -130,6 +130,7 @@ The `src\WebEid.AspNetCore.Example` directory contains the ASP.NET application s - `DigiDoc`: contains the C# binding files of the `libdigidocpp` library; these files must be copied from the `libdigidocpp` installation directory `\include\digidocpp_csharp`, - `Pages`: Razor pages, - `Services`: Web eID signing service implementation that uses `libdigidocpp`. +- `Options`: strongly-typed configuration classes for mobile Web eID settings such as `BaseRequestUri` and `RequestSigningCert` (when set to false, initiates a separate signing-certificate flow to demo requesting the certificate without prior authentication, as the signing certificate normally comes from the authentication flow). ## More information diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs new file mode 100644 index 0000000..dc7d09c --- /dev/null +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthInitController.cs @@ -0,0 +1,98 @@ +// Copyright (c) 2025-2025 Estonian Information System Authority +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +namespace WebEid.AspNetCore.Example.Controllers.Api +{ + using System; + using System.Text; + using Microsoft.AspNetCore.Mvc; + using Microsoft.Extensions.Options; + using System.Text.Json; + using System.Text.Json.Serialization; + using Options; + using Security.Challenge; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthInitController( + IChallengeNonceGenerator nonceGenerator, + IOptions mobileOptions + ) : ControllerBase + { + private const string WebEidMobileAuthPath = "auth"; + private const string MobileLoginPath = "/auth/mobile/login"; + + [HttpPost("init")] + public IActionResult Init() + { + var challenge = nonceGenerator.GenerateAndStoreNonce(TimeSpan.FromMinutes(5)); + var challengeBase64 = challenge.Base64EncodedNonce; + + var loginUri = $"{Request.Scheme}://{Request.Host}{MobileLoginPath}"; + + var payload = new AuthPayload + { + Challenge = challengeBase64, + LoginUri = loginUri, + GetSigningCertificate = mobileOptions.Value.RequestSigningCert ? true : null + }; + + var json = JsonSerializer.Serialize(payload); + var encodedPayload = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); + + var authUri = BuildAuthUri(encodedPayload); + + return Ok(new AuthUri + { + AuthUriValue = authUri + }); + } + + private string BuildAuthUri(string encodedPayload) + { + var baseUri = mobileOptions.Value.BaseRequestUri; + + return baseUri.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? $"{baseUri.TrimEnd('/')}/{WebEidMobileAuthPath}#{encodedPayload}" + : $"{baseUri}{WebEidMobileAuthPath}#{encodedPayload}"; + } + + private sealed record AuthPayload + { + [JsonInclude] + [JsonPropertyName("challenge")] + public required string Challenge { get; init; } + + [JsonInclude] + [JsonPropertyName("login_uri")] + public required string LoginUri { get; init; } + + [JsonInclude] + [JsonPropertyName("get_signing_certificate")] + public bool? GetSigningCertificate { get; init; } + } + + private sealed record AuthUri + { + [JsonInclude] + [JsonPropertyName("auth_uri")] + public required string AuthUriValue { get; init; } + } + } +} diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthLoginController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthLoginController.cs new file mode 100644 index 0000000..b12cb26 --- /dev/null +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/MobileAuthLoginController.cs @@ -0,0 +1,80 @@ +// Copyright (c) 2025-2025 Estonian Information System Authority +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +namespace WebEid.AspNetCore.Example.Controllers.Api +{ + using Microsoft.AspNetCore.Mvc; + using System.Text.Json; + using Dto; + using Security.Challenge; + using Security.Validator; + using System.Security.Claims; + using System.Threading.Tasks; + using Microsoft.AspNetCore.Authentication; + using Microsoft.AspNetCore.Authentication.Cookies; + using Security.Util; + + [ApiController] + [Route("auth/mobile")] + public class MobileAuthLoginController( + IAuthTokenValidator authTokenValidator, + IChallengeNonceStore challengeNonceStore + ) : ControllerBase + { + [HttpPost("login")] + public async Task MobileLogin([FromBody] AuthenticateRequestDto dto) + { + if (dto?.AuthToken == null) + { + return BadRequest(new { error = "Missing auth_token" }); + } + + var parsedToken = dto.AuthToken; + var certificate = await authTokenValidator.Validate( + parsedToken, + challengeNonceStore.GetAndRemove().Base64EncodedNonce); + + var identity = new ClaimsIdentity(CookieAuthenticationDefaults.AuthenticationScheme); + + identity.AddClaim(new Claim(ClaimTypes.GivenName, certificate.GetSubjectGivenName())); + identity.AddClaim(new Claim(ClaimTypes.Surname, certificate.GetSubjectSurname())); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, certificate.GetSubjectIdCode())); + identity.AddClaim(new Claim(ClaimTypes.Name, certificate.GetSubjectCn())); + + if (!string.IsNullOrEmpty(parsedToken.UnverifiedSigningCertificate)) + { + identity.AddClaim(new Claim("signingCertificate", parsedToken.UnverifiedSigningCertificate)); + } + + if (parsedToken.SupportedSignatureAlgorithms != null) + { + identity.AddClaim(new Claim( + "supportedSignatureAlgorithms", + JsonSerializer.Serialize(parsedToken.SupportedSignatureAlgorithms))); + } + + await HttpContext.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = false }); + + return Ok(new { redirect = "/welcome" }); + } + } +} \ No newline at end of file diff --git a/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs b/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs index d4f6806..7313a00 100644 --- a/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs +++ b/example/src/WebEid.AspNetCore.Example/Controllers/Api/SignController.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2024 Estonian Information System Authority +// Copyright (c) 2021-2025 Estonian Information System Authority // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -17,15 +17,16 @@ // IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN // CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -namespace WebEid.AspNetCore.Example.Controllers.Api +namespace WebEid.AspNetCore.Example.Controllers.Api { using System; using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; + using Dto; using Services; - using WebEid.AspNetCore.Example.Dto; + using Signing; [Route("[controller]")] [ApiController] @@ -33,11 +34,13 @@ public class SignController : BaseController { private const string SignedFile = "example-for-signing.asice"; private readonly SigningService signingService; + private readonly MobileSigningService mobileSigningService; private readonly ILogger logger; - public SignController(SigningService signingService, ILogger logger) + public SignController(SigningService signingService, MobileSigningService mobileSigningService, ILogger logger) { this.signingService = signingService; + this.mobileSigningService = mobileSigningService; this.logger = logger; } @@ -56,6 +59,49 @@ public FileDto Sign([FromBody] SignatureDto data) return new FileDto(SignedFile); } + [HttpPost("mobile/init")] + public MobileSigningService.MobileInitRequest MobileInit() + { + var identity = (ClaimsIdentity)HttpContext.User.Identity; + var container = GetUserContainerName(); + return mobileSigningService.InitCertificateOrSigningRequest(identity, container); + } + + [Route("sign/mobile/certificate")] + [HttpGet] + public IActionResult CertificateResponse() + { + return Redirect("/sign/mobile/certificate"); + } + + [Route("mobile/certificate")] + [HttpPost] + public MobileSigningService.MobileInitRequest CertificatePost([FromBody] CertificateDto certificateDto) + { + var identity = (ClaimsIdentity)HttpContext.User.Identity; + var containerName = GetUserContainerName(); + + return mobileSigningService.InitSigningRequest( + identity, + certificateDto, + containerName); + } + + [Route("sign/mobile/signature")] + [HttpGet] + public IActionResult SignatureResponse() + { + return Redirect("/sign/mobile/signature"); + } + + [Route("mobile/signature")] + [HttpPost] + public FileDto SignaturePost([FromBody] SignatureDto signatureDto) + { + signingService.SignContainer(signatureDto, GetUserContainerName()); + return new FileDto(SignedFile); + } + [Route("download")] [HttpGet] public async Task Download() diff --git a/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs b/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs index c36726f..6626e04 100644 --- a/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs +++ b/example/src/WebEid.AspNetCore.Example/Dto/AuthenticateRequestDto.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2021-2024 Estonian Information System Authority +// Copyright (c) 2021-2025 Estonian Information System Authority // // Permission is hereby granted, free of charge, to any person obtaining a copy of // this software and associated documentation files (the "Software"), to deal in @@ -24,7 +24,13 @@ public class AuthenticateRequestDto { - [JsonPropertyName("auth-token")] - public WebEidAuthToken AuthToken { get; set; } + // Mobile version uses "auth_token" + [JsonPropertyName("auth_token")] public WebEidAuthToken AuthTokenUnderscore { get; set; } + + // Desktop version uses "auth-token" + [JsonPropertyName("auth-token")] public WebEidAuthToken AuthTokenDash { get; set; } + + // Unified property for backend logic + [JsonIgnore] public WebEidAuthToken AuthToken => AuthTokenDash ?? AuthTokenUnderscore; } } diff --git a/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs b/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs new file mode 100644 index 0000000..a7f2310 --- /dev/null +++ b/example/src/WebEid.AspNetCore.Example/Options/WebEidMobileOptions.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2025-2025 Estonian Information System Authority +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of +// this software and associated documentation files (the "Software"), to deal in +// the Software without restriction, including without limitation the rights to +// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +// the Software, and to permit persons to whom the Software is furnished to do so, +// subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +namespace WebEid.AspNetCore.Example.Options +{ + using System.ComponentModel.DataAnnotations; + + public class WebEidMobileOptions + { + [Required] + [RegularExpression("^.*(?:[^/]|://)$", ErrorMessage = "Base URI must not have a trailing slash")] + public string BaseRequestUri { get; set; } = null!; + + public bool RequestSigningCert { get; set; } + } +} \ No newline at end of file diff --git a/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml b/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml index d25e828..a11aa76 100644 --- a/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml +++ b/example/src/WebEid.AspNetCore.Example/Pages/Index.cshtml @@ -3,115 +3,517 @@ - - + + + + @{ + var tokens = Xsrf.GetAndStoreTokens(HttpContext); + } + + Web eID: electronic ID smart cards on the Web - - + + + + + + + + -
-
-
-

Web eID: electronic ID smart cards on the Web

-

- The Web eID project enables usage of European Union electronic identity (eID) smart cards for - secure authentication and digital signing of documents on the web using public-key cryptography. -

-

- Estonian, Finnish, Latvian, Lithuanian and Croatian eID cards are supported in the first phase, but only - Estonian eID card support is currently enabled in the test application below. -

-

- Please get in touch by email at help@ria.ee in case you need support with adding Web eID to your project - or want to add support for a new eID card to Web eID. -

- -
- -

- More information about the Web eID project, including installation and usage instructions - is available on the project [website](https://web-eid.eu/). -

-

Click Authenticate below to test authentication and digital signing.

- -