@pedwise/next-firebase-auth-edge
Version:
Next.js 13 Firebase Authentication for Edge and server runtimes. Dedicated for Next 13 server components. Compatible with Next.js middleware.
299 lines (280 loc) • 9.33 kB
text/typescript
import {
CLIENT_CERT_URL,
FIREBASE_AUDIENCE,
FirebaseTokenInfo,
ID_TOKEN_INFO,
} from "./firebase";
import {
ALGORITHM_RS256,
DecodedToken,
decodeJwt,
EmulatorSignatureVerifier,
PublicKeySignatureVerifier,
SignatureVerifier,
} from "./signature-verifier";
import {
isNonEmptyString,
isNonNullObject,
isString,
isURL,
} from "./validator";
import { AuthClientErrorCode, FirebaseAuthError } from "./error";
import { JwtError, JwtErrorCode } from "./jwt/error";
export interface DecodedIdToken {
aud: string;
auth_time: number;
email?: string;
email_verified?: boolean;
exp: number;
firebase: {
identities: {
[key: string]: any;
};
sign_in_provider: string;
sign_in_second_factor?: string;
second_factor_identifier?: string;
tenant?: string;
[key: string]: any;
};
iat: number;
iss: string;
phone_number?: string;
picture?: string;
sub: string;
uid: string;
[key: string]: any;
}
const EMULATOR_VERIFIER = new EmulatorSignatureVerifier();
export class FirebaseTokenVerifier {
private readonly shortNameArticle: string;
private readonly signatureVerifier: SignatureVerifier;
constructor(
clientCertUrl: string,
private issuer: string,
private tokenInfo: FirebaseTokenInfo,
private projectId: string
) {
if (!isURL(clientCertUrl)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The provided public client certificate URL is an invalid URL."
);
} else if (!isURL(issuer)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The provided JWT issuer is an invalid URL."
);
} else if (!isNonNullObject(tokenInfo)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The provided JWT information is not an object or null."
);
} else if (!isURL(tokenInfo.url)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The provided JWT verification documentation URL is invalid."
);
} else if (!isNonEmptyString(tokenInfo.verifyApiName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The JWT verify API name must be a non-empty string."
);
} else if (!isNonEmptyString(tokenInfo.jwtName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The JWT public full name must be a non-empty string."
);
} else if (!isNonEmptyString(tokenInfo.shortName)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The JWT public short name must be a non-empty string."
);
} else if (
!isNonNullObject(tokenInfo.expiredErrorCode) ||
!("code" in tokenInfo.expiredErrorCode)
) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
"The JWT expiration error code must be a non-null ErrorInfo object."
);
}
this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i)
? "an"
: "a";
this.signatureVerifier =
PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl);
}
public async verifyJWT(
jwtToken: string,
isEmulator = false
): Promise<DecodedIdToken> {
if (!isString(jwtToken)) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
`First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`
);
}
const decoded = await this.decodeAndVerify(
jwtToken,
this.projectId,
isEmulator
);
const decodedIdToken = decoded.payload as DecodedIdToken;
decodedIdToken.uid = decodedIdToken.sub;
return decodedIdToken;
}
private decodeAndVerify(
token: string,
projectId: string,
isEmulator: boolean,
audience?: string
): Promise<DecodedToken> {
return this.safeDecode(token).then((decodedToken) => {
this.verifyContent(decodedToken, projectId, isEmulator, audience);
return this.verifySignature(token, isEmulator).then(() => decodedToken);
});
}
private safeDecode(jwtToken: string): Promise<DecodedToken> {
return decodeJwt(jwtToken).catch((err: JwtError) => {
if (err.code == JwtErrorCode.INVALID_ARGUMENT) {
const verifyJwtTokenDocsMessage =
` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
const errorMessage =
`Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` +
`the entire string JWT which represents ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}.` +
verifyJwtTokenDocsMessage;
throw FirebaseAuthError.toAuthErrorWithStack(
AuthClientErrorCode.INVALID_ARGUMENT,
errorMessage,
err
);
}
throw FirebaseAuthError.toAuthErrorWithStack(
AuthClientErrorCode.INTERNAL_ERROR,
err.message,
err
);
});
}
private verifyContent(
fullDecodedToken: DecodedToken,
projectId: string | null,
isEmulator: boolean,
audience: string | undefined
): void {
const header = fullDecodedToken && fullDecodedToken.header;
const payload = fullDecodedToken && fullDecodedToken.payload;
const projectIdMatchMessage =
` Make sure the ${this.tokenInfo.shortName} comes from the same ` +
"Firebase project as the service account used to authenticate this SDK.";
const verifyJwtTokenDocsMessage =
` See ${this.tokenInfo.url} ` +
`for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`;
let errorMessage: string | undefined;
if (!isEmulator && typeof header.kid === "undefined") {
const isCustomToken = payload.aud === FIREBASE_AUDIENCE;
const isLegacyCustomToken =
header.alg === "HS256" &&
payload.v === 0 &&
"d" in payload &&
"uid" in payload.d;
if (isCustomToken) {
errorMessage =
`${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}, but was given a custom token.`;
} else if (isLegacyCustomToken) {
errorMessage =
`${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` +
`${this.tokenInfo.shortName}, but was given a legacy custom token.`;
} else {
errorMessage = `${this.tokenInfo.jwtName} has no "kid" claim.`;
}
errorMessage += verifyJwtTokenDocsMessage;
} else if (!isEmulator && header.alg !== ALGORITHM_RS256) {
errorMessage =
`${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` +
ALGORITHM_RS256 +
'" but got ' +
'"' +
header.alg +
'".' +
verifyJwtTokenDocsMessage;
} else if (
typeof audience !== "undefined" &&
!(payload.aud as string).includes(audience)
) {
errorMessage =
`${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
audience +
'" but got "' +
payload.aud +
'".' +
verifyJwtTokenDocsMessage;
} else if (typeof audience === "undefined" && payload.aud !== projectId) {
errorMessage =
`${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` +
projectId +
'" but got "' +
payload.aud +
'".' +
projectIdMatchMessage +
verifyJwtTokenDocsMessage;
} else if (payload.iss !== this.issuer + projectId) {
errorMessage =
`${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` +
`"${this.issuer}` +
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 (errorMessage) {
throw new FirebaseAuthError(
AuthClientErrorCode.INVALID_ARGUMENT,
errorMessage
);
}
}
private verifySignature(
jwtToken: string,
isEmulator: boolean
): Promise<void> {
const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier;
return verifier.verify(jwtToken).catch((error) => {
throw this.mapJwtErrorToAuthError(error);
});
}
private mapJwtErrorToAuthError(error: JwtError): Error {
return FirebaseAuthError.fromJwtError(
error,
this.tokenInfo,
this.shortNameArticle
);
}
}
export function createIdTokenVerifier(
projectId: string
): FirebaseTokenVerifier {
return new FirebaseTokenVerifier(
CLIENT_CERT_URL,
"https://securetoken.google.com/",
ID_TOKEN_INFO,
projectId
);
}