From 0cd0b968df24914d1db1df490442e01be8d21110 Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:43:56 -0500 Subject: [PATCH 1/6] Reapply "Merge pull request #878 from Timothyw0/main" This reverts commit 83d3ca4e904e5b756b985450182bdd567e31aee2. --- src/core/constants.ts | 41 ++ src/msha/auth/index.ts | 15 +- .../routes/auth-login-provider-callback.ts | 363 +++++++----------- .../auth/routes/auth-login-provider-custom.ts | 55 ++- src/swa.d.ts | 19 +- 5 files changed, 255 insertions(+), 238 deletions(-) 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 = { From 22dc068bce58fd586a40b862919e1544d322dda9 Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:44:29 -0500 Subject: [PATCH 2/6] Reapply "Merge pull request #883 from codingoutloud/main" This reverts commit c62e7d9f568ea620d24fd65bc65e9b2804394e67. --- docs/www/docs/cli/swa.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/www/docs/cli/swa.md b/docs/www/docs/cli/swa.md index a8b59e65..707817e3 100644 --- a/docs/www/docs/cli/swa.md +++ b/docs/www/docs/cli/swa.md @@ -19,7 +19,7 @@ This is the commandline utility for streamlining local development for Azure Sta - start emulator or bind to dev server - deploy project to Azure Static Web Apps -If you don't enter any command and run `swa`, it will act as a macro command shorcut for `swa init`, `swa build`, `swa login` and `swa deploy`. The `swa init` command will only be executed if the `swa-cli.config.json` does not exist in the current folder. +If you don't enter any command and run `swa`, it will act as a macro command shortcut for `swa init`, `swa build`, `swa login` and `swa deploy`. The `swa init` command will only be executed if the `swa-cli.config.json` does not exist in the current folder. The best way to get started is to use the `swa` command and follow the interactive prompts. From 84f2a146fc443fe446636926e4b7979e1586230a Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:44:47 -0500 Subject: [PATCH 3/6] Reapply "Merge pull request #880 from Timothyw0/main" This reverts commit ed735e69caf6ab899c42baaf1231c3926894257f. --- package-lock.json | 30 +++- package.json | 1 + src/core/constants.ts | 13 +- src/msha/auth/index.ts | 2 +- .../routes/auth-login-provider-callback.ts | 153 ++++++------------ .../auth/routes/auth-login-provider-custom.ts | 96 +++++------ src/swa.d.ts | 8 +- 7 files changed, 142 insertions(+), 161 deletions(-) diff --git a/package-lock.json b/package-lock.json index 61433741..01ea47be 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 ccc1fe80..48dc7a66 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 5b50e93a..aef99fe7 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; }; From d48b3b476d78fa8bf75f9a15866446d41687e942 Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:45:05 -0500 Subject: [PATCH 4/6] Reapply "Merge pull request #889 from Timothyw0/main" This reverts commit dea34ff124369dfb103bb75d952d46c2e0e95227. --- src/core/constants.ts | 12 +++++- src/msha/auth/index.ts | 4 +- .../routes/auth-login-provider-callback.ts | 42 +++++++++++++------ .../auth/routes/auth-login-provider-custom.ts | 3 ++ 4 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/core/constants.ts b/src/core/constants.ts index aef99fe7..e0f8ddcd 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", "facebook", "dummy"]; +export const SUPPORTED_CUSTOM_AUTH_PROVIDERS = ["google", "github", "aad", "facebook", "twitter", "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 @@ -73,6 +73,10 @@ export const CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { host: "graph.facebook.com", path: "/v11.0/oauth/access_token", }, + twitter: { + host: "api.twitter.com", + path: "/2/oauth2/token", + }, }; export const CUSTOM_AUTH_USER_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { google: { @@ -87,18 +91,24 @@ export const CUSTOM_AUTH_USER_ENDPOINT_MAPPING: AuthIdentityTokenEndpoints = { host: "graph.microsoft.com", path: "/oidc/userinfo", }, + twitter: { + host: "api.twitter.com", + path: "/2/users/me", + }, }; export const CUSTOM_AUTH_ISS_MAPPING: AuthIdentityIssHosts = { google: "https://account.google.com", github: "", aad: "https://graph.microsoft.com", facebook: "https://www.facebook.com", + twitter: "https://www.x.com", }; export const CUSTOM_AUTH_REQUIRED_FIELDS: AuthIdentityRequiredFields = { google: ["clientIdSettingName", "clientSecretSettingName"], github: ["clientIdSettingName", "clientSecretSettingName"], aad: ["clientIdSettingName", "clientSecretSettingName", "openIdIssuer"], facebook: ["appIdSettingName", "appSecretSettingName"], + twitter: ["consumerKeySettingName", "consumerSecretSettingName"], }; export const AUTH_STATUS = { diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index 3ddd8052..d313545c 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -12,13 +12,13 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { paths.push({ method: "GET", - // only match for providers with custom auth support implemented (github, google, aad) + // only match for providers with custom auth support implemented (github, google, aad, facebook, twitter) 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, aad) + // only match for providers with custom auth support implemented (github, google, aad, facebook, twitter) route: new RegExp(`^/\\.auth/login/(?${supportedAuthsRegex})(\\?.*)?$`, "i"), function: "auth-login-provider-custom", }); diff --git a/src/msha/auth/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts index 4b9196ae..617141a4 100644 --- a/src/msha/auth/routes/auth-login-provider-callback.ts +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -45,14 +45,14 @@ const getAuthClientPrincipal = async function (authProvider: string, codeValue: } try { - const user = (await getOAuthUser(authProvider, authToken)) as { [key: string]: string }; + const user = (await getOAuthUser(authProvider, authToken)) as Record; - const userDetails = user["login"] || user["email"]; - const name = user["name"]; + const userDetails = user["login"] || user["email"] || user?.data?.["username"]; + const name = user["name"] || user?.data?.["name"]; const givenName = user["given_name"]; const familyName = user["family_name"]; const picture = user["picture"]; - const userId = user["id"]; + const userId = user["id"] || user?.data?.["id"]; const verifiedEmail = user["verified_email"]; const claims: { typ: string; val: string }[] = [ @@ -134,7 +134,8 @@ const getAuthClientPrincipal = async function (authProvider: string, codeValue: claims, userRoles: ["authenticated", "anonymous"], }; - } catch { + } catch (error) { + console.error(`Error while parsing user information: ${error}`); return null; } }; @@ -151,27 +152,42 @@ const getOAuthToken = function (authProvider: string, codeValue: string, authCon tenantId = authConfigs?.openIdIssuer.split("/")[3]; } - const data = querystring.stringify({ + const queryString: Record = { code: codeValue, - client_id: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, - client_secret: authConfigs?.clientSecretSettingName || authConfigs?.appSecretSettingName, grant_type: "authorization_code", redirect_uri: `${redirectUri}/.auth/login/${authProvider}/callback`, - }); + }; + + if (authProvider !== "twitter") { + queryString.client_id = authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName; + queryString.client_secret = authConfigs?.clientSecretSettingName || authConfigs?.appSecretSettingName; + } else { + queryString.code_verifier = "challenge"; + } + + const data = querystring.stringify(queryString); let tokenPath = CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING?.[authProvider]?.path; if (authProvider === "aad" && tenantId !== undefined) { tokenPath = tokenPath.replace("tenantId", tenantId); } + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(data), + }; + + if (authProvider === "twitter") { + const keySecretString = `${authConfigs?.consumerKeySettingName}:${authConfigs?.consumerSecretSettingName}`; + const encryptedCredentials = Buffer.from(keySecretString).toString("base64"); + headers.Authorization = `Basic ${encryptedCredentials}`; + } + const options = { host: CUSTOM_AUTH_TOKEN_ENDPOINT_MAPPING?.[authProvider]?.host, path: tokenPath, method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - "Content-Length": Buffer.byteLength(data), - }, + headers: headers, }; return new Promise((resolve, reject) => { diff --git a/src/msha/auth/routes/auth-login-provider-custom.ts b/src/msha/auth/routes/auth-login-provider-custom.ts index 5430a3d1..600dcf20 100644 --- a/src/msha/auth/routes/auth-login-provider-custom.ts +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -92,6 +92,9 @@ const httpTrigger = async function (context: Context, request: IncomingMessage, 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; + case "twitter": + location = `https://twitter.com/i/oauth2/authorize?response_type=code&client_id=${authFields?.consumerKeySettingName}&redirect_uri=${redirectUri}/.auth/login/twitter/callback&scope=users.read%20tweet.read&state=${hashedState}&code_challenge=challenge&code_challenge_method=plain`; + break; default: break; } From 9479c029cb5b4d7ed0a8be246ee7a8c2ba440f4f Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:45:32 -0500 Subject: [PATCH 5/6] Reapply "Fix undefined subscriptions in azureprofile.json" This reverts commit 49a6bbc49db7850b9e4d70f5ca158b968fa51c26. --- src/cli/commands/login/login.ts | 16 +++++++++------- src/swa.d.ts | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/cli/commands/login/login.ts b/src/cli/commands/login/login.ts index aceecd6b..682784ad 100644 --- a/src/cli/commands/login/login.ts +++ b/src/cli/commands/login/login.ts @@ -56,7 +56,7 @@ async function setupProjectCredentials(options: SWACLIConfig, credentialChain: T const tenants = await listTenants(credentialChain); if (tenants.length === 0) { throw new Error( - `No Azure tenants found in your account.\n Please read https://docs.microsoft.com/azure/cost-management-billing/manage/troubleshoot-sign-in-issue` + `No Azure tenants found in your account.\n Please read https://docs.microsoft.com/azure/cost-management-billing/manage/troubleshoot-sign-in-issue`, ); } else if (tenants.length === 1) { logger.silly(`Found 1 tenant: ${tenants[0].tenantId}`); @@ -81,7 +81,7 @@ async function setupProjectCredentials(options: SWACLIConfig, credentialChain: T const subscriptions = await listSubscriptions(credentialChain); if (subscriptions.length === 0) { throw new Error( - `No valid subscription found for tenant ${tenantId}.\n Please read https://docs.microsoft.com/azure/cost-management-billing/manage/no-subscriptions-found` + `No valid subscription found for tenant ${tenantId}.\n Please read https://docs.microsoft.com/azure/cost-management-billing/manage/no-subscriptions-found`, ); } else if (subscriptions.length === 1) { logger.silly(`Found 1 subscription: ${subscriptions[0].subscriptionId}`); @@ -109,7 +109,7 @@ async function storeProjectCredentialsInEnvFile( subscriptionId: string | undefined, tenantId: string | undefined, clientId: string | undefined, - clientSecret: string | undefined + clientSecret: string | undefined, ) { const envFile = path.join(process.cwd(), ENV_FILENAME); const envFileExists = existsSync(envFile); @@ -162,10 +162,12 @@ async function tryGetAzTenantAndSubscription(options: SWACLIConfig) { const azureProfile = await safeReadJson(AZURE_LOGIN_CONFIG); if (azureProfile) { const allSubscriptions = (azureProfile as AzureProfile).subscriptions; - const defaultAzureInfo = allSubscriptions.find((subscription) => subscription.isDefault == true); - if (defaultAzureInfo) { - options.tenantId = defaultAzureInfo.tenantId; - options.subscriptionId = defaultAzureInfo.id; + if (allSubscriptions) { + const defaultAzureInfo = allSubscriptions.find((subscription) => subscription.isDefault == true); + if (defaultAzureInfo) { + options.tenantId = defaultAzureInfo.tenantId; + options.subscriptionId = defaultAzureInfo.id; + } } } diff --git a/src/swa.d.ts b/src/swa.d.ts index 85023c80..c6df14d8 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -442,5 +442,5 @@ declare type AzureLoginInfo = { declare interface AzureProfile { installationId: string; - subscriptions: AzureLoginInfo[]; + subscriptions?: AzureLoginInfo[]; } From 2c6ea7ff847bc6eab0125dce2526b88afb1b4ea2 Mon Sep 17 00:00:00 2001 From: Timothy Wang Date: Fri, 27 Dec 2024 10:46:05 -0500 Subject: [PATCH 6/6] Reapply "Merge pull request #895 from jonnekleijer/fix/844-identity-provider-regex" This reverts commit ec3894989eec0b22dea3cfcfb6972efaf111ae3b. --- src/msha/auth/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index d313545c..27317821 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -36,7 +36,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { } else { paths.push({ method: "GET", - route: /^\/\.auth\/login\/(?github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?github|twitter|google|facebook|[a-z0-9]+)(\?.*)?$/i, function: "auth-login-provider", }); } @@ -54,7 +54,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { }, { method: "GET", - route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z0-9]+)(\?.*)?$/i, // locally, all purge requests are processed as logout requests function: "auth-logout", },