Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(auth): Add custom auth support for Google and GitHub providers #826

Merged
merged 6 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading