Skip to content

Supported cookie verification #12

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

Merged
merged 11 commits into from
Feb 19, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
added tests
  • Loading branch information
Code-Hex committed Feb 19, 2024
commit 49001a5a37623af37d0d7d643129a6446a107bde
2 changes: 1 addition & 1 deletion example/wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"
EMAIL_ADDRESS = "[email protected]"
PASSWORD = "test1234"

PROJECT_ID = "example-project12345" # see package.json (for emulator)
PROJECT_ID = "project12345" # see package.json (for emulator)

# Specify cache key to store and get public jwk.
PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key"
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@
],
"scripts": {
"test": "vitest run",
"test-with-emulator": "firebase emulators:exec --project example-project12345 'vitest run'",
"test-with-emulator": "firebase emulators:exec --project project12345 'vitest run'",
"build": "run-p build:*",
"build:main": "tsc -p tsconfig.main.json",
"build:module": "tsc -p tsconfig.module.json",
"start-firebase-emulator": "firebase emulators:start --project example-project12345",
"start-firebase-emulator": "firebase emulators:start --project project12345",
"start-example": "wrangler dev example/index.ts --config=example/wrangler.toml --local=true",
"prettier": "prettier --write --list-different \"**/*.ts\"",
"prettier:check": "prettier --check \"**/*.ts\"",
Expand Down
3 changes: 2 additions & 1 deletion src/auth-api-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export class AuthApiClient extends BaseClient {
// Convert to seconds.
validDuration: expiresIn / 1000,
};
return await this.fetch(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env);
const res = await this.fetch<{ sessionCookie: string }>(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env);
return res.sessionCookie;
}
}
25 changes: 18 additions & 7 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AuthApiClient } from './auth-api-requests';
import type { Credential } from './credential';
import type { EmulatorEnv } from './emulator';
import { useEmulator } from './emulator';
import { AuthClientErrorCode, FirebaseAuthError } from './errors';
import { AppErrorCodes, AuthClientErrorCode, FirebaseAppError, FirebaseAuthError } from './errors';
import type { KeyStorer } from './key-store';
import type { FirebaseIdToken, FirebaseTokenVerifier } from './token-verifier';
import { createIdTokenVerifier, createSessionCookieVerifier } from './token-verifier';
Expand All @@ -11,12 +12,22 @@ export class BaseAuth {
/** @internal */
protected readonly idTokenVerifier: FirebaseTokenVerifier;
protected readonly sessionCookieVerifier: FirebaseTokenVerifier;
private readonly authApiClient: AuthApiClient;
private readonly _authApiClient?: AuthApiClient;

constructor(projectId: string, keyStore: KeyStorer) {
constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) {
this.idTokenVerifier = createIdTokenVerifier(projectId, keyStore);
this.sessionCookieVerifier = createSessionCookieVerifier(projectId, keyStore);
this.authApiClient = new AuthApiClient(projectId);

if (credential) {
this._authApiClient = new AuthApiClient(projectId, credential);
}
}

private get authApiClient(): AuthApiClient {
if (this._authApiClient) {
return this._authApiClient;
}
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be required in initialization.');
}

/**
Expand Down Expand Up @@ -56,16 +67,16 @@ export class BaseAuth {
* @returns A promise that resolves on success with the
* created session cookie.
*/
public createSessionCookie(
public async createSessionCookie(
idToken: string,
sessionCookieOptions: SessionCookieOptions,
env?: EmulatorEnv
): Promise<string> {
// Return rejected promise if expiresIn is not available.
if (!isNonNullObject(sessionCookieOptions) || !isNumber(sessionCookieOptions.expiresIn)) {
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION));
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION);
}
return this.authApiClient.createSessionCookie(idToken, sessionCookieOptions.expiresIn, env);
return await this.authApiClient.createSessionCookie(idToken, sessionCookieOptions.expiresIn, env);
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/base64.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const encodeBase64 = (buf: ArrayBufferLike): string => {
};

// atob does not support utf-8 characters. So we need a little bit hack.
const decodeBase64 = (str: string): Uint8Array => {
export const decodeBase64 = (str: string): Uint8Array => {
const binary = atob(str);
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
const half = binary.length / 2;
Expand Down
11 changes: 10 additions & 1 deletion src/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { version } from '../package.json';
import type { ApiSettings } from './api-requests';
import type { EmulatorEnv } from './emulator';
import type { Credential } from './credential';
import { type EmulatorEnv } from './emulator';
import { AppErrorCodes, FirebaseAppError } from './errors';

/**
Expand Down Expand Up @@ -55,20 +56,28 @@ export function buildApiUrl(projectId: string, apiSettings: ApiSettings, env?: E
export class BaseClient {
constructor(
private projectId: string,
private credential: Credential,
private retryConfig: RetryConfig = defaultRetryConfig()
) {}

private async getToken(): Promise<string> {
const result = await this.credential.getAccessToken();
return result.access_token;
}

protected async fetch<T>(apiSettings: ApiSettings, requestData?: object, env?: EmulatorEnv): Promise<T> {
const fullUrl = buildApiUrl(this.projectId, apiSettings, env);
if (requestData) {
const requestValidator = apiSettings.getRequestValidator();
requestValidator(requestData);
}
const token = await this.getToken();
const method = apiSettings.getHttpMethod();
const signal = AbortSignal.timeout(25000); // 25s
return await this.fetchWithRetry<T>(fullUrl, {
method,
headers: {
Authorization: `Bearer ${token}`,
'User-Agent': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
'X-Client-Version': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
'Content-Type': 'application/json;charset=utf-8',
Expand Down
211 changes: 211 additions & 0 deletions src/credential.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import { encodeObjectBase64Url } from './../tests/jwk-utils';
import { decodeBase64, encodeBase64Url } from './base64';
import { AppErrorCodes, FirebaseAppError } from './errors';
import { isNonEmptyString, isNonNullObject } from './validator';

/**
* Type representing a Firebase OAuth access token (derived from a Google OAuth2 access token) which
* can be used to authenticate to Firebase services such as the Realtime Database and Auth.
*/
export interface FirebaseAccessToken {
accessToken: string;
expirationTime: number;
}

/**
* Interface for Google OAuth 2.0 access tokens.
*/
export interface GoogleOAuthAccessToken {
access_token: string;
expires_in: number;
}

/**
* Interface that provides Google OAuth2 access tokens used to authenticate
* with Firebase services.
*
* In most cases, you will not need to implement this yourself and can instead
* use the default implementations provided by the `firebase-admin/app` module.
*/
export interface Credential {
/**
* Returns a Google OAuth2 access token object used to authenticate with
* Firebase services.
*
* @returns A Google OAuth2 access token object.
*/
getAccessToken(): Promise<GoogleOAuthAccessToken>;
}

/**
* Implementation of Credential that uses with emulator.
*/
export class EmulatorCredential implements Credential {
public async getAccessToken(): Promise<GoogleOAuthAccessToken> {
return {
access_token: 'owner',
expires_in: 90 * 3600 * 3600,
};
}
}

const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token';
const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com';
const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token';

/**
* Implementation of Credential that uses a service account.
*/
export class ServiceAccountCredential implements Credential {
public readonly projectId: string;
public readonly privateKey: string;
public readonly clientEmail: string;

/**
* Creates a new ServiceAccountCredential from the given parameters.
*
* @param serviceAccountJson - Service account json content.
*
* @constructor
*/
constructor(serviceAccountJson: string) {
const serviceAccount = ServiceAccount.fromJSON(serviceAccountJson);
this.projectId = serviceAccount.projectId;
this.privateKey = serviceAccount.privateKey;
this.clientEmail = serviceAccount.clientEmail;
}

public async getAccessToken(): Promise<GoogleOAuthAccessToken> {
const header = encodeObjectBase64Url({
alg: 'RS256',
typ: 'JWT',
}).replace(/=/g, '');

const iat = Math.round(Date.now() / 1000);
const exp = iat + 3600;
const claim = encodeObjectBase64Url({
iss: this.clientEmail,
scope: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/identitytoolkit'].join(
' '
),
aud: GOOGLE_TOKEN_AUDIENCE,
exp,
iat,
}).replace(/=/g, '');

const unsignedContent = `${header}.${claim}`;
// This method is actually synchronous so we can capture and return the buffer.
const signature = await this.sign(unsignedContent, this.privateKey);
const jwt = `${unsignedContent}.${signature}`;
const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`;
const url = `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`;
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Cache-Control': 'no-cache',
Host: 'oauth2.googleapis.com',
},
body,
});
const json = (await res.json()) as any;
if (!json.access_token || !json.expires_in) {
throw new FirebaseAppError(
AppErrorCodes.INVALID_CREDENTIAL,
`Unexpected response while fetching access token: ${JSON.stringify(json)}`
);
}

return json;
}

private async sign(content: string, privateKey: string): Promise<string> {
const buf = this.str2ab(content);
const binaryKey = decodeBase64(privateKey);
const signer = await crypto.subtle.importKey(
'pkcs8',
binaryKey,
{
name: 'RSASSA-PKCS1-V1_5',
hash: { name: 'SHA-256' },
},
false,
['sign']
);
const binarySignature = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-V1_5' }, signer, buf);
return encodeBase64Url(binarySignature).replace(/=/g, '');
}

private str2ab(str: string): ArrayBuffer {
const buf = new ArrayBuffer(str.length);
const bufView = new Uint8Array(buf);
for (let i = 0, strLen = str.length; i < strLen; i += 1) {
bufView[i] = str.charCodeAt(i);
}
return buf;
}
}

/**
* A struct containing the properties necessary to use service account JSON credentials.
*/
class ServiceAccount {
public readonly projectId: string;
public readonly privateKey: string;
public readonly clientEmail: string;

public static fromJSON(text: string): ServiceAccount {
try {
return new ServiceAccount(JSON.parse(text));
} catch (error) {
// Throw a nicely formed error message if the file contents cannot be parsed
throw new FirebaseAppError(
AppErrorCodes.INVALID_CREDENTIAL,
'Failed to parse service account json file: ' + error
);
}
}

constructor(json: object) {
if (!isNonNullObject(json)) {
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be an object.');
}

copyAttr(this, json, 'projectId', 'project_id');
copyAttr(this, json, 'privateKey', 'private_key');
copyAttr(this, json, 'clientEmail', 'client_email');

let errorMessage;
if (!isNonEmptyString(this.projectId)) {
errorMessage = 'Service account object must contain a string "project_id" property.';
} else if (!isNonEmptyString(this.privateKey)) {
errorMessage = 'Service account object must contain a string "private_key" property.';
} else if (!isNonEmptyString(this.clientEmail)) {
errorMessage = 'Service account object must contain a string "client_email" property.';
}

if (typeof errorMessage !== 'undefined') {
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage);
}

this.privateKey = this.privateKey.replace(/-+(BEGIN|END).*/g, '').replace(/\s/g, '');
}
}

/**
* Copies the specified property from one object to another.
*
* If no property exists by the given "key", looks for a property identified by "alt", and copies it instead.
* This can be used to implement behaviors such as "copy property myKey or my_key".
*
* @param to - Target object to copy the property into.
* @param from - Source object to copy the property from.
* @param key - Name of the property to copy.
* @param alt - Alternative name of the property to copy.
*/
function copyAttr(to: { [key: string]: any }, from: { [key: string]: any }, key: string, alt: string): void {
const tmp = from[key] || from[alt];
if (typeof tmp !== 'undefined') {
to[key] = tmp;
}
}
9 changes: 5 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { BaseAuth } from './auth';
import type { Credential } from './credential';
import type { KeyStorer } from './key-store';
import { WorkersKVStore } from './key-store';

Expand All @@ -10,13 +11,13 @@ export type { FirebaseIdToken } from './token-verifier';
export class Auth extends BaseAuth {
private static instance?: Auth;

private constructor(projectId: string, keyStore: KeyStorer) {
super(projectId, keyStore);
private constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) {
super(projectId, keyStore, credential);
}

static getOrInitialize(projectId: string, keyStore: KeyStorer): Auth {
static getOrInitialize(projectId: string, keyStore: KeyStorer, credential?: Credential): Auth {
if (!Auth.instance) {
Auth.instance = new Auth(projectId, keyStore);
Auth.instance = new Auth(projectId, keyStore, credential);
}
return Auth.instance;
}
Expand Down
Loading