From 10c8866bbccd6aee88184445987557d1557f0edb Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Wed, 28 Mar 2018 13:21:01 -0700 Subject: [PATCH 1/4] Adds support for Firebase Auth session management. This adds 2 new APIs: admin.auth().createSessionCookie(idToken: string, sessionCookieOptions: SessionCookieOptions): Promise admin.auth().verifySessionCookie(sessionCookie: string, checkRevoked?: boolean): Promise Refactored token generator and split token verification to a new class FirebaseTokenVerifier so it can be used to also verify session cookies. Kept the same error handling for backward compatibility. In the process, ported the same tests to token verifier. Updated token generator ID token and session cookie verification to check token verifier is called underneath with the expected parameters. Added integration tests to test all common flows for session cookie creation and verification. Added mocks for session cookie JWTs. Fixed error in URL validator. --- package.json | 2 +- src/auth/auth-api-request.ts | 51 +++ src/auth/auth.ts | 110 ++++- src/auth/token-generator.ts | 211 +++------ src/auth/token-verifier.ts | 283 ++++++++++++ src/index.d.ts | 13 + src/utils/error.ts | 23 + src/utils/validator.ts | 4 +- test/integration/auth.spec.ts | 115 +++++ test/resources/mocks.ts | 22 + test/unit/auth/auth-api-request.spec.ts | 258 ++++++++++- test/unit/auth/auth.spec.ts | 319 ++++++++++++++ test/unit/auth/token-generator.spec.ts | 448 ++++++------------- test/unit/auth/token-verifier.spec.ts | 546 ++++++++++++++++++++++++ test/unit/index.spec.ts | 1 + test/unit/utils/validator.spec.ts | 1 + 16 files changed, 1900 insertions(+), 507 deletions(-) create mode 100644 src/auth/token-verifier.ts create mode 100644 test/unit/auth/token-verifier.spec.ts diff --git a/package.json b/package.json index 523d52df5f..eda678ae6b 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..dd9a3fb7e8 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 (!response.sessionCookie || !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. + * + * @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..833e0221fa 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -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,13 @@ export interface DecodedIdToken { } +/** Interface representing the session cookie options. */ +export interface SessionCookieOptions { + expiresIn: number; + refreshThreshold?: number; +} + + /** * Auth service bound to the provided app. */ @@ -171,23 +178,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, + new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_REVOKED)); }); } @@ -371,4 +364,85 @@ 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 this.authRequestHandler.createSessionCookie( + idToken, sessionCookieOptions && 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 applied. + * + * @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, + new FirebaseAuthError(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 {FirebaseAuthError} revocationError The revocation error to throw on revocation + * detection. + * @return {Promise} A Promise that will be fulfilled after a successful + * verification. + */ + private verifyDecodedJWTNotRevoked( + decodedIdToken: DecodedIdToken, revocationError: FirebaseAuthError): 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 revocationError; + } + } + // 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 c84a32c7f9..19cd018fc3 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'; @@ -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,33 @@ interface JWTPayload { uid?: string; } +/** User facing token information related to the Firebase session cookie. */ +export const SESSION_COOKIE_INFO: tokenVerify.FirebaseTokenInfo = { + // Placeholder until documentation page is created for session cookies. + url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + 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 +86,20 @@ export class FirebaseTokenGenerator { ); } this.certificate_ = certificate; + this.sessionCookieVerifier = new tokenVerify.FirebaseTokenVerifier( + SESSION_COOKIE_CERT_URL, + ALGORITHM, + 'https://session.firebase.google.com/', + this.certificate_.projectId, + SESSION_COOKIE_INFO, + ); + this.idTokenVerifier = new tokenVerify.FirebaseTokenVerifier( + CLIENT_CERT_URL, + ALGORITHM, + 'https://securetoken.google.com/', + this.certificate_.projectId, + ID_TOKEN_INFO, + ); } /** @@ -138,107 +176,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. @@ -257,62 +207,5 @@ export class FirebaseTokenGenerator { return false; } - - - /** - * 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..4f35baaa3a --- /dev/null +++ b/src/auth/token-verifier.ts @@ -0,0 +1,283 @@ +/*! + * 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; + + 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.`, + ); + } + // 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 (typeof jwtToken !== 'string') { + 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 an ${this.tokenInfo.shortName}.`; + + let errorMessage: string; + if (!fullDecodedToken) { + errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + + `which represents an ${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 an ${this.tokenInfo.shortName}, but ` + + `was given a custom token.`; + } else if (isLegacyCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects an ${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 an ${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 1bacf55fe8..a4e31857c8 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -200,6 +200,11 @@ declare namespace admin.auth { passwordSalt?: Buffer; } + interface SessionCookieOptions { + expiresIn: number; + refreshThreshold?: number; + } + interface Auth { app: admin.app.App; @@ -218,6 +223,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 ee43110c85..de00ac6b4c 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..1039553001 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'; @@ -321,6 +322,118 @@ 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 +/-2 seconds of variation. + expect(decodedIdToken.exp).to.be.within(expectedExp - 2, expectedExp + 2); + expect(decodedIdToken.iat).to.be.within(expectedIat - 2, expectedIat + 2); + // 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); + }); + }).timeout(5000); + + 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'); + }); + }).timeout(5000); + + it('fails when called with an invalid ID token', () => { + return admin.auth().createSessionCookie('invalid-token', {expiresIn}) + .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-id-token'); + }).timeout(5000); + + it('fails when called with an invalid duration', () => { + return admin.auth().createSessionCookie('invalid-token', {expiresIn: 60 * 1000}) + .should.eventually.be.rejected.and.have.property( + 'code', 'auth/invalid-session-cookie-duration'); + }).timeout(5000); + + 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'); + }); + }).timeout(5000); + + }); + + 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; @@ -624,6 +737,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..5ce3fe5ddf --- /dev/null +++ b/test/unit/auth/token-verifier.spec.ts @@ -0,0 +1,546 @@ +/*! + * 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', () => { + 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 legacy custom token', () => { + 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 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')) From 377a3597ccd47ddf45820adde54e06fe429b6a5e Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Fri, 30 Mar 2018 12:41:47 -0700 Subject: [PATCH 2/4] Addresses review comments. --- src/auth/auth-api-request.ts | 4 +-- src/auth/auth.ts | 22 +++++++++------- src/auth/token-generator.ts | 8 +++--- src/auth/token-verifier.ts | 19 ++++++++------ src/index.d.ts | 1 - test/integration/auth.spec.ts | 29 +++++++------------- test/unit/auth/token-verifier.spec.ts | 38 +++++++++++++++++++++++++-- 7 files changed, 75 insertions(+), 46 deletions(-) diff --git a/src/auth/auth-api-request.ts b/src/auth/auth-api-request.ts index dd9a3fb7e8..1493b5b96c 100644 --- a/src/auth/auth-api-request.ts +++ b/src/auth/auth-api-request.ts @@ -312,7 +312,7 @@ export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = // Set response validator. .setResponseValidator((response: any) => { // Response should always contain the session cookie. - if (!response.sessionCookie || !validator.isNonEmptyString(response.sessionCookie)) { + if (!validator.isNonEmptyString(response.sessionCookie)) { throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); } }); @@ -458,7 +458,7 @@ export class FirebaseAuthRequestHandler { * 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. + * @param {number} expiresIn The session cookie duration in milliseconds. * * @return {Promise} A promise that resolves on success with the created session cookie. */ diff --git a/src/auth/auth.ts b/src/auth/auth.ts index 833e0221fa..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, @@ -73,7 +73,6 @@ export interface DecodedIdToken { /** Interface representing the session cookie options. */ export interface SessionCookieOptions { expiresIn: number; - refreshThreshold?: number; } @@ -180,7 +179,7 @@ export class Auth implements FirebaseServiceInterface { } return this.verifyDecodedJWTNotRevoked( decodedIdToken, - new FirebaseAuthError(AuthClientErrorCode.ID_TOKEN_REVOKED)); + AuthClientErrorCode.ID_TOKEN_REVOKED); }); } @@ -378,8 +377,13 @@ export class Auth implements FirebaseServiceInterface { */ 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 && sessionCookieOptions.expiresIn); + idToken, sessionCookieOptions.expiresIn); } /** @@ -387,7 +391,7 @@ export class Auth implements FirebaseServiceInterface { * 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 applied. + * 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. @@ -411,7 +415,7 @@ export class Auth implements FirebaseServiceInterface { } return this.verifyDecodedJWTNotRevoked( decodedIdToken, - new FirebaseAuthError(AuthClientErrorCode.SESSION_COOKIE_REVOKED)); + AuthClientErrorCode.SESSION_COOKIE_REVOKED); }); } @@ -420,13 +424,13 @@ export class Auth implements FirebaseServiceInterface { * with the decoded claims on success. Rejects the promise with revocation error if revoked. * * @param {DecodedIdToken} decodedIdToken The JWT's decoded claims. - * @param {FirebaseAuthError} revocationError The revocation error to throw on revocation + * @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, revocationError: FirebaseAuthError): Promise { + decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { // Get tokens valid after time for the corresponding user. return this.getUser(decodedIdToken.sub) .then((user: UserRecord) => { @@ -438,7 +442,7 @@ export class Auth implements FirebaseServiceInterface { const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); // Check if authentication time is older than valid since time. if (authTimeUtc < validSinceUtc) { - throw revocationError; + throw new FirebaseAuthError(revocationErrorInfo); } } // All checks above passed. Return the decoded token. diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 19cd018fc3..26e1136beb 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -26,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 @@ -88,14 +88,14 @@ export class FirebaseTokenGenerator { this.certificate_ = certificate; this.sessionCookieVerifier = new tokenVerify.FirebaseTokenVerifier( SESSION_COOKIE_CERT_URL, - ALGORITHM, + ALGORITHM_RS256, 'https://session.firebase.google.com/', this.certificate_.projectId, SESSION_COOKIE_INFO, ); this.idTokenVerifier = new tokenVerify.FirebaseTokenVerifier( CLIENT_CERT_URL, - ALGORITHM, + ALGORITHM_RS256, 'https://securetoken.google.com/', this.certificate_.projectId, ID_TOKEN_INFO, @@ -162,7 +162,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); diff --git a/src/auth/token-verifier.ts b/src/auth/token-verifier.ts index 4f35baaa3a..b801e0a229 100644 --- a/src/auth/token-verifier.ts +++ b/src/auth/token-verifier.ts @@ -46,6 +46,7 @@ export interface FirebaseTokenInfo { 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, @@ -96,6 +97,8 @@ export class FirebaseTokenVerifier { `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. } @@ -107,7 +110,7 @@ export class FirebaseTokenVerifier { * token. */ public verifyJWT(jwtToken: string): Promise { - if (typeof jwtToken !== 'string') { + if (!validator.isString(jwtToken)) { throw new FirebaseAuthError( AuthClientErrorCode.INVALID_ARGUMENT, `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, @@ -131,22 +134,22 @@ export class FirebaseTokenVerifier { 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 an ${this.tokenInfo.shortName}.`; + `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 an ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + `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 an ${this.tokenInfo.shortName}, but ` + - `was given a custom token.`; + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a custom token.`; } else if (isLegacyCustomToken) { - errorMessage = `${this.tokenInfo.verifyApiName} expects an ${this.tokenInfo.shortName}, but ` + - `was given a legacy custom token.`; + 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.'; } @@ -202,7 +205,7 @@ export class FirebaseTokenVerifier { private verifyJwtSignatureWithKey(jwtToken: string, publicKey: string): Promise { let errorMessage: string; const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + - `for details on how to retrieve an ${this.tokenInfo.shortName}.`; + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; return new Promise((resolve, reject) => { jwt.verify(jwtToken, publicKey, { algorithms: [this.algorithm], diff --git a/src/index.d.ts b/src/index.d.ts index a4e31857c8..4d656d31b6 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -202,7 +202,6 @@ declare namespace admin.auth { interface SessionCookieOptions { expiresIn: number; - refreshThreshold?: number; } interface Auth { diff --git a/test/integration/auth.spec.ts b/test/integration/auth.spec.ts index 1039553001..08f8364899 100644 --- a/test/integration/auth.spec.ts +++ b/test/integration/auth.spec.ts @@ -163,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; @@ -245,7 +245,7 @@ describe('admin.auth', () => { } } }); - }).timeout(5000); + }); it('updateUser() updates the user record with the given parameters', () => { const updatedDisplayName = 'Updated User ' + newUserUid; @@ -350,9 +350,9 @@ describe('admin.auth', () => { }) .then((sessionCookie) => admin.auth().verifySessionCookie(sessionCookie)) .then((decodedIdToken) => { - // Check for expected expiration with +/-2 seconds of variation. - expect(decodedIdToken.exp).to.be.within(expectedExp - 2, expectedExp + 2); - expect(decodedIdToken.iat).to.be.within(expectedIat - 2, expectedIat + 2); + // 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. @@ -360,7 +360,7 @@ describe('admin.auth', () => { delete decodedIdToken.iat; expect(decodedIdToken).to.deep.equal(payloadClaims); }); - }).timeout(5000); + }); it('creates a revocable session cookie', () => { let currentSessionCookie: string; @@ -385,18 +385,7 @@ describe('admin.auth', () => { return admin.auth().verifySessionCookie(currentSessionCookie, true) .should.eventually.be.rejected.and.have.property('code', 'auth/session-cookie-revoked'); }); - }).timeout(5000); - - it('fails when called with an invalid ID token', () => { - return admin.auth().createSessionCookie('invalid-token', {expiresIn}) - .should.eventually.be.rejected.and.have.property('code', 'auth/invalid-id-token'); - }).timeout(5000); - - it('fails when called with an invalid duration', () => { - return admin.auth().createSessionCookie('invalid-token', {expiresIn: 60 * 1000}) - .should.eventually.be.rejected.and.have.property( - 'code', 'auth/invalid-session-cookie-duration'); - }).timeout(5000); + }); it('fails when called with a revoked ID token', () => { return admin.auth().createCustomToken(uid, {admin: true, groupId: '1234'}) @@ -412,7 +401,7 @@ describe('admin.auth', () => { return admin.auth().createSessionCookie(currentIdToken, {expiresIn}) .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-expired'); }); - }).timeout(5000); + }); }); @@ -590,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', () => { diff --git a/test/unit/auth/token-verifier.spec.ts b/test/unit/auth/token-verifier.spec.ts index 5ce3fe5ddf..f925f05d09 100644 --- a/test/unit/auth/token-verifier.spec.ts +++ b/test/unit/auth/token-verifier.spec.ts @@ -425,7 +425,7 @@ describe('FirebaseTokenVerifier', () => { .should.eventually.be.rejectedWith('Firebase ID token has invalid signature'); }); - it('should be rejected given a custom token', () => { + 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) @@ -433,7 +433,23 @@ describe('FirebaseTokenVerifier', () => { }); }); - it('should be rejected given a legacy 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, @@ -443,6 +459,24 @@ describe('FirebaseTokenVerifier', () => { .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()); From fbd6622ac08f20c225421f496ac43430980d8c26 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Mon, 2 Apr 2018 15:34:05 -0700 Subject: [PATCH 3/4] Updated Changelog. --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3295ec378f..7c6769e67c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ # Unreleased -- +- [feature] Added the session cookie management APIs for creating and verifying + session cookies, via `auth.createSessionCookie()` and + `auth.verifySessionCookie()`. # v5.11.0 From d51016247805a9d29abdb49b2632552e081ca941 Mon Sep 17 00:00:00 2001 From: bojeil-google Date: Thu, 5 Apr 2018 10:13:39 -0700 Subject: [PATCH 4/4] Updates the verifySessionCookie info URL to the finalized one. --- src/auth/token-generator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/auth/token-generator.ts b/src/auth/token-generator.ts index 26e1136beb..e92fb55526 100644 --- a/src/auth/token-generator.ts +++ b/src/auth/token-generator.ts @@ -52,8 +52,7 @@ interface JWTPayload { /** User facing token information related to the Firebase session cookie. */ export const SESSION_COOKIE_INFO: tokenVerify.FirebaseTokenInfo = { - // Placeholder until documentation page is created for session cookies. - url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', verifyApiName: 'verifySessionCookie()', jwtName: 'Firebase session cookie', shortName: 'session cookie',