mirror of
https://github.com/LukeHagar/better-auth.git
synced 2025-12-07 20:37:44 +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 { PasswordInput } from "@/components/ui/password-input";
|
||||
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 Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -151,6 +155,37 @@ export default function SignIn() {
|
||||
></path>
|
||||
</svg>
|
||||
</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
|
||||
variant="outline"
|
||||
className="w-full gap-2"
|
||||
|
||||
@@ -81,6 +81,10 @@ export const auth = betterAuth({
|
||||
clientId: process.env.TWITCH_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
|
||||
},
|
||||
twitter: {
|
||||
clientId: process.env.TWITTER_CLIENT_ID || "",
|
||||
clientSecret: process.env.TWITTER_CLIENT_SECRET || "",
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
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>
|
||||
</Steps>
|
||||
|
||||
@@ -56,6 +56,7 @@ exports[`init > should match config 1`] = `
|
||||
"deleteSessions": [Function],
|
||||
"deleteUser": [Function],
|
||||
"deleteVerificationValue": [Function],
|
||||
"findAccount": [Function],
|
||||
"findAccounts": [Function],
|
||||
"findSession": [Function],
|
||||
"findSessions": [Function],
|
||||
|
||||
@@ -58,16 +58,30 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
.getUserInfo(tokens)
|
||||
.then((res) => res?.user);
|
||||
const id = generateId();
|
||||
const data = userSchema.safeParse({
|
||||
...userInfo,
|
||||
const data = {
|
||||
id,
|
||||
});
|
||||
...userInfo,
|
||||
};
|
||||
|
||||
if (!userInfo || data.success === false) {
|
||||
logger.error("Unable to get user info", data.error);
|
||||
throw c.redirect(
|
||||
`${c.context.baseURL}/error?error=please_restart_the_process`,
|
||||
function redirectOnError(error: string) {
|
||||
let url = errorURL || callbackURL || `${c.context.baseURL}/error`;
|
||||
if (url.includes("?")) {
|
||||
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) {
|
||||
@@ -77,7 +91,7 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
);
|
||||
}
|
||||
if (link) {
|
||||
if (link.email !== userInfo.email.toLowerCase()) {
|
||||
if (link.email !== data.email.toLowerCase()) {
|
||||
return redirectOnError("email_doesn't_match");
|
||||
}
|
||||
const newAccount = await c.context.internalAdapter.createAccount({
|
||||
@@ -98,16 +112,8 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
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
|
||||
.findUserByEmail(userInfo.email, {
|
||||
.findUserByEmail(data.email, {
|
||||
includeAccounts: true,
|
||||
})
|
||||
.catch((e) => {
|
||||
@@ -166,13 +172,28 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
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 {
|
||||
try {
|
||||
const emailVerified = userInfo.emailVerified || false;
|
||||
user = await c.context.internalAdapter
|
||||
.createOAuthUser(
|
||||
{
|
||||
...data.data,
|
||||
...data,
|
||||
email: data.email as string,
|
||||
name: data.name || "",
|
||||
emailVerified,
|
||||
},
|
||||
{
|
||||
@@ -206,6 +227,7 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
redirectOnError("unable_to_create_user");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
return redirectOnError("unable_to_create_user");
|
||||
}
|
||||
|
||||
@@ -556,6 +556,18 @@ export const createInternalAdapter = (
|
||||
(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>) => {
|
||||
const account = await updateWithHooks<Account>(
|
||||
data,
|
||||
|
||||
@@ -28,7 +28,13 @@ export interface OAuthProvider<
|
||||
codeVerifier?: string;
|
||||
}) => Promise<OAuth2Tokens>;
|
||||
getUserInfo: (token: OAuth2Tokens) => Promise<{
|
||||
user: Omit<User, "createdAt" | "updatedAt">;
|
||||
user: {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string | null;
|
||||
image?: string;
|
||||
emailVerified: boolean;
|
||||
};
|
||||
data: T;
|
||||
} | null>;
|
||||
refreshAccessToken?: (refreshToken: string) => Promise<OAuth2Tokens>;
|
||||
|
||||
@@ -8,28 +8,38 @@ export async function validateAuthorizationCode({
|
||||
redirectURI,
|
||||
options,
|
||||
tokenEndpoint,
|
||||
authentication,
|
||||
}: {
|
||||
code: string;
|
||||
redirectURI: string;
|
||||
options: ProviderOptions;
|
||||
codeVerifier?: string;
|
||||
tokenEndpoint: string;
|
||||
authentication?: "basic" | "none";
|
||||
}) {
|
||||
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("code", code);
|
||||
codeVerifier && body.set("code_verifier", codeVerifier);
|
||||
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_secret", options.clientSecret);
|
||||
}
|
||||
const { data, error } = await betterFetch<object>(tokenEndpoint, {
|
||||
method: "POST",
|
||||
body: body,
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
accept: "application/json",
|
||||
"user-agent": "better-auth",
|
||||
},
|
||||
headers,
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
|
||||
@@ -98,12 +98,16 @@ export const twitter = (options: TwitterOption) => {
|
||||
id: "twitter",
|
||||
name: "Twitter",
|
||||
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);
|
||||
return createAuthorizationURL({
|
||||
id: "twitter",
|
||||
options,
|
||||
authorizationEndpoint: "https://twitter.com/i/oauth2/authorize",
|
||||
authorizationEndpoint: "https://x.com/i/oauth2/authorize",
|
||||
scopes: _scopes,
|
||||
state: data.state,
|
||||
codeVerifier: data.codeVerifier,
|
||||
@@ -114,9 +118,10 @@ export const twitter = (options: TwitterOption) => {
|
||||
return validateAuthorizationCode({
|
||||
code,
|
||||
codeVerifier,
|
||||
authentication: "basic",
|
||||
redirectURI: options.redirectURI || redirectURI,
|
||||
options,
|
||||
tokenEndpoint: "https://id.twitch.tv/oauth2/token",
|
||||
tokenEndpoint: "https://api.x.com/2/oauth2/token",
|
||||
});
|
||||
},
|
||||
async getUserInfo(token) {
|
||||
@@ -132,14 +137,11 @@ export const twitter = (options: TwitterOption) => {
|
||||
if (error) {
|
||||
return null;
|
||||
}
|
||||
if (!profile.data.email) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
user: {
|
||||
id: profile.data.id,
|
||||
name: profile.data.name,
|
||||
email: profile.data.email,
|
||||
email: profile.data.email || null,
|
||||
image: profile.data.profile_image_url,
|
||||
emailVerified: profile.data.verified || false,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user