Skip to content

Commit

Permalink
BREAKING CHANGE(js)!: Reconcile authPolicy. add firebase and api key …
Browse files Browse the repository at this point in the history
…helpers (#1743)
  • Loading branch information
inlined authored Feb 6, 2025
1 parent d9bc052 commit 2983ecb
Show file tree
Hide file tree
Showing 31 changed files with 1,848 additions and 360 deletions.
11 changes: 11 additions & 0 deletions .github/workflows/bump-js-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,17 @@ jobs:
preid: ${{ inputs.preid }}
commit-message: 'chore: bump genkitx-langchain version to {{version}}'
tag-prefix: 'genkitx-langchain@'
- name: 'js/plugins/next version bump'
uses: 'phips28/gh-action-bump-version@master'
env:
GITHUB_TOKEN: ${{ secrets.GENKIT_RELEASER_GITHUB_TOKEN }}
PACKAGEJSON_DIR: js/plugins/next
with:
default: ${{ inputs.releaseType }}
version-type: ${{ inputs.releaseType }}
preid: ${{ inputs.preid }}
commit-message: 'chore: bump @genkit-ai/next version to {{version}}'
tag-prefix: '@genkit-ai/next@'
- name: 'js/plugins/ollama version bump'
uses: 'phips28/gh-action-bump-version@master'
env:
Expand Down
136 changes: 78 additions & 58 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Alternatively, Firebase also provides auth context into the flow where it can
do its own checks. For non-Functions flows, auth can be managed and set
through middleware.

## Basic flow authorization
## Authorizing within a Flow

Flows can check authorization in two ways: either the request binding
(e.g. `onCallGenkit` for Cloud Functions for Firebase or `express`) can enforce
Expand All @@ -24,7 +24,7 @@ where the flow has access to the information for auth managed within the
flow.

```ts
import { genkit, z } from 'genkit';
import { genkit, z, UserFacingError } from 'genkit';

const ai = genkit({ ... });

Expand All @@ -34,10 +34,10 @@ export const selfSummaryFlow = ai.defineFlow( {
outputSchema: z.string(),
}, async (input, { context }) => {
if (!context.auth) {
throw new Error('Authorization required.');
throw new UserFacingErrorError('UNAUTHENTICATED', 'Unauthenticated');
}
if (input.uid !== context.auth.uid) {
throw new Error('You may only summarize your own profile data.');
throw new UserFacingError('PERMISSION_DENIED', 'You may only summarize your own profile data.');
}
// Flow logic here...
});
Expand Down Expand Up @@ -112,14 +112,13 @@ object in the UI, or on the command line with the `--context` flag:
genkit flow:run selfSummaryFlow '{"uid": "abc-def"}' --context '{"auth": {"email_verified": true}}'
```

## Cloud Functions for Firebase integration
## Authorizing with Cloud Functions for Firebase

The Cloud Functions for Firebase SDKs support Genkit including
integration with Firebase Auth / Google Cloud Identity Platform, as well as
built-in Firebase App Check support.

### Authorization

### User authentication
The `onCallGenkit()` wrapper provided by the Firebase Functions library works
natively with the Cloud Functions for Firebase
[client SDKs](https://firebase.google.com/docs/functions/callable?gen=2nd#call_the_function).
Expand Down Expand Up @@ -192,7 +191,7 @@ export const selfSummary = onCallGenkit({
}, selfSummaryFlow);
```

### Client integrity
#### Client integrity

Authentication on its own goes a long way to protect your app. But it's also
important to ensure that only your client apps are calling your functions. The
Expand Down Expand Up @@ -228,53 +227,74 @@ export const selfSummary = onCallGenkit({

When deploying flows to a server context outside of Cloud Functions for
Firebase, you'll want to have a way to set up your own authorization checks
alongside the native flows. You have two options:

1. Use whatever server framework you like, and pass the auth context through
using the flow call as noted earlier.

1. Use `startFlowsServer()`, which the `@genkit-ai/express` plugin
exposes, and provide Express auth middleware in the flow server config:

```ts
import { genkit, z } from 'genkit';
import { startFlowServer, withAuth } from '@genkit-ai/express';

const ai = genkit({ ... });;

export const selfSummaryFlow = ai.defineFlow(
{
name: 'selfSummaryFlow',
inputSchema: z.object({ uid: z.string() }),
outputSchema: z.string(),
},
async (input) => {
// Flow logic here...
}
);

const authProvider = (req, res, next) => {
const token = req.headers['authorization'];
const user = yourVerificationLibrary(token);

// Pass auth information to the flow
req.auth = user;
next();
};

startFlowServer({
flows: [
withAuth(selfSummaryFlow, authProvider, ({ auth, action, input, request }) => {
if (!auth) {
throw new Error('Authorization required.');
}
if (input.uid !== auth.uid) {
throw new Error('You may only summarize your own profile data.');
}
})
],
}); // Registers the auth provider middleware and policy
```

For more information about using Express, see the
[Cloud Run](/genkit/cloud-run) instructions.
alongside the native flows.

Use a `ContextProvider`, which both populates context values such as `auth` as well
as allowing you to provide a declarative policy or a policy callback. The genkit
SDK provides `ContextProvider` such as `apiKey`, and plugins may expose them as well,
such as `@genkit-ai/firebase/context` which can be used to build Firebase apps with
backends that are not Cloud Functions for Firebase.

Given the snippet common to all kinds of applications:

```ts
// Express app with a simple API key
import { genkit, z } from 'genkit';

const ai = genkit({ ... });;

export const selfSummaryFlow = ai.defineFlow(
{
name: 'selfSummaryFlow',
inputSchema: z.object({ uid: z.string() }),
outputSchema: z.string(),
},
async (input) => {
// Flow logic here...
}
);
```

You could secure a simple "flow server" express app with:

```ts
import { apiKey } from "genkit";
import { startFlowServer, withContext } from "@genkit-ai/express";

startFlowServer({
flows: [
withContext(selfSummaryFlow, apiKey(process.env.REQUIRED_API_KEY))
],
});
```

Or you can build a custom express application using the same tools:

```ts
import { apiKey } from "genkit";
import * as express from "express";
import { expressHandler } from "@genkit-ai/express;

const app = express();
// Capture but don't validate the API key (or its absence)
app.post('/summary', expressHandler(selfSummaryFlow, { contextProvider: apiKey()}))

app.listen(process.env.PORT, () => {
console.log(`Listening on port ${process.env.PORT}`);
})
```

`ContextProvider`s are intended to be abstract of any web framework, so these
tools work in other frameworks like Next.js as well. Here is an example of a
Firebase app built on Next.js.

```ts
import { appRoute } from "@genkit-ai/express";
import { firebaseContext } from "@genkit-ai/firebase";

export const POST = appRoute(selfSummaryFlow, { contextProvider: firebaseContext })
```

<!-- NOTE: Should we provide more docs? E.g. docs into various web frameworks and hosting services? -->
For more information about using Express, see the
[Cloud Run](/genkit/cloud-run) instructions.
1 change: 1 addition & 0 deletions docs/cloud-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ endpoints.

When you make the call, specify the flows you want to serve:

There is also
```ts
import { startFlowServer } from '@genkit-ai/express';

Expand Down
2 changes: 2 additions & 0 deletions docs/deploy-node.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
<!-- TODO: Add Next docs too. Maybe we need a web-hosting page that deploy-node
and cloud-run links to, which links to express, next, and maybe cloud functions>
# Deploy flows to any Node.js platform
Firebase Genkit has built-in integrations that help you deploy your flows to
Expand Down
12 changes: 12 additions & 0 deletions docs/errors/types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Error Types

Genkit knows about two specialized types: `GenkitError` and `UserFacingError`.
`GenkitError` is intended for use by Genkit itself or Genkit plugins.
`UserFacingError` is intended for [`ContextProviders`](../deploy-node.md) and
your code. The separation between these two error types helps you better understand
where your error is coming from.

Genkit plugins for web hosting (e.g. [`@genkit-ai/express`](https://js.api.genkit.dev/modules/_genkit-ai_express.html) or [`@genkit-ai/next`](https://js.api.genkit.dev/modules/_genkit-ai_next.html))
SHOULD capture all other Error types and instead report them as an internal error in the response.
This adds a layer of security to your application by ensuring that internal details of your application
do not leak to attackers.
65 changes: 65 additions & 0 deletions js/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import { runInActionRuntimeContext } from './action.js';
import { UserFacingError } from './error.js';
import { HasRegistry, Registry } from './registry.js';

const contextAlsKey = 'core.auth.context';
Expand Down Expand Up @@ -58,3 +59,67 @@ export function getContext(
registry = registry as Registry;
return registry.asyncStore.getStore<ActionContext>(contextAlsKey);
}

/**
* A universal type that request handling extensions (e.g. express, next) can map their request to.
* This allows ContextProviders to build consistent interfacese on any web framework.
* Headers must be lowercase to ensure portability.
*/
export interface RequestData<T = any> {
method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'QUERY';
headers: Record<string, string>;
input: T;
}

/**
* Middleware can read request data and add information to the context that will
* be passed to the Action. If middleware throws an error, that error will fail
* the request and the Action will not be invoked. Expected cases should return a
* UserFacingError, which allows the request handler to know what data is safe to
* return to end users.
*
* Middleware can provide validation in addition to parsing. For example, an auth
* middleware can have policies for validating auth in addition to passing auth context
* to the Action.
*/
export type ContextProvider<
C extends ActionContext = ActionContext,
T = any,
> = (request: RequestData<T>) => C | Promise<C>;

export interface ApiKeyContext extends ActionContext {
auth: {
apiKey: string | undefined;
};
}

export function apiKey(
policy: (context: ApiKeyContext) => void | Promise<void>
): ContextProvider<ApiKeyContext>;
export function apiKey(value?: string): ContextProvider<ApiKeyContext>;
export function apiKey(
valueOrPolicy?: ((context: ApiKeyContext) => void | Promise<void>) | string
): ContextProvider<ApiKeyContext> {
return async function (request: RequestData): Promise<ApiKeyContext> {
const context: ApiKeyContext = {
auth: { apiKey: request.headers['authorization'] },
};
if (typeof valueOrPolicy === 'string') {
if (!context.auth?.apiKey) {
console.error('THROWING UNAUTHENTICATED');
throw new UserFacingError('UNAUTHENTICATED', 'Unauthenticated');
}
if (context.auth?.apiKey != valueOrPolicy) {
console.error('Throwing PERMISSION_DENIED');
throw new UserFacingError('PERMISSION_DENIED', 'Permission Denied');
}
} else if (typeof valueOrPolicy === 'function') {
await valueOrPolicy(context);
} else if (typeof valueOrPolicy !== 'undefined') {
throw new Error(
`Invalid type ${typeof valueOrPolicy} passed to apiKey()`
);
}
return context;
};
}
63 changes: 62 additions & 1 deletion js/core/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,15 @@
*/

import { Registry } from './registry.js';
import { StatusName } from './statusTypes.js';
import { httpStatusCode, StatusName } from './statusTypes.js';

export { StatusName };

export interface HttpErrorWireFormat {
details?: unknown;
message: string;
status: StatusName;
}

/**
* Base error class for Genkit errors.
Expand All @@ -24,6 +32,11 @@ export class GenkitError extends Error {
source?: string;
status: StatusName;
detail?: any;
code: number;

// For easy printing, we wrap the error with information like the source
// and status, but that's redundant with JSON.
originalMessage: string;

constructor({
status,
Expand All @@ -37,10 +50,25 @@ export class GenkitError extends Error {
source?: string;
}) {
super(`${source ? `${source}: ` : ''}${status}: ${message}`);
this.originalMessage = message;
this.code = httpStatusCode(status);
this.status = status;
this.detail = detail;
this.name = 'GenkitError';
}

/**
* Returns a JSON-serializable representation of this object.
*/
public toJSON(): HttpErrorWireFormat {
return {
// This error type is used by 3P authors with the field "detail",
// but the actual Callable protocol value is "details"
...(this.detail === undefined ? {} : { details: this.detail }),
status: this.status,
message: this.originalMessage,
};
}
}

export class UnstableApiError extends GenkitError {
Expand Down Expand Up @@ -69,6 +97,39 @@ export function assertUnstable(
}
}

/**
* Creates a new class of Error for issues to be returned to users.
* Using this error allows a web framework handler (e.g. express, next) to know it
* is safe to return the message in a request. Other kinds of errors will
* result in a generic 500 message to avoid the possibility of internal
* exceptions being leaked to attackers.
* In JSON requests, code will be an HTTP code and error will be a response body.
* In streaming requests, { code, message } will be passed as the error message.
*/
export class UserFacingError extends GenkitError {
constructor(status: StatusName, message: string, details?: any) {
super({ status, detail: details, message });
super.name = 'UserFacingError';
}
}

export function getHttpStatus(e: any): number {
if (e instanceof GenkitError) {
return e.code;
}
return 500;
}

export function getCallableJSON(e: any): HttpErrorWireFormat {
if (e instanceof GenkitError) {
return e.toJSON();
}
return {
message: 'Internal Error',
status: 'INTERNAL',
};
}

/**
* Extracts error message from the given error object, or if input is not an error then just turn the error into a string.
*/
Expand Down
Loading

0 comments on commit 2983ecb

Please sign in to comment.