diff --git a/.github/workflows/E2E.yml b/.github/workflows/E2E.yml index 24fe7a6..a62a389 100644 --- a/.github/workflows/E2E.yml +++ b/.github/workflows/E2E.yml @@ -52,13 +52,37 @@ jobs: - uses: actions/checkout@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.IBERSANO_DOCKER_USERNAME }} + password: ${{ secrets.IBERSANO_DOCKER_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: | + ${{ vars.docker_repo }}/easy-auth-proxy + + - name: Build and push Docker images + uses: docker/build-push-action@v3 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + - name: Call the script continue-on-error: true run: | - bash main.sh -a "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -c "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -r "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -e ${{ vars.email }} -l ${{ vars.location }} + bash main.sh -i ${{ vars.imageName }} -a "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -c "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -r "${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}" -e ${{ vars.email }} -l ${{ vars.location }} - name: Delete e2e environment - if: ${{ vars.DeleteOnFailure == 'true' }} || success() + if: ${{ vars.DeleteOnFailure == 'true' }} run: | if [ $(az group exists --name ${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }}) == "true" ]; then az group delete -n ${{ vars.e2ePrefix }}-${{ env.GITHUB_PR_NUMBER }} --yes diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff80223..55bea52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,13 +62,13 @@ jobs: uses: actions/checkout@v2 - name: Log in to Docker Hub - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@v2 with: username: ${{ secrets.DOCKER_USER_ID }} password: ${{ secrets.DOCKER_REGISTRY_PASS }} - name: Log in to the Container registry - uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} @@ -76,14 +76,14 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + uses: docker/metadata-action@v4 with: images: | ghcr.io/${{ github.repository }}/easy-auth-proxy easyauthfork8s/easy-auth-proxy - name: Build and push Docker images - uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + uses: docker/build-push-action@v3 with: context: . push: true diff --git a/AutomationScripts/3-registerAADApp.sh b/AutomationScripts/3-registerAADApp.sh index b41b348..c4059d5 100644 --- a/AutomationScripts/3-registerAADApp.sh +++ b/AutomationScripts/3-registerAADApp.sh @@ -18,7 +18,7 @@ if [ -n "$ALT_TENANT_ID" ]; then fi fi -CLIENT_ID=$(az ad app create --display-name $AD_APP_NAME --web-home-page-url $HOMEPAGE --web-redirect-uris $REPLY_URLS --required-resource-accesses @./TemplateFiles/manifest.json -o json | jq -r '.appId') +CLIENT_ID=$(az ad app create --display-name $AD_APP_NAME --web-home-page-url $HOMEPAGE --web-redirect-uris $REPLY_URLS --required-resource-accesses @./TemplateFiles/manifest.json --optional-claims @./TemplateFiles/claims.json -o json | jq -r '.appId') echo "CLIENT_ID: " $CLIENT_ID # AAD core store is eventually consistent. Usually we can retrieve the object on the first try after creation, diff --git a/TemplateFiles/claims.json b/TemplateFiles/claims.json new file mode 100644 index 0000000..e1daa85 --- /dev/null +++ b/TemplateFiles/claims.json @@ -0,0 +1,10 @@ +{ + "idToken": [ + { + "name": "login_hint", + "essential": false + } + ], + "accessToken": [], + "saml2Token": [] +} \ No newline at end of file diff --git a/charts/easyauth-proxy/templates/statefulset.yaml b/charts/easyauth-proxy/templates/statefulset.yaml index b8114b0..d8d6186 100644 --- a/charts/easyauth-proxy/templates/statefulset.yaml +++ b/charts/easyauth-proxy/templates/statefulset.yaml @@ -57,13 +57,19 @@ spec: value: "{{ .Values.easyAuthForK8s.dataProtectionFileLocation }}" - name: EasyAuthForK8s__SigninPath value: "{{ .Values.basePath }}{{ .Values.easyAuthForK8s.signinPath }}" + - name: EasyAuthForK8s__SignoutPath + value: "{{ .Values.basePath }}{{ .Values.easyAuthForK8s.signoutPath }}" - name: EasyAuthForK8s__AuthPath value: "{{ .Values.basePath }}{{ .Values.easyAuthForK8s.authPath }}" - name: EasyAuthForK8s__AllowBearerToken value: "{{ .Values.easyAuthForK8s.allowBearerToken }}" - name: EasyAuthForK8s__DefaultRedirectAfterSignin value: "{{ .Values.easyAuthForK8s.defaultRedirectAfterSignin }}" - name: EasyAuthForK8s__CompressCookieClaims + - name: EasyAuthForK8s__DefaultRedirectAfterSignout + value: "{{ .Values.easyAuthForK8s.defaultRedirectAfterSignout }}" + - name: EasyAuthForK8s__SignedOutNoRedirectPath + value: "{{ .Values.basePath }}/signedout" + - name: EasyAuthForK8s__CompressCookieClaims value: "{{ .Values.easyAuthForK8s.compressCookieClaims }}" - name: EasyAuthForK8s__ResponseHeaderPrefix value: "{{ .Values.easyAuthForK8s.responseHeaderPrefix }}" @@ -81,8 +87,6 @@ spec: value: "{{ .Values.azureAd.clientId }}" - name: AzureAd__CallbackPath value: "{{ .Values.basePath }}{{ .Values.azureAd.callbackPath }}" - - name: AzureAd__SignedOutCallbackPath - value: "{{ .Values.basePath }}{{ .Values.azureAd.signedOutCallbackPath }}" - name: AzureAd__SignUpSignInPolicyId value: "{{ .Values.azureAd.signUpSignInPolicyId }}" - name: AzureAd__ClientSecret diff --git a/charts/easyauth-proxy/values.yaml b/charts/easyauth-proxy/values.yaml index 3faf968..12729af 100644 --- a/charts/easyauth-proxy/values.yaml +++ b/charts/easyauth-proxy/values.yaml @@ -4,6 +4,12 @@ tlsSecretName: "" appHostName: "" + +# The base path will be pre-pended to all urls on the EasyAuth Proxy +# For example, the Auth endpoint will listen on "/easyauth/auth" by default +# You should change the base path if you are deploying multiple EasyAuth pods +# that share the same host name, such as a mult-tenant app. See documentation +# for more. basePath: "/easyauth" replicaCount: 2 @@ -98,28 +104,32 @@ azureAd: tenantId: "" # app Id of the service principal. clientId: "" + # B2C Sign-in policy, if used + # Leave this blank if not using B2C + signUpSignInPolicyId: "" # configure paths for OIDC middleware - # there's no reason to change these unless there is a conflict - # such as another easyauth proxy using the same host name + # you shouldn't need to change these from the default + # all paths will be prefiixed with the basePath value callbackPath: "/signin-oidc" - signedOutCallbackPath : "/signout-callback-oidc" - # Leave this blank if not B2C - signUpSignInPolicyId: "" easyAuthForK8s: # data protection key ring location dataProtectionFileLocation: "/mnt/dp" # configure paths for EasyAuth middleware - # there's no reason to change these unless there is a conflict - # such as another easyauth proxy using the same host name + # you shouldn't need to change these from the default + # all paths will be prefiixed with the basePath value signinPath: "/login" authPath: "/auth" + signoutPath: "/logout" # use bearer token as a fall back for cookies # normally for API web applications only allowBearerToken: "false" # fallback path to redirect user after signin if # prior page url cannot be determined defaultRedirectAfterSignin: "/" + # fallback path to redirect user after signout + # if no redirect parameter is provided + defaultRedirectAfterSignout: "_blank" # Make the cookie payload as small as possible to avoid having to # increase the allowed nginx header size. compressCookieClaims: "true" diff --git a/docs/configuration.md b/docs/configuration.md index c57c30b..3bffd97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,15 +16,17 @@ Here's a list of possible configuration options for the EasyAuth Proxy, which yo | azureAd | domain | Optional. If your users are internal organizational accounts from a single tenant domain, this can be helpful by providing a "hint" during login to help ensure that the user logs in with the appropriate user account| | azureAd | tenantId | If you are using the setup script, this value will be determined at runtime and filled in for you. Otherwise, this is the GUID tenant identifier for the Azure AD tenant you want to use. See [How to find my tenant id](https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant)| | azureAd | clientId | If you are using the setup script, this value will be determined at runtime and filled in for you. Otherwise, this is the GUID application identifier for the Azure AD app registration you want to use. See [App Registrations](https://docs.microsoft.com/en-us/graph/auth-register-app-v2)| -| azureAd | callbackPath | The path that Open Id Connect messages will be returned from Azure AD. In the majority of cases, you should never need to change this. See [Advanced Scenarios](docs/scenarios.md)| -| azureAd | signedOutCallbackPath | Reserved for future use - Not currently used| | azureAd | signUpSignInPolicyId | For B2C only. This is the name of the policy that should be used. Otherwise, leave blank.| +| azureAd | callbackPath | The path that Open Id Connect messages will be returned from Azure AD. In the majority of cases, you should never need to change this. This configurationoption may be removed in the future. See [Advanced Scenarios](docs/scenarios.md)| +| azureAd | signedOutCallbackPath | The path that the user will be redirected after clearing the session with Azure AD. It is not recommended that you change this. This configuration option may be removed in the future. See [Advanced Scenarios](docs/scenarios.md)| | azureAd | clientSecretKeyRefName, clientSecretKeyRefKey | Secret container and key for the client secret. Do not change these or set them directly or store the secret in a yaml file. Rather, provide your secret to helm via the command line via *--set secret.azureclientsecret=$CLIENT_SECRET* | | easyAuthForK8s | dataProtectionFileLocation | data protection key ring location. | | easyAuthForK8s | signinPath | The path that the proxy host will respond to sign-in requests. The default should not need to be changed, except for in [Advanced Scenarios](docs/scenarios.md). Note that when changing this value, you must also update the *nginx.ingress.kubernetes.io/auth-signin* annotation in your ingresses to match. | +| easyAuthForK8s | signoutPath | The path that the proxy use to sign out a user. The default should not need to be changed, except for in [Advanced Scenarios](docs/scenarios.md). | | easyAuthForK8s | authPath | The path that the proxy host will respond to auth requests. The default should not need to be changed, except for in [Advanced Scenarios](docs/scenarios.md). Note that when changing this value, you must also update the *nginx.ingress.kubernetes.io/auth-url* annotation in your ingresses to match. | | easyAuthForK8s | allowBearerToken | Default is "false". If "true" this will allow bearer tokens to be used in addition to cookies. Primarily for API callers. | | easyAuthForK8s | defaultRedirectAfterSignin | This is the final fallback url that the user will be routed to after succesfully logging in. Depending on your nginx configuration, the primary redirect preference will be the path provided by the "rd" query parameter, followed by the url that the user was originally trying to access. This option provides a tertiary and final fallback, with "/" being the default | +| easyAuthForK8s | defaultRedirectAfterSignout | This is the fallback url that the user will be routed to after logging out. This should be a page that allows anonymous access, otherwise it will result in another log in challenge that results in the user being signed in again. If you don't have page that allows anonymous access, you can remove this variable or set it to a value of "_blank" to configure EasyAuth to render a basic page for you. | | easyAuthForK8s | compressCookieClaims | Option is "true" by default, set "false" to disable. Experimental feature that serializes, compresses, and encodes the payload of non-essential claims to keep the cookie size as small as possible. This helps to avoid increasing the nginx header buffers beyond the default settings and reduces the size of the data sent from the client with each request.

**WARNING!**: *This feature may introduce a security vulnerability, although no specific vulnerability is known at this time. CRIME, a well-known exploit, takes advantage of compressed streams to decrypt data, however it requires the attacker to be able to introduce arbitrary data into the stream and observe its compressed state. For this feature we are only compressing a portion of the payload which an attacker should not be able to manipulate, so it should be safe in theory. To mitigate any potential concerns, avoid sending sensitive data to the back-end service, or disable this feature.* | | easyAuthForK8s | responseHeaderPrefix | Prefix for all user information headers. Default is *"x-injected-"*. There is no reason to change this unless you have multiple EasyAuth proxies protecting the same backend and need to discern the source of the headers. | | easyAuthForK8s | claimEncodingMethod | Default is *UrlEncode*, which should work for most situations. Valid values are: | @@ -81,4 +83,5 @@ Notes: - Not all graph queries work with all types of users, since many graph resources are dependent on the various product licenses that are assigned to the user. If a query raises errors, an `error` property will be returned along with a message. - Finally, graph queries are run against the Azure AD tenant that EasyAuth is configured to use, which is not necessarily a particular user's home tenant. For example, let's say EasyAuth is configured to use the "Contoso" tenant, which contains a B2B Guest user from the "Fabrikam" tenant. You utilize a graph query that looks for groups users belong to. In this case the results returned for the B2B user will be their "Contoso" group memberships, not groups they belong to in their home "Fabrikam" tenant. - +# Implementing Sign-out functionality for your protected applications +For web applications, EasyAuth can sign a user out of both the cookie session and Azure AD. To sign out a user, you'll need to redirect them to the `signoutPath`, for which the default value is `"/easyauth/logout"`, by providing a link or a button in your application. You may optionally provide a url within your application to return them to after signing out by either setting the `defaultRedirectAfterSignout` value in the helm chart, or by setting the `rd` query string parameter. An example of a link you direct the user to my look like `"/easyauth/logout?rd=/signedout.html"`. If you don't provide a redirect url by setting either of these options, EasyAuth will render a basic page for you. diff --git a/docs/scenarios.md b/docs/scenarios.md index 3cdd442..ac6a800 100644 --- a/docs/scenarios.md +++ b/docs/scenarios.md @@ -1,2 +1,41 @@ # Advanced Scenarios -Yikes! We haven't had time to complete this doc yet. We are working on it, so check back later for some interesting ways to configure EasyAuth. \ No newline at end of file + +## Multi-tenant apps +For applications that need to support multiple Azure AD tenants independently, you can configure and deploy multiple EasyAuth pods. As long as you can distinguish different tenants with ingress rules, you will be able to route auth requests to the correct pod. + +For example, let's say you have an application with the url "https://mysharedapp.constoso.com/". This app is a multitenant evironment, where the base url path identifies the tenant within the application ("https://mysharedapp.constoso.com/fabrikam). Configure the helm chart values of each EasyAuth pod with a unique `basePath`, so that the ingress rules can route auth requests to the correct pod. Assuming we use "fabrikam" as the basePath for our sample tenant, your ingress configuration would look something like: + +``` +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: easyauth-fabrikam-tenant + annotations: + nginx.ingress.kubernetes.io/auth-url: "https://$host/fabrikam/auth" + nginx.ingress.kubernetes.io/auth-signin: "https://$host/fabrikam/login" + nginx.ingress.kubernetes.io/auth-response-headers: "x-injected-userinfo,x-injected-name,x-injected-oid,x-injected-preferred-username,x-injected-sub,x-injected-tid,x-injected-email,x-injected-groups,x-injected-scp,x-injected-roles,x-injected-graph" + cert-manager.io/cluster-issuer: {{your-cert-manager}} + +spec: + ingressClassName: nginx + tls: + - hosts: + - {{APP_HOSTNAME}} + secretName: {{TLS_SECRET_NAME}} + rules: + - host: {{APP_HOSTNAME}} + http: + paths: + - path: /fabrikam + pathType: Prefix + backend: + service: + name: mysharedapp-pod + port: + number: 80 +``` + + +You will also need to update your Azure AD App Registration (or create a new one) to include the OIDC reply url for the fabrikam EasyAuth pod. The url will be in the form of `https://host/{{baseUrl}}/{{azureAd.callbackPath}}`, which in this case would be "https://mysharedapp.constoso.com/fabrikam//signin-oidc". See [Add a redirect URI](https://learn.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app#add-a-redirect-uri) for more information. + +Finally, you will need to update the helm chart values to reflect fabrikam's Azure AD tenant settings. At a miminum, you'll need to set `azureAd.tenantId` to the GUID Id of fabrikam's Azure AD tenant, as well as the `azureAd.domain` value (not required, but provides the best user experience). If you are sharing the same App Registration among EasyAuth pods, the `clientId` value will be the same. In all cases where the App Registration is configured in a tenant that is different than the `azureAd.tenantId` value, you'll need to ensure that the App Registraion is [Multitenant](https://learn.microsoft.com/en-us/azure/active-directory/develop/single-and-multi-tenant-apps). \ No newline at end of file diff --git a/sample/EasyAuthForK8s.Sample/Pages/Shared/_Layout.cshtml b/sample/EasyAuthForK8s.Sample/Pages/Shared/_Layout.cshtml index 3a41d07..a3ce111 100644 --- a/sample/EasyAuthForK8s.Sample/Pages/Shared/_Layout.cshtml +++ b/sample/EasyAuthForK8s.Sample/Pages/Shared/_Layout.cshtml @@ -32,7 +32,7 @@ Graph Query diff --git a/sample/templates/sample-ingress.yaml b/sample/templates/sample-ingress.yaml index 9581987..3466f96 100644 --- a/sample/templates/sample-ingress.yaml +++ b/sample/templates/sample-ingress.yaml @@ -25,7 +25,6 @@ spec: name: easyauth-sample-pod port: number: 80 - --- apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/src/EasyAuthForK8s.Web/Constants.cs b/src/EasyAuthForK8s.Web/Constants.cs index 7895bbb..a559b3e 100644 --- a/src/EasyAuthForK8s.Web/Constants.cs +++ b/src/EasyAuthForK8s.Web/Constants.cs @@ -21,10 +21,19 @@ public class Claims public const string Name = "n"; public const string Subject = "s"; public const string Role = "r"; + public const string LoginHint = "h"; + } + //non-standard claims that AAD supports for OIDC + public class AadClaimParameters + { + public const string LoginHint = "login_hint"; + public const string LogoutHint = "logout_hint"; } public static readonly string[] IgnoredClaims = { "aud","iss","iat","idp","nbf","exp","c_hash","at_hash","aio","nonce","rh","unique_name","uti","ver" }; + + public const string NoOpRedirectUri = "_blank"; } diff --git a/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs b/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs index a130df4..25be611 100644 --- a/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs +++ b/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs @@ -45,9 +45,22 @@ public static void AddEasyAuthForK8s(this IServiceCollection services, IConfigur { azureAdConfigSection.Bind(o); + var nextRedirectHandler = o.Events.OnRedirectToIdentityProvider; o.Events.OnRedirectToIdentityProvider = async context => await eventHelper.HandleRedirectToIdentityProvider(context, nextRedirectHandler); + + var nextRemoteSignOutHandler = o.Events.OnRemoteSignOut; + o.Events.OnRemoteSignOut = async context => + await eventHelper.OidcRemoteSignout(context, nextRemoteSignOutHandler); + + var nextSignedOutCallbackRedirectHandler = o.Events.OnSignedOutCallbackRedirect; + o.Events.OnSignedOutCallbackRedirect = async context => + await eventHelper.OidcRemoteSignout(context, nextSignedOutCallbackRedirectHandler); + + var nextRedirectToIdentityProviderForSignOutHandler = o.Events.OnRedirectToIdentityProviderForSignOut; + o.Events.OnRedirectToIdentityProviderForSignOut = async context => + await eventHelper.OidcRedirectForSignout(context, nextRedirectToIdentityProviderForSignOutHandler); }, c => { @@ -64,6 +77,9 @@ public static void AddEasyAuthForK8s(this IServiceCollection services, IConfigur configureOptions.ResponseType = "code"; configureOptions.SaveTokens = true; configureOptions.ReturnUrlParameter = Constants.RedirectParameterName; + configureOptions.UsePkce = true; + configureOptions.RemoteSignOutPath = easyAuthConfig.SignoutPath; + configureOptions.SignedOutRedirectUri = easyAuthConfig.DefaultRedirectAfterSignout; }); diff --git a/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs b/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs index 6be89ac..954b426 100644 --- a/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs +++ b/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs @@ -3,14 +3,22 @@ public class EasyAuthConfigurationOptions { public string DataProtectionFileLocation { get; set; } = "C:\\mnt\\dp"; public string SigninPath { get; set; } = "/easyauth/login"; + public string SignoutPath { get; set; } = "/easyauth/logout"; + public string SignedOutNoRedirectPath { get; set; } = "/easyauth/signedout"; public string AuthPath { get; set; } = "/easyauth/auth"; public bool AllowBearerToken { get; set; } = false; /// - /// provides a default path to send the user after successful login where the + /// Provides a default path to send the user after successful login where the /// RedirectParam query string has no value /// public string DefaultRedirectAfterSignin { get; set; } = "/"; + /// + /// Provides a default path to send the user after successful sign-out where the + /// RedirectParam query string has no value + /// + public string DefaultRedirectAfterSignout { get; set; } = Constants.NoOpRedirectUri; + /// /// Experimental feature that serializes, compresses, and encodes the payload /// of non-essential claims to keep the cookie size as small as possible. This diff --git a/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs b/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs index 878c62f..b1b0cce 100644 --- a/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs +++ b/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs @@ -12,10 +12,14 @@ using Microsoft.Net.Http.Headers; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Net.Http; using System.Security.Claims; using System.Text; using System.Threading.Tasks; +using System.Web; +using System.Xml.Linq; namespace EasyAuthForK8s.Web; @@ -66,7 +70,7 @@ public async Task HandleChallenge(HttpContext context) { EasyAuthState state = context.EasyAuthStateFromHttpContext(); - LogRequestHeaders("HandleChallenge", context.Request); + _logger.LogInformation($"Invoke HandleChallenge - Path:{ context.Request.Path}, Query: { context.Request.QueryString}"); if (state.Status == EasyAuthState.AuthStatus.Forbidden) { //show error or redirect diff --git a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs index 5e0c244..2375ca6 100644 --- a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs +++ b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs @@ -7,21 +7,25 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using Microsoft.Identity.Web; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using System; using System.Collections.Generic; using System.IdentityModel.Tokens.Jwt; using System.Linq; +using System.Reflection.Metadata.Ecma335; using System.Security.Claims; using System.Text; using System.Threading.Tasks; using System.Web; +using static System.Net.Mime.MediaTypeNames; namespace EasyAuthForK8s.Web.Helpers { internal class EventHelper { + private const string PrincipalParameter = Constants.CookieName; private readonly EasyAuthConfigurationOptions _configOptions; public EventHelper(EasyAuthConfigurationOptions configOptions) { @@ -148,6 +152,10 @@ public async Task CookieSigningIn(CookieSigningInContext context, { addClaim(claim, Constants.Claims.Subject); } + else if (claim.Type == Constants.AadClaimParameters.LoginHint) + { + addClaim(claim, Constants.Claims.LoginHint); + } } string? access_token = context.Properties.GetTokenValue("access_token"); @@ -218,6 +226,7 @@ public async Task CookieSigningIn(CookieSigningInContext context, { throw new InvalidOperationException("id_token is missing from authentication properties. Ensure that SaveTokens option is 'true'."); } + JwtSecurityToken jwtSecurityToken = new JwtSecurityToken(id_token); userInfo.PopulateFromClaims(jwtSecurityToken.Claims); @@ -285,6 +294,101 @@ await ErrorPage.Render(context.Response, manifestResult.Succeeded ? manifestResult.AppManifest! : new AppManifest(), reasonPhrase ?? ReasonPhrases.GetReasonPhrase(code), message); } + + /// + /// Handles the cookie signout before proceeding with the OIDC remote sign out + /// + /// + /// + /// + public async Task OidcRemoteSignout(RemoteSignOutContext context, Func next) + { + EnsureLogger(context.HttpContext); + + AuthenticationProperties properties = context.Properties!.Clone(); + //see if the request specified a post-signout redirect, otherwise the default is used. + properties.RedirectUri = context.HttpContext.Request.Query.ContainsKey(Constants.RedirectParameterName) ? + context.HttpContext.Request.Query[Constants.RedirectParameterName].First() : _configOptions.DefaultRedirectAfterSignout; + + //we won't have loaded a principal at this point, so authenticate to see if the user is actually logged in currently + AuthenticateResult authN = await context.HttpContext.AuthenticateAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + //perform cookie signout before redirecting; + await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); + + if (authN.Succeeded) + { + + _logger!.LogInformation($"Handle Signout - Subject:{authN.Principal?.Identity?.Name}, Path:{context.HttpContext.Request.Path}, Query:{context.HttpContext.Request.QueryString}"); + + //add the principal to the property bag, so it's available + //to later handlers, since the user is now signed out locally + properties!.Parameters[PrincipalParameter] = authN.Principal; + + await context.HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, properties); + } + else + { + _logger!.LogInformation($"Handle Signout - Current claims principal is missing or unauthenticated. Skipping remote redirect."); + + await OidcRemoteSignoutCallback(context, (ctx) => { ctx.Response.Redirect(properties.RedirectUri!); return Task.CompletedTask; }) ; + } + + //mark the response as handled, since we will either render our own page or sign-out of OIDC directly + context.HandleResponse(); + + await next(context).ConfigureAwait(false); + } + + /// + /// Configures the non-standard Azure AD protocol options that the Generic OIDC Handler ignores + /// see: https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-protocols-oidc + /// + /// + /// + /// + public async Task OidcRedirectForSignout(RedirectContext context, Func next) + { + if (context.Properties.Parameters.ContainsKey(PrincipalParameter) && !context.ProtocolMessage.Parameters.ContainsKey(Constants.AadClaimParameters.LogoutHint)) + { + ClaimsPrincipal? principal = context.Properties.Parameters[PrincipalParameter] as ClaimsPrincipal; + + //if we received a login hint in the original token, and we still have it, + //set the parameter to logout that specific user, so they won't have to choose + //which account to sign out of + if (principal != null && principal!.HasClaim(c => c.Type == Constants.Claims.LoginHint)) + { + context.ProtocolMessage.Parameters.Add(Constants.AadClaimParameters.LogoutHint, principal.FindFirst(Constants.Claims.LoginHint)?.Value as string); + } + } + await next(context).ConfigureAwait(false); + } + + /// + /// Checks to see if an external redirect after signout is provided, otherwise renders a basic page + /// + /// + /// + /// + public async Task OidcRemoteSignoutCallback(RemoteSignOutContext context, Func next) + { + if (context.Properties?.RedirectUri == Constants.NoOpRedirectUri) + { + EnsureLogger(context.HttpContext); + _logger!.LogInformation("Render internal signed-out page"); + + var graphService = context.HttpContext.RequestServices.GetService(); + + var manifestResult = graphService != null ? await graphService.GetManifestConfigurationAsync(context.HttpContext.RequestAborted) : new(); + + await SignedOutPage.Render(context.Response, + manifestResult.Succeeded ? manifestResult.AppManifest! : new AppManifest()); + + context.HandleResponse(); + } + await next(context).ConfigureAwait(false); + } + private string BuildScopeString(string baseScope, IList additionalScopes) { return string.Join(' ', (baseScope ?? string.Empty) diff --git a/src/EasyAuthForK8s.Web/Helpers/GraphHelperService.cs b/src/EasyAuthForK8s.Web/Helpers/GraphHelperService.cs index a55771f..97ec685 100644 --- a/src/EasyAuthForK8s.Web/Helpers/GraphHelperService.cs +++ b/src/EasyAuthForK8s.Web/Helpers/GraphHelperService.cs @@ -306,7 +306,7 @@ async Task IConfigurationRetriever.GetConfigurationAsy } catch (Exception ex) { - _logger.LogError(ex, "Error retrieving application manifest configuration."); + _logger.LogError(ex, $"Error retrieving application manifest configuration. {ex.Message}"); throw; } diff --git a/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs b/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs index ca95985..4ca4901 100644 --- a/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs +++ b/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs @@ -157,7 +157,7 @@ public static UserInfoPayload PopulateFromClaims(this UserInfoPayload payload, I break; case ClaimConstants.Scp: case ClaimConstants.Scope: - payload.email = claim.Value; + payload.scp = claim.Value; break; default: { diff --git a/src/EasyAuthForK8s.Web/SignedOutPage.cs b/src/EasyAuthForK8s.Web/SignedOutPage.cs new file mode 100644 index 0000000..3807b87 --- /dev/null +++ b/src/EasyAuthForK8s.Web/SignedOutPage.cs @@ -0,0 +1,95 @@ +using EasyAuthForK8s.Web.Models; +using Microsoft.AspNetCore.Http; +using System; +using System.Text; +using System.Threading.Tasks; + +namespace EasyAuthForK8s.Web +{ + public class SignedOutPage + { + public static async Task Render(HttpResponse response, AppManifest appManifest) + { + response.ContentType = "text/html"; + StringBuilder sb = new StringBuilder(); + sb.Append(""); + sb.Append(HeadHtml()); + sb.Append(""); + sb.Append("
"); + sb.Append("
"); + sb.Append(DescriptionHtml(appManifest?.appDisplayName ?? "this application")); + sb.Append(PublisherHtml(appManifest?.publisherName ?? "No Information")); + sb.Append(LinksHtml(appManifest?.info!)); + sb.Append("
"); //main + sb.Append("
"); + sb.Append(LogoObjectHtml(appManifest?.info!)); + sb.Append("
"); //right + sb.Append("
"); //container + sb.Append(""); + sb.Append(""); + + await response.WriteAsync(sb.ToString()); + await response.StartAsync(); + } + + private static string HeadingHtml(string heading) + { + return $"

{heading}

"; + } + private static string DescriptionHtml(string appname) + { + return $"

You have been signed out of {appname}. You may close this browser window.

"; + } + + private static string PublisherHtml(string publisher) + { + return $"

Publisher: {publisher}

"; + } + private static string LogoObjectHtml(AppManifest.Info info) + { + //insert app's image if available + if (info != null && !string.IsNullOrEmpty(info.logoUrl) && Uri.IsWellFormedUriString(info.logoUrl, UriKind.RelativeOrAbsolute)) + return $""; + else + return string.Empty; + } + private static string LinksHtml(AppManifest.Info info) + { + if (info != null + && (!string.IsNullOrEmpty(info.supportUrl) + || !string.IsNullOrEmpty(info.termsOfServiceUrl) + || !string.IsNullOrEmpty(info.marketingUrl) + || !string.IsNullOrEmpty(info.privacyStatementUrl))) + { + StringBuilder sb = new StringBuilder(); + sb.Append("

App Links: "); + if (!string.IsNullOrEmpty(info.supportUrl) && Uri.IsWellFormedUriString(info.supportUrl, UriKind.RelativeOrAbsolute)) + sb.Append("Support "); + if (!string.IsNullOrEmpty(info.termsOfServiceUrl) && Uri.IsWellFormedUriString(info.termsOfServiceUrl, UriKind.RelativeOrAbsolute)) + sb.Append("Terms Of Service "); + if (!string.IsNullOrEmpty(info.marketingUrl) && Uri.IsWellFormedUriString(info.marketingUrl, UriKind.RelativeOrAbsolute)) + sb.Append("Marketing "); + if (!string.IsNullOrEmpty(info.privacyStatementUrl) && Uri.IsWellFormedUriString(info.privacyStatementUrl, UriKind.RelativeOrAbsolute)) + sb.Append("Privacy Statement "); + sb.Append("

"); + + return sb.ToString(); + } + else + return ""; + } + private static string TitleText() + { + return "Signed Out"; + } + private static string HeadHtml() + { + return $"{TitleText()}"; + } + + private class Constants + { + public const string CSS = "*{box-sizing:border-box}svg,img{max-width:100%;max-height:225px}.container{overflow:auto}.main{max-width:fit-content;float:left;width:70%;padding:0 20px}.right{float:left;width:30%;padding:15px;margin-top:7px}#error_details{width:100%;padding:10px;background-color:#f5f5f5;display:none;border-left:10px solid red;font-family:monospace} a{width:100%;margin:7px}@media only screen and (max-width:620px){.main,.right{width:100%}.container{display:flex;flex-direction:column-reverse}.right{padding:0 20px;margin-top:0;padding-left:15px}svg,img{max-width:100%;max-height:150px;display:block}}"; + } + } +} diff --git a/src/EasyAuthForK8s.Web/appsettings.json b/src/EasyAuthForK8s.Web/appsettings.json index 4def9c1..234f8e6 100644 --- a/src/EasyAuthForK8s.Web/appsettings.json +++ b/src/EasyAuthForK8s.Web/appsettings.json @@ -18,6 +18,7 @@ "EasyAuthForK8s": { "DataProtectionFileLocation": "/mnt/dp", "SigninPath": "/easyauth/login", + "SignoutPath": "/easyauth/logout", "AuthPath": "/easyauth/auth", "AllowBearerToken": false, "DefaultRedirectAfterSignin": "/", diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/EasyAuthMiddlewareTests.cs b/src/Tests/EasyAuthForK8s.Tests.Web/EasyAuthMiddlewareTests.cs index e5d7ad9..5832600 100644 --- a/src/Tests/EasyAuthForK8s.Tests.Web/EasyAuthMiddlewareTests.cs +++ b/src/Tests/EasyAuthForK8s.Tests.Web/EasyAuthMiddlewareTests.cs @@ -182,7 +182,8 @@ public async Task Invoke_HandleAuth_ResponseHeadersSet_Graph() EasyAuthConfigurationOptions options = new EasyAuthConfigurationOptions() { HeaderFormatOption = EasyAuthConfigurationOptions.HeaderFormat.Separate }; - HttpResponseMessage response = await GetResponseForHeadersWithCookieSignedInAsync(options); + using var server = await CookieAuthHelper.GetTestServerWithCookieSignedInAsync(options); + HttpResponseMessage response = await server.CreateClient().GetAsync(options.AuthPath); Assert.Equal(HttpStatusCode.Accepted, response.StatusCode); @@ -343,7 +344,7 @@ public async Task Invoke_HandleChallenge_Redirect() .ConfigureServices(services => { services.AddSingleton>(logger.Factory().CreateLogger()); - services.AddEasyAuthForK8s(GetConfiguration(options), logger.Factory()); + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), logger.Factory()); //swap out the cookiehandler with one that will do what we tell it services.Configure(options => @@ -362,7 +363,7 @@ public async Task Invoke_HandleChallenge_Redirect() //explicitly add a requested scope configureOptions.Scope.Add("User.Read"); }); - services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelper())); + services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); }) .ConfigureWebHost(webHostBuilder => { @@ -409,13 +410,7 @@ public async Task Invoke_HandleChallenge_Redirect() //this is not a requirement, just ensuring the sort is deterministic Assert.Equal("User.Read", scopeValues[scopeValues.Length - 2]); } - private IConfiguration GetConfiguration(EasyAuthConfigurationOptions options) - { - return new ConfigurationBuilder() - .AddJsonFile("testsettings.json", false, true) - .Add(new EasyAuthOptionsConfigurationSource(options)) - .Build(); - } + private HttpResponseMessage GetResponseForAuthN( EasyAuthConfigurationOptions options, string query, @@ -429,8 +424,8 @@ private HttpResponseMessage GetResponseForAuthN( .ConfigureServices(services => { services.AddSingleton>(logger.Factory().CreateLogger()); - services.AddEasyAuthForK8s(GetConfiguration(options), logger.Factory()); - services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelper())); + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), logger.Factory()); + services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); }) .ConfigureWebHost(webHostBuilder => { @@ -476,7 +471,7 @@ private HttpResponseMessage GetResponseForAuthZ( .ConfigureServices(services => { services.AddSingleton>(logger.Factory().CreateLogger()); - services.AddEasyAuthForK8s(GetConfiguration(options), logger.Factory()); + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), logger.Factory()); if (handlerOptions != null) services.AddSingleton(handlerOptions); @@ -490,7 +485,7 @@ private HttpResponseMessage GetResponseForAuthZ( s.HandlerType = typeof(TestAuthenticationHandler); } }); - services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelper())); + services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); }) .ConfigureWebHost(webHostBuilder => { @@ -523,7 +518,7 @@ private async Task GetResponseForHeadersAsync( using IHost host = new HostBuilder() .ConfigureServices(services => { - services.AddEasyAuthForK8s(GetConfiguration(options), new TestLogger().Factory()); + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), new TestLogger().Factory()); if (handlerOptions != null) services.AddSingleton(handlerOptions); @@ -537,37 +532,7 @@ private async Task GetResponseForHeadersAsync( s.HandlerType = typeof(TestAuthenticationHandler); } }); - services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelper())); - }) - .ConfigureWebHost(webHostBuilder => - { - webHostBuilder - .UseTestServer() - .Configure(app => - { - app.UseEasyAuthForK8s(); - }); - }).Build(); - - await host.StartAsync(); - - return await host.GetTestServer() - .CreateClient() - .GetAsync(options.AuthPath); - - } - private async Task GetResponseForHeadersWithCookieSignedInAsync( - EasyAuthConfigurationOptions options, - TestAuthenticationHandlerOptions handlerOptions = null) - { - using IHost host = new HostBuilder() - .ConfigureServices(services => - { - services.AddEasyAuthForK8s(GetConfiguration(options), new TestLogger().Factory()); - if (handlerOptions != null) - services.AddSingleton(handlerOptions); - - services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelper())); + services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); }) .ConfigureWebHost(webHostBuilder => { @@ -575,55 +540,6 @@ private async Task GetResponseForHeadersWithCookieSignedInA .UseTestServer() .Configure(app => { - app.Use(async (context, next) => - { - var token = new JwtSecurityToken("eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb24ifQ."); - AuthenticationProperties props = new AuthenticationProperties( - new Dictionary() { - { Constants.OidcGraphQueryStateBag, "foo" }, - { ".Token.access_token", new JwtSecurityTokenHandler().WriteToken(token) }, - { ".Token.id_token", new JwtSecurityTokenHandler().WriteToken(token) } - }); - - var signedIn = false; - var cookieValue = ""; - - //inject resultant cookie from the response back into to the request - var cookies = new Mock(); - cookies.Setup(x => x[Constants.CookieName]).Returns(() => - { - //force the sign in logic to run, which will execute graph queries - //should only run once - if (!signedIn) - { - signedIn = true; - - context.SignInAsync( - CookieAuthenticationDefaults.AuthenticationScheme, - new TestAuthenticationHandler().AuthenticateAsync().Result.Principal, - props).Wait(); - - var cookies = CookieHeaderValue.ParseList(context.Response.Headers.SetCookie); - - cookieValue = cookies - .Where(x => x.Name == Constants.CookieName) - .Select(x => x.Value) - .First() - .ToString(); - - } - return cookieValue; - } - ); - cookies.Setup(x => x.ContainsKey(Constants.CookieName)).Returns(true); - context.Request.Cookies = cookies.Object; - - - - - - await next.Invoke(); - }); app.UseEasyAuthForK8s(); }); }).Build(); @@ -635,9 +551,7 @@ private async Task GetResponseForHeadersWithCookieSignedInA .GetAsync(options.AuthPath); } - - - + private EasyAuthState GetStateFromResponseWithAsserts(IDataProtector dp, HttpResponseMessage response) { Assert.True(response.Headers.Contains(HeaderNames.SetCookie)); @@ -659,44 +573,5 @@ private async Task EvaluateMessagesWithAsserts(string containsMessage, HttpConte Assert.Contains(containsMessage, state.Msg); Assert.Contains(logs, x => x.Message.Contains(containsMessage)); } - private GraphHelperService MockGraphHelper(ILogger logger = null) - { - var manifest = new AppManifest() - { - appId = TestUtility.DummyGuid, - publishedPermissionScopes = new() - { - new() { value = "foo" }, - new() { value = "bar" } - }, - oidcScopes = new string[] - { - "openid", - "profile", - "email", - "offline_access" - } - }; - - var openIdConnectOptions = Mock.Of>(); - var httpClient = Mock.Of(); - logger = logger ?? Mock.Of>(); - - var graphService = new Mock(openIdConnectOptions, httpClient, logger); - - graphService.Setup(x => x.GetManifestConfigurationAsync(It.IsAny())) - .ReturnsAsync(new AppManifestResult() { AppManifest = manifest, Succeeded = true }); - - graphService.Setup(x => x.ExecuteQueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .ReturnsAsync(() => - { - var data = new List(); - GraphHelperService.ExtractGraphResponse(data, File.OpenRead("./Helpers/sample-graph-result.json"), logger).Wait(); - return data; - }); - - return graphService.Object; - } - } diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs new file mode 100644 index 0000000..ac0b1e9 --- /dev/null +++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs @@ -0,0 +1,127 @@ +using EasyAuthForK8s.Web; +using EasyAuthForK8s.Web.Helpers; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Builder; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.AspNetCore.Authentication; +using Moq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Net.Http.Headers; +using System.Net; +using Xunit; + +namespace EasyAuthForK8s.Tests.Web.Helpers +{ + public class CookieAuthHelper + { + public static async Task GetTestServerWithCookieSignedInAsync( + EasyAuthConfigurationOptions options, + TestAuthenticationHandlerOptions handlerOptions = null) + { + IHost host = new HostBuilder() + .ConfigureServices(services => + { + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), new TestLogger().Factory()); + if (handlerOptions != null) + services.AddSingleton(handlerOptions); + + services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); + }) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .Configure(app => + { + app.Use(async (context, next) => + { + var token = new JwtSecurityToken("eyJhbGciOiJub25lIn0.eyJpc3MiOiJqb24ifQ."); + AuthenticationProperties props = new AuthenticationProperties( + new Dictionary() { + { Constants.OidcGraphQueryStateBag, "foo" }, + { ".Token.access_token", new JwtSecurityTokenHandler().WriteToken(token) }, + { ".Token.id_token", new JwtSecurityTokenHandler().WriteToken(token) } + }); + + var signedIn = false; + var cookieValue = ""; + + //inject resultant cookie from the response back into to the request + var cookies = new Mock(); + cookies.Setup(x => x[Constants.CookieName]).Returns(() => + { + //force the sign in logic to run, which will execute graph queries + //should only run once + if (!signedIn) + { + signedIn = true; + + context.SignInAsync( + CookieAuthenticationDefaults.AuthenticationScheme, + new TestAuthenticationHandler(handlerOptions).AuthenticateAsync().Result.Principal, + props).Wait(); + + var cookies = CookieHeaderValue.ParseList(context.Response.Headers.SetCookie); + + cookieValue = cookies + .Where(x => x.Name == Constants.CookieName) + .Select(x => x.Value) + .First() + .ToString(); + + } + return cookieValue; + } + ); + cookies.Setup(x => x.ContainsKey(Constants.CookieName)).Returns(true); + context.Request.Cookies = cookies.Object; + + await next.Invoke(); + }); + app.UseEasyAuthForK8s(); + }); + }).Build(); + + await host.StartAsync(); + + return host.GetTestServer(); + + } + public static CookieCollection GetCookiesFromResponseMessage(HttpResponseMessage response) + { + CookieContainer cookieContainer = new CookieContainer(); + var uri = new Uri("http://localhost"); + var setCookie = response.Headers.Where(x => x.Key == HeaderNames.SetCookie).Single(); + foreach(var value in setCookie.Value) + { + cookieContainer.SetCookies(uri, value); + } + return cookieContainer.GetAllCookies(); + } + + public static SetCookieHeaderValue? GetAuthCookieFromResponse(HttpResponseMessage? response) + { + if(response != null && response.Headers.Contains(HeaderNames.SetCookie)) + { + IList cookies; + if(SetCookieHeaderValue.TryParseList(response.Headers.Single(x => x.Key == HeaderNames.SetCookie).Value.ToList(), out cookies) + && cookies.Any(c => c.Name == Constants.CookieName)) + { + return cookies.First(c => c.Name == Constants.CookieName); + } + } + return null; + } + } +} diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/MockGraphHelperService.cs b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/MockGraphHelperService.cs new file mode 100644 index 0000000..37f847d --- /dev/null +++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/MockGraphHelperService.cs @@ -0,0 +1,59 @@ +using EasyAuthForK8s.Web.Helpers; +using EasyAuthForK8s.Web.Models; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace EasyAuthForK8s.Tests.Web.Helpers +{ + public class MockGraphHelperService + { + public static GraphHelperService Factory(ILogger logger = null) + { + var manifest = new AppManifest() + { + appId = TestUtility.DummyGuid, + publishedPermissionScopes = new() + { + new() { value = "foo" }, + new() { value = "bar" } + }, + oidcScopes = new string[] + { + "openid", + "profile", + "email", + "offline_access" + } + }; + + var openIdConnectOptions = Mock.Of>(); + var httpClient = Mock.Of(); + logger = logger ?? Mock.Of>(); + + var graphService = new Mock(openIdConnectOptions, httpClient, logger); + + graphService.Setup(x => x.GetManifestConfigurationAsync(It.IsAny())) + .ReturnsAsync(new AppManifestResult() { AppManifest = manifest, Succeeded = true }); + + graphService.Setup(x => x.ExecuteQueryAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + var data = new List(); + GraphHelperService.ExtractGraphResponse(data, File.OpenRead("./Helpers/sample-graph-result.json"), logger).Wait(); + return data; + }); + + return graphService.Object; + } + } +} diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestAuthenticationHandler.cs b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestAuthenticationHandler.cs index 02c5042..a274fb4 100644 --- a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestAuthenticationHandler.cs +++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestAuthenticationHandler.cs @@ -40,7 +40,7 @@ public Task AuthenticateAsync() public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context) => Task.FromResult(0); #pragma warning restore CS8632 // The annotation for nullable reference types should only be used in code within a '#nullable' annotations context. } -internal class TestAuthenticationHandlerOptions +public class TestAuthenticationHandlerOptions { public List Claims { get; set; } = new List(); } diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestUtility.cs b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestUtility.cs index b38dd5b..5ba7c36 100644 --- a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestUtility.cs +++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/TestUtility.cs @@ -1,5 +1,7 @@ -using Microsoft.AspNetCore.Http; +using EasyAuthForK8s.Web; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; using System; using System.Collections.Generic; @@ -39,5 +41,12 @@ public static QueryCollection ParseQuery(string query) } return new QueryCollection(keyValuePairs); } + public static IConfiguration GetConfiguration(EasyAuthConfigurationOptions options) + { + return new ConfigurationBuilder() + .AddJsonFile("testsettings.json", false, true) + .Add(new EasyAuthOptionsConfigurationSource(options)) + .Build(); + } } } diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs b/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs new file mode 100644 index 0000000..d87256b --- /dev/null +++ b/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs @@ -0,0 +1,131 @@ +using EasyAuthForK8s.Tests.Web.Helpers; +using EasyAuthForK8s.Web; +using EasyAuthForK8s.Web.Helpers; +using EasyAuthForK8s.Web.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; +using Microsoft.Net.Http.Headers; +using Moq; +using System; +using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Claims; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Web; +using Xunit; + +namespace EasyAuthForK8s.Tests.Web +{ + public class SignoutTests + { + [Fact] + public async Task CookieDeletedAndRedirectOnSignOut() + { + const string login_hint = "myloginhint"; + var options = new EasyAuthConfigurationOptions() { DefaultRedirectAfterSignout = "/testsignout-post-redirect" }; + using var server = await CookieAuthHelper.GetTestServerWithCookieSignedInAsync(options, + new () { Claims = { new System.Security.Claims.Claim( Constants.AadClaimParameters.LoginHint, login_hint) } }); + var cookieHttpClient = server.CreateClient(); + + //send a dummy request to set the auth cookie + var authCookie = CookieAuthHelper.GetAuthCookieFromResponse(await cookieHttpClient.GetAsync("foo")); + + Assert.NotNull(authCookie); + Assert.False(StringSegment.IsNullOrEmpty(authCookie.Value)); + Assert.False(authCookie.Expires.HasValue && authCookie.Expires.Value == DateTimeOffset.MinValue); + + //validate cookie is invalidated after sign out + HttpResponseMessage signoutResponse = await GetResponseForSignout(options, authCookie); + + var authCookieSignedOut = CookieAuthHelper.GetAuthCookieFromResponse(signoutResponse); + + Assert.NotNull(authCookie); + Assert.True(StringSegment.IsNullOrEmpty(authCookieSignedOut.Value)); + Assert.True(authCookieSignedOut.Expires.HasValue && authCookieSignedOut.Expires.Value == DateTimeOffset.UnixEpoch); + + //verify we are redirected for remote signout + Assert.Equal(HttpStatusCode.Found, signoutResponse.StatusCode); + Assert.Contains(signoutResponse.Headers, x => x.Key == HeaderNames.Location); + + var oidcOptions = (server.Services.GetService(typeof(IOptionsMonitor)) as IOptionsMonitor).Get(OpenIdConnectDefaults.AuthenticationScheme); + var configuration = await oidcOptions.ConfigurationManager.GetConfigurationAsync(CancellationToken.None); + + var redirectUri = new Uri(signoutResponse.Headers.Single(x => x.Key == HeaderNames.Location).Value.First()); + + //make sure are are redirecting to the right place + Assert.Equal(configuration.EndSessionEndpoint, redirectUri.AbsoluteUri.Replace(redirectUri.Query, string.Empty)); + + var parameters = HttpUtility.ParseQueryString(redirectUri.Query); + + //make sure the logout hint is provided and matches + Assert.Equal(login_hint, parameters.Get(Constants.AadClaimParameters.LogoutHint)); + + //make sure default post-signout redirect url is used. + var authProperties = oidcOptions.StateDataFormat.Unprotect(parameters.Get("state")); + + Assert.Equal(options.DefaultRedirectAfterSignout, authProperties.RedirectUri); + + //override the default post-signout redirect + HttpResponseMessage signoutWithCustomRedirect = await GetResponseForSignout(options, authCookie, "/foo"); + var customRedirectUri = new Uri(signoutWithCustomRedirect.Headers.Single(x => x.Key == HeaderNames.Location).Value.First()); + var customAuthProps = oidcOptions.StateDataFormat.Unprotect(HttpUtility.ParseQueryString(customRedirectUri.Query).Get("state")); + + Assert.Equal("/foo", customAuthProps.RedirectUri); + } + private async Task GetResponseForSignout( + EasyAuthConfigurationOptions options, + SetCookieHeaderValue authCookie, + string redirectUri = null + ) + { + var redirectQuery = string.IsNullOrEmpty(redirectUri) ? string.Empty : $"?{Constants.RedirectParameterName}={HttpUtility.UrlEncode(redirectUri)}"; + TestLogger logger = new TestLogger(); + + using IHost host = new HostBuilder() + .ConfigureServices(services => + { + services.AddSingleton>(logger.Factory().CreateLogger()); + services.AddEasyAuthForK8s(TestUtility.GetConfiguration(options), logger.Factory()); + // services.Replace(new ServiceDescriptor(typeof(GraphHelperService), MockGraphHelperService.Factory())); + }) + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseTestServer() + .Configure(app => + { + app.UseEasyAuthForK8s(); + }); + }).Build(); + + await host.StartAsync(); + + var client = host.GetTestServer().CreateClient(); + + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, new Uri($"{options.SignoutPath}{redirectQuery}", UriKind.RelativeOrAbsolute)); + request.Headers.Add("Cookie", $"{authCookie.Name}={authCookie.Value}"); + return await client.SendAsync(request); + } + } +}