Skip to content

Commit 49001a5

Browse files
committed
added tests
1 parent caed773 commit 49001a5

File tree

10 files changed

+559
-20
lines changed

10 files changed

+559
-20
lines changed

example/wrangler.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ FIREBASE_AUTH_EMULATOR_HOST = "127.0.0.1:9099"
2121
EMAIL_ADDRESS = "[email protected]"
2222
PASSWORD = "test1234"
2323

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

2626
# Specify cache key to store and get public jwk.
2727
PUBLIC_JWK_CACHE_KEY = "public-jwk-cache-key"

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
],
1515
"scripts": {
1616
"test": "vitest run",
17-
"test-with-emulator": "firebase emulators:exec --project example-project12345 'vitest run'",
17+
"test-with-emulator": "firebase emulators:exec --project project12345 'vitest run'",
1818
"build": "run-p build:*",
1919
"build:main": "tsc -p tsconfig.main.json",
2020
"build:module": "tsc -p tsconfig.module.json",
21-
"start-firebase-emulator": "firebase emulators:start --project example-project12345",
21+
"start-firebase-emulator": "firebase emulators:start --project project12345",
2222
"start-example": "wrangler dev example/index.ts --config=example/wrangler.toml --local=true",
2323
"prettier": "prettier --write --list-different \"**/*.ts\"",
2424
"prettier:check": "prettier --check \"**/*.ts\"",

src/auth-api-requests.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export class AuthApiClient extends BaseClient {
5959
// Convert to seconds.
6060
validDuration: expiresIn / 1000,
6161
};
62-
return await this.fetch(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env);
62+
const res = await this.fetch<{ sessionCookie: string }>(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request, env);
63+
return res.sessionCookie;
6364
}
6465
}

src/auth.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { AuthApiClient } from './auth-api-requests';
2+
import type { Credential } from './credential';
23
import type { EmulatorEnv } from './emulator';
34
import { useEmulator } from './emulator';
4-
import { AuthClientErrorCode, FirebaseAuthError } from './errors';
5+
import { AppErrorCodes, AuthClientErrorCode, FirebaseAppError, FirebaseAuthError } from './errors';
56
import type { KeyStorer } from './key-store';
67
import type { FirebaseIdToken, FirebaseTokenVerifier } from './token-verifier';
78
import { createIdTokenVerifier, createSessionCookieVerifier } from './token-verifier';
@@ -11,12 +12,22 @@ export class BaseAuth {
1112
/** @internal */
1213
protected readonly idTokenVerifier: FirebaseTokenVerifier;
1314
protected readonly sessionCookieVerifier: FirebaseTokenVerifier;
14-
private readonly authApiClient: AuthApiClient;
15+
private readonly _authApiClient?: AuthApiClient;
1516

16-
constructor(projectId: string, keyStore: KeyStorer) {
17+
constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) {
1718
this.idTokenVerifier = createIdTokenVerifier(projectId, keyStore);
1819
this.sessionCookieVerifier = createSessionCookieVerifier(projectId, keyStore);
19-
this.authApiClient = new AuthApiClient(projectId);
20+
21+
if (credential) {
22+
this._authApiClient = new AuthApiClient(projectId, credential);
23+
}
24+
}
25+
26+
private get authApiClient(): AuthApiClient {
27+
if (this._authApiClient) {
28+
return this._authApiClient;
29+
}
30+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be required in initialization.');
2031
}
2132

2233
/**
@@ -56,16 +67,16 @@ export class BaseAuth {
5667
* @returns A promise that resolves on success with the
5768
* created session cookie.
5869
*/
59-
public createSessionCookie(
70+
public async createSessionCookie(
6071
idToken: string,
6172
sessionCookieOptions: SessionCookieOptions,
6273
env?: EmulatorEnv
6374
): Promise<string> {
6475
// Return rejected promise if expiresIn is not available.
6576
if (!isNonNullObject(sessionCookieOptions) || !isNumber(sessionCookieOptions.expiresIn)) {
66-
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION));
77+
throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION);
6778
}
68-
return this.authApiClient.createSessionCookie(idToken, sessionCookieOptions.expiresIn, env);
79+
return await this.authApiClient.createSessionCookie(idToken, sessionCookieOptions.expiresIn, env);
6980
}
7081

7182
/**

src/base64.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export const encodeBase64 = (buf: ArrayBufferLike): string => {
1313
};
1414

1515
// atob does not support utf-8 characters. So we need a little bit hack.
16-
const decodeBase64 = (str: string): Uint8Array => {
16+
export const decodeBase64 = (str: string): Uint8Array => {
1717
const binary = atob(str);
1818
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
1919
const half = binary.length / 2;

src/client.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { version } from '../package.json';
22
import type { ApiSettings } from './api-requests';
3-
import type { EmulatorEnv } from './emulator';
3+
import type { Credential } from './credential';
4+
import { type EmulatorEnv } from './emulator';
45
import { AppErrorCodes, FirebaseAppError } from './errors';
56

67
/**
@@ -55,20 +56,28 @@ export function buildApiUrl(projectId: string, apiSettings: ApiSettings, env?: E
5556
export class BaseClient {
5657
constructor(
5758
private projectId: string,
59+
private credential: Credential,
5860
private retryConfig: RetryConfig = defaultRetryConfig()
5961
) {}
6062

63+
private async getToken(): Promise<string> {
64+
const result = await this.credential.getAccessToken();
65+
return result.access_token;
66+
}
67+
6168
protected async fetch<T>(apiSettings: ApiSettings, requestData?: object, env?: EmulatorEnv): Promise<T> {
6269
const fullUrl = buildApiUrl(this.projectId, apiSettings, env);
6370
if (requestData) {
6471
const requestValidator = apiSettings.getRequestValidator();
6572
requestValidator(requestData);
6673
}
74+
const token = await this.getToken();
6775
const method = apiSettings.getHttpMethod();
6876
const signal = AbortSignal.timeout(25000); // 25s
6977
return await this.fetchWithRetry<T>(fullUrl, {
7078
method,
7179
headers: {
80+
Authorization: `Bearer ${token}`,
7281
'User-Agent': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
7382
'X-Client-Version': `Code-Hex/firebase-auth-cloudflare-workers/${version}`,
7483
'Content-Type': 'application/json;charset=utf-8',

src/credential.ts

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
import { encodeObjectBase64Url } from './../tests/jwk-utils';
2+
import { decodeBase64, encodeBase64Url } from './base64';
3+
import { AppErrorCodes, FirebaseAppError } from './errors';
4+
import { isNonEmptyString, isNonNullObject } from './validator';
5+
6+
/**
7+
* Type representing a Firebase OAuth access token (derived from a Google OAuth2 access token) which
8+
* can be used to authenticate to Firebase services such as the Realtime Database and Auth.
9+
*/
10+
export interface FirebaseAccessToken {
11+
accessToken: string;
12+
expirationTime: number;
13+
}
14+
15+
/**
16+
* Interface for Google OAuth 2.0 access tokens.
17+
*/
18+
export interface GoogleOAuthAccessToken {
19+
access_token: string;
20+
expires_in: number;
21+
}
22+
23+
/**
24+
* Interface that provides Google OAuth2 access tokens used to authenticate
25+
* with Firebase services.
26+
*
27+
* In most cases, you will not need to implement this yourself and can instead
28+
* use the default implementations provided by the `firebase-admin/app` module.
29+
*/
30+
export interface Credential {
31+
/**
32+
* Returns a Google OAuth2 access token object used to authenticate with
33+
* Firebase services.
34+
*
35+
* @returns A Google OAuth2 access token object.
36+
*/
37+
getAccessToken(): Promise<GoogleOAuthAccessToken>;
38+
}
39+
40+
/**
41+
* Implementation of Credential that uses with emulator.
42+
*/
43+
export class EmulatorCredential implements Credential {
44+
public async getAccessToken(): Promise<GoogleOAuthAccessToken> {
45+
return {
46+
access_token: 'owner',
47+
expires_in: 90 * 3600 * 3600,
48+
};
49+
}
50+
}
51+
52+
const GOOGLE_TOKEN_AUDIENCE = 'https://accounts.google.com/o/oauth2/token';
53+
const GOOGLE_AUTH_TOKEN_HOST = 'accounts.google.com';
54+
const GOOGLE_AUTH_TOKEN_PATH = '/o/oauth2/token';
55+
56+
/**
57+
* Implementation of Credential that uses a service account.
58+
*/
59+
export class ServiceAccountCredential implements Credential {
60+
public readonly projectId: string;
61+
public readonly privateKey: string;
62+
public readonly clientEmail: string;
63+
64+
/**
65+
* Creates a new ServiceAccountCredential from the given parameters.
66+
*
67+
* @param serviceAccountJson - Service account json content.
68+
*
69+
* @constructor
70+
*/
71+
constructor(serviceAccountJson: string) {
72+
const serviceAccount = ServiceAccount.fromJSON(serviceAccountJson);
73+
this.projectId = serviceAccount.projectId;
74+
this.privateKey = serviceAccount.privateKey;
75+
this.clientEmail = serviceAccount.clientEmail;
76+
}
77+
78+
public async getAccessToken(): Promise<GoogleOAuthAccessToken> {
79+
const header = encodeObjectBase64Url({
80+
alg: 'RS256',
81+
typ: 'JWT',
82+
}).replace(/=/g, '');
83+
84+
const iat = Math.round(Date.now() / 1000);
85+
const exp = iat + 3600;
86+
const claim = encodeObjectBase64Url({
87+
iss: this.clientEmail,
88+
scope: ['https://www.googleapis.com/auth/cloud-platform', 'https://www.googleapis.com/auth/identitytoolkit'].join(
89+
' '
90+
),
91+
aud: GOOGLE_TOKEN_AUDIENCE,
92+
exp,
93+
iat,
94+
}).replace(/=/g, '');
95+
96+
const unsignedContent = `${header}.${claim}`;
97+
// This method is actually synchronous so we can capture and return the buffer.
98+
const signature = await this.sign(unsignedContent, this.privateKey);
99+
const jwt = `${unsignedContent}.${signature}`;
100+
const body = `grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=${jwt}`;
101+
const url = `https://${GOOGLE_AUTH_TOKEN_HOST}${GOOGLE_AUTH_TOKEN_PATH}`;
102+
const res = await fetch(url, {
103+
method: 'POST',
104+
headers: {
105+
'Content-Type': 'application/x-www-form-urlencoded',
106+
'Cache-Control': 'no-cache',
107+
Host: 'oauth2.googleapis.com',
108+
},
109+
body,
110+
});
111+
const json = (await res.json()) as any;
112+
if (!json.access_token || !json.expires_in) {
113+
throw new FirebaseAppError(
114+
AppErrorCodes.INVALID_CREDENTIAL,
115+
`Unexpected response while fetching access token: ${JSON.stringify(json)}`
116+
);
117+
}
118+
119+
return json;
120+
}
121+
122+
private async sign(content: string, privateKey: string): Promise<string> {
123+
const buf = this.str2ab(content);
124+
const binaryKey = decodeBase64(privateKey);
125+
const signer = await crypto.subtle.importKey(
126+
'pkcs8',
127+
binaryKey,
128+
{
129+
name: 'RSASSA-PKCS1-V1_5',
130+
hash: { name: 'SHA-256' },
131+
},
132+
false,
133+
['sign']
134+
);
135+
const binarySignature = await crypto.subtle.sign({ name: 'RSASSA-PKCS1-V1_5' }, signer, buf);
136+
return encodeBase64Url(binarySignature).replace(/=/g, '');
137+
}
138+
139+
private str2ab(str: string): ArrayBuffer {
140+
const buf = new ArrayBuffer(str.length);
141+
const bufView = new Uint8Array(buf);
142+
for (let i = 0, strLen = str.length; i < strLen; i += 1) {
143+
bufView[i] = str.charCodeAt(i);
144+
}
145+
return buf;
146+
}
147+
}
148+
149+
/**
150+
* A struct containing the properties necessary to use service account JSON credentials.
151+
*/
152+
class ServiceAccount {
153+
public readonly projectId: string;
154+
public readonly privateKey: string;
155+
public readonly clientEmail: string;
156+
157+
public static fromJSON(text: string): ServiceAccount {
158+
try {
159+
return new ServiceAccount(JSON.parse(text));
160+
} catch (error) {
161+
// Throw a nicely formed error message if the file contents cannot be parsed
162+
throw new FirebaseAppError(
163+
AppErrorCodes.INVALID_CREDENTIAL,
164+
'Failed to parse service account json file: ' + error
165+
);
166+
}
167+
}
168+
169+
constructor(json: object) {
170+
if (!isNonNullObject(json)) {
171+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, 'Service account must be an object.');
172+
}
173+
174+
copyAttr(this, json, 'projectId', 'project_id');
175+
copyAttr(this, json, 'privateKey', 'private_key');
176+
copyAttr(this, json, 'clientEmail', 'client_email');
177+
178+
let errorMessage;
179+
if (!isNonEmptyString(this.projectId)) {
180+
errorMessage = 'Service account object must contain a string "project_id" property.';
181+
} else if (!isNonEmptyString(this.privateKey)) {
182+
errorMessage = 'Service account object must contain a string "private_key" property.';
183+
} else if (!isNonEmptyString(this.clientEmail)) {
184+
errorMessage = 'Service account object must contain a string "client_email" property.';
185+
}
186+
187+
if (typeof errorMessage !== 'undefined') {
188+
throw new FirebaseAppError(AppErrorCodes.INVALID_CREDENTIAL, errorMessage);
189+
}
190+
191+
this.privateKey = this.privateKey.replace(/-+(BEGIN|END).*/g, '').replace(/\s/g, '');
192+
}
193+
}
194+
195+
/**
196+
* Copies the specified property from one object to another.
197+
*
198+
* If no property exists by the given "key", looks for a property identified by "alt", and copies it instead.
199+
* This can be used to implement behaviors such as "copy property myKey or my_key".
200+
*
201+
* @param to - Target object to copy the property into.
202+
* @param from - Source object to copy the property from.
203+
* @param key - Name of the property to copy.
204+
* @param alt - Alternative name of the property to copy.
205+
*/
206+
function copyAttr(to: { [key: string]: any }, from: { [key: string]: any }, key: string, alt: string): void {
207+
const tmp = from[key] || from[alt];
208+
if (typeof tmp !== 'undefined') {
209+
to[key] = tmp;
210+
}
211+
}

src/index.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { BaseAuth } from './auth';
2+
import type { Credential } from './credential';
23
import type { KeyStorer } from './key-store';
34
import { WorkersKVStore } from './key-store';
45

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

13-
private constructor(projectId: string, keyStore: KeyStorer) {
14-
super(projectId, keyStore);
14+
private constructor(projectId: string, keyStore: KeyStorer, credential?: Credential) {
15+
super(projectId, keyStore, credential);
1516
}
1617

17-
static getOrInitialize(projectId: string, keyStore: KeyStorer): Auth {
18+
static getOrInitialize(projectId: string, keyStore: KeyStorer, credential?: Credential): Auth {
1819
if (!Auth.instance) {
19-
Auth.instance = new Auth(projectId, keyStore);
20+
Auth.instance = new Auth(projectId, keyStore, credential);
2021
}
2122
return Auth.instance;
2223
}

0 commit comments

Comments
 (0)