Repository navigation
Expand file tree
/
Copy pathbetter-auth.ts
More file actions
202 lines (193 loc) · 10.8 KB
/
Copy pathbetter-auth.ts
File metadata and controls
202 lines (193 loc) · 10.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { APIError, createAuthMiddleware } from "better-auth/api";
import { jwt } from "better-auth/plugins/jwt";
import {
createDpopReplayStore, createInsufficientScopeError, enforceDpopBinding,
isDpopBindingError, parseAccessTokenAuthorization, verifyJwsAccessToken,
} from "better-auth/oauth2";
import { mcp } from "@better-auth/mcp";
import { createResourceServerChallenge } from "@better-auth/oauth-provider";
import { decodeProtectedHeader, errors } from "jose";
import type { D1Database } from "@cloudflare/workers-types";
import { teamAllowsUser, teamProviderOptions, type TeamAuthEnvironment } from "./team-auth";
export type BetterAuthEnvironment = TeamAuthEnvironment & {
AUTH_MODE?: string;
AUTH_DB?: D1Database;
BETTER_AUTH_URL?: string;
BETTER_AUTH_SECRET?: string;
BETTER_AUTH_TRUSTED_IP_HEADER?: string;
};
export type ArtifactUser = { id: string; name: string; email: string; emailVerified: boolean };
export class ArtifactAuthConfigurationError extends Error {}
function nativeRedirectHint(value: unknown): boolean {
if (typeof value !== "string") return false;
try {
const url = new URL(value);
return url.protocol === "http:"
? ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
: url.protocol !== "https:";
} catch { return false; }
}
export function authOrigin(env: BetterAuthEnvironment): string {
if (!env.BETTER_AUTH_URL) throw new Error("Configure BETTER_AUTH_URL for this Artifact deployment");
const url = new URL(env.BETTER_AUTH_URL);
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash
|| (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
throw new Error("BETTER_AUTH_URL must be the deployment's HTTPS origin (HTTP loopback is allowed for development)");
}
return url.origin;
}
/** Existing OAuth clients retain their original scope; new clients request artifacts. */
export function requireArtifactScope(scope: unknown): void {
if (typeof scope !== "string" || !scope.split(" ").some(value => value === "artifacts" || value === "canvas")) {
throw createInsufficientScopeError(["artifacts"]);
}
}
export function artifactAuthOptions(env: BetterAuthEnvironment, database: NonNullable<BetterAuthOptions["database"]> = env.AUTH_DB!) {
const origin = authOrigin(env);
if (!database) throw new Error("Configure the AUTH_DB binding and apply the auth database migrations");
if (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 32) throw new Error("Configure BETTER_AUTH_SECRET with at least 32 random characters");
const providers = teamProviderOptions(env);
return {
appName: "Artifacts",
baseURL: origin,
basePath: "/api/auth",
secret: env.BETTER_AUTH_SECRET,
database,
socialProviders: providers.socialProviders,
account: { encryptOAuthTokens: true },
emailAndPassword: { enabled: false },
rateLimit: { enabled: true, storage: "database" },
advanced: {
// Migrations own the schema. celld 0.4.1 rejects the table-valued PRAGMA
// queries used by Better Auth's optional startup introspection.
database: { validateSchema: false },
ipAddress: { ipAddressHeaders: env.BETTER_AUTH_TRUSTED_IP_HEADER ? [env.BETTER_AUTH_TRUSTED_IP_HEADER] : [] },
},
user: {
validateUserInfo({ user }) {
if (!teamAllowsUser(env, user)) return { error: "access_denied", errorDescription: "Your account is not authorized for this team server." };
},
},
hooks: {
before: createAuthMiddleware(async ctx => {
// OAuth-only MCP clients may omit OIDC's application_type field.
// Infer native from their redirect, then let Better Auth validate every
// URI. Explicit types and HTTPS-only web registrations stay unchanged.
if (ctx.path !== "/oauth2/register" || ctx.body?.application_type !== undefined
|| !Array.isArray(ctx.body?.redirect_uris)) return;
if (ctx.body.redirect_uris.some(nativeRedirectHint)) ctx.body.application_type = "native";
}),
},
plugins: [
...(providers.plugins ?? []),
// celld 0.4.1's WebCrypto cannot verify Ed25519 (Better Auth's default).
// RS256 uses the native signing and verification paths on both runtimes.
jwt({ jwks: { keyPairConfig: { alg: "RS256" } } }),
mcp({
loginPage: "/sign-in", consentPage: "/consent", resource: `${origin}/mcp`,
scopes: ["openid", "profile", "email", "offline_access", "artifacts", "canvas"],
grantTypes: ["authorization_code", "refresh_token"],
accessTokenExpiresIn: 300,
allowDynamicClientRegistration: true,
allowUnauthenticatedClientRegistration: true,
allowPublicClientPrelogin: true,
enforcePerClientResources: true,
}),
],
} satisfies BetterAuthOptions;
}
function instantiateAuth(env: BetterAuthEnvironment) { return betterAuth(artifactAuthOptions(env)); }
export type ArtifactAuth = ReturnType<typeof instantiateAuth>;
export async function getArtifactAuth(env: BetterAuthEnvironment): Promise<ArtifactAuth> {
// Keep router/context state request-local: sharing an instance across
// concurrent celld requests can leave its auth handlers waiting forever.
let auth: ArtifactAuth;
try { auth = instantiateAuth(env); }
catch (error) { throw new ArtifactAuthConfigurationError(error instanceof Error ? error.message : "Invalid authentication configuration", { cause: error }); }
// Provider discovery (and any schema check enabled by a deployment) must
// finish in its originating request; workerd cancels I/O when it returns.
try {
const context = await auth.$context;
await context.checkSchema?.();
} catch (error) {
console.error("Artifacts authentication initialization failed", error);
throw new ArtifactAuthConfigurationError("Authentication is unavailable; verify provider configuration and auth database migrations", { cause: error });
}
return auth;
}
/** A person opening a URL in a tab, such as a shared private link. Frames and
* fetches keep the JSON challenge; the gallery client redirects on that. */
function documentNavigation(request: Request): boolean {
if (request.method !== "GET" && request.method !== "HEAD") return false;
const destination = request.headers.get("Sec-Fetch-Dest");
if (destination) return destination === "document";
// Browsers without Fetch Metadata still ask for HTML when navigating.
return request.headers.get("Accept")?.includes("text/html") ?? false;
}
export function browserSignIn(request: Request): Response {
const current = new URL(request.url);
const returnTo = current.pathname + current.search;
const login = `/sign-in?returnTo=${encodeURIComponent(returnTo)}`;
if ((request.method === "GET" && ["/", "/gallery"].includes(current.pathname)) || documentNavigation(request)) {
return new Response(null, { status: 302, headers: { Location: login, "Cache-Control": "no-store" } });
}
return Response.json({ error: "Sign in to Artifacts", loginUrl: login }, {
status: 401, headers: { "X-Artifact-Auth": "better-auth", "Cache-Control": "no-store" },
});
}
/** Browser sessions and OAuth access tokens resolve to the same Better Auth
* user ID. Provider tokens and caller-supplied identity headers are never used
* as Artifact authorization credentials. */
export async function authenticateBetterAuth(request: Request, env: BetterAuthEnvironment): Promise<ArtifactUser | Response> {
const auth = await getArtifactAuth(env);
const context = await auth.$context;
const origin = authOrigin(env);
const isMcp = new URL(request.url).pathname === "/mcp";
if (!isMcp) {
const session = await auth.api.getSession({ headers: request.headers });
if (!session) return browserSignIn(request);
if (!teamAllowsUser(env, session.user)) return Response.json({ error: "Your account is not authorized for this team server" }, { status: 403 });
return session.user;
}
const resource = `${origin}/mcp`;
try {
const authorization = parseAccessTokenAuthorization(request.headers.get("Authorization"));
if (!authorization?.token || authorization.scheme === "Unknown") throw new APIError("UNAUTHORIZED", { message: "User authorization required" });
// jose reports malformed compact-token headers with TypeError. Limit that
// classification to parsing so a JWKS/storage failure remains a server error.
try { decodeProtectedHeader(authorization.token); }
catch { throw new APIError("UNAUTHORIZED", { message: "Invalid access token" }); }
const claims = await verifyJwsAccessToken(authorization.token, {
// Read our own public keys through Better Auth, not a network request
// back into this Worker. Key selection/rotation remains library-owned.
jwksFetch: () => auth.api.getJwks(), jwksCacheKey: auth,
verifyOptions: { issuer: `${origin}/api/auth`, audience: resource, requiredClaims: ["sub", "sid", "exp", "iat"] },
});
await enforceDpopBinding({
payload: claims, authorization, proofJwt: request.headers.get("DPoP"),
method: request.method, url: request.url,
replayStore: createDpopReplayStore(context.internalAdapter),
});
requireArtifactScope(claims.scope);
if (typeof claims.sub !== "string" || !claims.sub || typeof claims.sid !== "string" || !claims.sid) throw new APIError("UNAUTHORIZED", { message: "A user session is required" });
// JWT verification alone accepts a token until expiry after sign-out.
// Check the attached session as well so both surfaces revoke together.
const session = await context.adapter.findOne<{ userId: string; expiresAt: Date }>({
model: "session", where: [{ field: "id", value: claims.sid }, { field: "userId", value: claims.sub }],
});
if (!session || new Date(session.expiresAt).getTime() <= Date.now()) throw new APIError("UNAUTHORIZED", { message: "Your user session has expired or was signed out" });
const user = await context.internalAdapter.findUserById(claims.sub);
if (!user) throw new APIError("UNAUTHORIZED", { message: "User no longer exists" });
if (!teamAllowsUser(env, user)) return Response.json({ error: "Your account is not authorized for this team server" }, { status: 403 });
return user;
} catch (error) {
if (isDpopBindingError(error)) error = new APIError("UNAUTHORIZED", { error: error.code, error_description: error.message });
else if (error instanceof errors.JOSEError) error = new APIError("UNAUTHORIZED", { message: "Invalid or expired access token" });
const challenge = createResourceServerChallenge(error, resource, { challengeScopes: ["artifacts"] });
if (!challenge) throw error;
return Response.json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: challenge.message } }, {
status: challenge.statusCode, headers: { ...challenge.headers, "Cache-Control": "no-store" },
});
}
}