diff --git a/src/core/constants.ts b/src/core/constants.ts index 82b782f6..01869a35 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -41,6 +41,7 @@ export type SWACommand = typeof SWA_COMMANDS[number]; export const SWA_RUNTIME_CONFIG_MAX_SIZE_IN_KB = 20; // 20kb +export const SWA_AUTH_CONTEXT_COOKIE = `StaticWebAppsAuthContextCookie`; export const SWA_AUTH_COOKIE = `StaticWebAppsAuthCookie`; export const ALLOWED_HTTP_METHODS_FOR_STATIC_CONTENT = ["GET", "HEAD", "OPTIONS"]; diff --git a/src/core/utils/auth.ts b/src/core/utils/auth.ts new file mode 100644 index 00000000..8bbc9378 --- /dev/null +++ b/src/core/utils/auth.ts @@ -0,0 +1,118 @@ +import crypto from "crypto"; + +export function newGuid() { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +export function hashStateGuid(guid: string) { + const hash = crypto.createHmac("sha256", process.env.SALT || ""); + hash.update(guid); + return hash.digest("hex"); +} + +export function newNonceWithExpiration() { + const nonceExpiration = Date.now() + 1000 * 60; + return `${newGuid()}_${nonceExpiration}}`; +} + +export function isNonceExpired(nonce: string) { + if (!nonce) { + return true; + } + + const expirationString = nonce.split("_")[1]; + + if (!expirationString) { + return true; + } + + const expirationParsed = parseInt(expirationString, 10); + + if (isNaN(expirationParsed) || expirationParsed < Date.now()) { + return true; + } + + return false; +} + +export function extractPostLoginRedirectUri(protocol?: string, host?: string, path?: string) { + if (!!protocol && !!host && !!path) { + try { + const url = new URL(`${protocol}://${host}${path}`); + return url.searchParams.get("post_login_redirect_uri") ?? undefined; + } catch {} + } + + return undefined; +} + +const IV_LENGTH = 16; // For AES, this is always 16 +const CIPHER_ALGORITHM = "aes-256-cbc"; + +const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY || crypto.randomBytes(16).toString("hex"); + +const SIGNING_KEY = process.env.SIGNING_KEY || crypto.randomBytes(16).toString("hex"); +const bitLength = SIGNING_KEY.length * 8; +const HMAC_ALGORITHM = bitLength <= 256 ? "sha256" : bitLength <= 384 ? "sha384" : "sha512"; + +export function encryptAndSign(value: string): string | undefined { + try { + // encrypt + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(CIPHER_ALGORITHM, Buffer.from(ENCRYPTION_KEY), iv); + let encrypted = cipher.update(value); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const encryptedValue = iv.toString("hex") + ":" + encrypted.toString("hex"); + + // sign + const hash = crypto.createHmac(HMAC_ALGORITHM, process.env.SALT || ""); + hash.update(encryptedValue); + const signature = hash.digest("hex"); + + const signedEncryptedValue = signature + ":" + encryptedValue; + return signedEncryptedValue; + } catch { + return undefined; + } +} + +export function validateSignatureAndDecrypt(data: string): string | undefined { + try { + const dataSegments: string[] = data.includes(":") ? data.split(":") : []; + + if (dataSegments.length < 3) { + return undefined; + } + + // validate signature + const signature = dataSegments.shift() || ""; + const signedData = dataSegments.join(":"); + + const hash = crypto.createHmac(HMAC_ALGORITHM, process.env.SALT || ""); + hash.update(signedData); + const testSignature = hash.digest("hex"); + + if (signature !== testSignature) { + return undefined; + } + + // decrypt + const iv = Buffer.from(dataSegments.shift() || "", "hex"); + const encryptedText = Buffer.from(dataSegments.join(":"), "hex"); + const decipher = crypto.createDecipheriv(CIPHER_ALGORITHM, Buffer.from(ENCRYPTION_KEY), iv); + let decrypted = decipher.update(encryptedText); + decrypted = Buffer.concat([decrypted, decipher.final()]); + return decrypted.toString(); + } catch { + return undefined; + } +} + +export function isValueEncryptedAndSigned(value: string) { + const segments = value.split(":"); + return segments.length === 3 && segments[0].length === 64 && segments[1].length >= 32; +} diff --git a/src/core/utils/cookie.ts b/src/core/utils/cookie.ts index cc8d717c..6fd05321 100644 --- a/src/core/utils/cookie.ts +++ b/src/core/utils/cookie.ts @@ -1,22 +1,9 @@ import chalk from "chalk"; import cookie from "cookie"; -import { SWA_AUTH_COOKIE } from "../constants"; +import { SWA_AUTH_CONTEXT_COOKIE, SWA_AUTH_COOKIE } from "../constants"; +import { isValueEncryptedAndSigned, validateSignatureAndDecrypt } from "./auth"; import { logger } from "./logger"; -/** - * Check if the StaticWebAppsAuthCookie is available. - * @param cookieValue The cookie value. - * @returns True if StaticWebAppsAuthCookie is found. False otherwise. - */ -export function validateCookie(cookieValue: string | number | string[]) { - if (typeof cookieValue !== "string") { - throw Error(`TypeError: cookie value must be a string`); - } - - const cookies = cookie.parse(cookieValue); - return !!cookies[SWA_AUTH_COOKIE]; -} - /** * Serialize a cookie name-value pair into a string that can be used in Set-Cookie header. * @param cookieName The name for the cookie. @@ -29,19 +16,210 @@ export function serializeCookie(cookieName: string, cookieValue: string, options return cookie.serialize(cookieName, cookieValue, options); } +/** + * Check if the StaticWebAppsAuthCookie is available. + * @param cookieValue The cookie value. + * @returns True if StaticWebAppsAuthCookie is found. False otherwise. + */ +export function validateCookie(cookieValue: string | number | string[]) { + return validateCookieByName(SWA_AUTH_COOKIE, cookieValue); +} + /** * * @param cookieValue * @returns A ClientPrincipal object. */ export function decodeCookie(cookieValue: string): ClientPrincipal | null { - logger.silly(`decoding cookie`); + const stringValue = decodeCookieByName(SWA_AUTH_COOKIE, cookieValue); + return stringValue ? JSON.parse(stringValue) : null; +} + +/** + * Check if the StaticWebAppsAuthContextCookie is available. + * @param cookieValue The cookie value. + * @returns True if StaticWebAppsAuthContextCookie is found. False otherwise. + */ +export function validateAuthContextCookie(cookieValue: string | number | string[]) { + return validateCookieByName(SWA_AUTH_CONTEXT_COOKIE, cookieValue); +} + +/** + * + * @param cookieValue + * @returns StaticWebAppsAuthContextCookie string. + */ +export function decodeAuthContextCookie(cookieValue: string): AuthContext | null { + const stringValue = decodeCookieByName(SWA_AUTH_CONTEXT_COOKIE, cookieValue); + return stringValue ? JSON.parse(stringValue) : null; +} + +// local functions +function getCookie(cookieName: string, cookies: Record) { + const nonChunkedCookie = cookies[cookieName]; + + if (nonChunkedCookie) { + // prefer the non-chunked cookie if it exists + return nonChunkedCookie; + } + + let chunkedCookie = ""; + let chunk = ""; + let index = 0; + + do { + chunkedCookie = `${chunkedCookie}${chunk}`; + chunk = cookies[`${cookieName}_${index}`]; + index += 1; + } while (chunk); + + return chunkedCookie; +} + +function validateCookieByName(cookieName: string, cookieValue: string | number | string[]) { + if (typeof cookieValue !== "string") { + throw Error(`TypeError: cookie value must be a string`); + } + + const cookies = cookie.parse(cookieValue); + return !!getCookie(cookieName, cookies); +} + +function decodeCookieByName(cookieName: string, cookieValue: string) { + logger.silly(`decoding ${cookieName} cookie`); const cookies = cookie.parse(cookieValue); - if (cookies[SWA_AUTH_COOKIE]) { - const decodedValue = Buffer.from(cookies[SWA_AUTH_COOKIE], "base64").toString(); - logger.silly(` - StaticWebAppsAuthCookie: ${chalk.yellow(decodedValue)}`); - return JSON.parse(decodedValue); + + const value = getCookie(cookieName, cookies); + + if (value) { + const decodedValue = Buffer.from(value, "base64").toString(); + logger.silly(` - ${cookieName} decoded: ${chalk.yellow(decodedValue)}`); + + if (!decodedValue) { + logger.silly(` - failed to decode '${cookieName}'`); + return null; + } + + if (isValueEncryptedAndSigned(decodedValue)) { + const decryptedValue = validateSignatureAndDecrypt(decodedValue); + logger.silly(` - ${cookieName} decrypted: ${chalk.yellow(decryptedValue)}`); + + if (!decryptedValue) { + logger.silly(` - failed to validate and decrypt '${cookieName}'`); + return null; + } + + return decryptedValue; + } + + return decodedValue; } - logger.silly(` - no cookie 'StaticWebAppsAuthCookie' found`); + logger.silly(` - no cookie '${cookieName}' found`); return null; } + +export interface CookieOptions extends Omit { + name: string; + value: string; + expires?: string; +} + +export class CookiesManager { + private readonly _chunkSize = 2000; + private readonly _existingCookies: Record; + private _cookiesToSet: Record = {}; + private _cookiesToDelete: Record = {}; + + constructor(requestCookie?: string) { + this._existingCookies = requestCookie ? cookie.parse(requestCookie) : {}; + } + + private _generateDeleteChunks(name: string, force: boolean /* add the delete cookie even if the corresponding cookie doesn't exist */) { + const cookies: Record = {}; + + // check for unchunked cookie + if (force || this._existingCookies[name]) { + cookies[name] = { + name: name, + value: "deleted", + path: "/", + httpOnly: false, + expires: new Date(1).toUTCString(), + }; + } + + // check for chunked cookie + let found = true; + let index = 0; + + while (found) { + const chunkName = `${name}_${index}`; + found = !!this._existingCookies[chunkName]; + if (found) { + cookies[chunkName] = { + name: chunkName, + value: "deleted", + path: "/", + httpOnly: false, + expires: new Date(1).toUTCString(), + }; + } + index += 1; + } + + return cookies; + } + + private _generateChunks(options: CookieOptions): CookieOptions[] { + const { name, value } = options; + + // pre-populate with cookies for deleting existing chunks + const cookies: Record = this._generateDeleteChunks(options.name, false); + + // generate chunks + if (value !== "deleted") { + const chunkCount = Math.ceil(value.length / this._chunkSize); + + let index = 0; + let chunkName = ""; + + while (index < chunkCount) { + const position = index * this._chunkSize; + const chunk = value.substring(position, position + this._chunkSize); + + chunkName = `${name}_${index}`; + + cookies[chunkName] = { + ...options, + name: chunkName, + value: chunk, + }; + + index += 1; + } + } + + return Object.values(cookies); + } + + public addCookieToSet(options: CookieOptions): void { + this._cookiesToSet[options.name.toLowerCase()] = options; + } + + public addCookieToDelete(name: string): void { + this._cookiesToDelete[name.toLowerCase()] = name; + } + + public getCookies(): CookieOptions[] { + const allCookies: CookieOptions[] = []; + Object.values(this._cookiesToDelete).forEach((cookieName) => { + const chunks = this._generateDeleteChunks(cookieName, true); + allCookies.push(...Object.values(chunks)); + }); + Object.values(this._cookiesToSet).forEach((cookie) => { + const chunks = this._generateChunks(cookie); + allCookies.push(...chunks); + }); + return allCookies; + } +} diff --git a/src/msha/auth/index.ts b/src/msha/auth/index.ts index 62b82ada..1417e640 100644 --- a/src/msha/auth/index.ts +++ b/src/msha/auth/index.ts @@ -1,31 +1,68 @@ import type http from "http"; -import { logger, serializeCookie } from "../../core"; - -const authPaths: Path[] = [ - { - method: "GET", - route: /^\/\.auth\/login\/(?aad|github|twitter|google|facebook|[a-z]+)/, - function: "auth-login-provider", - }, - { - method: "GET", - route: /^\/\.auth\/me/, - function: "auth-me", - }, - { - method: "GET", - route: /^\/\.auth\/logout/, - function: "auth-logout", - }, - { - method: "GET", - route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z]+)/, - // locally, all purge requests are processed as logout requests - function: "auth-logout", - }, -]; - -async function routeMatcher(url = "/"): Promise<{ func: Function | undefined; bindingData: undefined | { provider: string } }> { +import { logger, response as newResponse, serializeCookie } from "../../core"; + +function getAuthPaths(isCustomAuth: boolean): Path[] { + const paths: Path[] = []; + + if (isCustomAuth) { + paths.push({ + method: "GET", + // 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) + 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\/(?aad|twitter|facebook|[a-z]+)(\?.*)?$/i, + function: "auth-login-provider", + }); + paths.push({ + method: "POST", + route: /^\/\.auth\/complete(\?.*)?$/i, + function: "auth-complete", + }); + } else { + paths.push({ + method: "GET", + route: /^\/\.auth\/login\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, + function: "auth-login-provider", + }); + } + + paths.push( + { + method: "GET", + route: /^\/\.auth\/me(\?.*)?$/i, + function: "auth-me", + }, + { + method: "GET", + route: /^\/\.auth\/logout(\?.*)?$/i, + function: "auth-logout", + }, + { + method: "GET", + route: /^\/\.auth\/purge\/(?aad|github|twitter|google|facebook|[a-z]+)(\?.*)?$/i, + // locally, all purge requests are processed as logout requests + function: "auth-logout", + } + ); + + return paths; +} + +async function routeMatcher( + url = "/", + customAuth: SWAConfigFileAuth | undefined +): Promise<{ func: Function | undefined; bindingData: undefined | { provider: string } }> { + const authPaths = getAuthPaths(!!customAuth); for (let index = 0; index < authPaths.length; index++) { const path = authPaths[index]; const match = url.match(new RegExp(path.route)); @@ -45,7 +82,7 @@ async function routeMatcher(url = "/"): Promise<{ func: Function | undefined; bi return { func: undefined, bindingData: undefined }; } -export async function processAuth(request: http.IncomingMessage, response: http.ServerResponse, rewriteUrl?: string) { +export async function processAuth(request: http.IncomingMessage, response: http.ServerResponse, rewriteUrl?: string, customAuth?: SWAConfigFileAuth) { let defaultStatus = 200; const context: Context = { invocationId: new Date().getTime().toString(36) + Math.random().toString(36).slice(2), @@ -53,11 +90,11 @@ export async function processAuth(request: http.IncomingMessage, response: http. res: {}, }; - const { func, bindingData } = await routeMatcher(rewriteUrl || request.url); + const { func, bindingData } = await routeMatcher(rewriteUrl || request.url, customAuth); if (func) { context.bindingData = bindingData; try { - await func(context, request); + await func(context, request, customAuth); for (const key in context.res.headers) { const element = context.res.headers[key]; @@ -100,19 +137,28 @@ export async function processAuth(request: http.IncomingMessage, response: http. logger.error(errorMessage); defaultStatus = 500; - context.res.body = { - error: errorMessage, - }; + context.res = newResponse({ + context, + status: 500, + body: { + error: errorMessage, + }, + }); } } else { defaultStatus = 404; + context.res = newResponse({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: "We couldn't find that page, please check the URL and try again.", + }); } const statusCode = context.res.status || defaultStatus; - if (statusCode === 200 || statusCode === 302) { - response.writeHead(statusCode); - response.end(context.res.body); - } + + response.writeHead(statusCode); + response.end(context.res.body); return statusCode; } diff --git a/src/msha/auth/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts new file mode 100644 index 00000000..ffbb681b --- /dev/null +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -0,0 +1,502 @@ +import { CookiesManager, decodeAuthContextCookie, parseUrl, response, validateAuthContextCookie } from "../../../core"; +import * as http from "http"; +import * as https from "https"; +import * as querystring from "querystring"; +import { SWA_CLI_API_URI, SWA_CLI_APP_PROTOCOL } from "../../../core/constants"; +import { DEFAULT_CONFIG } from "../../../config"; +import { encryptAndSign, hashStateGuid, isNonceExpired } from "../../../core/utils/auth"; + +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) => { + 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 getGithubAuthToken(codeValue, clientId, clientSecret)) as string; + const authTokenParsed = querystring.parse(authTokenResponse); + authToken = authTokenParsed["access_token"] as string; + } catch { + return null; + } + + 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; + } + + 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); + const target = hostname === "localhost" ? `${protocol}//127.0.0.1:${port}` : cliApiUri; + const targetUrl = new URL(target!); + + const data = JSON.stringify(clientPrincipal); + + const options = { + host: targetUrl.hostname, + port: targetUrl.port, + path: rolesSource, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(data), + }, + }; + + return new Promise((resolve, reject) => { + const protocolModule = targetUrl.protocol === "https:" ? https : http; + + const req = protocolModule.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.write(data); + req.end(); + }); +}; + +const httpTrigger = async function (context: Context, request: http.IncomingMessage, customAuth?: SWAConfigFileAuth) { + const providerName = context.bindingData?.provider?.toLowerCase() || ""; + + if (providerName != "github" && providerName != "google") { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `Provider '${providerName}' not found`, + }); + return; + } + + const { cookie } = request.headers; + + if (!cookie || !validateAuthContextCookie(cookie)) { + context.res = response({ + context, + status: 401, + headers: { ["Content-Type"]: "text/plain" }, + body: "Invalid login request", + }); + return; + } + + const url = new URL(request.url!, `${SWA_CLI_APP_PROTOCOL}://${request?.headers?.host}`); + + const codeValue = url.searchParams.get("code"); + const stateValue = url.searchParams.get("state"); + + const authContext = decodeAuthContextCookie(cookie); + + if (!authContext?.authNonce || hashStateGuid(authContext.authNonce) !== stateValue) { + context.res = response({ + context, + status: 401, + headers: { ["Content-Type"]: "text/plain" }, + body: "Invalid login request", + }); + return; + } + + if (isNonceExpired(authContext.authNonce)) { + context.res = response({ + context, + status: 401, + headers: { ["Content-Type"]: "text/plain" }, + body: "Login timed out. Please try again.", + }); + return; + } + + 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 = + providerName === "github" + ? await getGitHubClientPrincipal(codeValue!, clientId, clientSecret) + : await getGoogleClientPrincipal(codeValue!, clientId, clientSecret); + + if (clientPrincipal !== null && customAuth?.rolesSource) { + try { + const rolesResult = (await getRoles(clientPrincipal, customAuth.rolesSource)) as { roles: string[] }; + clientPrincipal.userRoles.push(...rolesResult.roles); + } catch {} + } + + const authCookieString = clientPrincipal && JSON.stringify(clientPrincipal); + const authCookieEncrypted = authCookieString && encryptAndSign(authCookieString); + const authCookie = authCookieEncrypted ? btoa(authCookieEncrypted) : undefined; + + const cookiesManager = new CookiesManager(request.headers.cookie); + cookiesManager.addCookieToDelete("StaticWebAppsAuthContextCookie"); + if (authCookie) { + cookiesManager.addCookieToSet({ + name: "StaticWebAppsAuthCookie", + value: authCookie, + domain: DEFAULT_CONFIG.host, + path: "/", + secure: true, + httpOnly: true, + expires: new Date(Date.now() + 1000 * 60 * 60 * 8).toUTCString(), + }); + } + + context.res = response({ + context, + cookies: cookiesManager.getCookies(), + status: 302, + headers: { + status: 302, + Location: authContext.postLoginRedirectUri ?? "/", + }, + body: "", + }); +}; + +export default httpTrigger; diff --git a/src/msha/auth/routes/auth-login-provider-custom.ts b/src/msha/auth/routes/auth-login-provider-custom.ts new file mode 100644 index 00000000..fb7636f2 --- /dev/null +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -0,0 +1,92 @@ +import { CookiesManager, response } from "../../../core"; +import * as http from "http"; +import { SWA_CLI_APP_PROTOCOL } from "../../../core/constants"; +import { DEFAULT_CONFIG } from "../../../config"; +import { encryptAndSign, extractPostLoginRedirectUri, hashStateGuid, newNonceWithExpiration } from "../../../core/utils/auth"; + +const httpTrigger = async function (context: Context, request: http.IncomingMessage, customAuth?: SWAConfigFileAuth) { + await Promise.resolve(); + + const providerName = context.bindingData?.provider?.toLowerCase() || ""; + + if (providerName != "github" && providerName != "google") { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `Provider '${providerName}' not found`, + }); + return; + } + + const clientIdSettingName = customAuth?.identityProviders?.[providerName]?.registration?.clientIdSettingName; + + if (!clientIdSettingName) { + context.res = response({ + context, + status: 404, + headers: { ["Content-Type"]: "text/plain" }, + body: `ClientIdSettingName 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 state = newNonceWithExpiration(); + + const authContext: AuthContext = { + authNonce: state, + postLoginRedirectUri: extractPostLoginRedirectUri(SWA_CLI_APP_PROTOCOL, request.headers.host, request.url), + }; + + const authContextCookieString = JSON.stringify(authContext); + const authContextCookieEncrypted = encryptAndSign(authContextCookieString); + const authContextCookie = authContextCookieEncrypted ? btoa(authContextCookieEncrypted) : undefined; + + 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}`; + + const cookiesManager = new CookiesManager(request.headers.cookie); + if (!authContextCookie) { + cookiesManager.addCookieToDelete("StaticWebAppsAuthContextCookie"); + } else { + cookiesManager.addCookieToSet({ + name: "StaticWebAppsAuthContextCookie", + value: authContextCookie, + domain: DEFAULT_CONFIG.host, + path: "/", + secure: true, + httpOnly: true, + }); + } + + context.res = response({ + context, + cookies: cookiesManager.getCookies(), + status: 302, + headers: { + status: 302, + Location: location, + }, + body: "", + }); + return; +}; + +export default httpTrigger; diff --git a/src/msha/auth/routes/auth-login-provider.ts b/src/msha/auth/routes/auth-login-provider.ts index 4e0d973c..36f7c973 100644 --- a/src/msha/auth/routes/auth-login-provider.ts +++ b/src/msha/auth/routes/auth-login-provider.ts @@ -1,11 +1,15 @@ import { IncomingMessage } from "http"; -import { response } from "../../../core"; +import { CookiesManager, response } from "../../../core"; const fs = require("fs").promises; const path = require("path"); const httpTrigger = async function (context: Context, request: IncomingMessage) { const body = await fs.readFile(path.join(__dirname, "..", "..", "..", "public", "auth.html"), "utf-8"); + + const cookiesManager = new CookiesManager(request.headers.cookie); + cookiesManager.addCookieToDelete("StaticWebAppsAuthContextCookie"); + context.res = response({ context, status: 200, diff --git a/src/msha/auth/routes/auth-logout-https.spec.ts b/src/msha/auth/routes/auth-logout-https.spec.ts index 60055f71..0159bbbc 100644 --- a/src/msha/auth/routes/auth-logout-https.spec.ts +++ b/src/msha/auth/routes/auth-logout-https.spec.ts @@ -14,7 +14,7 @@ describe("auth-logout-https", () => { name: "StaticWebAppsAuthCookie", value: "deleted", path: "/", - HttpOnly: false, + httpOnly: false, expires: new Date(1).toUTCString(), }; diff --git a/src/msha/auth/routes/auth-logout.spec.ts b/src/msha/auth/routes/auth-logout.spec.ts index 602325d8..4b0cf8a4 100644 --- a/src/msha/auth/routes/auth-logout.spec.ts +++ b/src/msha/auth/routes/auth-logout.spec.ts @@ -14,7 +14,7 @@ describe("auth_logout", () => { name: "StaticWebAppsAuthCookie", value: "deleted", path: "/", - HttpOnly: false, + httpOnly: false, expires: new Date(1).toUTCString(), }; diff --git a/src/msha/auth/routes/auth-logout.ts b/src/msha/auth/routes/auth-logout.ts index 27d36b01..bf67d9de 100644 --- a/src/msha/auth/routes/auth-logout.ts +++ b/src/msha/auth/routes/auth-logout.ts @@ -1,5 +1,6 @@ import type http from "http"; -import { response } from "../../../core"; +import { CookiesManager, response } from "../../../core"; +// import { response } from "../../../core"; import { SWA_CLI_APP_PROTOCOL } from "../../../core/constants"; export default async function (context: Context, req: http.IncomingMessage) { @@ -17,18 +18,13 @@ export default async function (context: Context, req: http.IncomingMessage) { const query = new URL(req?.url || "", uri).searchParams; const location = `${uri}${query.get("post_logout_redirect_uri") || "/"}`; + const cookiesManager = new CookiesManager(req.headers.cookie); + cookiesManager.addCookieToDelete("StaticWebAppsAuthCookie"); + context.res = response({ context, status: 302, - cookies: [ - { - name: "StaticWebAppsAuthCookie", - value: "deleted", - path: "/", - HttpOnly: false, - expires: new Date(1).toUTCString(), - }, - ], + cookies: cookiesManager.getCookies(), headers: { Location: location, }, diff --git a/src/msha/handlers/auth.handler.ts b/src/msha/handlers/auth.handler.ts index b2b21394..6df81735 100644 --- a/src/msha/handlers/auth.handler.ts +++ b/src/msha/handlers/auth.handler.ts @@ -10,11 +10,9 @@ export async function handleAuthRequest( userConfig: SWAConfigFile | undefined ) { logger.silly(`processing auth request`); - const statusCode = await processAuth(req, res, matchedRoute?.rewrite); - if (statusCode === 404) { - logger.silly(` - auth returned 404`); - - handleErrorPage(req, res, 404, userConfig?.responseOverrides); + const statusCode = await processAuth(req, res, matchedRoute?.rewrite, userConfig?.auth); + if (statusCode >= 400) { + logger.silly(` - auth returned ${statusCode}`); } logRequest(req, "", statusCode); diff --git a/src/swa.d.ts b/src/swa.d.ts index 332dd691..9d7167f5 100644 --- a/src/swa.d.ts +++ b/src/swa.d.ts @@ -240,6 +240,12 @@ declare type SWACLIConfigInfo = { declare type ResponseOptions = { [key: string]: any; }; + +declare type AuthContext = { + authNonce: string; + postLoginRedirectUri?: string; +}; + declare type ClientPrincipal = { identityProvider: string; userId: string; @@ -284,6 +290,23 @@ declare type SWAConfigFileMimeTypes = { [key: string]: string; }; +declare type AuthIdentityProvider = { + registration: { + clientIdSettingName: string; + clientSecretSettingName: string; + }; +}; + +declare type SWAConfigFileAuthIdenityProviders = { + github?: AuthIdentityProvider; + google?: AuthIdentityProvider; +}; + +declare type SWAConfigFileAuth = { + rolesSource?: string; + identityProviders: SWAConfigFileAuthIdenityProviders; +}; + declare type SWAConfigFile = { routes: SWAConfigFileRoute[]; navigationFallback: SWAConfigFileNavigationFallback; @@ -291,6 +314,7 @@ declare type SWAConfigFile = { globalHeaders: SWAConfigFileGlobalHeaders; mimeTypes: SWAConfigFileMimeTypes; isLegacyConfigFile: boolean; + auth?: SWAConfigFileAuth; }; declare type DebugFilterLevel = "silly" | "silent" | "log" | "info" | "error"; @@ -370,3 +394,16 @@ const binaryType = { StaticSiteClient: 1, DataApiBuilder: 2, }; + +declare type RolesSourceFunctionRequestBody = { + identityProvider: string; + userId?: string; + userDetails?: string; + claims?: RolesSourceClaim[]; + accessToken?: string; +}; + +declare type RolesSourceClaim = { + typ: string; + val: string; +};