fix: twitter provider (#462)

This commit is contained in:
Bereket Engida
2024-11-08 23:08:00 +03:00
committed by GitHub
parent 9b723b139f
commit 7c3fe83bf3
9 changed files with 164 additions and 69 deletions

View File

@@ -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"

View File

@@ -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({

View File

@@ -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>

View File

@@ -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],

View File

@@ -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");
} }

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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;

View File

@@ -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,
}, },