Skip to content

Instantly share code, notes, and snippets.

@infomiho
Last active July 22, 2024 15:02
Show Gist options
  • Save infomiho/3c63de7d53aba59d6293bcb59501a029 to your computer and use it in GitHub Desktop.
Save infomiho/3c63de7d53aba59d6293bcb59501a029 to your computer and use it in GitHub Desktop.
Implementing custom OAuth provider with Wasp 0.14.0+ (Spotify in this case)
app spotifyOauth {
wasp: {
version: "^0.14.0"
},
title: "spotify-oauth",
client: {
rootComponent: import { App } from "@src/App",
},
auth: {
userEntity: User,
onAuthFailedRedirectTo: "/",
methods: {
// We had to enable at least one OAuth provider so Wasp would install the `arctic` package
google: {}
}
},
}
route RootRoute { path: "/", to: MainPage }
page MainPage {
component: import { MainPage } from "@src/MainPage",
}
route SpotifyCallback { path: "/auth/spotify", to: SpotifyCallback }
page SpotifyCallback {
component: import { SpotifyCallback } from "@src/SpotifyCallback",
}
api authWithSpotify {
httpRoute: (GET, "/auth/spotify"),
fn: import { authWithSpotify } from "@src/auth",
entities: []
}
api authWithSpotifyCallback {
httpRoute: (GET, "/auth/spotify/callback"),
fn: import { authWithSpotifyCallback } from "@src/auth",
entities: []
}
{
"name": "spotifyOauth",
"dependencies": {
"@tanstack/react-query-devtools": "^4.36.1",
"arctic": "^1.2.1",
"react": "^18.2.0",
"wasp": "file:.wasp/out/sdk/wasp",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/react": "^18.0.37",
"prisma": "4.16.2",
"typescript": "^5.1.0",
"vite": "^4.3.9"
}
}
datasource db {
provider = "sqlite"
// Wasp requires that the url is set to the DATABASE_URL environment variable.
url = env("DATABASE_URL")
}
// Wasp requires the `prisma-client-js` generator to be present.
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(cuid())
name String
profilePicture String
}
import { AuthWithSpotify, AuthWithSpotifyCallback } from "wasp/server/api";
import { generateState, Spotify, SpotifyTokens } from "arctic";
import { config } from "wasp/server";
import { createUser, findAuthIdentity, ProviderName } from "wasp/auth/utils";
import * as z from "zod";
import { createSession } from "wasp/auth/session";
if (
!process.env.AUTH_SPOTIFY_CLIENT_ID ||
!process.env.AUTH_SPOTIFY_CLIENT_SECRET
) {
throw new Error(
"Please provide AUTH_SPOTIFY_CLIENT_ID and AUTH_SPOTIFY_CLIENT_SECRET in .env.server file"
);
}
const clientId = process.env.AUTH_SPOTIFY_CLIENT_ID;
const clientSecret = process.env.AUTH_SPOTIFY_CLIENT_SECRET;
const redirectURI = `${config.serverUrl}/auth/spotify/callback`;
const spotify = new Spotify(clientId, clientSecret, redirectURI);
export const authWithSpotify: AuthWithSpotify = async (req, res) => {
const state = generateState();
const url: URL = await spotify.createAuthorizationURL(state, {
scopes: [],
});
res.redirect(url.toString());
};
export const authWithSpotifyCallback: AuthWithSpotifyCallback = async (
req,
res
) => {
const code = req.query.code as string;
const tokens: SpotifyTokens = await spotify.validateAuthorizationCode(code);
const spotifyUser = await getSpotifyUser(tokens.accessToken);
const providerId = {
// Hack to use `spotify` here
providerName: "spotify" as ProviderName,
providerUserId: spotifyUser.id,
};
// Check if user exists first
const existingUser = await findAuthIdentity(providerId);
if (existingUser) {
// Login
const authId = existingUser.authId;
return redirectWithSessionId(authId, res);
} else {
// Create new user
const user = await createUser(
providerId,
JSON.stringify(spotifyUser),
// User fields
{
name: spotifyUser.display_name,
profilePicture: spotifyUser.images[1].url
}
);
const authId = user.auth.id;
return redirectWithSessionId(authId, res);
}
};
async function redirectWithSessionId(
authId: string,
res: Parameters<AuthWithSpotifyCallback>[1]
) {
const sessionId = await createSession(authId);
console.log("Redirecting to frontend with sessionId", sessionId.id);
return res.redirect(`${config.frontendUrl}/auth/spotify#${sessionId.id}`);
}
const spotifyUserSchema = z.object({
id: z.string(),
display_name: z.string(),
external_urls: z.object({
spotify: z.string(),
}),
images: z.array(
z.object({
url: z.string(),
height: z.number(),
width: z.number(),
})
),
});
type SpotifyUser = z.infer<typeof spotifyUserSchema>;
async function getSpotifyUser(accessToken: string): Promise<SpotifyUser> {
const response = await fetch("https://api.spotify.com/v1/me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const spotifyUser = spotifyUserSchema.parse(await response.json());
return spotifyUser;
}
import { logout, useAuth } from "wasp/client/auth";
export const MainPage = () => {
const { data: user } = useAuth();
return (
<div className="container">
<main>
{user ? (
<p>
<img src={user.profilePicture} alt="profile" />
<br />
Logged in as {user.name}
<br />
<button onClick={logout}>Log out</button>
</p>
) : (
<p>Not logged in</p>
)}
<div className="buttons">
<a
className="button button-filled"
href="http://localhost:3001/auth/spotify"
>
Login with Spotify
</a>
</div>
</main>
</div>
);
};
import { useLocation, Redirect } from "react-router-dom";
import { initSession } from "wasp/auth/helpers/user";
import { useAuth } from "wasp/client/auth";
import { useEffect, useRef, useState } from "react";
export function SpotifyCallback() {
const { error, user } = useOAuthCallbackHandler();
console.log(user);
if (user !== undefined && user !== null) {
return <Redirect to="/" />;
}
if (error) {
return <div>{error}</div>;
}
return <div>Wait while we log you in... </div>;
}
function useOAuthCallbackHandler() {
const { data: user } = useAuth();
const [error, setError] = useState<string | null>(null);
const location = useLocation();
const [isDone, setIsDone] = useState(false);
async function handleCallback() {
try {
const sessionId = location.hash.slice(1);
if (!sessionId) {
setError("Unable to login with the OAuth provider.");
return;
}
await initSession(sessionId);
setIsDone(true);
} catch (e: unknown) {
console.error(e);
setError("Unable to login with the OAuth provider.");
}
}
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
handleCallback();
}
}, []);
return {
user,
error,
isDone,
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment