DEV Community

Andrew Elans
Andrew Elans

Posted on

Azure Static Web Apps: login_hint now works

This post shows implementation of the login_hint which does not work with Azure Static Web Apps (SWA) by default.

References:

Auth Flow

Going to https://swa.azurestaticapps.net/.auth/login/aad?login_hint=user@mail.com user is redirected to:

https://swa.azurestaticapps.net/.auth/login/aad?post_login_redirect_uri=/.auth/complete&staticWebAppsAuthNonce=aTakZLY%2fCmXnnD%2foxHxXW%2fWDcXGAy27B84se3dzrpE7UcwEFqKGy2VNnXRqvPInletF6R26ZDfdMSD0kKda41Y8%2b3BXO%2bHUoG3VEbaJpSkhdQ%2fRFWgFb1nKNWZ80dtzW and then to:

https://login.microsoftonline.com/c74da02d-281d-4a45-a4af-cc520eafa6e3/oauth2/v2.0/authorize?response_type=code+id_token&redirect_uri=https%3A%2F%2Fswa.azurestaticapps.net%2F.auth%2Flogin%2Faad%2Fcallback&client_id=6c3476f8-54c2-4322-8401-f7774963a1e1&scope=openid+profile+email&response_mode=form_post&resource=https%3A%2F%2Fgraph.microsoft.com&nonce=9108b12ec87a4effa26bd5287d792605_20251223165427&state=redir%3D%252F.auth%252Fcomplete

Problem

login_hint is lost and never passed to the login.microsoftonline.com endpoint.

Fix

I implement a middleware with Azure Functions v4 on Node with http2 request.

Why http2? The auth flow uses pseudo-headers :authority, :path that are a part of the HTTP/2 protocol.

I will provide full setup of the project later.

Steps

  1. User goes to https://swa.azurestaticapps.net/api/whoami?login_hint=user@mail.com

  2. HTTP request is received by function app.http("whoami"... and triggers a handler function.

  3. The handler gets original SWA url, login_hint from the request object, catches redirects, registers cookies and finally returns a full url for the login.microsoftonline.com endpoint with proper nonce cookie.

  4. The function returns a redirect response to the SWA page that handles final authentication.

  5. If fails, function returns a redirect to /.auth/login/aad, logs are registered with the context.log and can be checked in portal.azure.com

Here's the function code that has been tested with multiple user accounts.

import { app } from "@azure/functions";
import { connect } from "node:http2";

let count = 5;

async function getData(url, pWithResolvers, headersObj = {}, loginHint) {
    const p = pWithResolvers ?? Promise.withResolvers();
    const clientSession = connect(url);

    clientSession.on("error", (err) => console.error(err));

    const req = clientSession.request({
        ...headersObj,
    });

    req.on("response", (headers) => {
        req.on("error", (e) => {
            context.log(`problem with request:`, {
                msg: e.message,
                stack: e.stack,
                e: e,
            });
            clientSession.close();
            p.reject(e.message);
        });

        const status = headers[":status"];
        const cookie = headers["set-cookie"];

        if (status === 302 && --count) {
            const location = new URL(headers["location"]);
            const newHeaders = {
                ":authority": location.host,
                ":method": "GET",
                ":path": location.pathname + location.search,
                ":scheme": "https",
                ...(cookie ? { Cookie: cookie.join("; ") } : {}),
            };

            clientSession.close();

            return getData(location.href, p, newHeaders, loginHint);
        }

        const data = loginHint
            ? {
                    url: url.replace(
                        "prompt=select_account",
                        `login_hint=${loginHint}`,
                    ),
                    headersObj: {
                        ":authority": headersObj[":authority"],
                        ":method": headersObj[":method"],
                        ":path": headersObj[":path"].replace(
                            "prompt=select_account",
                            `login_hint=${loginHint}`
                        ),
                        ":scheme": headersObj[":scheme"],
                        Cookie: headersObj["Cookie"],
                    },
                }
            : { url, headersObj };

        clientSession.close();
        p.resolve(data);
    });

    req.end();

    return p.promise;
}

app.http("whoami", {
    handler: async (request, context) => {
        /* context.log(request) => HttpRequest {
            query: URLSearchParams { 'login_hint' => 'bla', 'prompt' => 'none' },
            params: { login_hint: 'bla', prompt: 'none' }
        } */
        try {
            const originalUrl = request.headers.get("x-ms-original-url");
            const loginHint = request.query.get("login_hint") ?? "";
            const data = await getData(
                originalUrl,
                null,
                {
                    ":authority": "swa.azurestaticapps.net",
                    ":method": "GET",
                    ":path": "/.auth/login/aad",
                    ":scheme": "https",
                },
                loginHint,
            ).catch((e) => {
                throw new Error('whoami failed', {cause: {msg: e.message, stack: e.stack, cause: e.cause, e: e}})
            });

            /* data => {
                url: "https://login.microsoftonline.com/c74da02d-281d-4a45-a4af-cc520eafa6e3/oauth2/v2.0/authorize?response_type=code+id_token&redirect_uri=https%3A%2F%2Fswa.azurestaticapps.net%2F.auth%2Flogin%2Faad%2Fcallback&client_id=6c3476f8-54c2-4322-8401-f7774963a1e1&scope=openid+profile+email&response_mode=form_post&resource=https%3A%2F%2Fgraph.microsoft.com&nonce=9108b12ec87a4effa26bd5287d792605_20251223165427&state=redir%3D%252F.auth%252Fcomplete",
                headersObj: {
                    ":authority": "login.microsoftonline.com",
                    ":method": "GET",
                    ":path":
                        "/c74da02d-281d-4a45-a4af-cc520eafa6e3/oauth2/v2.0/authorize?response_type=code+id_token&redirect_uri=https%3A%2F%2Fswa.azurestaticapps.net%2F.auth%2Flogin%2Faad%2Fcallback&client_id=6c3476f8-54c2-4322-8401-f7774963a1e1&scope=openid+profile+email&response_mode=form_post&resource=https%3A%2F%2Fgraph.microsoft.com&nonce=9108b12ec87a4effa26bd5287d792605_20251223165427&state=redir%3D%252F.auth%252Fcomplete",
                    ":scheme": "https",
                    Cookie:
                        "Nonce=xSJPvaizZDNrIEoi7WpbKykpYx/hYbj02Kh/AmUE6rwAS4NdrQqCVoY46gYT3ltBl0TTgMfTiqw/k/F0Zgql0zuXALXylBdhpxZJ6qatKTMCCPWXRFsVoDFReBgiKMq8; path=/; secure; HttpOnly",
                },
            } */

            context.log({ ...data, ...request, "x-ms-original-url": originalUrl });

            return {
                status: 302,
                headers: {
                    Location: data.url,
                    "Set-Cookie": [data.headersObj.Cookie]
                }
            };
        } catch(e) {
            context.log({msg: e.message, stack: e.stack, cause: e.cause, e: e});
            return {
                status: 302,
                headers: {
                    Location: "https://swa.azurestaticapps.net/.auth/login/aad",
                }
            };
        }
    },
});

Enter fullscreen mode Exit fullscreen mode

staticwebapp.config.json

{
    "trailingSlash": "auto",
    "responseOverrides": {
        "401": {
            "statusCode": 302,
            "redirect": "/aad-redirect/"
        }
    },
    "routes": [
        {
            "route": "/api/whoami*",
            "allowedRoles": ["anonymous", "authenticated"]
        },
        {
            "route": "/api/*",
            "allowedRoles": ["authenticated"]
        },
        {
            "route": "/aad-redirect",
            "allowedRoles": ["anonymous", "authenticated"]
        },
        {
            "route": ".auth/login/github",
            "statusCode": 404
        },
        {
            "route": "/*",
            "allowedRoles": ["authenticated"]
        }
    ],
    "navigationFallback": {
        "rewrite": "/index.html",
        "exclude": ["*.{js,css,png,gif,jpg,jpeg,ico}", "/api/*"]
    },
    "auth": {
        "rolesSource": "/api/getroles",
        "identityProviders": {
            "azureActiveDirectory": {
                "registration": {
                    "openIdIssuer": "https://login.microsoftonline.com/c74da02d-281d-4a45-a4af-cc520eafa6e3/v2.0/",
                    "clientIdSettingName": "AZURE_CLIENT_ID",
                    "clientSecretSettingName": "AZURE_CLIENT_SECRET_APP_SETTING_NAME"
                },
                "login": {
                    "loginParameters": ["resource=https://graph.microsoft.com", "prompt=select_account"]
                }
            }
        }
    },
    "platform": {
        "apiRuntime": "node:22"
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: prompt=select_account is used by default and replaced by login_hint if the former is present.

Page aad-redirect

<!doctype html>
<html lang="en">

<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script type="text/javascript">
        localStorage.setItem('aad-redirect', true); 
        // this is used to restore last page state location.search + location.hash
        // I could use param post_login_redirect_url=.referrer instead but in this case location.hash is always lost
        // For now, if key aad-redirect if present, last state is taken from localStorage when page loads

        let loginHint = '';
        if (location.search.includes('login_hint=')) {
            const params = new URLSearchParams(location.search);
            const value = params.get('login_hint');
            if (value) {
                loginHint = `?login_hint=${value}`
            }
        }

        location.replace(`/api/whoami${loginHint}`)
    </script>
</head>

<body>

</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Top comments (0)