openiddict / openiddict-core

Flexible and versatile OAuth 2.0/OpenID Connect stack for .NET
https://openiddict.com/
Apache License 2.0
4.43k stars 520 forks source link

Integration in MVC Owin app problem after authorize (token redirects to login?) #1406

Closed nbelley closed 2 years ago

nbelley commented 2 years ago

Confirm you've already contributed to this project or that you sponsor it

Version

3.x

Question

Intro on our systems:

So, I based (copied lol) the samples Mortis.client on the ClientApp and Mortis.server on TheManager.

The flow seems to be working quite well, until the token:

/// ClientApp
public void ConfigureAuth(IAppBuilder app)
{
    IdentityModelEventSource.ShowPII = true;

    app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

    app.UseCookieAuthentication(new CookieAuthenticationOptions());

    app.UseOpenIdConnectAuthentication(
        new OpenIdConnectAuthenticationOptions
        {
            Authority = "https://url/serverapp/"                    

            ClientId = "MyClient.MVC",
            ClientSecret = "myclientsecret",

            RedirectUri = $"https://url/clientapp/signin-oidc",

            RedeemCode = true,

            ResponseMode = OpenIdConnectResponseMode.Query,
            ResponseType = OpenIdConnectResponseType.Code,

            Scope = "openid profile email roles",

            SecurityTokenValidator =
                new JwtSecurityTokenHandler
                {
                    // Disable the built-in JWT claims mapping feature.
                    InboundClaimTypeMap = new Dictionary<string, string>()
                },

            TokenValidationParameters =
                new TokenValidationParameters
                {
                    NameClaimType = "name",
                    RoleClaimType = "role"
                },

            Notifications =
                new OpenIdConnectAuthenticationNotifications
                {
                    // Note: by default, the OIDC client throws an OpenIdConnectProtocolException
                    // when an error occurred during the authentication/authorization process.
                    // To prevent a YSOD from being displayed, the response is declared as handled.
                    AuthenticationFailed =
                        notification =>
                        {
                            if (string.Equals(notification.ProtocolMessage.Error, "access_denied", StringComparison.Ordinal))
                            {
                                notification.HandleResponse();

                                notification.Response.Redirect("/");
                            }

                            return Task.CompletedTask;
                        },
                    AuthorizationCodeReceived = AuthorizationCodeReceived,
                    MessageReceived = OnMessageReceived,
                    RedirectToIdentityProvider = OnRedirectToIdentityProvider,
                    SecurityTokenReceived = OnSecurityTokenReceived,
                    SecurityTokenValidated = OnSecurityTokenValidated,
                    TokenResponseReceived = OnTokenResponseReceived
                }
        });
}

I'm trying to implement the sample client server in my current MVC apps to use openiddict.

I have a client App, that I configured to the best of my knowledge the same way as the MvcClient app in the samples project (mortis.client).

This seems to work.

I have an authentication app, which is used to authenticate our users across multiple applications, I integrated the mortis.server example in it. But I'm pretty sure I missed some stuff, here is what happens:

The client app requests access to the server app, it works, the connect/authorize method is called in the AuthorizeController of the server app, then, it requests the login of the user first (if not loggued in). After that, connect/authorize is called again, logs signs in the user and builds the identity and it passes in the implicit consent type switch/case.

After that, there is a redirect to the connect/token method but my McvServer apps redirects to login (seems the user is not really logued in?)

What am I doing wrong?

This is my Client App setup

public void ConfigureAuth(IAppBuilder app) { IdentityModelEventSource.ShowPII = true;

app.SetDefaultSignInAsAuthenticationType(CookieAuthenticationDefaults.AuthenticationType);

app.UseCookieAuthentication(new CookieAuthenticationOptions());

app.UseOpenIdConnectAuthentication(
    new OpenIdConnectAuthenticationOptions
    {
        Authority = "https://url/serverapp/"                    

        ClientId = "MyClient.MVC",
        ClientSecret = "myclientsecret",

        RedirectUri = $"https://url/clientapp/signin-oidc",

        RedeemCode = true,

        ResponseMode = OpenIdConnectResponseMode.Query,
        ResponseType = OpenIdConnectResponseType.Code,

        Scope = "openid profile email roles",

        SecurityTokenValidator =
            new JwtSecurityTokenHandler
            {
                // Disable the built-in JWT claims mapping feature.
                InboundClaimTypeMap = new Dictionary<string, string>()
            },

        TokenValidationParameters =
            new TokenValidationParameters
            {
                NameClaimType = "name",
                RoleClaimType = "role"
            },

        Notifications =
            new OpenIdConnectAuthenticationNotifications
            {
                // Note: by default, the OIDC client throws an OpenIdConnectProtocolException
                // when an error occurred during the authentication/authorization process.
                // To prevent a YSOD from being displayed, the response is declared as handled.
                AuthenticationFailed =
                    notification =>
                    {
                        if (string.Equals(notification.ProtocolMessage.Error, "access_denied", StringComparison.Ordinal))
                        {
                            notification.HandleResponse();

                            notification.Response.Redirect("/");
                        }

                        return Task.CompletedTask;
                    },
                AuthorizationCodeReceived = AuthorizationCodeReceived,
                MessageReceived = OnMessageReceived,
                RedirectToIdentityProvider = OnRedirectToIdentityProvider,
                SecurityTokenReceived = OnSecurityTokenReceived,
                SecurityTokenValidated = OnSecurityTokenValidated,
                TokenResponseReceived = OnTokenResponseReceived
            }
    });

}

The signin link is the same as in the sample app (a sign in view that challenges owin access)

This is TheManager app configuration:

public void Configuration(IAppBuilder app)
{
    // SignalR
    app.MapSignalR();

    ConfigureAuth(app);

    ConfigureOpenIddict(app);
}

private void ConfigureOpenIddict(IAppBuilder app)
{
    var container = CreateContainer();

    // Register the Autofac scope injector middleware.
    app.UseAutofacLifetimeScopeInjector(container);

    // Register the two OpenIddict server/validation middleware.
    app.UseMiddlewareFromContainer<OpenIddictServerOwinMiddleware>();
    app.UseMiddlewareFromContainer<OpenIddictValidationOwinMiddleware>();

    // Configure ASP.NET MVC 5.2 to use Autofac when activating controller instances.
    DependencyResolver.SetResolver(new AutofacDependencyResolver(container));

    // Seed the database with the sample client using the OpenIddict application manager.
    // Note: in a real world application, this step should be part of a setup script.
    Task.Run(() => this.FillClients(container));
}
private static IContainer CreateContainer()
{
    var services = new ServiceCollection();
    services.AddOpenIddict()

        // Register the OpenIddict core components.
        .AddCore(
            options =>
            {
                // Configure OpenIddict to use the Entity Framework 6.x stores and models.
                // Note: call ReplaceDefaultEntities() to replace the default OpenIddict entities.
                options
                    .UseEntityFramework()
                    .UseDbContext<AppDbContext>();
            })

        // Register the OpenIddict server components.
        .AddServer(
            options =>
            {
                // Enable the authorization, logout and token endpoints.
                options
                    .SetAuthorizationEndpointUris("/connect/authorize")
                    .SetLogoutEndpointUris("/connect/logout")
                    .SetTokenEndpointUris("/connect/token");

                // Mark the "email", "profile" and "roles" scopes as supported scopes.
                options.RegisterScopes(Scopes.Email, Scopes.Profile, Scopes.Roles);

                // Note: this sample only uses the authorization code flow but you can enable
                // the other flows if you need to support implicit, password or client credentials.
                options.AllowAuthorizationCodeFlow();

                // Register the signing and encryption credentials.
                options.AddDevelopmentEncryptionCertificate()
                       .AddDevelopmentSigningCertificate();

                // Register the OWIN host and configure the OWIN-specific options.
                options.UseOwin()
                       .EnableAuthorizationEndpointPassthrough()
                       .EnableLogoutEndpointPassthrough()
                       .EnableTokenEndpointPassthrough();
            })

        // Register the OpenIddict validation components.
        .AddValidation(
            options =>
            {
                // Import the configuration from the local OpenIddict server instance.
                options.UseLocalServer();

                // Register the OWIN host.
                options.UseOwin();
            });

    // Create a new Autofac container and import the OpenIddict services.
    var builder = new ContainerBuilder();
    builder.Populate(services);

    // Register the MVC controllers.
    builder.RegisterControllers(typeof(Startup).Assembly);

    return builder.Build();
}

public void ConfigureAuth(IAppBuilder app)
{
    // Configure the db context, user manager and signin manager to use a single instance per request
    app.CreatePerOwinContext<AppDbContext>((options, context) => AppDbContext.Create());
    app.CreatePerOwinContext<ApplicationUserManager>(ApplicationUserManager.Create);
    app.CreatePerOwinContext<ApplicationSignInManager>(ApplicationSignInManager.Create);
    app.CreatePerOwinContext<ApplicationRoleManager>(Application‌​RoleManager.Create);

    // Enable the application to use a cookie to store information for the signed in user
    // and to use a cookie to temporarily store information about a user logging in with a third party login provider
    // Configure the sign in cookie
    app.UseCookieAuthentication(
        new CookieAuthenticationOptions
        {
            AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie,
            LoginPath = new PathString("/Account/Login"),

            CookieName = "CookieNameBla_SessionId",
            SlidingExpiration = true,

            AuthenticationMode = Microsoft.Owin.Security.AuthenticationMode.Active,
            CookieHttpOnly = true,
            CookiePath = "/",
            CookieSecure = this.ServiceEnvironment == ServiceEnvironment.Development ? CookieSecureOption.SameAsRequest : CookieSecureOption.Always,

            ExpireTimeSpan = TimeSpan.FromDays(14),

            Provider = new CookieAuthenticationProvider
            {
                // Enables the application to validate the security stamp when the user logs in.
                // This is a security feature which is used when you change a password or add an external login to your account.
                OnValidateIdentity = SecurityStampValidator.OnValidateIdentity<ApplicationUserManager, ApplicationUser>(
                validateInterval: TimeSpan.FromMinutes(30),
                regenerateIdentity: (manager, user) => user.GenerateUserIdentityAsync(manager)),
            }
        });

    // Use a cookie to temporarily store information about a user logging in with a third party login provider
    app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie);

    // Enables the application to temporarily store user information when they are verifying the second factor in the two-factor authentication process.
    app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5));

    // Enables the application to remember the second login verification factor such as phone or email.
    // Once you check this option, your second step of verification during the login process will be remembered on the device where you logged in from.
    // This is similar to the RememberMe option when you log in.
    app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie);
}

This is the AuthorizeController:

[AllowAnonymous]
    public class AuthorizationController : Controller
    {
        private readonly IOpenIddictApplicationManager _applicationManager;
        private readonly IOpenIddictAuthorizationManager _authorizationManager;
        private readonly IOpenIddictScopeManager _scopeManager;

        public AuthorizationController(
            IOpenIddictApplicationManager applicationManager,
            IOpenIddictAuthorizationManager authorizationManager,
            IOpenIddictScopeManager scopeManager)
        {
            _applicationManager = applicationManager;
            _authorizationManager = authorizationManager;
            _scopeManager = scopeManager;
        }

        [HttpGet, Route("~/connect/authorize")]
        public async Task<ActionResult> Authorize()
        {
            var context = HttpContext.GetOwinContext();
            var request = context.GetOpenIddictServerRequest() ??
                throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            // Retrieve the user principal stored in the authentication cookie.
            // If a max_age parameter was provided, ensure that the cookie is not too old.
            // If the user principal can't be extracted or the cookie is too old, redirect the user to the login page.
            var result = await context.Authentication.AuthenticateAsync(DefaultAuthenticationTypes.ApplicationCookie);
            if (result?.Identity == null || (request.MaxAge != null && result.Properties?.IssuedUtc != null &&
                DateTimeOffset.UtcNow - result.Properties.IssuedUtc > TimeSpan.FromSeconds(request.MaxAge.Value)))
            {
                context.Authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie);
                return new EmptyResult();
            }

            // Retrieve the profile of the logged in user.
            var user = await context.GetUserManager<ApplicationUserManager>().FindByIdAsync(result.Identity.GetUserId()) ??
                throw new InvalidOperationException("The user details cannot be retrieved.");

            // Retrieve the application details from the database.
            var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
                throw new InvalidOperationException("Details concerning the calling client application cannot be found.");

            // Retrieve the permanent authorizations associated with the user and the calling client application.
            var authorizations = await _authorizationManager.FindAsync(
                subject: user.Id,
                client: await _applicationManager.GetIdAsync(application),
                status: Statuses.Valid,
                type: AuthorizationTypes.Permanent,
                scopes: request.GetScopes()).ToListAsync();

            switch (await _applicationManager.GetConsentTypeAsync(application))
            {
                // If the consent is external (e.g when authorizations are granted by a sysadmin),
                // immediately return an error if no authorization can be found in the database.
                case ConsentTypes.External when !authorizations.Any():
                    context.Authentication.Challenge(
                        authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                        properties: new AuthenticationProperties(new Dictionary<string, string>
                        {
                            [OpenIddictServerOwinConstants.Properties.Error] = Errors.ConsentRequired,
                            [OpenIddictServerOwinConstants.Properties.ErrorDescription] =
                                "The logged in user is not allowed to access this client application."
                        }));

                    return new EmptyResult();

                // If the consent is implicit or if an authorization was found,
                // return an authorization response without displaying the consent form.
                case ConsentTypes.Implicit:
                case ConsentTypes.External when authorizations.Any():
                case ConsentTypes.Explicit when authorizations.Any() && !request.HasPrompt(Prompts.Consent):
                    var identity = new ClaimsIdentity(OpenIddictServerOwinDefaults.AuthenticationType);
                    identity.AddClaims((await context.Get<ApplicationSignInManager>().CreateUserIdentityAsync(user)).Claims);

                    identity.AddClaim(new Claim(Claims.Subject, identity.FindFirstValue(ClaimTypes.NameIdentifier)));
                    identity.AddClaim(new Claim(Claims.Name, identity.FindFirstValue(ClaimTypes.Name)));

                    var principal = new ClaimsPrincipal(identity);

                    // Note: in this sample, the granted scopes match the requested scope
                    // but you may want to allow the user to uncheck specific scopes.
                    // For that, simply restrict the list of scopes before calling SetScopes.
                    principal.SetScopes(request.GetScopes());
                    principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());

                    // Automatically create a permanent authorization to avoid requiring explicit consent
                    // for future authorization or token requests containing the same scopes.
                    var authorization = authorizations.LastOrDefault();
                    if (authorization == null)
                    {
                        authorization = await _authorizationManager.CreateAsync(
                            principal: principal,
                            subject: user.Id,
                            client: await _applicationManager.GetIdAsync(application),
                            type: AuthorizationTypes.Permanent,
                            scopes: principal.GetScopes());
                    }

                    principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));

                    foreach (var claim in principal.Claims)
                    {
                        claim.SetDestinations(GetDestinations(claim, principal));
                    }

                    context.Authentication.SignIn(new AuthenticationProperties(), (ClaimsIdentity)principal.Identity);

                    return new EmptyResult();

                // At this point, no authorization was found in the database and an error must be returned
                // if the client application specified prompt=none in the authorization request.
                case ConsentTypes.Explicit when request.HasPrompt(Prompts.None):
                case ConsentTypes.Systematic when request.HasPrompt(Prompts.None):
                    context.Authentication.Challenge(
                        authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                        properties: new AuthenticationProperties(new Dictionary<string, string>
                        {
                            [OpenIddictServerOwinConstants.Properties.Error] = Errors.ConsentRequired,
                            [OpenIddictServerOwinConstants.Properties.ErrorDescription] =
                                "Interactive user consent is required."
                        }));

                    return new EmptyResult();

                // In every other case, render the consent form.
                default:
                    return View(new AuthorizeViewModel
                    {
                        ApplicationName = await _applicationManager.GetDisplayNameAsync(application),
                        Scope = request.Scope,

                        // Flow the request parameters so they can be received by the Accept/Reject actions.
                        Parameters = string.Equals(Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase) ?
                   from name in Request.Form.AllKeys
                   from value in Request.Form.GetValues(name)
                   select new KeyValuePair<string, string>(name, value) :
                   from name in Request.QueryString.AllKeys
                   from value in Request.QueryString.GetValues(name)
                   select new KeyValuePair<string, string>(name, value)
                    });
            }
        }

        [Authorize, FormValueRequired("submit.Accept")]
        [HttpPost, Route("~/connect/authorize"), ValidateAntiForgeryToken]
        public async Task<ActionResult> Accept()
        {
            var context = HttpContext.GetOwinContext();
            var request = context.GetOpenIddictServerRequest() ??
                throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            // Retrieve the user principal stored in the authentication cookie.
            var result = await context.Authentication.AuthenticateAsync(DefaultAuthenticationTypes.ApplicationCookie);
            if (result == null || result.Identity == null)
            {
                context.Authentication.Challenge(DefaultAuthenticationTypes.ApplicationCookie);

                return new EmptyResult();
            }

            // Retrieve the profile of the logged in user.
            var user = await context.GetUserManager<ApplicationUserManager>().FindByIdAsync(result.Identity.GetUserId()) ??
                throw new InvalidOperationException("The user details cannot be retrieved.");

            // Retrieve the application details from the database.
            var application = await _applicationManager.FindByClientIdAsync(request.ClientId) ??
                throw new InvalidOperationException("Details concerning the calling client application cannot be found.");

            // Retrieve the permanent authorizations associated with the user and the calling client application.
            var authorizations = await _authorizationManager.FindAsync(
                subject: user.Id,
                client: await _applicationManager.GetIdAsync(application),
                status: Statuses.Valid,
                type: AuthorizationTypes.Permanent,
                scopes: request.GetScopes()).ToListAsync();

            // Note: the same check is already made in the other action but is repeated
            // here to ensure a malicious user can't abuse this POST-only endpoint and
            // force it to return a valid response without the external authorization.
            if (!authorizations.Any() && await _applicationManager.HasConsentTypeAsync(application, ConsentTypes.External))
            {
                context.Authentication.Challenge(
                    authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                    properties: new AuthenticationProperties(new Dictionary<string, string>
                    {
                        [OpenIddictServerOwinConstants.Properties.Error] = Errors.ConsentRequired,
                        [OpenIddictServerOwinConstants.Properties.ErrorDescription] =
                            "The logged in user is not allowed to access this client application."
                    }));

                return new EmptyResult();
            }

            var identity = new ClaimsIdentity(OpenIddictServerOwinDefaults.AuthenticationType);
            identity.AddClaims((await context.Get<ApplicationSignInManager>().CreateUserIdentityAsync(user)).Claims);

            identity.AddClaim(new Claim(Claims.Subject, identity.FindFirstValue(ClaimTypes.NameIdentifier)));
            identity.AddClaim(new Claim(Claims.Name, identity.FindFirstValue(ClaimTypes.Name)));

            var principal = new ClaimsPrincipal(identity);

            // Note: in this sample, the granted scopes match the requested scope
            // but you may want to allow the user to uncheck specific scopes.
            // For that, simply restrict the list of scopes before calling SetScopes.
            principal.SetScopes(request.GetScopes());
            principal.SetResources(await _scopeManager.ListResourcesAsync(principal.GetScopes()).ToListAsync());

            // Automatically create a permanent authorization to avoid requiring explicit consent
            // for future authorization or token requests containing the same scopes.
            var authorization = authorizations.LastOrDefault();
            if (authorization == null)
            {
                authorization = await _authorizationManager.CreateAsync(
                    principal: principal,
                    subject: user.Id,
                    client: await _applicationManager.GetIdAsync(application),
                    type: AuthorizationTypes.Permanent,
                    scopes: principal.GetScopes());
            }

            principal.SetAuthorizationId(await _authorizationManager.GetIdAsync(authorization));

            foreach (var claim in principal.Claims)
            {
                claim.SetDestinations(GetDestinations(claim, principal));
            }

            // Returning a SignInResult will ask OpenIddict to issue the appropriate access/identity tokens.
            context.Authentication.SignIn(new AuthenticationProperties(), (ClaimsIdentity)principal.Identity);

            return new EmptyResult();
        }

        [Authorize, FormValueRequired("submit.Deny")]
        [HttpPost, Route("~/connect/authorize"), ValidateAntiForgeryToken]
        public ActionResult Deny()
        {
            var context = HttpContext.GetOwinContext();
            context.Authentication.Challenge(OpenIddictServerOwinDefaults.AuthenticationType);

            return new EmptyResult();
        }

        [HttpGet, Route("~/connect/logout")]
        public ActionResult Logout()
            => View(
                new AuthorizeViewModel
                {
                    // Flow the request parameters so they can be received by the Accept/Reject actions.
                    Parameters =
                        string.Equals(Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)
                            ? from name in Request.Form.AllKeys
                              from value in Request.Form.GetValues(name)
                              select new KeyValuePair<string, string>(name, value)
                            : from name in Request.QueryString.AllKeys
                              from value in Request.QueryString.GetValues(name)
                              select new KeyValuePair<string, string>(name, value)
                });

        [ActionName(nameof(Logout)), HttpPost, Route("~/connect/logout"), ValidateAntiForgeryToken]
        public ActionResult LogoutPost()
        {
            var context = HttpContext.GetOwinContext();
            context.Authentication.SignOut(DefaultAuthenticationTypes.ApplicationCookie);

            context.Authentication.SignOut(
                authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                properties: new AuthenticationProperties
                {
                    RedirectUri = "/"
                });

            return new EmptyResult();
        }

        [HttpPost, Route("~/connect/token")]
        public async Task<ActionResult> Exchange()
        {
            var context = HttpContext.GetOwinContext();
            var request = context.GetOpenIddictServerRequest() ??
                throw new InvalidOperationException("The OpenID Connect request cannot be retrieved.");

            if (request.IsAuthorizationCodeGrantType() || request.IsRefreshTokenGrantType())
            {
                // Retrieve the claims principal stored in the authorization code/device code/refresh token.
                var principal = new ClaimsPrincipal((await context.Authentication.AuthenticateAsync(OpenIddictServerOwinDefaults.AuthenticationType)).Identity);

                // Retrieve the user profile corresponding to the authorization code/refresh token.
                var user = await context.GetUserManager<ApplicationUserManager>().FindByIdAsync(principal.GetClaim(Claims.Subject));
                if (user == null)
                {
                    context.Authentication.Challenge(
                        authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                        properties: new AuthenticationProperties(new Dictionary<string, string>
                        {
                            [OpenIddictServerOwinConstants.Properties.Error] = Errors.InvalidGrant,
                            [OpenIddictServerOwinConstants.Properties.ErrorDescription] = "The token is no longer valid."
                        }));

                    return new EmptyResult();
                }

                // Ensure the user is still allowed to sign in.
                if (context.GetUserManager<ApplicationUserManager>().IsLockedOut(user.Id))
                {
                    context.Authentication.Challenge(
                        authenticationTypes: OpenIddictServerOwinDefaults.AuthenticationType,
                        properties: new AuthenticationProperties(new Dictionary<string, string>
                        {
                            [OpenIddictServerOwinConstants.Properties.Error] = Errors.InvalidGrant,
                            [OpenIddictServerOwinConstants.Properties.ErrorDescription] = "The user is no longer allowed to sign in."
                        }));

                    return new EmptyResult();
                }

                var identity = new ClaimsIdentity(OpenIddictServerOwinDefaults.AuthenticationType);
                identity.AddClaims((await context.Get<ApplicationSignInManager>().CreateUserIdentityAsync(user)).Claims);

                identity.AddClaim(new Claim(Claims.Subject, identity.FindFirstValue(ClaimTypes.NameIdentifier)));
                identity.AddClaim(new Claim(Claims.Name, identity.FindFirstValue(ClaimTypes.Name)));

                foreach (var claim in identity.Claims)
                {
                    claim.SetDestinations(GetDestinations(claim, principal));
                }

                // Ask OpenIddict to issue the appropriate access/identity tokens.
                context.Authentication.SignIn(new AuthenticationProperties(), identity);

                return new EmptyResult();
            }

            throw new InvalidOperationException("The specified grant type is not supported.");
        }

        private IEnumerable<string> GetDestinations(Claim claim, ClaimsPrincipal principal)
        {
            // Note: by default, claims are NOT automatically included in the access and identity tokens.
            // To allow OpenIddict to serialize them, you must attach them a destination, that specifies
            // whether they should be included in access tokens, in identity tokens or in both.

            switch (claim.Type)
            {
                case Claims.Name:
                    yield return Destinations.AccessToken;

                    if (principal.HasScope(Scopes.Profile))
                        yield return Destinations.IdentityToken;

                    yield break;

                case Claims.Email:
                    yield return Destinations.AccessToken;

                    if (principal.HasScope(Scopes.Email))
                        yield return Destinations.IdentityToken;

                    yield break;

                case Claims.Role:
                    yield return Destinations.AccessToken;

                    if (principal.HasScope(Scopes.Roles))
                        yield return Destinations.IdentityToken;

                    yield break;

                // Never include the security stamp in the access and identity tokens, as it's a secret value.
                case "AspNet.Identity.SecurityStamp": yield break;

                default:
                    yield return Destinations.AccessToken;
                    yield break;
            }
        }
    }
kevinchalet commented 2 years ago

Thanks for sponsoring the project!

After that, there is a redirect to the connect/token method but my TheManager app redirects to login (seems the user is not really logued in? or something else?

This part sounds weird to me: the token endpoint is not an interactive endpoint and works pretty much like an API endpoint so you're not supposed to make any redirection at this stage.

Could you please capture a Fiddler trace? This would help pinpoint where the issue is.

nbelley commented 2 years ago

Thanks for sponsoring the project!

After that, there is a redirect to the connect/token method but my TheManager app redirects to login (seems the user is not really logued in? or something else?

This part sounds weird to me: the token endpoint is not an interactive endpoint and works pretty much like an API endpoint so you're not supposed to make any redirection at this stage.

Could you please capture a Fiddler trace? This would help pinpoint where the issue is.

Thanks for your answer! I'm not sure what's supposed to happen after the login on the server, should the token endpoint be called there or the process is already over?

Anyway, I have a fiddler trace, where can I send it to you?

kevinchalet commented 2 years ago

Thanks for your answer! I'm not sure what's supposed to happen after the login on the server, should the token endpoint be called there or the process is already over?

Yeah, the token endpoint is expected to be called using an HTTP client to redeem the authorization code and get an access token. Assuming you're using a recent version of the OWIN OIDC middleware, this should be supported and automatically done for you under the hood.

Anyway, I have a fiddler trace, where can I send it to you?

You can drag&drop it here or send it privately to my email address (it's indicated in my profile).

If you can also join client and server logs, don't hesitate.

kevinchalet commented 2 years ago

The traces indicate that the call to /connect/token resulted in a redirection response to a login page. Since it's not a valid JSON response, the OIDC middleware throws an exception to abort the callback process.

Do you have some custom code somewhere that forces all calls to be authenticated or something similar? Calls to /connect/token are unauthenticated by nature so this path must be excluded from any custom policy.

nbelley commented 2 years ago

I'm a bit at a loss there... I mean, I do have code that can result in a redirect to login, if the requested route is secured, but I have done nothing special for /connect/token.

I've tried to comment everything that requires an authorisation in the app... nothing works.

nbelley commented 2 years ago

It seems that it's related to my cookie configuration in my auth server.

I changed

AuthenticationMode = Microsoft.Owin.Security.AuthenticationMode.Active,

To

AuthenticationMode = Microsoft.Owin.Security.AuthenticationMode.Passive,

And I now have a new error, at least I came back to my other app to the signin-oidc callback

image

kevinchalet commented 2 years ago

Ah, you're unfortunately hitting an annoying design flaw in Katana: authentication middleware are known to be very greedy and catch 401 responses when using Active even when they are not supposed to. It's one of the annoying things we fixed when revamping ASP.NET Core's authentication stack.

In this specific case OpenIddict detects you're using invalid credentials and returns a 401 response as required by the OAuth 2.0 specification. Yet, the cookies middleware detects a 401 response is being returned and starts doing its redirection thing, which ends up overriding what OpenIddict did.

I'll consider introducing a workaround in a future version to avoid that. In the meantime, you can add one in your own app using OpenIddict's event model:

services.AddOpenIddict()
    .AddServer(options =>
    {
        options.AddEventHandler<ApplyTokenResponseContext>(builder =>
            builder.UseInlineHandler(context =>
            {
                var request = context.Transaction.GetOwinRequest();
                if (request.Context.Response.StatusCode is 401)
                {
                    // Attach an explicit challenge pointing to a non-existing authentication middleware
                    // to prevent the cookies middleware from overriding OpenIddict 401 responses.
                    request.Context.Authentication.AuthenticationResponseChallenge =
                        new AuthenticationResponseChallenge(new[] { "Dummy" }, new());
                }

                return default;
            })
            .SetOrder(AttachHttpResponseCode<ApplyTokenResponseContext>.Descriptor.Order + 1));
    });
nbelley commented 2 years ago

Yes, ça marche | it works! 👍 With my old config.

Now, the only question is why the client is invalid? Pretty sure it was good, I'll check that a bit to see. I'm not crazy, the client app doesn't need to have any of the openiddict tables?

kevinchalet commented 2 years ago

I'm not crazy, the client app doesn't need to have any of the openiddict tables?

Right, the client never accesses the server database. That said, you do need to have an entry in the applications table for that specific client. If you get this error, then it's likely either your client_id or your client_secret is/are invalid.

nbelley commented 2 years ago

EDIT: nevermind, it works, but I'm not receiving my custom claims that are added in the server. In the client, I only have the claims from oauth, my custom claims arent there?

nbelley commented 2 years ago

Now I'm stuck on the sign out, but as for the login, everything seems good, thanks for your help @kevinchalet

kevinchalet commented 2 years ago

For the record, this was fixed in OpenIddict 4.0 preview1: https://kevinchalet.com/2022/06/22/openiddict-4-0-preview1-is-out/

nbelley commented 2 years ago

For the record, this was fixed in OpenIddict 4.0 preview1: https://kevinchalet.com/2022/06/22/openiddict-4-0-preview1-is-out/

Thanks! Not sure I'm game to migrate to 4.0 right now! :)