Skip to content

Commit

Permalink
feat: introduced enableMultipleCookies auth middleware option to in…
Browse files Browse the repository at this point in the history
…crease token capacity
  • Loading branch information
awinogrodzki committed Jul 14, 2024
1 parent 06ebded commit 23ee02f
Show file tree
Hide file tree
Showing 14 changed files with 522 additions and 88 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function POST(request: NextRequest) {

const response = new NextResponse(
JSON.stringify({
customClaims: user?.customClaims
customClaims: user?.customClaims,
}),
{
status: 200,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export async function incrementCounterUsingClientFirestore(
if (docSnap.exists()) {
const data = docSnap.data();

updateDoc(docRef, {count: data.count + 1});
await updateDoc(docRef, {count: data.count + 1});
} else {
setDoc(docRef, {count: 1});
await setDoc(docRef, {count: 1});
}
}
27 changes: 17 additions & 10 deletions examples/next-typescript-starter/config/server-config.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,33 @@
export const serverConfig = {
useSecureCookies: process.env.USE_SECURE_COOKIES === "true",
useSecureCookies: process.env.USE_SECURE_COOKIES === 'true',
firebaseApiKey: process.env.FIREBASE_API_KEY!,
serviceAccount: process.env.FIREBASE_ADMIN_PRIVATE_KEY ? {
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY.replace(/\\n/g, "\n")!,
} : undefined,
serviceAccount: process.env.FIREBASE_ADMIN_PRIVATE_KEY
? {
projectId: process.env.FIREBASE_PROJECT_ID!,
clientEmail: process.env.FIREBASE_ADMIN_CLIENT_EMAIL!,
privateKey: process.env.FIREBASE_ADMIN_PRIVATE_KEY.replace(
/\\n/g,
'\n'
)!
}
: undefined
};

export const authConfig = {
apiKey: serverConfig.firebaseApiKey,
cookieName: "AuthToken",
cookieName: 'AuthToken',
cookieSignatureKeys: [
process.env.COOKIE_SECRET_CURRENT!,
process.env.COOKIE_SECRET_PREVIOUS!
],
cookieSerializeOptions: {
path: "/",
path: '/',
httpOnly: true,
secure: serverConfig.useSecureCookies, // Set this to true on HTTPS environments
sameSite: "lax" as const,
maxAge: 12 * 60 * 60 * 24, // twelve days
sameSite: 'lax' as const,
maxAge: 12 * 60 * 60 * 24 // twelve days
},
serviceAccount: serverConfig.serviceAccount,
// Set to false in Firebase Hosting environment due to https://stackoverflow.com/questions/44929653/firebase-cloud-function-wont-store-cookie-named-other-than-session
enableMultipleCookies: true
};
1 change: 1 addition & 0 deletions examples/next-typescript-starter/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export async function middleware(request: NextRequest) {
loginPath: '/api/login',
logoutPath: '/api/logout',
refreshTokenPath: '/api/refresh-token',
enableMultipleCookies: authConfig.enableMultipleCookies,
apiKey: authConfig.apiKey,
cookieName: authConfig.cookieName,
cookieSerializeOptions: authConfig.cookieSerializeOptions,
Expand Down
61 changes: 56 additions & 5 deletions src/auth/cookies/sign.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import {CustomTokens} from '../custom-token';
import {InvalidTokenError, InvalidTokenReason} from '../error';
import {parseTokens, signTokens} from './sign';
import {parseCookies, parseTokens, signCookies, signTokens} from './sign';

const secret = 'some-secret';
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0sgSUQgVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DSyBSRUZSRVNIIFRPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DSyBDVVNUT00gVE9LRU4ifQ.kTqBXm7eX9W9kWpgIGoTDUFvA5m8X_JfBNVcOPQZZ_w';
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DS19SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DS19DVVNUT01fVE9LRU4ifQ.sJze1hnPLbZ0ZyZG7e98FzEB_vOxPMJ5dC5tObGLyqU';
const signature = '3bhtJ0IotJLntIx53HJuWf8FNUhKV7kVhuJKN_gY3J4';
const customTokens: CustomTokens = {
idToken: 'MOCK ID TOKEN',
refreshToken: 'MOCK REFRESH TOKEN',
customToken: 'MOCK CUSTOM TOKEN'
idToken: 'MOCK_ID_TOKEN',
refreshToken: 'MOCK_REFRESH_TOKEN',
customToken: 'MOCK_CUSTOM_TOKEN'
};

const cookies = {
custom: customTokens.customToken,
signature,
signed: `${customTokens.idToken}:${customTokens.refreshToken}`
};

describe('signTokens', () => {
Expand All @@ -19,6 +26,50 @@ describe('signTokens', () => {
});
});

describe('signCookies', () => {
it('should sign provided tokens into signature string', async () => {
const result = await signCookies(customTokens, [secret]);

expect(result).toEqual(cookies);
});
});

describe('parseCookies', () => {
it('should parse and verify provided cookies into tokens', async () => {
const value = await parseCookies(cookies, [secret]);

expect(value).toEqual(customTokens);
});

it('should throw invalid signature error if secret is invalid', async () => {
return expect(() => parseCookies(cookies, ['foobar'])).rejects.toEqual(
new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE)
);
});

it('should throw missing credentials error if cookies are empty', async () => {
return expect(() =>
parseCookies({custom: '', signed: '', signature: ''}, [secret])
).rejects.toEqual(
new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS)
);
});

it('should throw malformed credentials error if custom tokens are empty', async () => {
const customTokens: CustomTokens = {
idToken: '',
refreshToken: '',
customToken: 'asd'
};

const emptySignature = await signCookies(customTokens, [secret]);

return expect(() => parseCookies(emptySignature, [secret])).rejects.toEqual(
new InvalidTokenError(InvalidTokenReason.MALFORMED_CREDENTIALS)
);
});
});

describe('parseTokens', () => {
it('should parse and verify provided string into id and refresh tokens', async () => {
const value = await parseTokens(jwt, [secret]);
Expand Down
56 changes: 56 additions & 0 deletions src/auth/cookies/sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,62 @@ export async function signTokens(
});
}

export type SignedCookies = {
signed: string;
custom: string;
signature: string;
};

export async function signCookies(
tokens: CustomTokens,
keys: string[]
): Promise<SignedCookies> {
const credential = new RotatingCredential(keys);
const signed = `${tokens.idToken}:${tokens.refreshToken}`;
const signature = await credential.createSignature(tokens);

return {
signed,
custom: tokens.customToken,
signature
};
}

export async function parseCookies(
{signed, custom, signature}: SignedCookies,
keys: string[]
): Promise<CustomTokens> {
if (!signed || !custom || !signature) {
throw new InvalidTokenError(InvalidTokenReason.MISSING_CREDENTIALS);
}

const [idToken, refreshToken] = signed.split(':');

if (!idToken || !refreshToken) {
throw new InvalidTokenError(InvalidTokenReason.MALFORMED_CREDENTIALS);
}

const customTokens: CustomTokens = {
idToken,
refreshToken,
customToken: custom
};

const credential = new RotatingCredential(keys);

try {
await credential.verifySignature(customTokens, signature);

return customTokens;
} catch (e) {
if (e instanceof errors.JWSSignatureVerificationFailed) {
throw new InvalidTokenError(InvalidTokenReason.INVALID_SIGNATURE);
}

throw e;
}
}

export async function parseTokens(
customJWT: string,
keys: string[]
Expand Down
8 changes: 4 additions & 4 deletions src/auth/custom-token/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {CustomJWTPayload, createCustomJWT, verifyCustomJWT} from '.';
describe('custom jwt', () => {
const secret = 'very-secure-secret';
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0sgSUQgVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DSyBSRUZSRVNIIFRPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DSyBDVVNUT00gVE9LRU4ifQ.Y9WD7_nVQ0k2QCmke4cgmDMLD1ThjskojFlvPGypnLU';
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DS19SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DS19DVVNUT01fVE9LRU4ifQ.-QNV35-rSl-jCgXHiR3gMW5G-TAkKcT5AimTc7BTPFA';
const payload: CustomJWTPayload = {
id_token: 'MOCK ID TOKEN',
refresh_token: 'MOCK REFRESH TOKEN',
custom_token: 'MOCK CUSTOM TOKEN'
id_token: 'MOCK_ID_TOKEN',
refresh_token: 'MOCK_REFRESH_TOKEN',
custom_token: 'MOCK_CUSTOM_TOKEN'
};

it('generates custom jwt with id, refresh and custom tokens as a payload', async () => {
Expand Down
31 changes: 30 additions & 1 deletion src/auth/custom-token/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import {JWTPayload, JWTVerifyResult, SignJWT, jwtVerify} from 'jose';
import {
FlattenedSign,
JWTPayload,
JWTVerifyResult,
SignJWT,
errors,
jwtVerify
} from 'jose';
import {toUint8Array} from '../utils';
import {DecodedIdToken} from '../token-verifier';

Expand Down Expand Up @@ -26,6 +33,28 @@ export interface CustomJWTPayload extends JWTPayload {
custom_token: string;
}

export async function createCustomSignature(tokens: CustomTokens, key: string) {
const jws = await new FlattenedSign(
toUint8Array(
`${tokens.idToken}.${tokens.refreshToken}.${tokens.customToken}`
)
)
.setProtectedHeader({alg: 'HS256'})
.sign(toUint8Array(key));

return jws.signature;
}

export async function verifyCustomSignature(
tokens: CustomTokens,
signature: string,
key: string
): Promise<void> {
if ((await createCustomSignature(tokens, key)) !== signature) {
throw new errors.JWSSignatureVerificationFailed('');
}
}

export async function createCustomJWT(
payload: CustomJWTPayload,
secret: string
Expand Down
46 changes: 41 additions & 5 deletions src/auth/rotating-credential.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import {errors} from 'jose';
import {CustomJWTPayload} from './custom-token';
import {CustomJWTPayload, CustomTokens} from './custom-token';
import {RotatingCredential} from './rotating-credential';

describe('rotating-credential', () => {
const jwt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0sgSUQgVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DSyBSRUZSRVNIIFRPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DSyBDVVNUT00gVE9LRU4ifQ.wOyqlxSHU2DyI0E8W-YRlDch5Dru8P802ncMMSvYzWo';
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZF90b2tlbiI6Ik1PQ0tfSURfVE9LRU4iLCJyZWZyZXNoX3Rva2VuIjoiTU9DS19SRUZSRVNIX1RPS0VOIiwiY3VzdG9tX3Rva2VuIjoiTU9DS19DVVNUT01fVE9LRU4ifQ.t5AtcgCy43udQyU62pwIWhk6MM179Q891VoEC6RZTe4';
const signature = 'Jyr2abaKtiXBmyp14Dc4Uxf_DM0PKE9FS5zDzVS97TA';

const payload: CustomJWTPayload = {
id_token: 'MOCK ID TOKEN',
refresh_token: 'MOCK REFRESH TOKEN',
custom_token: 'MOCK CUSTOM TOKEN'
id_token: 'MOCK_ID_TOKEN',
refresh_token: 'MOCK_REFRESH_TOKEN',
custom_token: 'MOCK_CUSTOM_TOKEN'
};

const customTokens: CustomTokens = {
idToken: 'MOCK_ID_TOKEN',
refreshToken: 'MOCK_REFRESH_TOKEN',
customToken: 'MOCK_CUSTOM_TOKEN'
};

it('should sign custom jwt payload', async () => {
Expand All @@ -18,6 +26,34 @@ describe('rotating-credential', () => {
expect(customJWT).toEqual(jwt);
});

it('should create signature', async () => {
const credential = new RotatingCredential(['key1', 'key2']);
const customSignature = await credential.createSignature(customTokens);

expect(customSignature).toEqual(signature);
});

it('should verify signature', async () => {
const credential = new RotatingCredential(['key1', 'key2']);

return expect(() => credential.verifySignature(customTokens, signature))
.resolves;
});

it('should verify signature with rotated keys', async () => {
const credential = new RotatingCredential(['key3', 'key1']);

await credential.verifySignature(customTokens, signature);
});

it('should throw invalid signature error if no keys match signature', async () => {
const credential = new RotatingCredential(['key3']);

return expect(() =>
credential.verifySignature(customTokens, signature)
).rejects.toBeInstanceOf(errors.JWSSignatureVerificationFailed);
});

it('should verify custom jwt payload', async () => {
const credential = new RotatingCredential(['key1', 'key2']);

Expand Down
37 changes: 29 additions & 8 deletions src/auth/rotating-credential.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
import {errors} from 'jose';
import {
CustomJWTPayload,
CustomTokens,
createCustomJWT,
verifyCustomJWT
createCustomSignature,
verifyCustomJWT,
verifyCustomSignature
} from './custom-token';

export class RotatingCredential {
constructor(private keys: string[]) {}

private async signPayload(
payload: CustomJWTPayload,
secret: string
): Promise<string> {
return createCustomJWT(payload, secret);
public async sign(payload: CustomJWTPayload) {
return createCustomJWT(payload, this.keys[0]);
}

public async sign(payload: CustomJWTPayload) {
return this.signPayload(payload, this.keys[0]);
public async createSignature(tokens: CustomTokens): Promise<string> {
return createCustomSignature(tokens, this.keys[0]);
}

public async verify(customJWT: string): Promise<CustomJWTPayload> {
Expand All @@ -37,4 +37,25 @@ export class RotatingCredential {
'Custom JWT could not be verified against any of the provided keys'
);
}

public async verifySignature(
tokens: CustomTokens,
signature: string
): Promise<void> {
for (const key of this.keys) {
try {
return await verifyCustomSignature(tokens, signature, key);
} catch (e) {
if (e instanceof errors.JWSSignatureVerificationFailed) {
continue;
}

throw e;
}
}

throw new errors.JWSSignatureVerificationFailed(
'Custom tokens signature could not be verified against any of the provided keys'
);
}
}
Loading

0 comments on commit 23ee02f

Please sign in to comment.