diff --git a/src/core/utils/auth.ts b/src/core/utils/auth.ts index 1a4c9332..8bbc9378 100644 --- a/src/core/utils/auth.ts +++ b/src/core/utils/auth.ts @@ -49,3 +49,70 @@ export function extractPostLoginRedirectUri(protocol?: string, host?: string, pa 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 900eae0d..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_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,21 +16,23 @@ 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 StaticWebAppsAuthCookie 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); - } - logger.silly(` - no cookie 'StaticWebAppsAuthCookie' found`); - return null; + const stringValue = decodeCookieByName(SWA_AUTH_COOKIE, cookieValue); + return stringValue ? JSON.parse(stringValue) : null; } /** @@ -52,12 +41,7 @@ export function decodeCookie(cookieValue: string): ClientPrincipal | null { * @returns True if StaticWebAppsAuthContextCookie is found. False otherwise. */ export function validateAuthContextCookie(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_CONTEXT_COOKIE]; + return validateCookieByName(SWA_AUTH_CONTEXT_COOKIE, cookieValue); } /** @@ -66,13 +50,176 @@ export function validateAuthContextCookie(cookieValue: string | number | string[ * @returns StaticWebAppsAuthContextCookie string. */ export function decodeAuthContextCookie(cookieValue: string): AuthContext | null { - logger.silly(`decoding StaticWebAppsAuthContextCookie cookie`); - const cookies = cookie.parse(cookieValue); - if (cookies[SWA_AUTH_CONTEXT_COOKIE]) { - const decodedValue = Buffer.from(cookies[SWA_AUTH_CONTEXT_COOKIE], "base64").toString(); - logger.silly(` - StaticWebAppsAuthContextCookie: ${chalk.yellow(decodedValue)}`); - return JSON.parse(decodedValue); + 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; } - logger.silly(` - no cookie 'StaticWebAppsAuthContextCookie' found`); + + 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); + + 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 '${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/routes/auth-login-provider-callback.ts b/src/msha/auth/routes/auth-login-provider-callback.ts index c011ebd2..ffbb681b 100644 --- a/src/msha/auth/routes/auth-login-provider-callback.ts +++ b/src/msha/auth/routes/auth-login-provider-callback.ts @@ -1,10 +1,10 @@ -import { decodeAuthContextCookie, parseUrl, response, validateAuthContextCookie } from "../../../core"; +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 { hashStateGuid, isNonceExpired } from "../../../core/utils/auth"; +import { encryptAndSign, hashStateGuid, isNonceExpired } from "../../../core/utils/auth"; const getGithubAuthToken = function (codeValue: string, clientId: string, clientSecret: string) { const data = querystring.stringify({ @@ -469,27 +469,27 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess } 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: [ - { - name: "StaticWebAppsAuthContextCookie", - value: "deleted", - path: "/", - secure: true, - httpOnly: true, - expires: new Date(1).toUTCString(), - }, - { - name: "StaticWebAppsAuthCookie", - value: clientPrincipal === null ? "deleted" : btoa(JSON.stringify(clientPrincipal)), - domain: DEFAULT_CONFIG.host, - path: "/", - secure: true, - httpOnly: true, - expires: clientPrincipal === null ? new Date(1).toUTCString() : new Date(Date.now() + 1000 * 60 * 60 * 8).toUTCString(), - }, - ], + cookies: cookiesManager.getCookies(), status: 302, headers: { status: 302, diff --git a/src/msha/auth/routes/auth-login-provider-custom.ts b/src/msha/auth/routes/auth-login-provider-custom.ts index 45e233d9..fb7636f2 100644 --- a/src/msha/auth/routes/auth-login-provider-custom.ts +++ b/src/msha/auth/routes/auth-login-provider-custom.ts @@ -1,8 +1,8 @@ -import { response } from "../../../core"; +import { CookiesManager, response } from "../../../core"; import * as http from "http"; import { SWA_CLI_APP_PROTOCOL } from "../../../core/constants"; import { DEFAULT_CONFIG } from "../../../config"; -import { extractPostLoginRedirectUri, hashStateGuid, newNonceWithExpiration } from "../../../core/utils/auth"; +import { encryptAndSign, extractPostLoginRedirectUri, hashStateGuid, newNonceWithExpiration } from "../../../core/utils/auth"; const httpTrigger = async function (context: Context, request: http.IncomingMessage, customAuth?: SWAConfigFileAuth) { await Promise.resolve(); @@ -50,6 +50,10 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess 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}`; @@ -58,18 +62,23 @@ const httpTrigger = async function (context: Context, request: http.IncomingMess ? `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: [ - { - name: "StaticWebAppsAuthContextCookie", - value: btoa(JSON.stringify(authContext)), - domain: DEFAULT_CONFIG.host, - path: "/", - secure: true, - httpOnly: true, - }, - ], + cookies: cookiesManager.getCookies(), status: 302, headers: { status: 302, 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, },