diff --git a/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs b/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs
index fdaeeb0..d9794a3 100644
--- a/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs
+++ b/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs
@@ -248,13 +248,11 @@ public async Task HandleLogout(HttpContext context)
LogRequestHeaders("HandleLogout", context.Request);
- await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
-
- // Re route the user to Azure AD to logout
-
// Delete the cookie
- context.Response.Cookies.Delete(Constants.StateCookieName);
+ context.Response.Cookies.Delete(Constants.CookieName);
+ // Re route the user to Azure AD to logout
+ await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = _configureOptions.DefaultRedirectAfterSignin }) ;
}
private void LogRequestHeaders(string prefix, HttpRequest request)
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": "/",
From 58ebcccc1d909c313604a4b59ef42972a4bd0715 Mon Sep 17 00:00:00 2001
From: Isabelle Bersano
Date: Wed, 22 Feb 2023 16:31:22 -0600
Subject: [PATCH 08/11] fix: reverted changes made while debugging
---
.github/workflows/E2E.yml | 20 ++++++++++----------
charts/easyauth-proxy/values.yaml | 4 ++--
main.sh | 2 +-
sample/templates/sample-ingress.yaml | 10 ----------
4 files changed, 13 insertions(+), 23 deletions(-)
diff --git a/.github/workflows/E2E.yml b/.github/workflows/E2E.yml
index 6682b26..a62a389 100644
--- a/.github/workflows/E2E.yml
+++ b/.github/workflows/E2E.yml
@@ -81,15 +81,15 @@ jobs:
run: |
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()
- # 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
- # fi
- # app_id=$(az ad app list --display-name ${{ vars.e2ePrefix}}-${{ env.GITHUB_PR_NUMBER }} --query "[0].appId" -o tsv)
- # if [ "$app_id" != "" ]; then
- # az ad app delete --id $app_id
- # fi
+ - name: Delete e2e environment
+ 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
+ fi
+ app_id=$(az ad app list --display-name ${{ vars.e2ePrefix}}-${{ env.GITHUB_PR_NUMBER }} --query "[0].appId" -o tsv)
+ if [ "$app_id" != "" ]; then
+ az ad app delete --id $app_id
+ fi
diff --git a/charts/easyauth-proxy/values.yaml b/charts/easyauth-proxy/values.yaml
index 3630b39..61e1d5a 100644
--- a/charts/easyauth-proxy/values.yaml
+++ b/charts/easyauth-proxy/values.yaml
@@ -9,10 +9,10 @@ basePath: "/easyauth"
replicaCount: 2
image:
- repository: docker.io/ibersanoms/easy-auth-proxy
+ repository: ghcr.io/azure/easyauthfork8s/easy-auth-proxy
pullPolicy: Always
# Overrides the image tag whose default is the chart appVersion.
- tag: pr-91
+ tag: v1.0.2
imagePullSecrets: []
nameOverride: ""
diff --git a/main.sh b/main.sh
index 03b1ffb..2cbac88 100644
--- a/main.sh
+++ b/main.sh
@@ -156,7 +156,7 @@ if [ -z "$INPUTIMAGE" ]; then
kubectl run easyauth-sample-pod --image=docker.io/dakondra/eak-sample:latest --expose --port=80
else
echo "Your custom image $INPUTIMAGE installed"
- kubectl run easyauth-sample-pod --image=$INPUTIMAGE --expose --port=80
+ kubectl run custom-pod --image=$INPUTIMAGE --expose --port=80
fi
echo "COMPLETE @ $(date +"%T"): Deployed sample app"
diff --git a/sample/templates/sample-ingress.yaml b/sample/templates/sample-ingress.yaml
index 813f960..3466f96 100644
--- a/sample/templates/sample-ingress.yaml
+++ b/sample/templates/sample-ingress.yaml
@@ -25,16 +25,6 @@ spec:
name: easyauth-sample-pod
port:
number: 80
- - host: {{APP_HOSTNAME}}
- http:
- paths:
- - path: /Signout
- pathType: Prefix
- backend:
- service:
- name: easyauth-sample-pod
- port:
- number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
From 27d80bd21fbc24282216e6a097e2485059852a3f Mon Sep 17 00:00:00 2001
From: Jon Lester
Date: Tue, 28 Feb 2023 17:56:49 -0500
Subject: [PATCH 09/11] fixes
-Moved signout out of the middleware to allow the OIDC handler to do it's job
- added configuration option for default page to arrive at after sign-out, ability to override with a redirect url parameter, and added a basic page that renders if the user is signed out but there's no option for an anonymous access page to send them back to
- preserve the login_hint claim in the easyauth cookie so that we can use the "logout_hint" protocol extension to log the user out of a specific account. Added the optional claim to the app manifest creation.
- updated docs.
- TODO: unit tests
---
AutomationScripts/3-registerAADApp.sh | 2 +-
TemplateFiles/claims.json | 10 ++
.../easyauth-proxy/templates/statefulset.yaml | 8 +-
charts/easyauth-proxy/values.yaml | 4 +-
docs/configuration.md | 6 +-
src/EasyAuthForK8s.Web/Constants.cs | 2 +
.../EasyAuthBuilderExtensions.cs | 15 +++
.../EasyAuthConfigurationOptions.cs | 9 +-
src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs | 23 +---
src/EasyAuthForK8s.Web/Helpers/EventHelper.cs | 101 ++++++++++++++++++
.../Helpers/GraphHelperService.cs | 2 +-
.../Models/ModelExtensions.cs | 5 +-
.../Models/UserInfoPayload.cs | 5 +
src/EasyAuthForK8s.Web/SignedOutPage.cs | 95 ++++++++++++++++
src/EasyAuthForK8s.Web/appsettings.json | 1 -
15 files changed, 258 insertions(+), 30 deletions(-)
create mode 100644 TemplateFiles/claims.json
create mode 100644 src/EasyAuthForK8s.Web/SignedOutPage.cs
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 4060f78..d8d6186 100644
--- a/charts/easyauth-proxy/templates/statefulset.yaml
+++ b/charts/easyauth-proxy/templates/statefulset.yaml
@@ -65,7 +65,11 @@ spec:
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 }}"
@@ -83,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 61e1d5a..612a4af 100644
--- a/charts/easyauth-proxy/values.yaml
+++ b/charts/easyauth-proxy/values.yaml
@@ -102,7 +102,6 @@ azureAd:
# there's no reason to change these unless there is a conflict
# such as another easyauth proxy using the same host name
callbackPath: "/signin-oidc"
- signedOutCallbackPath : "/signout-callback-oidc"
# Leave this blank if not B2C
signUpSignInPolicyId: ""
@@ -121,6 +120,9 @@ easyAuthForK8s:
# 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..20708cc 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -17,14 +17,15 @@ Here's a list of possible configuration options for the EasyAuth Proxy, which yo
| 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 | 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:
*UrlEncode*: Invalid characters are escaped according to IETF RFC 3986
*Base64*: The full string value is encoded from UTF-8 bytes to base64 text
*None*: Value is not encoded, and the original string value is sent. This may cause errors for downstream web servers, especially on older platforms
*NoneWithReject*: No encoding is applied, but any header value containing an unsafe character is rejected, and the value "encoding_error" is sent in its place
|
@@ -81,4 +82,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/src/EasyAuthForK8s.Web/Constants.cs b/src/EasyAuthForK8s.Web/Constants.cs
index 7895bbb..89c02ce 100644
--- a/src/EasyAuthForK8s.Web/Constants.cs
+++ b/src/EasyAuthForK8s.Web/Constants.cs
@@ -26,5 +26,7 @@ public class Claims
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..a619202 100644
--- a/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs
+++ b/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs
@@ -48,6 +48,18 @@ public static void AddEasyAuthForK8s(this IServiceCollection services, IConfigur
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 +76,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 31d6513..954b426 100644
--- a/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs
+++ b/src/EasyAuthForK8s.Web/EasyAuthConfigurationOptions.cs
@@ -4,14 +4,21 @@ 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 d9794a3..b1b0cce 100644
--- a/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs
+++ b/src/EasyAuthForK8s.Web/EasyAuthMiddleWare.cs
@@ -12,11 +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;
@@ -59,11 +62,6 @@ public async Task InvokeAsync(HttpContext context)
await HandleAuth(context);
return;
}
- else if (_configureOptions.SignoutPath == context.Request.Path)
- {
- await HandleLogout(context);
- return;
- }
// Call the next delegate/middleware in the pipeline
await _next(context);
@@ -72,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
@@ -242,19 +240,6 @@ public async Task HandleAuth(HttpContext context)
}
- public async Task HandleLogout(HttpContext context)
- {
- EasyAuthState state = context.EasyAuthStateFromHttpContext();
-
- LogRequestHeaders("HandleLogout", context.Request);
-
- // Delete the cookie
- context.Response.Cookies.Delete(Constants.CookieName);
-
- // Re route the user to Azure AD to logout
- await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, new AuthenticationProperties { RedirectUri = _configureOptions.DefaultRedirectAfterSignin }) ;
- }
-
private void LogRequestHeaders(string prefix, HttpRequest request)
{
StringBuilder sb = new StringBuilder();
diff --git a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
index 5e0c244..f9135bd 100644
--- a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
+++ b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
@@ -7,21 +7,26 @@
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
{
+ public const string LoginHint = "login_hint";
+
private readonly EasyAuthConfigurationOptions _configOptions;
public EventHelper(EasyAuthConfigurationOptions configOptions)
{
@@ -218,6 +223,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 +291,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}");
+
+ var userinfo = authN.Principal?.UserInfoPayloadFromPrincipal(_configOptions);
+ if (userinfo != null)
+ {
+ //set the login_hint for the redirect to the account we should sign out of
+ //avoids asking the user to choose.LoginHint
+ if (properties.Parameters.ContainsKey(LoginHint))
+ properties.Parameters[LoginHint] = userinfo.login_hint;
+ else
+ properties.Parameters.Add(LoginHint, userinfo.login_hint);
+ }
+
+ 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(LoginHint) && !context.ProtocolMessage.Parameters.ContainsKey("logout_hint"))
+ {
+ context.ProtocolMessage.Parameters.Add("logout_hint", context.Properties.Parameters[LoginHint] 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..1fadf63 100644
--- a/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs
+++ b/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs
@@ -157,7 +157,10 @@ public static UserInfoPayload PopulateFromClaims(this UserInfoPayload payload, I
break;
case ClaimConstants.Scp:
case ClaimConstants.Scope:
- payload.email = claim.Value;
+ payload.scp = claim.Value;
+ break;
+ case "login_hint":
+ payload.login_hint = claim.Value;
break;
default:
{
diff --git a/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs b/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
index 96f2964..7c2193d 100644
--- a/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
+++ b/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
@@ -32,6 +32,11 @@ public class UserInfoPayload
public List otherClaims { get; set; } = new List();
[Key(10)]
public List graph { get; set; } = new List();
+
+ //the following claims are used internally for session management, but not passed to
+ //the protected application
+ [Key(11)]
+ public string login_hint { get; set; } = string.Empty;
}
[MessagePackObject]
public struct ClaimValue
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("
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 234f8e6..a9e2fab 100644
--- a/src/EasyAuthForK8s.Web/appsettings.json
+++ b/src/EasyAuthForK8s.Web/appsettings.json
@@ -5,7 +5,6 @@
"TenantId": "{your tenant id guid}",
"ClientId": "{your app id guid}",
"CallbackPath": "/easyauth/signin-oidc",
- "SignedOutCallbackPath ": "/easyauth/signout-callback-oidc",
"ClientSecret": "{your secret}",
"SignUpSignInPolicyId": "",
"Scopes": "User.Read"
From 9733bf03f6d915351a3c75330b8f3391ac900764 Mon Sep 17 00:00:00 2001
From: Jon Lester
Date: Wed, 1 Mar 2023 16:24:26 -0500
Subject: [PATCH 10/11] Added basic test for signout
- Test cookie signout and oidc redirect
- some doc updates
---
charts/easyauth-proxy/values.yaml | 19 ++-
docs/configuration.md | 3 +-
docs/scenarios.md | 41 ++++-
.../EasyAuthBuilderExtensions.cs | 1 +
src/EasyAuthForK8s.Web/appsettings.json | 1 +
.../EasyAuthMiddlewareTests.cs | 149 ++----------------
.../Helpers/CookieAuthHelper.cs | 112 +++++++++++++
.../Helpers/MockGraphHelperService.cs | 59 +++++++
.../Helpers/TestAuthenticationHandler.cs | 2 +-
.../Helpers/TestUtility.cs | 11 +-
.../EasyAuthForK8s.Tests.Web/SignoutTests.cs | 72 +++++++++
11 files changed, 323 insertions(+), 147 deletions(-)
create mode 100644 src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs
create mode 100644 src/Tests/EasyAuthForK8s.Tests.Web/Helpers/MockGraphHelperService.cs
create mode 100644 src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs
diff --git a/charts/easyauth-proxy/values.yaml b/charts/easyauth-proxy/values.yaml
index 612a4af..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,19 +104,20 @@ 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"
- # 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"
diff --git a/docs/configuration.md b/docs/configuration.md
index 20708cc..3bffd97 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -16,8 +16,9 @@ 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 | 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. |
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/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs b/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs
index a619202..25be611 100644
--- a/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs
+++ b/src/EasyAuthForK8s.Web/EasyAuthBuilderExtensions.cs
@@ -45,6 +45,7 @@ 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);
diff --git a/src/EasyAuthForK8s.Web/appsettings.json b/src/EasyAuthForK8s.Web/appsettings.json
index a9e2fab..234f8e6 100644
--- a/src/EasyAuthForK8s.Web/appsettings.json
+++ b/src/EasyAuthForK8s.Web/appsettings.json
@@ -5,6 +5,7 @@
"TenantId": "{your tenant id guid}",
"ClientId": "{your app id guid}",
"CallbackPath": "/easyauth/signin-oidc",
+ "SignedOutCallbackPath ": "/easyauth/signout-callback-oidc",
"ClientSecret": "{your secret}",
"SignUpSignInPolicyId": "",
"Scopes": "User.Read"
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..5a2a184
--- /dev/null
+++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs
@@ -0,0 +1,112 @@
+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;
+
+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().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();
+ }
+ }
+}
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..136f797
--- /dev/null
+++ b/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs
@@ -0,0 +1,72 @@
+using EasyAuthForK8s.Tests.Web.Helpers;
+using EasyAuthForK8s.Web;
+using Microsoft.AspNetCore.Authentication.OpenIdConnect;
+using Microsoft.Extensions.Options;
+using Microsoft.Extensions.Primitives;
+using Microsoft.IdentityModel.Protocols;
+using Microsoft.IdentityModel.Protocols.OpenIdConnect;
+using Microsoft.Net.Http.Headers;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace EasyAuthForK8s.Tests.Web
+{
+ public class SignoutTests
+ {
+ [Fact]
+ public async Task CookieDeletedAndRedirectOnSignOut()
+ {
+ var options = new EasyAuthConfigurationOptions();
+ using var server = await CookieAuthHelper.GetTestServerWithCookieSignedInAsync(options);
+ var client = server.CreateClient();
+
+ //send a dummy request to set the auth cookie
+ HttpResponseMessage response = await client.GetAsync("foo");
+
+ //validate initial cookie
+ Assert.Contains(response.Headers, x => x.Key == HeaderNames.SetCookie);
+
+ var cookiesSignedIn = SetCookieHeaderValue.ParseList(response.Headers.Single(x => x.Key == HeaderNames.SetCookie).Value.ToList());
+ Assert.Contains(cookiesSignedIn, x => x.Name == Constants.CookieName);
+
+ var authCookie = cookiesSignedIn.Single(x => x.Name == Constants.CookieName);
+
+ 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 client.GetAsync(options.SignoutPath);
+ Assert.Contains(signoutResponse.Headers, x => x.Key == HeaderNames.SetCookie);
+
+ var cookiesSignedOut = SetCookieHeaderValue.ParseList(signoutResponse.Headers.Single(x => x.Key == HeaderNames.SetCookie).Value.ToList());
+ Assert.Contains(cookiesSignedOut, x => x.Name == Constants.CookieName);
+
+ var authCookieSignedOut = cookiesSignedOut.Single(x => x.Name == Constants.CookieName);
+
+ 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(redirectUri.AbsoluteUri.Replace(redirectUri.Query, string.Empty), configuration.EndSessionEndpoint);
+
+ //TODO: add test for post-signout redirect
+
+ }
+ }
+}
From ee44efee370cd5cb1aa114d0b0e677e351fb7883 Mon Sep 17 00:00:00 2001
From: Jon Lester
Date: Thu, 2 Mar 2023 13:22:50 -0500
Subject: [PATCH 11/11] Fixes: Logout Redirects
- moved login_hint claim out of the userInfoPayload so that it wouldn't be sent to the backend application
- updated signout tests to include parameters for logout_hint and post-logout redirect
---
src/EasyAuthForK8s.Web/Constants.cs | 7 ++
src/EasyAuthForK8s.Web/Helpers/EventHelper.cs | 35 +++---
.../Models/ModelExtensions.cs | 3 -
.../Models/UserInfoPayload.cs | 5 -
.../Helpers/CookieAuthHelper.cs | 17 ++-
.../EasyAuthForK8s.Tests.Web/SignoutTests.cs | 105 ++++++++++++++----
6 files changed, 124 insertions(+), 48 deletions(-)
diff --git a/src/EasyAuthForK8s.Web/Constants.cs b/src/EasyAuthForK8s.Web/Constants.cs
index 89c02ce..a559b3e 100644
--- a/src/EasyAuthForK8s.Web/Constants.cs
+++ b/src/EasyAuthForK8s.Web/Constants.cs
@@ -21,6 +21,13 @@ 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 = {
diff --git a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
index f9135bd..2375ca6 100644
--- a/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
+++ b/src/EasyAuthForK8s.Web/Helpers/EventHelper.cs
@@ -25,8 +25,7 @@ namespace EasyAuthForK8s.Web.Helpers
{
internal class EventHelper
{
- public const string LoginHint = "login_hint";
-
+ private const string PrincipalParameter = Constants.CookieName;
private readonly EasyAuthConfigurationOptions _configOptions;
public EventHelper(EasyAuthConfigurationOptions configOptions)
{
@@ -153,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");
@@ -303,7 +306,6 @@ public async Task OidcRemoteSignout(RemoteSignOutContext context, Func { ctx.Response.Redirect(properties.RedirectUri); return Task.CompletedTask; }) ;
+ 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
@@ -354,9 +349,17 @@ public async Task OidcRemoteSignout(RemoteSignOutContext context, Func
public async Task OidcRedirectForSignout(RedirectContext context, Func next)
{
- if (context.Properties.Parameters.ContainsKey(LoginHint) && !context.ProtocolMessage.Parameters.ContainsKey("logout_hint"))
+ if (context.Properties.Parameters.ContainsKey(PrincipalParameter) && !context.ProtocolMessage.Parameters.ContainsKey(Constants.AadClaimParameters.LogoutHint))
{
- context.ProtocolMessage.Parameters.Add("logout_hint", context.Properties.Parameters[LoginHint] as string);
+ 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);
}
diff --git a/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs b/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs
index 1fadf63..4ca4901 100644
--- a/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs
+++ b/src/EasyAuthForK8s.Web/Models/ModelExtensions.cs
@@ -159,9 +159,6 @@ public static UserInfoPayload PopulateFromClaims(this UserInfoPayload payload, I
case ClaimConstants.Scope:
payload.scp = claim.Value;
break;
- case "login_hint":
- payload.login_hint = claim.Value;
- break;
default:
{
if (!Constants.IgnoredClaims.Any(x => x == claim.Type))
diff --git a/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs b/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
index 7c2193d..96f2964 100644
--- a/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
+++ b/src/EasyAuthForK8s.Web/Models/UserInfoPayload.cs
@@ -32,11 +32,6 @@ public class UserInfoPayload
public List otherClaims { get; set; } = new List();
[Key(10)]
public List graph { get; set; } = new List();
-
- //the following claims are used internally for session management, but not passed to
- //the protected application
- [Key(11)]
- public string login_hint { get; set; } = string.Empty;
}
[MessagePackObject]
public struct ClaimValue
diff --git a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs
index 5a2a184..ac0b1e9 100644
--- a/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs
+++ b/src/Tests/EasyAuthForK8s.Tests.Web/Helpers/CookieAuthHelper.cs
@@ -19,6 +19,7 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Net.Http.Headers;
using System.Net;
+using Xunit;
namespace EasyAuthForK8s.Tests.Web.Helpers
{
@@ -68,7 +69,7 @@ public static async Task GetTestServerWithCookieSignedInAsync(
context.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
- new TestAuthenticationHandler().AuthenticateAsync().Result.Principal,
+ new TestAuthenticationHandler(handlerOptions).AuthenticateAsync().Result.Principal,
props).Wait();
var cookies = CookieHeaderValue.ParseList(context.Response.Headers.SetCookie);
@@ -108,5 +109,19 @@ public static CookieCollection GetCookiesFromResponseMessage(HttpResponseMessage
}
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/SignoutTests.cs b/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs
index 136f797..d87256b 100644
--- a/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs
+++ b/src/Tests/EasyAuthForK8s.Tests.Web/SignoutTests.cs
@@ -1,19 +1,38 @@
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.IdentityModel.Protocols;
-using Microsoft.IdentityModel.Protocols.OpenIdConnect;
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.Text;
+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
@@ -23,33 +42,25 @@ public class SignoutTests
[Fact]
public async Task CookieDeletedAndRedirectOnSignOut()
{
- var options = new EasyAuthConfigurationOptions();
- using var server = await CookieAuthHelper.GetTestServerWithCookieSignedInAsync(options);
- var client = server.CreateClient();
+ 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
- HttpResponseMessage response = await client.GetAsync("foo");
-
- //validate initial cookie
- Assert.Contains(response.Headers, x => x.Key == HeaderNames.SetCookie);
-
- var cookiesSignedIn = SetCookieHeaderValue.ParseList(response.Headers.Single(x => x.Key == HeaderNames.SetCookie).Value.ToList());
- Assert.Contains(cookiesSignedIn, x => x.Name == Constants.CookieName);
-
- var authCookie = cookiesSignedIn.Single(x => x.Name == Constants.CookieName);
+ 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 client.GetAsync(options.SignoutPath);
- Assert.Contains(signoutResponse.Headers, x => x.Key == HeaderNames.SetCookie);
+ HttpResponseMessage signoutResponse = await GetResponseForSignout(options, authCookie);
- var cookiesSignedOut = SetCookieHeaderValue.ParseList(signoutResponse.Headers.Single(x => x.Key == HeaderNames.SetCookie).Value.ToList());
- Assert.Contains(cookiesSignedOut, x => x.Name == Constants.CookieName);
-
- var authCookieSignedOut = cookiesSignedOut.Single(x => x.Name == Constants.CookieName);
+ var authCookieSignedOut = CookieAuthHelper.GetAuthCookieFromResponse(signoutResponse);
+ Assert.NotNull(authCookie);
Assert.True(StringSegment.IsNullOrEmpty(authCookieSignedOut.Value));
Assert.True(authCookieSignedOut.Expires.HasValue && authCookieSignedOut.Expires.Value == DateTimeOffset.UnixEpoch);
@@ -63,10 +74,58 @@ public async Task CookieDeletedAndRedirectOnSignOut()
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(redirectUri.AbsoluteUri.Replace(redirectUri.Query, string.Empty), configuration.EndSessionEndpoint);
+ 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();
- //TODO: add test for post-signout redirect
+ 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);
}
}
}