Azure / static-web-apps

Azure Static Web Apps. For bugs and feature requests, please create an issue in this repo. For community discussions, latest updates, kindly refer to the Discussions Tab. To know what's new in Static Web Apps, visit https://aka.ms/swa/ThisMonth
https://aka.ms/swa
MIT License
325 stars 56 forks source link

Claims missing from x-ms-client-principal #897

Open woodman231 opened 2 years ago

woodman231 commented 2 years ago

Describe the bug

When deserializing the x-ms-client-principal header the claims are missing; however, the claims can be seen at /.auth/me/

I will say though that the claims are available in the "StaticWebAppsAuthCookie". I can use this "StaticWebAppsAuthCookie" in development. However in an Azure Environment I can not use the StaticWebAppsAuthCookie because I cannot decrypt it.

To be a little more clear. The sample code has something like this

            var principal = new ClientPrincipal();

            if (req.Headers.TryGetValues("x-ms-client-principal", out var header))
            {
                var data = header.First();
                var decoded = Convert.FromBase64String(data);
                var json = Encoding.UTF8.GetString(decoded);
                principal = JsonSerializer.Deserialize<ClientPrincipal>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
            }

I realize that the sample code ClientPrincipal didn't have a claims property anyways so I tried to add it. I am using the following as my client principal

using System.Security.Claims;

namespace Shared.Models
{
    public class ClientPrincipal
    {
        public string IdentityProvider { get; set; } = null!;
        public string UserId { get; set; } = null!;
        public string UserDetails { get; set; } = null!;
        public IEnumerable<string> UserRoles { get; set; } = null!;
        public IEnumerable<ClientPrincipalClaim>? Claims { get; set; }
    }

    public class ClientPrincipalClaim
    {
        public string Typ { get; set; } = null!;
        public string Val { get; set; } = null!;
    }
}

Even with the property available to be copied, the claims are still missing.

If I use the following code locally. I get the claims that I want.

        private static ClientPrincipal GetClientPrincipal(HttpRequest req, ILogger log)
        {
            var principal = new ClientPrincipal();            

            var swaCookie = req.Cookies["StaticWebAppsAuthCookie"];

            if(swaCookie != null)
            {
                log.LogInformation("SWA Cookie Found");
                var decoded = Convert.FromBase64String(swaCookie);
                log.LogInformation("SWA Cookie Decoded to a Byte Array");
                var json = Encoding.UTF8.GetString(decoded);
                log.LogInformation($"SWA Cookie JSON: {json}");
                principal = JsonConvert.DeserializeObject<ClientPrincipal>(json);
                log.LogInformation($"SWA Cookie Deserialized");
            }

            return principal;
        }

However when I call this end point in production. The cookie is encrypted and thus not able to be read. In a standard c# api I would done something to decrypted the cookie, but I am not aware of how I could decrypt this cookie. So it seems that we need a way to either have the claims be present in the x-ms-client-principal header, or have a way to decrypt the StaticWebAppsAuthCookie within the runtime (maybe there already is)

Expected behavior x-ms-client-principal header to contain the same information as /.auth/me

Screenshots Screen shot of it from my local dev environment when being called from my client image

Screen shot of the encrypted cookie in App Insights image

Screen shot of that line of code in a break-point during local debug image

woodman231 commented 2 years ago

I decided that since I can use that cookie in postman and then go to /.auth/me, that I could create an HTTP Client and go to {produrl}/.auth/me while in the Function with the cookie that was provided. Again this works in development, but does not work in production. In production it gives me a 401, not authorized. Which doesn't make allot of sense to me. Why would I be able to make an HTTP Request from PostMan to {prodUrl}/.auth/me with a cookie from production, but when a C# HttpClient makes the request from an Azure Function, it says not authorized. That makes no sense to me at all.

The code mentioned earlier, but trying it with an HTTPClient instead of reading the cookie directly

        private static async Task<ClientPrincipal> GetClientPrincipal(HttpRequest req, ILogger log)
        {            
            var principal = new ClientPrincipal();            

            var swaCookie = req.Cookies["StaticWebAppsAuthCookie"];

            if(swaCookie != null)
            {
                try
                {

                    var baseAddress = new Uri($"{req.Scheme}://{req.Host}");

                    var cookieContainer = new CookieContainer();

                    cookieContainer.Add(baseAddress, new Cookie("StaticWebAppsAuthCookie", swaCookie));

                    var handler = new HttpClientHandler() { CookieContainer = cookieContainer };

                    var client = new HttpClient(handler) { BaseAddress = baseAddress };                    

                    var response = await client.GetFromJsonAsync<ClientPrincipalPayLoad>("/.auth/me");                    

                    if (response is not null)
                    {
                        if (response.ClientPrincipal is not null)
                        {
                            return response.ClientPrincipal;
                        }
                    }
                }
                catch(Exception ex)
                {
                    log.LogError(ex.Message);
                }
            }

            return principal;
        }

Works in development

image

Throw 401 in production...

image

Part of the reason that it is so hard for me to understand is that when I use postman and go to {authUrl}/.auth/me and I do not supply any cookie at all, I just get an null clientPrincipal. So why unauthorized instead of just an empty result like I see in postman?

This still leaves me in the original question: How can the API know the User Claims since they are missing from x-ms-client-principal, and there doesn't seem to be any other way to decrypt the StaticWebAppsAuthCookie?

remirobichet commented 2 years ago

I'm facing the exact same issue. Claims are missing after deserializing the x-ms-client-principal header in my code. But claims are available at /.auth/me

Everything was working fine 2 weeks ago. This issue breaks all access to my swa 😢

remirobichet commented 2 years ago

I'm a bit confused, the documentation mention the claims attribute in the "Client principal data" section. It also mention the way to retrieve data from backend in "API functions" section. ("The API functions available in Static Web Apps via the Azure Functions backend have access to the same user information as a client application") 🤔 https://docs.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript

noontz commented 1 year ago

As I see it, the missing access to readily available claims from the client in the current implementation of the x-ms-client-principal header / jwt bearer token on the functions side of the SWA, can only be solved via workarounds by either extracting the claims client side and sending them in the request or store redundant user claims in a back end store with the username/id as key. Both options are forcing developers into an anti pattern. Is there a reason for this other than prioritization?

ismailhozza commented 1 year ago

Hi @remirobichet, have you found a solution for this? I am facing this issue currently. Thanks.

mlagk commented 1 year ago

+1 on this for me too.

In my local 'swa start' setup. When I submit themocked auth form with the user claims I can see them being serialised using btoa to the StaticWebAppsAuthCookie, but on my local function app the claims are never populated in the x-ms-client-principal header. Though they are populated on the /.auth/me endpoint.

I can see in the docs that it does call out the claims wouldnt be populated in the function API calls, but is never explained why?

In contradiction to this, in the GetRoles method when deployed to my 'preview' environment the user claims are populated?

In the end up, I have added an end of days hack to my code:

.. do some stuff with the original header (which works deployed)

#if DEBUG
                // ML: Mother of all hacks while this bug gets fixed
                // https://github.com/Azure/static-web-apps/issues/897

                if (req.Cookies.ContainsKey("StaticWebAppsAuthCookie"))
                {
                    data = req.Cookies["StaticWebAppsAuthCookie"].ToString();
                    decoded = Convert.FromBase64String(data);
                    json = Encoding.UTF8.GetString(decoded);
                    var cookiePrincipal = JsonSerializer.Deserialize<AuthenticatedUser>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
                    principal.Claims = cookiePrincipal.Claims;
                }
#endif

... return the enriched user

Less than ideal for sure and either a large hole in the documentation, or a bug IMO,

@anthonychu @aaronpowell apologies for tagging, but do you guys have any thoughts on this?

seantleonard commented 1 year ago

FWIW I see that docs have recently been updated at the bottom of the following page in the note bubble:

The x-ms-client-principal header accessible in the API function does not contain the claims array.

https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp

mlagk commented 1 year ago

FWIW I see that docs have recently been updated at the bottom of the following page in the note bubble:

The x-ms-client-principal header accessible in the API function does not contain the claims array.

https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp

I seen that previously, thanks for adding the link. It leans in to my earlier question though, why? Bizarre it would be included in the direct access endpoint but not in the function app call. Especially as it’s all in the cookie.

remirobichet commented 1 year ago

@mlagk your workaround is working but it can be hacked. Someone could generate a cookie with a JWT modified (such as email) to authenticate. You can't validate the JWT so this is not secure.

@ismailhozza I have a similar implementation of @mlagk workaround in js👼 but as mention this is not secure...

mlagk commented 1 year ago

@mlagk your workaround is working but it can be hacked. Someone could generate a cookie with a JWT modified (such as email) to authenticate. You can't validate the JWT so this is not secure.

This code would only run if deployed to a function app compiled in DEBUG mode, which we don't do, everything goes to a remote environment compiled in dotnet as RELEASE. So this code will not be executed on the server, only my local machine where the issue occurs. Everything works fine on the remote server for me.

noontz commented 1 year ago

Is there any roadmap for this or should we rely and settle on our own workarounds?

A "simple" solution would be to expand the "auth": {"rolesSource": "/api/getroles", .... API method to support appending a claims array in the response alongside the appended roles array.

Feels kinda dusty to rely solely on RBAC in 2023.

As a side note I would recommend everybody here upvoting this proposal

kamilpaszkowski commented 1 year ago

I am also facing the same issue. Claims are visible in /.auth/me but not after parsing header in azure function

kamilpaszkowski commented 1 year ago

I see now that is not a bug but this is just by design. In doc its written that claims are not accessible in 'x-ms-client-principal' header: https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp

olibos commented 1 year ago

Hello, if it's by design what's the best way to access thoses claims on the server side?

noontz commented 1 year ago

@olibos With the current implementation I don't see any "best way", so, as we wait for a better solution, you can call /.auth/me on the client and include what you need in the API call.

I did a brief experiment calling /.auth/me directly from the SWA API to avoid claims in the client side traffic and it succeeded with the local SWA CLI but failed on Azure. I never pursued it due to other priorities, but it should be possible, and seems to be a "better" approach

olibos commented 1 year ago

Thanks @noontz , I think I'll try with Microsoft Graph on server side to avoid that a user can send non genuine/validated claims to the server.

I hope there will be a more official way 😉

AverageCakeSlice commented 1 year ago

@noontz, can you share any further details on how you were able to call /.auth/me from the API?

noontz commented 1 year ago

@AverageCakeSlice Sure.

The main idea is to copy the cookie and reuse it. Here is the index.js from the function I was testing with:

const http = require('node:http');
const https = require('node:https');

module.exports = async (context) => new Promise((resolve, reject) => {
try {
    const req = context.req;
    const staticWebAppCookie = req.headers.cookie;
    const referer = req.headers.referer;
    const split = referer.split('/');
    const hasPort = split[2].includes(':');
    const host = hasPort ? split[2].split(':')[0] : split[2];
    const https = split[0].includes('s');
    const port = hasPort ? split[2].split(':')[1] : https ? 443 : 80;

    const options = {
        host: host,
        port: port,
        path: '/.auth/me',
        method: 'GET',
        headers: {
            cookie: staticWebAppCookie,
            host: referer
        }
    };

    const protocol = https ? https : http;

    const request = protocol.request(options, (res) => {
        let buffer = '';

        res.on('data', (chunk) => buffer += chunk)

        res.on('end', () => {
            const result = JSON.parse(buffer);
            const clientPrincipal = result.clientPrincipal;
            // do what you like and  add it to the context.res               
            resolve();
        });

    }).on('error', (error) => {
        context.error("REQUEST ERROR: " + error);
        reject();
    });

    request.end();
}
catch (error) {
    context.log("NODE ERROR: " + error);
    reject(error);
}
});

Quick and dirty, no guarantees attached, use with caution, and, as mentioned, I only managed to make it work locally. Interesting if you could make something work on Azure. pls share if so ;)

thomasgauvin commented 1 year ago

Updating this thread, we've clarified in the docs that the claims attribute is not accessible in the API functions in response to another thread on this topic https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=javascript#api-functions. I know this is something that would enable use cases like the ones mentioned in this thread, so we'll be considering this as a new feature. It's currently in our 'to-assess' backlog

calloncampbell commented 8 months ago

This is still broken with API Function backends and nowhere does it say this is not supported for API backends. Can you clarify where in the docs? My backend API is .NET/C# and docs: https://learn.microsoft.com/en-us/azure/static-web-apps/user-information?tabs=csharp#api-functions

thomasgauvin commented 8 months ago

@calloncampbell, the note at the bottom of the section you linked states The x-ms-client-principal header accessible in the API function does not contain the claims array. I'll look into bumping the section above the code snippet to make it clearer

calloncampbell commented 8 months ago

Thank you @thomasgauvin . I see it now and bumping it up would make sense.

shobhitkasliwal-onbe commented 7 months ago

+1 Adding +1 to this issue. It's very beneficial for us to be able to get custom claims at SWA backend so that we can use that for claims based authorization.

stefanello57 commented 1 week ago

Any information on why this behaviour has been changed? As remirobichet mentioned this did work before.

What is the Microsoft suggestion to access the user claims in the backend of a static web app?