Skip to content

Commit 9b0351a

Browse files
committed
feat(fac): Implement the App Check API (#22)
* Implement the App Check API
1 parent 855a901 commit 9b0351a

File tree

8 files changed

+1182
-0
lines changed

8 files changed

+1182
-0
lines changed
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/*!
2+
* @license
3+
* Copyright 2021 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { appCheck } from './index';
19+
import {
20+
HttpRequestConfig, HttpClient, HttpError, AuthorizedHttpClient, HttpResponse
21+
} from '../utils/api-request';
22+
import { FirebaseApp } from '../firebase-app';
23+
import { PrefixedFirebaseError } from '../utils/error';
24+
25+
import * as utils from '../utils/index';
26+
import * as validator from '../utils/validator';
27+
28+
import AppCheckToken = appCheck.AppCheckToken;
29+
30+
// App Check backend constants
31+
const FIREBASE_APP_CHECK_V1_API_URL_FORMAT = 'https://firebaseappcheck.googleapis.com/v1alpha/projects/{projectId}/apps/{appId}:exchangeCustomToken';
32+
33+
const FIREBASE_APP_CHECK_CONFIG_HEADERS = {
34+
'X-Firebase-Client': `fire-admin-node/${utils.getSdkVersion()}`
35+
};
36+
37+
/**
38+
* Class that facilitates sending requests to the Firebase App Check backend API.
39+
*
40+
* @internal
41+
*/
42+
export class AppCheckApiClient {
43+
private readonly httpClient: HttpClient;
44+
private projectId?: string;
45+
46+
constructor(private readonly app: FirebaseApp) {
47+
if (!validator.isNonNullObject(app) || !('options' in app)) {
48+
throw new FirebaseAppCheckError(
49+
'invalid-argument',
50+
'First argument passed to admin.appCheck() must be a valid Firebase app instance.');
51+
}
52+
this.httpClient = new AuthorizedHttpClient(app);
53+
}
54+
55+
/**
56+
* Exchange a signed custom token to App Check token
57+
*
58+
* @param customToken The custom token to be exchanged.
59+
* @param appId The mobile App ID.
60+
* @return A promise that fulfills with a `AppCheckToken`.
61+
*/
62+
public exchangeToken(customToken: string, appId: string): Promise<AppCheckToken> {
63+
if (!validator.isNonEmptyString(appId)) {
64+
throw new FirebaseAppCheckError(
65+
'invalid-argument',
66+
'`appId` must be a non-empty string.');
67+
}
68+
if (!validator.isNonEmptyString(customToken)) {
69+
throw new FirebaseAppCheckError(
70+
'invalid-argument',
71+
'`customToken` must be a non-empty string.');
72+
}
73+
return this.getUrl(appId)
74+
.then((url) => {
75+
const request: HttpRequestConfig = {
76+
method: 'POST',
77+
url,
78+
headers: FIREBASE_APP_CHECK_CONFIG_HEADERS,
79+
data: { customToken }
80+
};
81+
return this.httpClient.send(request);
82+
})
83+
.then((resp) => {
84+
return this.toAppCheckToken(resp);
85+
})
86+
.catch((err) => {
87+
throw this.toFirebaseError(err);
88+
});
89+
}
90+
91+
private getUrl(appId: string): Promise<string> {
92+
return this.getProjectId()
93+
.then((projectId) => {
94+
const urlParams = {
95+
projectId,
96+
appId,
97+
};
98+
const baseUrl = utils.formatString(FIREBASE_APP_CHECK_V1_API_URL_FORMAT, urlParams);
99+
return utils.formatString(baseUrl);
100+
});
101+
}
102+
103+
private getProjectId(): Promise<string> {
104+
if (this.projectId) {
105+
return Promise.resolve(this.projectId);
106+
}
107+
return utils.findProjectId(this.app)
108+
.then((projectId) => {
109+
if (!validator.isNonEmptyString(projectId)) {
110+
throw new FirebaseAppCheckError(
111+
'unknown-error',
112+
'Failed to determine project ID. Initialize the '
113+
+ 'SDK with service account credentials or set project ID as an app option. '
114+
+ 'Alternatively, set the GOOGLE_CLOUD_PROJECT environment variable.');
115+
}
116+
this.projectId = projectId;
117+
return projectId;
118+
});
119+
}
120+
121+
private toFirebaseError(err: HttpError): PrefixedFirebaseError {
122+
if (err instanceof PrefixedFirebaseError) {
123+
return err;
124+
}
125+
126+
const response = err.response;
127+
if (!response.isJson()) {
128+
return new FirebaseAppCheckError(
129+
'unknown-error',
130+
`Unexpected response with status: ${response.status} and body: ${response.text}`);
131+
}
132+
133+
const error: Error = (response.data as ErrorResponse).error || {};
134+
let code: AppCheckErrorCode = 'unknown-error';
135+
if (error.status && error.status in APP_CHECK_ERROR_CODE_MAPPING) {
136+
code = APP_CHECK_ERROR_CODE_MAPPING[error.status];
137+
}
138+
const message = error.message || `Unknown server error: ${response.text}`;
139+
return new FirebaseAppCheckError(code, message);
140+
}
141+
142+
/**
143+
* Creates an AppCheckToken from the API response.
144+
*
145+
* @param resp API response object.
146+
* @return An AppCheckToken instance.
147+
*/
148+
private toAppCheckToken(resp: HttpResponse): AppCheckToken {
149+
const token = resp.data.attestationToken;
150+
// `timeToLive` is a string with the suffix "s" preceded by the number of seconds,
151+
// with nanoseconds expressed as fractional seconds.
152+
const ttlMillis = this.stringToMilliseconds(resp.data.timeToLive);
153+
return {
154+
token,
155+
ttlMillis
156+
}
157+
}
158+
159+
/**
160+
* Converts a duration string with the suffix `s` to milliseconds.
161+
*
162+
* @param duration The duration as a string with the suffix "s" preceded by the
163+
* number of seconds, with fractional seconds. For example, 3 seconds with 0 nanoseconds
164+
* is expressed as "3s", while 3 seconds and 1 nanosecond is expressed as "3.000000001s",
165+
* and 3 seconds and 1 microsecond is expressed as "3.000001s".
166+
*
167+
* @return The duration in milliseconds.
168+
*/
169+
private stringToMilliseconds(duration: string): number {
170+
if (!validator.isNonEmptyString(duration) || !duration.endsWith('s')) {
171+
throw new FirebaseAppCheckError(
172+
'invalid-argument', '`timeToLive` must be a valid duration string with the suffix `s`.');
173+
}
174+
const seconds = duration.slice(0, -1);
175+
return Math.floor(Number(seconds) * 1000);
176+
}
177+
}
178+
179+
interface ErrorResponse {
180+
error?: Error;
181+
}
182+
183+
interface Error {
184+
code?: number;
185+
message?: string;
186+
status?: string;
187+
}
188+
189+
export const APP_CHECK_ERROR_CODE_MAPPING: { [key: string]: AppCheckErrorCode } = {
190+
ABORTED: 'aborted',
191+
INVALID_ARGUMENT: 'invalid-argument',
192+
INVALID_CREDENTIAL: 'invalid-credential',
193+
INTERNAL: 'internal-error',
194+
PERMISSION_DENIED: 'permission-denied',
195+
UNAUTHENTICATED: 'unauthenticated',
196+
NOT_FOUND: 'not-found',
197+
UNKNOWN: 'unknown-error',
198+
};
199+
200+
export type AppCheckErrorCode =
201+
'aborted'
202+
| 'invalid-argument'
203+
| 'invalid-credential'
204+
| 'internal-error'
205+
| 'permission-denied'
206+
| 'unauthenticated'
207+
| 'not-found'
208+
| 'unknown-error';
209+
210+
/**
211+
* Firebase App Check error code structure. This extends PrefixedFirebaseError.
212+
*
213+
* @param {AppCheckErrorCode} code The error code.
214+
* @param {string} message The error message.
215+
* @constructor
216+
*/
217+
export class FirebaseAppCheckError extends PrefixedFirebaseError {
218+
constructor(code: AppCheckErrorCode, message: string) {
219+
super('app-check', code, message);
220+
221+
/* tslint:disable:max-line-length */
222+
// Set the prototype explicitly. See the following link for more details:
223+
// https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work
224+
/* tslint:enable:max-line-length */
225+
(this as any).__proto__ = FirebaseAppCheckError.prototype;
226+
}
227+
}

src/app-check/app-check.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*!
2+
* @license
3+
* Copyright 2021 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { FirebaseApp } from '../firebase-app';
19+
import { appCheck } from './index';
20+
import { AppCheckApiClient } from './app-check-api-client-internal';
21+
import {
22+
appCheckErrorFromCryptoSignerError, AppCheckTokenGenerator
23+
} from './token-generator';
24+
import { cryptoSignerFromApp } from '../utils/crypto-signer';
25+
26+
import AppCheckInterface = appCheck.AppCheck;
27+
import AppCheckToken = appCheck.AppCheckToken;
28+
29+
/**
30+
* AppCheck service bound to the provided app.
31+
*/
32+
export class AppCheck implements AppCheckInterface {
33+
34+
private readonly client: AppCheckApiClient;
35+
private readonly tokenGenerator: AppCheckTokenGenerator;
36+
37+
/**
38+
* @param app The app for this AppCheck service.
39+
* @constructor
40+
*/
41+
constructor(readonly app: FirebaseApp) {
42+
this.client = new AppCheckApiClient(app);
43+
try {
44+
this.tokenGenerator = new AppCheckTokenGenerator(cryptoSignerFromApp(app));
45+
} catch (err) {
46+
throw appCheckErrorFromCryptoSignerError(err);
47+
}
48+
}
49+
50+
/**
51+
* Creates a new {@link appCheck.AppCheckToken `AppCheckToken`} that can be sent
52+
* back to a client.
53+
*
54+
* @return A promise that fulfills with a `AppCheckToken`.
55+
*/
56+
public createToken(appId: string): Promise<AppCheckToken> {
57+
return this.tokenGenerator.createCustomToken(appId)
58+
.then((customToken) => {
59+
return this.client.exchangeToken(customToken, appId);
60+
});
61+
}
62+
}

src/app-check/index.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*!
2+
* @license
3+
* Copyright 2021 Google Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { app } from '../firebase-namespace-api';
19+
20+
/**
21+
* Gets the {@link appCheck.AppCheck `AppCheck`} service for the
22+
* default app or a given app.
23+
*
24+
* You can call `admin.appCheck()` with no arguments to access the default
25+
* app's {@link appCheck.AppCheck `AppCheck`} service or as
26+
* `admin.appCheck(app)` to access the
27+
* {@link appCheck.AppCheck `AppCheck`} service associated with a
28+
* specific app.
29+
*
30+
* @example
31+
* ```javascript
32+
* // Get the `AppCheck` service for the default app
33+
* var defaultAppCheck = admin.appCheck();
34+
* ```
35+
*
36+
* @example
37+
* ```javascript
38+
* // Get the `AppCheck` service for a given app
39+
* var otherAppCheck = admin.appCheck(otherApp);
40+
* ```
41+
*
42+
* @param app Optional app for which to return the `AppCheck` service.
43+
* If not provided, the default `AppCheck` service is returned.
44+
*
45+
* @return The default `AppCheck` service if no
46+
* app is provided, or the `AppCheck` service associated with the provided
47+
* app.
48+
*/
49+
export declare function appCheck(app?: app.App): appCheck.AppCheck;
50+
51+
/* eslint-disable @typescript-eslint/no-namespace */
52+
export namespace appCheck {
53+
/**
54+
* The Firebase `AppCheck` service interface.
55+
*/
56+
export interface AppCheck {
57+
app: app.App;
58+
59+
/**
60+
* Creates a new {@link appCheck.AppCheckToken `AppCheckToken`} that can be sent
61+
* back to a client.
62+
*
63+
* @return A promise that fulfills with a `AppCheckToken`.
64+
*/
65+
createToken(appId: string): Promise<AppCheckToken>;
66+
}
67+
68+
/**
69+
* Interface representing an App Check token.
70+
*/
71+
export interface AppCheckToken {
72+
/**
73+
* Firebase App Check token
74+
*/
75+
token: string;
76+
77+
/**
78+
* Time-to-live duration of the token in milliseconds.
79+
*/
80+
ttlMillis: number;
81+
}
82+
}

0 commit comments

Comments
 (0)