fix: OAuth flow errors (#189)

* fix: remove codeverifier

* chore: release v0.4.10-beta.6

* fix: add claims and proper scopes for twitch

* chore: release v0.4.10-beta.6

* chore: release v0.4.10-beta.7

* fix: generic oauth should use the code from qparam

* chore: release v0.4.10-beta.8

* fix: scope failing to merge

* chore: release v0.4.10-beta.9

* fix: twitch get profile

* chore: release v0.4.10-beta.10

* fix:lint
This commit is contained in:
Bereket Engida
2024-10-16 20:45:03 +03:00
committed by GitHub
parent ccf8f0f364
commit 1d77d0c338
9 changed files with 71 additions and 22 deletions

View File

@@ -15,7 +15,7 @@ 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 } from "@radix-ui/react-icons";
import { Key, Loader2 } 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";
import { useState } from "react"; import { useState } from "react";
@@ -129,6 +129,28 @@ export default function SignIn() {
> >
<DiscordLogoIcon /> <DiscordLogoIcon />
</Button> </Button>
<Button
variant="outline"
className="w-full gap-2"
onClick={async () => {
await signIn.social({
provider: "twitch",
callbackURL: "/dashboard",
});
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1.2em"
height="1.2em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M11.64 5.93h1.43v4.28h-1.43m3.93-4.28H17v4.28h-1.43M7 2L3.43 5.57v12.86h4.28V22l3.58-3.57h2.85L20.57 12V2m-1.43 9.29l-2.85 2.85h-2.86l-2.5 2.5v-2.5H7.71V3.43h11.43Z"
></path>
</svg>
</Button>
<Button <Button
variant="outline" variant="outline"
className="w-full gap-2" className="w-full gap-2"

View File

@@ -107,5 +107,9 @@ export const auth = betterAuth({
clientId: process.env.MICROSOFT_CLIENT_ID || "", clientId: process.env.MICROSOFT_CLIENT_ID || "",
clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "", clientSecret: process.env.MICROSOFT_CLIENT_SECRET || "",
}, },
twitch: {
clientId: process.env.TWITCH_CLIENT_ID || "",
clientSecret: process.env.TWITCH_CLIENT_SECRET || "",
},
}, },
}); });

View File

@@ -25,7 +25,7 @@ export const forgetPassword = createAuthEndpoint(
async (ctx) => { async (ctx) => {
if (!ctx.context.options.emailAndPassword?.sendResetPassword) { if (!ctx.context.options.emailAndPassword?.sendResetPassword) {
ctx.context.logger.error( ctx.context.logger.error(
"Reset password isn't enabled.Please pass an emailAndPassword.sendResetPasswordToken function to your auth config!", "Reset password isn't enabled.Please pass an emailAndPassword.sendResetPasswordToken function in your auth config!",
); );
throw new APIError("BAD_REQUEST", { throw new APIError("BAD_REQUEST", {
message: "Reset password isn't enabled", message: "Reset password isn't enabled",

View File

@@ -39,7 +39,7 @@ export const signInOAuth = createAuthEndpoint(
); );
if (!provider) { if (!provider) {
c.context.logger.error( c.context.logger.error(
"Provider not found. Make sure to add the provider to your auth config", "Provider not found. Make sure to add the provider in your auth config",
{ {
provider: c.body.provider, provider: c.body.provider,
}, },

View File

@@ -8,6 +8,7 @@ export async function createAuthorizationURL({
state, state,
codeVerifier, codeVerifier,
scopes, scopes,
claims,
disablePkce, disablePkce,
redirectURI, redirectURI,
}: { }: {
@@ -19,6 +20,7 @@ export async function createAuthorizationURL({
codeVerifier?: string; codeVerifier?: string;
scopes: string[]; scopes: string[];
disablePkce?: boolean; disablePkce?: boolean;
claims?: string[];
}) { }) {
const url = new URL(authorizationEndpoint); const url = new URL(authorizationEndpoint);
url.searchParams.set("response_type", "code"); url.searchParams.set("response_type", "code");
@@ -32,6 +34,20 @@ export async function createAuthorizationURL({
url.searchParams.set("code_challenge_method", "S256"); url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("code_challenge", codeChallenge); url.searchParams.set("code_challenge", codeChallenge);
} }
if (claims) {
const claimsObj = claims.reduce(
(acc, claim) => {
acc[claim] = null;
return acc;
},
{} as Record<string, null>,
);
url.searchParams.set(
"claims",
JSON.stringify({
id_token: { email: null, email_verified: null, ...claimsObj },
}),
);
}
return url; return url;
} }

View File

@@ -20,7 +20,11 @@ export function getOAuth2Tokens(data: Record<string, any>): OAuth2Tokens {
accessTokenExpiresAt: data.expires_at accessTokenExpiresAt: data.expires_at
? new Date((Date.now() + data.expires_in) * 1000) ? new Date((Date.now() + data.expires_in) * 1000)
: undefined, : undefined,
scopes: data.scope?.split(" ") || [], scopes: data?.scope
? typeof data.scope === "string"
? data.scope.split(" ")
: data.scope
: [],
idToken: data.id_token, idToken: data.id_token,
}; };
} }

View File

@@ -301,8 +301,9 @@ export const genericOAuth = (options: GenericOAuthOptions) => {
} }
const state = ctx.query.state; const state = ctx.query.state;
const { const {
data: { callbackURL, currentURL, code }, data: { callbackURL, currentURL },
} = parsedState; } = parsedState;
const code = ctx.query.code;
const errorURL = const errorURL =
parsedState.data?.currentURL || `${ctx.context.baseURL}/error`; parsedState.data?.currentURL || `${ctx.context.baseURL}/error`;
const storedState = await ctx.getSignedCookie( const storedState = await ctx.getSignedCookie(

View File

@@ -26,11 +26,10 @@ export const facebook = (options: FacebookOptions) => {
return await createAuthorizationURL({ return await createAuthorizationURL({
id: "facebook", id: "facebook",
options, options,
authorizationEndpoint: "https://www.facebook.com/v16.0/dialog/oauth", authorizationEndpoint: "https://www.facebook.com/v21.0/dialog/oauth",
scopes: _scopes, scopes: _scopes,
state, state,
redirectURI, redirectURI,
codeVerifier,
}); });
}, },
validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
@@ -39,12 +38,12 @@ export const facebook = (options: FacebookOptions) => {
codeVerifier, codeVerifier,
redirectURI: options.redirectURI || redirectURI, redirectURI: options.redirectURI || redirectURI,
options, options,
tokenEndpoint: "https://graph.facebook.com/v16.0/oauth/access_token", tokenEndpoint: "https://graph.facebook.com/oauth/access_token",
}); });
}, },
async getUserInfo(token) { async getUserInfo(token) {
const { data: profile, error } = await betterFetch<FacebookProfile>( const { data: profile, error } = await betterFetch<FacebookProfile>(
"https://graph.facebook.com/me", "https://graph.facebook.com/me?fields=id,name,email,picture",
{ {
auth: { auth: {
type: "Bearer", type: "Bearer",

View File

@@ -1,5 +1,7 @@
import { betterFetch } from "@better-fetch/fetch"; import { betterFetch } from "@better-fetch/fetch";
import type { OAuthProvider, ProviderOptions } from "../oauth2"; import type { OAuthProvider, ProviderOptions } from "../oauth2";
import { logger } from "../utils";
import { parseJWT } from "oslo/jwt";
import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2"; import { createAuthorizationURL, validateAuthorizationCode } from "../oauth2";
export interface TwitchProfile { export interface TwitchProfile {
@@ -21,13 +23,15 @@ export interface TwitchProfile {
picture: string; picture: string;
} }
export interface TwitchOptions extends ProviderOptions {} export interface TwitchOptions extends ProviderOptions {
claims?: string[];
}
export const twitch = (options: TwitchOptions) => { export const twitch = (options: TwitchOptions) => {
return { return {
id: "twitch", id: "twitch",
name: "Twitch", name: "Twitch",
createAuthorizationURL({ state, scopes, redirectURI }) { createAuthorizationURL({ state, scopes, redirectURI }) {
const _scopes = options.scope || scopes || ["activity:write", "read"]; const _scopes = options.scope || scopes || ["user:read:email", "openid"];
return createAuthorizationURL({ return createAuthorizationURL({
id: "twitch", id: "twitch",
redirectURI, redirectURI,
@@ -35,6 +39,11 @@ export const twitch = (options: TwitchOptions) => {
authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize", authorizationEndpoint: "https://id.twitch.tv/oauth2/authorize",
scopes: _scopes, scopes: _scopes,
state, state,
claims: options.claims || [
"email",
"email_verified",
"preferred_username",
],
}); });
}, },
validateAuthorizationCode: async ({ code, redirectURI }) => { validateAuthorizationCode: async ({ code, redirectURI }) => {
@@ -46,18 +55,12 @@ export const twitch = (options: TwitchOptions) => {
}); });
}, },
async getUserInfo(token) { async getUserInfo(token) {
const { data: profile, error } = await betterFetch<TwitchProfile>( const idToken = token.idToken;
"https://api.twitch.tv/helix/users", if (!idToken) {
{ logger.error("No idToken found in token");
method: "GET",
headers: {
Authorization: `Bearer ${token.accessToken}`,
},
},
);
if (error) {
return null; return null;
} }
const profile = parseJWT(idToken)?.payload as TwitchProfile;
return { return {
user: { user: {
id: profile.sub, id: profile.sub,