adding signing and encryption
This commit is contained in:
Родитель
75211b2ed4
Коммит
23140c4c45
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<string, string>) {
|
||||
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<cookie.CookieSerializeOptions, "expires"> {
|
||||
name: string;
|
||||
value: string;
|
||||
expires?: string;
|
||||
}
|
||||
|
||||
export class CookiesManager {
|
||||
private readonly _chunkSize = 2000;
|
||||
private readonly _existingCookies: Record<string, string>;
|
||||
private _cookiesToSet: Record<string, CookieOptions> = {};
|
||||
private _cookiesToDelete: Record<string, string> = {};
|
||||
|
||||
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<string, CookieOptions> = {};
|
||||
|
||||
// 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<string, CookieOptions> = 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -14,7 +14,7 @@ describe("auth-logout-https", () => {
|
|||
name: "StaticWebAppsAuthCookie",
|
||||
value: "deleted",
|
||||
path: "/",
|
||||
HttpOnly: false,
|
||||
httpOnly: false,
|
||||
expires: new Date(1).toUTCString(),
|
||||
};
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@ describe("auth_logout", () => {
|
|||
name: "StaticWebAppsAuthCookie",
|
||||
value: "deleted",
|
||||
path: "/",
|
||||
HttpOnly: false,
|
||||
httpOnly: false,
|
||||
expires: new Date(1).toUTCString(),
|
||||
};
|
||||
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
Загрузка…
Ссылка в новой задаче