diff --git a/package-lock.json b/package-lock.json index 91f16b23..ee0fb3c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "internal-ip": "^6.2.0", "json-schema-library": "^9.3.5", "json-source-map": "^0.6.1", + "jwt-decode": "^4.0.0", "keytar": "^7.9.0", "node-fetch": "^2.7.0", "open": "^8.4.2", @@ -79,8 +80,8 @@ "vitest": "^2.0.2" }, "engines": { - "node": ">=14.0.0", - "npm": ">=6.0.0" + "node": ">=18.0.0", + "npm": ">=9.0.0" } }, "node_modules/@ampproject/remapping": { @@ -3798,9 +3799,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7530,6 +7531,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", @@ -17466,9 +17475,9 @@ "dev": true }, "axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", + "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -20245,6 +20254,11 @@ "safe-buffer": "^5.0.1" } }, + "jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==" + }, "keytar": { "version": "7.9.0", "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", diff --git a/package.json b/package.json index 2be1e25a..a60d3160 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "internal-ip": "^6.2.0", "json-schema-library": "^9.3.5", "json-source-map": "^0.6.1", + "jwt-decode": "^4.0.0", "keytar": "^7.9.0", "node-fetch": "^2.7.0", "open": "^8.4.2", diff --git a/src/core/constants.ts b/src/core/constants.ts index a8dd52f1..4ea23819 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -50,7 +50,7 @@ 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"]; +export const SUPPORTED_CUSTOM_AUTH_PROVIDERS = ["google", "github", "aad", "facebook", "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 @@ -69,6 +69,10 @@ export const CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { host: "login.microsoftonline.com", path: "/tenantId/oauth2/v2.0/token", }, + facebook: { + host: "graph.facebook.com", + path: "/v11.0/oauth/access_token", + }, }; export const CUSTOM_AUTH_USER_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { google: { @@ -88,6 +92,13 @@ export const CUSTOM_AUTH_ISS_MAPPING: AuthIdentityIssHosts = { google: "https://account.google.com", github: "", aad: "https://graph.microsoft.com", + facebook: "https://www.facebook.com", +}; +export const CUSTOM_AUTH_REQUIRED_FIELDS: AuthIdentityRequiredFields = { + google: ["clientIdSettingName", "clientSecretSettingName"], + github: ["clientIdSettingName", "clientSecretSettingName"], + aad: ["clientIdSettingName", "clientSecretSettingName", "openIdIssuer"], + facebook: ["appIdSettingName", "appSecretSettingName"], }; export const AUTH_STATUS = { diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index ac129559..3ddd8052 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -25,7 +25,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { paths.push({ method: "GET", // For providers with custom auth support not implemented, revert to old behavior - route: /^\/\.auth\/login\/(?twitter|facebook|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?twitter|[a-z]+)(\?.*)?$/i, function: "auth-login-provider", }); paths.push({ diff --git a/src/msha/auth/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts index d8e2d47b..4b9196ae 100644 --- a/src/msha/auth/routes/auth-login-provider-callback.ts +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -5,7 +5,6 @@ import * as querystring from "node:querystring"; import { CookiesManager, decodeAuthContextCookie, validateAuthContextCookie } from "../../../core/utils/cookie.js"; import { parseUrl, response } from "../../../core/utils/net.js"; import { - ENTRAID_FULL_NAME, CUSTOM_AUTH_ISS_MAPPING, CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING, CUSTOM_AUTH_USER_ENDPOINT_MAPPING, @@ -15,26 +14,27 @@ import { } from "../../../core/constants.js"; import { DEFAULT_CONFIG } from "../../../config.js"; import { encryptAndSign, hashStateGuid, isNonceExpired } from "../../../core/utils/auth.js"; -import { normalizeAuthProvider } from "./auth-login-provider-custom.js"; - -const getAuthClientPrincipal = async function ( - authProvider: string, - codeValue: string, - clientId: string, - clientSecret: string, - openIdIssuer: string = "", -) { +import { checkCustomAuthConfigFields, normalizeAuthProvider } from "./auth-login-provider-custom.js"; +import { jwtDecode } from "jwt-decode"; + +const getAuthClientPrincipal = async function (authProvider: string, codeValue: string, authConfigs: Record) { let authToken: string; try { - const authTokenResponse = (await getOAuthToken(authProvider, codeValue!, clientId, clientSecret, openIdIssuer)) as string; + const authTokenResponse = (await getOAuthToken(authProvider, codeValue!, authConfigs)) as string; let authTokenParsed; try { authTokenParsed = JSON.parse(authTokenResponse); } catch (e) { authTokenParsed = querystring.parse(authTokenResponse); } - authToken = authTokenParsed["access_token"] as string; + + // Facebook sends back a JWT in the id_token + if (authProvider !== "facebook") { + authToken = authTokenParsed["access_token"] as string; + } else { + authToken = authTokenParsed["id_token"] as string; + } } catch (error) { console.error(`Error in getting OAuth token: ${error}`); return null; @@ -62,11 +62,11 @@ const getAuthClientPrincipal = async function ( }, { typ: "azp", - val: clientId, + val: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, }, { typ: "aud", - val: clientId, + val: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, }, ]; @@ -139,7 +139,7 @@ const getAuthClientPrincipal = async function ( } }; -const getOAuthToken = function (authProvider: string, codeValue: string, clientId: string, clientSecret: string, openIdIssuer: string = "") { +const getOAuthToken = function (authProvider: string, codeValue: string, authConfigs: Record) { const redirectUri = `${SWA_CLI_APP_PROTOCOL}://${DEFAULT_CONFIG.host}:${DEFAULT_CONFIG.port}`; let tenantId; @@ -148,13 +148,13 @@ const getOAuthToken = function (authProvider: string, codeValue: string, clientI } if (authProvider === "aad") { - tenantId = openIdIssuer.split("/")[3]; + tenantId = authConfigs?.openIdIssuer.split("/")[3]; } const data = querystring.stringify({ code: codeValue, - client_id: clientId, - client_secret: clientSecret, + client_id: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, + client_secret: authConfigs?.clientSecretSettingName || authConfigs?.appSecretSettingName, grant_type: "authorization_code", redirect_uri: `${redirectUri}/.auth/login/${authProvider}/callback`, }); @@ -198,40 +198,45 @@ const getOAuthToken = function (authProvider: string, codeValue: string, clientI }; const getOAuthUser = function (authProvider: string, accessToken: string) { - const options = { - host: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.host, - path: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.path, - method: "GET", - headers: { - Authorization: `Bearer ${accessToken}`, - "User-Agent": "Azure Static Web Apps Emulator", - }, - }; + // Facebook does not have an OIDC introspection so we need to manually decode the token :( + if (authProvider === "facebook") { + return jwtDecode(accessToken); + } else { + const options = { + host: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.host, + path: CUSTOM_AUTH_USER_ENDPOINT_MAPPING?.[authProvider]?.path, + 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 = ""; + return new Promise((resolve, reject) => { + const req = https.request(options, (res) => { + res.setEncoding("utf8"); + let responseBody = ""; - res.on("data", (chunk) => { - responseBody += chunk; + res.on("data", (chunk) => { + responseBody += chunk; + }); + + res.on("end", () => { + try { + resolve(JSON.parse(responseBody)); + } catch (err) { + reject(err); + } + }); }); - res.on("end", () => { - try { - resolve(JSON.parse(responseBody)); - } catch (err) { - reject(err); - } + req.on("error", (err) => { + reject(err); }); - }); - req.on("error", (err) => { - reject(err); + req.end(); }); - - req.end(); - }); + } }; const getRoles = function (clientPrincipal: RolesSourceFunctionRequestBody, rolesSource: string) { @@ -334,64 +339,12 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess return; } - const { clientIdSettingName, clientSecretSettingName, openIdIssuer } = - customAuth?.identityProviders?.[providerName == "aad" ? ENTRAID_FULL_NAME : providerName]?.registration || {}; - - if (!clientIdSettingName) { - context.res = response({ - context, - status: 400, - headers: { ["Content-Type"]: "text/plain" }, - body: `ClientIdSettingName not found for '${providerName}' provider`, - }); - return; - } - - if (!clientSecretSettingName) { - context.res = response({ - context, - 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: 400, - headers: { ["Content-Type"]: "text/plain" }, - body: `ClientId not found for '${providerName}' provider`, - }); - return; - } - - const clientSecret = process.env[clientSecretSettingName]; - - if (!clientSecret) { - context.res = response({ - context, - status: 400, - headers: { ["Content-Type"]: "text/plain" }, - body: `ClientSecret not found for '${providerName}' provider`, - }); + const authConfigs = checkCustomAuthConfigFields(context, providerName, customAuth); + if (!authConfigs) { return; } - const clientPrincipal = await getAuthClientPrincipal(providerName, codeValue!, clientId, clientSecret, openIdIssuer!); + const clientPrincipal = await getAuthClientPrincipal(providerName, codeValue!, authConfigs); if (clientPrincipal !== null && customAuth?.rolesSource) { try { diff --git a/src/msha/auth/routes/auth-login-provider-custom.ts b/src/msha/auth/routes/auth-login-provider-custom.ts index 5df1c564..5430a3d1 100644 --- a/src/msha/auth/routes/auth-login-provider-custom.ts +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -1,70 +1,67 @@ import { IncomingMessage } from "node:http"; import { CookiesManager } from "../../../core/utils/cookie.js"; import { response } from "../../../core/utils/net.js"; -import { ENTRAID_FULL_NAME, SUPPORTED_CUSTOM_AUTH_PROVIDERS, SWA_CLI_APP_PROTOCOL } from "../../../core/constants.js"; +import { CUSTOM_AUTH_REQUIRED_FIELDS, ENTRAID_FULL_NAME, 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) => { +export const normalizeAuthProvider = function (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: string = normalizeAuthProvider(context.bindingData?.provider); - - if (!SUPPORTED_CUSTOM_AUTH_PROVIDERS.includes(providerName)) { - context.res = response({ +export const checkCustomAuthConfigFields = function (context: Context, providerName: string, customAuth?: SWAConfigFileAuth) { + const generateResponse = function (msg: string) { + return { context, status: 400, headers: { ["Content-Type"]: "text/plain" }, - body: `Provider '${providerName}' not found`, - }); - return; + body: msg, + }; + }; + + if (!CUSTOM_AUTH_REQUIRED_FIELDS[providerName]) { + context.res = response(generateResponse(`Provider '${providerName}' not found`)); + return false; } - const clientIdSettingName = - customAuth?.identityProviders?.[providerName == "aad" ? ENTRAID_FULL_NAME : providerName]?.registration?.clientIdSettingName; + const requiredFields = CUSTOM_AUTH_REQUIRED_FIELDS[providerName]; + const configFileProviderName = providerName === "aad" ? ENTRAID_FULL_NAME : providerName; + const authConfigs: Record = {}; - if (!clientIdSettingName) { - context.res = response({ - context, - status: 400, - headers: { ["Content-Type"]: "text/plain" }, - body: `ClientIdSettingName not found for '${providerName}' provider`, - }); - return; + for (const field of requiredFields) { + const settingName = customAuth?.identityProviders?.[configFileProviderName]?.registration?.[field]; + if (!settingName) { + context.res = response(generateResponse(`${field} not found for '${providerName}' provider`)); + return false; + } + + // Special case for aad where the openIdIssuer is in the config file itself rather than the env + if (providerName === "aad" && field === "openIdIssuer") { + authConfigs[field] = settingName; + } else { + const settingValue = process.env[settingName]; + if (!settingValue) { + context.res = response(generateResponse(`${settingName} not found in env for '${providerName}' provider`)); + return false; + } + + authConfigs[field] = settingValue; + } } - const clientId = process.env[clientIdSettingName]; + return authConfigs; +}; - if (!clientId) { - context.res = response({ - context, - status: 400, - headers: { ["Content-Type"]: "text/plain" }, - body: `ClientId not found for '${providerName}' provider`, - }); - return; - } +const httpTrigger = async function (context: Context, request: IncomingMessage, customAuth?: SWAConfigFileAuth) { + await Promise.resolve(); - 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 providerName: string = normalizeAuthProvider(context.bindingData?.provider); + const authFields = checkCustomAuthConfigFields(context, providerName, customAuth); + if (!authFields) { + return; } const state = newNonceWithExpiration(); @@ -84,13 +81,16 @@ const httpTrigger = async function (context: Context, request: IncomingMessage, 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}`; + location = `https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=${authFields?.clientIdSettingName}&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}`; + location = `https://github.com/login/oauth/authorize?response_type=code&client_id=${authFields?.clientIdSettingName}&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}`; + location = `${authFields?.openIdIssuer}/authorize?response_type=code&client_id=${authFields?.clientIdSettingName}&redirect_uri=${redirectUri}/.auth/login/aad/callback&scope=openid+profile+email&state=${hashedState}`; + break; + case "facebook": + location = `https://facebook.com/v11.0/dialog/oauth?client_id=${authFields?.appIdSettingName}&redirect_uri=${redirectUri}/.auth/login/facebook/callback&scope=openid&state=${hashedState}&response_type=code`; break; default: break; diff --git a/src/swa.d.ts b/src/swa.d.ts index 6e480b4a..85023c80 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -303,12 +303,14 @@ declare type AuthIdentityIssHosts = { declare type AuthIdentityProvider = { registration: { - clientIdSettingName: string; - clientSecretSettingName: string; - openIdIssuer?: string; + [key: string]: string; }; }; +declare type AuthIdentityRequiredFields = { + [key: string]: string[]; +}; + declare type SWAConfigFileAuthIdenityProviders = { [key: string]: AuthIdentityProvider; };