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

View File

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

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>
</Steps>

View File

@@ -56,6 +56,7 @@ exports[`init > should match config 1`] = `
"deleteSessions": [Function],
"deleteUser": [Function],
"deleteVerificationValue": [Function],
"findAccount": [Function],
"findAccounts": [Function],
"findSession": [Function],
"findSessions": [Function],

View File

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

View File

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

View File

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

View File

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

View File

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