Skip to content

[gRPC] Enable PATs to be used for authorizing with the gRPC API #19081

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

Merged
merged 2 commits into from
Nov 20, 2023
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 35 additions & 18 deletions components/server/src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { MethodKind, ServiceType } from "@bufbuild/protobuf";
import { Code, ConnectError, ConnectRouter, HandlerContext, ServiceImpl } from "@connectrpc/connect";
import { expressConnectMiddleware } from "@connectrpc/connect-express";
import { User } from "@gitpod/gitpod-protocol";
import { ApplicationError, ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
import { PublicAPIConverter } from "@gitpod/gitpod-protocol/lib/public-api-converter";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
Expand Down Expand Up @@ -49,6 +48,7 @@ import { ConfigurationServiceAPI } from "./configuration-service-api";
import { AuthProviderServiceAPI } from "./auth-provider-service-api";
import { Unauthenticated } from "./unauthenticated";
import { SubjectId } from "../auth/subject-id";
import { BearerAuth } from "../auth/bearer-authenticator";

decorate(injectable(), PublicAPIConverter);

Expand All @@ -71,6 +71,7 @@ export class API {
@inject(Redis) private readonly redis: Redis;
@inject(Config) private readonly config: Config;
@inject(UserService) private readonly userService: UserService;
@inject(BearerAuth) private readonly bearerAuthenticator: BearerAuth;

listenPrivate(): http.Server {
const app = express();
Expand Down Expand Up @@ -220,20 +221,20 @@ export class API {
// actually call the RPC handler
const auth = async () => {
// Authenticate
const userId = await self.verify(connectContext);
const isAuthenticated = !!userId;
const subjectId = await self.verify(connectContext);
const isAuthenticated = !!subjectId;
const requiresAuthentication = !Unauthenticated.get(target, prop);
if (!isAuthenticated && requiresAuthentication) {
throw new ConnectError("unauthenticated", Code.Unauthenticated);
}

if (isAuthenticated) {
await rateLimit(userId);
await self.ensureFgaMigration(userId);
await rateLimit(subjectId.toString());
await self.ensureFgaMigration(subjectId);
}
// TODO(at) if unauthenticated, we still need to apply enforece a rate limit

return userId ? SubjectId.fromUserId(userId) : undefined;
return subjectId;
};

const apply = async <T>(): Promise<T> => {
Expand Down Expand Up @@ -293,30 +294,46 @@ export class API {
};
}

private async verify(context: HandlerContext): Promise<string | undefined> {
private async verify(context: HandlerContext): Promise<SubjectId | undefined> {
// 1. Try Bearer token first
try {
const subjectId = await this.bearerAuthenticator.tryAuthFromHeaders(context.requestHeader);
if (subjectId) {
return subjectId;
}
// fall-through to session JWT
} catch (err) {
log.warn("error authenticating subject by Bearer token", err);
}

// 2. Try for session JWT in the "cookie" header
const cookieHeader = (context.requestHeader.get("cookie") || "") as string;
try {
const claims = await this.sessionHandler.verifyJWTCookie(cookieHeader);
const userId = claims?.sub;
return userId;
} catch (error) {
log.warn("Failed to authenticate user with JWT Session", error);
return !!userId ? SubjectId.fromUserId(userId) : undefined;
} catch (err) {
log.warn("Failed to authenticate user with JWT Session", err);
return undefined;
}
}

private async ensureFgaMigration(userId: string): Promise<User> {
const fgaChecksEnabled = await isFgaChecksEnabled(userId);
private async ensureFgaMigration(subjectId: SubjectId): Promise<void> {
const fgaChecksEnabled = await isFgaChecksEnabled(subjectId.userId());
if (!fgaChecksEnabled) {
throw new ConnectError("unauthorized", Code.PermissionDenied);
}
try {
return await this.userService.findUserById(userId, userId);
} catch (e) {
if (e instanceof ApplicationError && e.code === ErrorCodes.NOT_FOUND) {
throw new ConnectError("unauthorized", Code.PermissionDenied);

if (subjectId.kind === "user") {
const userId = subjectId.userId()!;
try {
await this.userService.findUserById(userId, userId);
} catch (e) {
if (e instanceof ApplicationError && e.code === ErrorCodes.NOT_FOUND) {
throw new ConnectError("unauthorized", Code.PermissionDenied);
}
throw e;
}
throw e;
}
}

Expand Down
2 changes: 2 additions & 0 deletions components/server/src/api/teams.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Config } from "../config";
import { OrganizationService } from "../orgs/organization-service";
import { ProjectsService } from "../projects/projects-service";
import { AuthProviderService } from "../auth/auth-provider-service";
import { BearerAuth } from "../auth/bearer-authenticator";

const expect = chai.expect;

Expand All @@ -45,6 +46,7 @@ export class APITeamsServiceSpec {
this.container.bind(WorkspaceService).toConstantValue({} as WorkspaceService);
this.container.bind(OrganizationService).toConstantValue({} as OrganizationService);
this.container.bind(UserAuthentication).toConstantValue({} as UserAuthentication);
this.container.bind(BearerAuth).toConstantValue({} as BearerAuth);
this.container.bind(SessionHandler).toConstantValue({} as SessionHandler);
this.container.bind(Config).toConstantValue({} as Config);
this.container.bind(Redis).toConstantValue({} as Redis);
Expand Down
146 changes: 146 additions & 0 deletions components/server/src/auth/bearer-authenticator.spec.db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Copyright (c) 2022 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { TypeORM, resetDB } from "@gitpod/gitpod-db/lib";
import { BearerAuth, PersonalAccessToken } from "./bearer-authenticator";
import { expect } from "chai";
import { describe } from "mocha";
import { Container } from "inversify";
import { createTestContainer } from "../test/service-testing-container-module";
import { Experiments } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
import { UserService } from "../user/user-service";
import { User } from "@gitpod/gitpod-protocol";
import { Config } from "../config";
import { Request } from "express";
import { WithResourceAccessGuard } from "./resource-access";
import { WithFunctionAccessGuard } from "./function-access";
import { fail } from "assert";
import { SubjectId } from "./subject-id";

function toDateTime(date: Date): string {
return date.toISOString().replace("T", " ").replace("Z", "");
}

describe("BearerAuth", () => {
let container: Container;
let bearerAuth: BearerAuth;
let userService: UserService;
let typeORM: TypeORM;
let testUser: User;

async function insertPat(userId: string, patId: string, scopes: string[] = ["function:*"]): Promise<string> {
const patValue = "someValue";
const signature = "V7BsZVjpMQRaWxS5XJE9r-Ovpxk2xT_bfFSmic4yW6g"; // depends on the value
const pat = new PersonalAccessToken("doesnotmatter", patValue);

const conn = await typeORM.getConnection();
await conn.query(
"INSERT d_b_personal_access_token (id, userId, hash, name, scopes, expirationTime, createdAt) VALUES (?, ?, ?, ?, ?, ?, ?)",
[patId, userId, pat.hash(), patId, scopes, toDateTime(new Date("2030")), toDateTime(new Date())],
);
return `gitpod_pat_${signature}.${patValue}`;
}

beforeEach(async () => {
container = createTestContainer();
Experiments.configureTestingClient({
centralizedPermissions: true,
});
const oldConfig = container.get<Config>(Config);
container.rebind(Config).toDynamicValue((ctx) => {
return {
...oldConfig,
patSigningKey: "super-duper-secret-pat-signing-key",
};
});
bearerAuth = container.get(BearerAuth);
userService = container.get<UserService>(UserService);
typeORM = container.get<TypeORM>(TypeORM);

testUser = await userService.createUser({
identity: {
authId: "gh-user-1",
authName: "testUser",
authProviderId: "public-github",
},
});
});

afterEach(async () => {
// Clean-up database
await resetDB(container.get(TypeORM));
});

it("authExpressRequest should successfully authenticate BearerToken (PAT)", async () => {
const pat1 = await insertPat(testUser.id, "pat-1");

const req = {
headers: {
authorization: `Bearer ${pat1}`,
},
} as Request;
await bearerAuth.authExpressRequest(req);

expect(req.user?.id).to.equal(testUser.id);
expect((req as WithResourceAccessGuard).resourceGuard).to.not.be.undefined;
expect((req as WithFunctionAccessGuard).functionGuard).to.not.be.undefined;
});

it("authExpressRequest should fail to authenticate with missing BearerToken in header", async () => {
await insertPat(testUser.id, "pat-1");

const req = {
headers: {
authorization: `Bearer `, // missing
},
} as Request;
await expectError(async () => bearerAuth.authExpressRequest(req), "missing bearer token header");
});

it("authExpressRequest should fail to authenticate with missing BearerToken from DB (PAT)", async () => {
const patNotStored = "gitpod_pat_GrvGthczSRf3ypqFhNtcRiN5fK6CV7rdCkkPLfpbc_4";

const req = {
headers: {
authorization: `Bearer ${patNotStored}`,
},
} as Request;
await expectError(async () => bearerAuth.authExpressRequest(req), "cannot find token");
});

it("tryAuthFromHeaders should successfully authenticate BearerToken (PAT)", async () => {
const pat1 = await insertPat(testUser.id, "pat-1");

const headers = new Headers();
headers.set("authorization", `Bearer ${pat1}`);
const subjectId = await bearerAuth.tryAuthFromHeaders(headers);

expect(subjectId?.toString()).to.equal(SubjectId.fromUserId(testUser.id).toString());
});

it("tryAuthFromHeaders should return undefined with missing BearerToken in header", async () => {
await insertPat(testUser.id, "pat-1");

const headers = new Headers();
headers.set("authorization", `Bearer `); // missing
expect(await bearerAuth.tryAuthFromHeaders(headers)).to.be.undefined;
});

it("tryAuthFromHeaders should fail to authenticate with missing BearerToken from DB (PAT)", async () => {
const patNotStored = "gitpod_pat_GrvGthczSRf3ypqFhNtcRiN5fK6CV7rdCkkPLfpbc_4";

const headers = new Headers();
headers.set("authorization", `Bearer ${patNotStored}`);
await expectError(async () => bearerAuth.tryAuthFromHeaders(headers), "cannot find token");
});

async function expectError(fun: () => Promise<any>, message: string) {
try {
await fun();
fail(`Expected error: ${message}`);
} catch (err) {}
}
});
46 changes: 32 additions & 14 deletions components/server/src/auth/bearer-authenticator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,24 @@ import { GitpodTokenType, User } from "@gitpod/gitpod-protocol";
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
import * as crypto from "crypto";
import express from "express";
import { IncomingHttpHeaders } from "http";
import { inject, injectable } from "inversify";
import { Config } from "../config";
import { AllAccessFunctionGuard, ExplicitFunctionAccessGuard, WithFunctionAccessGuard } from "./function-access";
import {
AllAccessFunctionGuard,
ExplicitFunctionAccessGuard,
FunctionAccessGuard,
WithFunctionAccessGuard,
} from "./function-access";
import { TokenResourceGuard, WithResourceAccessGuard } from "./resource-access";
import { UserService } from "../user/user-service";
import { SubjectId } from "./subject-id";

export function getBearerToken(headers: IncomingHttpHeaders): string | undefined {
const authorizationHeader = headers["authorization"];
export function getBearerToken(authorizationHeader: string | undefined | null): string | undefined {
if (!authorizationHeader || !(typeof authorizationHeader === "string")) {
return;
return undefined;
}
if (!authorizationHeader.startsWith("Bearer ")) {
return;
return undefined;
}

return authorizationHeader.substring("Bearer ".length);
Expand Down Expand Up @@ -51,7 +55,7 @@ export class BearerAuth {
get restHandler(): express.RequestHandler {
return async (req, res, next) => {
try {
await this.auth(req);
await this.authExpressRequest(req);
} catch (e) {
if (isBearerAuthError(e)) {
// (AT) while investigating https://github.com/gitpod-io/gitpod/issues/8703 we
Expand All @@ -71,16 +75,16 @@ export class BearerAuth {
get restHandlerOptionally(): express.RequestHandler {
return async (req, res, next) => {
try {
await this.auth(req);
await this.authExpressRequest(req);
} catch (e) {
// don't error the request, we just have not bearer authentication token
}
return next();
};
}

async auth(req: express.Request): Promise<void> {
const token = getBearerToken(req.headers);
async authExpressRequest(req: express.Request): Promise<void> {
const token = getBearerToken(req.headers["authorization"]);
if (!token) {
throw createBearerAuthError("missing Bearer token");
}
Expand All @@ -90,10 +94,8 @@ export class BearerAuth {
const resourceGuard = new TokenResourceGuard(user.id, scopes);
(req as WithResourceAccessGuard).resourceGuard = resourceGuard;

const functionScopes = scopes
.filter((s) => s.startsWith("function:"))
.map((s) => s.substring("function:".length));
if (functionScopes.length === 1 && functionScopes[0] === "*") {
const { isAllAccessFunctionGuard, functionScopes } = FunctionAccessGuard.extractFunctionScopes(scopes);
if (isAllAccessFunctionGuard) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@geropl how can we test for a regression of this path manually? to use gitpod cli with PAT?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. Still works 😉

(req as WithFunctionAccessGuard).functionGuard = new AllAccessFunctionGuard();
} else {
// We always install a function access guard. If the token has no scopes, it's not allowed to do anything.
Expand All @@ -103,6 +105,22 @@ export class BearerAuth {
req.user = user;
}

async tryAuthFromHeaders(headers: Headers): Promise<SubjectId | undefined> {
const token = getBearerToken(headers.get("authorization"));
if (!token) {
return undefined;
}
const { user, scopes } = await this.userAndScopesFromToken(token);

// gpl: Once we move PAT to FGA-backed scopes, this special case will go away, and covered by a different SubjectIdKind.
const { isAllAccessFunctionGuard } = FunctionAccessGuard.extractFunctionScopes(scopes);
if (!isAllAccessFunctionGuard) {
return undefined;
}

return SubjectId.fromUserId(user.id);
}

private async userAndScopesFromToken(token: string): Promise<{ user: User; scopes: string[] }> {
// We handle two types of Bearer tokens:
// 1. Personal Access Tokens which are prefixed with `gitpod_pat_`
Expand Down
13 changes: 13 additions & 0 deletions components/server/src/auth/function-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,19 @@ export interface FunctionAccessGuard {
canAccess(name: string): boolean;
}

export namespace FunctionAccessGuard {
export function extractFunctionScopes(scopes: string[]): {
functionScopes: string[];
isAllAccessFunctionGuard: boolean;
} {
const functionScopes = scopes
.filter((s) => s.startsWith("function:"))
.map((s) => s.substring("function:".length));
const isAllAccessFunctionGuard = functionScopes.length === 1 && functionScopes[0] === "*";
return { functionScopes, isAllAccessFunctionGuard };
}
}

export interface WithFunctionAccessGuard {
functionGuard?: FunctionAccessGuard;
}
Expand Down
Loading