12 |
13 | IMPORTANT NOTE: The code in this repository is not production-ready. It serves only to demonstrate the
14 | main points via minimal working code, and contains no exception handling or other special cases. Refer to the
15 | official documentation and samples for more information. Similarly, by design, it does not implement any
16 | caching or data persistence (e.g. to a database) to minimize the concepts and technologies being used.
17 |
18 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Constants.cs:
--------------------------------------------------------------------------------
1 | namespace DelegatedUserManagement.WebApp
2 | {
3 | public static class Constants
4 | {
5 | public static class ClaimTypes
6 | {
7 | public const string ObjectId = "oid";
8 | }
9 |
10 | public static class UserAttributes
11 | {
12 | public const string DelegatedUserManagementRole = nameof(DelegatedUserManagementRole);
13 | public const string InvitationCode = nameof(InvitationCode);
14 | public const string CompanyId = nameof(CompanyId);
15 | }
16 |
17 | public static class DelegatedUserManagementRoles
18 | {
19 | public const string GlobalAdmin = nameof(GlobalAdmin);
20 | public const string CompanyAdmin = nameof(CompanyAdmin);
21 | public const string CompanyUser = nameof(CompanyUser);
22 | }
23 | }
24 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Pages/Error.cshtml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Diagnostics;
4 | using System.Linq;
5 | using System.Threading.Tasks;
6 | using Microsoft.AspNetCore.Mvc;
7 | using Microsoft.AspNetCore.Mvc.RazorPages;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace AppRoles.WebApp.Pages
11 | {
12 | [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
13 | public class ErrorModel : PageModel
14 | {
15 | public string RequestId { get; set; }
16 |
17 | public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
18 |
19 | private readonly ILogger _logger;
20 |
21 | public ErrorModel(ILogger logger)
22 | {
23 | _logger = logger;
24 | }
25 |
26 | public void OnGet()
27 | {
28 | RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
29 | }
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Pages/Error.cshtml:
--------------------------------------------------------------------------------
1 | @page
2 | @model ErrorModel
3 | @{
4 | ViewData["Title"] = "Error";
5 | }
6 |
7 |
Error.
8 |
An error occurred while processing your request.
9 |
10 | @if (Model.ShowRequestId)
11 | {
12 |
13 | Request ID:@Model.RequestId
14 |
15 | }
16 |
17 |
Development Mode
18 |
19 | Swapping to the Development environment displays detailed information about the error that occurred.
20 |
21 |
22 | The Development environment shouldn't be enabled for deployed applications.
23 | It can result in displaying sensitive information from exceptions to end users.
24 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
25 | and restarting the app.
26 |
19 | Swapping to the Development environment displays detailed information about the error that occurred.
20 |
21 |
22 | The Development environment shouldn't be enabled for deployed applications.
23 | It can result in displaying sensitive information from exceptions to end users.
24 | For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development
25 | and restarting the app.
26 |
14 |
15 | IMPORTANT NOTE: The code in this repository is not production-ready. It serves only to demonstrate the
16 | main points via minimal working code, and contains no exception handling or other special cases. Refer to the
17 | official documentation and samples for more information. Similarly, by design, it does not implement any
18 | caching or data persistence (e.g. to a database) to minimize the concepts and technologies being used.
19 |
20 |
21 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Jelle Druyts
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jelle Druyts
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Pages/Shared/_LoginPartial.cshtml:
--------------------------------------------------------------------------------
1 | @using Microsoft.Extensions.Configuration
2 | @inject IConfiguration Configuration
3 |
4 |
32 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.183.0/containers/dotnet/.devcontainer/base.Dockerfile
2 |
3 | # [Choice] .NET version
4 | ARG VARIANT="7.0-bullseye-slim"
5 | FROM mcr.microsoft.com/devcontainers/dotnet:0-${VARIANT}
6 |
7 | # [Option] Install Node.js
8 | ARG INSTALL_NODE="true"
9 | ARG NODE_VERSION="lts/*"
10 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
11 |
12 | # [Option] Install Azure CLI
13 | ARG INSTALL_AZURE_CLI="false"
14 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/
15 | RUN if [ "$INSTALL_AZURE_CLI" = "true" ]; then bash /tmp/library-scripts/azcli-debian.sh; fi \
16 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
17 |
18 | # [Optional] Uncomment this section to install additional OS packages.
19 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
20 | # && apt-get -y install --no-install-recommends
21 |
22 | # [Optional] Uncomment this line to install global node packages.
23 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.183.0/containers/dotnet/.devcontainer/base.Dockerfile
2 |
3 | # [Choice] .NET version
4 | ARG VARIANT="7.0-bullseye-slim"
5 | FROM mcr.microsoft.com/devcontainers/dotnet:0-${VARIANT}
6 |
7 | # [Option] Install Node.js
8 | ARG INSTALL_NODE="true"
9 | ARG NODE_VERSION="lts/*"
10 | RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "umask 0002 && . /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
11 |
12 | # [Option] Install Azure CLI
13 | ARG INSTALL_AZURE_CLI="false"
14 | COPY library-scripts/*.sh library-scripts/*.env /tmp/library-scripts/
15 | RUN if [ "$INSTALL_AZURE_CLI" = "true" ]; then bash /tmp/library-scripts/azcli-debian.sh; fi \
16 | && apt-get clean -y && rm -rf /var/lib/apt/lists/* /tmp/library-scripts
17 |
18 | # [Optional] Uncomment this section to install additional OS packages.
19 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
20 | # && apt-get -y install --no-install-recommends
21 |
22 | # [Optional] Uncomment this line to install global node packages.
23 | # RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Services/StringSplitClaimTransformation.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Linq;
3 | using System.Security.Claims;
4 | using System.Threading.Tasks;
5 | using Microsoft.AspNetCore.Authentication;
6 |
7 | namespace AppRoles.WebApp.Services
8 | {
9 | public class StringSplitClaimsTransformation : IClaimsTransformation
10 | {
11 | private readonly string claimType;
12 |
13 | public StringSplitClaimsTransformation(string claimType)
14 | {
15 | this.claimType = claimType;
16 | }
17 |
18 | public Task TransformAsync(ClaimsPrincipal principal)
19 | {
20 | // Find all claims of the requested claim type, split their values by spaces
21 | // and then take the ones that aren't yet on the principal individually.
22 | var claims = principal.FindAll(this.claimType)
23 | .SelectMany(c => c.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries))
24 | .Where(s => !principal.HasClaim(this.claimType, s)).ToList();
25 |
26 | // Add all new claims to the principal's identity.
27 | ((ClaimsIdentity)principal.Identity).AddClaims(claims.Select(s => new Claim(this.claimType, s)));
28 | return Task.FromResult(principal);
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "label": "build",
6 | "command": "dotnet",
7 | "type": "process",
8 | "args": [
9 | "build",
10 | "${workspaceFolder}/AppRoles.WebApp/AppRoles.WebApp.csproj",
11 | "/property:GenerateFullPaths=true",
12 | "/consoleloggerparameters:NoSummary"
13 | ],
14 | "problemMatcher": "$msCompile",
15 | "group": {
16 | "kind": "build",
17 | "isDefault": true
18 | }
19 | },
20 | {
21 | "label": "publish",
22 | "command": "dotnet",
23 | "type": "process",
24 | "args": [
25 | "publish",
26 | "${workspaceFolder}/AppRoles.WebApp/AppRoles.WebApp.csproj",
27 | "/property:GenerateFullPaths=true",
28 | "/consoleloggerparameters:NoSummary"
29 | ],
30 | "problemMatcher": "$msCompile"
31 | },
32 | {
33 | "label": "watch",
34 | "command": "dotnet",
35 | "type": "process",
36 | "args": [
37 | "watch",
38 | "run",
39 | "${workspaceFolder}/AppRoles.WebApp/AppRoles.WebApp.csproj",
40 | "/property:GenerateFullPaths=true",
41 | "/consoleloggerparameters:NoSummary"
42 | ],
43 | "problemMatcher": "$msCompile"
44 | }
45 | ]
46 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | // Use IntelliSense to find out which attributes exist for C# debugging
6 | // Use hover for the description of the existing attributes
7 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
8 | "name": ".NET Core Launch (web)",
9 | "type": "coreclr",
10 | "request": "launch",
11 | "preLaunchTask": "build",
12 | // If you have changed target frameworks, make sure to update the program path.
13 | "program": "${workspaceFolder}/AppRoles.WebApp/bin/Debug/net7.0/AppRoles.WebApp.dll",
14 | "args": [],
15 | "cwd": "${workspaceFolder}/AppRoles.WebApp",
16 | "console": "integratedTerminal",
17 | "stopAtEntry": false,
18 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
19 | "serverReadyAction": {
20 | "action": "openExternally",
21 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
22 | },
23 | "env": {
24 | "ASPNETCORE_ENVIRONMENT": "Development"
25 | },
26 | "sourceFileMap": {
27 | "/Views": "${workspaceFolder}/AppRoles.WebApp/Views"
28 | }
29 | },
30 | {
31 | "name": ".NET Core Attach",
32 | "type": "coreclr",
33 | "request": "attach"
34 | }
35 | ]
36 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/wwwroot/css/site.css:
--------------------------------------------------------------------------------
1 | /* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 | for details on configuring this project to bundle and minify static web assets. */
3 |
4 | a.navbar-brand {
5 | white-space: normal;
6 | text-align: center;
7 | word-break: break-all;
8 | }
9 |
10 | /* Provide sufficient contrast against white background */
11 | a {
12 | color: #0366d6;
13 | }
14 |
15 | .btn-primary {
16 | color: #fff;
17 | background-color: #1b6ec2;
18 | border-color: #1861ac;
19 | }
20 |
21 | .nav-pills .nav-link.active, .nav-pills .show > .nav-link {
22 | color: #fff;
23 | background-color: #1b6ec2;
24 | border-color: #1861ac;
25 | }
26 |
27 | /* Sticky footer styles
28 | -------------------------------------------------- */
29 | html {
30 | font-size: 14px;
31 | }
32 | @media (min-width: 768px) {
33 | html {
34 | font-size: 16px;
35 | }
36 | }
37 |
38 | .border-top {
39 | border-top: 1px solid #e5e5e5;
40 | }
41 | .border-bottom {
42 | border-bottom: 1px solid #e5e5e5;
43 | }
44 |
45 | .box-shadow {
46 | box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
47 | }
48 |
49 | button.accept-policy {
50 | font-size: 1rem;
51 | line-height: inherit;
52 | }
53 |
54 | /* Sticky footer styles
55 | -------------------------------------------------- */
56 | html {
57 | position: relative;
58 | min-height: 100%;
59 | }
60 |
61 | body {
62 | /* Margin bottom by footer height */
63 | margin-bottom: 60px;
64 | }
65 | .footer {
66 | position: absolute;
67 | bottom: 0;
68 | width: 100%;
69 | white-space: nowrap;
70 | line-height: 60px; /* Vertically center the text there */
71 | }
72 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to find out which attributes exist for C# debugging
3 | // Use hover for the description of the existing attributes
4 | // For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": ".NET Core Launch (web)",
9 | "type": "coreclr",
10 | "request": "launch",
11 | "preLaunchTask": "build",
12 | // If you have changed target frameworks, make sure to update the program path.
13 | "program": "${workspaceFolder}/DelegatedUserManagement.WebApp/bin/Debug/net7.0/DelegatedUserManagement.WebApp.dll",
14 | "args": [],
15 | "cwd": "${workspaceFolder}/DelegatedUserManagement.WebApp",
16 | "stopAtEntry": false,
17 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser
18 | "serverReadyAction": {
19 | "action": "openExternally",
20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)"
21 | },
22 | "env": {
23 | "ASPNETCORE_ENVIRONMENT": "Development"
24 | },
25 | "sourceFileMap": {
26 | "/Views": "${workspaceFolder}/DelegatedUserManagement.WebApp/Views"
27 | }
28 | },
29 | {
30 | "name": ".NET Core Attach",
31 | "type": "coreclr",
32 | "request": "attach",
33 | "processId": "${command:pickProcess}"
34 | }
35 | ]
36 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/.devcontainer/library-scripts/azcli-debian.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #-------------------------------------------------------------------------------------------------------------
3 | # Copyright (c) Microsoft Corporation. All rights reserved.
4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
5 | #-------------------------------------------------------------------------------------------------------------
6 | #
7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/azcli.md
8 | # Maintainer: The VS Code and Codespaces Teams
9 | #
10 | # Syntax: ./azcli-debian.sh
11 |
12 | set -e
13 |
14 | if [ "$(id -u)" -ne 0 ]; then
15 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
16 | exit 1
17 | fi
18 |
19 | export DEBIAN_FRONTEND=noninteractive
20 |
21 | # Install curl, apt-transport-https, lsb-release, or gpg if missing
22 | if ! dpkg -s apt-transport-https curl ca-certificates lsb-release > /dev/null 2>&1 || ! type gpg > /dev/null 2>&1; then
23 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
24 | apt-get update
25 | fi
26 | apt-get -y install --no-install-recommends apt-transport-https curl ca-certificates lsb-release gnupg2
27 | fi
28 |
29 | # Install the Azure CLI
30 | echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list
31 | curl -sL https://packages.microsoft.com/keys/microsoft.asc | (OUT=$(apt-key add - 2>&1) || echo $OUT)
32 | apt-get update
33 | apt-get install -y azure-cli
34 | echo "Done!"
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/wwwroot/css/site.css:
--------------------------------------------------------------------------------
1 | /* Please see documentation at https://docs.microsoft.com/aspnet/core/client-side/bundling-and-minification
2 | for details on configuring this project to bundle and minify static web assets. */
3 |
4 | a.navbar-brand {
5 | white-space: normal;
6 | text-align: center;
7 | word-break: break-all;
8 | }
9 |
10 | /* Provide sufficient contrast against white background */
11 | a {
12 | color: #0366d6;
13 | }
14 |
15 | .btn-primary {
16 | color: #fff;
17 | background-color: #1b6ec2;
18 | border-color: #1861ac;
19 | }
20 |
21 | .nav-pills .nav-link.active, .nav-pills .show > .nav-link {
22 | color: #fff;
23 | background-color: #1b6ec2;
24 | border-color: #1861ac;
25 | }
26 |
27 | /* Sticky footer styles
28 | -------------------------------------------------- */
29 | html {
30 | font-size: 14px;
31 | }
32 | @media (min-width: 768px) {
33 | html {
34 | font-size: 16px;
35 | }
36 | }
37 |
38 | .border-top {
39 | border-top: 1px solid #e5e5e5;
40 | }
41 | .border-bottom {
42 | border-bottom: 1px solid #e5e5e5;
43 | }
44 |
45 | .box-shadow {
46 | box-shadow: 0 .25rem .75rem rgba(0, 0, 0, .05);
47 | }
48 |
49 | button.accept-policy {
50 | font-size: 1rem;
51 | line-height: inherit;
52 | }
53 |
54 | /* Sticky footer styles
55 | -------------------------------------------------- */
56 | html {
57 | position: relative;
58 | min-height: 100%;
59 | }
60 |
61 | body {
62 | /* Margin bottom by footer height */
63 | margin-bottom: 60px;
64 | }
65 | .footer {
66 | position: absolute;
67 | bottom: 0;
68 | width: 100%;
69 | white-space: nowrap;
70 | line-height: 60px; /* Vertically center the text there */
71 | }
72 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/.devcontainer/library-scripts/azcli-debian.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | #-------------------------------------------------------------------------------------------------------------
3 | # Copyright (c) Microsoft Corporation. All rights reserved.
4 | # Licensed under the MIT License. See https://go.microsoft.com/fwlink/?linkid=2090316 for license information.
5 | #-------------------------------------------------------------------------------------------------------------
6 | #
7 | # Docs: https://github.com/microsoft/vscode-dev-containers/blob/main/script-library/docs/azcli.md
8 | # Maintainer: The VS Code and Codespaces Teams
9 | #
10 | # Syntax: ./azcli-debian.sh
11 |
12 | set -e
13 |
14 | if [ "$(id -u)" -ne 0 ]; then
15 | echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
16 | exit 1
17 | fi
18 |
19 | export DEBIAN_FRONTEND=noninteractive
20 |
21 | # Install curl, apt-transport-https, lsb-release, or gpg if missing
22 | if ! dpkg -s apt-transport-https curl ca-certificates lsb-release > /dev/null 2>&1 || ! type gpg > /dev/null 2>&1; then
23 | if [ ! -d "/var/lib/apt/lists" ] || [ "$(ls /var/lib/apt/lists/ | wc -l)" = "0" ]; then
24 | apt-get update
25 | fi
26 | apt-get -y install --no-install-recommends apt-transport-https curl ca-certificates lsb-release gnupg2
27 | fi
28 |
29 | # Install the Azure CLI
30 | echo "deb [arch=amd64] https://packages.microsoft.com/repos/azure-cli/ $(lsb_release -cs) main" > /etc/apt/sources.list.d/azure-cli.list
31 | curl -sL https://packages.microsoft.com/keys/microsoft.asc | (OUT=$(apt-key add - 2>&1) || echo $OUT)
32 | apt-get update
33 | apt-get install -y azure-cli
34 | echo "Done!"
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "label": "build",
8 | "command": "dotnet",
9 | "type": "shell",
10 | "args": [
11 | "build",
12 | "/property:GenerateFullPaths=true",
13 | "/consoleloggerparameters:NoSummary"
14 | ],
15 | "group": {
16 | "kind": "build",
17 | "isDefault": true
18 | },
19 | "presentation": {
20 | "reveal": "silent"
21 | },
22 | "problemMatcher": "$msCompile"
23 | },
24 | {
25 | "label": "clean",
26 | "command": "dotnet",
27 | "type": "process",
28 | "args": [
29 | "clean",
30 | "${workspaceFolder}/DelegatedUserManagement.WebApp",
31 | "/property:GenerateFullPaths=true",
32 | "/consoleloggerparameters:NoSummary"
33 | ],
34 | "problemMatcher": "$msCompile"
35 | },
36 | {
37 | "label": "publish-release",
38 | "command": "dotnet",
39 | "type": "process",
40 | "args": [
41 | "publish",
42 | "${workspaceFolder}/DelegatedUserManagement.WebApp",
43 | "--configuration",
44 | "Release",
45 | "/property:GenerateFullPaths=true",
46 | "/consoleloggerparameters:NoSummary"
47 | ],
48 | "problemMatcher": "$msCompile",
49 | "dependsOn": "clean"
50 | }
51 | ]
52 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/IdentitySamplesB2C-DelegatedUserManagement.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio 15
4 | VisualStudioVersion = 15.0.26124.0
5 | MinimumVisualStudioVersion = 15.0.26124.0
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DelegatedUserManagement.WebApp", "DelegatedUserManagement.WebApp\DelegatedUserManagement.WebApp.csproj", "{34D0D033-B71C-464E-AF42-223622E36687}"
7 | EndProject
8 | Global
9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
10 | Debug|Any CPU = Debug|Any CPU
11 | Debug|x64 = Debug|x64
12 | Debug|x86 = Debug|x86
13 | Release|Any CPU = Release|Any CPU
14 | Release|x64 = Release|x64
15 | Release|x86 = Release|x86
16 | EndGlobalSection
17 | GlobalSection(SolutionProperties) = preSolution
18 | HideSolutionNode = FALSE
19 | EndGlobalSection
20 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
21 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
22 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|Any CPU.Build.0 = Debug|Any CPU
23 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|x64.ActiveCfg = Debug|Any CPU
24 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|x64.Build.0 = Debug|Any CPU
25 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|x86.ActiveCfg = Debug|Any CPU
26 | {34D0D033-B71C-464E-AF42-223622E36687}.Debug|x86.Build.0 = Debug|Any CPU
27 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|Any CPU.ActiveCfg = Release|Any CPU
28 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|Any CPU.Build.0 = Release|Any CPU
29 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|x64.ActiveCfg = Release|Any CPU
30 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|x64.Build.0 = Release|Any CPU
31 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|x86.ActiveCfg = Release|Any CPU
32 | {34D0D033-B71C-464E-AF42-223622E36687}.Release|x86.Build.0 = Release|Any CPU
33 | EndGlobalSection
34 | EndGlobal
35 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Pages/Identity.cshtml:
--------------------------------------------------------------------------------
1 | @page
2 | @model IdentityModel
3 | @inject AppRolesOptions AppRolesOptions
4 | @{
5 | ViewData["Title"] = "Identity";
6 | }
7 |
App Roles
8 |
9 | These are all the app roles you have for this application (based on the @AppRolesOptions.UserAttributeName claims).
10 |
11 |
12 | Note that the @AppRolesOptions.UserAttributeName claim can occur multiple times even though Azure AD B2C emits
13 | app roles as a single (space-separated) value; this is because the application splits it into multiple claim values to make
14 | it easier to consume the app roles.
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # api-connector-samples
2 | This is a community maintained collection of samples for scenarios enabled by API connectors for Azure AD B2C 'built-in' user flows.
3 |
4 | ## Overview of API connectors feature
5 |
6 | As a developer or IT administrator, you can use API connectors to integrate your sign-up and sign-in user flows with web APIs to customize the user experience. For example, with API connectors, you can:
7 |
8 | - **Validate user input data**. Validate against malformed or invalid user data. For example, you can validate user-provided data against existing data in an external data store or list of permitted values. If invalid, you can ask a user to provide valid data or block the user from continuing the sign-up flow.
9 | - **Integrate with a custom approval workflow**. Connect to a custom approval system for managing and limiting account creation.
10 | - **Overwrite user attributes**. Reformat or assign a value to an attribute collected from the user. For example, if a user enters the first name in all lowercase or all uppercase letters, you can format the name with only the first letter capitalized.
11 | - **Perform identity verification**. Use an identity verification service to add an extra level of security to account creation decisions.
12 | - **Run custom business logic**. You can trigger downstream events in your cloud systems to send push notifications, update corporate databases, manage permissions, audit databases, and perform other custom actions.
13 | - **Augment tokens**. Enrich tokens for your sign-in and sign-up user flows with attributes from legacy identity systems, custom data stores, and other cloud services.
14 |
15 | ## Microsoft documentation
16 |
17 | - [Overview](https://docs.microsoft.com/azure/active-directory-b2c/api-connectors-overview)
18 | - [Add an API connector](https://docs.microsoft.com/azure/active-directory-b2c/add-api-connector)
19 | - [Official quickstarts and samples](https://docs.microsoft.com/azure/active-directory-b2c/code-samples#api-connectors)
20 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Pages/Shared/_Layout.cshtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | @ViewData["Title"] - Authorization using App Roles
7 |
8 |
9 |
10 |
11 |
12 |
31 |
32 |
33 |
34 | @RenderBody()
35 |
36 |
37 |
38 |
39 |
40 |
41 | @RenderSection("Scripts", required: false)
42 |
43 |
44 |
--------------------------------------------------------------------------------
/Authorization-AppRoles/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.183.0/containers/dotnet
3 | {
4 | "name": "C# (.NET)",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "args": {
8 | // Update 'VARIANT' to pick a .NET Core version: 3.1, 6.0, 7.0
9 | // Append -bullseye or -focal to pin to an OS version.
10 | "VARIANT": "7.0",
11 | // Options
12 | "INSTALL_NODE": "false",
13 | "NODE_VERSION": "lts/*",
14 | "INSTALL_AZURE_CLI": "true"
15 | }
16 | },
17 |
18 | // Set *default* container specific settings.json values on container create.
19 | "settings": {},
20 |
21 | // Add the IDs of extensions you want installed when the container is created.
22 | "extensions": [
23 | "ms-dotnettools.csharp",
24 | "azureadb2ctools.aadb2c",
25 | "ms-vscode.azure-account",
26 | "humao.rest-client"
27 | ],
28 |
29 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
30 | "forwardPorts": [5000, 5001],
31 |
32 | // [Optional] To reuse of your local HTTPS dev cert:
33 | //
34 | // 1. Export it locally using this command:
35 | // * Windows PowerShell:
36 | // dotnet dev-certs https --trust; dotnet dev-certs https -ep "$env:USERPROFILE/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
37 | // * macOS/Linux terminal:
38 | // dotnet dev-certs https --trust; dotnet dev-certs https -ep "${HOME}/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
39 | //
40 | // 2. Uncomment these 'remoteEnv' lines:
41 | // "remoteEnv": {
42 | // "ASPNETCORE_Kestrel__Certificates__Default__Password": "SecurePwdGoesHere",
43 | // "ASPNETCORE_Kestrel__Certificates__Default__Path": "/home/vscode/.aspnet/https/aspnetapp.pfx",
44 | // },
45 | //
46 | // 3. Do one of the following depending on your scenario:
47 | // * When using GitHub Codespaces and/or Remote - Containers:
48 | // 1. Start the container
49 | // 2. Drag ~/.aspnet/https/aspnetapp.pfx into the root of the file explorer
50 | // 3. Open a terminal in VS Code and run "mkdir -p /home/vscode/.aspnet/https && mv aspnetapp.pfx /home/vscode/.aspnet/https"
51 | //
52 | // * If only using Remote - Containers with a local container, uncomment this line instead:
53 | // "mounts": [ "source=${env:HOME}${env:USERPROFILE}/.aspnet/https,target=/home/vscode/.aspnet/https,type=bind" ],
54 |
55 | // Use 'postCreateCommand' to run commands after the container is created.
56 | "postCreateCommand": "dotnet restore",
57 |
58 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
59 | "remoteUser": "vscode"
60 | }
61 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.183.0/containers/dotnet
3 | {
4 | "name": "C# (.NET)",
5 | "build": {
6 | "dockerfile": "Dockerfile",
7 | "args": {
8 | // Update 'VARIANT' to pick a .NET Core version: 3.1, 6.0, 7.0
9 | // Append -bullseye or -focal to pin to an OS version.
10 | "VARIANT": "7.0",
11 | // Options
12 | "INSTALL_NODE": "false",
13 | "NODE_VERSION": "lts/*",
14 | "INSTALL_AZURE_CLI": "true"
15 | }
16 | },
17 |
18 | // Set *default* container specific settings.json values on container create.
19 | "settings": {},
20 |
21 | // Add the IDs of extensions you want installed when the container is created.
22 | "extensions": [
23 | "ms-dotnettools.csharp",
24 | "azureadb2ctools.aadb2c",
25 | "ms-vscode.azure-account",
26 | "humao.rest-client"
27 | ],
28 |
29 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
30 | "forwardPorts": [5000, 5001],
31 |
32 | // [Optional] To reuse of your local HTTPS dev cert:
33 | //
34 | // 1. Export it locally using this command:
35 | // * Windows PowerShell:
36 | // dotnet dev-certs https --trust; dotnet dev-certs https -ep "$env:USERPROFILE/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
37 | // * macOS/Linux terminal:
38 | // dotnet dev-certs https --trust; dotnet dev-certs https -ep "${HOME}/.aspnet/https/aspnetapp.pfx" -p "SecurePwdGoesHere"
39 | //
40 | // 2. Uncomment these 'remoteEnv' lines:
41 | // "remoteEnv": {
42 | // "ASPNETCORE_Kestrel__Certificates__Default__Password": "SecurePwdGoesHere",
43 | // "ASPNETCORE_Kestrel__Certificates__Default__Path": "/home/vscode/.aspnet/https/aspnetapp.pfx",
44 | // },
45 | //
46 | // 3. Do one of the following depending on your scenario:
47 | // * When using GitHub Codespaces and/or Remote - Containers:
48 | // 1. Start the container
49 | // 2. Drag ~/.aspnet/https/aspnetapp.pfx into the root of the file explorer
50 | // 3. Open a terminal in VS Code and run "mkdir -p /home/vscode/.aspnet/https && mv aspnetapp.pfx /home/vscode/.aspnet/https"
51 | //
52 | // * If only using Remote - Containers with a local container, uncomment this line instead:
53 | // "mounts": [ "source=${env:HOME}${env:USERPROFILE}/.aspnet/https,target=/home/vscode/.aspnet/https,type=bind" ],
54 |
55 | // Use 'postCreateCommand' to run commands after the container is created.
56 | "postCreateCommand": "dotnet restore",
57 |
58 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
59 | "remoteUser": "vscode"
60 | }
61 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Pages/Shared/_Layout.cshtml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | @ViewData["Title"] - Delegated User Management
7 |
8 |
9 |
10 |
11 |
12 |
34 |
35 |
14 | Note that making any changes to user information will write them immediately to the directory
15 | through the Graph API; but to see the new values reflected in the token (and as a consequence
16 | in the application), the user will have to sign out and back in.
17 |
18 |
19 |
20 |
21 |
22 | @if (Model.CanSelectCompany)
23 | {
24 |
Company
25 | }
26 |
Role
27 |
Name
28 |
Invitation Code
29 |
30 |
31 |
32 | @foreach (var user in Model.Users)
33 | {
34 |
35 |
36 |
41 |
42 | @if (Model.CanSelectCompany)
43 | {
44 |
45 |
46 |
47 | }
48 |
49 |
57 |
58 |
59 |
60 |
61 |
62 |
@user.InvitationCode
63 |
64 |
65 | }
66 |
67 |
68 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Services/FileStorageUserInvitationRepository.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.IO;
4 | using System.Text.Json;
5 | using System.Threading.Tasks;
6 |
7 | namespace DelegatedUserManagement.WebApp
8 | {
9 | // Stores user invitations in files; in real production scenarios you would likely use a database for this.
10 | public class FileStorageUserInvitationRepository : IUserInvitationRepository
11 | {
12 | private readonly string basePath;
13 |
14 | public FileStorageUserInvitationRepository(string basePath)
15 | {
16 | if (string.IsNullOrWhiteSpace(basePath))
17 | {
18 | throw new ArgumentNullException(nameof(basePath));
19 | }
20 |
21 | // Ensure that the directory exists.
22 | this.basePath = basePath;
23 | Directory.CreateDirectory(this.basePath);
24 | }
25 |
26 | public async Task CreateUserInvitationAsync(UserInvitation userInvitation)
27 | {
28 | var contents = JsonSerializer.Serialize(userInvitation);
29 | await File.WriteAllTextAsync(GetUserInvitationFileName(userInvitation.InvitationCode), contents);
30 | }
31 |
32 | public Task GetPendingUserInvitationAsync(string invitationCode)
33 | {
34 | return GetUserInvitationAsync(GetUserInvitationFileName(invitationCode));
35 | }
36 |
37 | public Task RedeemUserInvitationAsync(string invitationCode)
38 | {
39 | File.Delete(GetUserInvitationFileName(invitationCode));
40 | return Task.CompletedTask;
41 | }
42 |
43 | public Task DeletePendingUserInvitationAsync(string invitationCode)
44 | {
45 | File.Delete(GetUserInvitationFileName(invitationCode));
46 | return Task.CompletedTask;
47 | }
48 |
49 | public async Task> GetPendingUserInvitationsAsync(string companyId = null)
50 | {
51 | var userInvitations = new List();
52 | foreach (var fileName in Directory.EnumerateFiles(this.basePath))
53 | {
54 | var userInvitation = await GetUserInvitationAsync(fileName);
55 | if (string.IsNullOrWhiteSpace(companyId) || string.Equals(userInvitation.CompanyId, companyId, StringComparison.InvariantCultureIgnoreCase))
56 | {
57 | userInvitations.Add(userInvitation);
58 | }
59 | }
60 | return userInvitations;
61 | }
62 |
63 | private async Task GetUserInvitationAsync(string fileName)
64 | {
65 | if (!File.Exists(fileName))
66 | {
67 | return null;
68 | }
69 | var contents = await File.ReadAllTextAsync(fileName);
70 | return JsonSerializer.Deserialize(contents);
71 | }
72 |
73 | private string GetUserInvitationFileName(string invitationCode)
74 | {
75 | if (string.IsNullOrWhiteSpace(invitationCode))
76 | {
77 | throw new ArgumentNullException(nameof(invitationCode));
78 | }
79 | return Path.Combine(this.basePath, invitationCode + ".json");
80 | }
81 | }
82 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/SameSiteCookieExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Builder;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | namespace AppRoles.WebApp
6 | {
7 | // Allows the breaking change for SameSite cookies to be handled more easily.
8 | // See https://docs.microsoft.com/en-us/aspnet/samesite/system-web-samesite and
9 | // https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/.
10 | public static class SameSiteCookieExtensions
11 | {
12 | public static void ConfigureSameSiteCookiePolicy(this IServiceCollection services)
13 | {
14 | services.Configure(options =>
15 | {
16 | options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
17 | options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
18 | options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
19 | });
20 | }
21 |
22 | // Must be called before "UseAuthentication" or anything else that writes cookies.
23 | public static void ApplySameSiteCookiePolicy(this IApplicationBuilder app)
24 | {
25 | app.UseCookiePolicy();
26 | }
27 |
28 | private static void CheckSameSite(HttpContext httpContext, CookieOptions options)
29 | {
30 | if (options.SameSite == SameSiteMode.None)
31 | {
32 | var userAgent = httpContext.Request.Headers[Microsoft.Net.Http.Headers.HeaderNames.UserAgent].ToString();
33 | if (DisallowsSameSiteNone(userAgent))
34 | {
35 | options.SameSite = SameSiteMode.Unspecified;
36 | }
37 | }
38 | }
39 |
40 | private static bool DisallowsSameSiteNone(string userAgent)
41 | {
42 | // Cover all iOS based browsers here. This includes:
43 | // - Safari on iOS 12 for iPhone, iPod Touch, iPad
44 | // - WkWebview on iOS 12 for iPhone, iPod Touch, iPad
45 | // - Chrome on iOS 12 for iPhone, iPod Touch, iPad
46 | // All of which are broken by SameSite=None, because they use the iOS
47 | // networking stack.
48 | if (userAgent.Contains("CPU iPhone OS 12") ||
49 | userAgent.Contains("iPad; CPU OS 12"))
50 | {
51 | return true;
52 | }
53 |
54 | // Cover Mac OS X based browsers that use the Mac OS networking stack.
55 | // This includes:
56 | // - Safari on Mac OS X.
57 | // This does not include:
58 | // - Chrome on Mac OS X
59 | // Because they do not use the Mac OS networking stack.
60 | if (userAgent.Contains("Macintosh; Intel Mac OS X 10_14") &&
61 | userAgent.Contains("Version/") && userAgent.Contains("Safari"))
62 | {
63 | return true;
64 | }
65 |
66 | // Cover Chrome 50-69, because some versions are broken by SameSite=None,
67 | // and none in this range require it.
68 | // Note: this covers some pre-Chromium Edge versions,
69 | // but pre-Chromium Edge does not require SameSite=None.
70 | if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6"))
71 | {
72 | return true;
73 | }
74 |
75 | return false;
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/SameSiteCookieExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Builder;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.Extensions.DependencyInjection;
4 |
5 | namespace DelegatedUserManagement.WebApp
6 | {
7 | // Allows the breaking change for SameSite cookies to be handled more easily.
8 | // See https://docs.microsoft.com/en-us/aspnet/samesite/system-web-samesite and
9 | // https://devblogs.microsoft.com/aspnet/upcoming-samesite-cookie-changes-in-asp-net-and-asp-net-core/.
10 | public static class SameSiteCookieExtensions
11 | {
12 | public static void ConfigureSameSiteCookiePolicy(this IServiceCollection services)
13 | {
14 | services.Configure(options =>
15 | {
16 | options.MinimumSameSitePolicy = SameSiteMode.Unspecified;
17 | options.OnAppendCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
18 | options.OnDeleteCookie = cookieContext => CheckSameSite(cookieContext.Context, cookieContext.CookieOptions);
19 | });
20 | }
21 |
22 | // Must be called before "UseAuthentication" or anything else that writes cookies.
23 | public static void ApplySameSiteCookiePolicy(this IApplicationBuilder app)
24 | {
25 | app.UseCookiePolicy();
26 | }
27 |
28 | private static void CheckSameSite(HttpContext httpContext, CookieOptions options)
29 | {
30 | if (options.SameSite == SameSiteMode.None)
31 | {
32 | var userAgent = httpContext.Request.Headers[Microsoft.Net.Http.Headers.HeaderNames.UserAgent].ToString();
33 | if (DisallowsSameSiteNone(userAgent))
34 | {
35 | options.SameSite = SameSiteMode.Unspecified;
36 | }
37 | }
38 | }
39 |
40 | private static bool DisallowsSameSiteNone(string userAgent)
41 | {
42 | // Cover all iOS based browsers here. This includes:
43 | // - Safari on iOS 12 for iPhone, iPod Touch, iPad
44 | // - WkWebview on iOS 12 for iPhone, iPod Touch, iPad
45 | // - Chrome on iOS 12 for iPhone, iPod Touch, iPad
46 | // All of which are broken by SameSite=None, because they use the iOS
47 | // networking stack.
48 | if (userAgent.Contains("CPU iPhone OS 12") ||
49 | userAgent.Contains("iPad; CPU OS 12"))
50 | {
51 | return true;
52 | }
53 |
54 | // Cover Mac OS X based browsers that use the Mac OS networking stack.
55 | // This includes:
56 | // - Safari on Mac OS X.
57 | // This does not include:
58 | // - Chrome on Mac OS X
59 | // Because they do not use the Mac OS networking stack.
60 | if (userAgent.Contains("Macintosh; Intel Mac OS X 10_14") &&
61 | userAgent.Contains("Version/") && userAgent.Contains("Safari"))
62 | {
63 | return true;
64 | }
65 |
66 | // Cover Chrome 50-69, because some versions are broken by SameSite=None,
67 | // and none in this range require it.
68 | // Note: this covers some pre-Chromium Edge versions,
69 | // but pre-Chromium Edge does not require SameSite=None.
70 | if (userAgent.Contains("Chrome/5") || userAgent.Contains("Chrome/6"))
71 | {
72 | return true;
73 | }
74 |
75 | return false;
76 | }
77 | }
78 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Services/AzureADAppRolesProvider.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 | using Azure.Identity;
6 | using Microsoft.Extensions.Logging;
7 | using Microsoft.Extensions.Options;
8 | using Microsoft.Graph;
9 |
10 | namespace AppRoles.WebApp.Services
11 | {
12 | public class AzureADAppRolesProvider : IAppRolesProvider
13 | {
14 | private readonly ILogger logger;
15 | private readonly GraphServiceClient graphClient;
16 |
17 | public AzureADAppRolesProvider(ILogger logger, IOptions options)
18 | {
19 | this.logger = logger;
20 | // Create the Graph client using an app which refers back to the "regular" Azure AD endpoints
21 | // of the B2C directory, i.e. not "tenant.b2clogin.com" but "login.microsoftonline.com/tenant".
22 | // This can then be used to perform Graph API calls using the specified client application's identity
23 | // and client credentials.
24 | var clientSecretCredential = new ClientSecretCredential(options.Value.Domain, options.Value.AzureADAppRolesProviderClientId, options.Value.AzureADAppRolesProviderClientSecret);
25 | this.graphClient = new GraphServiceClient(clientSecretCredential);
26 | }
27 |
28 | public async Task> GetAppRolesAsync(string userId, string appId)
29 | {
30 | // Look up the user's app roles on the requested app.
31 | // This code requires (Application.Read.All + User.Read.All) OR (Directory.Read.All) for the
32 | // client application calling the Graph API.
33 | // In production code, the graph client as well as potentially the service principals of resource apps and perhaps
34 | // even the user's app roles for each resource app should be cached for optimized performance to avoid additional
35 | // requests for each individual user authentication.
36 | this.logger.LogInformation($"Retrieving app roles for user id \"{userId}\" and app id \"{appId}\"");
37 |
38 | // Get the service principal of the resource app that the user is trying to sign in to.
39 | // See https://docs.microsoft.com/en-us/graph/api/serviceprincipal-list.
40 | var servicePrincipalsForResourceApp = await this.graphClient.ServicePrincipals.Request().Filter($"appId eq '{appId}'").GetAsync();
41 | var servicePrincipalForResourceApp = servicePrincipalsForResourceApp.SingleOrDefault();
42 | if (servicePrincipalForResourceApp == null)
43 | {
44 | this.logger.LogError($"The service principal of app \"{appId}\" could not be found; no app roles will be returned.");
45 | throw new ArgumentException($"App roles could not be determined for app \"{appId}\".");
46 | }
47 |
48 | // Get all app role assignments for the given user and resource app service principal.
49 | // See https://docs.microsoft.com/en-us/graph/api/user-list-approleassignments.
50 | var userAppRoleAssignments = await this.graphClient.Users[userId].AppRoleAssignments.Request().Filter($"resourceId eq {servicePrincipalForResourceApp.Id}").GetAsync();
51 | var appRoleIds = userAppRoleAssignments.Select(a => a.AppRoleId).ToArray();
52 | var appRoles = servicePrincipalForResourceApp.AppRoles.Where(a => appRoleIds.Contains(a.Id)).Select(a => a.Value).ToArray();
53 |
54 | this.logger.LogInformation($"Retrieved app roles for user id \"{userId}\" and app id \"{appId}\": {string.Join(' ', appRoles)}");
55 | return appRoles;
56 | }
57 | }
58 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Controllers/AppRolesController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Text.Json;
5 | using System.Threading.Tasks;
6 | using AppRoles.WebApp.Services;
7 | using Microsoft.AspNetCore.Mvc;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace AppRoles.WebApp.Controllers
11 | {
12 | [Route("api/[controller]")]
13 | [ApiController]
14 | public class AppRolesController : ControllerBase
15 | {
16 | private readonly ILogger logger;
17 | private readonly IAppRolesProvider appRolesProvider;
18 | private readonly AppRolesOptions options;
19 |
20 | public AppRolesController(ILogger logger, IAppRolesProvider appRolesProvider, AppRolesOptions options)
21 | {
22 | this.logger = logger;
23 | this.appRolesProvider = appRolesProvider;
24 | this.options = options;
25 | }
26 |
27 | [HttpPost(nameof(GetAppRoles))]
28 | public async Task GetAppRoles([FromBody] JsonElement body)
29 | {
30 | // Azure AD B2C calls into this API when a user is attempting to sign in.
31 | // We expect a JSON object in the HTTP request which contains the input claims.
32 | try
33 | {
34 | this.logger.LogInformation("App roles are being requested.");
35 |
36 | // Log the incoming request body.
37 | logger.LogInformation("Request body:");
38 | logger.LogInformation(JsonSerializer.Serialize(body, new JsonSerializerOptions { WriteIndented = true }));
39 |
40 | // Get the object id of the user that is signing in.
41 | var objectId = body.GetProperty("objectId").GetString();
42 |
43 | // Get the client id of the app that the user is signing in to.
44 | var clientId = body.GetProperty("client_id").GetString();
45 |
46 | // Retrieve the app roles assigned to the user for the requested application.
47 | var appRoles = await this.appRolesProvider.GetAppRolesAsync(objectId, clientId);
48 |
49 | // Custom user attributes in Azure AD B2C cannot be collections, so we emit them
50 | // into a single claim value separated with spaces.
51 | var appRolesValue = (appRoles == null || !appRoles.Any()) ? null : string.Join(' ', appRoles);
52 |
53 | return GetContinueApiResponse("GetAppRoles-Succeeded", "Your app roles were successfully determined.", appRolesValue);
54 | }
55 | catch (Exception exc)
56 | {
57 | this.logger.LogError(exc, "Error while processing request body: " + exc.ToString());
58 | return GetBlockPageApiResponse("GetAppRoles-InternalError", "An error occurred while determining your app roles, please try again later.");
59 | }
60 | }
61 |
62 | private IActionResult GetContinueApiResponse(string code, string userMessage, string appRoles)
63 | {
64 | return GetB2cApiConnectorResponse("Continue", code, userMessage, 200, appRoles);
65 | }
66 |
67 | private IActionResult GetValidationErrorApiResponse(string code, string userMessage)
68 | {
69 | return GetB2cApiConnectorResponse("ValidationError", code, userMessage, 400, null);
70 | }
71 |
72 | private IActionResult GetBlockPageApiResponse(string code, string userMessage)
73 | {
74 | return GetB2cApiConnectorResponse("ShowBlockPage", code, userMessage, 200, null);
75 | }
76 |
77 | private IActionResult GetB2cApiConnectorResponse(string action, string code, string userMessage, int statusCode, string appRoles)
78 | {
79 | var responseProperties = new Dictionary
80 | {
81 | { "version", "1.0.0" },
82 | { "action", action },
83 | { "userMessage", userMessage },
84 | { this.options.UserAttributeName, appRoles }
85 | };
86 | if (statusCode != 200)
87 | {
88 | // Include the status in the body as well, but only for validation errors.
89 | responseProperties["status"] = statusCode.ToString();
90 | }
91 | return new JsonResult(responseProperties) { StatusCode = statusCode };
92 | }
93 | }
94 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/AppRoles.WebApp/Startup.cs:
--------------------------------------------------------------------------------
1 | using System.IdentityModel.Tokens.Jwt;
2 | using AppRoles.WebApp.Services;
3 | using Microsoft.AspNetCore.Authentication;
4 | using Microsoft.AspNetCore.Authentication.AzureADB2C.UI;
5 | using Microsoft.AspNetCore.Authentication.OpenIdConnect;
6 | using Microsoft.AspNetCore.Builder;
7 | using Microsoft.AspNetCore.Hosting;
8 | using Microsoft.Extensions.Configuration;
9 | using Microsoft.Extensions.DependencyInjection;
10 | using Microsoft.Extensions.Hosting;
11 |
12 | namespace AppRoles.WebApp
13 | {
14 | public class Startup
15 | {
16 | public Startup(IConfiguration configuration)
17 | {
18 | Configuration = configuration;
19 | }
20 |
21 | public IConfiguration Configuration { get; }
22 |
23 | // This method gets called by the runtime. Use this method to add services to the container.
24 | public void ConfigureServices(IServiceCollection services)
25 | {
26 | // Configure App Roles options.
27 | var appRolesOptions = new AppRolesOptions();
28 | Configuration.GetSection("AppRoles").Bind(appRolesOptions);
29 | services.AddSingleton(appRolesOptions);
30 |
31 | // Inject a service to work with App Roles in the Azure AD B2C directory itself which is accessed through the Graph API.
32 | services.Configure(Configuration.GetSection("AzureAdB2C"));
33 | services.AddSingleton();
34 |
35 | // Configure support for the SameSite cookies breaking change.
36 | services.ConfigureSameSiteCookiePolicy();
37 |
38 | // Don't map any standard OpenID Connect claims to Microsoft-specific claims.
39 | // See https://leastprivilege.com/2017/11/15/missing-claims-in-the-asp-net-core-2-openid-connect-handler/.
40 | JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
41 |
42 | // Add Azure AD B2C authentication using OpenID Connect.
43 | #pragma warning disable 0618 // AzureADB2CDefaults is obsolete in favor of "Microsoft.Identity.Web"
44 | services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
45 | .AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options));
46 |
47 | services.Configure(AzureADB2CDefaults.OpenIdScheme, options =>
48 | {
49 | // Don't remove any incoming claims.
50 | options.ClaimActions.Clear();
51 |
52 | // Define the role claim type to match the configured user attribute name in Azure AD B2C.
53 | options.TokenValidationParameters.RoleClaimType = appRolesOptions.UserAttributeName;
54 | });
55 | #pragma warning restore 0618
56 |
57 | // Add a claims transformation to split the space-separated app roles into multiple individual claims,
58 | // so that we can more easily check if a user has a role with User.IsInRole(roleName) and other built-in
59 | // roles functionality within ASP.NET.
60 | services.AddSingleton(new StringSplitClaimsTransformation(appRolesOptions.UserAttributeName));
61 |
62 | services.AddRazorPages().AddRazorRuntimeCompilation();
63 | services.AddControllers();
64 | services.AddRouting(options => { options.LowercaseUrls = true; });
65 | }
66 |
67 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
68 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
69 | {
70 | if (env.IsDevelopment())
71 | {
72 | app.UseDeveloperExceptionPage();
73 | }
74 | else
75 | {
76 | app.UseExceptionHandler("/Error");
77 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
78 | app.UseHsts();
79 | }
80 |
81 | app.UseHttpsRedirection();
82 | app.UseStaticFiles();
83 |
84 | app.UseRouting();
85 |
86 | app.UseAuthentication();
87 | app.UseAuthorization();
88 |
89 | app.UseEndpoints(endpoints =>
90 | {
91 | endpoints.MapRazorPages();
92 | endpoints.MapControllers();
93 | });
94 | }
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Pages/User.cshtml.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using System.Threading.Tasks;
4 | using Microsoft.AspNetCore.Authorization;
5 | using Microsoft.AspNetCore.Mvc;
6 | using Microsoft.AspNetCore.Mvc.RazorPages;
7 | using Microsoft.Extensions.Logging;
8 |
9 | namespace DelegatedUserManagement.WebApp.Pages
10 | {
11 | [Authorize]
12 | public class UserModel : PageModel
13 | {
14 | private readonly ILogger logger;
15 | private readonly B2cGraphService b2cGraphService;
16 | public bool CanManageUsers { get; set; }
17 | public bool CanSelectGlobalAdmins { get; set; }
18 | public bool CanSelectCompany { get; set; }
19 | public IList Users { get; set; }
20 |
21 | public UserModel(ILogger logger, B2cGraphService b2cGraphService)
22 | {
23 | this.logger = logger;
24 | this.b2cGraphService = b2cGraphService;
25 | }
26 |
27 | public async Task OnGetAsync()
28 | {
29 | if (this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin))
30 | {
31 | // If the current user is a global admin, show all users.
32 | this.Users = await this.b2cGraphService.GetUsersAsync();
33 | this.CanManageUsers = true;
34 | this.CanSelectGlobalAdmins = true;
35 | this.CanSelectCompany = true;
36 | }
37 | else if (this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
38 | {
39 | // If the current user is a company admin, show only that company's users.
40 | var userCompanyId = this.User.FindFirst(this.b2cGraphService.GetUserAttributeClaimName(Constants.UserAttributes.CompanyId))?.Value;
41 | this.Users = await this.b2cGraphService.GetUsersAsync(userCompanyId);
42 | this.CanManageUsers = true;
43 | this.CanSelectGlobalAdmins = false;
44 | this.CanSelectCompany = false;
45 | }
46 | else
47 | {
48 | // If the current user is no admin, they cannot see or manage any users.
49 | this.CanManageUsers = false;
50 | }
51 |
52 | this.Users = this.Users?.OrderBy(u => u.CompanyId).ThenBy(u => u.DelegatedUserManagementRole).ThenBy(u => u.Name).ToArray();
53 | }
54 |
55 | public async Task OnPostUpdateUserAsync(User user)
56 | {
57 | // Check that the current user has permissions to create the invitation.
58 | if (!this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin) && !this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
59 | {
60 | return this.Unauthorized();
61 | }
62 |
63 | // In a real production scenario, additional validation would be needed here especially for Company Admins:
64 | // - Ensure that the user being modified is of the same company as the current user.
65 | // - Ensure that the user being modified isn't being changed to a different company.
66 | // - Ensure that the user's role isn't being elevated to global admin.
67 | // - ...
68 |
69 | await this.b2cGraphService.UpdateUserAsync(user);
70 | return RedirectToPage();
71 | }
72 |
73 | public async Task OnPostDeleteUserAsync(string id)
74 | {
75 | // Check that the current user has permissions to create the invitation.
76 | if (!this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin) && !this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
77 | {
78 | return this.Unauthorized();
79 | }
80 |
81 | // In a real production scenario, additional validation would be needed here especially for Company Admins:
82 | // - Ensure that the user being deleted is of the same company as the current user.
83 | // - ...
84 |
85 | // Ensure you can't delete yourself.
86 | var currentUserId = this.User.FindFirst(Constants.ClaimTypes.ObjectId).Value;
87 | if (!string.Equals(currentUserId, id))
88 | {
89 | await this.b2cGraphService.DeleteUserAsync(id);
90 | }
91 | return RedirectToPage();
92 | }
93 | }
94 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Startup.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.IdentityModel.Tokens.Jwt;
3 | using System.IO;
4 | using Microsoft.AspNetCore.Authentication;
5 | using Microsoft.AspNetCore.Authentication.AzureADB2C.UI;
6 | using Microsoft.AspNetCore.Authentication.OpenIdConnect;
7 | using Microsoft.AspNetCore.Builder;
8 | using Microsoft.AspNetCore.Hosting;
9 | using Microsoft.Extensions.Configuration;
10 | using Microsoft.Extensions.DependencyInjection;
11 | using Microsoft.Extensions.Hosting;
12 |
13 | namespace DelegatedUserManagement.WebApp
14 | {
15 | public class Startup
16 | {
17 | public Startup(IConfiguration configuration)
18 | {
19 | Configuration = configuration;
20 | }
21 |
22 | public IConfiguration Configuration { get; }
23 |
24 | // This method gets called by the runtime. Use this method to add services to the container.
25 | public void ConfigureServices(IServiceCollection services)
26 | {
27 | // Inject a service to store user invitations.
28 | var userInvitationsBasePath = Configuration.GetValue("App:UserInvitationsBasePath");
29 | if (string.IsNullOrWhiteSpace(userInvitationsBasePath))
30 | {
31 | userInvitationsBasePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "UserInvitations");
32 | }
33 | services.AddSingleton(new FileStorageUserInvitationRepository(userInvitationsBasePath));
34 |
35 | // Inject a service to work with Azure AD B2C through the Graph API.
36 | #pragma warning disable 0618 // AzureADB2CDefaults is obsolete in favor of "Microsoft.Identity.Web"
37 | var b2cConfigurationSection = Configuration.GetSection("AzureAdB2C");
38 | var b2cGraphService = new B2cGraphService(
39 | clientId: b2cConfigurationSection.GetValue(nameof(AzureADB2COptions.ClientId)),
40 | domain: b2cConfigurationSection.GetValue(nameof(AzureADB2COptions.Domain)),
41 | clientSecret: b2cConfigurationSection.GetValue(nameof(AzureADB2COptions.ClientSecret)),
42 | b2cExtensionsAppClientId: b2cConfigurationSection.GetValue("B2cExtensionsAppClientId"));
43 | services.AddSingleton(b2cGraphService);
44 |
45 | // Configure support for the SameSite cookies breaking change.
46 | services.ConfigureSameSiteCookiePolicy();
47 |
48 | // Don't map any standard OpenID Connect claims to Microsoft-specific claims.
49 | // See https://leastprivilege.com/2017/11/15/missing-claims-in-the-asp-net-core-2-openid-connect-handler/.
50 | JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
51 |
52 | // Add Azure AD B2C authentication using OpenID Connect.
53 | services.AddAuthentication(AzureADB2CDefaults.AuthenticationScheme)
54 | .AddAzureADB2C(options => Configuration.Bind("AzureAdB2C", options));
55 |
56 | services.Configure(AzureADB2CDefaults.OpenIdScheme, options =>
57 | {
58 | // Don't remove any incoming claims.
59 | options.ClaimActions.Clear();
60 |
61 | // Set the "role" claim type to be the "extension_DelegatedUserManagementRole" user attribute.
62 | options.TokenValidationParameters.RoleClaimType = b2cGraphService.GetUserAttributeClaimName(Constants.UserAttributes.DelegatedUserManagementRole);
63 | });
64 | #pragma warning restore 0618
65 |
66 | services.AddRazorPages();
67 | services.AddControllers();
68 | services.AddRouting(options => { options.LowercaseUrls = true; });
69 | }
70 |
71 | // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
72 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
73 | {
74 | if (env.IsDevelopment())
75 | {
76 | app.UseDeveloperExceptionPage();
77 | }
78 | else
79 | {
80 | app.UseExceptionHandler("/Error");
81 | // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
82 | app.UseHsts();
83 | }
84 |
85 | app.UseHttpsRedirection();
86 | app.UseStaticFiles();
87 |
88 | app.UseRouting();
89 |
90 | app.UseAuthentication();
91 | app.UseAuthorization();
92 |
93 | app.UseEndpoints(endpoints =>
94 | {
95 | endpoints.MapRazorPages();
96 | endpoints.MapControllers();
97 | });
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Pages/UserInvitation.cshtml:
--------------------------------------------------------------------------------
1 | @page
2 | @model UserInvitationModel
3 | @{
4 | ViewData["Title"] = "Invitations";
5 | }
6 | @if (Model.ShowGlobalAdminUserInvitation)
7 | {
8 |
Sign-up as Global Admin
9 |
10 | There aren't any users yet, you can sign up
11 | as the initial global admin with the following invitation code:
12 |
98 | }
99 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/azuredeploy.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
3 | "contentVersion": "1.0.0.0",
4 | "parameters": {
5 | "appServiceWebAppName": {
6 | "type": "String"
7 | },
8 | "appServicePlanName": {
9 | "type": "String"
10 | },
11 | "azureAdB2cDomain": {
12 | "type": "String",
13 | "defaultValue": ".onmicrosoft.com"
14 | },
15 | "azureAdB2cInstance": {
16 | "type": "String",
17 | "defaultValue": "https://.b2clogin.com/tfp/"
18 | },
19 | "azureAdB2cClientId": {
20 | "type": "String",
21 | "defaultValue": ""
22 | },
23 | "azureAdB2cClientSecret": {
24 | "type": "String",
25 | "defaultValue": ""
26 | },
27 | "azureAdB2cSignUpSignInPolicyId": {
28 | "type": "String",
29 | "defaultValue": ""
30 | },
31 | "azureAdB2cResetPasswordPolicyId": {
32 | "type": "String",
33 | "defaultValue": ""
34 | },
35 | "azureAdB2cEditProfilePolicyId": {
36 | "type": "String",
37 | "defaultValue": ""
38 | },
39 | "azureAdB2cExtensionsAppClientId": {
40 | "type": "String",
41 | "defaultValue": ""
42 | }
43 | },
44 | "resources": [
45 | {
46 | "apiVersion": "2019-08-01",
47 | "type": "Microsoft.Web/serverfarms",
48 | "name": "[parameters('appServicePlanName')]",
49 | "location": "[resourceGroup().location]",
50 | "kind": "linux",
51 | "sku": {
52 | "Name": "F1"
53 | },
54 | "properties": {
55 | "name": "[parameters('appServicePlanName')]",
56 | "reserved": true
57 | }
58 | },
59 | {
60 | "apiVersion": "2019-08-01",
61 | "type": "Microsoft.Web/sites",
62 | "name": "[parameters('appServiceWebAppName')]",
63 | "location": "[resourceGroup().location]",
64 | "dependsOn": [
65 | "[resourceId('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"
66 | ],
67 | "properties": {
68 | "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]",
69 | "siteConfig": {
70 | "appSettings": [
71 | {
72 | "name": "AzureAdB2C__Domain",
73 | "value": "[parameters('azureAdB2cDomain')]"
74 | },
75 | {
76 | "name": "AzureAdB2C__Instance",
77 | "value": "[parameters('azureAdB2cInstance')]"
78 | },
79 | {
80 | "name": "AzureAdB2C__ClientId",
81 | "value": "[parameters('azureAdB2cClientId')]"
82 | },
83 | {
84 | "name": "AzureAdB2C__ClientSecret",
85 | "value": "[parameters('azureAdB2cClientSecret')]"
86 | },
87 | {
88 | "name": "AzureAdB2C__SignUpSignInPolicyId",
89 | "value": "[parameters('azureAdB2cSignUpSignInPolicyId')]"
90 | },
91 | {
92 | "name": "AzureAdB2C__ResetPasswordPolicyId",
93 | "value": "[parameters('azureAdB2cResetPasswordPolicyId')]"
94 | },
95 | {
96 | "name": "AzureAdB2C__EditProfilePolicyId",
97 | "value": "[parameters('azureAdB2cEditProfilePolicyId')]"
98 | },
99 | {
100 | "name": "AzureAdB2C__B2cExtensionsAppClientId",
101 | "value": "[parameters('azureAdB2cExtensionsAppClientId')]"
102 | },
103 | {
104 | "name": "ASPNETCORE_FORWARDEDHEADERS_ENABLED",
105 | "value": "true"
106 | },
107 | {
108 | "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE",
109 | "value": "false"
110 | }
111 | ],
112 | "linuxFxVersion": "DOCKER|jelledruyts/identitysamplesb2c-delegatedusermanagement"
113 | }
114 | }
115 | }
116 | ]
117 | }
--------------------------------------------------------------------------------
/Authorization-AppRoles/azuredeploy.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
3 | "contentVersion": "1.0.0.0",
4 | "parameters": {
5 | "appServiceWebAppName": {
6 | "type": "String"
7 | },
8 | "appServicePlanName": {
9 | "type": "String"
10 | },
11 | "azureAdB2cDomain": {
12 | "type": "String",
13 | "defaultValue": ".onmicrosoft.com"
14 | },
15 | "azureAdB2cAzureADAppRolesProviderClientId": {
16 | "type": "String",
17 | "defaultValue": ""
18 | },
19 | "azureAdB2cAzureADAppRolesProviderClientSecret": {
20 | "type": "String",
21 | "defaultValue": ""
22 | },
23 | "azureAdB2cInstance": {
24 | "type": "String",
25 | "defaultValue": "https://.b2clogin.com/tfp/"
26 | },
27 | "azureAdB2cClientId": {
28 | "type": "String",
29 | "defaultValue": ""
30 | },
31 | "azureAdB2cSignUpSignInPolicyId": {
32 | "type": "String",
33 | "defaultValue": ""
34 | },
35 | "azureAdB2cResetPasswordPolicyId": {
36 | "type": "String",
37 | "defaultValue": ""
38 | },
39 | "azureAdB2cEditProfilePolicyId": {
40 | "type": "String",
41 | "defaultValue": ""
42 | },
43 | "appRolesUserAttributeName": {
44 | "type": "String",
45 | "defaultValue": "extension_AppRoles"
46 | }
47 | },
48 | "resources": [
49 | {
50 | "apiVersion": "2019-08-01",
51 | "type": "Microsoft.Web/serverfarms",
52 | "name": "[parameters('appServicePlanName')]",
53 | "location": "[resourceGroup().location]",
54 | "kind": "linux",
55 | "sku": {
56 | "Name": "F1"
57 | },
58 | "properties": {
59 | "name": "[parameters('appServicePlanName')]",
60 | "reserved": true
61 | }
62 | },
63 | {
64 | "apiVersion": "2019-08-01",
65 | "type": "Microsoft.Web/sites",
66 | "name": "[parameters('appServiceWebAppName')]",
67 | "location": "[resourceGroup().location]",
68 | "dependsOn": [
69 | "[resourceId('Microsoft.Web/serverfarms/', parameters('appServicePlanName'))]"
70 | ],
71 | "properties": {
72 | "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', parameters('appServicePlanName'))]",
73 | "siteConfig": {
74 | "appSettings": [
75 | {
76 | "name": "AzureAdB2C__Domain",
77 | "value": "[parameters('azureAdB2cDomain')]"
78 | },
79 | {
80 | "name": "AzureAdB2C__AzureADAppRolesProviderClientId",
81 | "value": "[parameters('azureAdB2cAzureADAppRolesProviderClientId')]"
82 | },
83 | {
84 | "name": "AzureAdB2C__AzureADAppRolesProviderClientSecret",
85 | "value": "[parameters('azureAdB2cAzureADAppRolesProviderClientSecret')]"
86 | },
87 | {
88 | "name": "AzureAdB2C__Instance",
89 | "value": "[parameters('azureAdB2cInstance')]"
90 | },
91 | {
92 | "name": "AzureAdB2C__ClientId",
93 | "value": "[parameters('azureAdB2cClientId')]"
94 | },
95 | {
96 | "name": "AzureAdB2C__SignUpSignInPolicyId",
97 | "value": "[parameters('azureAdB2cSignUpSignInPolicyId')]"
98 | },
99 | {
100 | "name": "AzureAdB2C__ResetPasswordPolicyId",
101 | "value": "[parameters('azureAdB2cResetPasswordPolicyId')]"
102 | },
103 | {
104 | "name": "AzureAdB2C__EditProfilePolicyId",
105 | "value": "[parameters('azureAdB2cEditProfilePolicyId')]"
106 | },
107 | {
108 | "name": "AppRoles__UserAttributeName",
109 | "value": "[parameters('appRolesUserAttributeName')]"
110 | },
111 | {
112 | "name": "ASPNETCORE_FORWARDEDHEADERS_ENABLED",
113 | "value": "true"
114 | },
115 | {
116 | "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE",
117 | "value": "false"
118 | }
119 | ],
120 | "linuxFxVersion": "DOCKER|jelledruyts/identitysamplesb2c-approles"
121 | }
122 | }
123 | }
124 | ]
125 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Services/B2cGraphService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Generic;
2 | using System.Linq;
3 | using System.Threading.Tasks;
4 | using Azure.Identity;
5 | using Microsoft.Graph;
6 |
7 | namespace DelegatedUserManagement.WebApp
8 | {
9 | public class B2cGraphService
10 | {
11 | private readonly GraphServiceClient graphClient;
12 | private readonly string b2cExtensionPrefix;
13 |
14 | public B2cGraphService(string clientId, string domain, string clientSecret, string b2cExtensionsAppClientId)
15 | {
16 | // Create the Graph client using an app which refers back to the "regular" Azure AD endpoints
17 | // of the B2C directory, i.e. not "tenant.b2clogin.com" but "login.microsoftonline.com/tenant".
18 | // This can then be used to perform Graph API calls using the B2C client application's identity and client credentials.
19 | var clientSecretCredential = new ClientSecretCredential(domain, clientId, clientSecret);
20 | this.graphClient = new GraphServiceClient(clientSecretCredential);
21 |
22 | this.b2cExtensionPrefix = b2cExtensionsAppClientId.Replace("-", "");
23 | }
24 |
25 | public async Task> GetUsersAsync(string companyId = null)
26 | {
27 | // Determine all the user properties to request from the Graph API.
28 | // Note: there is currently no API to return *all* user properties, only a subset is returned by default
29 | // and if you need more, you have to explicitly request these as below.
30 | var companyIdExtensionName = GetUserAttributeExtensionName(Constants.UserAttributes.CompanyId);
31 | var delegatedUserManagementRoleExtensionName = GetUserAttributeExtensionName(Constants.UserAttributes.DelegatedUserManagementRole);
32 | var invitationCodeExtensionName = GetUserAttributeExtensionName(Constants.UserAttributes.InvitationCode);
33 | var userPropertiesToRequest = new[] { nameof(Microsoft.Graph.User.Id), nameof(Microsoft.Graph.User.DisplayName), nameof(Microsoft.Graph.User.Identities),
34 | companyIdExtensionName, delegatedUserManagementRoleExtensionName, invitationCodeExtensionName };
35 |
36 | // Perform the Graph API user request and keep paging through the results until we have them all.
37 | var users = new List();
38 | var userRequest = this.graphClient.Users.Request().Select(string.Join(",", userPropertiesToRequest));
39 | if (!string.IsNullOrWhiteSpace(companyId))
40 | {
41 | // Filter directly in the Graph API call to retrieve only users that are from the specified CompanyId.
42 | // Make sure to properly escape single quotes into two consecutive single quotes.
43 | userRequest = userRequest.Filter($"{companyIdExtensionName} eq '{companyId.Replace("'", "''")}'");
44 | }
45 | while (userRequest != null)
46 | {
47 | var usersPage = await userRequest.GetAsync();
48 | foreach (var user in usersPage)
49 | {
50 | // Check if the user is a "real" B2C user, i.e. one that has signed up through a B2C user flow
51 | // and therefore has at least one B2C user attribute in the AdditionalData dictionary.
52 | if (user.AdditionalData != null && user.AdditionalData.Any())
53 | {
54 | users.Add(new User
55 | {
56 | Id = user.Id,
57 | Name = user.DisplayName,
58 | InvitationCode = GetUserAttribute(user, invitationCodeExtensionName),
59 | CompanyId = GetUserAttribute(user, companyIdExtensionName),
60 | DelegatedUserManagementRole = GetUserAttribute(user, delegatedUserManagementRoleExtensionName)
61 | });
62 | }
63 | }
64 | userRequest = usersPage.NextPageRequest;
65 | }
66 | return users;
67 | }
68 |
69 | public async Task UpdateUserAsync(User user)
70 | {
71 | var userPatch = new Microsoft.Graph.User();
72 | userPatch.DisplayName = user.Name;
73 | userPatch.AdditionalData = new Dictionary();
74 | userPatch.AdditionalData[GetUserAttributeExtensionName(Constants.UserAttributes.CompanyId)] = user.CompanyId;
75 | userPatch.AdditionalData[GetUserAttributeExtensionName(Constants.UserAttributes.DelegatedUserManagementRole)] = user.DelegatedUserManagementRole;
76 | await this.graphClient.Users[user.Id].Request().UpdateAsync(userPatch);
77 | }
78 |
79 | public async Task DeleteUserAsync(string userId)
80 | {
81 | await this.graphClient.Users[userId].Request().DeleteAsync();
82 | }
83 |
84 | public string GetUserAttributeClaimName(string userAttributeName)
85 | {
86 | return $"extension_{userAttributeName}";
87 | }
88 |
89 | public string GetUserAttributeExtensionName(string userAttributeName)
90 | {
91 | return $"extension_{this.b2cExtensionPrefix}_{userAttributeName}";
92 | }
93 |
94 | private string GetUserAttribute(Microsoft.Graph.User user, string extensionName)
95 | {
96 | if (user.AdditionalData == null || !user.AdditionalData.ContainsKey(extensionName))
97 | {
98 | return null;
99 | }
100 | return (string)user.AdditionalData[extensionName];
101 | }
102 | }
103 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Pages/UserInvitation.cshtml.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Linq;
4 | using System.Threading.Tasks;
5 | using Microsoft.AspNetCore.Authorization;
6 | using Microsoft.AspNetCore.Mvc;
7 | using Microsoft.AspNetCore.Mvc.RazorPages;
8 | using Microsoft.Extensions.Logging;
9 |
10 | namespace DelegatedUserManagement.WebApp.Pages
11 | {
12 | public class UserInvitationModel : PageModel
13 | {
14 | private readonly ILogger logger;
15 | private readonly IUserInvitationRepository userInvitationRepository;
16 | private readonly B2cGraphService b2cGraphService;
17 | public bool ShowGlobalAdminUserInvitation { get; set; }
18 | public bool CanManageUserInvitations { get; set; }
19 | public bool CanSelectGlobalAdmins { get; set; }
20 | public bool CanSelectCompany { get; set; }
21 | public string GlobalAdminInvitationCode { get; } = Guid.Empty.ToString();
22 | public IList PendingUserInvitations { get; set; }
23 |
24 | public UserInvitationModel(ILogger logger, IUserInvitationRepository userInvitationRepository, B2cGraphService b2cGraphService)
25 | {
26 | this.logger = logger;
27 | this.userInvitationRepository = userInvitationRepository;
28 | this.b2cGraphService = b2cGraphService;
29 | }
30 |
31 | public async Task OnGetAsync()
32 | {
33 | var allUsers = await this.b2cGraphService.GetUsersAsync();
34 | if (!allUsers.Any())
35 | {
36 | // If there aren't any users yet, allow anonymous access to bootstrap the initial global admin.
37 | var globalAdminUserInvitation = new UserInvitation
38 | {
39 | InvitationCode = GlobalAdminInvitationCode,
40 | CompanyId = null,
41 | DelegatedUserManagementRole = Constants.DelegatedUserManagementRoles.GlobalAdmin,
42 | CreatedTime = DateTimeOffset.UtcNow,
43 | ExpiresTime = DateTimeOffset.UtcNow.AddYears(1),
44 | };
45 | await this.userInvitationRepository.CreateUserInvitationAsync(globalAdminUserInvitation);
46 | this.ShowGlobalAdminUserInvitation = true;
47 | this.CanManageUserInvitations = false;
48 | }
49 | else
50 | {
51 | if (!this.User.Identity.IsAuthenticated)
52 | {
53 | // Force the user to sign in if they're not authenticated at this point.
54 | return this.Challenge();
55 | }
56 | this.ShowGlobalAdminUserInvitation = false;
57 |
58 | if (this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin))
59 | {
60 | this.CanManageUserInvitations = true;
61 | this.CanSelectGlobalAdmins = true;
62 | this.CanSelectCompany = true;
63 | this.PendingUserInvitations = await this.userInvitationRepository.GetPendingUserInvitationsAsync(); ;
64 | }
65 | else if (this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
66 | {
67 | this.CanManageUserInvitations = true;
68 | this.CanSelectGlobalAdmins = false;
69 | this.CanSelectCompany = false;
70 | var userCompanyId = this.User.FindFirst(this.b2cGraphService.GetUserAttributeClaimName(Constants.UserAttributes.CompanyId))?.Value;
71 | this.PendingUserInvitations = await this.userInvitationRepository.GetPendingUserInvitationsAsync(userCompanyId); ;
72 | }
73 | else
74 | {
75 | this.CanManageUserInvitations = false;
76 | }
77 |
78 | this.PendingUserInvitations = this.PendingUserInvitations?.OrderBy(u => u.CompanyId).ThenBy(u => u.DelegatedUserManagementRole).ToArray();
79 | }
80 | return this.Page();
81 | }
82 |
83 | public async Task OnPostAsync(UserInvitationRequest userInvitationRequest)
84 | {
85 | // Check that the current user has permissions to create the invitation.
86 | if (!this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin) && !this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
87 | {
88 | return this.Unauthorized();
89 | }
90 |
91 | var userInvitation = new UserInvitation
92 | {
93 | InvitationCode = Guid.NewGuid().ToString(),
94 | CompanyId = userInvitationRequest.CompanyId,
95 | DelegatedUserManagementRole = userInvitationRequest.DelegatedUserManagementRole,
96 | CreatedTime = DateTimeOffset.UtcNow,
97 | ExpiresTime = DateTimeOffset.UtcNow.AddHours(userInvitationRequest.ValidHours),
98 | CreatedBy = this.User.FindFirst(Constants.ClaimTypes.ObjectId)?.Value
99 | };
100 |
101 | if (this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
102 | {
103 | // For company admins, ensure to set the newly invited user's company to the inviting user's company.
104 | var userCompanyId = this.User.FindFirst(this.b2cGraphService.GetUserAttributeClaimName(Constants.UserAttributes.CompanyId))?.Value;
105 | userInvitation.CompanyId = userCompanyId;
106 |
107 | // Also ensure the invited user isn't elevated to a global admin.
108 | if (string.Equals(userInvitation.DelegatedUserManagementRole, Constants.DelegatedUserManagementRoles.GlobalAdmin, StringComparison.InvariantCultureIgnoreCase))
109 | {
110 | userInvitation.DelegatedUserManagementRole = Constants.DelegatedUserManagementRoles.CompanyAdmin;
111 | }
112 | }
113 | await this.userInvitationRepository.CreateUserInvitationAsync(userInvitation);
114 | return RedirectToPage();
115 | }
116 |
117 | public async Task OnPostDeleteUserInvitationAsync(string invitationCode)
118 | {
119 | // Check that the current user has permissions to delete the invitation.
120 | if (!this.User.IsInRole(Constants.DelegatedUserManagementRoles.GlobalAdmin) && !this.User.IsInRole(Constants.DelegatedUserManagementRoles.CompanyAdmin))
121 | {
122 | return this.Unauthorized();
123 | }
124 |
125 | // In a real production scenario, additional validation would be needed here especially for Company Admins:
126 | // - Ensure that the user invitation being deleted is of the same company as the current user.
127 | // - ...
128 |
129 | await this.userInvitationRepository.DeletePendingUserInvitationAsync(invitationCode);
130 | return RedirectToPage();
131 | }
132 | }
133 | }
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/DelegatedUserManagement.WebApp/Controllers/UserInvitationController.cs:
--------------------------------------------------------------------------------
1 | using System;
2 | using System.Collections.Generic;
3 | using System.Text.Json;
4 | using System.Threading.Tasks;
5 | using Microsoft.AspNetCore.Mvc;
6 | using Microsoft.Extensions.Logging;
7 |
8 | namespace DelegatedUserManagement.WebApp.Controllers
9 | {
10 | [Route("api/[controller]")]
11 | [ApiController]
12 | public class UserInvitationController : ControllerBase
13 | {
14 | private readonly ILogger logger;
15 | private readonly IUserInvitationRepository userInvitationRepository;
16 | private readonly B2cGraphService b2cGraphService;
17 |
18 | public UserInvitationController(ILogger logger, IUserInvitationRepository userInvitationRepository, B2cGraphService b2cGraphService)
19 | {
20 | this.logger = logger;
21 | this.userInvitationRepository = userInvitationRepository;
22 | this.b2cGraphService = b2cGraphService;
23 | }
24 |
25 | [HttpPost(nameof(Redeem))]
26 | public async Task Redeem([FromBody] JsonElement body)
27 | {
28 | // Azure AD B2C calls into this API when a user is attempting to sign up with an invitation code.
29 | // We expect a JSON object in the HTTP request which contains the input claims as well as an additional
30 | // property "ui_locales" containing the locale being used in the user journey (browser flow).
31 | try
32 | {
33 | this.logger.LogInformation("An invitation code is being redeemed.");
34 |
35 | // Look up the invitation code in the incoming request.
36 | var invitationCode = default(string);
37 | this.logger.LogInformation("Request properties:");
38 | foreach (var element in body.EnumerateObject())
39 | {
40 | this.logger.LogInformation($"- {element.Name}: {element.Value.GetRawText()}");
41 | // The element name should be the full extension name as seen by the Graph API (e.g. "extension_appid_InvitationCode").
42 | if (element.Name.Equals(this.b2cGraphService.GetUserAttributeExtensionName(Constants.UserAttributes.InvitationCode), StringComparison.InvariantCultureIgnoreCase))
43 | {
44 | invitationCode = element.Value.GetString();
45 | }
46 | }
47 |
48 | if (string.IsNullOrWhiteSpace(invitationCode) || invitationCode.Length < 10)
49 | {
50 | // No invitation code was found in the request or it was too short, return a validation error.
51 | this.logger.LogInformation($"The provided invitation code \"{invitationCode}\" is invalid.");
52 | return GetValidationErrorApiResponse("UserInvitationRedemptionFailed-Invalid", "The invitation code you provided is invalid.");
53 | }
54 | else
55 | {
56 | // An invitation code was found in the request, look up the user invitation in persistent storage.
57 | this.logger.LogInformation($"Looking up user invitation for invitation code \"{invitationCode}\"...");
58 | var userInvitation = await this.userInvitationRepository.GetPendingUserInvitationAsync(invitationCode);
59 | if (userInvitation == null)
60 | {
61 | // The requested invitation code was not found in persistent storage.
62 | this.logger.LogWarning($"User invitation for invitation code \"{invitationCode}\" was not found.");
63 | return GetValidationErrorApiResponse("UserInvitationRedemptionFailed-NotFound", "The invitation code you provided is invalid.");
64 | }
65 | else if (userInvitation.ExpiresTime < DateTimeOffset.UtcNow)
66 | {
67 | // The requested invitation code has expired.
68 | this.logger.LogWarning($"User invitation for invitation code \"{invitationCode}\" has expired on {userInvitation.ExpiresTime.ToString("o")}.");
69 | return GetValidationErrorApiResponse("UserInvitationRedemptionFailed-Expired", "The invitation code you provided has expired.");
70 | }
71 | else
72 | {
73 | // The requested invitation code was found in persistent storage and is valid.
74 | this.logger.LogInformation($"User invitation found for invitation code \"{invitationCode}\".");
75 |
76 | // At this point, the invitation can be deleted again as it has been redeemed.
77 | await this.userInvitationRepository.RedeemUserInvitationAsync(invitationCode);
78 |
79 | return GetContinueApiResponse("UserInvitationRedemptionSucceeded", "The invitation code you provided is valid.", userInvitation);
80 | }
81 | }
82 | }
83 | catch (Exception exc)
84 | {
85 | this.logger.LogError(exc, "Error while processing request body: " + exc.ToString());
86 | return GetBlockPageApiResponse("UserInvitationRedemptionFailed-InternalError", "An error occurred while validating your invitation code, please try again later.");
87 | }
88 | }
89 |
90 | private IActionResult GetContinueApiResponse(string code, string userMessage, UserInvitation userInvitation)
91 | {
92 | return GetB2cApiConnectorResponse("Continue", code, userMessage, 200, userInvitation);
93 | }
94 |
95 | private IActionResult GetValidationErrorApiResponse(string code, string userMessage)
96 | {
97 | return GetB2cApiConnectorResponse("ValidationError", code, userMessage, 400, null);
98 | }
99 |
100 | private IActionResult GetBlockPageApiResponse(string code, string userMessage)
101 | {
102 | return GetB2cApiConnectorResponse("ShowBlockPage", code, userMessage, 200, null);
103 | }
104 |
105 | private IActionResult GetB2cApiConnectorResponse(string action, string code, string userMessage, int statusCode, UserInvitation userInvitation)
106 | {
107 | var responseProperties = new Dictionary
108 | {
109 | { "version", "1.0.0" },
110 | { "action", action },
111 | { "userMessage", userMessage },
112 | { this.b2cGraphService.GetUserAttributeExtensionName(Constants.UserAttributes.CompanyId), userInvitation?.CompanyId }, // Note: returning just "extension_" (without the App ID) would work as well!
113 | { this.b2cGraphService.GetUserAttributeExtensionName(Constants.UserAttributes.DelegatedUserManagementRole), userInvitation?.DelegatedUserManagementRole } // Note: returning just "extension_" (without the App ID) would work as well!
114 | };
115 | if (statusCode != 200)
116 | {
117 | // Include the status in the body as well, but only for validation errors.
118 | responseProperties["status"] = statusCode.ToString();
119 | }
120 | return new JsonResult(responseProperties) { StatusCode = statusCode };
121 | }
122 | }
123 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Mono auto generated files
17 | mono_crash.*
18 |
19 | # Build results
20 | [Dd]ebug/
21 | [Dd]ebugPublic/
22 | [Rr]elease/
23 | [Rr]eleases/
24 | x64/
25 | x86/
26 | [Aa][Rr][Mm]/
27 | [Aa][Rr][Mm]64/
28 | bld/
29 | [Bb]in/
30 | [Oo]bj/
31 | [Ll]og/
32 | [Ll]ogs/
33 |
34 | # Visual Studio 2015/2017 cache/options directory
35 | .vs/
36 | # Uncomment if you have tasks that create the project's static files in wwwroot
37 | #wwwroot/
38 |
39 | # Visual Studio 2017 auto generated files
40 | Generated\ Files/
41 |
42 | # MSTest test Results
43 | [Tt]est[Rr]esult*/
44 | [Bb]uild[Ll]og.*
45 |
46 | # NUnit
47 | *.VisualState.xml
48 | TestResult.xml
49 | nunit-*.xml
50 |
51 | # Build Results of an ATL Project
52 | [Dd]ebugPS/
53 | [Rr]eleasePS/
54 | dlldata.c
55 |
56 | # Benchmark Results
57 | BenchmarkDotNet.Artifacts/
58 |
59 | # .NET Core
60 | project.lock.json
61 | project.fragment.lock.json
62 | artifacts/
63 |
64 | # StyleCop
65 | StyleCopReport.xml
66 |
67 | # Files built by Visual Studio
68 | *_i.c
69 | *_p.c
70 | *_h.h
71 | *.ilk
72 | *.meta
73 | *.obj
74 | *.iobj
75 | *.pch
76 | *.pdb
77 | *.ipdb
78 | *.pgc
79 | *.pgd
80 | *.rsp
81 | *.sbr
82 | *.tlb
83 | *.tli
84 | *.tlh
85 | *.tmp
86 | *.tmp_proj
87 | *_wpftmp.csproj
88 | *.log
89 | *.vspscc
90 | *.vssscc
91 | .builds
92 | *.pidb
93 | *.svclog
94 | *.scc
95 |
96 | # Chutzpah Test files
97 | _Chutzpah*
98 |
99 | # Visual C++ cache files
100 | ipch/
101 | *.aps
102 | *.ncb
103 | *.opendb
104 | *.opensdf
105 | *.sdf
106 | *.cachefile
107 | *.VC.db
108 | *.VC.VC.opendb
109 |
110 | # Visual Studio profiler
111 | *.psess
112 | *.vsp
113 | *.vspx
114 | *.sap
115 |
116 | # Visual Studio Trace Files
117 | *.e2e
118 |
119 | # TFS 2012 Local Workspace
120 | $tf/
121 |
122 | # Guidance Automation Toolkit
123 | *.gpState
124 |
125 | # ReSharper is a .NET coding add-in
126 | _ReSharper*/
127 | *.[Rr]e[Ss]harper
128 | *.DotSettings.user
129 |
130 | # TeamCity is a build add-in
131 | _TeamCity*
132 |
133 | # DotCover is a Code Coverage Tool
134 | *.dotCover
135 |
136 | # AxoCover is a Code Coverage Tool
137 | .axoCover/*
138 | !.axoCover/settings.json
139 |
140 | # Visual Studio code coverage results
141 | *.coverage
142 | *.coveragexml
143 |
144 | # NCrunch
145 | _NCrunch_*
146 | .*crunch*.local.xml
147 | nCrunchTemp_*
148 |
149 | # MightyMoose
150 | *.mm.*
151 | AutoTest.Net/
152 |
153 | # Web workbench (sass)
154 | .sass-cache/
155 |
156 | # Installshield output folder
157 | [Ee]xpress/
158 |
159 | # DocProject is a documentation generator add-in
160 | DocProject/buildhelp/
161 | DocProject/Help/*.HxT
162 | DocProject/Help/*.HxC
163 | DocProject/Help/*.hhc
164 | DocProject/Help/*.hhk
165 | DocProject/Help/*.hhp
166 | DocProject/Help/Html2
167 | DocProject/Help/html
168 |
169 | # Click-Once directory
170 | publish/
171 |
172 | # Publish Web Output
173 | *.[Pp]ublish.xml
174 | *.azurePubxml
175 | # Note: Comment the next line if you want to checkin your web deploy settings,
176 | # but database connection strings (with potential passwords) will be unencrypted
177 | *.pubxml
178 | *.publishproj
179 |
180 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
181 | # checkin your Azure Web App publish settings, but sensitive information contained
182 | # in these scripts will be unencrypted
183 | PublishScripts/
184 |
185 | # NuGet Packages
186 | *.nupkg
187 | # NuGet Symbol Packages
188 | *.snupkg
189 | # The packages folder can be ignored because of Package Restore
190 | **/[Pp]ackages/*
191 | # except build/, which is used as an MSBuild target.
192 | !**/[Pp]ackages/build/
193 | # Uncomment if necessary however generally it will be regenerated when needed
194 | #!**/[Pp]ackages/repositories.config
195 | # NuGet v3's project.json files produces more ignorable files
196 | *.nuget.props
197 | *.nuget.targets
198 |
199 | # Microsoft Azure Build Output
200 | csx/
201 | *.build.csdef
202 |
203 | # Microsoft Azure Emulator
204 | ecf/
205 | rcf/
206 |
207 | # Windows Store app package directories and files
208 | AppPackages/
209 | BundleArtifacts/
210 | Package.StoreAssociation.xml
211 | _pkginfo.txt
212 | *.appx
213 | *.appxbundle
214 | *.appxupload
215 |
216 | # Visual Studio cache files
217 | # files ending in .cache can be ignored
218 | *.[Cc]ache
219 | # but keep track of directories ending in .cache
220 | !?*.[Cc]ache/
221 |
222 | # Others
223 | ClientBin/
224 | ~$*
225 | *~
226 | *.dbmdl
227 | *.dbproj.schemaview
228 | *.jfm
229 | *.pfx
230 | *.publishsettings
231 | orleans.codegen.cs
232 |
233 | # Including strong name files can present a security risk
234 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
235 | #*.snk
236 |
237 | # Since there are multiple workflows, uncomment next line to ignore bower_components
238 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
239 | #bower_components/
240 |
241 | # RIA/Silverlight projects
242 | Generated_Code/
243 |
244 | # Backup & report files from converting an old project file
245 | # to a newer Visual Studio version. Backup files are not needed,
246 | # because we have git ;-)
247 | _UpgradeReport_Files/
248 | Backup*/
249 | UpgradeLog*.XML
250 | UpgradeLog*.htm
251 | ServiceFabricBackup/
252 | *.rptproj.bak
253 |
254 | # SQL Server files
255 | *.mdf
256 | *.ldf
257 | *.ndf
258 |
259 | # Business Intelligence projects
260 | *.rdl.data
261 | *.bim.layout
262 | *.bim_*.settings
263 | *.rptproj.rsuser
264 | *- [Bb]ackup.rdl
265 | *- [Bb]ackup ([0-9]).rdl
266 | *- [Bb]ackup ([0-9][0-9]).rdl
267 |
268 | # Microsoft Fakes
269 | FakesAssemblies/
270 |
271 | # GhostDoc plugin setting file
272 | *.GhostDoc.xml
273 |
274 | # Node.js Tools for Visual Studio
275 | .ntvs_analysis.dat
276 | node_modules/
277 |
278 | # Visual Studio 6 build log
279 | *.plg
280 |
281 | # Visual Studio 6 workspace options file
282 | *.opt
283 |
284 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
285 | *.vbw
286 |
287 | # Visual Studio LightSwitch build output
288 | **/*.HTMLClient/GeneratedArtifacts
289 | **/*.DesktopClient/GeneratedArtifacts
290 | **/*.DesktopClient/ModelManifest.xml
291 | **/*.Server/GeneratedArtifacts
292 | **/*.Server/ModelManifest.xml
293 | _Pvt_Extensions
294 |
295 | # Paket dependency manager
296 | .paket/paket.exe
297 | paket-files/
298 |
299 | # FAKE - F# Make
300 | .fake/
301 |
302 | # CodeRush personal settings
303 | .cr/personal
304 |
305 | # Python Tools for Visual Studio (PTVS)
306 | __pycache__/
307 | *.pyc
308 |
309 | # Cake - Uncomment if you are using it
310 | # tools/**
311 | # !tools/packages.config
312 |
313 | # Tabs Studio
314 | *.tss
315 |
316 | # Telerik's JustMock configuration file
317 | *.jmconfig
318 |
319 | # BizTalk build output
320 | *.btp.cs
321 | *.btm.cs
322 | *.odx.cs
323 | *.xsd.cs
324 |
325 | # OpenCover UI analysis results
326 | OpenCover/
327 |
328 | # Azure Stream Analytics local run output
329 | ASALocalRun/
330 |
331 | # MSBuild Binary and Structured Log
332 | *.binlog
333 |
334 | # NVidia Nsight GPU debugger configuration file
335 | *.nvuser
336 |
337 | # MFractors (Xamarin productivity tool) working folder
338 | .mfractor/
339 |
340 | # Local History for Visual Studio
341 | .localhistory/
342 |
343 | # BeatPulse healthcheck temp database
344 | healthchecksdb
345 |
346 | # Backup folder for Package Reference Convert tool in Visual Studio 2017
347 | MigrationBackup/
348 |
349 | # Ionide (cross platform F# VS Code tools) working folder
350 | .ionide/
351 |
352 | # Other
353 | .DS_Store
--------------------------------------------------------------------------------
/InvitationCodeDelegatedUserManagement/README.md:
--------------------------------------------------------------------------------
1 | # Identity Sample for Azure AD B2C - Delegated User Management
2 |
3 | This repository contains an ASP.NET Core project which demonstrates delegated user management in [Azure Active Directory B2C](https://azure.microsoft.com/services/active-directory-b2c/) using [API Connectors](https://docs.microsoft.com/azure/active-directory-b2c/api-connectors-overview).
4 |
5 | **IMPORTANT NOTE: The code in this repository is _not_ production-ready. It serves only to demonstrate the main points via minimal working code, and contains no exception handling or other special cases. Refer to the official documentation and samples for more information. Similarly, by design, it does not implement any caching or data persistence (e.g. to a database) to minimize the concepts and technologies being used.**
6 |
7 | ## Scenario
8 |
9 | While Azure AD B2C is often used for "open" sign up scenarios, where _any user_ can self-register an account and access the application (e.g. in e-commerce or open community scenarios), it can also be used for a more closed environment where only users who are explicitly invited can register an account.
10 |
11 | This sample demonstrates such a scenario by using an "invitation code" based sign up flow, which allows users to register for an account through any of the regular supported identity providers (local, social or federated accounts). However, before they are actually allowed to create the account they will need to enter an invitation code which they received from an administrator of the application. Without a valid invitation code, they cannot sign up.
12 |
13 | > Alternatively, the administrator could also pre-create the user in Azure AD B2C (e.g. manually in the Azure Portal or through the Graph API) and send them a password reset link to allow them to sign in to their newly created account. However, this has the downside that it does not allow the end user to choose which identity they want to sign in with (i.e. a local, social or federated identity): the administrator must already choose that when the user account is pre-created on their behalf. It also has the disadvantage that users who never actually access the application will still have an inactive "ghost" account in the directory.
14 |
15 | This sample goes one step further in that it also supports _delegated_ user management, where there aren't just administrators who can invite users, but they can delegate user management for a certain subset of users to others. Imagine that the application is a Software-as-a-Service solution that the vendor is selling to their customers (companies). They will want to have a few "global" administrators in the back-office who can sign up new customers (companies) and invite delegated user administrators for those companies. These company administrators in turn have permissions to invite other users, but _only for their own company_.
16 |
17 | This means that for this sample, there are 3 personas (i.e. application roles):
18 |
19 | - **Global Administrators** who can invite anyone and manage all users
20 | - **Company Administrators** who can only invite and manage users for their own company
21 | - **Company Users** who can use the application but cannot invite or manage any users
22 |
23 | The user's role, as well as the company identifier that they belong to (or blank for global administrators) are stored in Azure AD B2C as custom user attributes and are therefore issued as claims inside the token issued by Azure AD B2C so that the application has this information available directly.
24 |
25 | ## Setup
26 |
27 | ### Configure Azure AD B2C
28 |
29 | - Create an **app registration for the sample app**:
30 | - Make sure to create the app registration for use with **Accounts in any identity provider or organizational directory (for authenticating users with user flows)**.
31 | - The client id of this application should go into the `AzureAdB2C:ClientId` application setting.
32 | - Allow the Implicit grant flow (for Access and ID tokens).
33 | - Set the Redirect URI to `https://localhost:5001/signin-oidc` when running locally or `https:///signin-oidc` when running publicly.
34 | - Create a client secret for this application (it will be needed to [acquire tokens using the OAuth 2.0 Client Credentials grant](https://docs.microsoft.com/azure/active-directory-b2c/microsoft-graph-get-started?tabs=app-reg-ga#microsoft-graph-api-interaction-modes)); this secret value should go into the `AzureAdB2C:ClientSecret` application setting.
35 | - Configure **Application Permissions** for the Microsoft Graph with `User.ReadWrite.All` permissions and perform the required admin consent.
36 | - **Create custom user attributes** in Azure AD B2C:
37 | - Follow the documentation to [create a custom attribute](https://docs.microsoft.com/azure/active-directory-b2c/user-flow-custom-attributes?pivots=b2c-user-flow#create-a-custom-attribute) and configure the following user attributes:
38 | - `CompanyId` (String): The identifier of the user's company.
39 | - `DelegatedUserManagementRole` (String): The role of the user for the purposes of delegated user management.
40 | - `InvitationCode` (String): The invitation code that you have received which allows you to sign up.
41 | - **Create user flows** for **Sign up and sign in** (and optionally **Password reset** and **Profile editing**):
42 | - For all these flows, use the *recommended* version which gives you access to the API connectors feature.
43 | - On all these flows, ensure to return at least `CompanyId`, `DelegatedUserManagementRole`, `Display Name`, `InvitationCode` and `User's Object ID` as the **Application claims**.
44 | - On the **Sign up and sign in** flow, ensure to collect at least `CompanyId`, `DelegatedUserManagementRole` and `InvitationCode` as the **User attributes**. Note that the final values of these attributes will be determined by the user invitation. For now, only user attributes that are explicitly selected here will be persisted to the directory, so if you do not configure these claims here as **User attributes**, they will not be populated with the information from the user invitation! To prevent end user confusion around these fields (which they should ideally never see), you can consider hiding them from the page by providing custom page content (see below).
45 | - On the **Profile editing** flow, ensure *not* to select `CompanyId`, `DelegatedUserManagementRole` and `InvitationCode` in the **User attributes**; otherwise, users could change their own role for example!
46 |
47 | ### Configure and run the sample app
48 |
49 | There are a few options to run the sample app (containing both the REST API and the web application):
50 |
51 | - You can build and run it locally.
52 | - You can open the root folder of this repo in [Visual Studio Code](https://code.visualstudio.com/) where you can just build and debug (install the recommended extensions in the workspace if you don't have them).
53 | - In this case, application settings are configured in the `DelegatedUserManagement.WebApp/appsettings.json` file or by using [.NET User Secrets](https://docs.microsoft.com/aspnet/core/security/app-secrets).
54 | - You can build and run it in a [devcontainer](https://code.visualstudio.com/docs/remote/containers) (including [GitHub Codespaces](https://github.com/features/codespaces)).
55 | - All pre-requisites such as .NET Core are provided in the devcontainer so you don't need to install anything locally.
56 | - In this case, application settings are configured in the `DelegatedUserManagement.WebApp/appsettings.json` file or by using [.NET User Secrets](https://docs.microsoft.com/aspnet/core/security/app-secret).
57 | - You can host a pre-built Docker container which contains the sample app.
58 | - You can find the latest published version of the Docker container publicly on **Docker Hub** at **[jelledruyts/identitysamplesb2c-delegatedusermanagement](https://hub.docker.com/r/jelledruyts/identitysamplesb2c-delegatedusermanagement)**
59 | - In this case, application settings are configured through environment variables. Note that on Linux a colon (`:`) is not allowed in an environment variable, so use a double underscore instead of `:` in that case (e.g. `AzureAdB2C__ClientId`).
60 | - You can easily deploy that same container to Azure App Service.
61 | - [](https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fazure-ad-b2c%2Fapi-connector-samples%2Fmain%2FInvitationCodeDelegatedUserManagement%2Fazuredeploy.json)
62 | - In this case, you will be prompted to fill in the right application settings for the web app during deployment.
63 |
64 | ### Configure the API Connector
65 |
66 | - Create an API connector towards the invitation redemption API exposed by the sample app:
67 | - Follow the documentation to [add an API connector to a user flow](https://docs.microsoft.com/azure/active-directory-b2c/add-api-connector?pivots=b2c-user-flow).
68 | - The API connector should have the endpoint URL defined as `https:///api/userinvitation/redeem`.
69 | - Note that you need a publicly accessible endpoint for this; when running locally you can consider using a tool such as [ngrok](https://ngrok.com/) to tunnel the traffic to your local machine.
70 | - Note that the REST API in this sample isn't secured; you can set the authentication type to Basic and fill in a dummy username and password. In a real world production case, this should of course be [properly secured](https://docs.microsoft.com/azure/active-directory-b2c/api-connectors-overview?pivots=b2c-user-flow#security-considerations).
71 | - Go back to the **Sign up and sign in** user flow you created earlier and configure the API Connector to run during the **Before creating the user** step.
72 |
73 | ### Try it out
74 |
75 | When the sample app is running and the API Connector is configured, you can browse to the web application and navigate to the *Invitations* page where you can find the initial invitation code for the first global admin. Copy it and perform a sign up; during sign up you will be prompted to enter this invitation code. From then on, you can invite and manage other users on the *Users* page. To check if everything is working correctly, you can see all the claims in the token on the *Identity* page.
76 |
77 | ### Use custom page content (optional)
78 |
79 | As explained above, user attributes that need to be persisted during user creation must currently also be selected in the **User attributes** list (even if they are ultimately populated through the API connector).
80 |
81 | For these fields which the user should not see, you can use custom page content with a small CSS snippet that selects the right HTML elements and then hides them.
82 |
83 | Note that this will not allow users to bypass security and provide their own values: even if they *un-hide* the right fields, the API connector will be called *after* the user has filled in their details, and the information coming back from the API connector will overwrite whatever the user had entered manually.
84 |
85 | To improve the user experience by hiding the necessary fields:
86 |
87 | - Ensure to follow the steps to [customize the user interface](https://docs.microsoft.com/azure/active-directory-b2c/customize-ui-overview).
88 | - Host the [selfAsserted.html](PageLayouts/selfAsserted.html) file (which is based on the *Ocean Blue* template in this case) in a publicly accessible location, e.g. in [Azure Blob Storage](https://docs.microsoft.com/azure/storage/blobs/storage-blobs-introduction) by following the steps in the [custom page content walkthrough](https://docs.microsoft.com/azure/active-directory-b2c/custom-policy-ui-customization#custom-page-content-walkthrough). Note the small CSS `
93 |
101 |
102 |
108 |
109 |
110 |
116 |
117 |
124 |
125 |
126 |
127 |