diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8d5c7b46..6b5f21dff7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # Unreleased +- [feature] Added the session cookie management APIs for creating and verifying + session cookies, via `auth.createSessionCookie()` and + `auth.verifySessionCookie()`. - [added] Added the `mutableContent` optional field to the `Aps` type of the FCM API. - [added] Added the support for specifying arbitrary custom key-value diff --git a/package.json b/package.json index de5bcafedd..ffb7a41f3d 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "test": "run-s lint test:unit", "integration": "run-s build test:integration", "test:unit": "mocha test/unit/*.spec.ts --compilers ts:ts-node/register", - "test:integration": "mocha test/integration/*.ts --slow 5000 --compilers ts:ts-node/register", + "test:integration": "mocha test/integration/*.ts --slow 5000 --timeout 5000 --compilers ts:ts-node/register", "test:coverage": "nyc npm run test:unit", "lint:src": "tslint --format stylish -p tsconfig.json", "lint:unit": "tslint -c tslint-test.json --format stylish test/unit/*.ts test/unit/**/*.ts", diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index d7520e0bf7..1493b5b96c 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -59,6 +59,12 @@ const MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE = 1000; /** Maximum allowed number of users to batch upload at one time. */ const MAX_UPLOAD_ACCOUNT_BATCH_SIZE = 1000; +/** Minimum allowed session cookie duration in seconds (5 minutes). */ +const MIN_SESSION_COOKIE_DURATION_SECS = 5 * 60; + +/** Maximum allowed session cookie duration in seconds (2 weeks). */ +const MAX_SESSION_COOKIE_DURATION_SECS = 14 * 24 * 60 * 60; + /** * Validates a providerUserInfo object. All unsupported parameters @@ -287,6 +293,31 @@ function validateCreateEditRequest(request: any, uploadAccountRequest: boolean = } +/** Instantiates the createSessionCookie endpoint settings. */ +export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = + new ApiSettings('createSessionCookie', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate the ID token is a non-empty string. + if (!validator.isNonEmptyString(request.idToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + } + // Validate the custom session cookie duration. + if (!validator.isNumber(request.validDuration) || + request.validDuration < MIN_SESSION_COOKIE_DURATION_SECS || + request.validDuration > MAX_SESSION_COOKIE_DURATION_SECS) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the session cookie. + if (!validator.isNonEmptyString(response.sessionCookie)) { + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + } + }); + + /** Instantiates the uploadAccount endpoint settings. */ export const FIREBASE_AUTH_UPLOAD_ACCOUNT = new ApiSettings('uploadAccount', 'POST'); @@ -421,6 +452,26 @@ export class FirebaseAuthRequestHandler { this.signedApiRequestHandler = new SignedApiRequestHandler(app); } + /** + * Creates a new Firebase session cookie with the specified duration that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param {string} idToken The Firebase ID token to exchange for a session cookie. + * @param {number} expiresIn The session cookie duration in milliseconds. + * + * @return {Promise} A promise that resolves on success with the created session cookie. + */ + public createSessionCookie(idToken: string, expiresIn: number): Promise { + const request = { + idToken, + // Convert to seconds. + validDuration: expiresIn / 1000, + }; + return this.invokeRequestHandler(FIREBASE_AUTH_CREATE_SESSION_COOKIE, request) + .then((response: any) => response.sessionCookie); + } + /** * Looks up a user by uid. * diff --git a/src/auth/auth.ts b/src/auth/auth.ts index c953c4860d..a88fe63021 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -19,7 +19,7 @@ import {Certificate} from './credential'; import {FirebaseApp} from '../firebase-app'; import {FirebaseTokenGenerator} from './token-generator'; import {FirebaseAuthRequestHandler} from './auth-api-request'; -import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; +import {AuthClientErrorCode, FirebaseAuthError, ErrorInfo} from '../utils/error'; import {FirebaseServiceInterface, FirebaseServiceInternalsInterface} from '../firebase-service'; import { UserImportOptions, UserImportRecord, UserImportResult, @@ -51,7 +51,7 @@ export interface ListUsersResult { } -/** Inteface representing a decoded ID token. */ +/** Interface representing a decoded ID token. */ export interface DecodedIdToken { aud: string; auth_time: number; @@ -70,6 +70,12 @@ export interface DecodedIdToken { } +/** Interface representing the session cookie options. */ +export interface SessionCookieOptions { + expiresIn: number; +} + + /** * Auth service bound to the provided app. */ @@ -171,23 +177,9 @@ export class Auth implements FirebaseServiceInterface { if (!checkRevoked) { return decodedIdToken; } - // Get tokens valid after time for the corresponding user. - return this.getUser(decodedIdToken.sub) - .then((user: UserRecord) => { - // If no tokens valid after time available, token is not revoked. - if (user.tokensValidAfterTime) { - // Get the ID token authentication time and convert to milliseconds UTC. - const authTimeUtc = decodedIdToken.auth_time * 1000; - // Get user tokens valid after time in milliseconds UTC. - const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); - // Check if authentication time is older than valid since time. - if (authTimeUtc < validSinceUtc) { - throw new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_REVOKED); - } - } - // All checks above passed. Return the decoded token. - return decodedIdToken; - }); + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.ID_TOKEN_REVOKED); }); } @@ -371,4 +363,90 @@ export class Auth implements FirebaseServiceInterface { users: UserImportRecord[], options?: UserImportOptions): Promise { return this.authRequestHandler.uploadAccount(users, options); } + + /** + * Creates a new Firebase session cookie with the specified options that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param {string} idToken The Firebase ID token to exchange for a session cookie. + * @param {SessionCookieOptions} sessionCookieOptions The session cookie options which includes + * custom session duration. + * + * @return {Promise} A promise that resolves on success with the created session cookie. + */ + public createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { + // Return rejected promise if expiresIn is not available. + if (!validator.isNonNullObject(sessionCookieOptions) || + !validator.isNumber(sessionCookieOptions.expiresIn)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); + } + return this.authRequestHandler.createSessionCookie( + idToken, sessionCookieOptions.expiresIn); + } + + /** + * Verifies a Firebase session cookie. Returns a Promise with the tokens claims. Rejects + * the promise if the token could not be verified. If checkRevoked is set to true, + * verifies if the session corresponding to the session cookie was revoked. If the corresponding + * user's session was invalidated, an auth/session-cookie-revoked error is thrown. If not + * specified the check is not performed. + * + * @param {string} sessionCookie The session cookie to verify. + * @param {boolean=} checkRevoked Whether to check if the session cookie is revoked. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + public verifySessionCookie( + sessionCookie: string, checkRevoked: boolean = false): Promise { + if (typeof this.tokenGenerator_ === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + 'GCLOUD_PROJECT environment variable to call auth().verifySessionCookie().', + ); + } + return this.tokenGenerator_.verifySessionCookie(sessionCookie) + .then((decodedIdToken: DecodedIdToken) => { + // Whether to check if the token was revoked. + if (!checkRevoked) { + return decodedIdToken; + } + return this.verifyDecodedJWTNotRevoked( + decodedIdToken, + AuthClientErrorCode.SESSION_COOKIE_REVOKED); + }); + } + + /** + * Verifies the decoded Firebase issued JWT is not revoked. Returns a promise that resolves + * with the decoded claims on success. Rejects the promise with revocation error if revoked. + * + * @param {DecodedIdToken} decodedIdToken The JWT's decoded claims. + * @param {ErrorInfo} revocationErrorInfo The revocation error info to throw on revocation + * detection. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + private verifyDecodedJWTNotRevoked( + decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { + // Get tokens valid after time for the corresponding user. + return this.getUser(decodedIdToken.sub) + .then((user: UserRecord) => { + // If no tokens valid after time available, token is not revoked. + if (user.tokensValidAfterTime) { + // Get the ID token authentication time and convert to milliseconds UTC. + const authTimeUtc = decodedIdToken.auth_time * 1000; + // Get user tokens valid after time in milliseconds UTC. + const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); + // Check if authentication time is older than valid since time. + if (authTimeUtc < validSinceUtc) { + throw new FirebaseAuthError(revocationErrorInfo); + } + } + // All checks above passed. Return the decoded token. + return decodedIdToken; + }); + } } diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index fe3dc0c0e3..616cc88980 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -18,6 +18,7 @@ import {Certificate} from './credential'; import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; import * as validator from '../utils/validator'; +import * as tokenVerify from './token-verifier'; import * as jwt from 'jsonwebtoken'; @@ -25,7 +26,7 @@ import * as jwt from 'jsonwebtoken'; import https = require('https'); -const ALGORITHM = 'RS256'; +const ALGORITHM_RS256 = 'RS256'; const ONE_HOUR_IN_SECONDS = 60 * 60; // List of blacklisted claims which cannot be provided when creating a custom token @@ -38,6 +39,9 @@ const BLACKLISTED_CLAIMS = [ // Auth ID tokens) const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; +// URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon. +const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; + // Audience to use for Firebase Auth Custom tokens const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; @@ -46,13 +50,32 @@ interface JWTPayload { uid?: string; } +/** User facing token information related to the Firebase session cookie. */ +export const SESSION_COOKIE_INFO: tokenVerify.FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', + verifyApiName: 'verifySessionCookie()', + jwtName: 'Firebase session cookie', + shortName: 'session cookie', + expiredErrorCode: 'auth/session-cookie-expired', +}; + +/** User facing token information related to the Firebase ID token. */ +export const ID_TOKEN_INFO: tokenVerify.FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + verifyApiName: 'verifyIdToken()', + jwtName: 'Firebase ID token', + shortName: 'ID token', + expiredErrorCode: 'auth/id-token-expired', +}; + + /** * Class for generating and verifying different types of Firebase Auth tokens (JWTs). */ export class FirebaseTokenGenerator { private certificate_: Certificate; - private publicKeys_: object; - private publicKeysExpireAt_: number; + private sessionCookieVerifier: tokenVerify.FirebaseTokenVerifier; + private idTokenVerifier: tokenVerify.FirebaseTokenVerifier; constructor(certificate: Certificate) { if (!certificate) { @@ -62,6 +85,20 @@ export class FirebaseTokenGenerator { ); } this.certificate_ = certificate; + this.sessionCookieVerifier = new tokenVerify.FirebaseTokenVerifier( + SESSION_COOKIE_CERT_URL, + ALGORITHM_RS256, + 'https://session.firebase.google.com/', + this.certificate_.projectId, + SESSION_COOKIE_INFO, + ); + this.idTokenVerifier = new tokenVerify.FirebaseTokenVerifier( + CLIENT_CERT_URL, + ALGORITHM_RS256, + 'https://securetoken.google.com/', + this.certificate_.projectId, + ID_TOKEN_INFO, + ); } /** @@ -124,7 +161,7 @@ export class FirebaseTokenGenerator { expiresIn: ONE_HOUR_IN_SECONDS, issuer: this.certificate_.clientEmail, subject: this.certificate_.clientEmail, - algorithm: ALGORITHM, + algorithm: ALGORITHM_RS256, }); return Promise.resolve(customToken); @@ -138,107 +175,19 @@ export class FirebaseTokenGenerator { * token. */ public verifyIdToken(idToken: string): Promise { - if (typeof idToken !== 'string') { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'First argument to verifyIdToken() must be a Firebase ID token string.', - ); - } - - if (!validator.isNonEmptyString(this.certificate_.projectId)) { - throw new FirebaseAuthError( - AuthClientErrorCode.INVALID_CREDENTIAL, - 'verifyIdToken() requires a certificate with "project_id" set.', - ); - } - - const fullDecodedToken: any = jwt.decode(idToken, { - complete: true, - }); - - const header = fullDecodedToken && fullDecodedToken.header; - const payload = fullDecodedToken && fullDecodedToken.payload; - - const projectIdMatchMessage = ' Make sure the ID token comes from the same Firebase project as the ' + - 'service account used to authenticate this SDK.'; - const verifyIdTokenDocsMessage = ' See https://firebase.google.com/docs/auth/admin/verify-id-tokens ' + - 'for details on how to retrieve an ID token.'; - - let errorMessage: string; - if (!fullDecodedToken) { - errorMessage = 'Decoding Firebase ID token failed. Make sure you passed the entire string JWT ' + - 'which represents an ID token.' + verifyIdTokenDocsMessage; - } else if (typeof header.kid === 'undefined') { - const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); - const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); - - if (isCustomToken) { - errorMessage = 'verifyIdToken() expects an ID token, but was given a custom token.'; - } else if (isLegacyCustomToken) { - errorMessage = 'verifyIdToken() expects an ID token, but was given a legacy custom token.'; - } else { - errorMessage = 'Firebase ID token has no "kid" claim.'; - } - - errorMessage += verifyIdTokenDocsMessage; - } else if (header.alg !== ALGORITHM) { - errorMessage = 'Firebase ID token has incorrect algorithm. Expected "' + ALGORITHM + '" but got ' + - '"' + header.alg + '".' + verifyIdTokenDocsMessage; - } else if (payload.aud !== this.certificate_.projectId) { - errorMessage = 'Firebase ID token has incorrect "aud" (audience) claim. Expected "' + - this.certificate_.projectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage + - verifyIdTokenDocsMessage; - } else if (payload.iss !== 'https://securetoken.google.com/' + this.certificate_.projectId) { - errorMessage = 'Firebase ID token has incorrect "iss" (issuer) claim. Expected ' + - '"https://securetoken.google.com/' + this.certificate_.projectId + '" but got "' + - payload.iss + '".' + projectIdMatchMessage + verifyIdTokenDocsMessage; - } else if (typeof payload.sub !== 'string') { - errorMessage = 'Firebase ID token has no "sub" (subject) claim.' + verifyIdTokenDocsMessage; - } else if (payload.sub === '') { - errorMessage = 'Firebase ID token has an empty string "sub" (subject) claim.' + verifyIdTokenDocsMessage; - } else if (payload.sub.length > 128) { - errorMessage = 'Firebase ID token has "sub" (subject) claim longer than 128 characters.' + - verifyIdTokenDocsMessage; - } - - if (typeof errorMessage !== 'undefined') { - return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } - - return this.fetchPublicKeys_().then((publicKeys) => { - if (!publicKeys.hasOwnProperty(header.kid)) { - return Promise.reject( - new FirebaseAuthError( - AuthClientErrorCode.INVALID_ARGUMENT, - 'Firebase ID token has "kid" claim which does not correspond to a known public key. ' + - 'Most likely the ID token is expired, so get a fresh token from your client app and ' + - 'try again.' + verifyIdTokenDocsMessage, - ), - ); - } - - return new Promise((resolve, reject) => { - jwt.verify(idToken, publicKeys[header.kid], { - algorithms: [ALGORITHM], - }, (error, decodedToken: any) => { - if (error) { - if (error.name === 'TokenExpiredError') { - errorMessage = 'Firebase ID token has expired. Get a fresh token from your client app and try ' + - 'again (auth/id-token-expired).' + verifyIdTokenDocsMessage; - } else if (error.name === 'JsonWebTokenError') { - errorMessage = 'Firebase ID token has invalid signature.' + verifyIdTokenDocsMessage; - } - - return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); - } else { - decodedToken.uid = decodedToken.sub; - resolve(decodedToken); - } - }); - }); - }); + return this.idTokenVerifier.verifyJWT(idToken); } + /** + * Verifies the format and signature of a Firebase session cookie JWT. + * + * @param {string} sessionCookie The Firebase session cookie to verify. + * @return {Promise} A promise fulfilled with the decoded claims of the Firebase session + * cookie. + */ + public verifySessionCookie(sessionCookie: string): Promise { + return this.sessionCookieVerifier.verifyJWT(sessionCookie); + } /** * Returns whether or not the provided developer claims are valid. @@ -252,62 +201,5 @@ export class FirebaseTokenGenerator { } return validator.isNonNullObject(developerClaims); } - - - /** - * Fetches the public keys for the Google certs. - * - * @return {Promise} A promise fulfilled with public keys for the Google certs. - */ - private fetchPublicKeys_(): Promise { - const publicKeysExist = (typeof this.publicKeys_ !== 'undefined'); - const publicKeysExpiredExists = (typeof this.publicKeysExpireAt_ !== 'undefined'); - const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt_); - if (publicKeysExist && publicKeysStillValid) { - return Promise.resolve(this.publicKeys_); - } - - return new Promise((resolve, reject) => { - https.get(CLIENT_CERT_URL, (res) => { - const buffers: Buffer[] = []; - - res.on('data', (buffer) => buffers.push(buffer as Buffer)); - - res.on('end', () => { - try { - const response = JSON.parse(Buffer.concat(buffers).toString()); - - if (response.error) { - let errorMessage = 'Error fetching public keys for Google certs: ' + response.error; - /* istanbul ignore else */ - if (response.error_description) { - errorMessage += ' (' + response.error_description + ')'; - } - - reject(new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage)); - } else { - /* istanbul ignore else */ - if (res.headers.hasOwnProperty('cache-control')) { - const cacheControlHeader: string = res.headers['cache-control'] as string; - const parts = cacheControlHeader.split(','); - parts.forEach((part) => { - const subParts = part.trim().split('='); - if (subParts[0] === 'max-age') { - const maxAge: number = +subParts[1]; - this.publicKeysExpireAt_ = Date.now() + (maxAge * 1000); - } - }); - } - - this.publicKeys_ = response; - resolve(response); - } - } catch (e) { - /* istanbul ignore next */ - reject(e); - } - }); - }).on('error', reject); - }); - } } + diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts new file mode 100644 index 0000000000..b801e0a229 --- /dev/null +++ b/src/auth/token-verifier.ts @@ -0,0 +1,286 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {AuthClientErrorCode, FirebaseAuthError} from '../utils/error'; + +import * as validator from '../utils/validator'; + +import * as jwt from 'jsonwebtoken'; + +// Use untyped import syntax for Node built-ins +import https = require('https'); + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; + +/** Interface that defines token related user facing information. */ +export interface FirebaseTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** JWT Expiration error code. */ + expiredErrorCode: string; +} + +/** + * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. + */ +export class FirebaseTokenVerifier { + private publicKeys: object; + private publicKeysExpireAt: number; + private shortNameArticle: string; + + constructor(private clientCertUrl: string, private algorithm: string, + private issuer: string, private projectId: string, + private tokenInfo: FirebaseTokenInfo) { + if (!validator.isURL(clientCertUrl)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided public client certificate URL is an invalid URL.`, + ); + } else if (!validator.isNonEmptyString(algorithm)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT algorithm is an empty string.`, + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT issuer is an invalid URL.`, + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT information is not an object or null.`, + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The provided JWT verification documentation URL is invalid.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT verify API name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public full name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT public short name must be a non-empty string.`, + ); + } else if (!validator.isNonEmptyString(tokenInfo.expiredErrorCode)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `The JWT expiration error code must be a non-empty string.`, + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + // For backward compatibility, the project ID is validated in the verification call. + } + + /** + * Verifies the format and signature of a Firebase Auth JWT token. + * + * @param {string} jwtToken The Firebase Auth JWT token to verify. + * @return {Promise} A promise fulfilled with the decoded claims of the Firebase Auth ID + * token. + */ + public verifyJWT(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + if (!validator.isNonEmptyString(this.projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + `${this.tokenInfo.verifyApiName} requires a certificate with "project_id" set.`, + ); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + const header = fullDecodedToken && fullDecodedToken.header; + const payload = fullDecodedToken && fullDecodedToken.payload; + + const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + + `Firebase project as the service account used to authenticate this SDK.`; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string; + if (!fullDecodedToken) { + errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + + `which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + } else if (typeof header.kid === 'undefined') { + const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); + const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); + + if (isCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a custom token.`; + } else if (isLegacyCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a legacy custom token.`; + } else { + errorMessage = 'Firebase ID token has no "kid" claim.'; + } + + errorMessage += verifyJwtTokenDocsMessage; + } else if (header.alg !== this.algorithm) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + this.algorithm + `" but got ` + + `"` + header.alg + `".` + verifyJwtTokenDocsMessage; + } else if (payload.aud !== this.projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + this.projectId + `" but got "` + payload.aud + `".` + projectIdMatchMessage + + verifyJwtTokenDocsMessage; + } else if (payload.iss !== this.issuer + this.projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + + `"${this.issuer}"` + this.projectId + `" but got "` + + payload.iss + `".` + projectIdMatchMessage + verifyJwtTokenDocsMessage; + } else if (typeof payload.sub !== 'string') { + errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub.length > 128) { + errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + + verifyJwtTokenDocsMessage; + } + if (typeof errorMessage !== 'undefined') { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } + + return this.fetchPublicKeys().then((publicKeys) => { + if (!publicKeys.hasOwnProperty(header.kid)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `${this.tokenInfo.jwtName} has "kid" claim which does not correspond to a known public key. ` + + `Most likely the ${this.tokenInfo.shortName} is expired, so get a fresh token from your ` + + `client app and try again.`, + ), + ); + } else { + return this.verifyJwtSignatureWithKey(jwtToken, publicKeys[header.kid]); + } + + }); + } + + /** + * Verifies the JWT signature using the provided public key. + * @param {string} jwtToken The JWT token to verify. + * @param {string} publicKey The public key certificate. + * @return {Promise} A promise that resolves with the decoded JWT claims on successful + * verification. + */ + private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { + let errorMessage: string; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + return new Promise((resolve, reject) => { + jwt.verify(jwtToken, publicKey, { + algorithms: [this.algorithm], + }, (error, decodedToken: any) => { + if (error) { + if (error.name === 'TokenExpiredError') { + errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh token from your client ` + + `app and try again (${this.tokenInfo.expiredErrorCode}).` + verifyJwtTokenDocsMessage; + } else if (error.name === 'JsonWebTokenError') { + errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + } + + return reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); + } else { + decodedToken.uid = decodedToken.sub; + resolve(decodedToken); + } + }); + }); + } + + /** + * Fetches the public keys for the Google certs. + * + * @return {Promise} A promise fulfilled with public keys for the Google certs. + */ + private fetchPublicKeys(): Promise { + const publicKeysExist = (typeof this.publicKeys !== 'undefined'); + const publicKeysExpiredExists = (typeof this.publicKeysExpireAt !== 'undefined'); + const publicKeysStillValid = (publicKeysExpiredExists && Date.now() < this.publicKeysExpireAt); + if (publicKeysExist && publicKeysStillValid) { + return Promise.resolve(this.publicKeys); + } + + return new Promise((resolve, reject) => { + https.get(this.clientCertUrl, (res) => { + const buffers: Buffer[] = []; + + res.on('data', (buffer) => buffers.push(buffer as Buffer)); + + res.on('end', () => { + try { + const response = JSON.parse(Buffer.concat(buffers).toString()); + + if (response.error) { + let errorMessage = 'Error fetching public keys for Google certs: ' + response.error; + /* istanbul ignore else */ + if (response.error_description) { + errorMessage += ' (' + response.error_description + ')'; + } + + reject(new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, errorMessage)); + } else { + /* istanbul ignore else */ + if (res.headers.hasOwnProperty('cache-control')) { + const cacheControlHeader: string = res.headers['cache-control'] as string; + const parts = cacheControlHeader.split(','); + parts.forEach((part) => { + const subParts = part.trim().split('='); + if (subParts[0] === 'max-age') { + const maxAge: number = +subParts[1]; + this.publicKeysExpireAt = Date.now() + (maxAge * 1000); + } + }); + } + + this.publicKeys = response; + resolve(response); + } + } catch (e) { + /* istanbul ignore next */ + reject(e); + } + }); + }).on('error', reject); + }); + } +} diff --git a/src/index.d.ts b/src/index.d.ts index ffa79b6292..a4d15a6202 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -200,6 +200,10 @@ declare namespace admin.auth { passwordSalt?: Buffer; } + interface SessionCookieOptions { + expiresIn: number; + } + interface Auth { app: admin.app.App; @@ -218,6 +222,14 @@ declare namespace admin.auth { users: admin.auth.UserImportRecord[], options?: admin.auth.UserImportOptions, ): Promise + createSessionCookie( + idToken: string, + sessionCookieOptions: admin.auth.SessionCookieOptions, + ): Promise; + verifySessionCookie( + sessionCookie: string, + checkForRevocation?: boolean, + ): Promise; } } diff --git a/src/utils/error.ts b/src/utils/error.ts index c20f7889b8..922a20e701 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -318,6 +318,10 @@ export class AuthClientErrorCode { code: 'claims-too-large', message: 'Developer claims maximum payload size exceeded.', }; + public static ID_TOKEN_EXPIRED = { + code: 'id-token-expired', + message: 'The provided Firebase ID token is expired.', + }; public static INVALID_ARGUMENT = { code: 'argument-error', message: 'Invalid argument provided.', @@ -330,6 +334,10 @@ export class AuthClientErrorCode { code: 'reserved-claim', message: 'The specified developer claim is reserved and cannot be specified.', }; + public static INVALID_ID_TOKEN = { + code: 'invalid-id-token', + message: 'The provided ID token is not a valid Firebase ID token.', + }; public static ID_TOKEN_REVOKED = { code: 'id-token-revoked', message: 'The Firebase ID token has been revoked.', @@ -436,6 +444,11 @@ export class AuthClientErrorCode { code: 'invalid-provider-id', message: 'The providerId must be a valid supported provider identifier string.', }; + public static INVALID_SESSION_COOKIE_DURATION = { + code: 'invalid-session-cookie-duration', + message: 'The session cookie duration must be a valid number in milliseconds ' + + 'between 5 minutes and 2 weeks.', + }; public static INVALID_UID = { code: 'invalid-uid', message: 'The uid must be a non-empty string with at most 128 characters.', @@ -482,6 +495,10 @@ export class AuthClientErrorCode { 'https://firebase.google.com/docs/admin/setup for details on how to authenticate this SDK ' + 'with appropriate permissions.', }; + public static SESSION_COOKIE_REVOKED = { + code: 'session-cookie-revoked', + message: 'The Firebase session cookie has been revoked.', + }; public static UID_ALREADY_EXISTS = { code: 'uid-already-exists', message: 'The user with the provided uid already exists.', @@ -627,8 +644,12 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { FORBIDDEN_CLAIM: 'FORBIDDEN_CLAIM', // Invalid claims provided. INVALID_CLAIMS: 'INVALID_CLAIMS', + // Invalid session cookie duration. + INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', // Invalid email provided. INVALID_EMAIL: 'INVALID_EMAIL', + // Invalid ID token provided. + INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', // Invalid page token. INVALID_PAGE_SELECTION: 'INVALID_PAGE_TOKEN', // Invalid phone number. @@ -645,6 +666,8 @@ const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', // Project not found. PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + // Token expired error. + TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', // User on which action is to be performed is not found. USER_NOT_FOUND: 'USER_NOT_FOUND', // Password provided is too weak. diff --git a/src/utils/validator.ts b/src/utils/validator.ts index 593415e7e6..a70aeb73c8 100644 --- a/src/utils/validator.ts +++ b/src/utils/validator.ts @@ -202,9 +202,9 @@ export function isURL(urlStr: any): boolean { if (!/^[a-zA-Z0-9]+[\w\-]*([\.]?[a-zA-Z0-9]+[\w\-]*)*$/.test(hostname)) { return false; } - // Allow for pathnames: (/chars+)* + // Allow for pathnames: (/chars+)*/? // Where chars can be a combination of: a-z A-Z 0-9 - _ . ~ ! $ & ' ( ) * + , ; = : @ % - const pathnameRe = /^(\/[\w\-\.\~\!\$\'\(\)\*\+\,\;\=\:\@\%]+)*$/; + const pathnameRe = /^(\/[\w\-\.\~\!\$\'\(\)\*\+\,\;\=\:\@\%]+)*\/?$/; // Validate pathname. if (pathname && pathname !== '/' && diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 386a0743d1..08f8364899 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -31,6 +31,7 @@ const expect = chai.expect; const newUserUid = generateRandomString(20); const nonexistentUid = generateRandomString(20); +const sessionCookieUid = generateRandomString(20); const testPhoneNumber = '+11234567890'; const testPhoneNumber2 = '+16505550101'; const nonexistentPhoneNumber = '+18888888888'; @@ -162,7 +163,7 @@ describe('admin.auth', () => { expect(listUsersResult.users[1].passwordHash.length).greaterThan(0); expect(listUsersResult.users[1].passwordSalt.length).greaterThan(0); }); - }).timeout(5000); + }); it('revokeRefreshTokens() invalidates existing sessions and ID tokens', () => { let currentIdToken: string = null; @@ -244,7 +245,7 @@ describe('admin.auth', () => { } } }); - }).timeout(5000); + }); it('updateUser() updates the user record with the given parameters', () => { const updatedDisplayName = 'Updated User ' + newUserUid; @@ -321,6 +322,107 @@ describe('admin.auth', () => { ]).should.eventually.be.fulfilled; }); + describe('createSessionCookie()', () => { + let expectedExp: number; + let expectedIat: number; + const expiresIn = 24 * 60 * 60 * 1000; + let payloadClaims: any; + let currentIdToken: string; + const uid = sessionCookieUid; + + it('creates a valid Firebase session cookie', () => { + return admin.auth().createCustomToken(uid, {admin: true, groupId: '1234'}) + .then((customToken) => firebase.auth().signInWithCustomToken(customToken)) + .then((user) => user.getIdToken()) + .then((idToken) => { + currentIdToken = idToken; + return admin.auth().verifyIdToken(idToken); + }).then((decodedIdTokenClaims) => { + expectedExp = Math.floor((new Date().getTime() + expiresIn) / 1000); + payloadClaims = decodedIdTokenClaims; + payloadClaims.iss = payloadClaims.iss.replace( + 'securetoken.google.com', 'session.firebase.google.com'); + delete payloadClaims.exp; + delete payloadClaims.iat; + expectedIat = Math.floor(new Date().getTime() / 1000); + // One day long session cookie. + return admin.auth().createSessionCookie(currentIdToken, {expiresIn}); + }) + .then((sessionCookie) => admin.auth().verifySessionCookie(sessionCookie)) + .then((decodedIdToken) => { + // Check for expected expiration with +/-5 seconds of variation. + expect(decodedIdToken.exp).to.be.within(expectedExp - 5, expectedExp + 5); + expect(decodedIdToken.iat).to.be.within(expectedIat - 5, expectedIat + 5); + // Not supported in ID token, + delete decodedIdToken.nonce; + // exp and iat may vary depending on network connection latency. + delete decodedIdToken.exp; + delete decodedIdToken.iat; + expect(decodedIdToken).to.deep.equal(payloadClaims); + }); + }); + + it('creates a revocable session cookie', () => { + let currentSessionCookie: string; + return admin.auth().createCustomToken(uid) + .then((customToken) => firebase.auth().signInWithCustomToken(customToken)) + .then((user) => user.getIdToken()) + .then((idToken) => { + // One day long session cookie. + return admin.auth().createSessionCookie(idToken, {expiresIn}); + }) + .then((sessionCookie) => { + currentSessionCookie = sessionCookie; + return new Promise((resolve) => setTimeout(() => resolve( + admin.auth().revokeRefreshTokens(uid), + ), 1000)); + }) + .then(() => { + return admin.auth().verifySessionCookie(currentSessionCookie) + .should.eventually.be.fulfilled; + }) + .then(() => { + return admin.auth().verifySessionCookie(currentSessionCookie, true) + .should.eventually.be.rejected.and.have.property('code', 'auth/session-cookie-revoked'); + }); + }); + + it('fails when called with a revoked ID token', () => { + return admin.auth().createCustomToken(uid, {admin: true, groupId: '1234'}) + .then((customToken) => firebase.auth().signInWithCustomToken(customToken)) + .then((user) => user.getIdToken()) + .then((idToken) => { + currentIdToken = idToken; + return new Promise((resolve) => setTimeout(() => resolve( + admin.auth().revokeRefreshTokens(uid), + ), 1000)); + }) + .then(() => { + return admin.auth().createSessionCookie(currentIdToken, {expiresIn}) + .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-expired'); + }); + }); + + }); + + describe('verifySessionCookie()', () => { + const uid = sessionCookieUid; + it('fails when called with an invalid session cookie', () => { + return admin.auth().verifySessionCookie('invalid-token') + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + + it('fails when called with a Firebase ID token', () => { + return admin.auth().createCustomToken(uid) + .then((customToken) => firebase.auth().signInWithCustomToken(customToken)) + .then((user) => user.getIdToken()) + .then((idToken) => { + return admin.auth().verifySessionCookie(idToken) + .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); + }); + }); + }); + describe('importUsers()', () => { const randomUid = 'import_' + generateRandomString(20).toLowerCase(); let importUserRecord; @@ -477,7 +579,7 @@ describe('admin.auth', () => { importUserRecord, fixture.importOptions, fixture.rawPassword) .should.eventually.be.fulfilled; - }).timeout(5000); + }); }); it('successfully imports users with multiple OAuth providers', () => { @@ -624,6 +726,8 @@ function cleanup() { deletePhoneNumberUser(nonexistentPhoneNumber), deletePhoneNumberUser(updatedPhone), ]; + // Delete user created for session cookie tests. + uids.push(sessionCookieUid); // Delete list of users for testing listUsers. uids.forEach((uid) => { // Use safeDelete to avoid getting throttled. diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index 465d1ff7f4..564a72f7b4 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -185,6 +185,28 @@ export function generateIdToken(overrides?: object): string { return jwt.sign(developerClaims, certificateObject.private_key, options); } +/** + * Generates a mocked Firebase session cookie. + * + * @param {object=} overrides Overrides for the generated token's attributes. + * @param {number=} expiresIn Optional custom session cookie expiration in seconds. + * @return {string} A mocked Firebase session cookie with any provided overrides included. + */ +export function generateSessionCookie(overrides?: object, expiresIn?: number): string { + const options = _.assign({ + audience: projectId, + expiresIn: expiresIn || ONE_HOUR_IN_SECONDS, + issuer: 'https://session.firebase.google.com/' + projectId, + subject: uid, + algorithm: ALGORITHM, + header: { + kid: certificateObject.private_key_id, + }, + }, overrides); + + return jwt.sign(developerClaims, certificateObject.private_key, options); +} + export function firebaseServiceFactory( firebaseApp: FirebaseApp, extendApp: (props: object) => void, diff --git a/test/unit/auth/auth-api-request.spec.ts b/test/unit/auth/auth-api-request.spec.ts index d3fcc56067..30b458a2c9 100644 --- a/test/unit/auth/auth-api-request.spec.ts +++ b/test/unit/auth/auth-api-request.spec.ts @@ -34,7 +34,7 @@ import { FirebaseAuthRequestHandler, FIREBASE_AUTH_GET_ACCOUNT_INFO, FIREBASE_AUTH_DELETE_ACCOUNT, FIREBASE_AUTH_SET_ACCOUNT_INFO, FIREBASE_AUTH_SIGN_UP_NEW_USER, FIREBASE_AUTH_DOWNLOAD_ACCOUNT, - RESERVED_CLAIMS, FIREBASE_AUTH_UPLOAD_ACCOUNT, + RESERVED_CLAIMS, FIREBASE_AUTH_UPLOAD_ACCOUNT, FIREBASE_AUTH_CREATE_SESSION_COOKIE, } from '../../../src/auth/auth-api-request'; import { UserImportBuilder, UserImportRecord, UserImportResult, UserImportOptions, @@ -65,6 +65,120 @@ function createRandomString(numOfChars: number): string { } +describe('FIREBASE_AUTH_CREATE_SESSION_COOKIE', () => { + // Spy on all validators. + let isNonEmptyString: sinon.SinonSpy; + let isNumber: sinon.SinonSpy; + + beforeEach(() => { + isNonEmptyString = sinon.spy(validator, 'isNonEmptyString'); + isNumber = sinon.spy(validator, 'isNumber'); + }); + afterEach(() => { + isNonEmptyString.restore(); + isNumber.restore(); + }); + + it('should return the correct endpoint', () => { + expect(FIREBASE_AUTH_CREATE_SESSION_COOKIE.getEndpoint()).to.equal('createSessionCookie'); + }); + it('should return the correct http method', () => { + expect(FIREBASE_AUTH_CREATE_SESSION_COOKIE.getHttpMethod()).to.equal('POST'); + }); + describe('requestValidator', () => { + const requestValidator = FIREBASE_AUTH_CREATE_SESSION_COOKIE.getRequestValidator(); + it('should succeed with valid parameters passed', () => { + const validRequest = {idToken: 'ID_TOKEN', validDuration: 60 * 60}; + expect(() => { + return requestValidator(validRequest); + }).not.to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith(60 * 60); + }); + it('should succeed with duration set at minimum allowed', () => { + const validDuration = 60 * 5; + const validRequest = {idToken: 'ID_TOKEN', validDuration}; + expect(() => { + return requestValidator(validRequest); + }).not.to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith(validDuration); + }); + it('should succeed with duration set at maximum allowed', () => { + const validDuration = 60 * 60 * 24 * 14; + const validRequest = {idToken: 'ID_TOKEN', validDuration}; + expect(() => { + return requestValidator(validRequest); + }).not.to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith(validDuration); + }); + it('should fail when idToken not passed', () => { + const invalidRequest = {validDuration: 60 * 60}; + expect(() => { + return requestValidator(invalidRequest); + }).to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith(undefined); + }); + it('should fail when validDuration not passed', () => { + const invalidRequest = {idToken: 'ID_TOKEN'}; + expect(() => { + return requestValidator(invalidRequest); + }).to.throw(); + expect(isNumber).to.have.been.calledOnce.and.calledWith(undefined); + }); + describe('called with invalid parameters', () => { + it('should fail with invalid idToken', () => { + expect(() => { + return requestValidator({idToken: '', validDuration: 60 * 60}); + }).to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith(''); + }); + it('should fail with invalid validDuration', () => { + expect(() => { + return requestValidator({idToken: 'ID_TOKEN', validDuration: 'invalid'}); + }).to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith('invalid'); + }); + it('should fail with validDuration less than minimum allowed', () => { + // Duration less 5 minutes. + const outOfBoundDuration = 60 * 5 - 1; + expect(() => { + return requestValidator({idToken: 'ID_TOKEN', validDuration: outOfBoundDuration}); + }).to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith(outOfBoundDuration); + }); + it('should fail with validDuration greater than maximum allowed', () => { + // Duration greater than 14 days. + const outOfBoundDuration = 60 * 60 * 24 * 14 + 1; + expect(() => { + return requestValidator({idToken: 'ID_TOKEN', validDuration: outOfBoundDuration}); + }).to.throw(); + expect(isNonEmptyString).to.have.been.calledOnce.and.calledWith('ID_TOKEN'); + expect(isNumber).to.have.been.calledOnce.and.calledWith(outOfBoundDuration); + }); + }); + }); + describe('responseValidator', () => { + const responseValidator = FIREBASE_AUTH_CREATE_SESSION_COOKIE.getResponseValidator(); + it('should succeed with sessionCookie returned', () => { + const validResponse = {sessionCookie: 'SESSION_COOKIE'}; + expect(() => { + return responseValidator(validResponse); + }).not.to.throw(); + }); + it('should fail when no session cookie is returned', () => { + const invalidResponse = {}; + expect(() => { + responseValidator(invalidResponse); + }).to.throw(); + }); + }); +}); + + describe('FIREBASE_AUTH_UPLOAD_ACCOUNT', () => { it('should return the correct endpoint', () => { expect(FIREBASE_AUTH_UPLOAD_ACCOUNT.getEndpoint()).to.equal('uploadAccount'); @@ -643,6 +757,148 @@ describe('FirebaseAuthRequestHandler', () => { }); }); + describe('createSessionCookie', () => { + const durationInMs = 24 * 60 * 60 * 1000; + const httpMethod = 'POST'; + const host = 'www.googleapis.com'; + const port = 443; + const path = '/identitytoolkit/v3/relyingparty/createSessionCookie'; + const timeout = 10000; + it('should be fulfilled given a valid localId', () => { + const expectedResult = { + sessionCookie: 'SESSION_COOKIE', + }; + const data = {idToken: 'ID_TOKEN', validDuration: durationInMs / 1000}; + + const stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest') + .returns(Promise.resolve(expectedResult)); + stubs.push(stub); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith( + host, port, path, httpMethod, data, expectedHeaders, timeout); + }); + }); + it('should be fulfilled given a duration equal to the maximum allowed', () => { + const expectedResult = { + sessionCookie: 'SESSION_COOKIE', + }; + const durationAtLimitInMs = 14 * 24 * 60 * 60 * 1000; + const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; + + const stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest') + .returns(Promise.resolve(expectedResult)); + stubs.push(stub); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith( + host, port, path, httpMethod, data, expectedHeaders, timeout); + }); + }); + it('should be fulfilled given a duration equal to the minimum allowed', () => { + const expectedResult = { + sessionCookie: 'SESSION_COOKIE', + }; + const durationAtLimitInMs = 5 * 60 * 1000; + const data = {idToken: 'ID_TOKEN', validDuration: durationAtLimitInMs / 1000}; + + const stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest') + .returns(Promise.resolve(expectedResult)); + stubs.push(stub); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', durationAtLimitInMs) + .then((result) => { + expect(result).to.deep.equal('SESSION_COOKIE'); + expect(stub).to.have.been.calledOnce.and.calledWith( + host, port, path, httpMethod, data, expectedHeaders, timeout); + }); + }); + it('should be rejected given an invalid ID token', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ID_TOKEN, + ); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('', durationInMs) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given an invalid duration', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', 'invalid' as any) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given a duration less than minimum allowed', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); + const outOfBoundDuration = 60 * 1000 * 5 - 1; + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected given a duration greater than maximum allowed', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION, + ); + const outOfBoundDuration = 60 * 60 * 1000 * 24 * 14 + 1; + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('ID_TOKEN', outOfBoundDuration) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + }); + }); + it('should be rejected when the backend returns an error', () => { + const expectedResult = { + error: { + message: 'INVALID_ID_TOKEN', + }, + }; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + const data = {idToken: 'invalid-token', validDuration: durationInMs / 1000}; + + const stub = sinon.stub(HttpRequestHandler.prototype, 'sendRequest') + .returns(Promise.resolve(expectedResult)); + stubs.push(stub); + + const requestHandler = new FirebaseAuthRequestHandler(mockApp); + return requestHandler.createSessionCookie('invalid-token', durationInMs) + .then((resp) => { + throw new Error('Unexpected success'); + }, (error) => { + expect(error).to.deep.equal(expectedError); + expect(stub).to.have.been.calledOnce.and.calledWith( + host, port, path, httpMethod, data, expectedHeaders, timeout); + }); + }); + }); + describe('getAccountInfoByEmail', () => { const httpMethod = 'POST'; const host = 'www.googleapis.com'; diff --git a/test/unit/auth/auth.spec.ts b/test/unit/auth/auth.spec.ts index a523da5eac..5e2e61d6fc 100644 --- a/test/unit/auth/auth.spec.ts +++ b/test/unit/auth/auth.spec.ts @@ -121,6 +121,29 @@ function getDecodedIdToken(uid: string, authTime: Date): DecodedIdToken { } +/** + * Generates a mock decoded session cookie with the provided parameters. + * + * @param {string} uid The uid corresponding to the session cookie. + * @param {Date} authTime The authentication time of the session cookie. + * @return {DecodedIdToken} The generated decoded session cookie. + */ +function getDecodedSessionCookie(uid: string, authTime: Date): DecodedIdToken { + return { + iss: 'https://session.firebase.google.com/project123456789', + aud: 'project123456789', + auth_time: Math.floor(authTime.getTime() / 1000), + sub: uid, + iat: Math.floor(authTime.getTime() / 1000), + exp: Math.floor(authTime.getTime() / 1000 + 3600), + firebase: { + identities: {}, + sign_in_provider: 'custom', + }, + }; +} + + describe('Auth', () => { let auth: Auth; let mockApp: FirebaseApp; @@ -142,6 +165,8 @@ describe('Auth', () => { rejectedPromiseAccessTokenAuth = new Auth(mocks.appRejectedWhileFetchingAccessToken()); oldProcessEnv = process.env; + // Project ID not set in the environment. + delete process.env.GCLOUD_PROJECT; }); afterEach(() => { @@ -423,6 +448,195 @@ describe('Auth', () => { }); }); + describe('verifySessionCookie()', () => { + let stub: sinon.SinonStub; + let mockSessionCookie: string; + const expectedUserRecord = getValidUserRecord(getValidGetAccountInfoResponse()); + // Set auth_time of token to expected user's tokensValidAfterTime. + const validSince = new Date(expectedUserRecord.tokensValidAfterTime); + // Set expected uid to expected user's. + const uid = expectedUserRecord.uid; + // Set expected decoded session cookie with expected UID and auth time. + const decodedSessionCookie = getDecodedSessionCookie(uid, validSince); + let clock; + + // Stubs used to simulate underlying api calls. + const stubs: sinon.SinonStub[] = []; + beforeEach(() => { + stub = sinon.stub(FirebaseTokenGenerator.prototype, 'verifySessionCookie') + .returns(Promise.resolve(decodedSessionCookie)); + stubs.push(stub); + mockSessionCookie = mocks.generateSessionCookie(); + clock = sinon.useFakeTimers(validSince.getTime()); + }); + afterEach(() => { + _.forEach(stubs, (s) => s.restore()); + clock.restore(); + }); + + it('should throw if a cert credential is not specified', () => { + const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + + expect(() => { + mockCredentialAuth.verifySessionCookie(mockSessionCookie); + }).to.throw('Must initialize app with a cert credential'); + }); + + it('should forward on the call to the token generator\'s verifySessionCookie() method', () => { + // Stub getUser call. + const getUserStub = sinon.stub(Auth.prototype, 'getUser'); + stubs.push(getUserStub); + return auth.verifySessionCookie(mockSessionCookie).then((result) => { + // Confirm getUser never called. + expect(getUserStub).not.to.have.been.called; + expect(result).to.deep.equal(decodedSessionCookie); + expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); + }); + }); + + it('should work with a non-cert credential when the GCLOUD_PROJECT environment variable is present', () => { + process.env.GCLOUD_PROJECT = mocks.projectId; + + const mockCredentialAuth = new Auth(mocks.mockCredentialApp()); + + return mockCredentialAuth.verifySessionCookie(mockSessionCookie).then(() => { + expect(stub).to.have.been.calledOnce.and.calledWith(mockSessionCookie); + }); + }); + + it('should be fulfilled given an app which returns null access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return nullAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled given an app which returns invalid access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return malformedAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled given an app which fails to generate access tokens', () => { + // verifySessionCookie() does not rely on an access token and therefore works in this scenario. + return rejectedPromiseAccessTokenAuth.verifySessionCookie(mockSessionCookie) + .should.eventually.be.fulfilled; + }); + + it('should be fulfilled with checkRevoked set to true using an unrevoked session cookie', () => { + const getUserStub = sinon.stub(Auth.prototype, 'getUser') + .returns(Promise.resolve(expectedUserRecord)); + stubs.push(getUserStub); + // Verify ID token while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedSessionCookie); + }); + }); + + it('should be rejected with checkRevoked set to true using a revoked session cookie', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate revoked session cookie returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenGenerator.prototype, 'verifySessionCookie') + .returns(Promise.resolve(getDecodedSessionCookie(uid, oneSecBeforeValidSince))); + stubs.push(stub); + const getUserStub = sinon.stub(Auth.prototype, 'getUser') + .returns(Promise.resolve(expectedUserRecord)); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.have.property('code', 'auth/session-cookie-revoked'); + }); + }); + + it('should be fulfilled with checkRevoked set to false using a revoked session cookie', () => { + // One second before validSince. + const oneSecBeforeValidSince = new Date(validSince.getTime() - 1000); + const oneSecBeforeValidSinceDecodedSessionCookie = + getDecodedSessionCookie(uid, oneSecBeforeValidSince); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate revoked session cookie returned with auth_time one second before validSince. + stub = sinon.stub(FirebaseTokenGenerator.prototype, 'verifySessionCookie') + .returns(Promise.resolve(oneSecBeforeValidSinceDecodedSessionCookie)); + stubs.push(stub); + // Verify session cookie without checking if revoked. + // This call should succeed. + return auth.verifySessionCookie(mockSessionCookie, false) + .then((result) => { + expect(result).to.deep.equal(oneSecBeforeValidSinceDecodedSessionCookie); + }); + }); + + it('should be rejected with checkRevoked set to true if underlying RPC fails', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + const getUserStub = sinon.stub(Auth.prototype, 'getUser') + .returns(Promise.reject(expectedError)); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + // This should fail with the underlying RPC error. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + + it('should be fulfilled with checkRevoked set to true when no validSince available', () => { + // Simulate no validSince set on the user. + const noValidSinceGetAccountInfoResponse = getValidGetAccountInfoResponse(); + delete (noValidSinceGetAccountInfoResponse.users[0] as any).validSince; + const noValidSinceExpectedUserRecord = + getValidUserRecord(noValidSinceGetAccountInfoResponse); + // Confirm null tokensValidAfterTime on user. + expect(noValidSinceExpectedUserRecord.tokensValidAfterTime).to.be.null; + // Simulate getUser returns the expected user with no validSince. + const getUserStub = sinon.stub(Auth.prototype, 'getUser') + .returns(Promise.resolve(noValidSinceExpectedUserRecord)); + stubs.push(getUserStub); + // Verify session cookie while checking if revoked. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(getUserStub).to.have.been.calledOnce.and.calledWith(uid); + expect(result).to.deep.equal(decodedSessionCookie); + }); + }); + + it('should be rejected with checkRevoked set to true using an invalid session cookie', () => { + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_CREDENTIAL); + // Restore verifySessionCookie stub. + stub.restore(); + // Simulate session cookie is invalid. + stub = sinon.stub(FirebaseTokenGenerator.prototype, 'verifySessionCookie') + .returns(Promise.reject(expectedError)); + stubs.push(stub); + // Verify session cookie while checking if revoked. + // This should fail with the underlying token generator verifySessionCookie error. + return auth.verifySessionCookie(mockSessionCookie, true) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + describe('getUser()', () => { const uid = 'abcdefghijklmnopqrstuvwxyz'; const expectedGetAccountInfoResult = getValidGetAccountInfoResponse(); @@ -1421,6 +1635,111 @@ describe('Auth', () => { }); }); + describe('createSessionCookie()', () => { + const idToken = 'ID_TOKEN'; + const options = {expiresIn: 60 * 60 * 24 * 1000}; + const sessionCookie = 'SESSION_COOKIE'; + const expectedError = new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + // Stubs used to simulate underlying api calls. + let stubs: sinon.SinonStub[] = []; + beforeEach(() => { + sinon.spy(validator, 'isNonEmptyString'); + }); + afterEach(() => { + (validator.isNonEmptyString as any).restore(); + _.forEach(stubs, (stub) => stub.restore()); + stubs = []; + }); + + it('should be rejected given no ID token', () => { + return (auth as any).createSessionCookie(undefined, options) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-id-token'); + }); + + it('should be rejected given an invalid ID token', () => { + const invalidIdToken = {} as any; + return auth.createSessionCookie(invalidIdToken, options) + .then(() => { + throw new Error('Unexpected success'); + }) + .catch((error) => { + expect(error).to.have.property('code', 'auth/invalid-id-token'); + expect(validator.isNonEmptyString).to.have.been.calledOnce.and.calledWith(invalidIdToken); + }); + }); + + it('should be rejected given no session duration', () => { + return (auth as any).createSessionCookie(idToken, undefined) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); + + it('should be rejected given an invalid session duration', () => { + // Invalid object. + const invalidOptions = {} as any; + return auth.createSessionCookie(idToken, invalidOptions) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); + + it('should be rejected given out of range session duration', () => { + // 1 minute duration. + const invalidOptions = {expiresIn: 60 * 1000}; + return auth.createSessionCookie(idToken, invalidOptions) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }); + + it('should be rejected given an app which returns null access tokens', () => { + return nullAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which returns invalid access tokens', () => { + return malformedAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should be rejected given an app which fails to generate access tokens', () => { + return rejectedPromiseAccessTokenAuth.createSessionCookie(idToken, options) + .should.eventually.be.rejected.and.have.property('code', 'app/invalid-credential'); + }); + + it('should resolve on underlying createSessionCookie request success', () => { + // Stub createSessionCookie to return expected sessionCookie. + const createSessionCookieStub = + sinon.stub(FirebaseAuthRequestHandler.prototype, 'createSessionCookie') + .returns(Promise.resolve(sessionCookie)); + stubs.push(createSessionCookieStub); + return auth.createSessionCookie(idToken, options) + .then((result) => { + // Confirm underlying API called with expected parameters. + expect(createSessionCookieStub) + .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); + // Confirm expected response returned. + expect(result).to.be.equal(sessionCookie); + }); + }); + + it('should throw when underlying createSessionCookie request returns an error', () => { + // Stub createSessionCookie to throw a backend error. + const createSessionCookieStub = + sinon.stub(FirebaseAuthRequestHandler.prototype, 'createSessionCookie') + .returns(Promise.reject(expectedError)); + stubs.push(createSessionCookieStub); + return auth.createSessionCookie(idToken, options) + .then((result) => { + throw new Error('Unexpected success'); + }, (error) => { + // Confirm underlying API called with expected parameters. + expect(createSessionCookieStub) + .to.have.been.calledOnce.and.calledWith(idToken, options.expiresIn); + // Confirm expected error returned. + expect(error).to.equal(expectedError); + }); + }); + }); + describe('INTERNAL.delete()', () => { it('should delete Auth instance', () => { auth.INTERNAL.delete().should.eventually.be.fulfilled; diff --git a/test/unit/auth/token-generator.spec.ts b/test/unit/auth/token-generator.spec.ts index 15d9bed73e..27d5f436f5 100644 --- a/test/unit/auth/token-generator.spec.ts +++ b/test/unit/auth/token-generator.spec.ts @@ -16,20 +16,20 @@ 'use strict'; -// Use untyped import syntax for Node built-ins -import https = require('https'); - import * as _ from 'lodash'; import * as jwt from 'jsonwebtoken'; import * as chai from 'chai'; -import * as nock from 'nock'; import * as sinon from 'sinon'; import * as sinonChai from 'sinon-chai'; import * as chaiAsPromised from 'chai-as-promised'; -import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); import * as mocks from '../../resources/mocks'; -import {FirebaseTokenGenerator} from '../../../src/auth/token-generator'; +import { + FirebaseTokenGenerator, SESSION_COOKIE_INFO, ID_TOKEN_INFO, +} from '../../../src/auth/token-generator'; +import * as verifier from '../../../src/auth/token-verifier'; +import {FirebaseAuthError, AuthClientErrorCode} from '../../../src/utils/error'; + import {Certificate} from '../../../src/auth/credential'; chai.should(); @@ -48,65 +48,6 @@ const BLACKLISTED_CLAIMS = [ ]; -/** - * Returns a mocked out success response from the URL containing the public keys for the Google certs. - * - * @return {Object} A nock response object. - */ -function mockFetchPublicKeys(): nock.Scope { - const mockedResponse = {}; - mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[0].public; - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .reply(200, mockedResponse, { - 'cache-control': 'public, max-age=1, must-revalidate, no-transform', - }); -} - -/** - * Returns a mocked out success response from the URL containing the public keys for the Google certs - * which contains a public key which won't match the mocked token. - * - * @return {Object} A nock response object. - */ -function mockFetchWrongPublicKeys(): nock.Scope { - const mockedResponse = {}; - mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[1].public; - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .reply(200, mockedResponse, { - 'cache-control': 'public, max-age=1, must-revalidate, no-transform', - }); -} - -/** - * Returns a mocked out error response from the URL containing the public keys for the Google certs. - * The status code is 200 but the response itself will contain an 'error' key. - * - * @return {Object} A nock response object. - */ -function mockFetchPublicKeysWithErrorResponse(): nock.Scope { - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .reply(200, { - error: 'message', - error_description: 'description', - }); -} - -/** - * Returns a mocked out failed response from the URL containing the public keys for the Google certs. - * The status code is non-200 and the response itself will fail. - * - * @return {Object} A nock response object. - */ -function mockFailedFetchPublicKeys(): nock.Scope { - return nock('https://www.googleapis.com') - .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') - .replyWithError('message'); -} - - /** * Verifies a token is signed with the private key corresponding to the provided public key. * @@ -133,10 +74,8 @@ describe('FirebaseTokenGenerator', () => { let tokenGenerator: FirebaseTokenGenerator; let clock: sinon.SinonFakeTimers; - let httpsSpy: sinon.SinonSpy; beforeEach(() => { tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); - httpsSpy = sinon.spy(https, 'get'); }); afterEach(() => { @@ -144,11 +83,6 @@ describe('FirebaseTokenGenerator', () => { clock.restore(); clock = undefined; } - httpsSpy.restore(); - }); - - after(() => { - nock.cleanAll(); }); describe('Constructor', () => { @@ -412,281 +346,143 @@ describe('FirebaseTokenGenerator', () => { }); }); - - describe('verifyIdToken()', () => { - let mockedRequests: nock.Scope[] = []; - - afterEach(() => { - _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); - mockedRequests = []; - }); - - it('should throw given no ID token', () => { - expect(() => { - (tokenGenerator as any).verifyIdToken(); - }).to.throw('First argument to verifyIdToken() must be a Firebase ID token'); - }); - - const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; - invalidIdTokens.forEach((invalidIdToken) => { - it('should throw given a non-string ID token: ' + JSON.stringify(invalidIdToken), () => { - expect(() => { - tokenGenerator.verifyIdToken(invalidIdToken as any); - }).to.throw('First argument to verifyIdToken() must be a Firebase ID token'); - }); - }); - - it('should throw given an empty string ID token', () => { - return tokenGenerator.verifyIdToken('') - .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); - }); - - it('should be rejected given an invalid ID token', () => { - return tokenGenerator.verifyIdToken('invalid-token') - .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); - }); - - it('should throw if the token generator was initialized with no "project_id"', () => { - const certificateObjectWithNoProjectId: any = _.omit(mocks.certificateObject, 'project_id'); - const tokenGeneratorWithNoProjectId = new FirebaseTokenGenerator(certificateObjectWithNoProjectId); - - const mockIdToken = mocks.generateIdToken(); - - expect(() => { - tokenGeneratorWithNoProjectId.verifyIdToken(mockIdToken); - }).to.throw('verifyIdToken() requires a certificate with "project_id" set'); - }); - - it('should be rejected given an ID token with no kid', () => { - const mockIdToken = mocks.generateIdToken({ - header: {foo: 'bar'}, - }); - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim'); - }); - - it('should be rejected given an ID token with a kid which does not match any of the actual public keys', () => { - mockedRequests.push(mockFetchPublicKeys()); - - const mockIdToken = mocks.generateIdToken({ - header: { - kid: 'wrongkid', - }, - }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has "kid" claim which does not ' + - 'correspond to a known public key'); - }); - - it('should be rejected given an ID token with an incorrect algorithm', () => { - const mockIdToken = mocks.generateIdToken({ - algorithm: 'HS256', - }); - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has incorrect algorithm'); - }); - - it('should be rejected given an ID token with an incorrect audience', () => { - const mockIdToken = mocks.generateIdToken({ - audience: 'incorrectAudience', - }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has incorrect "aud" (audience) claim'); - }); - - it('should be rejected given an ID token with an incorrect issuer', () => { - const mockIdToken = mocks.generateIdToken({ - issuer: 'incorrectIssuer', - }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has incorrect "iss" (issuer) claim'); - }); - - // TODO(jwenger): jsonwebtoken no longer allows the subject to be empty, so we need to find a - // new way to test this - xit('should be rejected given an ID token with an empty string subject', () => { - const mockIdToken = mocks.generateIdToken({ - subject: '', - }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has an empty string "sub" (subject) claim'); + describe('verifySessionCookie()', () => { + const sessionCookie = mocks.generateSessionCookie(); + const decodedSessionCookie = jwt.decode(sessionCookie); + let stubs: sinon.SinonStub[] = []; + let tokenVerifierConstructorStub: sinon.SinonStub; + let sessionCookieVerifier: any; + let idTokenVerifier: any; + + beforeEach(() => { + // Create stub instances to be used for session cookie verifier and id token verifier. + sessionCookieVerifier = sinon.createStubInstance(verifier.FirebaseTokenVerifier); + idTokenVerifier = sinon.createStubInstance(verifier.FirebaseTokenVerifier); + // Stub FirebaseTokenVerifier constructor to return stub instance above depending on + // issuer. + tokenVerifierConstructorStub = sinon.stub(verifier, 'FirebaseTokenVerifier') + .callsFake((certUrl, algorithm, issuer, projectId, tokenInfo) => { + // Return mock token verifier. + if (issuer === 'https://session.firebase.google.com/') { + return sessionCookieVerifier; + } else { + return idTokenVerifier; + } + }); + stubs.push(tokenVerifierConstructorStub); }); - // TODO(jwenger): jsonwebtoken no longer allows the subject to be a non-string, so we need to - // find a new way to test this - xit('should be rejected given an ID token with a non-string subject', () => { - const mockIdToken = mocks.generateIdToken({ - subject: 100, - }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has no "sub" (subject) claim'); + after(() => { + stubs = []; }); - it('should be rejected given an ID token with a subject with greater than 128 characters', () => { - mockedRequests.push(mockFetchPublicKeys()); - - // uid of length 128 should be fulfilled - let uid = Array(129).join('a'); - expect(uid).to.have.length(128); - let mockIdToken = mocks.generateIdToken({ - subject: uid, - }); - return tokenGenerator.verifyIdToken(mockIdToken).then(() => { - // uid of length 129 should be rejected - uid = Array(130).join('a'); - expect(uid).to.have.length(129); - mockIdToken = mocks.generateIdToken({ - subject: uid, + afterEach(() => { + // Confirm token verifiers initialized with expected arguments. + expect(tokenVerifierConstructorStub).to.have.been.calledTwice.and.calledWith( + 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', + ALGORITHM, + 'https://session.firebase.google.com/', + 'project_id', + SESSION_COOKIE_INFO); + expect(tokenVerifierConstructorStub).to.have.been.calledTwice.and.calledWith( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + ALGORITHM, + 'https://securetoken.google.com/', + 'project_id', + ID_TOKEN_INFO); + _.forEach(stubs, (stub) => stub.restore()); + }); + + it('resolves when underlying sessionCookieVerifier.verifyJWT() resolves with expected result', () => { + sessionCookieVerifier.verifyJWT.withArgs(sessionCookie).returns(Promise.resolve(decodedSessionCookie)); + + tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); + + return tokenGenerator.verifySessionCookie(sessionCookie) + .then((result) => { + expect(result).to.deep.equal(decodedSessionCookie); }); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has "sub" (subject) claim longer than 128 characters'); - }); - }); - - it('should be rejected given an expired ID token', () => { - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); - - const mockIdToken = mocks.generateIdToken(); - - clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); - - // Token should still be valid - return tokenGenerator.verifyIdToken(mockIdToken).then(() => { - clock.tick(1); - - // Token should now be invalid - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has expired. Get a fresh token from your client ' + - 'app and try again (auth/id-token-expired)'); - }); }); - it('should be rejected given an ID token which was not signed with the kid it specifies', () => { - mockedRequests.push(mockFetchWrongPublicKeys()); + it('rejects when underlying sessionCookieVerifier.verifyJWT() rejects with expected error', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase session cookie failed'); + sessionCookieVerifier.verifyJWT.withArgs(sessionCookie).returns(Promise.reject(expectedError)); - const mockIdToken = mocks.generateIdToken(); + tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); + return tokenGenerator.verifySessionCookie(sessionCookie) + .should.eventually.be.rejectedWith('Decoding Firebase session cookie failed'); }); + }); - it('should be rejected given a custom token', () => { - return tokenGenerator.createCustomToken(mocks.uid) - .then((customToken) => { - return tokenGenerator.verifyIdToken(customToken) - .should.eventually.be.rejectedWith('verifyIdToken() expects an ID token, but was given a custom token'); + describe('verifyIdToken()', () => { + const idToken = mocks.generateIdToken(); + const decodedIdToken = jwt.decode(idToken); + let stubs: sinon.SinonStub[] = []; + let tokenVerifierConstructorStub: sinon.SinonStub; + let sessionCookieVerifier: any; + let idTokenVerifier: any; + + beforeEach(() => { + // Create stub instances to be used for session cookie verifier and id token verifier. + sessionCookieVerifier = sinon.createStubInstance(verifier.FirebaseTokenVerifier); + idTokenVerifier = sinon.createStubInstance(verifier.FirebaseTokenVerifier); + // Stub FirebaseTokenVerifier constructor to return stub instance above depending on + // issuer. + tokenVerifierConstructorStub = sinon.stub(verifier, 'FirebaseTokenVerifier') + .callsFake((certUrl, algorithm, issuer, projectId, tokenInfo) => { + // Return mock token verifier. + if (issuer === 'https://session.firebase.google.com/') { + return sessionCookieVerifier; + } else { + return idTokenVerifier; + } }); + stubs.push(tokenVerifierConstructorStub); }); - it('should be rejected given a legacy custom token', () => { - const legacyTokenGenerator = new LegacyFirebaseTokenGenerator('foo'); - const legacyCustomToken = legacyTokenGenerator.createToken({ - uid: mocks.uid, - }); - - return tokenGenerator.verifyIdToken(legacyCustomToken) - .should.eventually.be.rejectedWith('verifyIdToken() expects an ID token, but was given a legacy custom token'); + after(() => { + stubs = []; }); - it('should be fulfilled with decoded claims given a valid ID token', () => { - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); - - const mockIdToken = mocks.generateIdToken(); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.fulfilled.and.deep.equal({ - one: 'uno', - two: 'dos', - iat: 1, - exp: ONE_HOUR_IN_SECONDS + 1, - aud: mocks.projectId, - iss: 'https://securetoken.google.com/' + mocks.projectId, - sub: mocks.uid, - uid: mocks.uid, + afterEach(() => { + // Confirm token verifiers initialized with expected arguments. + expect(tokenVerifierConstructorStub).to.have.been.calledTwice.and.calledWith( + 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', + ALGORITHM, + 'https://session.firebase.google.com/', + 'project_id', + SESSION_COOKIE_INFO); + expect(tokenVerifierConstructorStub).to.have.been.calledTwice.and.calledWith( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + ALGORITHM, + 'https://securetoken.google.com/', + 'project_id', + ID_TOKEN_INFO); + _.forEach(stubs, (stub) => stub.restore()); + }); + + it('resolves when underlying idTokenVerifier.verifyJWT() resolves with expected result', () => { + idTokenVerifier.verifyJWT.withArgs(idToken).returns(Promise.resolve(decodedIdToken)); + + tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); + + return tokenGenerator.verifyIdToken(idToken) + .then((result) => { + expect(result).to.deep.equal(decodedIdToken); }); }); - it('should not fetch the Google cert public keys until the first time verifyIdToken() is called', () => { - mockedRequests.push(mockFetchPublicKeys()); - - const anotherTokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); - expect(https.get).not.to.have.been.called; - - const mockIdToken = mocks.generateIdToken(); - - return anotherTokenGenerator.verifyIdToken(mockIdToken) - .then(() => expect(https.get).to.have.been.calledOnce); - }); - - it('should not re-fetch the Google cert public keys every time verifyIdToken() is called', () => { - mockedRequests.push(mockFetchPublicKeys()); + it('rejects when underlying idTokenVerifier.verifyJWT() rejects with expected error', () => { + const expectedError = new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, 'Decoding Firebase ID token failed'); + idTokenVerifier.verifyJWT.withArgs(idToken).returns(Promise.reject(expectedError)); - const mockIdToken = mocks.generateIdToken(); + tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); - return tokenGenerator.verifyIdToken(mockIdToken).then(() => { - expect(https.get).to.have.been.calledOnce; - return tokenGenerator.verifyIdToken(mockIdToken); - }).then(() => expect(https.get).to.have.been.calledOnce); - }); - - it('should refresh the Google cert public keys after the "max-age" on the request expires', () => { - mockedRequests.push(mockFetchPublicKeys()); - mockedRequests.push(mockFetchPublicKeys()); - mockedRequests.push(mockFetchPublicKeys()); - - clock = sinon.useFakeTimers(1000); - - const mockIdToken = mocks.generateIdToken(); - - return tokenGenerator.verifyIdToken(mockIdToken).then(() => { - expect(https.get).to.have.been.calledOnce; - clock.tick(999); - return tokenGenerator.verifyIdToken(mockIdToken); - }).then(() => { - expect(https.get).to.have.been.calledOnce; - clock.tick(1); - return tokenGenerator.verifyIdToken(mockIdToken); - }).then(() => { - // One second has passed - expect(https.get).to.have.been.calledTwice; - clock.tick(999); - return tokenGenerator.verifyIdToken(mockIdToken); - }).then(() => { - expect(https.get).to.have.been.calledTwice; - clock.tick(1); - return tokenGenerator.verifyIdToken(mockIdToken); - }).then(() => { - // Two seconds have passed - expect(https.get).to.have.been.calledThrice; - }); - }); - - it('should be rejected if fetching the Google public keys fails', () => { - mockedRequests.push(mockFailedFetchPublicKeys()); - - const mockIdToken = mocks.generateIdToken(); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('message'); - }); - - it('should be rejected if fetching the Google public keys returns a response with an error message', () => { - mockedRequests.push(mockFetchPublicKeysWithErrorResponse()); - - const mockIdToken = mocks.generateIdToken(); - - return tokenGenerator.verifyIdToken(mockIdToken) - .should.eventually.be.rejectedWith('Error fetching public keys for Google certs: message (description)'); + return tokenGenerator.verifyIdToken(idToken) + .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); }); }); }); diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts new file mode 100644 index 0000000000..f925f05d09 --- /dev/null +++ b/test/unit/auth/token-verifier.spec.ts @@ -0,0 +1,580 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +// Use untyped import syntax for Node built-ins +import https = require('https'); + +import * as _ from 'lodash'; +import * as jwt from 'jsonwebtoken'; +import * as chai from 'chai'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import LegacyFirebaseTokenGenerator = require('firebase-token-generator'); + +import * as mocks from '../../resources/mocks'; +import { + FirebaseTokenGenerator, SESSION_COOKIE_INFO, ID_TOKEN_INFO, +} from '../../../src/auth/token-generator'; +import {FirebaseTokenVerifier, FirebaseTokenInfo} from '../../../src/auth/token-verifier'; + +import {Certificate} from '../../../src/auth/credential'; + +chai.should(); +chai.use(sinonChai); +chai.use(chaiAsPromised); + +const expect = chai.expect; + + +const ONE_HOUR_IN_SECONDS = 60 * 60; + + +/** + * Returns a mocked out success response from the URL containing the public keys for the Google certs. + * + * @return {Object} A nock response object. + */ +function mockFetchPublicKeys(): nock.Scope { + const mockedResponse = {}; + mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[0].public; + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .reply(200, mockedResponse, { + 'cache-control': 'public, max-age=1, must-revalidate, no-transform', + }); +} + +/** + * Returns a mocked out success response from the URL containing the public keys for the Google certs + * which contains a public key which won't match the mocked token. + * + * @return {Object} A nock response object. + */ +function mockFetchWrongPublicKeys(): nock.Scope { + const mockedResponse = {}; + mockedResponse[mocks.certificateObject.private_key_id] = mocks.keyPairs[1].public; + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .reply(200, mockedResponse, { + 'cache-control': 'public, max-age=1, must-revalidate, no-transform', + }); +} + +/** + * Returns a mocked out error response from the URL containing the public keys for the Google certs. + * The status code is 200 but the response itself will contain an 'error' key. + * + * @return {Object} A nock response object. + */ +function mockFetchPublicKeysWithErrorResponse(): nock.Scope { + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .reply(200, { + error: 'message', + error_description: 'description', + }); +} + +/** + * Returns a mocked out failed response from the URL containing the public keys for the Google certs. + * The status code is non-200 and the response itself will fail. + * + * @return {Object} A nock response object. + */ +function mockFailedFetchPublicKeys(): nock.Scope { + return nock('https://www.googleapis.com') + .get('/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com') + .replyWithError('message'); +} + + +describe('FirebaseTokenVerifier', () => { + let tokenVerifier: FirebaseTokenVerifier; + let tokenGenerator: FirebaseTokenGenerator; + let clock: sinon.SinonFakeTimers; + let httpsSpy: sinon.SinonSpy; + beforeEach(() => { + // Needed to generate custom token for testing. + tokenGenerator = new FirebaseTokenGenerator(new Certificate(mocks.certificateObject)); + tokenVerifier = new FirebaseTokenVerifier( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + 'RS256', + 'https://securetoken.google.com/', + 'project_id', + ID_TOKEN_INFO, + ); + httpsSpy = sinon.spy(https, 'get'); + }); + + afterEach(() => { + if (clock) { + clock.restore(); + clock = undefined; + } + httpsSpy.restore(); + }); + + after(() => { + nock.cleanAll(); + }); + + describe('Constructor', () => { + it('should not throw when valid arguments are provided', () => { + expect(() => { + tokenVerifier = new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + { + url: 'https://docs.example.com/verify-tokens', + verifyApiName: 'verifyToken()', + jwtName: 'Important Token', + shortName: 'token', + expiredErrorCode: 'auth/important-token-expired', + }, + ); + }).not.to.throw(); + }); + + const invalidCertURLs = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, 'file://invalid']; + invalidCertURLs.forEach((invalidCertUrl) => { + it('should throw given a non-URL public cert: ' + JSON.stringify(invalidCertUrl), () => { + expect(() => { + new FirebaseTokenVerifier( + invalidCertUrl as any, + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + ID_TOKEN_INFO); + }).to.throw('The provided public client certificate URL is an invalid URL.'); + }); + }); + + const invalidAlgorithms = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; + invalidAlgorithms.forEach((invalidAlgorithm) => { + it('should throw given an invalid algorithm: ' + JSON.stringify(invalidAlgorithm), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + invalidAlgorithm as any, + 'https://www.example.com/issuer/', + 'project_id', + ID_TOKEN_INFO); + }).to.throw('The provided JWT algorithm is an empty string.'); + }); + }); + + const invalidIssuers = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, 'file://invalid']; + invalidIssuers.forEach((invalidIssuer) => { + it('should throw given a non-URL issuer: ' + JSON.stringify(invalidIssuer), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + invalidIssuer as any, + 'project_id', + ID_TOKEN_INFO); + }).to.throw('The provided JWT issuer is an invalid URL.'); + }); + }); + + const invalidVerifyApiNames = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; + invalidVerifyApiNames.forEach((invalidVerifyApiName) => { + it('should throw given an invalid verify API name: ' + JSON.stringify(invalidVerifyApiName), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + { + url: 'https://docs.example.com/verify-tokens', + verifyApiName: invalidVerifyApiName as any, + jwtName: 'Important Token', + shortName: 'token', + expiredErrorCode: 'auth/important-token-expired', + }); + }).to.throw('The JWT verify API name must be a non-empty string.'); + }); + }); + + const invalidJwtNames = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; + invalidJwtNames.forEach((invalidJwtName) => { + it('should throw given an invalid JWT full name: ' + JSON.stringify(invalidJwtName), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + { + url: 'https://docs.example.com/verify-tokens', + verifyApiName: 'verifyToken()', + jwtName: invalidJwtName as any, + shortName: 'token', + expiredErrorCode: 'auth/important-token-expired', + }); + }).to.throw('The JWT public full name must be a non-empty string.'); + }); + }); + + const invalidShortNames = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; + invalidShortNames.forEach((invalidShortName) => { + it('should throw given an invalid JWT short name: ' + JSON.stringify(invalidShortName), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + { + url: 'https://docs.example.com/verify-tokens', + verifyApiName: 'verifyToken()', + jwtName: 'Important Token', + shortName: invalidShortName as any, + expiredErrorCode: 'auth/important-token-expired', + }); + }).to.throw('The JWT public short name must be a non-empty string.'); + }); + }); + + const invalidExpiredErrorCodes = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop, '']; + invalidExpiredErrorCodes.forEach((invalidExpiredErrorCode) => { + it('should throw given an invalid expiration error code: ' + JSON.stringify(invalidExpiredErrorCode), () => { + expect(() => { + new FirebaseTokenVerifier( + 'https://www.example.com/publicKeys', + 'RS256', + 'https://www.example.com/issuer/', + 'project_id', + { + url: 'https://docs.example.com/verify-tokens', + verifyApiName: 'verifyToken()', + jwtName: 'Important Token', + shortName: 'token', + expiredErrorCode: invalidExpiredErrorCode as any, + }); + }).to.throw('The JWT expiration error code must be a non-empty string.'); + }); + }); + }); + + describe('verifyJWT()', () => { + let mockedRequests: nock.Scope[] = []; + + afterEach(() => { + _.forEach(mockedRequests, (mockedRequest) => mockedRequest.done()); + mockedRequests = []; + }); + + it('should throw given no Firebase JWT token', () => { + expect(() => { + (tokenVerifier as any).verifyJWT(); + }).to.throw('First argument to verifyIdToken() must be a Firebase ID token'); + }); + + const invalidIdTokens = [null, NaN, 0, 1, true, false, [], {}, { a: 1 }, _.noop]; + invalidIdTokens.forEach((invalidIdToken) => { + it('should throw given a non-string Firebase JWT token: ' + JSON.stringify(invalidIdToken), () => { + expect(() => { + tokenVerifier.verifyJWT(invalidIdToken as any); + }).to.throw('First argument to verifyIdToken() must be a Firebase ID token'); + }); + }); + + it('should throw given an empty string Firebase JWT token', () => { + return tokenVerifier.verifyJWT('') + .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); + }); + + it('should be rejected given an invalid Firebase JWT token', () => { + return tokenVerifier.verifyJWT('invalid-token') + .should.eventually.be.rejectedWith('Decoding Firebase ID token failed'); + }); + + it('should throw if the token verifier was initialized with no "project_id"', () => { + const tokenVerifierWithNoProjectId = new FirebaseTokenVerifier( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + 'RS256', + 'https://securetoken.google.com/', + undefined as any, + ID_TOKEN_INFO, + ); + const mockIdToken = mocks.generateIdToken(); + + expect(() => { + tokenVerifierWithNoProjectId.verifyJWT(mockIdToken); + }).to.throw('verifyIdToken() requires a certificate with "project_id" set'); + }); + + it('should be rejected given a Firebase JWT token with no kid', () => { + const mockIdToken = mocks.generateIdToken({ + header: {foo: 'bar'}, + }); + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has no "kid" claim'); + }); + + it('should be rejected given a Firebase JWT token with a kid which does not match any of the ' + + 'actual public keys', () => { + mockedRequests.push(mockFetchPublicKeys()); + + const mockIdToken = mocks.generateIdToken({ + header: { + kid: 'wrongkid', + }, + }); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has "kid" claim which does not ' + + 'correspond to a known public key'); + }); + + it('should be rejected given a Firebase JWT token with an incorrect algorithm', () => { + const mockIdToken = mocks.generateIdToken({ + algorithm: 'HS256', + }); + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has incorrect algorithm'); + }); + + it('should be rejected given a Firebase JWT token with an incorrect audience', () => { + const mockIdToken = mocks.generateIdToken({ + audience: 'incorrectAudience', + }); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has incorrect "aud" (audience) claim'); + }); + + it('should be rejected given a Firebase JWT token with an incorrect issuer', () => { + const mockIdToken = mocks.generateIdToken({ + issuer: 'incorrectIssuer', + }); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has incorrect "iss" (issuer) claim'); + }); + + it('should be rejected given a Firebase JWT token with a subject with greater than 128 characters', () => { + mockedRequests.push(mockFetchPublicKeys()); + + // uid of length 128 should be fulfilled + let uid = Array(129).join('a'); + expect(uid).to.have.length(128); + let mockIdToken = mocks.generateIdToken({ + subject: uid, + }); + return tokenVerifier.verifyJWT(mockIdToken).then(() => { + // uid of length 129 should be rejected + uid = Array(130).join('a'); + expect(uid).to.have.length(129); + mockIdToken = mocks.generateIdToken({ + subject: uid, + }); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has "sub" (subject) claim longer than 128 characters'); + }); + }); + + it('should be rejected given an expired Firebase JWT token', () => { + mockedRequests.push(mockFetchPublicKeys()); + + clock = sinon.useFakeTimers(1000); + + const mockIdToken = mocks.generateIdToken(); + + clock.tick((ONE_HOUR_IN_SECONDS * 1000) - 1); + + // Token should still be valid + return tokenVerifier.verifyJWT(mockIdToken).then(() => { + clock.tick(1); + + // Token should now be invalid + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has expired. Get a fresh token from your client ' + + 'app and try again (auth/id-token-expired)'); + }); + }); + + it('should be rejected given a Firebase JWT token which was not signed with the kid it specifies', () => { + mockedRequests.push(mockFetchWrongPublicKeys()); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); + }); + + it('should be rejected given a custom token with error using article "an" before JWT short name', () => { + return tokenGenerator.createCustomToken(mocks.uid) + .then((customToken) => { + return tokenVerifier.verifyJWT(customToken) + .should.eventually.be.rejectedWith('verifyIdToken() expects an ID token, but was given a custom token'); + }); + }); + + it('should be rejected given a custom token with error using article "a" before JWT short name', () => { + const tokenVerifierSessionCookie = new FirebaseTokenVerifier( + 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', + 'RS256', + 'https://session.firebase.google.com/', + 'project_id', + SESSION_COOKIE_INFO, + ); + return tokenGenerator.createCustomToken(mocks.uid) + .then((customToken) => { + return tokenVerifierSessionCookie.verifyJWT(customToken) + .should.eventually.be.rejectedWith( + 'verifySessionCookie() expects a session cookie, but was given a custom token'); + }); + }); + + it('should be rejected given a legacy custom token with error using article "an" before JWT short name', () => { + const legacyTokenGenerator = new LegacyFirebaseTokenGenerator('foo'); + const legacyCustomToken = legacyTokenGenerator.createToken({ + uid: mocks.uid, + }); + + return tokenVerifier.verifyJWT(legacyCustomToken) + .should.eventually.be.rejectedWith('verifyIdToken() expects an ID token, but was given a legacy custom token'); + }); + + it('should be rejected given a legacy custom token with error using article "a" before JWT short name', () => { + const tokenVerifierSessionCookie = new FirebaseTokenVerifier( + 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys', + 'RS256', + 'https://session.firebase.google.com/', + 'project_id', + SESSION_COOKIE_INFO, + ); + const legacyTokenGenerator = new LegacyFirebaseTokenGenerator('foo'); + const legacyCustomToken = legacyTokenGenerator.createToken({ + uid: mocks.uid, + }); + + return tokenVerifierSessionCookie.verifyJWT(legacyCustomToken) + .should.eventually.be.rejectedWith( + 'verifySessionCookie() expects a session cookie, but was given a legacy custom token'); + }); + + it('should be fulfilled with decoded claims given a valid Firebase JWT token', () => { + mockedRequests.push(mockFetchPublicKeys()); + + clock = sinon.useFakeTimers(1000); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.fulfilled.and.deep.equal({ + one: 'uno', + two: 'dos', + iat: 1, + exp: ONE_HOUR_IN_SECONDS + 1, + aud: mocks.projectId, + iss: 'https://securetoken.google.com/' + mocks.projectId, + sub: mocks.uid, + uid: mocks.uid, + }); + }); + + it('should not fetch the Google cert public keys until the first time verifyJWT() is called', () => { + mockedRequests.push(mockFetchPublicKeys()); + + const testTokenVerifier = new FirebaseTokenVerifier( + 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com', + 'RS256', + 'https://securetoken.google.com/', + 'project_id', + ID_TOKEN_INFO, + ); + expect(https.get).not.to.have.been.called; + + const mockIdToken = mocks.generateIdToken(); + + return testTokenVerifier.verifyJWT(mockIdToken) + .then(() => expect(https.get).to.have.been.calledOnce); + }); + + it('should not re-fetch the Google cert public keys every time verifyJWT() is called', () => { + mockedRequests.push(mockFetchPublicKeys()); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken).then(() => { + expect(https.get).to.have.been.calledOnce; + return tokenVerifier.verifyJWT(mockIdToken); + }).then(() => expect(https.get).to.have.been.calledOnce); + }); + + it('should refresh the Google cert public keys after the "max-age" on the request expires', () => { + mockedRequests.push(mockFetchPublicKeys()); + mockedRequests.push(mockFetchPublicKeys()); + mockedRequests.push(mockFetchPublicKeys()); + + clock = sinon.useFakeTimers(1000); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken).then(() => { + expect(https.get).to.have.been.calledOnce; + clock.tick(999); + return tokenVerifier.verifyJWT(mockIdToken); + }).then(() => { + expect(https.get).to.have.been.calledOnce; + clock.tick(1); + return tokenVerifier.verifyJWT(mockIdToken); + }).then(() => { + // One second has passed + expect(https.get).to.have.been.calledTwice; + clock.tick(999); + return tokenVerifier.verifyJWT(mockIdToken); + }).then(() => { + expect(https.get).to.have.been.calledTwice; + clock.tick(1); + return tokenVerifier.verifyJWT(mockIdToken); + }).then(() => { + // Two seconds have passed + expect(https.get).to.have.been.calledThrice; + }); + }); + + it('should be rejected if fetching the Google public keys fails', () => { + mockedRequests.push(mockFailedFetchPublicKeys()); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('message'); + }); + + it('should be rejected if fetching the Google public keys returns a response with an error message', () => { + mockedRequests.push(mockFetchPublicKeysWithErrorResponse()); + + const mockIdToken = mocks.generateIdToken(); + + return tokenVerifier.verifyJWT(mockIdToken) + .should.eventually.be.rejectedWith('Error fetching public keys for Google certs: message (description)'); + }); + }); +}); + diff --git a/test/unit/index.spec.ts b/test/unit/index.spec.ts index 1a1a1c3ab4..d000c90c53 100644 --- a/test/unit/index.spec.ts +++ b/test/unit/index.spec.ts @@ -30,6 +30,7 @@ import './auth/auth.spec'; import './auth/credential.spec'; import './auth/user-record.spec'; import './auth/token-generator.spec'; +import './auth/token-verifier.spec'; import './auth/auth-api-request.spec'; import './auth/user-import-builder.spec'; diff --git a/test/unit/utils/validator.spec.ts b/test/unit/utils/validator.spec.ts index d768145acf..00a0ea6c2b 100644 --- a/test/unit/utils/validator.spec.ts +++ b/test/unit/utils/validator.spec.ts @@ -382,6 +382,7 @@ describe('isURL()', () => { it('show return true with a valid web URL string', () => { expect(isURL('https://www.example.com:8080')).to.be.true; expect(isURL('https://www.example.com')).to.be.true; + expect(isURL('http://localhost/path/name/')).to.be.true; expect(isURL('https://www.example.com:8080/path/name/index.php?a=1&b=2&c=3#abcd')) .to.be.true; expect(isURL('http://www.example.com:8080/path/name/index.php?a=1&b=2&c=3#abcd'))