mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 12:27:44 +00:00
feat: OIDC Plugin (#765)
This commit is contained in:
114
demo/nextjs/app/apps/register/page.tsx
Normal file
114
demo/nextjs/app/apps/register/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { client } from "@/lib/auth-client";
|
||||
|
||||
export default function RegisterOAuthClient() {
|
||||
const [name, setName] = useState("");
|
||||
const [logo, setLogo] = useState<File | null>(null);
|
||||
const [redirectUri, setRedirectUri] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
if (!name || !logo || !redirectUri) {
|
||||
setError("All fields are required");
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
const res = await client.oauth2.registerClient({
|
||||
name,
|
||||
icon: await convertImageToBase64(logo),
|
||||
redirectURLs: [redirectUri],
|
||||
});
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<Card className="max-w-md mx-auto">
|
||||
<CardHeader>
|
||||
<CardTitle>Register OAuth Client</CardTitle>
|
||||
<CardDescription>
|
||||
Provide details to register a new OAuth client as a provider.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter client name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="logo">Logo</Label>
|
||||
<Input
|
||||
id="logo"
|
||||
type="file"
|
||||
onChange={(e) => setLogo(e.target.files?.[0] || null)}
|
||||
accept="image/*"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="redirectUri">Redirect URI</Label>
|
||||
<Input
|
||||
id="redirectUri"
|
||||
value={redirectUri}
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
placeholder="https://your-app.com/callback"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Error</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Registering...
|
||||
</>
|
||||
) : (
|
||||
"Register Client"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function convertImageToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
47
demo/nextjs/app/oauth/authorize/concet-buttons.tsx
Normal file
47
demo/nextjs/app/oauth/authorize/concet-buttons.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CardFooter } from "@/components/ui/card";
|
||||
import { client } from "@/lib/auth-client";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function ConsentBtns() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
return (
|
||||
<CardFooter className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
const res = await client.oauth2.consent({
|
||||
accept: true,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.data?.redirectURI) {
|
||||
window.location.href = res.data.redirectURI;
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to authorize");
|
||||
}}
|
||||
>
|
||||
{loading ? <Loader2 size={15} className="animate-spin" /> : "Authorize"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
const res = await client.oauth2.consent({
|
||||
accept: false,
|
||||
});
|
||||
if (res.data?.redirectURI) {
|
||||
window.location.href = res.data.redirectURI;
|
||||
return;
|
||||
}
|
||||
toast.error("Failed to cancel");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</CardFooter>
|
||||
);
|
||||
}
|
||||
114
demo/nextjs/app/oauth/authorize/page.tsx
Normal file
114
demo/nextjs/app/oauth/authorize/page.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Metadata } from "next";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { ArrowLeftRight, ArrowUpRight, Mail, Users } from "lucide-react";
|
||||
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Logo } from "@/components/logo";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { ConsentBtns } from "./concet-buttons";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Authorize Application",
|
||||
description: "Grant access to your account",
|
||||
};
|
||||
|
||||
interface AuthorizePageProps {
|
||||
searchParams: Promise<{
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
cancel_uri: string;
|
||||
client_id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function AuthorizePage({
|
||||
searchParams,
|
||||
}: AuthorizePageProps) {
|
||||
const { redirect_uri, scope, client_id, cancel_uri } = await searchParams;
|
||||
const session = await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
});
|
||||
const clientDetails = await auth.api.getOAuthClient({
|
||||
params: {
|
||||
id: client_id,
|
||||
},
|
||||
headers: await headers(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-10">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">
|
||||
Authorize Application
|
||||
</h1>
|
||||
<div className="min-h-screen bg-black text-white flex flex-col">
|
||||
<div className="flex flex-col items-center justify-center max-w-2xl mx-auto px-4">
|
||||
<div className="flex items-center gap-8 mb-8">
|
||||
<div className="w-16 h-16 border rounded-full flex items-center justify-center">
|
||||
{clientDetails.icon ? (
|
||||
<Image
|
||||
src={clientDetails.icon}
|
||||
alt="App Logo"
|
||||
className="object-cover"
|
||||
width={64}
|
||||
height={64}
|
||||
/>
|
||||
) : (
|
||||
<Logo />
|
||||
)}
|
||||
</div>
|
||||
<ArrowLeftRight className="h-6 w-6" />
|
||||
<div className="w-16 h-16 rounded-full overflow-hidden">
|
||||
<Avatar className="hidden h-16 w-16 sm:flex ">
|
||||
<AvatarImage
|
||||
src={session?.user.image || "#"}
|
||||
alt="Avatar"
|
||||
className="object-cover"
|
||||
/>
|
||||
<AvatarFallback>{session?.user.name.charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold text-center mb-8">
|
||||
{clientDetails.name} is requesting access to your Better Auth
|
||||
account
|
||||
</h1>
|
||||
|
||||
<Card className="w-full bg-zinc-900 border-zinc-800 rounded-none">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between p-4 bg-zinc-800 rounded-lg mb-6">
|
||||
<div>
|
||||
<div className="font-medium">{session?.user.name}</div>
|
||||
<div className="text-zinc-400">{session?.user.email}</div>
|
||||
</div>
|
||||
<ArrowUpRight className="h-5 w-5 text-zinc-400" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-lg mb-4">
|
||||
Continuing will allow Sign in with {clientDetails.name} to:
|
||||
</div>
|
||||
{scope.includes("profile") && (
|
||||
<div className="flex items-center gap-3 text-zinc-300">
|
||||
<Users className="h-5 w-5" />
|
||||
<span>Read your Better Auth user data.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scope.includes("email") && (
|
||||
<div className="flex items-center gap-3 text-zinc-300">
|
||||
<Mail className="h-5 w-5" />
|
||||
<span>Read your email address.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<ConsentBtns />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -171,7 +171,7 @@ export default function SignIn() {
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={async () => {
|
||||
await signIn.social({
|
||||
const { data } = await signIn.social({
|
||||
provider: "microsoft",
|
||||
callbackURL: "/dashboard",
|
||||
});
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
adminClient,
|
||||
multiSessionClient,
|
||||
oneTapClient,
|
||||
oidcClient,
|
||||
genericOAuthClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -23,6 +25,8 @@ export const client = createAuthClient({
|
||||
oneTapClient({
|
||||
clientId: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!,
|
||||
}),
|
||||
oidcClient(),
|
||||
genericOAuthClient(),
|
||||
],
|
||||
fetchOptions: {
|
||||
onError(e) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
oneTap,
|
||||
oAuthProxy,
|
||||
openAPI,
|
||||
oidcProvider,
|
||||
} from "better-auth/plugins";
|
||||
import { reactInvitationEmail } from "./email/invitation";
|
||||
import { LibsqlDialect } from "@libsql/kysely-libsql";
|
||||
@@ -40,23 +41,7 @@ export const auth = betterAuth({
|
||||
appName: "Better Auth Demo",
|
||||
database: {
|
||||
dialect,
|
||||
type: process.env.USE_MYSQL ? "mysql" : "sqlite",
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
update: {
|
||||
async before(user) {
|
||||
if (user.emailVerified) {
|
||||
return {
|
||||
data: {
|
||||
...user,
|
||||
emailVerifiedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "sqlite",
|
||||
},
|
||||
emailVerification: {
|
||||
async sendVerificationEmail({ user, url }) {
|
||||
@@ -71,7 +56,7 @@ export const auth = betterAuth({
|
||||
},
|
||||
account: {
|
||||
accountLinking: {
|
||||
trustedProviders: ["google", "github"],
|
||||
trustedProviders: ["google", "github", "demo-app"],
|
||||
},
|
||||
},
|
||||
emailAndPassword: {
|
||||
@@ -161,5 +146,8 @@ export const auth = betterAuth({
|
||||
oneTap(),
|
||||
oAuthProxy(),
|
||||
nextCookies(),
|
||||
oidcProvider({
|
||||
loginPage: "/sign-in",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -895,6 +895,24 @@ export const contents: Content[] = [
|
||||
href: "/docs/plugins/1st-party-plugins",
|
||||
icon: () => <LucideAArrowDown className="w-4 h-4" />,
|
||||
},
|
||||
{
|
||||
title: "OIDC Provider",
|
||||
href: "/docs/plugins/oidc-provider",
|
||||
icon: () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1.2em"
|
||||
height="1.2em"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16 2a8 8 0 1 0 8 8a8.01 8.01 0 0 0-8-8m5.91 7h-2.438a15.3 15.3 0 0 0-.791-4.36A6.01 6.01 0 0 1 21.91 9m-5.888 6.999h-.008c-.38-.12-1.309-1.821-1.479-4.999h2.93c-.17 3.176-1.094 4.877-1.443 4.999M14.535 9c.17-3.176 1.094-4.877 1.443-4.999h.008c.38.12 1.309 1.821 1.479 4.999zM13.32 4.64A15.3 15.3 0 0 0 12.528 9H10.09a6.01 6.01 0 0 1 3.23-4.36M10.09 11h2.437a15.3 15.3 0 0 0 .792 4.36A6.01 6.01 0 0 1 10.09 11m8.59 4.36a15.3 15.3 0 0 0 .792-4.36h2.438a6.01 6.01 0 0 1-3.23 4.36M28 30H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2M4 22v6h24v-6z"
|
||||
></path>
|
||||
<circle cx="7" cy="25" r="1" fill="currentColor"></circle>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "SSO",
|
||||
icon: () => (
|
||||
@@ -956,9 +974,13 @@ export const contents: Content[] = [
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16 2a8 8 0 1 0 8 8a8.01 8.01 0 0 0-8-8m5.91 7h-2.438a15.3 15.3 0 0 0-.791-4.36A6.01 6.01 0 0 1 21.91 9m-5.888 6.999h-.008c-.38-.12-1.309-1.821-1.479-4.999h2.93c-.17 3.176-1.094 4.877-1.443 4.999M14.535 9c.17-3.176 1.094-4.877 1.443-4.999h.008c.38.12 1.309 1.821 1.479 4.999zM13.32 4.64A15.3 15.3 0 0 0 12.528 9H10.09a6.01 6.01 0 0 1 3.23-4.36M10.09 11h2.437a15.3 15.3 0 0 0 .792 4.36A6.01 6.01 0 0 1 10.09 11m8.59 4.36a15.3 15.3 0 0 0 .792-4.36h2.438a6.01 6.01 0 0 1-3.23 4.36M28 30H4a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2h24a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2M4 22v6h24v-6z"
|
||||
d="M6 30h20a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2m0-8h20v6H6Z"
|
||||
></path>
|
||||
<circle cx="9" cy="25" r="1" fill="currentColor"></circle>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m26 2l-1.41 1.41L27.17 6h-4.855A6.984 6.984 0 0 0 9.08 10H4.83l2.58-2.59L6 6l-5 5l5 5l1.41-1.41L4.83 12h4.855A6.984 6.984 0 0 0 22.92 8h4.25l-2.58 2.59L26 12l5-5Zm-5 7a4.983 4.983 0 0 1-8.974 3H16v-2h-4.899a4.985 4.985 0 0 1 8.874-4H16v2h4.899A5 5 0 0 1 21 9"
|
||||
></path>
|
||||
<circle cx="7" cy="25" r="1" fill="currentColor"></circle>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -22,7 +22,6 @@ import { jwt, bearer } from "better-auth/plugins"
|
||||
export const auth = betterAuth({
|
||||
plugins: [ // [!code highlight]
|
||||
jwt(), // [!code highlight]
|
||||
bearer() //this allows you to pass the session as a bearer token // [!code highlight]
|
||||
] // [!code highlight]
|
||||
})
|
||||
```
|
||||
|
||||
392
docs/content/docs/plugins/oidc-provider.mdx
Normal file
392
docs/content/docs/plugins/oidc-provider.mdx
Normal file
@@ -0,0 +1,392 @@
|
||||
---
|
||||
title: OIDC Provider
|
||||
description: Open ID Connect plugin for Better Auth that allows you to have your own OIDC provider.
|
||||
---
|
||||
|
||||
The **OIDC Provider Plugin** enables you to build and manage your own OpenID Connect (OIDC) provider, granting full control over user authentication without relying on third-party services like Okta or Azure AD. It also allows other services to authenticate users through your OIDC provider.
|
||||
|
||||
**Key Features**:
|
||||
|
||||
- **Client Registration**: Register clients to authenticate with your OIDC provider.
|
||||
- **Dynamic Client Registration**: Allow clients to register dynamically.
|
||||
- **Authorization Code Flow**: Support the Authorization Code Flow.
|
||||
- **JWKS Endpoint**: Publish a JWKS endpoint to allow clients to verify tokens. (Not fully implemented)
|
||||
- **Refresh Tokens**: Issue refresh tokens and handle access token renewal using the `refresh_token` grant.
|
||||
- **OAuth Consent**: Implement OAuth consent screens for user authorization, with an option to bypass consent for trusted applications.
|
||||
- **UserInfo Endpoint**: Provide a UserInfo endpoint for clients to retrieve user details.
|
||||
|
||||
<Callout type="warn">
|
||||
This plugin is in active development and may not be suitable for production use. Please report any issues or bugs on [GitHub](https://github.com/better-auth/better-auth).
|
||||
</Callout>
|
||||
|
||||
## Installation
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
### Mount the Plugin
|
||||
|
||||
Add the OIDC plugin to your auth config. See [OIDC Configuration](#oidc-configuration) on how to configure the plugin.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
import { oidcProvider } from "better-auth/plugins/oidc-provider";
|
||||
|
||||
const auth = betterAuth({
|
||||
plugins: [oidcProvider({
|
||||
loginPage: "/sign-in", // path to the login page
|
||||
// ...other options
|
||||
})]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Migrate the Database
|
||||
|
||||
Run the migration or generate the schema to add the necessary fields and tables to the database.
|
||||
|
||||
<Tabs items={["migrate", "generate"]}>
|
||||
<Tab value="migrate">
|
||||
```bash
|
||||
npx @better-auth/cli migrate
|
||||
```
|
||||
</Tab>
|
||||
<Tab value="generate">
|
||||
```bash
|
||||
npx @better-auth/cli generate
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
See the [Schema](#schema) section to add the fields manually.
|
||||
</Step>
|
||||
|
||||
<Step>
|
||||
### Add the Client Plugin
|
||||
|
||||
Add the OIDC client plugin to your auth client config.
|
||||
|
||||
```ts
|
||||
import { createAuthClient } from "better-auth/client";
|
||||
import { oidcClient } from "better-auth/client/plugins"
|
||||
const authClient = createAuthClient({
|
||||
plugins: [oidcClient({
|
||||
// Your OIDC configuration
|
||||
})]
|
||||
})
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Usage
|
||||
|
||||
Once installed, you can utilize the OIDC Provider to manage authentication flows within your application.
|
||||
|
||||
### Register a New Client
|
||||
|
||||
To register a new OIDC client, use the `oauth2.register` method.
|
||||
|
||||
```ts title="client.ts"
|
||||
const application = await client.oauth2.register({
|
||||
name: "My Client",
|
||||
redirectURLs: ["https://client.example.com/callback"],
|
||||
});
|
||||
```
|
||||
|
||||
Once the application is created, you will receive a `clientId` and `clientSecret` that you can display to the user.
|
||||
|
||||
### Consent Screen
|
||||
|
||||
When a user is redirected to the OIDC provider for authentication, they may be prompted to authorize the application to access their data. This is known as the consent screen. By default, Better Auth will display a sample consent screen. You can customize the consent screen by providing a `consentPage` option during initialization.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [oidcProvider({
|
||||
consentPage: "/path/to/consent/page"
|
||||
})]
|
||||
})
|
||||
```
|
||||
|
||||
The plugin will redirect the user to the specified path with a `client_id` and `scope` query parameter. You can use this information to display a custom consent screen. Once the user consents, you can call `oauth2.consent` to complete the authorization.
|
||||
|
||||
```ts title="server.ts"
|
||||
const res = await client.oauth2.consent({
|
||||
accept: true, // or false to deny
|
||||
});
|
||||
```
|
||||
|
||||
The `client_id` and other necessary information are stored in the browser cookie, so you don't need to pass them in the request. If they don't exist in the cookie, the consent method will return an error.
|
||||
|
||||
### Handling Login
|
||||
|
||||
When a user is redirected to the OIDC provider for authentication, if they are not already logged in, they will be redirected to the login page. You can customize the login page by providing a `loginPage` option during initialization.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [oidcProvider({
|
||||
loginPage: "/sign-in"
|
||||
})]
|
||||
})
|
||||
```
|
||||
|
||||
You don't need to handle anything from your side; when a new session is created, the plugin will handle continuing the authorization flow.
|
||||
|
||||
## Configuration
|
||||
|
||||
### OIDC Metadata
|
||||
|
||||
Customize the OIDC metadata by providing a configuration object during initialization.
|
||||
|
||||
```ts title="auth.ts"
|
||||
import { betterAuth } from "better-auth";
|
||||
import { oidcProvider } from "better-auth/plugins/oidc-provider";
|
||||
|
||||
export const auth = betterAuth({
|
||||
plugins: [oidcProvider({
|
||||
metadata: {
|
||||
issuer: "https://your-domain.com",
|
||||
authorization_endpoint: "/custom/oauth2/authorize",
|
||||
token_endpoint: "/custom/oauth2/token",
|
||||
// ...other custom metadata
|
||||
}
|
||||
})]
|
||||
})
|
||||
```
|
||||
|
||||
### JWKS Endpoint (Not Fully Implemented)
|
||||
|
||||
For JWKS support, you need to use the `jwt` plugin. It exposes the `/jwks` endpoint to provide the public keys.
|
||||
|
||||
<Callout type="warn">
|
||||
Currently, the token is signed with the application's secret key. The JWKS endpoint is not fully implemented yet.
|
||||
</Callout>
|
||||
|
||||
### Dynamic Client Registration
|
||||
|
||||
If you want to allow clients to register dynamically, you can enable this feature by setting the `allowDynamicClientRegistration` option to `true`.
|
||||
|
||||
```ts title="auth.ts"
|
||||
const auth = betterAuth({
|
||||
plugins: [oidcProvider({
|
||||
allowDynamicClientRegistration: true,
|
||||
})]
|
||||
})
|
||||
```
|
||||
|
||||
This will allow clients to register using the `/register` endpoint to be publicly available.
|
||||
|
||||
## Schema
|
||||
|
||||
The OIDC Provider plugin adds the following tables to the database:
|
||||
|
||||
### OAuth Client
|
||||
|
||||
Table Name: `oauthClient`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Database ID of the OAuth client",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "clientId",
|
||||
type: "string",
|
||||
description: "Unique identifier for each OAuth client",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "clientSecret",
|
||||
type: "string",
|
||||
description: "Secret key for the OAuth client",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: "string",
|
||||
description: "Name of the OAuth client",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "redirectURLs",
|
||||
type: "string",
|
||||
description: "Comma-separated list of redirect URLs",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "metadata",
|
||||
type: "string",
|
||||
description: "Additional metadata for the OAuth client",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "type",
|
||||
type: "string",
|
||||
description: "Type of OAuth client (e.g., web, mobile)",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "disabled",
|
||||
type: "boolean",
|
||||
description: "Indicates if the client is disabled",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "ID of the user who owns the client. (optional)",
|
||||
isOptional: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the OAuth client was created"
|
||||
},
|
||||
{
|
||||
name: "updatedAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the OAuth client was last updated"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
### OAuth Access Token
|
||||
|
||||
Table Name: `oauthAccessToken`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Database ID of the access token",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "accessToken",
|
||||
type: "string",
|
||||
description: "Access token issued to the client",
|
||||
},
|
||||
{
|
||||
name: "refreshToken",
|
||||
type: "string",
|
||||
description: "Refresh token issued to the client",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "accessTokenExpiresAt",
|
||||
type: "Date",
|
||||
description: "Expiration date of the access token",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "refreshTokenExpiresAt",
|
||||
type: "Date",
|
||||
description: "Expiration date of the refresh token",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "clientId",
|
||||
type: "string",
|
||||
description: "ID of the OAuth client",
|
||||
isForeignKey: true,
|
||||
references: { model: "oauthClient", field: "clientId" }
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "ID of the user associated with the token",
|
||||
isForeignKey: true,
|
||||
references: { model: "user", field: "id" }
|
||||
},
|
||||
{
|
||||
name: "scopes",
|
||||
type: "string",
|
||||
description: "Comma-separated list of scopes granted",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the access token was created"
|
||||
},
|
||||
{
|
||||
name: "updatedAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the access token was last updated"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
### OAuth Consent
|
||||
|
||||
Table Name: `oauthConsent`
|
||||
|
||||
<DatabaseTable
|
||||
fields={[
|
||||
{
|
||||
name: "id",
|
||||
type: "string",
|
||||
description: "Database ID of the consent",
|
||||
isPrimaryKey: true
|
||||
},
|
||||
{
|
||||
name: "userId",
|
||||
type: "string",
|
||||
description: "ID of the user who gave consent",
|
||||
isForeignKey: true,
|
||||
references: { model: "user", field: "id" }
|
||||
},
|
||||
{
|
||||
name: "clientId",
|
||||
type: "string",
|
||||
description: "ID of the OAuth client",
|
||||
isForeignKey: true,
|
||||
references: { model: "oauthClient", field: "clientId" }
|
||||
},
|
||||
{
|
||||
name: "scopes",
|
||||
type: "string",
|
||||
description: "Comma-separated list of scopes consented to",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "consentGiven",
|
||||
type: "boolean",
|
||||
description: "Indicates if consent was given",
|
||||
isRequired: true
|
||||
},
|
||||
{
|
||||
name: "createdAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the consent was given"
|
||||
},
|
||||
{
|
||||
name: "updatedAt",
|
||||
type: "Date",
|
||||
description: "Timestamp of when the consent was last updated"
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
## Options
|
||||
|
||||
**allowDynamicClientRegistration**: `boolean` - Enable or disable dynamic client registration.
|
||||
|
||||
**metadata**: `OIDCMetadata` - Customize the OIDC provider metadata.
|
||||
|
||||
**loginPage**: `string` - Path to the custom login page.
|
||||
|
||||
**consentPage**: `string` - Path to the custom consent page.
|
||||
|
||||
**allowDynamicClientRegistration**: `boolean` - Enable or disable dynamic client registration.
|
||||
|
||||
**allowDynamicClientRegistration**: `boolean` - Enable or disable dynamic client registration.
|
||||
|
||||
**allowDynamicClientRegistration**: `boolean` - Enable or disable dynamic client registration.
|
||||
|
||||
@@ -337,6 +337,16 @@
|
||||
"default": "./dist/plugins/sso.cjs"
|
||||
}
|
||||
},
|
||||
"./plugins/oidc-plugin": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/oidc-plugin.d.ts",
|
||||
"default": "./dist/plugins/oidc-plugin.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/plugins/oidc-plugin.d.cts",
|
||||
"default": "./dist/plugins/oidc-plugin.cjs"
|
||||
}
|
||||
},
|
||||
"./plugins/magic-link": {
|
||||
"import": {
|
||||
"types": "./dist/plugins/magic-link.d.ts",
|
||||
@@ -510,6 +520,15 @@
|
||||
"plugins/generic-oauth": [
|
||||
"./dist/plugins/generic-oauth.d.ts"
|
||||
],
|
||||
"plugins/oauth-proxy": [
|
||||
"./dist/plugins/oauth-proxy.d.ts"
|
||||
],
|
||||
"plugins/sso": [
|
||||
"./dist/plugins/sso.d.ts"
|
||||
],
|
||||
"plugins/oidc-plugin": [
|
||||
"./dist/plugins/oidc-plugin.d.ts"
|
||||
],
|
||||
"plugins/jwt": [
|
||||
"./dist/plugins/jwt.d.ts"
|
||||
],
|
||||
|
||||
@@ -323,6 +323,10 @@ export function getEndpoints<
|
||||
}
|
||||
});
|
||||
}
|
||||
if (response instanceof APIError) {
|
||||
response.headers = endpoint.headers;
|
||||
throw response;
|
||||
}
|
||||
return response;
|
||||
};
|
||||
api[key].path = endpoint.path;
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from "../../plugins/one-tap/client";
|
||||
export * from "../../plugins/custom-session/client";
|
||||
export * from "./infer-plugin";
|
||||
export * from "../../plugins/sso/client";
|
||||
export * from "../../plugins/oidc-provider/client";
|
||||
|
||||
@@ -69,7 +69,7 @@ export const init = async (options: BetterAuthOptions) => {
|
||||
if (value.enabled === false) {
|
||||
return null;
|
||||
}
|
||||
if (!value.clientId || !value.clientSecret) {
|
||||
if (!value.clientId) {
|
||||
logger.warn(
|
||||
`Social provider ${key} is missing clientId or clientSecret`,
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export async function validateAuthorizationCode({
|
||||
body: body,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ interface GenericOAuthConfig {
|
||||
* Prompt parameter for the authorization request.
|
||||
* Controls the authentication experience for the user.
|
||||
*/
|
||||
prompt?: string;
|
||||
prompt?: "none" | "login" | "consent" | "select_account";
|
||||
/**
|
||||
* Whether to use PKCE (Proof Key for Code Exchange)
|
||||
* @default false
|
||||
@@ -116,21 +116,19 @@ async function getUserInfo(
|
||||
) {
|
||||
if (tokens.idToken) {
|
||||
const decoded = decodeJwt(tokens.idToken) as {
|
||||
payload: {
|
||||
sub: string;
|
||||
email_verified: boolean;
|
||||
email: string;
|
||||
name: string;
|
||||
picture: string;
|
||||
};
|
||||
};
|
||||
if (decoded?.payload) {
|
||||
if (decoded.payload.sub && decoded.payload.email) {
|
||||
if (decoded) {
|
||||
if (decoded.sub && decoded.email) {
|
||||
return {
|
||||
id: decoded.payload.sub,
|
||||
emailVerified: decoded.payload.email_verified,
|
||||
image: decoded.payload.picture,
|
||||
...decoded.payload,
|
||||
id: decoded.sub,
|
||||
emailVerified: decoded.email_verified,
|
||||
image: decoded.picture,
|
||||
...decoded,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -285,6 +283,11 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
description: "The URL to redirect to if an error occurs",
|
||||
})
|
||||
.optional(),
|
||||
disableRedirect: z
|
||||
.boolean({
|
||||
description: "Disable redirect",
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
metadata: {
|
||||
openapi: {
|
||||
@@ -387,7 +390,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
|
||||
return ctx.json({
|
||||
url: authUrl.toString(),
|
||||
redirect: true,
|
||||
redirect: !ctx.body.disableRedirect,
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -406,9 +409,11 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
description: "The error message, if any",
|
||||
})
|
||||
.optional(),
|
||||
state: z.string({
|
||||
state: z
|
||||
.string({
|
||||
description: "The state parameter from the OAuth2 request",
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
metadata: {
|
||||
openapi: {
|
||||
@@ -436,7 +441,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
async (ctx) => {
|
||||
if (ctx.query.error || !ctx.query.code) {
|
||||
throw ctx.redirect(
|
||||
`${ctx.context.baseURL}?error=${
|
||||
`${ctx.context.options.baseURL}?error=${
|
||||
ctx.query.error || "oAuth_code_missing"
|
||||
}`,
|
||||
);
|
||||
@@ -532,6 +537,7 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
|
||||
scope: tokens.scopes?.join(","),
|
||||
},
|
||||
});
|
||||
|
||||
function redirectOnError(error: string) {
|
||||
throw ctx.redirect(
|
||||
`${
|
||||
|
||||
@@ -17,3 +17,4 @@ export * from "./one-tap";
|
||||
export * from "./oauth-proxy";
|
||||
export * from "./custom-session";
|
||||
export * from "./open-api";
|
||||
export * from "./oidc-provider";
|
||||
|
||||
@@ -155,6 +155,48 @@ export const jwt = (options?: JwtOptions) => {
|
||||
|
||||
const keySets = await adapter.getAllKeys();
|
||||
|
||||
if (keySets.length === 0) {
|
||||
const { publicKey, privateKey } = await generateKeyPair(
|
||||
options?.jwks?.keyPairConfig?.alg ?? "EdDSA",
|
||||
options?.jwks?.keyPairConfig ?? {
|
||||
crv: "Ed25519",
|
||||
extractable: true,
|
||||
},
|
||||
);
|
||||
|
||||
const publicWebKey = await exportJWK(publicKey);
|
||||
const privateWebKey = await exportJWK(privateKey);
|
||||
const stringifiedPrivateWebKey = JSON.stringify(privateWebKey);
|
||||
const privateKeyEncryptionEnabled =
|
||||
!options?.jwks?.disablePrivateKeyEncryption;
|
||||
let jwk: Partial<Jwk> = {
|
||||
id: ctx.context.generateId({
|
||||
model: "jwks",
|
||||
}),
|
||||
publicKey: JSON.stringify(publicWebKey),
|
||||
privateKey: privateKeyEncryptionEnabled
|
||||
? JSON.stringify(
|
||||
await symmetricEncrypt({
|
||||
key: ctx.context.options.secret!,
|
||||
data: stringifiedPrivateWebKey,
|
||||
}),
|
||||
)
|
||||
: stringifiedPrivateWebKey,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await adapter.createJwk(jwk as Jwk);
|
||||
|
||||
return ctx.json({
|
||||
keys: [
|
||||
{
|
||||
...publicWebKey,
|
||||
kid: jwk.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.json({
|
||||
keys: keySets.map((keySet) => ({
|
||||
...JSON.parse(keySet.publicKey),
|
||||
@@ -214,7 +256,9 @@ export const jwt = (options?: JwtOptions) => {
|
||||
const stringifiedPrivateWebKey = JSON.stringify(privateWebKey);
|
||||
|
||||
let jwk: Partial<Jwk> = {
|
||||
id: crypto.randomUUID(),
|
||||
id: ctx.context.generateId({
|
||||
model: "jwks",
|
||||
}),
|
||||
publicKey: JSON.stringify(publicWebKey),
|
||||
privateKey: privateKeyEncryptionEnabled
|
||||
? JSON.stringify(
|
||||
|
||||
266
packages/better-auth/src/plugins/oidc-provider/authorize.ts
Normal file
266
packages/better-auth/src/plugins/oidc-provider/authorize.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { APIError } from "better-call";
|
||||
import type { GenericEndpointContext } from "../../types";
|
||||
import { getSessionFromCtx } from "../../api";
|
||||
import type { AuthorizationQuery, Client, OIDCOptions } from "./types";
|
||||
import { schema } from "./schema";
|
||||
import { generateRandomString } from "../../crypto";
|
||||
|
||||
function redirectErrorURL(url: string, error: string, description: string) {
|
||||
return `${
|
||||
url.includes("?") ? "&" : "?"
|
||||
}error=${error}&error_description=${description}`;
|
||||
}
|
||||
|
||||
export async function authorize(
|
||||
ctx: GenericEndpointContext,
|
||||
options: OIDCOptions,
|
||||
) {
|
||||
const opts = {
|
||||
codeExpiresIn: 600,
|
||||
defaultScope: "openid",
|
||||
...options,
|
||||
scopes: [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
...(options?.scopes || []),
|
||||
],
|
||||
};
|
||||
if (!ctx.request) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "request not found",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
const session = await getSessionFromCtx(ctx);
|
||||
if (!session) {
|
||||
/**
|
||||
* If the user is not logged in, we need to redirect them to the
|
||||
* login page.
|
||||
*/
|
||||
await ctx.setSignedCookie(
|
||||
"oidc_login_prompt",
|
||||
JSON.stringify(ctx.query),
|
||||
ctx.context.secret,
|
||||
{
|
||||
maxAge: 600,
|
||||
},
|
||||
);
|
||||
const queryFromURL = ctx.request.url?.split("?")[1];
|
||||
throw ctx.redirect(`${options.loginPage}?${queryFromURL}`);
|
||||
}
|
||||
|
||||
const query = ctx.query as AuthorizationQuery;
|
||||
if (!query.client_id) {
|
||||
throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`);
|
||||
}
|
||||
|
||||
if (!query.response_type) {
|
||||
throw ctx.redirect(
|
||||
redirectErrorURL(
|
||||
`${ctx.context.baseURL}/error`,
|
||||
"invalid_request",
|
||||
"response_type is required",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const client = await ctx.context.adapter
|
||||
.findOne<Record<string, any>>({
|
||||
model: "oauthApplication",
|
||||
where: [
|
||||
{
|
||||
field: "clientId",
|
||||
value: ctx.query.client_id,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...res,
|
||||
redirectURLs: res.redirectURLs.split(","),
|
||||
metadata: res.metadata ? JSON.parse(res.metadata) : {},
|
||||
} as Client;
|
||||
});
|
||||
if (!client) {
|
||||
throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`);
|
||||
}
|
||||
const redirectURI = client.redirectURLs.find(
|
||||
(url) => url === ctx.query.redirect_uri,
|
||||
);
|
||||
|
||||
if (!redirectURI || !query.redirect_uri) {
|
||||
/**
|
||||
* show UI error here warning the user that the redirect URI is invalid
|
||||
*/
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Invalid redirect URI",
|
||||
});
|
||||
}
|
||||
if (client.disabled) {
|
||||
throw ctx.redirect(`${ctx.context.baseURL}/error?error=client_disabled`);
|
||||
}
|
||||
|
||||
if (query.response_type !== "code") {
|
||||
throw ctx.redirect(
|
||||
`${ctx.context.baseURL}/error?error=unsupported_response_type`,
|
||||
);
|
||||
}
|
||||
|
||||
const requestScope =
|
||||
query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" ");
|
||||
const invalidScopes = requestScope.filter((scope) => {
|
||||
const isInvalid =
|
||||
!opts.scopes.includes(scope) ||
|
||||
(scope === "offline_access" && query.prompt !== "consent");
|
||||
return isInvalid;
|
||||
});
|
||||
if (invalidScopes.length) {
|
||||
throw ctx.redirect(
|
||||
redirectErrorURL(
|
||||
query.redirect_uri,
|
||||
"invalid_scope",
|
||||
`The following scopes are invalid: ${invalidScopes.join(", ")}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(!query.code_challenge || !query.code_challenge_method) &&
|
||||
options.requirePKCE
|
||||
) {
|
||||
throw ctx.redirect(
|
||||
redirectErrorURL(
|
||||
query.redirect_uri,
|
||||
"invalid_request",
|
||||
"pkce is required",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
![
|
||||
"s256",
|
||||
options.allowPlainCodeChallengeMethod ? "plain" : "s256",
|
||||
].includes(query.code_challenge_method?.toLowerCase() || "")
|
||||
) {
|
||||
throw ctx.redirect(
|
||||
redirectErrorURL(
|
||||
query.redirect_uri,
|
||||
"invalid_request",
|
||||
"invalid code_challenge method",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
||||
const codeExpiresInMs = opts.codeExpiresIn * 1000;
|
||||
const expiresAt = new Date(Date.now() + codeExpiresInMs);
|
||||
try {
|
||||
/**
|
||||
* Save the code in the database
|
||||
*/
|
||||
await ctx.context.internalAdapter.createVerificationValue({
|
||||
value: JSON.stringify({
|
||||
clientId: client.clientId,
|
||||
redirectURI: query.redirect_uri,
|
||||
scope: requestScope,
|
||||
userId: session.user.id,
|
||||
authTime: session.session.createdAt.getTime(),
|
||||
/**
|
||||
* If the prompt is set to `consent`, then we need
|
||||
* to require the user to consent to the scopes.
|
||||
*
|
||||
* This means the code now needs to be treated as a
|
||||
* consent request.
|
||||
*
|
||||
* once the user consents, teh code will be updated
|
||||
* with the actual code. This is to prevent the
|
||||
* client from using the code before the user
|
||||
* consents.
|
||||
*/
|
||||
requireConsent: query.prompt === "consent",
|
||||
state: query.prompt === "consent" ? query.state : null,
|
||||
codeChallenge: query.code_challenge,
|
||||
codeChallengeMethod: query.code_challenge_method,
|
||||
}),
|
||||
identifier: code,
|
||||
expiresAt,
|
||||
});
|
||||
} catch (e) {
|
||||
throw ctx.redirect(
|
||||
redirectErrorURL(
|
||||
query.redirect_uri,
|
||||
"server_error",
|
||||
"An error occurred while processing the request",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const redirectURIWithCode = new URL(redirectURI);
|
||||
redirectURIWithCode.searchParams.set("code", code);
|
||||
redirectURIWithCode.searchParams.set("state", ctx.query.state);
|
||||
|
||||
if (query.prompt !== "consent") {
|
||||
throw ctx.redirect(redirectURIWithCode.toString());
|
||||
}
|
||||
|
||||
const hasAlreadyConsented = await ctx.context.adapter
|
||||
.findOne<{
|
||||
consentGiven: boolean;
|
||||
}>({
|
||||
model: "oauthConsent",
|
||||
where: [
|
||||
{
|
||||
field: "clientId",
|
||||
value: client.clientId,
|
||||
},
|
||||
{
|
||||
field: "userId",
|
||||
value: session.user.id,
|
||||
},
|
||||
],
|
||||
})
|
||||
.then((res) => !!res?.consentGiven);
|
||||
|
||||
if (hasAlreadyConsented) {
|
||||
throw ctx.redirect(redirectURIWithCode.toString());
|
||||
}
|
||||
|
||||
if (options?.consentPage) {
|
||||
await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, {
|
||||
maxAge: 600,
|
||||
});
|
||||
const conceptURI = `${options.consentPage}?client_id=${
|
||||
client.clientId
|
||||
}&scope=${requestScope.join(" ")}`;
|
||||
throw ctx.redirect(conceptURI);
|
||||
}
|
||||
const htmlFn = options?.getConsentHTML;
|
||||
|
||||
if (!htmlFn) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "No consent page provided",
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
htmlFn({
|
||||
scopes: requestScope,
|
||||
clientMetadata: client.metadata,
|
||||
clientIcon: client?.icon,
|
||||
clientId: client.clientId,
|
||||
clientName: client.name,
|
||||
code,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
9
packages/better-auth/src/plugins/oidc-provider/client.ts
Normal file
9
packages/better-auth/src/plugins/oidc-provider/client.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { oidcProvider } from ".";
|
||||
import type { BetterAuthClientPlugin } from "../../types";
|
||||
|
||||
export const oidcClient = () => {
|
||||
return {
|
||||
id: "oidc-client",
|
||||
$InferServerPlugin: {} as ReturnType<typeof oidcProvider>,
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
};
|
||||
738
packages/better-auth/src/plugins/oidc-provider/index.ts
Normal file
738
packages/better-auth/src/plugins/oidc-provider/index.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
import { base64url, SignJWT } from "jose";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
APIError,
|
||||
createAuthEndpoint,
|
||||
getSessionFromCtx,
|
||||
sessionMiddleware,
|
||||
} from "../../api";
|
||||
import type { BetterAuthPlugin, GenericEndpointContext } from "../../types";
|
||||
import { generateRandomString } from "../../crypto";
|
||||
import { schema } from "./schema";
|
||||
import type {
|
||||
Client,
|
||||
CodeVerificationValue,
|
||||
OAuthAccessToken,
|
||||
OIDCMetadata,
|
||||
OIDCOptions,
|
||||
} from "./types";
|
||||
import { authorize } from "./authorize";
|
||||
import { parseSetCookieHeader } from "../../cookies";
|
||||
import { sha256 } from "oslo/crypto";
|
||||
import type { Endpoint } from "better-call";
|
||||
|
||||
const getMetadata = (
|
||||
ctx: GenericEndpointContext,
|
||||
options?: OIDCOptions,
|
||||
): OIDCMetadata => {
|
||||
const issuer = ctx.context.options.baseURL as string;
|
||||
const baseURL = ctx.context.baseURL;
|
||||
return {
|
||||
issuer,
|
||||
authorization_endpoint: `${baseURL}/oauth2/authorize`,
|
||||
token_endpoint: `${baseURL}/oauth2/token`,
|
||||
userInfo_endpoint: `${baseURL}/oauth2/userinfo`,
|
||||
jwks_uri: `${baseURL}/jwks`,
|
||||
registration_endpoint: `${baseURL}/oauth2/register`,
|
||||
scopes_supported: ["openid", "profile", "email", "offline_access"],
|
||||
response_types_supported: ["code"],
|
||||
response_modes_supported: ["query"],
|
||||
grant_types_supported: ["authorization_code"],
|
||||
acr_values_supported: [
|
||||
"urn:mace:incommon:iap:silver",
|
||||
"urn:mace:incommon:iap:bronze",
|
||||
],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256", "none"],
|
||||
token_endpoint_auth_methods_supported: [
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
],
|
||||
claims_supported: [
|
||||
"sub",
|
||||
"iss",
|
||||
"aud",
|
||||
"exp",
|
||||
"nbf",
|
||||
"iat",
|
||||
"jti",
|
||||
"email",
|
||||
"email_verified",
|
||||
"name",
|
||||
],
|
||||
...options?.metadata,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenID Connect (OIDC) plugin for Better Auth. This plugin implements the
|
||||
* authorization code flow and the token exchange flow. It also implements the
|
||||
* userinfo endpoint.
|
||||
*
|
||||
* @param options - The options for the OIDC plugin.
|
||||
* @returns A Better Auth plugin.
|
||||
*/
|
||||
export const oidcProvider = (options: OIDCOptions) => {
|
||||
const modelName = {
|
||||
oauthClient: "oauthApplication",
|
||||
oauthAccessToken: "oauthAccessToken",
|
||||
oauthConsent: "oauthConsent",
|
||||
};
|
||||
|
||||
const opts = {
|
||||
codeExpiresIn: 600,
|
||||
defaultScope: "openid",
|
||||
accessTokenExpiresIn: 3600,
|
||||
refreshTokenExpiresIn: 604800,
|
||||
...options,
|
||||
scopes: [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
...(options?.scopes || []),
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
id: "oidc",
|
||||
hooks: {
|
||||
after: [
|
||||
{
|
||||
matcher() {
|
||||
return true;
|
||||
},
|
||||
handler: async (ctx) => {
|
||||
const cookie = await ctx.getSignedCookie(
|
||||
"oidc_login_prompt",
|
||||
ctx.context.secret,
|
||||
);
|
||||
const cookieName = ctx.context.authCookies.sessionToken.name;
|
||||
const parsedSetCookieHeader = parseSetCookieHeader(
|
||||
ctx.responseHeader.get("set-cookie") || "",
|
||||
);
|
||||
const hasSessionToken = parsedSetCookieHeader.has(cookieName);
|
||||
if (!cookie || !hasSessionToken) {
|
||||
return;
|
||||
}
|
||||
ctx.setCookie("oidc_login_prompt", "", {
|
||||
maxAge: 0,
|
||||
});
|
||||
const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value;
|
||||
const sessionToken = sessionCookie?.split(".")[0];
|
||||
if (!sessionToken) {
|
||||
return;
|
||||
}
|
||||
const session =
|
||||
await ctx.context.internalAdapter.findSession(sessionToken);
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
ctx.query = JSON.parse(cookie);
|
||||
ctx.query.prompt = "consent";
|
||||
ctx.context.session = session;
|
||||
const response = await authorize(ctx, opts);
|
||||
return response;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
endpoints: {
|
||||
getOpenIdConfig: createAuthEndpoint(
|
||||
"/.well-known/openid-configuration",
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) => {
|
||||
const metadata = getMetadata(ctx, options);
|
||||
return metadata;
|
||||
},
|
||||
),
|
||||
oAuth2authorize: createAuthEndpoint(
|
||||
"/oauth2/authorize",
|
||||
{
|
||||
method: "GET",
|
||||
query: z.record(z.string(), z.any()),
|
||||
},
|
||||
async (ctx) => {
|
||||
return authorize(ctx, opts);
|
||||
},
|
||||
),
|
||||
oAuthConsent: createAuthEndpoint(
|
||||
"/oauth2/consent",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
accept: z.boolean(),
|
||||
}),
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const storedCode = await ctx.getSignedCookie(
|
||||
"oidc_consent_prompt",
|
||||
ctx.context.secret,
|
||||
);
|
||||
if (!storedCode) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "No consent prompt found",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
const verification =
|
||||
await ctx.context.internalAdapter.findVerificationValue(storedCode);
|
||||
if (!verification) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "Invalid code",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
if (verification.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
);
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "Code expired",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
const value = JSON.parse(verification.value) as CodeVerificationValue;
|
||||
if (!value.requireConsent || !value.state) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "Consent not required",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
|
||||
if (!ctx.body.accept) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verification.id,
|
||||
);
|
||||
return ctx.json({
|
||||
redirectURI: `${value.redirectURI}?error=access_denied&error_description=User denied access`,
|
||||
});
|
||||
}
|
||||
const code = generateRandomString(32, "a-z", "A-Z", "0-9");
|
||||
const codeExpiresInMs = opts.codeExpiresIn * 1000;
|
||||
const expiresAt = new Date(Date.now() + codeExpiresInMs);
|
||||
await ctx.context.internalAdapter.updateVerificationValue(
|
||||
verification.id,
|
||||
{
|
||||
value: JSON.stringify({
|
||||
...value,
|
||||
requireConsent: false,
|
||||
}),
|
||||
identifier: code,
|
||||
expiresAt,
|
||||
},
|
||||
);
|
||||
await ctx.context.adapter.create({
|
||||
model: modelName.oauthConsent,
|
||||
data: {
|
||||
clientId: value.clientId,
|
||||
userId: value.userId,
|
||||
scopes: value.scope.join(" "),
|
||||
consentGiven: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const redirectURI = new URL(value.redirectURI);
|
||||
redirectURI.searchParams.set("code", code);
|
||||
redirectURI.searchParams.set("state", value.state);
|
||||
return ctx.json({
|
||||
redirectURI: redirectURI.toString(),
|
||||
});
|
||||
},
|
||||
),
|
||||
oAuth2token: createAuthEndpoint(
|
||||
"/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.any(),
|
||||
metadata: {
|
||||
isAction: false,
|
||||
},
|
||||
},
|
||||
async (ctx) => {
|
||||
let { body } = ctx;
|
||||
if (!body) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "request body not found",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
body = Object.fromEntries(body.entries());
|
||||
}
|
||||
if (!(body instanceof Object)) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "request body is not an object",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
const {
|
||||
client_id,
|
||||
client_secret,
|
||||
grant_type,
|
||||
code,
|
||||
redirect_uri,
|
||||
refresh_token,
|
||||
code_verifier,
|
||||
} = body;
|
||||
if (grant_type === "refresh_token") {
|
||||
if (!refresh_token) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "refresh_token is required",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
const token = await ctx.context.adapter.findOne<OAuthAccessToken>({
|
||||
model: modelName.oauthAccessToken,
|
||||
where: [
|
||||
{
|
||||
field: "refreshToken",
|
||||
value: refresh_token.toString(),
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!token) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid refresh token",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
if (token.clientId !== client_id?.toString()) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid client_id",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
if (token.refreshTokenExpiresAt < new Date()) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "refresh token expired",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
const accessToken = generateRandomString(32, "a-z", "A-Z");
|
||||
const newRefreshToken = generateRandomString(32, "a-z", "A-Z");
|
||||
const accessTokenExpiresAt = new Date(
|
||||
Date.now() + opts.accessTokenExpiresIn * 1000,
|
||||
);
|
||||
const refreshTokenExpiresAt = new Date(
|
||||
Date.now() + opts.refreshTokenExpiresIn * 1000,
|
||||
);
|
||||
await ctx.context.adapter.create({
|
||||
model: modelName.oauthAccessToken,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
clientId: client_id.toString(),
|
||||
userId: token.userId,
|
||||
scopes: token.scopes,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return ctx.json({
|
||||
access_token: accessToken,
|
||||
token_type: "bearer",
|
||||
expires_in: opts.accessTokenExpiresIn,
|
||||
refresh_token: newRefreshToken,
|
||||
scope: token.scopes,
|
||||
});
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "code is required",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
if (options.requirePKCE && !code_verifier) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "code verifier is missing",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* We need to check if the code is valid before we can proceed
|
||||
* with the rest of the request.
|
||||
*/
|
||||
const verificationValue =
|
||||
await ctx.context.internalAdapter.findVerificationValue(
|
||||
code.toString(),
|
||||
);
|
||||
if (!verificationValue) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid code",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
if (verificationValue.expiresAt < new Date()) {
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
);
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "code expired",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
verificationValue.id,
|
||||
);
|
||||
if (!client_id || !client_secret) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "client_id and client_secret are required",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
if (!grant_type) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "grant_type is required",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
if (grant_type !== "authorization_code") {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "grant_type must be 'authorization_code'",
|
||||
error: "unsupported_grant_type",
|
||||
});
|
||||
}
|
||||
|
||||
if (!redirect_uri) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "redirect_uri is required",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const client = await ctx.context.adapter
|
||||
.findOne<Record<string, any>>({
|
||||
model: modelName.oauthClient,
|
||||
where: [{ field: "clientId", value: client_id.toString() }],
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...res,
|
||||
redirectURLs: res.redirectURLs.split(","),
|
||||
metadata: res.metadata ? JSON.parse(res.metadata) : {},
|
||||
} as Client;
|
||||
});
|
||||
if (!client) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid client_id",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
if (client.disabled) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "client is disabled",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
const isValidSecret =
|
||||
client.clientSecret === client_secret.toString();
|
||||
if (!isValidSecret) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid client_secret",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
const value = JSON.parse(
|
||||
verificationValue.value,
|
||||
) as CodeVerificationValue;
|
||||
if (value.clientId !== client_id.toString()) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid client_id",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
if (value.redirectURI !== redirect_uri.toString()) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid redirect_uri",
|
||||
error: "invalid_client",
|
||||
});
|
||||
}
|
||||
if (value.codeChallenge && !code_verifier) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: "code verifier is missing",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const challenge =
|
||||
value.codeChallengeMethod === "plain"
|
||||
? code_verifier
|
||||
: Buffer.from(
|
||||
await sha256(new TextEncoder().encode(code_verifier || "")),
|
||||
).toString("base64url");
|
||||
|
||||
if (challenge !== value.codeChallenge) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "code verification failed",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
const requestedScopes = value.scope;
|
||||
await ctx.context.internalAdapter.deleteVerificationValue(
|
||||
code.toString(),
|
||||
);
|
||||
const accessToken = generateRandomString(32, "a-z", "A-Z");
|
||||
const refreshToken = generateRandomString(32, "A-Z", "a-z");
|
||||
const accessTokenExpiresAt = new Date(
|
||||
Date.now() + opts.accessTokenExpiresIn * 1000,
|
||||
);
|
||||
const refreshTokenExpiresAt = new Date(
|
||||
Date.now() + opts.refreshTokenExpiresIn * 1000,
|
||||
);
|
||||
await ctx.context.adapter.create({
|
||||
model: modelName.oauthAccessToken,
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt,
|
||||
clientId: client_id.toString(),
|
||||
userId: value.userId,
|
||||
scopes: requestedScopes.join(" "),
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const user = await ctx.context.internalAdapter.findUserById(
|
||||
value.userId,
|
||||
);
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "user not found",
|
||||
error: "invalid_grant",
|
||||
});
|
||||
}
|
||||
let secretKey = {
|
||||
alg: "HS256",
|
||||
key: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
true,
|
||||
["sign", "verify"],
|
||||
),
|
||||
};
|
||||
const profile = {
|
||||
given_name: user.name.split(" ")[0],
|
||||
family_name: user.name.split(" ")[1],
|
||||
name: user.name,
|
||||
profile: user.image,
|
||||
updated_at: user.updatedAt.toISOString(),
|
||||
};
|
||||
const email = {
|
||||
email: user.email,
|
||||
email_verified: user.emailVerified,
|
||||
};
|
||||
const userClaims = {
|
||||
...(requestedScopes.includes("profile") ? profile : {}),
|
||||
...(requestedScopes.includes("email") ? email : {}),
|
||||
};
|
||||
|
||||
const idToken = await new SignJWT({
|
||||
sub: user.id,
|
||||
aud: client_id.toString(),
|
||||
iat: Date.now(),
|
||||
auth_time: ctx.context.session?.session.createdAt.getTime(),
|
||||
nonce: body.nonce,
|
||||
acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata
|
||||
...userClaims,
|
||||
})
|
||||
.setProtectedHeader({ alg: secretKey.alg })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(
|
||||
Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn,
|
||||
)
|
||||
.sign(secretKey.key);
|
||||
|
||||
return ctx.json(
|
||||
{
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: opts.accessTokenExpiresIn,
|
||||
refresh_token: requestedScopes.includes("offline_access")
|
||||
? refreshToken
|
||||
: undefined,
|
||||
scope: requestedScopes.join(" "),
|
||||
id_token: requestedScopes.includes("openid")
|
||||
? idToken
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
Pragma: "no-cache",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
oAuth2userInfo: createAuthEndpoint(
|
||||
"/oauth2/userinfo",
|
||||
{
|
||||
method: "GET",
|
||||
metadata: {
|
||||
isAction: false,
|
||||
},
|
||||
},
|
||||
async (ctx) => {
|
||||
if (!ctx.request) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "request not found",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
const authorization = ctx.request.headers.get("authorization");
|
||||
if (!authorization) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "authorization header not found",
|
||||
error: "invalid_request",
|
||||
});
|
||||
}
|
||||
const token = authorization.replace("Bearer ", "");
|
||||
const accessToken =
|
||||
await ctx.context.adapter.findOne<OAuthAccessToken>({
|
||||
model: modelName.oauthAccessToken,
|
||||
where: [
|
||||
{
|
||||
field: "accessToken",
|
||||
value: token,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (!accessToken) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "invalid access token",
|
||||
error: "invalid_token",
|
||||
});
|
||||
}
|
||||
if (accessToken.accessTokenExpiresAt < new Date()) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "The Access Token expired",
|
||||
error: "invalid_token",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.context.internalAdapter.findUserById(
|
||||
accessToken.userId,
|
||||
);
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
error_description: "user not found",
|
||||
error: "invalid_token",
|
||||
});
|
||||
}
|
||||
const requestedScopes = accessToken.scopes.split(" ");
|
||||
const userClaims = {
|
||||
email: requestedScopes.includes("email") ? user.email : undefined,
|
||||
name: requestedScopes.includes("profile") ? user.name : undefined,
|
||||
picture: requestedScopes.includes("profile")
|
||||
? user.image
|
||||
: undefined,
|
||||
given_name: requestedScopes.includes("profile")
|
||||
? user.name.split(" ")[0]
|
||||
: undefined,
|
||||
family_name: requestedScopes.includes("profile")
|
||||
? user.name.split(" ")[1]
|
||||
: undefined,
|
||||
email_verified: requestedScopes.includes("email")
|
||||
? user.emailVerified
|
||||
: undefined,
|
||||
};
|
||||
return ctx.json(userClaims);
|
||||
},
|
||||
),
|
||||
registerOAuthApplication: createAuthEndpoint(
|
||||
"/oauth2/register",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
icon: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
redirectURLs: z.array(z.string()),
|
||||
}),
|
||||
},
|
||||
async (ctx) => {
|
||||
const body = ctx.body;
|
||||
const session = await getSessionFromCtx(ctx);
|
||||
if (!session && !options.allowDynamicClientRegistration) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Unauthorized",
|
||||
});
|
||||
}
|
||||
const clientId =
|
||||
options.generateClientId?.() ||
|
||||
generateRandomString(32, "a-z", "A-Z");
|
||||
const clientSecret =
|
||||
options.generateClientSecret?.() ||
|
||||
generateRandomString(32, "a-z", "A-Z");
|
||||
const client = await ctx.context.adapter.create<Record<string, any>>({
|
||||
model: modelName.oauthClient,
|
||||
data: {
|
||||
name: body.name,
|
||||
icon: body.icon,
|
||||
metadata: body.metadata ? JSON.stringify(body.metadata) : null,
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
redirectURLs: body.redirectURLs.join(","),
|
||||
type: "web",
|
||||
authenticationScheme: "client_secret",
|
||||
disabled: false,
|
||||
userId: session?.session.userId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
return ctx.json({
|
||||
...client,
|
||||
redirectURLs: client.redirectURLs.split(","),
|
||||
metadata: client.metadata ? JSON.parse(client.metadata) : null,
|
||||
} as Client);
|
||||
},
|
||||
),
|
||||
getOAuthClient: createAuthEndpoint(
|
||||
"/oauth2/client/:id",
|
||||
{
|
||||
method: "GET",
|
||||
use: [sessionMiddleware],
|
||||
},
|
||||
async (ctx) => {
|
||||
const client = await ctx.context.adapter.findOne<Record<string, any>>(
|
||||
{
|
||||
model: modelName.oauthClient,
|
||||
where: [{ field: "clientId", value: ctx.params.id }],
|
||||
},
|
||||
);
|
||||
if (!client) {
|
||||
throw new APIError("NOT_FOUND", {
|
||||
error_description: "client not found",
|
||||
error: "not_found",
|
||||
});
|
||||
}
|
||||
return ctx.json({
|
||||
clientId: client.clientId as string,
|
||||
name: client.name as string,
|
||||
icon: client.icon as string,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
schema,
|
||||
} satisfies BetterAuthPlugin;
|
||||
};
|
||||
325
packages/better-auth/src/plugins/oidc-provider/oidc.test.ts
Normal file
325
packages/better-auth/src/plugins/oidc-provider/oidc.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { afterAll, beforeAll, describe, it } from "vitest";
|
||||
import { getTestInstance } from "../../test-utils/test-instance";
|
||||
import { oidcProvider } from ".";
|
||||
import { genericOAuth } from "../generic-oauth";
|
||||
import type { Client } from "./types";
|
||||
import { createAuthClient } from "../../client";
|
||||
import { oidcClient } from "./client";
|
||||
import { genericOAuthClient } from "../generic-oauth/client";
|
||||
import { listen, type Listener } from "listhen";
|
||||
import { toNodeHandler } from "../../integrations/node";
|
||||
import { jwt } from "../jwt";
|
||||
|
||||
describe("oidc", async () => {
|
||||
const {
|
||||
auth: authorizationServer,
|
||||
signInWithTestUser,
|
||||
customFetchImpl,
|
||||
testUser,
|
||||
} = await getTestInstance({
|
||||
baseURL: "http://localhost:3000",
|
||||
plugins: [
|
||||
oidcProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/oauth2/authorize",
|
||||
requirePKCE: true,
|
||||
}),
|
||||
jwt(),
|
||||
],
|
||||
});
|
||||
const { headers } = await signInWithTestUser();
|
||||
const serverClient = createAuthClient({
|
||||
plugins: [oidcClient()],
|
||||
baseURL: "http://localhost:3000",
|
||||
fetchOptions: {
|
||||
customFetchImpl,
|
||||
headers,
|
||||
},
|
||||
});
|
||||
|
||||
let server: Listener;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await listen(toNodeHandler(authorizationServer.handler), {
|
||||
port: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
let application: Client = {
|
||||
clientId: "test-client-id",
|
||||
clientSecret: "test-client-secret-oidc",
|
||||
redirectURLs: ["http://localhost:3000/api/auth/oauth2/callback/test"],
|
||||
metadata: {},
|
||||
icon: "",
|
||||
type: "web",
|
||||
disabled: false,
|
||||
name: "test",
|
||||
};
|
||||
|
||||
it("should create oidc client", async ({ expect }) => {
|
||||
const createdClient = await serverClient.oauth2.register({
|
||||
name: application.name,
|
||||
redirectURLs: application.redirectURLs,
|
||||
icon: application.icon,
|
||||
metadata: {
|
||||
custom: "data",
|
||||
},
|
||||
});
|
||||
expect(createdClient.data).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name: "test",
|
||||
icon: "",
|
||||
metadata: {
|
||||
custom: "data",
|
||||
},
|
||||
clientId: expect.any(String),
|
||||
clientSecret: expect.any(String),
|
||||
redirectURLs: ["http://localhost:3000/api/auth/oauth2/callback/test"],
|
||||
type: "web",
|
||||
disabled: false,
|
||||
});
|
||||
if (createdClient.data) {
|
||||
application = createdClient.data;
|
||||
}
|
||||
});
|
||||
|
||||
it("should sign in the user with the provider", async ({ expect }) => {
|
||||
// The RP (Relying Party) - the client application
|
||||
const { customFetchImpl: customFetchImplRP } = await getTestInstance({
|
||||
account: {
|
||||
accountLinking: {
|
||||
trustedProviders: ["test"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "test",
|
||||
clientId: application.clientId,
|
||||
clientSecret: application.clientSecret,
|
||||
authorizationUrl:
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
tokenUrl: "http://localhost:3000/api/auth/oauth2/token",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
baseURL: "http://localhost:5000",
|
||||
fetchOptions: {
|
||||
customFetchImpl: customFetchImplRP,
|
||||
},
|
||||
});
|
||||
const data = await client.signIn.oauth2(
|
||||
{
|
||||
providerId: "test",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
},
|
||||
);
|
||||
expect(data.url).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
);
|
||||
expect(data.url).toContain(`client_id=${application.clientId}`);
|
||||
|
||||
let redirectURI = "";
|
||||
await serverClient.$fetch(data.url, {
|
||||
method: "GET",
|
||||
onError(context) {
|
||||
redirectURI = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
expect(redirectURI).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/callback/test?code=",
|
||||
);
|
||||
|
||||
let callbackURL = "";
|
||||
await client.$fetch(redirectURI, {
|
||||
onError(context) {
|
||||
callbackURL = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
expect(callbackURL).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("should sign in after a consent flow", async ({ expect }) => {
|
||||
// The RP (Relying Party) - the client application
|
||||
const { customFetchImpl: customFetchImplRP, cookieSetter } =
|
||||
await getTestInstance({
|
||||
account: {
|
||||
accountLinking: {
|
||||
trustedProviders: ["test"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "test",
|
||||
clientId: application.clientId,
|
||||
clientSecret: application.clientSecret,
|
||||
authorizationUrl:
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
tokenUrl: "http://localhost:3000/api/auth/oauth2/token",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
prompt: "consent",
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
baseURL: "http://localhost:5000",
|
||||
fetchOptions: {
|
||||
customFetchImpl: customFetchImplRP,
|
||||
},
|
||||
});
|
||||
const data = await client.signIn.oauth2(
|
||||
{
|
||||
providerId: "test",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
},
|
||||
);
|
||||
expect(data.url).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
);
|
||||
expect(data.url).toContain(`client_id=${application.clientId}`);
|
||||
|
||||
let redirectURI = "";
|
||||
const newHeaders = new Headers();
|
||||
await serverClient.$fetch(data.url, {
|
||||
method: "GET",
|
||||
onError(context) {
|
||||
redirectURI = context.response.headers.get("Location") || "";
|
||||
cookieSetter(newHeaders)(context);
|
||||
newHeaders.append("Cookie", headers.get("Cookie") || "");
|
||||
},
|
||||
});
|
||||
expect(redirectURI).toContain("/oauth2/authorize?client_id=");
|
||||
const res = await serverClient.oauth2.consent(
|
||||
{
|
||||
accept: true,
|
||||
},
|
||||
{
|
||||
headers: newHeaders,
|
||||
throw: true,
|
||||
},
|
||||
);
|
||||
expect(res.redirectURI).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/callback/test?code=",
|
||||
);
|
||||
|
||||
let callbackURL = "";
|
||||
await client.$fetch(res.redirectURI, {
|
||||
onError(context) {
|
||||
callbackURL = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
expect(callbackURL).toContain("/dashboard");
|
||||
});
|
||||
|
||||
it("should sign in after a login flow", async ({ expect }) => {
|
||||
// The RP (Relying Party) - the client application
|
||||
const { customFetchImpl: customFetchImplRP, cookieSetter } =
|
||||
await getTestInstance({
|
||||
account: {
|
||||
accountLinking: {
|
||||
trustedProviders: ["test"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "test",
|
||||
clientId: application.clientId,
|
||||
clientSecret: application.clientSecret,
|
||||
authorizationUrl:
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
tokenUrl: "http://localhost:3000/api/auth/oauth2/token",
|
||||
scopes: ["openid", "profile", "email"],
|
||||
prompt: "login",
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const client = createAuthClient({
|
||||
plugins: [genericOAuthClient()],
|
||||
baseURL: "http://localhost:5000",
|
||||
fetchOptions: {
|
||||
customFetchImpl: customFetchImplRP,
|
||||
},
|
||||
});
|
||||
const data = await client.signIn.oauth2(
|
||||
{
|
||||
providerId: "test",
|
||||
callbackURL: "/dashboard",
|
||||
},
|
||||
{
|
||||
throw: true,
|
||||
},
|
||||
);
|
||||
expect(data.url).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/authorize",
|
||||
);
|
||||
expect(data.url).toContain(`client_id=${application.clientId}`);
|
||||
|
||||
let redirectURI = "";
|
||||
const newHeaders = new Headers();
|
||||
await serverClient.$fetch(data.url, {
|
||||
method: "GET",
|
||||
onError(context) {
|
||||
redirectURI = context.response.headers.get("Location") || "";
|
||||
cookieSetter(newHeaders)(context);
|
||||
},
|
||||
headers: newHeaders,
|
||||
});
|
||||
expect(redirectURI).toContain("/login");
|
||||
|
||||
await serverClient.signIn.email(
|
||||
{
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
},
|
||||
{
|
||||
headers: newHeaders,
|
||||
onError(context) {
|
||||
redirectURI = context.response.headers.get("Location") || "";
|
||||
cookieSetter(newHeaders)(context);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(redirectURI).toContain(
|
||||
"http://localhost:3000/api/auth/oauth2/callback/test?code=",
|
||||
);
|
||||
let callbackURL = "";
|
||||
await client.$fetch(redirectURI, {
|
||||
onError(context) {
|
||||
callbackURL = context.response.headers.get("Location") || "";
|
||||
},
|
||||
});
|
||||
expect(callbackURL).toContain("/dashboard");
|
||||
});
|
||||
});
|
||||
106
packages/better-auth/src/plugins/oidc-provider/schema.ts
Normal file
106
packages/better-auth/src/plugins/oidc-provider/schema.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { PluginSchema } from "../../types";
|
||||
|
||||
export const schema = {
|
||||
oauthApplication: {
|
||||
modelName: "oauthApplication",
|
||||
fields: {
|
||||
name: {
|
||||
type: "string",
|
||||
},
|
||||
icon: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
metadata: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
clientId: {
|
||||
type: "string",
|
||||
unique: true,
|
||||
},
|
||||
clientSecret: {
|
||||
type: "string",
|
||||
},
|
||||
redirectURLs: {
|
||||
type: "string",
|
||||
},
|
||||
type: {
|
||||
type: "string",
|
||||
},
|
||||
disabled: {
|
||||
type: "boolean",
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
},
|
||||
updatedAt: {
|
||||
type: "date",
|
||||
},
|
||||
},
|
||||
},
|
||||
oauthAccessToken: {
|
||||
modelName: "oauthAccessToken",
|
||||
fields: {
|
||||
accessToken: {
|
||||
type: "string",
|
||||
unique: true,
|
||||
},
|
||||
refreshToken: {
|
||||
type: "string",
|
||||
unique: true,
|
||||
},
|
||||
accessTokenExpiresAt: {
|
||||
type: "date",
|
||||
},
|
||||
refreshTokenExpiresAt: {
|
||||
type: "date",
|
||||
},
|
||||
clientId: {
|
||||
type: "string",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
required: false,
|
||||
},
|
||||
scopes: {
|
||||
type: "string",
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
},
|
||||
updatedAt: {
|
||||
type: "date",
|
||||
},
|
||||
},
|
||||
},
|
||||
oauthConsent: {
|
||||
modelName: "oauthConsent",
|
||||
fields: {
|
||||
clientId: {
|
||||
type: "string",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
},
|
||||
scopes: {
|
||||
type: "string",
|
||||
},
|
||||
createdAt: {
|
||||
type: "date",
|
||||
},
|
||||
updatedAt: {
|
||||
type: "date",
|
||||
},
|
||||
consentGiven: {
|
||||
type: "boolean",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies PluginSchema;
|
||||
488
packages/better-auth/src/plugins/oidc-provider/types.ts
Normal file
488
packages/better-auth/src/plugins/oidc-provider/types.ts
Normal file
@@ -0,0 +1,488 @@
|
||||
export interface OIDCOptions {
|
||||
/**
|
||||
* The amount of time in seconds that the access token is valid for.
|
||||
*
|
||||
* @default 3600 (1 hour) - Recommended by the OIDC spec
|
||||
*/
|
||||
accessTokenExpiresIn?: number;
|
||||
/**
|
||||
* Allow dynamic client registration.
|
||||
*/
|
||||
allowDynamicClientRegistration?: boolean;
|
||||
/**
|
||||
* The metadata for the OpenID Connect provider.
|
||||
*/
|
||||
metadata?: Partial<OIDCMetadata>;
|
||||
/**
|
||||
* The amount of time in seconds that the refresh token is valid for.
|
||||
*
|
||||
* @default 604800 (7 days) - Recommended by the OIDC spec
|
||||
*/
|
||||
refreshTokenExpiresIn?: number;
|
||||
/**
|
||||
* The amount of time in seconds that the authorization code is valid for.
|
||||
*
|
||||
* @default 600 (10 minutes) - Recommended by the OIDC spec
|
||||
*/
|
||||
codeExpiresIn?: number;
|
||||
/**
|
||||
* The scopes that the client is allowed to request.
|
||||
*
|
||||
* @see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
|
||||
* @default
|
||||
* ```ts
|
||||
* ["openid", "profile", "email", "offline_access"]
|
||||
* ```
|
||||
*/
|
||||
scopes?: string[];
|
||||
/**
|
||||
* The default scope to use if the client does not provide one.
|
||||
*
|
||||
* @default "openid"
|
||||
*/
|
||||
defaultScope?: string;
|
||||
/**
|
||||
* A URL to the consent page where the user will be redirected if the client
|
||||
* requests consent.
|
||||
*
|
||||
* After the user consents, they should be redirected by the client to the
|
||||
* `redirect_uri` with the authorization code.
|
||||
*
|
||||
* When the server redirects the user to the consent page, it will include the
|
||||
* following query parameters:
|
||||
* authorization code.
|
||||
* - `client_id` - The ID of the client.
|
||||
* - `scope` - The requested scopes.
|
||||
* - `code` - The authorization code.
|
||||
*
|
||||
* once the user consents, you need to call the `/oauth2/consent` endpoint
|
||||
* with the code and `accept: true` to complete the authorization. Which will
|
||||
* then return the client to the `redirect_uri` with the authorization code.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* consentPage: "/oauth/authorize"
|
||||
* ```
|
||||
*/
|
||||
consentPage?: string;
|
||||
/**
|
||||
* The HTML for the consent page. This is used if `consentPage` is not
|
||||
* provided. This should be a function that returns an HTML string.
|
||||
* The function will be called with the following props:
|
||||
*/
|
||||
getConsentHTML?: (props: {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
clientIcon?: string;
|
||||
clientMetadata: Record<string, any> | null;
|
||||
code: string;
|
||||
scopes: string[];
|
||||
}) => string;
|
||||
/**
|
||||
* The URL to the login page. This is used if the client requests the `login`
|
||||
* prompt.
|
||||
*/
|
||||
loginPage: string;
|
||||
/**
|
||||
* Weather to require PKCE (proof key code exchange) or not
|
||||
*
|
||||
* According to OAuth2.1 spec this should be required. But in any
|
||||
* case if you want to disable this you can use this options.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
requirePKCE?: boolean;
|
||||
/**
|
||||
* Allow plain to be used as a code challenge method.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
allowPlainCodeChallengeMethod?: boolean;
|
||||
/**
|
||||
* Custom function to generate a client ID.
|
||||
*/
|
||||
generateClientId?: () => string;
|
||||
/**
|
||||
* Custom function to generate a client secret.
|
||||
*/
|
||||
generateClientSecret?: () => string;
|
||||
}
|
||||
|
||||
export interface AuthorizationQuery {
|
||||
/**
|
||||
* The response type. Must be 'code' or 'token'. Code is for authorization code flow, token is
|
||||
* for implicit flow.
|
||||
*/
|
||||
response_type: "code" | "token";
|
||||
/**
|
||||
* The redirect URI for the client. Must be one of the registered redirect URLs for the client.
|
||||
*/
|
||||
redirect_uri?: string;
|
||||
/**
|
||||
* The scope of the request. Must be a space-separated list of case sensitive strings.
|
||||
*
|
||||
* - "openid" is required for all requests
|
||||
* - "profile" is required for requests that require user profile information.
|
||||
* - "email" is required for requests that require user email information.
|
||||
* - "offline_access" is required for requests that require a refresh token.
|
||||
*/
|
||||
scope?: string;
|
||||
/**
|
||||
* Opaque value used to maintain state between the request and the callback. Typically,
|
||||
* Cross-Site Request Forgery (CSRF, XSRF) mitigation is done by cryptographically binding the
|
||||
* value of this parameter with a browser cookie.
|
||||
*
|
||||
* Note: Better Auth stores the state in a database instead of a cookie. - This is to minimize
|
||||
* the complication with native apps and other clients that may not have access to cookies.
|
||||
*/
|
||||
state: string;
|
||||
/**
|
||||
* The client ID. Must be the ID of a registered client.
|
||||
*/
|
||||
client_id: string;
|
||||
/**
|
||||
* The prompt parameter is used to specify the type of user interaction that is required.
|
||||
*/
|
||||
prompt?: "none" | "consent" | "login" | "select_account";
|
||||
/**
|
||||
* The display parameter is used to specify how the authorization server displays the
|
||||
* authentication and consent user interface pages to the end user.
|
||||
*/
|
||||
display?: "page" | "popup" | "touch" | "wap";
|
||||
/**
|
||||
* End-User's preferred languages and scripts for the user interface, represented as a
|
||||
* space-separated list of BCP47 [RFC5646] language tag values, ordered by preference. For
|
||||
* instance, the value "fr-CA fr en" represents a preference for French as spoken in Canada,
|
||||
* then French (without a region designation), followed by English (without a region
|
||||
* designation).
|
||||
*
|
||||
* Better Auth does not support this parameter yet. It'll not throw an error if it's provided,
|
||||
*
|
||||
* 🏗️ currently not implemented
|
||||
*/
|
||||
ui_locales?: string;
|
||||
/**
|
||||
* The maximum authentication age.
|
||||
*
|
||||
* Specifies the allowable elapsed time in seconds since the last time the End-User was
|
||||
* actively authenticated by the provider. If the elapsed time is greater than this value, the
|
||||
* provider MUST attempt to actively re-authenticate the End-User.
|
||||
*
|
||||
* Note that max_age=0 is equivalent to prompt=login.
|
||||
*/
|
||||
max_age?: number;
|
||||
/**
|
||||
* Requested Authentication Context Class Reference values.
|
||||
*
|
||||
* Space-separated string that
|
||||
* specifies the acr values that the Authorization Server is being requested to use for
|
||||
* processing this Authentication Request, with the values appearing in order of preference.
|
||||
* The Authentication Context Class satisfied by the authentication performed is returned as
|
||||
* the acr Claim Value, as specified in Section 2. The acr Claim is requested as a Voluntary
|
||||
* Claim by this parameter.
|
||||
*/
|
||||
acr_values?: string;
|
||||
/**
|
||||
* Hint to the Authorization Server about the login identifier the End-User might use to log in
|
||||
* (if necessary). This hint can be used by an RP if it first asks the End-User for their
|
||||
* e-mail address (or other identifier) and then wants to pass that value as a hint to the
|
||||
* discovered authorization service. It is RECOMMENDED that the hint value match the value used
|
||||
* for discovery. This value MAY also be a phone number in the format specified for the
|
||||
* phone_number Claim. The use of this parameter is left to the OP's discretion.
|
||||
*/
|
||||
login_hint?: string;
|
||||
/**
|
||||
* ID Token previously issued by the Authorization Server being passed as a hint about the
|
||||
* End-User's current or past authenticated session with the Client.
|
||||
*
|
||||
* 🏗️ currently not implemented
|
||||
*/
|
||||
id_token_hint?: string;
|
||||
/**
|
||||
* Code challenge
|
||||
*/
|
||||
code_challenge?: string;
|
||||
/**
|
||||
* Code challenge method used
|
||||
*/
|
||||
code_challenge_method?: "plain" | "s256";
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
/**
|
||||
* Client ID
|
||||
*
|
||||
* size 32
|
||||
*
|
||||
* as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.2
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* Client Secret
|
||||
*
|
||||
* A secret for the client, if required by the authorization server.
|
||||
*
|
||||
* size 32
|
||||
*/
|
||||
clientSecret: string;
|
||||
/**
|
||||
* The client type
|
||||
*
|
||||
* as described on https://www.rfc-editor.org/rfc/rfc6749.html#section-2.1
|
||||
*
|
||||
* - web - A web application
|
||||
* - native - A mobile application
|
||||
* - user-agent-based - A user-agent-based application
|
||||
*/
|
||||
type: "web" | "native" | "user-agent-based";
|
||||
/**
|
||||
* List of registered redirect URLs. Must include the whole URL, including the protocol, port,
|
||||
* and path.
|
||||
*
|
||||
* For example, `https://example.com/auth/callback`
|
||||
*/
|
||||
redirectURLs: string[];
|
||||
/**
|
||||
* The name of the client.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The icon of the client.
|
||||
*/
|
||||
icon?: string;
|
||||
/**
|
||||
* Additional metadata about the client.
|
||||
*/
|
||||
metadata: {
|
||||
[key: string]: any;
|
||||
} | null;
|
||||
/**
|
||||
* Whether the client is disabled or not.
|
||||
*/
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface TokenBody {
|
||||
/**
|
||||
* The grant type. Must be 'authorization_code' or 'refresh_token'.
|
||||
*/
|
||||
grant_type: "authorization_code" | "refresh_token";
|
||||
/**
|
||||
* The authorization code received from the authorization server.
|
||||
*/
|
||||
code?: string;
|
||||
/**
|
||||
* The redirect URI of the client.
|
||||
*/
|
||||
redirect_uri?: string;
|
||||
/**
|
||||
* The client ID.
|
||||
*/
|
||||
client_id?: string;
|
||||
/**
|
||||
* The client secret.
|
||||
*/
|
||||
client_secret?: string;
|
||||
/**
|
||||
* The refresh token received from the authorization server.
|
||||
*/
|
||||
refresh_token?: string;
|
||||
}
|
||||
|
||||
export interface CodeVerificationValue {
|
||||
/**
|
||||
* The client ID
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* The redirect URI for the client
|
||||
*/
|
||||
redirectURI: string;
|
||||
/**
|
||||
* The scopes that the client requested
|
||||
*/
|
||||
scope: string[];
|
||||
/**
|
||||
* The user ID
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The time that the user authenticated
|
||||
*/
|
||||
authTime: number;
|
||||
/**
|
||||
* Whether the user needs to consent to the scopes
|
||||
* before the code can be exchanged for an access token.
|
||||
*
|
||||
* If this is true, then the code is treated as a consent
|
||||
* request. Once the user consents, the code will be updated
|
||||
* with the actual code.
|
||||
*/
|
||||
requireConsent: boolean;
|
||||
/**
|
||||
* The state parameter from the request
|
||||
*
|
||||
* If the prompt is set to `consent`, then the state
|
||||
* parameter is saved here. This is to prevent the client
|
||||
* from using the code before the user consents.
|
||||
*/
|
||||
state: string | null;
|
||||
/**
|
||||
* Code challenge
|
||||
*/
|
||||
codeChallenge?: string;
|
||||
/**
|
||||
* Code Challenge Method
|
||||
*/
|
||||
codeChallengeMethod?: "sha256" | "plain";
|
||||
}
|
||||
|
||||
export interface OAuthAccessToken {
|
||||
/**
|
||||
* The access token
|
||||
*/
|
||||
accessToken: string;
|
||||
/**
|
||||
* The refresh token
|
||||
*/
|
||||
refreshToken: string;
|
||||
/**
|
||||
* The time that the access token expires
|
||||
*/
|
||||
accessTokenExpiresAt: Date;
|
||||
/**
|
||||
* The time that the refresh token expires
|
||||
*/
|
||||
refreshTokenExpiresAt: Date;
|
||||
/**
|
||||
* The client ID
|
||||
*/
|
||||
clientId: string;
|
||||
/**
|
||||
* The user ID
|
||||
*/
|
||||
userId: string;
|
||||
/**
|
||||
* The scopes that the access token has access to
|
||||
*/
|
||||
scopes: string;
|
||||
}
|
||||
|
||||
export interface OIDCMetadata {
|
||||
/**
|
||||
* The issuer identifier, this is the URL of the provider and can be used to verify
|
||||
* the `iss` claim in the ID token.
|
||||
*
|
||||
* default: the base URL of the server (e.g. `https://example.com`)
|
||||
*/
|
||||
issuer: string;
|
||||
/**
|
||||
* The URL of the authorization endpoint.
|
||||
*
|
||||
* @default `/oauth2/authorize`
|
||||
*/
|
||||
authorization_endpoint: string;
|
||||
/**
|
||||
* The URL of the token endpoint.
|
||||
*
|
||||
* @default `/oauth2/token`
|
||||
*/
|
||||
token_endpoint: string;
|
||||
/**
|
||||
* The URL of the userinfo endpoint.
|
||||
*
|
||||
* @default `/oauth2/userinfo`
|
||||
*/
|
||||
userInfo_endpoint: string;
|
||||
/**
|
||||
* The URL of the jwks_uri endpoint.
|
||||
*
|
||||
* For JWKS to work, you must install the `jwt` plugin.
|
||||
*
|
||||
* This value is automatically set to `/jwks` if the `jwt` plugin is installed.
|
||||
*
|
||||
* @default `/jwks`
|
||||
*/
|
||||
jwks_uri: string;
|
||||
/**
|
||||
* The URL of the dynamic client registration endpoint.
|
||||
*
|
||||
* @default `/oauth2/register`
|
||||
*/
|
||||
registration_endpoint: string;
|
||||
/**
|
||||
* Supported scopes.
|
||||
*/
|
||||
scopes_supported: string[];
|
||||
/**
|
||||
* Supported response types.
|
||||
*
|
||||
* only `code` is supported.
|
||||
*/
|
||||
response_types_supported: ["code"];
|
||||
/**
|
||||
* Supported response modes.
|
||||
*
|
||||
* `query`: the authorization code is returned in the query string
|
||||
*
|
||||
* only `query` is supported.
|
||||
*/
|
||||
response_modes_supported: ["query"];
|
||||
/**
|
||||
* Supported grant types.
|
||||
*
|
||||
* only `authorization_code` is supported.
|
||||
*/
|
||||
grant_types_supported: ["authorization_code"];
|
||||
/**
|
||||
* acr_values supported.
|
||||
*
|
||||
* - `urn:mace:incommon:iap:silver`: Silver level of assurance
|
||||
* - `urn:mace:incommon:iap:bronze`: Bronze level of assurance
|
||||
*
|
||||
* only `urn:mace:incommon:iap:silver` and `urn:mace:incommon:iap:bronze` are supported.
|
||||
*
|
||||
*
|
||||
* @default
|
||||
* ["urn:mace:incommon:iap:silver", "urn:mace:incommon:iap:bronze"]
|
||||
* @see https://incommon.org/federation/attributes.html
|
||||
*/
|
||||
acr_values_supported: string[];
|
||||
/**
|
||||
* Supported subject types.
|
||||
*
|
||||
* pairwise: the subject identifier is unique to the client
|
||||
* public: the subject identifier is unique to the server
|
||||
*
|
||||
* only `public` is supported.
|
||||
*/
|
||||
subject_types_supported: ["public"];
|
||||
/**
|
||||
* Supported ID token signing algorithms.
|
||||
*
|
||||
* only `RS256` and `none` are supported.
|
||||
*
|
||||
* @default
|
||||
* ["RS256", "none"]
|
||||
*/
|
||||
id_token_signing_alg_values_supported: ("RS256" | "none")[];
|
||||
/**
|
||||
* Supported token endpoint authentication methods.
|
||||
*
|
||||
* only `client_secret_basic` and `client_secret_post` are supported.
|
||||
*
|
||||
* @default
|
||||
* ["client_secret_basic", "client_secret_post"]
|
||||
*/
|
||||
token_endpoint_auth_methods_supported: [
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
];
|
||||
/**
|
||||
* Supported claims.
|
||||
*
|
||||
* @default
|
||||
* ["sub", "iss", "aud", "exp", "nbf", "iat", "jti", "email", "email_verified", "name"]
|
||||
*/
|
||||
claims_supported: string[];
|
||||
}
|
||||
142
packages/better-auth/src/plugins/oidc-provider/ui.ts
Normal file
142
packages/better-auth/src/plugins/oidc-provider/ui.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export const authorizeHTML = ({
|
||||
scopes,
|
||||
clientIcon,
|
||||
clientName,
|
||||
redirectURI,
|
||||
cancelURI,
|
||||
}: {
|
||||
scopes: string[];
|
||||
clientIcon?: string;
|
||||
clientName: string;
|
||||
redirectURI: string;
|
||||
cancelURI: string;
|
||||
clientMetadata?: Record<string, any>;
|
||||
}) => `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta clientName="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Authorize Application</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #000000;
|
||||
--card-color: #1a1a1a;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #b0b0b0;
|
||||
--border-color: #333333;
|
||||
--button-color: #ffffff;
|
||||
--button-text: #000000;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', 'Helvetica', 'Arial', sans-serif;
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.authorize-container {
|
||||
background-color: var(--card-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 32px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
box-shadow: 0 8px 24px rgba(255,255,255,0.1);
|
||||
}
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.app-clientIcon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-right: 16px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.app-clientName {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.permissions-list {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.permissions-list h3 {
|
||||
margin-top: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.permissions-list ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.permissions-list li {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.permissions-list li::before {
|
||||
content: "•";
|
||||
color: var(--text-primary);
|
||||
font-size: 18px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
.button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.authorize {
|
||||
background-color: var(--button-color);
|
||||
color: var(--button-text);
|
||||
}
|
||||
.authorize:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.cancel {
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--text-secondary);
|
||||
}
|
||||
.cancel:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="authorize-container">
|
||||
<div class="app-info">
|
||||
<img src="${clientIcon || ""}" alt="${clientName} clientIcon" class="app-clientIcon">
|
||||
<span class="app-clientName">${clientName}</span>
|
||||
</div>
|
||||
<p>${clientName} would like permission to access your account</p>
|
||||
<div class="permissions-list">
|
||||
<h3>This will allow ${clientName} to:</h3>
|
||||
<ul>
|
||||
${scopes.map((scope) => `<li>${scope}</li>`).join("")}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<a href="${cancelURI}" class="button cancel">Cancel</a>
|
||||
<a href="${redirectURI}" class="button authorize">Authorize</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -67,7 +67,7 @@ export const microsoft = (options: MicrosoftOptions) => {
|
||||
if (!token.idToken) {
|
||||
return null;
|
||||
}
|
||||
const user = decodeJwt(token.idToken)?.payload as MicrosoftEntraIDProfile;
|
||||
const user = decodeJwt(token.idToken) as MicrosoftEntraIDProfile;
|
||||
const profilePhotoSize = options.profilePhotoSize || 48;
|
||||
await betterFetch<ArrayBuffer>(
|
||||
`https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const twitch = (options: TwitchOptions) => {
|
||||
logger.error("No idToken found in token");
|
||||
return null;
|
||||
}
|
||||
const profile = decodeJwt(idToken)?.payload as TwitchProfile;
|
||||
const profile = decodeJwt(idToken) as TwitchProfile;
|
||||
const userMap = await options.mapProfileToUser?.(profile);
|
||||
return {
|
||||
user: {
|
||||
|
||||
@@ -133,6 +133,9 @@ export type BetterAuthPlugin = {
|
||||
* The options of the plugin
|
||||
*/
|
||||
options?: Record<string, any>;
|
||||
/**
|
||||
* types to be inferred
|
||||
*/
|
||||
$Infer?: Record<string, any>;
|
||||
/**
|
||||
* The rate limit rules to apply to specific paths.
|
||||
|
||||
@@ -41,6 +41,7 @@ export default defineConfig((env) => {
|
||||
"plugins/one-tap": "./src/plugins/one-tap/index.ts",
|
||||
"plugins/open-api": "./src/plugins/open-api/index.ts",
|
||||
"plugins/organization": "./src/plugins/organization/index.ts",
|
||||
"plugins/oidc-provider": "./src/plugins/oidc-provider/index.ts",
|
||||
"plugins/passkey": "./src/plugins/passkey/index.ts",
|
||||
"plugins/phone-number": "./src/plugins/phone-number/index.ts",
|
||||
"plugins/sso": "./src/plugins/sso/index.ts",
|
||||
|
||||
174
pnpm-lock.yaml
generated
174
pnpm-lock.yaml
generated
@@ -3807,10 +3807,18 @@ packages:
|
||||
'@juggle/resize-observer@3.4.0':
|
||||
resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==}
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
resolution: {integrity: sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==}
|
||||
engines: {node: '>= 14.0.0'}
|
||||
|
||||
'@koa/router@12.0.2':
|
||||
resolution: {integrity: sha512-sYcHglGKTxGF+hQ6x67xDfkE9o+NhVlRHBqq6gLywaMc6CojK/5vFZByphdonKinYlMLkEkacm+HEse9HzwgTA==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
'@koa/router@13.1.0':
|
||||
resolution: {integrity: sha512-mNVu1nvkpSd8Q8gMebGbCkDWJ51ODetrFvLKYusej+V0ByD4btqHYnPIzTBLXnQMVUlm/oxVwqmWBY3zQfZilw==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
'@kobalte/core@0.12.6':
|
||||
resolution: {integrity: sha512-+Ta2o2wEqZ2fCqLMkvjT40VHNmcFKdGe8TNDVQbbMPk66qoU6g/DDRFR/Ib7eAjb+C95VoIyk6zaafos2VOo0w==}
|
||||
peerDependencies:
|
||||
@@ -6861,6 +6869,9 @@ packages:
|
||||
'@types/hast@3.0.4':
|
||||
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
|
||||
|
||||
'@types/http-cache-semantics@4.0.4':
|
||||
resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==}
|
||||
|
||||
'@types/http-proxy@1.17.15':
|
||||
resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==}
|
||||
|
||||
@@ -8280,6 +8291,14 @@ packages:
|
||||
resolution: {integrity: sha512-IKufZ1o4Ut42YUrZSo8+qnMTrFuKkvyoLXUywKz9GJ5BrhOFGhLdkx9sG4KAnVvbY6kEcSFjLQul+DVmBm2bgA==}
|
||||
engines: {node: '>= 6.0.0'}
|
||||
|
||||
cacheable-lookup@7.0.0:
|
||||
resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
cacheable-request@10.2.14:
|
||||
resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
call-bind@1.0.7:
|
||||
resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -9247,6 +9266,10 @@ packages:
|
||||
defaults@1.0.4:
|
||||
resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==}
|
||||
|
||||
defer-to-connect@2.0.1:
|
||||
resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -10029,6 +10052,10 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eta@3.5.0:
|
||||
resolution: {integrity: sha512-e3x3FBvGzeCIHhF+zhK8FZA2vC5uFn6b4HJjegUbIWrDb4mJ7JjTGMJY9VGIbRVpmSwHopNiaJibhjIr+HfLug==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
etag@1.8.1:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -10417,6 +10444,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data-encoder@2.1.4:
|
||||
resolution: {integrity: sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==}
|
||||
engines: {node: '>= 14.17'}
|
||||
|
||||
form-data@2.5.2:
|
||||
resolution: {integrity: sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==}
|
||||
engines: {node: '>= 0.12'}
|
||||
@@ -10810,6 +10841,10 @@ packages:
|
||||
gopd@1.0.1:
|
||||
resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==}
|
||||
|
||||
got@13.0.0:
|
||||
resolution: {integrity: sha512-XfBk1CxOOScDcMr9O1yKkNaQyy865NbYs+F7dr4H0LZMVgCj2Le59k6PqbNHoL5ToeaEQUYh6c6yMfVcc6SJxA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
got@6.7.1:
|
||||
resolution: {integrity: sha512-Y/K3EDuiQN9rTZhBvPRWMLXIKdeD1Rj0nzunfoi0Yyn5WBEbzxXKU9Ub2X41oZBagVWOBU3MuDonFMgPWQFnwg==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -11052,6 +11087,10 @@ packages:
|
||||
resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
|
||||
http2-wrapper@2.2.1:
|
||||
resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==}
|
||||
engines: {node: '>=10.19.0'}
|
||||
|
||||
https-proxy-agent@2.2.4:
|
||||
resolution: {integrity: sha512-OmvfoQ53WLjtA9HeYP9RNrWMJzzAz1JGaSFr1nijg0PVR1JaD/xbJq1mdEIIlxGpXp9eSe/O2LgU9DJmTPd0Eg==}
|
||||
engines: {node: '>= 4.5.0'}
|
||||
@@ -12192,6 +12231,10 @@ packages:
|
||||
resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
lowercase-keys@3.0.0:
|
||||
resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
lowlight@3.2.0:
|
||||
resolution: {integrity: sha512-8Me8xHTCBYEXwcJIPcurnXTeERl3plwb4207v6KPye48kX/oaYDiwXy+OCm3M/pyAPUrkMhalKsbYPm24f/UDg==}
|
||||
|
||||
@@ -12768,6 +12811,10 @@ packages:
|
||||
resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
mimic-response@4.0.0:
|
||||
resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
mini-svg-data-uri@1.4.4:
|
||||
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
|
||||
hasBin: true
|
||||
@@ -13345,6 +13392,13 @@ packages:
|
||||
ohash@1.1.4:
|
||||
resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==}
|
||||
|
||||
oidc-provider@8.6.0:
|
||||
resolution: {integrity: sha512-LTzQza+KA72fFWe/70ttjTpCPvwZRoaydPFY2izNfQjo6u33lFOzJeqA9Q0TblTShkaH56ChoE2KdMYIQlNHdw==}
|
||||
|
||||
oidc-token-hash@5.0.3:
|
||||
resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==}
|
||||
engines: {node: ^10.13.0 || >=12.0.0}
|
||||
|
||||
on-finished@2.3.0:
|
||||
resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -13451,6 +13505,10 @@ packages:
|
||||
outvariant@1.4.0:
|
||||
resolution: {integrity: sha512-AlWY719RF02ujitly7Kk/0QlV+pXGFDHrHf9O2OKqyqgBieaPOIeuSkL8sRK6j2WK+/ZAURq2kZsY0d8JapUiw==}
|
||||
|
||||
p-cancelable@3.0.0:
|
||||
resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
|
||||
engines: {node: '>=12.20'}
|
||||
|
||||
p-finally@1.0.0:
|
||||
resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -14313,6 +14371,14 @@ packages:
|
||||
queue@6.0.2:
|
||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||
|
||||
quick-lru@5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
quick-lru@7.0.0:
|
||||
resolution: {integrity: sha512-MX8gB7cVYTrYcFfAnfLlhRd0+Toyl8yX8uBx1MrX7K0jegiz9TumwOK27ldXrgDlHRdVi+MqU9Ssw6dr4BNreg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
quickselect@2.0.0:
|
||||
resolution: {integrity: sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==}
|
||||
|
||||
@@ -14335,6 +14401,10 @@ packages:
|
||||
resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
raw-body@3.0.0:
|
||||
resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
rc9@2.1.2:
|
||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||
|
||||
@@ -14826,6 +14896,9 @@ packages:
|
||||
resolution: {integrity: sha512-EkCRfzKw9JX7N75L+0BC8oXohDBLhlhl4w7AgrkEW2TAsOMBsVcbQHPe8cRWP6Ea7KDhD158TsNjbCBcohed5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
resolve-alpn@1.2.1:
|
||||
resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
|
||||
|
||||
resolve-dir@0.1.1:
|
||||
resolution: {integrity: sha512-QxMPqI6le2u0dCLyiGzgy92kjkkL6zO0XyvHzjdTNH3zM6e5Hz3BwG6+aEyNgiQ5Xz6PwTwgQEj3U50dByPKIA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -14867,6 +14940,10 @@ packages:
|
||||
resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==}
|
||||
hasBin: true
|
||||
|
||||
responselike@3.0.0:
|
||||
resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
restore-cursor@2.0.0:
|
||||
resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -19859,6 +19936,10 @@ snapshots:
|
||||
|
||||
'@juggle/resize-observer@3.4.0': {}
|
||||
|
||||
'@koa/cors@5.0.0':
|
||||
dependencies:
|
||||
vary: 1.1.2
|
||||
|
||||
'@koa/router@12.0.2':
|
||||
dependencies:
|
||||
debug: 4.3.7(supports-color@9.4.0)
|
||||
@@ -19869,6 +19950,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@koa/router@13.1.0':
|
||||
dependencies:
|
||||
http-errors: 2.0.0
|
||||
koa-compose: 4.1.0
|
||||
path-to-regexp: 6.3.0
|
||||
|
||||
'@kobalte/core@0.12.6(solid-js@1.9.3)':
|
||||
dependencies:
|
||||
'@floating-ui/dom': 1.6.12
|
||||
@@ -23086,6 +23173,8 @@ snapshots:
|
||||
'@sinclair/typebox@0.34.11':
|
||||
optional: true
|
||||
|
||||
'@sindresorhus/is@5.6.0': {}
|
||||
|
||||
'@sindresorhus/merge-streams@2.3.0': {}
|
||||
|
||||
'@sindresorhus/merge-streams@4.0.0': {}
|
||||
@@ -23804,6 +23893,8 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/http-cache-semantics@4.0.4': {}
|
||||
|
||||
'@types/http-proxy@1.17.15':
|
||||
dependencies:
|
||||
'@types/node': 20.17.9
|
||||
@@ -26201,6 +26292,18 @@ snapshots:
|
||||
mime-types: 2.1.35
|
||||
ylru: 1.4.0
|
||||
|
||||
cacheable-lookup@7.0.0: {}
|
||||
|
||||
cacheable-request@10.2.14:
|
||||
dependencies:
|
||||
'@types/http-cache-semantics': 4.0.4
|
||||
get-stream: 6.0.1
|
||||
http-cache-semantics: 4.1.1
|
||||
keyv: 4.5.4
|
||||
mimic-response: 4.0.0
|
||||
normalize-url: 8.0.1
|
||||
responselike: 3.0.0
|
||||
|
||||
call-bind@1.0.7:
|
||||
dependencies:
|
||||
es-define-property: 1.0.0
|
||||
@@ -27207,6 +27310,8 @@ snapshots:
|
||||
dependencies:
|
||||
clone: 1.0.4
|
||||
|
||||
defer-to-connect@2.0.1: {}
|
||||
|
||||
define-data-property@1.1.4:
|
||||
dependencies:
|
||||
es-define-property: 1.0.0
|
||||
@@ -28132,6 +28237,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eta@3.5.0: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
eval@0.1.8:
|
||||
@@ -28733,6 +28840,8 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data-encoder@2.1.4: {}
|
||||
|
||||
form-data@2.5.2:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
@@ -29312,6 +29421,20 @@ snapshots:
|
||||
dependencies:
|
||||
get-intrinsic: 1.2.4
|
||||
|
||||
got@13.0.0:
|
||||
dependencies:
|
||||
'@sindresorhus/is': 5.6.0
|
||||
'@szmarczak/http-timer': 5.0.1
|
||||
cacheable-lookup: 7.0.0
|
||||
cacheable-request: 10.2.14
|
||||
decompress-response: 6.0.0
|
||||
form-data-encoder: 2.1.4
|
||||
get-stream: 6.0.1
|
||||
http2-wrapper: 2.2.1
|
||||
lowercase-keys: 3.0.0
|
||||
p-cancelable: 3.0.0
|
||||
responselike: 3.0.0
|
||||
|
||||
got@6.7.1:
|
||||
dependencies:
|
||||
'@types/keyv': 3.1.4
|
||||
@@ -29706,6 +29829,11 @@ snapshots:
|
||||
|
||||
http-shutdown@1.2.2: {}
|
||||
|
||||
http2-wrapper@2.2.1:
|
||||
dependencies:
|
||||
quick-lru: 5.1.1
|
||||
resolve-alpn: 1.2.1
|
||||
|
||||
https-proxy-agent@2.2.4:
|
||||
dependencies:
|
||||
agent-base: 4.3.0
|
||||
@@ -30806,6 +30934,8 @@ snapshots:
|
||||
|
||||
lowercase-keys@1.0.1: {}
|
||||
|
||||
lowercase-keys@3.0.0: {}
|
||||
|
||||
lowlight@3.2.0:
|
||||
dependencies:
|
||||
'@types/hast': 3.0.4
|
||||
@@ -31937,6 +32067,8 @@ snapshots:
|
||||
|
||||
mimic-response@3.1.0: {}
|
||||
|
||||
mimic-response@4.0.0: {}
|
||||
|
||||
mini-svg-data-uri@1.4.4: {}
|
||||
|
||||
minimatch@3.0.8:
|
||||
@@ -32558,8 +32690,7 @@ snapshots:
|
||||
|
||||
normalize-range@0.1.2: {}
|
||||
|
||||
normalize-url@8.0.1:
|
||||
optional: true
|
||||
normalize-url@8.0.1: {}
|
||||
|
||||
npm-install-checks@6.3.0:
|
||||
dependencies:
|
||||
@@ -32821,6 +32952,26 @@ snapshots:
|
||||
|
||||
ohash@1.1.4: {}
|
||||
|
||||
oidc-provider@8.6.0:
|
||||
dependencies:
|
||||
'@koa/cors': 5.0.0
|
||||
'@koa/router': 13.1.0
|
||||
debug: 4.3.7(supports-color@9.4.0)
|
||||
eta: 3.5.0
|
||||
got: 13.0.0
|
||||
jose: 5.9.6
|
||||
jsesc: 3.0.2
|
||||
koa: 2.15.3
|
||||
nanoid: 5.0.8
|
||||
object-hash: 3.0.0
|
||||
oidc-token-hash: 5.0.3
|
||||
quick-lru: 7.0.0
|
||||
raw-body: 3.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
oidc-token-hash@5.0.3: {}
|
||||
|
||||
on-finished@2.3.0:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -32970,6 +33121,8 @@ snapshots:
|
||||
|
||||
outvariant@1.4.0: {}
|
||||
|
||||
p-cancelable@3.0.0: {}
|
||||
|
||||
p-finally@1.0.0: {}
|
||||
|
||||
p-limit@2.3.0:
|
||||
@@ -33812,6 +33965,10 @@ snapshots:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
quick-lru@7.0.0: {}
|
||||
|
||||
quickselect@2.0.0: {}
|
||||
|
||||
radix-vue@1.9.11(vue@3.5.13(typescript@5.7.2)):
|
||||
@@ -33846,6 +34003,13 @@ snapshots:
|
||||
iconv-lite: 0.4.24
|
||||
unpipe: 1.0.0
|
||||
|
||||
raw-body@3.0.0:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
http-errors: 2.0.0
|
||||
iconv-lite: 0.6.3
|
||||
unpipe: 1.0.0
|
||||
|
||||
rc9@2.1.2:
|
||||
dependencies:
|
||||
defu: 6.1.4
|
||||
@@ -34575,6 +34739,8 @@ snapshots:
|
||||
- react
|
||||
- react-dom
|
||||
|
||||
resolve-alpn@1.2.1: {}
|
||||
|
||||
resolve-dir@0.1.1:
|
||||
dependencies:
|
||||
expand-tilde: 1.2.2
|
||||
@@ -34615,6 +34781,10 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
responselike@3.0.0:
|
||||
dependencies:
|
||||
lowercase-keys: 3.0.0
|
||||
|
||||
restore-cursor@2.0.0:
|
||||
dependencies:
|
||||
onetime: 2.0.1
|
||||
|
||||
Reference in New Issue
Block a user