diff --git a/.github/workflows/azuresdkdrop.yml b/.github/workflows/azuresdkdrop.yml index 42e0cb5e..1e49f8cd 100644 --- a/.github/workflows/azuresdkdrop.yml +++ b/.github/workflows/azuresdkdrop.yml @@ -45,7 +45,7 @@ jobs: - run: npm pack - name: Upload - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v2 with: name: package path: "*.tgz" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3850931c..1bdf51f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -228,7 +228,7 @@ jobs: - run: npm version prerelease --preid=ci-$GITHUB_RUN_ID --no-git-tag-version - run: npm pack - name: Upload - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v2 with: name: static-web-apps-cli path: "*.tgz" diff --git a/docs/www/docs/cli/swa.md b/docs/www/docs/cli/swa.md index 707817e3..a8b59e65 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 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. +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. The best way to get started is to use the `swa` command and follow the interactive prompts. diff --git a/package-lock.json b/package-lock.json index ee0fb3c3..61433741 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@azure/static-web-apps-cli", - "version": "2.0.1", + "version": "2.0.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@azure/static-web-apps-cli", - "version": "2.0.1", + "version": "2.0.2", "license": "MIT", "dependencies": { "@azure/arm-appservice": "^15.0.0", @@ -29,7 +29,6 @@ "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", @@ -80,8 +79,8 @@ "vitest": "^2.0.2" }, "engines": { - "node": ">=18.0.0", - "npm": ">=9.0.0" + "node": ">=14.0.0", + "npm": ">=6.0.0" } }, "node_modules/@ampproject/remapping": { @@ -3799,9 +3798,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -7531,14 +7530,6 @@ "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", @@ -17475,9 +17466,9 @@ "dev": true }, "axios": { - "version": "1.7.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz", - "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", + "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", "requires": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -20254,11 +20245,6 @@ "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 a60d3160..ccc1fe80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@azure/static-web-apps-cli", - "version": "2.0.1", + "version": "2.0.2", "description": "Azure Static Web Apps CLI", "type": "module", "scripts": { @@ -49,7 +49,6 @@ "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 86e71a46..eb1c5a4e 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -11,7 +11,7 @@ const __dirname = path.dirname(__filename); export const DEPLOY_BINARY_NAME = "StaticSitesClient"; export const DEPLOY_BINARY_STABLE_TAG = "stable"; export const DEPLOY_FOLDER = path.join(os.homedir(), ".swa", "deploy"); -export const STATIC_SITE_CLIENT_RELEASE_METADATA_URL = "https://swalocaldeploy.azureedge.net/downloads/versions.json"; +export const STATIC_SITE_CLIENT_RELEASE_METADATA_URL = "https://aka.ms/swalocaldeploy"; // Data-api-builder related constants export const DATA_API_BUILDER_BINARY_NAME = "DataApiBuilder"; @@ -49,68 +49,6 @@ 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", "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 -*/ -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", - }, - facebook: { - 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: { - host: "www.googleapis.com", - path: "/oauth2/v2/userinfo", - }, - github: { - host: "api.github.com", - path: "/user", - }, - aad: { - 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 = { NoAuth: 0, HostNameAuthLogin: 1, diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index 27317821..ddc0c812 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -2,30 +2,27 @@ 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, aad, facebook, twitter) - route: new RegExp(`^/\\.auth/login/(?${supportedAuthsRegex})/callback(\\?.*)?$`, "i"), + // only match for providers with custom auth support implemented (github, google) + route: /^\/\.auth\/login\/(?github|google|dummy)\/callback(\?.*)?$/i, function: "auth-login-provider-callback", }); paths.push({ method: "GET", - // only match for providers with custom auth support implemented (github, google, aad, facebook, twitter) - route: new RegExp(`^/\\.auth/login/(?${supportedAuthsRegex})(\\?.*)?$`, "i"), + // only match for providers with custom auth support implemented (github, google) + route: /^\/\.auth\/login\/(?github|google|dummy)(\?.*)?$/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\/(?twitter|[a-z]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?aad|twitter|facebook|[a-z]+)(\?.*)?$/i, function: "auth-login-provider", }); paths.push({ @@ -36,7 +33,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { } else { paths.push({ method: "GET", - route: /^\/\.auth\/login\/(?github|twitter|google|facebook|[a-z0-9]+)(\?.*)?$/i, + route: /^\/\.auth\/login\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, function: "auth-login-provider", }); } @@ -54,7 +51,7 @@ function getAuthPaths(isCustomAuth: boolean): Path[] { }, { method: "GET", - route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z0-9]+)(\?.*)?$/i, + route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, // locally, all purge requests are processed as logout requests function: "auth-logout", }, diff --git a/src/msha/auth/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts index 617141a4..41ddc4cf 100644 --- a/src/msha/auth/routes/auth-login-provider-callback.ts +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -4,39 +4,221 @@ import * as querystring from "node:querystring"; import { CookiesManager, decodeAuthContextCookie, validateAuthContextCookie } from "../../../core/utils/cookie.js"; import { parseUrl, response } from "../../../core/utils/net.js"; -import { - 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 { 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"; -import { checkCustomAuthConfigFields, normalizeAuthProvider } from "./auth-login-provider-custom.js"; -import { jwtDecode } from "jwt-decode"; -const getAuthClientPrincipal = async function (authProvider: string, codeValue: string, authConfigs: Record) { +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) { let authToken: string; try { - const authTokenResponse = (await getOAuthToken(authProvider, codeValue!, authConfigs)) as string; - let authTokenParsed; - try { - authTokenParsed = JSON.parse(authTokenResponse); - } catch (e) { - authTokenParsed = querystring.parse(authTokenResponse); - } + const authTokenResponse = (await getGithubAuthToken(codeValue, clientId, clientSecret)) as string; + const authTokenParsed = querystring.parse(authTokenResponse); + authToken = authTokenParsed["access_token"] as string; + } catch { + return null; + } - // 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}`); + if (!authToken) { + return null; + } + + try { + const user = (await getGitHubUser(authToken)) as { [key: string]: string }; + + const userId = user["id"]; + const userDetails = user["login"]; + + const claims: { typ: string; val: string }[] = [ + { + typ: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier", + val: userId, + }, + ]; + + Object.keys(user).forEach((key) => { + claims.push({ + typ: `urn:github:${key}`, + val: user[key], + }); + }); + + return { + identityProvider: "github", + userId, + userDetails, + userRoles: ["authenticated", "anonymous"], + claims, + }; + } catch { + return null; + } +}; + +const getGoogleAuthToken = function (codeValue: string, clientId: string, clientSecret: string) { + 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`, + }); + + const options = { + host: "oauth2.googleapis.com", + path: "/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) => { + reject(err); + }); + + req.write(data); + req.end(); + }); +}; + +const getGoogleUser = function (accessToken: string) { + const options = { + host: "www.googleapis.com", + path: "/oauth2/v2/userinfo", + 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 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; } @@ -45,31 +227,38 @@ const getAuthClientPrincipal = async function (authProvider: string, codeValue: } try { - const user = (await getOAuthUser(authProvider, authToken)) as Record; + const user = (await getGoogleUser(authToken)) as { [key: string]: string }; - const userDetails = user["login"] || user["email"] || user?.data?.["username"]; - const name = user["name"] || user?.data?.["name"]; + 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 userId = user["id"] || user?.data?.["id"]; - const verifiedEmail = user["verified_email"]; const claims: { typ: string; val: string }[] = [ { typ: "iss", - val: CUSTOM_AUTH_ISS_MAPPING?.[authProvider], + val: "https://accounts.google.com", }, { typ: "azp", - val: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, + val: clientId, }, { typ: "aud", - val: authConfigs?.clientIdSettingName || authConfigs?.appIdSettingName, + 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", @@ -77,6 +266,13 @@ const getAuthClientPrincipal = async function (authProvider: string, codeValue: }); } + if (verifiedEmail !== undefined) { + claims.push({ + typ: "email_verified", + val: verifiedEmail, + }); + } + if (name) { claims.push({ typ: "name", @@ -105,156 +301,18 @@ const getAuthClientPrincipal = async function (authProvider: string, codeValue: }); } - 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: authProvider, + identityProvider: "google", + userId, userDetails, claims, userRoles: ["authenticated", "anonymous"], }; - } catch (error) { - console.error(`Error while parsing user information: ${error}`); + } catch { return null; } }; -const getOAuthToken = function (authProvider: string, codeValue: string, authConfigs: Record) { - 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 = authConfigs?.openIdIssuer.split("/")[3]; - } - - const queryString: Record = { - code: codeValue, - 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: headers, - }; - - 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) => { - reject(err); - }); - - req.write(data); - req.end(); - }); -}; - -const getOAuthUser = function (authProvider: string, accessToken: string) { - // 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 = ""; - - 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 getRoles = function (clientPrincipal: RolesSourceFunctionRequestBody, rolesSource: string) { let cliApiUri = SWA_CLI_API_URI(); const { protocol, hostname, port } = parseUrl(cliApiUri); @@ -304,12 +362,12 @@ const getRoles = function (clientPrincipal: RolesSourceFunctionRequestBody, role }; const httpTrigger = async function (context: Context, request: http.IncomingMessage, customAuth?: SWAConfigFileAuth) { - const providerName = normalizeAuthProvider(context.bindingData?.provider); + const providerName = context.bindingData?.provider?.toLowerCase() || ""; - if (!SUPPORTED_CUSTOM_AUTH_PROVIDERS.includes(providerName)) { + if (providerName != "github" && providerName != "google") { context.res = response({ context, - status: 400, + status: 404, headers: { ["Content-Type"]: "text/plain" }, body: `Provider '${providerName}' not found`, }); @@ -355,17 +413,61 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess return; } - const authConfigs = checkCustomAuthConfigFields(context, providerName, customAuth); - if (!authConfigs) { + const { clientIdSettingName, clientSecretSettingName } = customAuth?.identityProviders?.[providerName]?.registration || {}; + + if (!clientIdSettingName) { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientIdSettingName not found for '${providerName}' provider`, + }); + return; + } + + if (!clientSecretSettingName) { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientSecretSettingName not found for '${providerName}' provider`, + }); + return; + } + + const clientId = process.env[clientIdSettingName]; + + if (!clientId) { + context.res = response({ + context, + status: 404, + 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: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientSecret not found for '${providerName}' provider`, + }); return; } - const clientPrincipal = await getAuthClientPrincipal(providerName, codeValue!, authConfigs); + const clientPrincipal = + providerName === "github" + ? await getGitHubClientPrincipal(codeValue!, clientId, clientSecret) + : await getGoogleClientPrincipal(codeValue!, clientId, clientSecret); if (clientPrincipal !== null && customAuth?.rolesSource) { try { - const rolesResult = (await getRoles(clientPrincipal as RolesSourceFunctionRequestBody, customAuth.rolesSource)) as { roles: string[] }; - clientPrincipal?.userRoles.push(...rolesResult.roles); + const rolesResult = (await getRoles(clientPrincipal, 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 600dcf20..e04b655a 100644 --- a/src/msha/auth/routes/auth-login-provider-custom.ts +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -1,66 +1,46 @@ import { IncomingMessage } from "node:http"; import { CookiesManager } from "../../../core/utils/cookie.js"; import { response } from "../../../core/utils/net.js"; -import { CUSTOM_AUTH_REQUIRED_FIELDS, ENTRAID_FULL_NAME, SWA_CLI_APP_PROTOCOL } from "../../../core/constants.js"; +import { 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 = 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 = context.bindingData?.provider?.toLowerCase() || ""; -export const checkCustomAuthConfigFields = function (context: Context, providerName: string, customAuth?: SWAConfigFileAuth) { - const generateResponse = function (msg: string) { - return { + if (providerName != "github" && providerName != "google") { + context.res = response({ context, - status: 400, + status: 404, headers: { ["Content-Type"]: "text/plain" }, - body: msg, - }; - }; - - if (!CUSTOM_AUTH_REQUIRED_FIELDS[providerName]) { - context.res = response(generateResponse(`Provider '${providerName}' not found`)); - return false; + body: `Provider '${providerName}' not found`, + }); + return; } - const requiredFields = CUSTOM_AUTH_REQUIRED_FIELDS[providerName]; - const configFileProviderName = providerName === "aad" ? ENTRAID_FULL_NAME : providerName; - const authConfigs: Record = {}; + const clientIdSettingName = customAuth?.identityProviders?.[providerName]?.registration?.clientIdSettingName; - 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; - } + if (!clientIdSettingName) { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientIdSettingName not found for '${providerName}' provider`, + }); + return; } - return authConfigs; -}; + const clientId = process.env[clientIdSettingName]; -const httpTrigger = async function (context: Context, request: IncomingMessage, customAuth?: SWAConfigFileAuth) { - await Promise.resolve(); - - const providerName: string = normalizeAuthProvider(context.bindingData?.provider); - const authFields = checkCustomAuthConfigFields(context, providerName, customAuth); - if (!authFields) { + if (!clientId) { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientId not found for '${providerName}' provider`, + }); return; } @@ -78,26 +58,10 @@ const httpTrigger = async function (context: Context, request: IncomingMessage, const hashedState = hashStateGuid(state); const redirectUri = `${SWA_CLI_APP_PROTOCOL}://${DEFAULT_CONFIG.host}:${DEFAULT_CONFIG.port}`; - let location; - switch (providerName) { - case "google": - 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=${authFields?.clientIdSettingName}&redirect_uri=${redirectUri}/.auth/login/github/callback&scope=read:user&state=${hashedState}`; - break; - case "aad": - 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; - 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; - } + 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}`; const cookiesManager = new CookiesManager(request.headers.cookie); if (!authContextCookie) { diff --git a/src/swa.d.ts b/src/swa.d.ts index 85023c80..052a6c86 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,34 +290,21 @@ 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: { - [key: string]: string; + clientIdSettingName: string; + clientSecretSettingName: string; }; }; -declare type AuthIdentityRequiredFields = { - [key: string]: string[]; -}; - declare type SWAConfigFileAuthIdenityProviders = { - [key: string]: AuthIdentityProvider; + github?: AuthIdentityProvider; + google?: AuthIdentityProvider; }; declare type SWAConfigFileAuth = { rolesSource?: string; - identityProviders?: SWAConfigFileAuthIdenityProviders; + identityProviders: SWAConfigFileAuthIdenityProviders; }; declare type SWAConfigFile = {