diff --git a/src/core/constants.ts b/src/core/constants.ts index eb1c5a4e..5b50e93a 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -49,6 +49,47 @@ export const SWA_AUTH_CONTEXT_COOKIE = `StaticWebAppsAuthContextCookie`; export const SWA_AUTH_COOKIE = `StaticWebAppsAuthCookie`; export const ALLOWED_HTTP_METHODS_FOR_STATIC_CONTENT = ["GET", "HEAD", "OPTIONS"]; +// Custom Auth constants +export const SUPPORTED_CUSTOM_AUTH_PROVIDERS = ["google", "github", "aad", "dummy"]; +/* + The full name is required in staticwebapp.config.json's schema that will be normalized to aad + https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-custom?tabs=aad%2Cinvitations +*/ +export const ENTRAID_FULL_NAME = "azureActiveDirectory"; +export const CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { + google: { + host: "oauth2.googleapis.com", + path: "/token", + }, + github: { + host: "github.com", + path: "/login/oauth/access_token", + }, + aad: { + host: "login.microsoftonline.com", + path: "/tenantId/oauth2/v2.0/token", + }, +}; +export const CUSTOM_AUTH_USER_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { + google: { + host: "www.googleapis.com", + path: "/oauth2/v2/userinfo", + }, + github: { + host: "api.github.com", + path: "/user", + }, + aad: { + host: "graph.microsoft.com", + path: "/oidc/userinfo", + }, +}; +export const CUSTOM_AUTH_ISS_MAPPING: AuthIdentityIssHosts = { + google: "https://account.google.com", + github: "", + aad: "https://graph.microsoft.com", +}; + export const AUTH_STATUS = { NoAuth: 0, HostNameAuthLogin: 1, diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index ddc0c812..ac129559 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -2,27 +2,30 @@ import type http from "node:http"; import { serializeCookie } from "../../core/utils/cookie.js"; import { logger } from "../../core/utils/logger.js"; import { response as newResponse } from "../../core/utils/net.js"; +import { SUPPORTED_CUSTOM_AUTH_PROVIDERS } from "../../core/constants.js"; function getAuthPaths(isCustomAuth: boolean): Path[] { const paths: Path[] = []; if (isCustomAuth) { + const supportedAuthsRegex = SUPPORTED_CUSTOM_AUTH_PROVIDERS.join("|"); + paths.push({ method: "GET", - // only match for providers with custom auth support implemented (github, google) - route: /^\/\.auth\/login\/(?github|google|dummy)\/callback(\?.*)?$/i, + // only match for providers with custom auth support implemented (github, google, aad) + route: new RegExp(`^/\\.auth/login/(?${supportedAuthsRegex})/callback(\\?.*)?$`, "i"), function: "auth-login-provider-callback", }); paths.push({ method: "GET", - // only match for providers with custom auth support implemented (github, google) - route: /^\/\.auth\/login\/(?github|google|dummy)(\?.*)?$/i, + // only match for providers with custom auth support implemented (github, google, aad) + route: new RegExp(`^/\\.auth/login/(?${supportedAuthsRegex})(\\?.*)?$`, "i"), function: "auth-login-provider-custom", }); paths.push({ method: "GET", // For providers with custom auth support not implemented, revert to old behavior - route: /^\/\.auth\/login\/(?aad|twitter|facebook|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?twitter|facebook|[a-z]+)(\?.*)?$/i, function: "auth-login-provider", }); paths.push({ @@ -33,7 +36,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { } else { paths.push({ method: "GET", - route: /^\/\.auth\/login\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, function: "auth-login-provider", }); } diff --git a/src/msha/auth/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts index 41ddc4cf..d8e2d47b 100644 --- a/src/msha/auth/routes/auth-login-provider-callback.ts +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -4,95 +4,39 @@ import * as querystring from "node:querystring"; import { CookiesManager, decodeAuthContextCookie, validateAuthContextCookie } from "../../../core/utils/cookie.js"; import { parseUrl, response } from "../../../core/utils/net.js"; -import { SWA_CLI_API_URI, SWA_CLI_APP_PROTOCOL } from "../../../core/constants.js"; +import { + ENTRAID_FULL_NAME, + CUSTOM_AUTH_ISS_MAPPING, + CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING, + CUSTOM_AUTH_USER_ENDPOINT_MAPPING, + SUPPORTED_CUSTOM_AUTH_PROVIDERS, + SWA_CLI_API_URI, + SWA_CLI_APP_PROTOCOL, +} from "../../../core/constants.js"; import { DEFAULT_CONFIG } from "../../../config.js"; import { encryptAndSign, hashStateGuid, isNonceExpired } from "../../../core/utils/auth.js"; - -const getGithubAuthToken = function (codeValue: string, clientId: string, clientSecret: string) { - const data = querystring.stringify({ - code: codeValue, - client_id: clientId, - client_secret: clientSecret, - }); - - const options = { - host: "github.com", - path: "/login/oauth/access_token", - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "Content-Length": Buffer.byteLength(data), - }, - }; - - return new Promise((resolve, reject) => { - const req = https.request(options, (res) => { - res.setEncoding("utf8"); - let responseBody = ""; - - res.on("data", (chunk) => { - responseBody += chunk; - }); - - res.on("end", () => { - resolve(responseBody); - }); - }); - - req.on("error", (err: Error) => { - reject(err); - }); - - req.write(data); - req.end(); - }); -}; - -const getGitHubUser = function (accessToken: string) { - const options = { - host: "api.github.com", - path: "/user", - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": "Azure Static Web Apps Emulator", - }, - }; - - return new Promise((resolve, reject) => { - const req = https.request(options, (res) => { - res.setEncoding("utf8"); - let responseBody = ""; - - res.on("data", (chunk) => { - responseBody += chunk; - }); - - res.on("end", () => { - try { - resolve(JSON.parse(responseBody)); - } catch (err) { - reject(err); - } - }); - }); - - req.on("error", (err) => { - reject(err); - }); - - req.end(); - }); -}; - -const getGitHubClientPrincipal = async function (codeValue: string, clientId: string, clientSecret: string) { +import { normalizeAuthProvider } from "./auth-login-provider-custom.js"; + +const getAuthClientPrincipal = async function ( + authProvider: string, + codeValue: string, + clientId: string, + clientSecret: string, + openIdIssuer: string = "", +) { let authToken: string; try { - const authTokenResponse = (await getGithubAuthToken(codeValue, clientId, clientSecret)) as string; - const authTokenParsed = querystring.parse(authTokenResponse); + const authTokenResponse = (await getOAuthToken(authProvider, codeValue!, clientId, clientSecret, openIdIssuer)) as string; + let authTokenParsed; + try { + authTokenParsed = JSON.parse(authTokenResponse); + } catch (e) { + authTokenParsed = querystring.parse(authTokenResponse); + } authToken = authTokenParsed["access_token"] as string; - } catch { + } catch (error) { + console.error(`Error in getting OAuth token: ${error}`); return null; } @@ -101,49 +45,128 @@ const getGitHubClientPrincipal = async function (codeValue: string, clientId: st } try { - const user = (await getGitHubUser(authToken)) as { [key: string]: string }; + const user = (await getOAuthUser(authProvider, authToken)) as { [key: string]: string }; + const userDetails = user["login"] || user["email"]; + const name = user["name"]; + const givenName = user["given_name"]; + const familyName = user["family_name"]; + const picture = user["picture"]; const userId = user["id"]; - const userDetails = user["login"]; + const verifiedEmail = user["verified_email"]; const claims: { typ: string; val: string }[] = [ { - typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", - val: userId, + typ: "iss", + val: CUSTOM_AUTH_ISS_MAPPING?.[authProvider], + }, + { + typ: "azp", + val: clientId, + }, + { + typ: "aud", + val: clientId, }, ]; - Object.keys(user).forEach((key) => { + if (userDetails) { claims.push({ - typ: `urn:github:${key}`, - val: user[key], + typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + val: userDetails, }); - }); + } + + if (name) { + claims.push({ + typ: "name", + val: name, + }); + } + + if (picture) { + claims.push({ + typ: "picture", + val: picture, + }); + } + + if (givenName) { + claims.push({ + typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", + val: givenName, + }); + } + + if (familyName) { + claims.push({ + typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", + val: familyName, + }); + } + + if (userId) { + claims.push({ + typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", + val: userId, + }); + } + + if (verifiedEmail) { + claims.push({ + typ: "email_verified", + val: verifiedEmail, + }); + } + + if (authProvider === "github") { + Object.keys(user).forEach((key) => { + claims.push({ + typ: `urn:github:${key}`, + val: user[key], + }); + }); + } return { - identityProvider: "github", - userId, + identityProvider: authProvider, userDetails, - userRoles: ["authenticated", "anonymous"], claims, + userRoles: ["authenticated", "anonymous"], }; } catch { return null; } }; -const getGoogleAuthToken = function (codeValue: string, clientId: string, clientSecret: string) { +const getOAuthToken = function (authProvider: string, codeValue: string, clientId: string, clientSecret: string, openIdIssuer: string = "") { + const redirectUri = `${SWA_CLI_APP_PROTOCOL}://${DEFAULT_CONFIG.host}:${DEFAULT_CONFIG.port}`; + let tenantId; + + if (!Object.keys(CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING).includes(authProvider)) { + return null; + } + + if (authProvider === "aad") { + tenantId = openIdIssuer.split("/")[3]; + } + const data = querystring.stringify({ code: codeValue, client_id: clientId, client_secret: clientSecret, grant_type: "authorization_code", - redirect_uri: `${SWA_CLI_APP_PROTOCOL}://${DEFAULT_CONFIG.host}:${DEFAULT_CONFIG.port}/.auth/login/google/callback`, + redirect_uri: `${redirectUri}/.auth/login/${authProvider}/callback`, }); + let tokenPath = CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING?.[authProvider]?.path; + if (authProvider === "aad" && tenantId !== undefined) { + tokenPath = tokenPath.replace("tenantId", tenantId); + } + const options = { - host: "oauth2.googleapis.com", - path: "/token", + host: CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING?.[authProvider]?.host, + path: tokenPath, method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -174,10 +197,10 @@ const getGoogleAuthToken = function (codeValue: string, clientId: string, client }); }; -const getGoogleUser = function (accessToken: string) { +const getOAuthUser = function (authProvider: string, accessToken: string) { const options = { - host: "www.googleapis.com", - path: "/oauth2/v2/userinfo", + host: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.host, + path: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.path, method: "GET", headers: { Authorization: `Bearer ${accessToken}`, @@ -211,108 +234,6 @@ const getGoogleUser = function (accessToken: string) { }); }; -const getGoogleClientPrincipal = async function (codeValue: string, clientId: string, clientSecret: string) { - let authToken: string; - - try { - const authTokenResponse = (await getGoogleAuthToken(codeValue!, clientId, clientSecret)) as string; - const authTokenParsed = JSON.parse(authTokenResponse); - authToken = authTokenParsed["access_token"] as string; - } catch { - return null; - } - - if (!authToken) { - return null; - } - - try { - const user = (await getGoogleUser(authToken)) as { [key: string]: string }; - - const userId = user["id"]; - const userDetails = user["email"]; - const verifiedEmail = user["verified_email"]; - const name = user["name"]; - const givenName = user["given_name"]; - const familyName = user["family_name"]; - const picture = user["picture"]; - - const claims: { typ: string; val: string }[] = [ - { - typ: "iss", - val: "https://accounts.google.com", - }, - { - typ: "azp", - val: clientId, - }, - { - typ: "aud", - val: clientId, - }, - ]; - - if (userId) { - claims.push({ - typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", - val: userId, - }); - } - - if (userDetails) { - claims.push({ - typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", - val: userDetails, - }); - } - - if (verifiedEmail !== undefined) { - claims.push({ - typ: "email_verified", - val: verifiedEmail, - }); - } - - if (name) { - claims.push({ - typ: "name", - val: name, - }); - } - - if (picture) { - claims.push({ - typ: "picture", - val: picture, - }); - } - - if (givenName) { - claims.push({ - typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname", - val: givenName, - }); - } - - if (familyName) { - claims.push({ - typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname", - val: familyName, - }); - } - - return { - identityProvider: "google", - userId, - userDetails, - claims, - userRoles: ["authenticated", "anonymous"], - }; - } catch { - return null; - } -}; - const getRoles = function (clientPrincipal: RolesSourceFunctionRequestBody, rolesSource: string) { let cliApiUri = SWA_CLI_API_URI(); const { protocol, hostname, port } = parseUrl(cliApiUri); @@ -362,12 +283,12 @@ const getRoles = function (clientPrincipal: RolesSourceFunctionRequestBody, role }; const httpTrigger = async function (context: Context, request: http.IncomingMessage, customAuth?: SWAConfigFileAuth) { - const providerName = context.bindingData?.provider?.toLowerCase() || ""; + const providerName = normalizeAuthProvider(context.bindingData?.provider); - if (providerName != "github" && providerName != "google") { + if (!SUPPORTED_CUSTOM_AUTH_PROVIDERS.includes(providerName)) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `Provider '${providerName}' not found`, }); @@ -413,12 +334,13 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess return; } - const { clientIdSettingName, clientSecretSettingName } = customAuth?.identityProviders?.[providerName]?.registration || {}; + const { clientIdSettingName, clientSecretSettingName, openIdIssuer } = + customAuth?.identityProviders?.[providerName == "aad" ? ENTRAID_FULL_NAME : providerName]?.registration || {}; if (!clientIdSettingName) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientIdSettingName not found for '${providerName}' provider`, }); @@ -428,19 +350,29 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess if (!clientSecretSettingName) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientSecretSettingName not found for '${providerName}' provider`, }); return; } + if (providerName == "aad" && !openIdIssuer) { + context.res = response({ + context, + status: 400, + headers: { ["Content-Type"]: "text/plain" }, + body: `openIdIssuer not found for '${providerName}' provider`, + }); + return; + } + const clientId = process.env[clientIdSettingName]; if (!clientId) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientId not found for '${providerName}' provider`, }); @@ -452,22 +384,19 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess if (!clientSecret) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientSecret not found for '${providerName}' provider`, }); return; } - const clientPrincipal = - providerName === "github" - ? await getGitHubClientPrincipal(codeValue!, clientId, clientSecret) - : await getGoogleClientPrincipal(codeValue!, clientId, clientSecret); + const clientPrincipal = await getAuthClientPrincipal(providerName, codeValue!, clientId, clientSecret, openIdIssuer!); if (clientPrincipal !== null && customAuth?.rolesSource) { try { - const rolesResult = (await getRoles(clientPrincipal, customAuth.rolesSource)) as { roles: string[] }; - clientPrincipal.userRoles.push(...rolesResult.roles); + const rolesResult = (await getRoles(clientPrincipal as RolesSourceFunctionRequestBody, customAuth.rolesSource)) as { roles: string[] }; + clientPrincipal?.userRoles.push(...rolesResult.roles); } catch {} } diff --git a/src/msha/auth/routes/auth-login-provider-custom.ts b/src/msha/auth/routes/auth-login-provider-custom.ts index e04b655a..5df1c564 100644 --- a/src/msha/auth/routes/auth-login-provider-custom.ts +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -1,31 +1,39 @@ import { IncomingMessage } from "node:http"; import { CookiesManager } from "../../../core/utils/cookie.js"; import { response } from "../../../core/utils/net.js"; -import { SWA_CLI_APP_PROTOCOL } from "../../../core/constants.js"; +import { ENTRAID_FULL_NAME, SUPPORTED_CUSTOM_AUTH_PROVIDERS, SWA_CLI_APP_PROTOCOL } from "../../../core/constants.js"; import { DEFAULT_CONFIG } from "../../../config.js"; import { encryptAndSign, extractPostLoginRedirectUri, hashStateGuid, newNonceWithExpiration } from "../../../core/utils/auth.js"; +export const normalizeAuthProvider = (providerName?: string) => { + if (providerName === ENTRAID_FULL_NAME) { + return "aad"; + } + return providerName?.toLowerCase() || ""; +}; + const httpTrigger = async function (context: Context, request: IncomingMessage, customAuth?: SWAConfigFileAuth) { await Promise.resolve(); - const providerName = context.bindingData?.provider?.toLowerCase() || ""; + const providerName: string = normalizeAuthProvider(context.bindingData?.provider); - if (providerName != "github" && providerName != "google") { + if (!SUPPORTED_CUSTOM_AUTH_PROVIDERS.includes(providerName)) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `Provider '${providerName}' not found`, }); return; } - const clientIdSettingName = customAuth?.identityProviders?.[providerName]?.registration?.clientIdSettingName; + const clientIdSettingName = + customAuth?.identityProviders?.[providerName == "aad" ? ENTRAID_FULL_NAME : providerName]?.registration?.clientIdSettingName; if (!clientIdSettingName) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientIdSettingName not found for '${providerName}' provider`, }); @@ -37,13 +45,28 @@ const httpTrigger = async function (context: Context, request: IncomingMessage, if (!clientId) { context.res = response({ context, - status: 404, + status: 400, headers: { ["Content-Type"]: "text/plain" }, body: `ClientId not found for '${providerName}' provider`, }); return; } + let aadIssuer; + if (providerName == "aad") { + aadIssuer = customAuth?.identityProviders?.[ENTRAID_FULL_NAME]?.registration?.openIdIssuer; + + if (!aadIssuer) { + context.res = response({ + context, + status: 400, + headers: { ["Content-Type"]: "text/plain" }, + body: `openIdIssuer not found for '${providerName}' provider`, + }); + return; + } + } + const state = newNonceWithExpiration(); const authContext: AuthContext = { @@ -58,10 +81,20 @@ const httpTrigger = async function (context: Context, request: IncomingMessage, const hashedState = hashStateGuid(state); const redirectUri = `${SWA_CLI_APP_PROTOCOL}://${DEFAULT_CONFIG.host}:${DEFAULT_CONFIG.port}`; - const location = - providerName === "google" - ? `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}/.auth/login/google/callback&scope=openid+profile+email&state=${hashedState}` - : `https://github.com/login/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}/.auth/login/github/callback&scope=read:user&state=${hashedState}`; + let location; + switch (providerName) { + case "google": + location = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}/.auth/login/google/callback&scope=openid+profile+email&state=${hashedState}`; + break; + case "github": + location = `https://github.com/login/oauth/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}/.auth/login/github/callback&scope=read:user&state=${hashedState}`; + break; + case "aad": + location = `${aadIssuer}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${redirectUri}/.auth/login/aad/callback&scope=openid+profile+email&state=${hashedState}`; + break; + default: + break; + } const cookiesManager = new CookiesManager(request.headers.cookie); if (!authContextCookie) { diff --git a/src/swa.d.ts b/src/swa.d.ts index 052a6c86..6e480b4a 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -95,7 +95,7 @@ declare interface Context { value: string; expires: string | Date; domaine: string; - } + }, ]; headers?: { [key: string]: string }; body?: { [key: string]: string } | string | null; @@ -290,21 +290,32 @@ declare type SWAConfigFileMimeTypes = { [key: string]: string; }; +declare type AuthIdentityTokenEndpoints = { + [key: string]: { + host: string; + path: string; + }; +}; + +declare type AuthIdentityIssHosts = { + [key: string]: string; +}; + declare type AuthIdentityProvider = { registration: { clientIdSettingName: string; clientSecretSettingName: string; + openIdIssuer?: string; }; }; declare type SWAConfigFileAuthIdenityProviders = { - github?: AuthIdentityProvider; - google?: AuthIdentityProvider; + [key: string]: AuthIdentityProvider; }; declare type SWAConfigFileAuth = { rolesSource?: string; - identityProviders: SWAConfigFileAuthIdenityProviders; + identityProviders?: SWAConfigFileAuthIdenityProviders; }; declare type SWAConfigFile = {