Skip to content
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

Make grantToken tenant-aware #4475

Merged
merged 4 commits into from
Apr 27, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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