Skip to content

Commit

Permalink
Make grantToken tenant-aware (#4475)
Browse files Browse the repository at this point in the history
Adds multi-tenancy support on grantToken endpoint by pulling tenant ID from refresh token. Changes include:
- Make refresh token minting / validating stateless by serializing RefreshTokenRecord instead of representing with random string 
- Changing server side logic to check request body's refresh token for tenant ID (note that `grantToken` is the only endpoint in which the tenant ID cannot be obtained from the request path, query params, or ID token in the request body)
- Added additional unit tests for `grantToken` edge cases

Fixes #4414.
  • Loading branch information
lisajian committed Apr 27, 2022
1 parent 28b3467 commit 58a95e0
Show file tree
Hide file tree
Showing 6 changed files with 191 additions and 41 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
@@ -1 +1,2 @@
- Updates `superstatic` to `v8` to fix audit issues.
- Make grantToken tenant-aware (#4475)
1 change: 0 additions & 1 deletion src/emulator/auth/operations.ts
Expand Up @@ -1704,7 +1704,6 @@ function grantToken(
assert(reqBody.refreshToken, "MISSING_REFRESH_TOKEN");

const refreshTokenRecord = state.validateRefreshToken(reqBody.refreshToken);
assert(refreshTokenRecord, "INVALID_REFRESH_TOKEN");
assert(!refreshTokenRecord.user.disabled, "USER_DISABLED");
const tokens = issueTokens(state, refreshTokenRecord.user, refreshTokenRecord.provider, {
extraClaims: refreshTokenRecord.extraClaims,
Expand Down
15 changes: 14 additions & 1 deletion src/emulator/auth/server.ts
Expand Up @@ -7,7 +7,7 @@ import { OpenAPIObject, PathsObject, ServerObject, OperationObject } from "opena
import { EmulatorLogger } from "../emulatorLogger";
import { Emulators } from "../types";
import { authOperations, AuthOps, AuthOperation, FirebaseJwtPayload } from "./operations";
import { AgentProjectState, ProjectState } from "./state";
import { AgentProjectState, decodeRefreshToken, ProjectState } from "./state";
import apiSpecUntyped from "./apiSpec";
import {
PromiseController,
Expand Down Expand Up @@ -506,6 +506,19 @@ function toExegesisController(
targetTenantId = targetTenantId || decoded?.payload.firebase.tenant;
}

// Need to check refresh token for tenant ID for grantToken endpoint
if (ctx.requestBody?.refreshToken) {
const refreshTokenRecord = decodeRefreshToken(ctx.requestBody!.refreshToken);
if (refreshTokenRecord.tenantId && targetTenantId) {
// Shouldn't ever reach this assertion, but adding for completeness
assert(
refreshTokenRecord.tenantId === targetTenantId,
"TENANT_ID_MISMATCH: ((Refresh token tenant ID does not match target tenant ID.))"
);
}
targetTenantId = targetTenantId || refreshTokenRecord.tenantId;
}

return operation(getProjectStateById(targetProjectId, targetTenantId), ctx.requestBody, ctx);
};
}
Expand Down
71 changes: 36 additions & 35 deletions src/emulator/auth/state.ts
Expand Up @@ -9,7 +9,7 @@ import {
} from "./utils";
import { MakeRequired } from "./utils";
import { AuthCloudFunction } from "./cloudFunctions";
import { assert } from "./errors";
import { assert, BadRequestError } from "./errors";
import { MfaEnrollments, Schemas } from "./types";

export const PROVIDER_PASSWORD = "password";
Expand All @@ -27,8 +27,6 @@ export abstract class ProjectState {
private localIdForPhoneNumber: Map<string, string> = new Map();
private localIdsForProviderEmail: Map<string, Set<string>> = new Map();
private userIdForProviderRawId: Map<string, Map<string, string>> = new Map();
private refreshTokens: Map<string, RefreshTokenRecord> = new Map();
private refreshTokensForLocalId: Map<string, Set<string>> = new Map();
private oobs: Map<string, OobRecord> = new Map();
private verificationCodes: Map<string, PhoneVerificationRecord> = new Map();
private temporaryProofs: Map<string, TemporaryProofRecord> = new Map();
Expand Down Expand Up @@ -123,15 +121,6 @@ export abstract class ProjectState {
deleteUser(user: UserInfo): void {
this.users.delete(user.localId);
this.removeUserFromIndex(user);

const refreshTokens = this.refreshTokensForLocalId.get(user.localId);
if (refreshTokens) {
this.refreshTokensForLocalId.delete(user.localId);
for (const refreshToken of refreshTokens) {
this.refreshTokens.delete(refreshToken);
}
}

this.authCloudFunction.dispatch("delete", user);
}

Expand Down Expand Up @@ -399,34 +388,30 @@ export abstract class ProjectState {
} = {}
): string {
const localId = userInfo.localId;
const refreshToken = randomBase64UrlStr(204);
this.refreshTokens.set(refreshToken, {
const refreshTokenRecord = {
_AuthEmulatorRefreshToken: "DO NOT MODIFY",
localId,
provider,
extraClaims,
projectId: this.projectId,
secondFactor,
tenantId: userInfo.tenantId,
});
let refreshTokens = this.refreshTokensForLocalId.get(localId);
if (!refreshTokens) {
refreshTokens = new Set();
this.refreshTokensForLocalId.set(localId, refreshTokens);
}
refreshTokens.add(refreshToken);
};
const refreshToken = encodeRefreshToken(refreshTokenRecord);
return refreshToken;
}

validateRefreshToken(refreshToken: string):
| {
user: UserInfo;
provider: string;
extraClaims: Record<string, unknown>;
secondFactor?: SecondFactorRecord;
}
| undefined {
const record = this.refreshTokens.get(refreshToken);
if (!record) {
return undefined;
validateRefreshToken(refreshToken: string): {
user: UserInfo;
provider: string;
extraClaims: Record<string, unknown>;
secondFactor?: SecondFactorRecord;
} {
const record = decodeRefreshToken(refreshToken);
assert(record.projectId === this.projectId, "INVALID_REFRESH_TOKEN");
if (this instanceof TenantProjectState) {
// Shouldn't ever reach this assertion, but adding for completeness
assert(record.tenantId === this.tenantId, "TENANT_ID_MISMATCH");
}
return {
user: this.getUserByLocalIdAssertingExists(record.localId),
Expand Down Expand Up @@ -495,8 +480,6 @@ export abstract class ProjectState {
this.localIdForPhoneNumber.clear();
this.localIdsForProviderEmail.clear();
this.userIdForProviderRawId.clear();
this.refreshTokens.clear();
this.refreshTokensForLocalId.clear();

// We do not clear OOBs / phone verification codes since some of those may
// still be valid (e.g. email link / phone sign-in may still create a new
Expand Down Expand Up @@ -871,10 +854,12 @@ export type Config = {
blockingFunctions: BlockingFunctionsConfig;
};

interface RefreshTokenRecord {
export interface RefreshTokenRecord {
_AuthEmulatorRefreshToken: string;
localId: string;
provider: string;
extraClaims: Record<string, unknown>;
projectId: string;
secondFactor?: SecondFactorRecord;
tenantId?: string;
}
Expand Down Expand Up @@ -922,6 +907,22 @@ interface TemporaryProofRecord {
// a bit easier. Therefore, there's no need to record createdAt timestamps.
}

export function encodeRefreshToken(refreshTokenRecord: RefreshTokenRecord): string {
return Buffer.from(JSON.stringify(refreshTokenRecord), "utf8").toString("base64");
}

export function decodeRefreshToken(refreshTokenString: string): RefreshTokenRecord {
let refreshTokenRecord: RefreshTokenRecord;
try {
const json = Buffer.from(refreshTokenString, "base64").toString("utf8");
refreshTokenRecord = JSON.parse(json) as RefreshTokenRecord;
} catch {
throw new BadRequestError("INVALID_REFRESH_TOKEN");
}
assert(refreshTokenRecord._AuthEmulatorRefreshToken, "INVALID_REFRESH_TOKEN");
return refreshTokenRecord;
}

function getProviderEmailsForUser(user: UserInfo): Set<string> {
const emails = new Set<string>();
user.providerUserInfo?.forEach(({ email }) => {
Expand Down
138 changes: 137 additions & 1 deletion src/test/emulators/auth/misc.spec.ts
@@ -1,6 +1,11 @@
import { expect } from "chai";
import { decode as decodeJwt, JwtHeader } from "jsonwebtoken";
import { UserInfo } from "../../../emulator/auth/state";
import {
decodeRefreshToken,
encodeRefreshToken,
RefreshTokenRecord,
UserInfo,
} from "../../../emulator/auth/state";
import {
deleteAccount,
getAccountInfoByIdToken,
Expand Down Expand Up @@ -47,6 +52,40 @@ describeAuthEmulator("token refresh", ({ authApi, getClock }) => {
});
});

it("should exchange refresh tokens for new tokens in a tenant project", async () => {
const tenant = await registerTenant(authApi(), PROJECT_ID, {
disableAuth: false,
allowPasswordSignup: true,
});
const { refreshToken, localId } = await registerUser(authApi(), {
email: "alice@example.com",
password: "notasecret",
tenantId: tenant.tenantId,
});

await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
// snake_case parameters also work, per OAuth 2.0 spec.
.send({ refresh_token: refreshToken, grantType: "refresh_token" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(200, res);
expect(res.body.id_token).to.be.a("string");
expect(res.body.access_token).to.equal(res.body.id_token);
expect(res.body.refresh_token).to.be.a("string");
expect(res.body.expires_in)
.to.be.a("string")
.matches(/[0-9]+/);
expect(res.body.project_id).to.equal("12345");
expect(res.body.token_type).to.equal("Bearer");
expect(res.body.user_id).to.equal(localId);

const refreshTokenRecord = decodeRefreshToken(res.body.refresh_token);
expect(refreshTokenRecord.tenantId).to.equal(tenant.tenantId);
});
});

it("should populate auth_time to match lastLoginAt (in seconds since epoch)", async () => {
getClock().tick(444); // Make timestamps a bit more interesting (non-zero).
const emailUser = { email: "alice@example.com", password: "notasecret" };
Expand Down Expand Up @@ -75,6 +114,62 @@ describeAuthEmulator("token refresh", ({ authApi, getClock }) => {
expect(decoded!.payload.auth_time).to.equal(lastLoginAtSeconds);
});

it("should error if grant type is missing", async () => {
const { refreshToken } = await registerAnonUser(authApi());

await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
// snake_case parameters also work, per OAuth 2.0 spec.
.send({ refresh_token: refreshToken })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).to.contain("MISSING_GRANT_TYPE");
});
});

it("should error if grant type is not refresh_token", async () => {
const { refreshToken } = await registerAnonUser(authApi());

await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
// snake_case parameters also work, per OAuth 2.0 spec.
.send({ refresh_token: refreshToken, grantType: "other_grant_type" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).to.contain("INVALID_GRANT_TYPE");
});
});

it("should error if refresh token is missing", async () => {
await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
// snake_case parameters also work, per OAuth 2.0 spec.
.send({ grantType: "refresh_token" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).to.contain("MISSING_REFRESH_TOKEN");
});
});

it("should error on malformed refresh tokens", async () => {
await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
// snake_case parameters also work, per OAuth 2.0 spec.
.send({ refresh_token: "malformedToken", grantType: "refresh_token" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error.message).to.contain("INVALID_REFRESH_TOKEN");
});
});

it("should error if user is disabled", async () => {
const { refreshToken, localId } = await registerAnonUser(authApi());
await updateAccountByLocalId(authApi(), localId, { disableUser: true });
Expand Down Expand Up @@ -107,6 +202,47 @@ describeAuthEmulator("token refresh", ({ authApi, getClock }) => {
.equals("UNSUPPORTED_PASSTHROUGH_OPERATION");
});
});

it("should error when refresh tokens are from a different project", async () => {
const refreshTokenRecord = {
_AuthEmulatorRefreshToken: "DO NOT MODIFY",
localId: "localId",
provider: "provider",
extraClaims: {},
projectId: "notMatchingProjectId",
};
const refreshToken = encodeRefreshToken(refreshTokenRecord);

await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
.send({ refresh_token: refreshToken, grantType: "refresh_token" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equals("INVALID_REFRESH_TOKEN");
});
});

it("should error on refresh tokens without required fields", async () => {
const refreshTokenRecord = {
localId: "localId",
provider: "provider",
extraClaims: {},
projectId: "notMatchingProjectId",
};
const refreshToken = encodeRefreshToken(refreshTokenRecord as RefreshTokenRecord);

await authApi()
.post("/securetoken.googleapis.com/v1/token")
.type("form")
.send({ refresh_token: refreshToken, grantType: "refresh_token" })
.query({ key: "fake-api-key" })
.then((res) => {
expectStatusCode(400, res);
expect(res.body.error).to.have.property("message").equals("INVALID_REFRESH_TOKEN");
});
});
});

describeAuthEmulator("createSessionCookie", ({ authApi }) => {
Expand Down
6 changes: 3 additions & 3 deletions src/test/emulators/auth/rest.spec.ts
Expand Up @@ -180,7 +180,7 @@ describeAuthEmulator("authentication", ({ authApi }) => {
});
});

it("should deny requests where tenant IDs do not match in the token and path", async () => {
it("should deny requests where tenant IDs do not match in the ID token and path", async () => {
const tenant = await registerTenant(authApi(), PROJECT_ID, {
disableAuth: false,
allowPasswordSignup: true,
Expand All @@ -203,12 +203,12 @@ describeAuthEmulator("authentication", ({ authApi }) => {
});
});

it("should deny requests where tenant IDs do not match in the token and request body", async () => {
it("should deny requests where tenant IDs do not match in the ID token and request body", async () => {
const tenant = await registerTenant(authApi(), PROJECT_ID, {
disableAuth: false,
allowPasswordSignup: true,
});
const { idToken, localId } = await registerUser(authApi(), {
const { idToken } = await registerUser(authApi(), {
email: "alice@example.com",
password: "notasecret",
tenantId: tenant.tenantId,
Expand Down

0 comments on commit 58a95e0

Please sign in to comment.