fix: oauth scopes
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from "./leafOAuth.js";
|
||||
export * from "./mcpOAuth.js";
|
||||
export * from "./oauthUrls.js";
|
||||
|
||||
15
packages/auth/src/oauth/leafOAuth.ts
Normal file
15
packages/auth/src/oauth/leafOAuth.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes";
|
||||
import type { ScopeString } from "@autumn/shared/scopeDefinitions";
|
||||
|
||||
const leafScopeSet = new Set<string>(LEAF_OAUTH_SCOPES);
|
||||
|
||||
export const getDefaultOAuthScopes = (requestedScopes?: string[] | null) => {
|
||||
const requested =
|
||||
requestedScopes && requestedScopes.length > 0
|
||||
? requestedScopes
|
||||
: [...LEAF_OAUTH_SCOPES];
|
||||
|
||||
return [...new Set(requested)].filter((scope): scope is ScopeString =>
|
||||
leafScopeSet.has(scope),
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,3 @@
|
||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared/leafOAuthScopes";
|
||||
import type { ScopeString } from "@autumn/shared/scopeDefinitions";
|
||||
|
||||
export const MCP_CLIENT_KIND = "mcp_client";
|
||||
export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack";
|
||||
export const AUTUMN_ADMIN_OAUTH_CLIENT_ID = "autumn_admin";
|
||||
@@ -71,19 +68,6 @@ export const isMcpOAuthResource = (resource: string | null | undefined) => {
|
||||
return new URL(resource).pathname.replace(/\/+$/, "").endsWith("/mcp");
|
||||
};
|
||||
|
||||
const leafScopeSet = new Set<string>(LEAF_OAUTH_SCOPES);
|
||||
|
||||
export const getLeafMcpOAuthScopes = (requestedScopes?: string[] | null) => {
|
||||
const requested =
|
||||
requestedScopes && requestedScopes.length > 0
|
||||
? requestedScopes
|
||||
: [...LEAF_OAUTH_SCOPES];
|
||||
|
||||
return [...new Set(requested)].filter((scope): scope is ScopeString =>
|
||||
leafScopeSet.has(scope),
|
||||
);
|
||||
};
|
||||
|
||||
export const getResourceFromOAuthTokenRequest = async (request: Request) => {
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
const rawBody = await request.text();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
getLeafMcpOAuthScopes,
|
||||
getDefaultOAuthScopes,
|
||||
MCP_CLIENT_KIND,
|
||||
MCP_OAUTH_CLIENTS,
|
||||
type MpcClientInfo,
|
||||
@@ -126,9 +126,9 @@ export const getRequestedScopesForMcpClient = ({
|
||||
scope: unknown;
|
||||
}) => {
|
||||
if (typeof scope !== "string" || !scope.trim()) {
|
||||
return getLeafMcpOAuthScopes();
|
||||
return getDefaultOAuthScopes();
|
||||
}
|
||||
return getLeafMcpOAuthScopes(scope.split(" "));
|
||||
return getDefaultOAuthScopes(scope.split(" "));
|
||||
};
|
||||
|
||||
const mergeMetadata = ({
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { AppEnv, RecaseError } from "@autumn/shared";
|
||||
import type { Context } from "hono";
|
||||
import { db } from "@/db/initDrizzle.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { auth } from "@/utils/auth.js";
|
||||
import { oauthConsentRepo } from "../repos/index.js";
|
||||
import { isAtmnOAuthClientId } from "./atmnOAuthClients.js";
|
||||
import { assertMcpOAuthScopeGrant } from "./mcpOAuthScopes.js";
|
||||
import { getOAuthConsentScopeGrant } from "./oauthConsentScopes.js";
|
||||
|
||||
type RequestFields = Record<string, unknown>;
|
||||
|
||||
@@ -68,10 +67,6 @@ const getRedirectUriFromFields = (fields: RequestFields) =>
|
||||
getString(fields.redirectUri) ??
|
||||
getNestedOAuthField(fields.oauth_query, "redirect_uri");
|
||||
|
||||
const getResourceFromFields = (fields: RequestFields) =>
|
||||
getString(fields.resource) ??
|
||||
getNestedOAuthField(fields.oauth_query, "resource");
|
||||
|
||||
const getScopesFromFields = (fields: RequestFields) => {
|
||||
const rawScope =
|
||||
getString(fields.scope) ?? getNestedOAuthField(fields.oauth_query, "scope");
|
||||
@@ -159,25 +154,19 @@ export const handleOAuthConsentWithEnv = async (c: Context) => {
|
||||
const orgId = session?.session?.activeOrganizationId;
|
||||
if (userId && orgId) {
|
||||
try {
|
||||
const scopeGrant = await assertMcpOAuthScopeGrant({
|
||||
clientId,
|
||||
ctx: {
|
||||
db,
|
||||
oauthResource: getResourceFromFields(fields) ?? undefined,
|
||||
org: { id: orgId },
|
||||
userId,
|
||||
} as AutumnContext,
|
||||
const scopeGrant = await getOAuthConsentScopeGrant({
|
||||
db,
|
||||
organizationId: orgId,
|
||||
requestedScopes: getScopesFromFields(fields),
|
||||
userId,
|
||||
});
|
||||
grantedScopes = scopeGrant;
|
||||
request = withScope({
|
||||
contentType,
|
||||
request,
|
||||
fields,
|
||||
scope: scopeGrant.join(" "),
|
||||
});
|
||||
if (scopeGrant) {
|
||||
grantedScopes = scopeGrant;
|
||||
request = withScope({
|
||||
contentType,
|
||||
request,
|
||||
fields,
|
||||
scope: scopeGrant.join(" "),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RecaseError) {
|
||||
return jsonOAuthError({ error });
|
||||
|
||||
@@ -6,14 +6,15 @@ import {
|
||||
import { RecaseError } from "@autumn/shared";
|
||||
import type { Context } from "hono";
|
||||
import { db } from "@/db/initDrizzle.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { auth } from "@/utils/auth.js";
|
||||
import { assertMcpOAuthScopeGrant } from "./mcpOAuthScopes.js";
|
||||
import { oauthAccessTokenRepo, oauthRefreshTokenRepo } from "../repos/index.js";
|
||||
import { isMcpOAuthClient } from "./mcpOAuthScopes.js";
|
||||
import {
|
||||
getExternalOAuthApiKeyForToken,
|
||||
getOAuthAccessTokenRecord,
|
||||
scopesFromOAuthScopeString,
|
||||
} from "./oauthAccessTokenApiKey.js";
|
||||
import { getOAuthConsentScopeGrant } from "./oauthConsentScopes.js";
|
||||
|
||||
const getString = (value: unknown) =>
|
||||
typeof value === "string" && value.length > 0 ? value : null;
|
||||
@@ -131,25 +132,41 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
|
||||
resource,
|
||||
requestedScopes,
|
||||
});
|
||||
const mcpScopeGrant = await assertMcpOAuthScopeGrant({
|
||||
clientId: tokenRecord.clientId,
|
||||
ctx: {
|
||||
db,
|
||||
oauthResource: resource ?? undefined,
|
||||
org: { id: tokenRecord.referenceId },
|
||||
userId: tokenRecord.userId,
|
||||
} as AutumnContext,
|
||||
const issuedScopes = await getOAuthConsentScopeGrant({
|
||||
db,
|
||||
organizationId: tokenRecord.referenceId,
|
||||
requestedScopes: tokenRecord.scopes,
|
||||
userId: tokenRecord.userId,
|
||||
});
|
||||
tokenRecord.scopes = issuedScopes;
|
||||
if (tokenRecord.id) {
|
||||
await oauthAccessTokenRepo.updateScopes({
|
||||
db,
|
||||
id: tokenRecord.id,
|
||||
scopes: issuedScopes,
|
||||
});
|
||||
}
|
||||
if (tokenRecord.refreshId) {
|
||||
await oauthRefreshTokenRepo.updateScopes({
|
||||
db,
|
||||
id: tokenRecord.refreshId,
|
||||
scopes: issuedScopes,
|
||||
});
|
||||
}
|
||||
const isMcpClient = await isMcpOAuthClient({
|
||||
clientId: tokenRecord.clientId,
|
||||
db,
|
||||
resource: resource ?? undefined,
|
||||
});
|
||||
if (
|
||||
mcpScopeGrant ||
|
||||
isMcpClient ||
|
||||
returnsOAuthAccessTokenForClientId({ clientId: tokenRecord.clientId })
|
||||
) {
|
||||
return jsonTokenResponse({
|
||||
body: rewriteOAuthAccessTokenBody({
|
||||
accessToken: prefixOAuthToken({ token: accessToken }),
|
||||
body,
|
||||
scopes: mcpScopeGrant ?? tokenRecord.scopes,
|
||||
scopes: tokenRecord.scopes,
|
||||
}),
|
||||
response,
|
||||
status: response.status,
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
import {
|
||||
getLeafMcpOAuthScopes,
|
||||
getDefaultOAuthScopes,
|
||||
isKnownMcpOAuthClientId,
|
||||
isMcpOAuthClientRecord,
|
||||
isMcpOAuthResource,
|
||||
} from "@autumn/auth/oauth";
|
||||
import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
|
||||
import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js";
|
||||
import { oauthClientRepo } from "../repos/index.js";
|
||||
|
||||
export const isMcpOAuthClient = async ({
|
||||
clientId,
|
||||
db,
|
||||
resource,
|
||||
}: {
|
||||
clientId: string;
|
||||
db: DrizzleCli;
|
||||
resource?: string;
|
||||
}) => {
|
||||
if (isMcpOAuthResource(resource)) return true;
|
||||
if (isKnownMcpOAuthClientId({ clientId })) return true;
|
||||
|
||||
const client = await oauthClientRepo.getByClientId({ db, clientId });
|
||||
if (!client) return false;
|
||||
|
||||
return isMcpOAuthClientRecord(client);
|
||||
};
|
||||
|
||||
export const isMcpOAuthClientId = async ({
|
||||
clientId,
|
||||
ctx,
|
||||
}: {
|
||||
clientId: string;
|
||||
ctx: AutumnContext;
|
||||
}) => {
|
||||
if (isMcpOAuthResource(ctx.oauthResource)) return true;
|
||||
if (isKnownMcpOAuthClientId({ clientId })) return true;
|
||||
|
||||
const client = await oauthClientRepo.getByClientId({ db: ctx.db, clientId });
|
||||
if (!client) return false;
|
||||
|
||||
return isMcpOAuthClientRecord(client);
|
||||
};
|
||||
}) =>
|
||||
isMcpOAuthClient({
|
||||
clientId,
|
||||
db: ctx.db,
|
||||
resource: ctx.oauthResource,
|
||||
});
|
||||
|
||||
export const getMcpOAuthScopeGrant = async ({
|
||||
clientId,
|
||||
@@ -44,7 +60,7 @@ export const getMcpOAuthScopeGrant = async ({
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
const leafScopes = getLeafMcpOAuthScopes(requestedScopes);
|
||||
const leafScopes = getDefaultOAuthScopes(requestedScopes);
|
||||
const { scopes: userScopes } = await getScopesForUserInOrg({
|
||||
db: ctx.db,
|
||||
userId: ctx.userId,
|
||||
|
||||
34
server/src/internal/auth/oauth/oauthConsentScopes.ts
Normal file
34
server/src/internal/auth/oauth/oauthConsentScopes.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getDefaultOAuthScopes } from "@autumn/auth/oauth";
|
||||
import { ErrCode, isScopeSubset, RecaseError } from "@autumn/shared";
|
||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||
import { getScopesForUserInOrg } from "@/utils/authUtils/customSessionScopes.js";
|
||||
|
||||
export const getOAuthConsentScopeGrant = async ({
|
||||
db,
|
||||
organizationId,
|
||||
requestedScopes,
|
||||
userId,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
organizationId: string;
|
||||
requestedScopes?: string[] | null;
|
||||
userId: string;
|
||||
}) => {
|
||||
const requestedLeafScopes = getDefaultOAuthScopes(requestedScopes);
|
||||
const { scopes: userScopes } = await getScopesForUserInOrg({
|
||||
db,
|
||||
userId,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
const grant = requestedLeafScopes.filter((scope) =>
|
||||
isScopeSubset([scope], userScopes),
|
||||
);
|
||||
if (grant.length > 0) return grant;
|
||||
|
||||
throw new RecaseError({
|
||||
message: "No requested scopes can be granted to this OAuth client",
|
||||
code: ErrCode.InsufficientScopes,
|
||||
statusCode: 403,
|
||||
});
|
||||
};
|
||||
@@ -43,7 +43,22 @@ export const deleteOAuthAccessTokensByClientAndReference = async ({
|
||||
),
|
||||
);
|
||||
|
||||
export const updateOAuthAccessTokenScopes = async ({
|
||||
db,
|
||||
id,
|
||||
scopes,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
id: string;
|
||||
scopes: string[];
|
||||
}) =>
|
||||
db
|
||||
.update(oauthAccessToken)
|
||||
.set({ scopes })
|
||||
.where(eq(oauthAccessToken.id, id));
|
||||
|
||||
export const oauthAccessTokenRepo = {
|
||||
getValidByTokenValues: getValidOAuthAccessTokenByTokenValues,
|
||||
deleteByClientAndReference: deleteOAuthAccessTokensByClientAndReference,
|
||||
updateScopes: updateOAuthAccessTokenScopes,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,21 @@ export const deleteOAuthRefreshTokensByClientAndReference = async ({
|
||||
),
|
||||
);
|
||||
|
||||
export const updateOAuthRefreshTokenScopes = async ({
|
||||
db,
|
||||
id,
|
||||
scopes,
|
||||
}: {
|
||||
db: DrizzleCli;
|
||||
id: string;
|
||||
scopes: string[];
|
||||
}) =>
|
||||
db
|
||||
.update(oauthRefreshToken)
|
||||
.set({ scopes })
|
||||
.where(eq(oauthRefreshToken.id, id));
|
||||
|
||||
export const oauthRefreshTokenRepo = {
|
||||
deleteByClientAndReference: deleteOAuthRefreshTokensByClientAndReference,
|
||||
updateScopes: updateOAuthRefreshTokenScopes,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ export type OAuthApiKeyRequestBody = {
|
||||
};
|
||||
|
||||
export type ResourceAccessTokenRecord = {
|
||||
id?: string;
|
||||
refreshId?: string | null;
|
||||
userId: string | null;
|
||||
referenceId: string | null;
|
||||
clientId: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getLeafMcpOAuthScopes } from "@autumn/auth/oauth";
|
||||
import { getDefaultOAuthScopes } from "@autumn/auth/oauth";
|
||||
import { LEAF_OAUTH_SCOPES } from "@autumn/shared";
|
||||
import { Scopes } from "@autumn/shared/scopeDefinitions";
|
||||
import { getRequestedScopesForMcpClient } from "@/internal/auth/actions/registerMcpOAuthClient.js";
|
||||
@@ -37,7 +37,7 @@ describe("getRequestedScopesForMcpClient", () => {
|
||||
|
||||
test("caps OAuth grants to Leaf scopes", () => {
|
||||
expect(
|
||||
getLeafMcpOAuthScopes([
|
||||
getDefaultOAuthScopes([
|
||||
Scopes.Customers.Read,
|
||||
Scopes.ApiKeys.Write,
|
||||
Scopes.Analytics.Read,
|
||||
|
||||
Reference in New Issue
Block a user