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
User goes to
https://swa.azurestaticapps.net/api/whoami?login_hint=user@mail.comHTTP request is received by function
app.http("whoami"...and triggers ahandlerfunction.The handler gets original SWA url, login_hint from the
requestobject, catches redirects, registers cookies and finally returns a full url for thelogin.microsoftonline.comendpoint with proper nonce cookie.The function returns a redirect response to the SWA page that handles final authentication.
If fails, function returns a redirect to
/.auth/login/aad, logs are registered with thecontext.logand can be checked inportal.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",
}
};
}
},
});
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"
}
}
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>
Top comments (0)