mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-09 12:27:43 +00:00
fix: twitter provider (#462)
This commit is contained in:
@@ -14,7 +14,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { PasswordInput } from "@/components/ui/password-input";
|
import { PasswordInput } from "@/components/ui/password-input";
|
||||||
import { signIn } from "@/lib/auth-client";
|
import { signIn } from "@/lib/auth-client";
|
||||||
import { DiscordLogoIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
import {
|
||||||
|
DiscordLogoIcon,
|
||||||
|
GitHubLogoIcon,
|
||||||
|
TwitterLogoIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
import { Key, Loader2, TwitchIcon } from "lucide-react";
|
import { Key, Loader2, TwitchIcon } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -151,6 +155,37 @@ export default function SignIn() {
|
|||||||
></path>
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-2"
|
||||||
|
onClick={async () => {
|
||||||
|
await signIn.social({
|
||||||
|
provider: "twitter",
|
||||||
|
callbackURL: "/dashboard",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="1em"
|
||||||
|
height="1em"
|
||||||
|
viewBox="0 0 14 14"
|
||||||
|
>
|
||||||
|
<g fill="none">
|
||||||
|
<g clipPath="url(#primeTwitter0)">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M11.025.656h2.147L8.482 6.03L14 13.344H9.68L6.294 8.909l-3.87 4.435H.275l5.016-5.75L0 .657h4.43L7.486 4.71zm-.755 11.4h1.19L3.78 1.877H2.504z"
|
||||||
|
></path>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="primeTwitter0">
|
||||||
|
<path fill="#fff" d="M0 0h14v14H0z"></path>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full gap-2"
|
className="w-full gap-2"
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export const auth = betterAuth({
|
|||||||
clientId: process.env.TWITCH_CLIENT_ID || "",
|
clientId: process.env.TWITCH_CLIENT_ID || "",
|
||||||
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
|
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
|
||||||
},
|
},
|
||||||
|
twitter: {
|
||||||
|
clientId: process.env.TWITTER_CLIENT_ID || "",
|
||||||
|
clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
organization({
|
organization({
|
||||||
|
|||||||
@@ -43,5 +43,8 @@ description: Twitter Provider
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
<Callout type="warn">
|
||||||
|
If twitter doesn't return the email address, the authencatino wouldn't be successful. Make sure to ask for the email address when you create the Twitter app.
|
||||||
|
</Callout>
|
||||||
</Step>
|
</Step>
|
||||||
</Steps>
|
</Steps>
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ exports[`init > should match config 1`] = `
|
|||||||
"deleteSessions": [Function],
|
"deleteSessions": [Function],
|
||||||
"deleteUser": [Function],
|
"deleteUser": [Function],
|
||||||
"deleteVerificationValue": [Function],
|
"deleteVerificationValue": [Function],
|
||||||
|
"findAccount": [Function],
|
||||||
"findAccounts": [Function],
|
"findAccounts": [Function],
|
||||||
"findSession": [Function],
|
"findSession": [Function],
|
||||||
"findSessions": [Function],
|
"findSessions": [Function],
|
||||||
|
|||||||
@@ -58,16 +58,30 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
.getUserInfo(tokens)
|
.getUserInfo(tokens)
|
||||||
.then((res) => res?.user);
|
.then((res) => res?.user);
|
||||||
const id = generateId();
|
const id = generateId();
|
||||||
const data = userSchema.safeParse({
|
const data = {
|
||||||
...userInfo,
|
|
||||||
id,
|
id,
|
||||||
});
|
...userInfo,
|
||||||
|
};
|
||||||
|
|
||||||
if (!userInfo || data.success === false) {
|
function redirectOnError(error: string) {
|
||||||
logger.error("Unable to get user info", data.error);
|
let url = errorURL || callbackURL || `${c.context.baseURL}/error`;
|
||||||
throw c.redirect(
|
if (url.includes("?")) {
|
||||||
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
url = `${url}&error=${error}`;
|
||||||
|
} else {
|
||||||
|
url = `${url}?error=${error}`;
|
||||||
|
}
|
||||||
|
throw c.redirect(url);
|
||||||
|
}
|
||||||
|
if (!userInfo) {
|
||||||
|
logger.error("Unable to get user info");
|
||||||
|
return redirectOnError("unable_to_get_user_info");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.email) {
|
||||||
|
c.context.logger.error(
|
||||||
|
"Provider did not return email. This could be due to misconfiguration in the provider settings.",
|
||||||
);
|
);
|
||||||
|
return redirectOnError("email_not_found");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!callbackURL) {
|
if (!callbackURL) {
|
||||||
@@ -77,7 +91,7 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (link) {
|
if (link) {
|
||||||
if (link.email !== userInfo.email.toLowerCase()) {
|
if (link.email !== data.email.toLowerCase()) {
|
||||||
return redirectOnError("email_doesn't_match");
|
return redirectOnError("email_doesn't_match");
|
||||||
}
|
}
|
||||||
const newAccount = await c.context.internalAdapter.createAccount({
|
const newAccount = await c.context.internalAdapter.createAccount({
|
||||||
@@ -98,16 +112,8 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
throw c.redirect(toRedirectTo);
|
throw c.redirect(toRedirectTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
function redirectOnError(error: string) {
|
|
||||||
throw c.redirect(
|
|
||||||
`${
|
|
||||||
errorURL || callbackURL || `${c.context.baseURL}/error`
|
|
||||||
}?error=${error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbUser = await c.context.internalAdapter
|
const dbUser = await c.context.internalAdapter
|
||||||
.findUserByEmail(userInfo.email, {
|
.findUserByEmail(data.email, {
|
||||||
includeAccounts: true,
|
includeAccounts: true,
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
@@ -166,13 +172,28 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
expiresAt: tokens.accessTokenExpiresAt,
|
expiresAt: tokens.accessTokenExpiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const findAccount = await c.context.internalAdapter.findAccount(data.id);
|
||||||
|
if (findAccount) {
|
||||||
|
const accountUser = await c.context.internalAdapter.findUserById(
|
||||||
|
findAccount.userId,
|
||||||
|
);
|
||||||
|
if (!accountUser) {
|
||||||
|
return redirectOnError("account_linked_to_unknown_user");
|
||||||
|
}
|
||||||
|
if (accountUser.email && accountUser.email !== data.email) {
|
||||||
|
return redirectOnError("account_linked_to_different_email");
|
||||||
|
}
|
||||||
|
user = accountUser;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const emailVerified = userInfo.emailVerified || false;
|
const emailVerified = userInfo.emailVerified || false;
|
||||||
user = await c.context.internalAdapter
|
user = await c.context.internalAdapter
|
||||||
.createOAuthUser(
|
.createOAuthUser(
|
||||||
{
|
{
|
||||||
...data.data,
|
...data,
|
||||||
|
email: data.email as string,
|
||||||
|
name: data.name || "",
|
||||||
emailVerified,
|
emailVerified,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -206,6 +227,7 @@ export const callbackOAuth = createAuthEndpoint(
|
|||||||
redirectOnError("unable_to_create_user");
|
redirectOnError("unable_to_create_user");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return redirectOnError("unable_to_create_user");
|
return redirectOnError("unable_to_create_user");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -556,6 +556,18 @@ export const createInternalAdapter = (
|
|||||||
(account) => convertFromDB(tables.account.fields, account) as Account,
|
(account) => convertFromDB(tables.account.fields, account) as Account,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
findAccount: async (accountId: string) => {
|
||||||
|
const account = await adapter.findOne<Account>({
|
||||||
|
model: tables.account.tableName,
|
||||||
|
where: [
|
||||||
|
{
|
||||||
|
field: tables.account.fields.accountId.fieldName || "accountId",
|
||||||
|
value: accountId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return account;
|
||||||
|
},
|
||||||
updateAccount: async (accountId: string, data: Partial<Account>) => {
|
updateAccount: async (accountId: string, data: Partial<Account>) => {
|
||||||
const account = await updateWithHooks<Account>(
|
const account = await updateWithHooks<Account>(
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export interface OAuthProvider<
|
|||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
}) => Promise<OAuth2Tokens>;
|
}) => Promise<OAuth2Tokens>;
|
||||||
getUserInfo: (token: OAuth2Tokens) => Promise<{
|
getUserInfo: (token: OAuth2Tokens) => Promise<{
|
||||||
user: Omit<User, "createdAt" | "updatedAt">;
|
user: {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string | null;
|
||||||
|
image?: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
};
|
||||||
data: T;
|
data: T;
|
||||||
} | null>;
|
} | null>;
|
||||||
refreshAccessToken?: (refreshToken: string) => Promise<OAuth2Tokens>;
|
refreshAccessToken?: (refreshToken: string) => Promise<OAuth2Tokens>;
|
||||||
|
|||||||
@@ -8,28 +8,38 @@ export async function validateAuthorizationCode({
|
|||||||
redirectURI,
|
redirectURI,
|
||||||
options,
|
options,
|
||||||
tokenEndpoint,
|
tokenEndpoint,
|
||||||
|
authentication,
|
||||||
}: {
|
}: {
|
||||||
code: string;
|
code: string;
|
||||||
redirectURI: string;
|
redirectURI: string;
|
||||||
options: ProviderOptions;
|
options: ProviderOptions;
|
||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
tokenEndpoint: string;
|
tokenEndpoint: string;
|
||||||
|
authentication?: "basic" | "none";
|
||||||
}) {
|
}) {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
|
const headers: Record<string, any> = {
|
||||||
|
"content-type": "application/x-www-form-urlencoded",
|
||||||
|
accept: "application/json",
|
||||||
|
"user-agent": "better-auth",
|
||||||
|
};
|
||||||
body.set("grant_type", "authorization_code");
|
body.set("grant_type", "authorization_code");
|
||||||
body.set("code", code);
|
body.set("code", code);
|
||||||
codeVerifier && body.set("code_verifier", codeVerifier);
|
codeVerifier && body.set("code_verifier", codeVerifier);
|
||||||
body.set("redirect_uri", redirectURI);
|
body.set("redirect_uri", redirectURI);
|
||||||
|
if (authentication === "basic") {
|
||||||
|
const encodedCredentials = btoa(
|
||||||
|
`${options.clientId}:${options.clientSecret}`,
|
||||||
|
);
|
||||||
|
headers["authorization"] = `Basic ${encodedCredentials}`;
|
||||||
|
} else {
|
||||||
body.set("client_id", options.clientId);
|
body.set("client_id", options.clientId);
|
||||||
body.set("client_secret", options.clientSecret);
|
body.set("client_secret", options.clientSecret);
|
||||||
|
}
|
||||||
const { data, error } = await betterFetch<object>(tokenEndpoint, {
|
const { data, error } = await betterFetch<object>(tokenEndpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: body,
|
body: body,
|
||||||
headers: {
|
headers,
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
|
||||||
accept: "application/json",
|
|
||||||
"user-agent": "better-auth",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -98,12 +98,16 @@ export const twitter = (options: TwitterOption) => {
|
|||||||
id: "twitter",
|
id: "twitter",
|
||||||
name: "Twitter",
|
name: "Twitter",
|
||||||
createAuthorizationURL(data) {
|
createAuthorizationURL(data) {
|
||||||
const _scopes = data.scopes || ["account_info.read"];
|
const _scopes = data.scopes || [
|
||||||
|
"users.read",
|
||||||
|
"tweet.read",
|
||||||
|
"offline.access",
|
||||||
|
];
|
||||||
options.scope && _scopes.push(...options.scope);
|
options.scope && _scopes.push(...options.scope);
|
||||||
return createAuthorizationURL({
|
return createAuthorizationURL({
|
||||||
id: "twitter",
|
id: "twitter",
|
||||||
options,
|
options,
|
||||||
authorizationEndpoint: "https://twitter.com/i/oauth2/authorize",
|
authorizationEndpoint: "https://x.com/i/oauth2/authorize",
|
||||||
scopes: _scopes,
|
scopes: _scopes,
|
||||||
state: data.state,
|
state: data.state,
|
||||||
codeVerifier: data.codeVerifier,
|
codeVerifier: data.codeVerifier,
|
||||||
@@ -114,9 +118,10 @@ export const twitter = (options: TwitterOption) => {
|
|||||||
return validateAuthorizationCode({
|
return validateAuthorizationCode({
|
||||||
code,
|
code,
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
|
authentication: "basic",
|
||||||
redirectURI: options.redirectURI || redirectURI,
|
redirectURI: options.redirectURI || redirectURI,
|
||||||
options,
|
options,
|
||||||
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
|
tokenEndpoint: "https://api.x.com/2/oauth2/token",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
async getUserInfo(token) {
|
async getUserInfo(token) {
|
||||||
@@ -132,14 +137,11 @@ export const twitter = (options: TwitterOption) => {
|
|||||||
if (error) {
|
if (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!profile.data.email) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
user: {
|
user: {
|
||||||
id: profile.data.id,
|
id: profile.data.id,
|
||||||
name: profile.data.name,
|
name: profile.data.name,
|
||||||
email: profile.data.email,
|
email: profile.data.email || null,
|
||||||
image: profile.data.profile_image_url,
|
image: profile.data.profile_image_url,
|
||||||
emailVerified: profile.data.verified || false,
|
emailVerified: profile.data.verified || false,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user