-
Notifications
You must be signed in to change notification settings - Fork 392
feat(auth): Add support for Auth Emulator #1044
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 4 commits
4505e8f
654e19b
ee5ff77
f51e31b
b506e15
3b43e21
8ef7f0a
2a249fc
d7503f8
66b9abb
e65d769
3d5989f
17c1f45
6c1ee4b
6915268
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,9 +21,12 @@ import { AuthorizedHttpClient, HttpError, HttpRequestConfig, HttpClient } from ' | |
|
||
import * as validator from '../utils/validator'; | ||
import { toWebSafeBase64 } from '../utils'; | ||
import { Algorithm } from "jsonwebtoken"; | ||
samtstern marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
const ALGORITHM_RS256 = 'RS256'; | ||
const ALGORITHM_RS256: Algorithm = 'RS256' as const; | ||
const ALGORITHM_NONE: Algorithm = 'none' as const; | ||
|
||
const ONE_HOUR_IN_SECONDS = 60 * 60; | ||
|
||
// List of blacklisted claims which cannot be provided when creating a custom token | ||
|
@@ -39,6 +42,12 @@ const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identit | |
* CryptoSigner interface represents an object that can be used to sign JWTs. | ||
*/ | ||
export interface CryptoSigner { | ||
|
||
/** | ||
* The name of the signing algorithm. | ||
*/ | ||
algorithm: Algorithm; | ||
samtstern marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* Cryptographically signs a buffer of data. | ||
* | ||
|
@@ -82,6 +91,8 @@ interface JWTBody { | |
* sign data. Performs all operations locally, and does not make any RPC calls. | ||
*/ | ||
export class ServiceAccountSigner implements CryptoSigner { | ||
|
||
algorithm = ALGORITHM_RS256; | ||
|
||
/** | ||
* Creates a new CryptoSigner instance from the given service account credential. | ||
|
@@ -124,6 +135,8 @@ export class ServiceAccountSigner implements CryptoSigner { | |
* @see https://cloud.google.com/compute/docs/storing-retrieving-metadata | ||
*/ | ||
export class IAMSigner implements CryptoSigner { | ||
algorithm = ALGORITHM_RS256; | ||
|
||
private readonly httpClient: AuthorizedHttpClient; | ||
private serviceAccountId?: string; | ||
|
||
|
@@ -213,6 +226,36 @@ export class IAMSigner implements CryptoSigner { | |
); | ||
}); | ||
} | ||
|
||
/** | ||
* @inheritdoc | ||
*/ | ||
public getAlgorithm(): string { | ||
samtstern marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return ALGORITHM_RS256; | ||
} | ||
} | ||
|
||
/** | ||
* A CryptoSigner implementation that is used when communicating with the Auth emulator. | ||
* It produces unsigned tokens. | ||
*/ | ||
export class EmulatedSigner implements CryptoSigner { | ||
|
||
algorithm = ALGORITHM_NONE; | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public sign(_: Buffer): Promise<Buffer> { | ||
return Promise.resolve(Buffer.from('')); | ||
} | ||
|
||
/** | ||
* @inheritDoc | ||
*/ | ||
public getAccountId(): Promise<string> { | ||
return Promise.resolve('[email protected]'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not so sure about this ... could we do something better here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cc @yuchenshi There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably the best thing we can do |
||
} | ||
} | ||
|
||
/** | ||
|
@@ -242,17 +285,15 @@ export class FirebaseTokenGenerator { | |
* @param tenantId The tenant ID to use for the generated Firebase Auth | ||
* Custom token. If absent, then no tenant ID claim will be set in the | ||
* resulting JWT. | ||
* @param useEmulator When true we should generate tokens on behalf of the | ||
* Auth Emulator, which means no signature. | ||
*/ | ||
constructor(signer: CryptoSigner, public readonly tenantId?: string, public readonly useEmulator?: boolean) { | ||
constructor(signer: CryptoSigner, public readonly tenantId?: string) { | ||
if (!validator.isNonNullObject(signer)) { | ||
throw new FirebaseAuthError( | ||
AuthClientErrorCode.INVALID_CREDENTIAL, | ||
'INTERNAL ASSERT: Must provide a CryptoSigner to use FirebaseTokenGenerator.', | ||
); | ||
} | ||
if (typeof tenantId !== 'undefined' && !validator.isNonEmptyString(tenantId)) { | ||
if (typeof this.tenantId !== 'undefined' && !validator.isNonEmptyString(this.tenantId)) { | ||
throw new FirebaseAuthError( | ||
AuthClientErrorCode.INVALID_ARGUMENT, | ||
'`tenantId` argument must be a non-empty string.'); | ||
|
@@ -299,13 +340,8 @@ export class FirebaseTokenGenerator { | |
} | ||
} | ||
return this.signer.getAccountId().then((account) => { | ||
// When communicating with the Auth Emulator we don't sign custom tokens which means: | ||
// - Algorithm is 'none' | ||
// - Third token segment (signature) is empty | ||
const alg = this.useEmulator ? 'none' : ALGORITHM_RS256; | ||
|
||
const header: JWTHeader = { | ||
alg, | ||
alg: this.signer.algorithm, | ||
typ: 'JWT', | ||
}; | ||
const iat = Math.floor(Date.now() / 1000); | ||
|
@@ -325,11 +361,7 @@ export class FirebaseTokenGenerator { | |
body.claims = claims; | ||
} | ||
const token = `${this.encodeSegment(header)}.${this.encodeSegment(body)}`; | ||
|
||
// When running inside the Auth emulator we generate unsigned custom tokens | ||
const signPromise = this.useEmulator | ||
? Promise.resolve(Buffer.from("")) | ||
: this.signer.sign(Buffer.from(token)); | ||
const signPromise = this.signer.sign(Buffer.from(token)); | ||
|
||
return Promise.all([token, signPromise]); | ||
}).then(([token, signature]) => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -77,12 +77,7 @@ export class FirebaseTokenVerifier { | |
|
||
constructor(private clientCertUrl: string, private algorithm: jwt.Algorithm, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This really ought to accept an options object. But it's ok to do that later. |
||
private issuer: string, private tokenInfo: FirebaseTokenInfo, | ||
private readonly app: FirebaseApp, | ||
private readonly useEmulator: boolean = false) { | ||
|
||
if (this.useEmulator) { | ||
this.algorithm = 'none'; | ||
} | ||
private readonly app: FirebaseApp) { | ||
|
||
if (!validator.isURL(clientCertUrl)) { | ||
throw new FirebaseAuthError( | ||
|
@@ -150,16 +145,20 @@ export class FirebaseTokenVerifier { | |
); | ||
} | ||
|
||
if (this.useEmulator) { | ||
return this.verifyJwtSignatureWithKey(jwtToken, null); | ||
} | ||
|
||
return util.findProjectId(this.app) | ||
.then((projectId) => { | ||
return this.verifyJWTWithProjectId(jwtToken, projectId); | ||
}); | ||
} | ||
|
||
/** | ||
* Override the JWT signing algorithm. | ||
* @param algorithm the new signing algorithm. | ||
*/ | ||
public setAlgorithm(algorithm: jwt.Algorithm): void { | ||
this.algorithm = algorithm; | ||
} | ||
|
||
private verifyJWTWithProjectId(jwtToken: string, projectId: string | null): Promise<DecodedIdToken> { | ||
if (!validator.isNonEmptyString(projectId)) { | ||
throw new FirebaseAuthError( | ||
|
@@ -185,7 +184,7 @@ export class FirebaseTokenVerifier { | |
if (!fullDecodedToken) { | ||
errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed the entire string JWT ` + | ||
`which represents ${this.shortNameArticle} ${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; | ||
} else if (typeof header.kid === 'undefined') { | ||
} else if (typeof header.kid === 'undefined' && this.algorithm !== "none") { | ||
samtstern marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); | ||
const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); | ||
|
||
|
@@ -223,6 +222,12 @@ export class FirebaseTokenVerifier { | |
return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage)); | ||
} | ||
|
||
// When the algorithm is set to "none" there will be no signature and therefore we don't check | ||
// the public keys. | ||
if (this.algorithm === "none") { | ||
return this.verifyJwtSignatureWithKey(jwtToken, null); | ||
} | ||
|
||
return this.fetchPublicKeys().then((publicKeys) => { | ||
if (!Object.prototype.hasOwnProperty.call(publicKeys, header.kid)) { | ||
return Promise.reject( | ||
|
@@ -251,9 +256,7 @@ export class FirebaseTokenVerifier { | |
const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + | ||
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; | ||
return new Promise((resolve, reject) => { | ||
// The types are wrong here so it won't accept 'null' for publicKey, but testing (including our unit tests) | ||
// confirms that this works and is the required value when validating a token with alg: 'none' | ||
jwt.verify(jwtToken, publicKey as any, { | ||
jwt.verify(jwtToken, publicKey || "", { | ||
algorithms: [this.algorithm], | ||
}, (error: jwt.VerifyErrors | null, decodedToken: object | undefined) => { | ||
if (error) { | ||
|
@@ -339,14 +342,16 @@ export class FirebaseTokenVerifier { | |
* @param {FirebaseApp} app Firebase app instance. | ||
* @return {FirebaseTokenVerifier} | ||
*/ | ||
export function createIdTokenVerifier(app: FirebaseApp, useEmulator = false): FirebaseTokenVerifier { | ||
export function createIdTokenVerifier( | ||
samtstern marked this conversation as resolved.
Show resolved
Hide resolved
|
||
app: FirebaseApp, | ||
algorithm: jwt.Algorithm = ALGORITHM_RS256 | ||
): FirebaseTokenVerifier { | ||
return new FirebaseTokenVerifier( | ||
CLIENT_CERT_URL, | ||
ALGORITHM_RS256, | ||
algorithm, | ||
'https://securetoken.google.com/', | ||
ID_TOKEN_INFO, | ||
app, | ||
useEmulator | ||
app | ||
); | ||
} | ||
|
||
|
@@ -356,13 +361,15 @@ export function createIdTokenVerifier(app: FirebaseApp, useEmulator = false): Fi | |
* @param {FirebaseApp} app Firebase app instance. | ||
* @return {FirebaseTokenVerifier} | ||
*/ | ||
export function createSessionCookieVerifier(app: FirebaseApp, useEmulator = false): FirebaseTokenVerifier { | ||
export function createSessionCookieVerifier( | ||
app: FirebaseApp, | ||
algorithm: jwt.Algorithm = ALGORITHM_RS256 | ||
): FirebaseTokenVerifier { | ||
return new FirebaseTokenVerifier( | ||
SESSION_COOKIE_CERT_URL, | ||
ALGORITHM_RS256, | ||
algorithm, | ||
'https://session.firebase.google.com/', | ||
SESSION_COOKIE_INFO, | ||
app, | ||
useEmulator | ||
app | ||
); | ||
} |
Uh oh!
There was an error while loading. Please reload this page.