Skip to content

Commit

Permalink
Merge pull request #826 from andimarc/custom-auth-0
Browse files Browse the repository at this point in the history
feat(auth): Add custom auth support for Google and GitHub providers
  • Loading branch information
mkarmark authored May 13, 2024
2 parents dc37b72 + 23140c4 commit e8e6003
Show file tree
Hide file tree
Showing 12 changed files with 1,048 additions and 76 deletions.
1 change: 1 addition & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down
118 changes: 118 additions & 0 deletions src/core/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -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;
}
220 changes: 199 additions & 21 deletions src/core/utils/cookie.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string, string>) {
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<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;
}
}
Loading

0 comments on commit e8e6003

Please sign in to comment.