@shivaedev/mcp 0.1.11 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -0
- package/README.md +31 -2
- package/dist/adapters/bearer.d.ts +6 -0
- package/dist/adapters/bearer.d.ts.map +1 -0
- package/dist/adapters/bearer.js +37 -0
- package/dist/adapters/bearer.js.map +1 -0
- package/dist/adapters/betterAuth.d.ts +6 -15
- package/dist/adapters/betterAuth.d.ts.map +1 -1
- package/dist/adapters/betterAuth.js +9 -24
- package/dist/adapters/betterAuth.js.map +1 -1
- package/dist/adapters/oauthGrants.d.ts +32 -0
- package/dist/adapters/oauthGrants.d.ts.map +1 -0
- package/dist/adapters/oauthGrants.js +43 -0
- package/dist/adapters/oauthGrants.js.map +1 -0
- package/package.json +8 -8
- package/src/adapters/bearer.ts +48 -0
- package/src/adapters/betterAuth.ts +11 -39
- package/src/adapters/oauthGrants.ts +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1 - 2026-10-31
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
|
|
7
|
+
- Update `@shivaedev/access` from 0.3.3 to 0.3.4.
|
|
8
|
+
- Update `@shivaedev/errors` from 0.3.0 to 0.4.0.
|
|
9
|
+
- Update `@shivaedev/identity` from 0.12.0 to 0.13.0.
|
|
10
|
+
|
|
11
|
+
## 0.2.0 - 2026-10-31
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Return `revokeClient(userId, clientId)` and `revokeAllClients(userId)` Effects from `betterAuthMcp` to delete consent and stored access and refresh tokens.
|
|
16
|
+
- Allow provider `scopes`, including `offline_access`, and an in-process `jwks` source for token verification.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Refuse banned users and access tokens whose client consent no longer exists with the MCP unauthorized resource challenge.
|
|
21
|
+
|
|
22
|
+
### Breaking
|
|
23
|
+
|
|
24
|
+
- Custom Better Auth wrappers passed to `betterAuthMcp` must expose the native context's adapter as well as its user lookup. Pass the full Better Auth instance. Custom signed tokens must identify their OAuth client and have stored consent for that user and client.
|
|
25
|
+
|
|
3
26
|
## 0.1.11 - 2026-10-31
|
|
4
27
|
|
|
5
28
|
### Changed
|
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Application roles and grants come from `@shivaedev/access`; identity resolves th
|
|
|
67
67
|
|
|
68
68
|
### Connect OAuth to identity and the host
|
|
69
69
|
|
|
70
|
-
The Better Auth adapter is an optional edge module. Add its provider to the auth instance used by identity, alongside Better Auth's JWT plugin. `mcpProvider`
|
|
70
|
+
The Better Auth adapter is an optional edge module. Add its provider to the auth instance used by identity, alongside Better Auth's JWT plugin. `mcpProvider` defaults to the `mcp` scope and configures the endpoint's OAuth resource. Pass `scopes: ["mcp", "offline_access"]` to allow refresh tokens; the client must also register the `refresh_token` grant and request `offline_access` during authorization. The application's login and consent pages implement the provider's ordinary UI flow; the provider owns authorization codes, PKCE and tokens.
|
|
71
71
|
|
|
72
72
|
```ts
|
|
73
73
|
import { admin, jwt } from "better-auth/plugins";
|
|
@@ -121,7 +121,36 @@ const application = Effect.gen(function* () {
|
|
|
121
121
|
|
|
122
122
|
`authPool`, `Api` and `Handlers` are the application's existing auth database, RPC group and handlers. Run the Better Auth migrations with the OAuth and JWT plugins included. For existing role management, keep the application's existing role plugin and `RoleSet` in this configuration.
|
|
123
123
|
|
|
124
|
-
The edge adapter verifies access-token signatures, issuer, endpoint audience and `mcp` scope, then loads the current user through Better Auth. Identity resolves the stored role on each request. An invalid token receives `401` with a `WWW-Authenticate` protected-resource metadata pointer. The metadata names the authorization server and its discovery endpoint, so a client can start OAuth. MCP bearer credentials are separate from browser session cookies.
|
|
124
|
+
The edge adapter verifies access-token signatures, issuer, endpoint audience and `mcp` scope, then loads the current user and the token client's consent through Better Auth. Banned users and clients without consent receive the same unauthorized response as invalid tokens. Identity resolves the stored role on each request. An invalid token receives `401` with a `WWW-Authenticate` protected-resource metadata pointer. The metadata names the authorization server and its discovery endpoint, so a client can start OAuth. MCP bearer credentials are separate from browser session cookies.
|
|
125
|
+
|
|
126
|
+
### Revoke client grants
|
|
127
|
+
|
|
128
|
+
The adapter returns `revokeClient(userId, clientId)` and `revokeAllClients(userId)` as Effects that fail with `AuthUnavailable` when storage fails. The application authorizes these operations. Each deletes matching consent, stored access tokens and refresh tokens through Better Auth's adapter. Use the first when disconnecting a client and the second after a password reset. JWT access tokens are refused because each request requires the client's consent.
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
yield* oauth.revokeClient(userId, clientId);
|
|
132
|
+
yield* oauth.revokeAllClients(userId);
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Deleting consent through Better Auth also refuses MCP access immediately. Use these revocation methods to delete refresh tokens as well.
|
|
136
|
+
|
|
137
|
+
### Dynamic origins and local keys
|
|
138
|
+
|
|
139
|
+
When Better Auth uses a dynamic `baseURL`, give it a `fallback`, for example `{ allowedHosts: ["app.example.com"], fallback: "https://app.example.com" }`. The adapter loads OAuth metadata outside a request, so Better Auth needs that origin to determine the issuer.
|
|
140
|
+
|
|
141
|
+
By default verification fetches the authorization server's JWKS URL. Pass `jwks` to use an in-process public key source instead, including on story hosts whose public origin has no HTTP route:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
const oauth = yield* betterAuthMcp({
|
|
145
|
+
auth: mounted.auth,
|
|
146
|
+
resource,
|
|
147
|
+
origin,
|
|
148
|
+
roles: Roles,
|
|
149
|
+
jwks: () => mounted.auth.api.getJwks(),
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The callback returns the public JWK set from the JWT plugin. Signature, issuer, audience and `mcp` scope checks still apply. Signing keys stay with the auth instance.
|
|
125
154
|
|
|
126
155
|
### Install and limits
|
|
127
156
|
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { verifyJwsAccessToken } from "better-auth/oauth2";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
4
|
+
export type JwksSource = Parameters<typeof verifyJwsAccessToken>[1]["jwksFetch"];
|
|
5
|
+
export declare function bearer(jwks: JwksSource, audience: string, issuer: string): (token: string | undefined) => Effect.Effect<Option.Option<never> | Option.Option<import("better-auth").JWTPayload>, AuthUnavailable, never>;
|
|
6
|
+
//# sourceMappingURL=bearer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bearer.d.ts","sourceRoot":"","sources":["../../src/adapters/bearer.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAExC,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAEhE,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,OAAO,oBAAoB,CAAC,CAAC,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC;AAyBjF,wBAAgB,MAAM,CAAC,IAAI,EAAE,UAAU,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,WACzD,MAAM,GAAG,SAAS,mHAejC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { APIError, isAPIError } from "better-auth/api";
|
|
2
|
+
import { verifyJwsAccessToken } from "better-auth/oauth2";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { redact } from "@shivaedev/errors/redact.ts";
|
|
5
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
6
|
+
const INVALID_TOKEN_ERRORS = new Set([
|
|
7
|
+
"ERR_JWT_EXPIRED",
|
|
8
|
+
"ERR_JWT_CLAIM_VALIDATION_FAILED",
|
|
9
|
+
"ERR_JWT_INVALID",
|
|
10
|
+
"ERR_JWS_INVALID",
|
|
11
|
+
"ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
|
|
12
|
+
"ERR_JWKS_NO_MATCHING_KEY",
|
|
13
|
+
"ERR_JOSE_ALG_NOT_ALLOWED",
|
|
14
|
+
"ERR_JOSE_NOT_SUPPORTED",
|
|
15
|
+
]);
|
|
16
|
+
function bearerFailure(cause) {
|
|
17
|
+
if ((isAPIError(cause) && (cause.status === "UNAUTHORIZED" || cause.status === "FORBIDDEN"))
|
|
18
|
+
|| (cause instanceof Error && INVALID_TOKEN_ERRORS.has(String(Reflect.get(cause, "code"))))) {
|
|
19
|
+
return Effect.succeed(Option.none());
|
|
20
|
+
}
|
|
21
|
+
return Effect.logError("MCP authentication verification failed", redact(cause)).pipe(Effect.andThen(Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" }))));
|
|
22
|
+
}
|
|
23
|
+
export function bearer(jwks, audience, issuer) {
|
|
24
|
+
return (token) => {
|
|
25
|
+
if (!token) {
|
|
26
|
+
return Effect.succeed(Option.none());
|
|
27
|
+
}
|
|
28
|
+
return Effect.tryPromise(async () => {
|
|
29
|
+
const claims = await verifyJwsAccessToken(token, { jwksFetch: jwks, verifyOptions: { audience, issuer } });
|
|
30
|
+
if (typeof claims.scope !== "string" || !claims.scope.split(" ").includes("mcp") || claims.cnf !== undefined) {
|
|
31
|
+
throw new APIError("UNAUTHORIZED");
|
|
32
|
+
}
|
|
33
|
+
return claims;
|
|
34
|
+
}).pipe(Effect.map(Option.some), Effect.catch(({ cause }) => bearerFailure(cause)));
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=bearer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"bearer.js","sourceRoot":"","sources":["../../src/adapters/bearer.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAIhE,MAAM,oBAAoB,GAAG,IAAI,GAAG,CAAC;IACpC,iBAAiB;IACjB,iCAAiC;IACjC,iBAAiB;IACjB,iBAAiB;IACjB,uCAAuC;IACvC,0BAA0B;IAC1B,0BAA0B;IAC1B,wBAAwB;CACxB,CAAC,CAAC;AAEH,SAAS,aAAa,CAAC,KAAc;IACpC,IACC,CAAC,UAAU,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,MAAM,KAAK,cAAc,IAAI,KAAK,CAAC,MAAM,KAAK,WAAW,CAAC,CAAC;WACrF,CAAC,KAAK,YAAY,KAAK,IAAI,oBAAoB,CAAC,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC,CAAC,EAC1F,CAAC;QACF,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IACtC,CAAC;IACD,OAAO,MAAM,CAAC,QAAQ,CAAC,wCAAwC,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,CACnF,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,2CAA2C,EAAE,CAAC,CAAC,CAAC,CAC1G,CAAC;AACH,CAAC;AAED,MAAM,UAAU,MAAM,CAAC,IAAgB,EAAE,QAAgB,EAAE,MAAc;IACxE,OAAO,CAAC,KAAyB,EAAE,EAAE;QACpC,IAAI,CAAC,KAAK,EAAE,CAAC;YACZ,OAAO,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;QACtC,CAAC;QACD,OAAO,MAAM,CAAC,UAAU,CAAC,KAAK,IAAI,EAAE;YACnC,MAAM,MAAM,GAAG,MAAM,oBAAoB,CAAC,KAAK,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,aAAa,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC;YAC3G,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,CAAC,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,MAAM,CAAC,GAAG,KAAK,SAAS,EAAE,CAAC;gBAC9G,MAAM,IAAI,QAAQ,CAAC,cAAc,CAAC,CAAC;YACpC,CAAC;YACD,OAAO,MAAM,CAAC;QACf,CAAC,CAAC,CAAC,IAAI,CACN,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,EACvB,MAAM,CAAC,KAAK,CAAC,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC,CACjD,CAAC;IACH,CAAC,CAAC;AACH,CAAC"}
|
|
@@ -1,35 +1,26 @@
|
|
|
1
1
|
import { type OAuthOptions, oauthProvider, type Scope } from "@better-auth/oauth-provider";
|
|
2
|
-
import type { AuthContext, BetterAuthOptions } from "better-auth";
|
|
3
2
|
import { Effect } from "effect";
|
|
4
3
|
import { HttpRouter } from "effect/http";
|
|
5
4
|
import type { RoleSet } from "@shivaedev/access/roleSet.ts";
|
|
6
5
|
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
7
6
|
import type { OriginPolicy } from "@shivaedev/identity/server/origin.ts";
|
|
8
7
|
import type { SessionPolicy, UserSessionShape } from "@shivaedev/identity/server/session.ts";
|
|
9
|
-
|
|
8
|
+
import { type JwksSource } from "./bearer.ts";
|
|
9
|
+
import { type OauthAuth } from "./oauthGrants.ts";
|
|
10
|
+
export declare function mcpProvider({ resource, scopes, ...options }: Omit<OAuthOptions<Scope[]>, "resources" | "clientRegistrationDefaultResources"> & {
|
|
10
11
|
readonly resource: string;
|
|
11
12
|
}): ReturnType<typeof oauthProvider>;
|
|
12
|
-
interface OauthAuth {
|
|
13
|
-
readonly $context: Promise<{
|
|
14
|
-
readonly internalAdapter: Pick<AuthContext["internalAdapter"], "findUserById">;
|
|
15
|
-
}>;
|
|
16
|
-
readonly api: {
|
|
17
|
-
readonly "getOAuthServerConfig": () => Promise<{
|
|
18
|
-
readonly issuer: string;
|
|
19
|
-
readonly "jwks_uri"?: string;
|
|
20
|
-
}>;
|
|
21
|
-
};
|
|
22
|
-
readonly options: BetterAuthOptions;
|
|
23
|
-
}
|
|
24
13
|
export declare const betterAuthMcp: (options: {
|
|
25
14
|
readonly auth: OauthAuth;
|
|
26
15
|
readonly resource: string;
|
|
16
|
+
readonly jwks?: JwksSource;
|
|
27
17
|
readonly origin: OriginPolicy;
|
|
28
18
|
readonly roles?: RoleSet;
|
|
29
19
|
}) => Effect.Effect<{
|
|
20
|
+
revokeAllClients: (userId: string) => Effect.Effect<void, AuthUnavailable, never>;
|
|
21
|
+
revokeClient: (userId: string, clientId: string) => Effect.Effect<void, AuthUnavailable, never>;
|
|
30
22
|
resourceMetadataUrl: string;
|
|
31
23
|
routes: import("effect/Layer").Layer<never, never, HttpRouter.HttpRouter | HttpRouter.Request<"Error", import("effect/http/HttpServerError").InternalError> | HttpRouter.Request<"Error", import("effect/http/HttpServerError").RequestParseError> | HttpRouter.Request<"Error", import("effect/http/HttpServerError").RouteNotFound>>;
|
|
32
24
|
sessions: SessionPolicy<UserSessionShape>;
|
|
33
25
|
}, AuthUnavailable, never>;
|
|
34
|
-
export {};
|
|
35
26
|
//# sourceMappingURL=betterAuth.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"betterAuth.d.ts","sourceRoot":"","sources":["../../src/adapters/betterAuth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,aAAa,EAAmC,KAAK,KAAK,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"betterAuth.d.ts","sourceRoot":"","sources":["../../src/adapters/betterAuth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,YAAY,EAAE,aAAa,EAAmC,KAAK,KAAK,EAAE,MAAM,6BAA6B,CAAC;AAC5H,OAAO,EAAE,MAAM,EAAU,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,UAAU,EAAyC,MAAM,aAAa,CAAC;AAChF,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,8BAA8B,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAChE,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sCAAsC,CAAC;AACzE,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,MAAM,uCAAuC,CAAC;AAC7F,OAAO,EAAU,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AACtD,OAAO,EAAe,KAAK,SAAS,EAAe,MAAM,kBAAkB,CAAC;AAE5E,wBAAgB,WAAW,CAAC,EAC3B,QAAQ,EACR,MAAgB,EAChB,GAAG,OAAO,EACV,EAAE,IAAI,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,EAAE,WAAW,GAAG,oCAAoC,CAAC,GAAG;IAAE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAAG,UAAU,CAC9H,OAAO,aAAa,CACpB,CAEA;AAED,eAAO,MAAM,aAAa;mBACV,SAAS;uBACL,MAAM;oBACT,UAAU;qBACT,YAAY;qBACZ,OAAO;;;;;;;0BAgDvB,CAAC"}
|
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
import { oauthProvider, oauthProviderAuthServerMetadata } from "@better-auth/oauth-provider";
|
|
2
|
-
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
|
3
|
-
import { isAPIError } from "better-auth/api";
|
|
4
2
|
import { Effect, Option } from "effect";
|
|
5
3
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
6
|
-
import { redact } from "@shivaedev/errors/redact.ts";
|
|
7
4
|
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
if (isAPIError(cause) && (cause.status === "UNAUTHORIZED" || cause.status === "FORBIDDEN")) {
|
|
13
|
-
return Effect.succeed(Option.none());
|
|
14
|
-
}
|
|
15
|
-
return Effect.logError("MCP authentication verification failed", redact(cause)).pipe(Effect.andThen(Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" }))));
|
|
5
|
+
import { bearer } from "./bearer.js";
|
|
6
|
+
import { grantedUser, oauthGrants } from "./oauthGrants.js";
|
|
7
|
+
export function mcpProvider({ resource, scopes = ["mcp"], ...options }) {
|
|
8
|
+
return oauthProvider({ ...options, clientRegistrationDefaultResources: [resource], resources: [resource], scopes });
|
|
16
9
|
}
|
|
17
10
|
export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options) {
|
|
18
11
|
const config = yield* Effect.tryPromise({
|
|
@@ -23,7 +16,7 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options)
|
|
|
23
16
|
const url = new URL(resource);
|
|
24
17
|
const metadataPath = `/.well-known/oauth-protected-resource${url.pathname}`;
|
|
25
18
|
const resourceMetadataUrl = new URL(metadataPath, url).href;
|
|
26
|
-
const
|
|
19
|
+
const verify = bearer(options.jwks ?? config.jwks_uri ?? new URL("/api/auth/jwks", url).href, resource, config.issuer);
|
|
27
20
|
const sessions = {
|
|
28
21
|
origin: options.origin,
|
|
29
22
|
...(options.roles === undefined ? {} : { roles: options.roles }),
|
|
@@ -31,19 +24,11 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options)
|
|
|
31
24
|
get: (headers) => Effect.gen(function* () {
|
|
32
25
|
const authorization = headers.authorization;
|
|
33
26
|
const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
|
|
34
|
-
const claims = yield*
|
|
35
|
-
|
|
36
|
-
requiredScopes: ["mcp"],
|
|
37
|
-
verifyOptions: { audience: resource, issuer: config.issuer },
|
|
38
|
-
})).pipe(Effect.map(Option.some), Effect.catch(({ cause }) => bearerFailure(cause)));
|
|
39
|
-
if (Option.isNone(claims) || typeof claims.value.sub !== "string") {
|
|
27
|
+
const claims = yield* verify(token);
|
|
28
|
+
if (Option.isNone(claims) || typeof claims.value.sub !== "string" || typeof claims.value.client_id !== "string") {
|
|
40
29
|
return Option.none();
|
|
41
30
|
}
|
|
42
|
-
const
|
|
43
|
-
const user = yield* Effect.tryPromise({
|
|
44
|
-
catch: () => new AuthUnavailable({ message: "OAuth user lookup failed" }),
|
|
45
|
-
try: async () => (await auth.$context).internalAdapter.findUserById(id),
|
|
46
|
-
});
|
|
31
|
+
const user = yield* grantedUser(auth, claims.value.sub, claims.value.client_id);
|
|
47
32
|
if (user === null) {
|
|
48
33
|
return Option.none();
|
|
49
34
|
}
|
|
@@ -61,6 +46,6 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options)
|
|
|
61
46
|
yield* router.add("GET", metadataPath, HttpServerResponse.jsonUnsafe({ "authorization_servers": [config.issuer], resource, "scopes_supported": ["mcp"] }));
|
|
62
47
|
yield* router.add("GET", "/.well-known/oauth-authorization-server/api/auth", authorizationMetadata);
|
|
63
48
|
}));
|
|
64
|
-
return { resourceMetadataUrl, routes, sessions };
|
|
49
|
+
return { ...oauthGrants(auth), resourceMetadataUrl, routes, sessions };
|
|
65
50
|
});
|
|
66
51
|
//# sourceMappingURL=betterAuth.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"betterAuth.js","sourceRoot":"","sources":["../../src/adapters/betterAuth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAqB,aAAa,EAAE,+BAA+B,EAAc,MAAM,6BAA6B,CAAC;AAC5H,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"betterAuth.js","sourceRoot":"","sources":["../../src/adapters/betterAuth.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AuthContext, BetterAuthOptions } from "better-auth";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
4
|
+
export interface OauthAuth {
|
|
5
|
+
readonly $context: Promise<{
|
|
6
|
+
readonly adapter: Pick<AuthContext["adapter"], "findOne"> & {
|
|
7
|
+
readonly transaction: <A>(run: (adapter: Pick<AuthContext["adapter"], "deleteMany">) => Promise<A>) => Promise<A>;
|
|
8
|
+
};
|
|
9
|
+
readonly internalAdapter: Pick<AuthContext["internalAdapter"], "findUserById">;
|
|
10
|
+
}>;
|
|
11
|
+
readonly api: {
|
|
12
|
+
readonly "getOAuthServerConfig": () => Promise<{
|
|
13
|
+
readonly issuer: string;
|
|
14
|
+
readonly "jwks_uri"?: string;
|
|
15
|
+
}>;
|
|
16
|
+
};
|
|
17
|
+
readonly options: BetterAuthOptions;
|
|
18
|
+
}
|
|
19
|
+
export declare function oauthGrants(auth: OauthAuth): {
|
|
20
|
+
revokeAllClients: (userId: string) => Effect.Effect<void, AuthUnavailable, never>;
|
|
21
|
+
revokeClient: (userId: string, clientId: string) => Effect.Effect<void, AuthUnavailable, never>;
|
|
22
|
+
};
|
|
23
|
+
export declare const grantedUser: (auth: OauthAuth, userId: string, clientId: string) => Effect.Effect<{
|
|
24
|
+
id: string;
|
|
25
|
+
createdAt: Date;
|
|
26
|
+
updatedAt: Date;
|
|
27
|
+
email: string;
|
|
28
|
+
emailVerified: boolean;
|
|
29
|
+
name: string;
|
|
30
|
+
image?: string | null | undefined;
|
|
31
|
+
} | null, AuthUnavailable, never>;
|
|
32
|
+
//# sourceMappingURL=oauthGrants.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauthGrants.d.ts","sourceRoot":"","sources":["../../src/adapters/oauthGrants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAClE,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAEhE,MAAM,WAAW,SAAS;IACzB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;QAC1B,QAAQ,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC,GAAG;YAC3D,QAAQ,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,WAAW,CAAC,SAAS,CAAC,EAAE,YAAY,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;SAClH,CAAC;QACF,QAAQ,CAAC,eAAe,EAAE,IAAI,CAAC,WAAW,CAAC,iBAAiB,CAAC,EAAE,cAAc,CAAC,CAAC;KAC/E,CAAC,CAAC;IACH,QAAQ,CAAC,GAAG,EAAE;QAAE,QAAQ,CAAC,sBAAsB,EAAE,MAAM,OAAO,CAAC;YAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;YAAC,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,CAAA;KAAE,CAAC;IAC5H,QAAQ,CAAC,OAAO,EAAE,iBAAiB,CAAC;CACpC;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS;IAgBzC,gBAAgB,WAAW,MAAM;IACjC,YAAY,WAAW,MAAM,YAAY,MAAM;EAEhD;AAED,eAAO,MAAM,WAAW;;;;;;;;iCAmBtB,CAAC"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
3
|
+
export function oauthGrants(auth) {
|
|
4
|
+
const revoke = Effect.fn("Mcp.revoke")(function* (userId, clientId) {
|
|
5
|
+
yield* Effect.tryPromise({
|
|
6
|
+
catch: () => new AuthUnavailable({ message: "OAuth grant revocation failed" }),
|
|
7
|
+
try: async () => {
|
|
8
|
+
const { adapter } = await auth.$context;
|
|
9
|
+
const where = [{ field: "userId", value: userId }, ...(clientId === undefined ? [] : [{ field: "clientId", value: clientId }])];
|
|
10
|
+
await adapter.transaction(async (transaction) => {
|
|
11
|
+
for (const model of ["oauthConsent", "oauthAccessToken", "oauthRefreshToken"]) {
|
|
12
|
+
await transaction.deleteMany({ model, where });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
},
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
revokeAllClients: (userId) => revoke(userId),
|
|
20
|
+
revokeClient: (userId, clientId) => revoke(userId, clientId),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export const grantedUser = Effect.fn("Mcp.grantedUser")(function* (auth, userId, clientId) {
|
|
24
|
+
return yield* Effect.tryPromise({
|
|
25
|
+
catch: () => new AuthUnavailable({ message: "OAuth grant lookup failed" }),
|
|
26
|
+
try: async () => {
|
|
27
|
+
const { adapter, internalAdapter } = await auth.$context;
|
|
28
|
+
const user = await internalAdapter.findUserById(userId);
|
|
29
|
+
if (user === null || Reflect.get(user, "banned") === true) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const consent = await adapter.findOne({
|
|
33
|
+
model: "oauthConsent",
|
|
34
|
+
where: [
|
|
35
|
+
{ field: "userId", value: userId },
|
|
36
|
+
{ field: "clientId", value: clientId },
|
|
37
|
+
],
|
|
38
|
+
});
|
|
39
|
+
return consent === null ? null : user;
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
//# sourceMappingURL=oauthGrants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oauthGrants.js","sourceRoot":"","sources":["../../src/adapters/oauthGrants.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAahE,MAAM,UAAU,WAAW,CAAC,IAAe;IAC1C,MAAM,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,QAAQ,CAAC,EAAE,MAAc,EAAE,QAAiB;QAClF,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC;YACxB,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,+BAA+B,EAAE,CAAC;YAC9E,GAAG,EAAE,KAAK,IAAI,EAAE;gBACf,MAAM,EAAE,OAAO,EAAE,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC;gBACxC,MAAM,KAAK,GAAG,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,GAAG,CAAC,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAC,CAAC,CAAC;gBAChI,MAAM,OAAO,CAAC,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,EAAE;oBAC/C,KAAK,MAAM,KAAK,IAAI,CAAC,cAAc,EAAE,kBAAkB,EAAE,mBAAmB,CAAC,EAAE,CAAC;wBAC/E,MAAM,WAAW,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;oBAChD,CAAC;gBACF,CAAC,CAAC,CAAC;YACJ,CAAC;SACD,CAAC,CAAC;IACJ,CAAC,CAAC,CAAC;IACH,OAAO;QACN,gBAAgB,EAAE,CAAC,MAAc,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,CAAC;QACpD,YAAY,EAAE,CAAC,MAAc,EAAE,QAAgB,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC;KAC5E,CAAC;AACH,CAAC;AAED,MAAM,CAAC,MAAM,WAAW,GAAG,MAAM,CAAC,EAAE,CAAC,iBAAiB,CAAC,CAAC,QAAQ,CAAC,EAAE,IAAe,EAAE,MAAc,EAAE,QAAgB;IACnH,OAAO,KAAK,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC;QAC/B,KAAK,EAAE,GAAG,EAAE,CAAC,IAAI,eAAe,CAAC,EAAE,OAAO,EAAE,2BAA2B,EAAE,CAAC;QAC1E,GAAG,EAAE,KAAK,IAAI,EAAE;YACf,MAAM,EAAE,OAAO,EAAE,eAAe,EAAE,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC;YACzD,MAAM,IAAI,GAAG,MAAM,eAAe,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;YACxD,IAAI,IAAI,KAAK,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC3D,OAAO,IAAI,CAAC;YACb,CAAC;YACD,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,OAAO,CAAC;gBACrC,KAAK,EAAE,cAAc;gBACrB,KAAK,EAAE;oBACN,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE;oBAClC,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,QAAQ,EAAE;iBACtC;aACD,CAAC,CAAC;YACH,OAAO,OAAO,KAAK,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;QACvC,CAAC;KACD,CAAC,CAAC;AACJ,CAAC,CAAC,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shivaedev/mcp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Identity-aware Effect MCP tools mounted on the application host",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect"
|
|
@@ -43,18 +43,18 @@
|
|
|
43
43
|
"README.md"
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@shivaedev/access": "0.3.
|
|
47
|
-
"@shivaedev/errors": "0.
|
|
48
|
-
"@shivaedev/identity": "0.
|
|
46
|
+
"@shivaedev/access": "0.3.4",
|
|
47
|
+
"@shivaedev/errors": "0.4.0",
|
|
48
|
+
"@shivaedev/identity": "0.13.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@better-auth/oauth-provider": "1.7.12",
|
|
52
52
|
"@effect/platform-node": "4.0.6",
|
|
53
53
|
"@effect/sql-pg": "4.0.6",
|
|
54
|
-
"@shivaedev/quality": "0.
|
|
55
|
-
"@shivaedev/server": "0.
|
|
56
|
-
"@shivaedev/test-sql": "0.15.
|
|
57
|
-
"@shivaedev/test-story": "0.
|
|
54
|
+
"@shivaedev/quality": "0.52.0",
|
|
55
|
+
"@shivaedev/server": "0.14.0",
|
|
56
|
+
"@shivaedev/test-sql": "0.15.1",
|
|
57
|
+
"@shivaedev/test-story": "0.31.1",
|
|
58
58
|
"@types/node": "24.19.2",
|
|
59
59
|
"better-auth": "1.7.12",
|
|
60
60
|
"effect": "4.0.6",
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { APIError, isAPIError } from "better-auth/api";
|
|
2
|
+
import { verifyJwsAccessToken } from "better-auth/oauth2";
|
|
3
|
+
import { Effect, Option } from "effect";
|
|
4
|
+
import { redact } from "@shivaedev/errors/redact.ts";
|
|
5
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
6
|
+
|
|
7
|
+
export type JwksSource = Parameters<typeof verifyJwsAccessToken>[1]["jwksFetch"];
|
|
8
|
+
|
|
9
|
+
const INVALID_TOKEN_ERRORS = new Set([
|
|
10
|
+
"ERR_JWT_EXPIRED",
|
|
11
|
+
"ERR_JWT_CLAIM_VALIDATION_FAILED",
|
|
12
|
+
"ERR_JWT_INVALID",
|
|
13
|
+
"ERR_JWS_INVALID",
|
|
14
|
+
"ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
|
|
15
|
+
"ERR_JWKS_NO_MATCHING_KEY",
|
|
16
|
+
"ERR_JOSE_ALG_NOT_ALLOWED",
|
|
17
|
+
"ERR_JOSE_NOT_SUPPORTED",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function bearerFailure(cause: unknown) {
|
|
21
|
+
if (
|
|
22
|
+
(isAPIError(cause) && (cause.status === "UNAUTHORIZED" || cause.status === "FORBIDDEN"))
|
|
23
|
+
|| (cause instanceof Error && INVALID_TOKEN_ERRORS.has(String(Reflect.get(cause, "code"))))
|
|
24
|
+
) {
|
|
25
|
+
return Effect.succeed(Option.none());
|
|
26
|
+
}
|
|
27
|
+
return Effect.logError("MCP authentication verification failed", redact(cause)).pipe(
|
|
28
|
+
Effect.andThen(Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" }))),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function bearer(jwks: JwksSource, audience: string, issuer: string) {
|
|
33
|
+
return (token: string | undefined) => {
|
|
34
|
+
if (!token) {
|
|
35
|
+
return Effect.succeed(Option.none());
|
|
36
|
+
}
|
|
37
|
+
return Effect.tryPromise(async () => {
|
|
38
|
+
const claims = await verifyJwsAccessToken(token, { jwksFetch: jwks, verifyOptions: { audience, issuer } });
|
|
39
|
+
if (typeof claims.scope !== "string" || !claims.scope.split(" ").includes("mcp") || claims.cnf !== undefined) {
|
|
40
|
+
throw new APIError("UNAUTHORIZED");
|
|
41
|
+
}
|
|
42
|
+
return claims;
|
|
43
|
+
}).pipe(
|
|
44
|
+
Effect.map(Option.some),
|
|
45
|
+
Effect.catch(({ cause }) => bearerFailure(cause)),
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -1,42 +1,27 @@
|
|
|
1
1
|
import { type OAuthOptions, oauthProvider, oauthProviderAuthServerMetadata, type Scope } from "@better-auth/oauth-provider";
|
|
2
|
-
import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client";
|
|
3
|
-
import type { AuthContext, BetterAuthOptions } from "better-auth";
|
|
4
|
-
import { isAPIError } from "better-auth/api";
|
|
5
2
|
import { Effect, Option } from "effect";
|
|
6
3
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http";
|
|
7
4
|
import type { RoleSet } from "@shivaedev/access/roleSet.ts";
|
|
8
|
-
import { redact } from "@shivaedev/errors/redact.ts";
|
|
9
5
|
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
10
6
|
import type { OriginPolicy } from "@shivaedev/identity/server/origin.ts";
|
|
11
7
|
import type { SessionPolicy, UserSessionShape } from "@shivaedev/identity/server/session.ts";
|
|
8
|
+
import { bearer, type JwksSource } from "./bearer.ts";
|
|
9
|
+
import { grantedUser, type OauthAuth, oauthGrants } from "./oauthGrants.ts";
|
|
12
10
|
|
|
13
11
|
export function mcpProvider({
|
|
14
12
|
resource,
|
|
13
|
+
scopes = ["mcp"],
|
|
15
14
|
...options
|
|
16
|
-
}: Omit<OAuthOptions<Scope[]>, "resources" | "
|
|
15
|
+
}: Omit<OAuthOptions<Scope[]>, "resources" | "clientRegistrationDefaultResources"> & { readonly resource: string }): ReturnType<
|
|
17
16
|
typeof oauthProvider
|
|
18
17
|
> {
|
|
19
|
-
return oauthProvider({ ...options, clientRegistrationDefaultResources: [resource], resources: [resource], scopes
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
interface OauthAuth {
|
|
23
|
-
readonly $context: Promise<{ readonly internalAdapter: Pick<AuthContext["internalAdapter"], "findUserById"> }>;
|
|
24
|
-
readonly api: { readonly "getOAuthServerConfig": () => Promise<{ readonly issuer: string; readonly "jwks_uri"?: string }> };
|
|
25
|
-
readonly options: BetterAuthOptions;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function bearerFailure(cause: unknown) {
|
|
29
|
-
if (isAPIError(cause) && (cause.status === "UNAUTHORIZED" || cause.status === "FORBIDDEN")) {
|
|
30
|
-
return Effect.succeed(Option.none());
|
|
31
|
-
}
|
|
32
|
-
return Effect.logError("MCP authentication verification failed", redact(cause)).pipe(
|
|
33
|
-
Effect.andThen(Effect.fail(new AuthUnavailable({ message: "Authentication is temporarily unavailable" }))),
|
|
34
|
-
);
|
|
18
|
+
return oauthProvider({ ...options, clientRegistrationDefaultResources: [resource], resources: [resource], scopes });
|
|
35
19
|
}
|
|
36
20
|
|
|
37
21
|
export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options: {
|
|
38
22
|
readonly auth: OauthAuth;
|
|
39
23
|
readonly resource: string;
|
|
24
|
+
readonly jwks?: JwksSource;
|
|
40
25
|
readonly origin: OriginPolicy;
|
|
41
26
|
readonly roles?: RoleSet;
|
|
42
27
|
}) {
|
|
@@ -48,7 +33,7 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options:
|
|
|
48
33
|
const url = new URL(resource);
|
|
49
34
|
const metadataPath = `/.well-known/oauth-protected-resource${url.pathname}` as const;
|
|
50
35
|
const resourceMetadataUrl = new URL(metadataPath, url).href;
|
|
51
|
-
const
|
|
36
|
+
const verify = bearer(options.jwks ?? config.jwks_uri ?? new URL("/api/auth/jwks", url).href, resource, config.issuer);
|
|
52
37
|
const sessions: SessionPolicy<UserSessionShape> = {
|
|
53
38
|
origin: options.origin,
|
|
54
39
|
...(options.roles === undefined ? {} : { roles: options.roles }),
|
|
@@ -57,24 +42,11 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options:
|
|
|
57
42
|
Effect.gen(function* () {
|
|
58
43
|
const authorization = headers.authorization;
|
|
59
44
|
const token = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
|
|
60
|
-
const claims = yield*
|
|
61
|
-
|
|
62
|
-
jwksUrl: config.jwks_uri ?? new URL("/api/auth/jwks", url).href,
|
|
63
|
-
requiredScopes: ["mcp"],
|
|
64
|
-
verifyOptions: { audience: resource, issuer: config.issuer },
|
|
65
|
-
}),
|
|
66
|
-
).pipe(
|
|
67
|
-
Effect.map(Option.some),
|
|
68
|
-
Effect.catch(({ cause }) => bearerFailure(cause)),
|
|
69
|
-
);
|
|
70
|
-
if (Option.isNone(claims) || typeof claims.value.sub !== "string") {
|
|
45
|
+
const claims = yield* verify(token);
|
|
46
|
+
if (Option.isNone(claims) || typeof claims.value.sub !== "string" || typeof claims.value.client_id !== "string") {
|
|
71
47
|
return Option.none();
|
|
72
48
|
}
|
|
73
|
-
const
|
|
74
|
-
const user = yield* Effect.tryPromise({
|
|
75
|
-
catch: () => new AuthUnavailable({ message: "OAuth user lookup failed" }),
|
|
76
|
-
try: async () => (await auth.$context).internalAdapter.findUserById(id),
|
|
77
|
-
});
|
|
49
|
+
const user = yield* grantedUser(auth, claims.value.sub, claims.value.client_id);
|
|
78
50
|
if (user === null) {
|
|
79
51
|
return Option.none();
|
|
80
52
|
}
|
|
@@ -98,5 +70,5 @@ export const betterAuthMcp = Effect.fn("Mcp.betterAuthMcp")(function* (options:
|
|
|
98
70
|
yield* router.add("GET", "/.well-known/oauth-authorization-server/api/auth", authorizationMetadata);
|
|
99
71
|
}),
|
|
100
72
|
);
|
|
101
|
-
return { resourceMetadataUrl, routes, sessions };
|
|
73
|
+
return { ...oauthGrants(auth), resourceMetadataUrl, routes, sessions };
|
|
102
74
|
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AuthContext, BetterAuthOptions } from "better-auth";
|
|
2
|
+
import { Effect } from "effect";
|
|
3
|
+
import { AuthUnavailable } from "@shivaedev/errors/taxonomy.ts";
|
|
4
|
+
|
|
5
|
+
export interface OauthAuth {
|
|
6
|
+
readonly $context: Promise<{
|
|
7
|
+
readonly adapter: Pick<AuthContext["adapter"], "findOne"> & {
|
|
8
|
+
readonly transaction: <A>(run: (adapter: Pick<AuthContext["adapter"], "deleteMany">) => Promise<A>) => Promise<A>;
|
|
9
|
+
};
|
|
10
|
+
readonly internalAdapter: Pick<AuthContext["internalAdapter"], "findUserById">;
|
|
11
|
+
}>;
|
|
12
|
+
readonly api: { readonly "getOAuthServerConfig": () => Promise<{ readonly issuer: string; readonly "jwks_uri"?: string }> };
|
|
13
|
+
readonly options: BetterAuthOptions;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function oauthGrants(auth: OauthAuth) {
|
|
17
|
+
const revoke = Effect.fn("Mcp.revoke")(function* (userId: string, clientId?: string) {
|
|
18
|
+
yield* Effect.tryPromise({
|
|
19
|
+
catch: () => new AuthUnavailable({ message: "OAuth grant revocation failed" }),
|
|
20
|
+
try: async () => {
|
|
21
|
+
const { adapter } = await auth.$context;
|
|
22
|
+
const where = [{ field: "userId", value: userId }, ...(clientId === undefined ? [] : [{ field: "clientId", value: clientId }])];
|
|
23
|
+
await adapter.transaction(async (transaction) => {
|
|
24
|
+
for (const model of ["oauthConsent", "oauthAccessToken", "oauthRefreshToken"]) {
|
|
25
|
+
await transaction.deleteMany({ model, where });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
revokeAllClients: (userId: string) => revoke(userId),
|
|
33
|
+
revokeClient: (userId: string, clientId: string) => revoke(userId, clientId),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const grantedUser = Effect.fn("Mcp.grantedUser")(function* (auth: OauthAuth, userId: string, clientId: string) {
|
|
38
|
+
return yield* Effect.tryPromise({
|
|
39
|
+
catch: () => new AuthUnavailable({ message: "OAuth grant lookup failed" }),
|
|
40
|
+
try: async () => {
|
|
41
|
+
const { adapter, internalAdapter } = await auth.$context;
|
|
42
|
+
const user = await internalAdapter.findUserById(userId);
|
|
43
|
+
if (user === null || Reflect.get(user, "banned") === true) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const consent = await adapter.findOne({
|
|
47
|
+
model: "oauthConsent",
|
|
48
|
+
where: [
|
|
49
|
+
{ field: "userId", value: userId },
|
|
50
|
+
{ field: "clientId", value: clientId },
|
|
51
|
+
],
|
|
52
|
+
});
|
|
53
|
+
return consent === null ? null : user;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|