In today’s content-driven world, a regular requirement is to share content across multiple platforms to maximize reach and engagement. However, managing separate accounts and authentication flows for each platform can be cumbersome. What if you could build an application that allows users to upload videos to both YouTube (Google) and X.com (Twitter) with a single login? This is where FusionAuth’s Identity Provider Links feature becomes invaluable.
Identity Provider (IDP) Links allow you to connect a FusionAuth user account with multiple social identity providers through access and refresh tokens, enabling your application to securely access APIs from different platforms while maintaining a single unified user account.
What Are Identity Provider Links?
Identity Provider Links establish a relationship between a FusionAuth user and users in third-party identity providers such as Google and X.com (Twitter). When a link is created, FusionAuth stores information about the connection, including:
- The identity provider ID
- The user ID in the identity provider
- The FusionAuth user ID
- A display name (usually an email or username)
- Access or refresh token from the identity provider
This feature enables your application to securely access APIs from different platforms while maintaining a single unified user account.
Preparation
Before we dive into the implementation, let’s set up our environment. We’ll need:
- A running FusionAuth instance
- Google and X.com (Twitter) developer accounts with API access
- A video upload application that connects to multiple services
Setting Up FusionAuth
If you don’t already have FusionAuth running, you can follow Step-1 of the Get Started Tutorial. Alternatively, check out our example application on GitHub which includes a pre-defined Kickstart configuration.
Once FusionAuth is running, create an application:
- Log in to the FusionAuth admin UI
- Navigate to Applications and click Add
- Enter a name for your application (e.g.,
Video Upload App
) - On the OAuth tab, add a Redirect URL (e.g.,
http://localhost:3000/auth/callback
) - Enable the Authorization Code grant
- Save the application and note the Client ID and Client Secret
Creating the Video Upload Application
For our demo, we’ll create a Node.js application using Fastify that will:
- Allow users to log in with FusionAuth
- Connect to both Google (YouTube) and X.com (Twitter) accounts
- Upload videos to multiple platforms simultaneously
The complete application structure is available in our example repository.
Initializing the Application
To get started, initialize the Fastify application:
npm init fastify links-app -- --lang=ts && cd links-app
This will initialize the Fastify application in the links-app
directory.
Fastify is a web framework for Node.js that provides a simple, yet powerful API for building web applications.
Next, we install the necessary dependencies:
npm i @fastify/view @fastify/passport @fastify/cookie @fastify/session @fusionauth/typescript-client ejs passport-oauth2 jose @fastify/multipart envalid qs
npm i -D @types/passport-oauth2 @biomejs/biome
Next, create a new file in the src
directory called utils.ts
and add the following code:
import FusionAuthClient from "@fusionauth/typescript-client";
import { cleanEnv, port, str, url } from "envalid";
import type { preValidationMetaHookHandler } from "fastify/types/hooks";
export const ALLOWED_MIME_TYPES = [
"video/mp4",
"video/webm",
"video/ogg",
"video/quicktime",
"video/x-msvideo",
"video/x-ms-wmv",
"video/x-flv",
];
/**
* Check if the user is authenticated
*/
export const checkAuthenticated: preValidationMetaHookHandler = (
request,
reply,
done,
) => {
if (!request.isAuthenticated()) {
reply.redirect("/");
}
done();
};
export const env = cleanEnv(process.env, {
FUSIONAUTH_URL: url(),
FUSIONAUTH_API_KEY: str(),
FUSIONAUTH_CLIENT_ID: str(),
FUSIONAUTH_CLIENT_SECRET: str(),
FUSIONAUTH_REDIRECT_URI: url(),
TWITTER_PROVIDER_ID: str(),
TWITTER_API_KEY: str(),
TWITTER_API_SECRET: str(),
GOOGLE_PROVIDER_ID: str(),
GOOGLE_CLIENT_ID: str(),
GOOGLE_CLIENT_SECRET: str(),
SESSION_SECRET: str({
default: "this_should_be_at_least_32_chars_long_and_secure_random_string",
}),
PORT: port({ default: 3000 }),
});
export const faClient = new FusionAuthClient(
env.FUSIONAUTH_API_KEY,
env.FUSIONAUTH_URL,
);
The utils.ts
file contains helper functions that we’ll use throughout the application. In our utilities we initialize the faClient
using the previously installed FusionAuth TypeScript Client library which we use later on to retrievUserLinksByUserId
and deleteUserLink
.
Setting Up the Views
We’ll use EJS to render the views for our application. Create a new file in the src/plugins
directory called view.ts
and add the following code:
import fastifyView, { type FastifyViewOptions } from "@fastify/view";
import fp from "fastify-plugin";
/**
* This plugins adds some utilities to handle http errors
*
* @see https://github.com/fastify/fastify-sensible
*/
export default fp<FastifyViewOptions>(async (fastify) => {
fastify.register(fastifyView, {
engine: {
ejs: require("ejs"),
},
root: "./templates",
layout: "_layout.ejs",
});
});
The plugin registers the EJS view engine with Fastify and configures the view directory.
Next, create a new file in the root located templates
directory called _layout.ejs
and add the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video Upload</title>
<link href="//cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
<link href="//cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css" rel="stylesheet">
<script src="//unpkg.com/alpinejs" defer></script>
</head>
<body>
<div class="p-5" style="margin: 0 auto; max-width: 750px;">
<h1 class="text-center">Video Upload</h1>
<div class="card shadow-sm">
<div class="card-body">
<%- body %>
</div>
</div>
</div>
<script src="//cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js" integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q" crossorigin="anonymous"></script>
</body>
</html>
The layout file defines the basic structure of our application.
Next, create a new file in the templates
directory called root.ejs
and add the following code:
<style>
#drop-zone {
cursor: pointer;
}
#drop-zone > * {
pointer-events: none;
}
video {
inset: 0;
position: absolute;
width: 100%;
height: 100%;
}
</style>
<div x-data="video">
<div class="d-flex flex-column gap-3" :style="{'pointer-events': posting ? 'none' : 'auto'}">
<span>
Hello <%= user %>!
</span>
<div class="alert alert-danger" role="alert" x-show="error" x-text="error" x-transition></div>
<div class="alert alert-danger" role="alert" x-show="results?.error.length" x-transition>
<p>Error posting video!</p>
<ul>
<template x-for="error in results?.error">
<li>
<span x-text="error.name"></span>:
<span x-text="error.message"></span>
</li>
</template>
</ul>
</div>
<div class="alert alert-success" role="alert" x-show="results?.success.length" x-transition>
<p>Video successfully posted!</p>
<ul>
<template x-for="link in results?.success">
<li>
<span x-text="link.name"></span>:
<a :href="link.url" target="_blank" rel="noopener noreferrer" x-text="link.url"></a>
</li>
</template>
</ul>
</div>
<div class="ratio ratio-4x3 position-relative">
<div id="drop-zone" class="rounded border border-primary p-4 text-center" x-data="{ dragging: false }"
:class="{'bg-light': dragging}"
@dragover.prevent="dragging = true"
@dragleave.prevent="dragging = false"
@click="selectFile"
@drop.prevent="upload($event.dataTransfer.files[0])"
x-show="!file">
<div>Drag and drop video file</div>
<div>or</div>
<label for="file-upload" class="btn btn-primary">click to choose file</label>
<input type="file" id="file-upload" name="file" class="d-none" accept="video/*"
@change="upload($event.target.files[0])"/>
</div>
<div class="rounded border border-primary p-4 text-center" x-show="file">
<video controls :src="file"></video>
<div class="position-absolute top-0 end-0 p-1 z-1">
<button class="btn btn-outline-danger" @click="resetFile"><i class="bi bi-trash"></i></button>
</div>
</div>
</div>
<div class="progress" x-show="uploading" x-transition x-transition:leave.duration.500ms id="progress"
role="progressbar"
aria-label="Basic example" aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">
<div class="progress-bar" id="progress-bar" :class="{'bg-success': progress >= 99}"
:style="{width: progress + '%'}" x-text="!uploaded ? progress + '%' : 'Uploaded!'">
</div>
</div>
<div class="row row-cols-2 g-3" id="link">
<% links.forEach(function(link){ %>
<div class="d-flex gap-2" x-data="<%= JSON.stringify(link) %>">
<div class="d-flex flex-column flex-grow-1">
<input type="checkbox" class="btn-check" id="link-<%= link.id %>"
value="<%= link.id %>"
x-model="links"
<% if(link.disabled) { %>
disabled
<% } %>
>
<label class="btn text-start" for="link-<%= link.id %>">
<i class="<%= link.icon %>" style="color: <%= link.color %>"></i>
<%= link.name %>
</label>
</div>
<% if(link.disabled) { %>
<button class="float-end d-flex align-items-center btn btn-outline-info"
title="Do link your account, logout and use the <%= link.name %> IdP to login"
@click="link(providerId)">
<i class="bi bi-link-45deg"></i>
</button>
<% } else { %>
<button class="float-end d-flex align-items-center btn btn-outline-info"
title="Unlink account" @click="unlink(providerId)">
<i class="bi bi-trash"></i>
</button>
<% } %>
</div>
<% }); %>
</div>
<button class="btn btn-primary" id="post-video" :disabled="!uploaded || !links.length" @click="postFiles">
Post
</button>
<div class="d-flex justify-content-end">
<a href="/auth/logout" class="btn btn-warning">Logout</a>
</div>
</div>
<div class="container w-100 h-100 position-absolute top-0 start-0 d-flex justify-content-center align-items-center bg-secondary bg-opacity-50"
x-show.important="posting" x-transition x-transition.opacity>
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<script>
addEventListener('alpine:init', () => {
const SUPPORTED_TYPES = <%- JSON.stringify(mimeTypes) %>;
const links = <%- JSON.stringify(links) %>;
Alpine.data('video', () => ({
file: null,
links: links.filter(link => !link.disabled).map(link => link.id),
progress: 0,
uploading: false,
uploaded: false,
dragging: false,
posting: false,
results: null,
error: null,
resetFile() {
this.file = null;
this.progress = 0;
this.uploading = false;
this.uploaded = false;
this.posting = false;
},
selectFile() {
document.getElementById('file-upload').click();
},
async upload(file) {
if (!SUPPORTED_TYPES.includes(file.type)) {
this.error = 'File type not supported';
return;
}
this.error = null;
const formData = new FormData();
formData.append('file', file);
this.file = URL.createObjectURL(file);
this.uploading = true;
this.uploaded = false;
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', e => {
if (e.lengthComputable) {
this.progress = (e.loaded / e.total) * 100;
}
});
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState !== 4) {
return;
}
this.uploaded = true;
})
xhr.open('POST', '/upload');
xhr.send(formData);
},
postFiles() {
this.posting = true;
fetch('/upload/post', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
links: this.links
})
})
.then(response => response.json())
.then(data => {
this.posting = false;
const results = data.map(link => ({
...link,
name: links.find(link => link.id === link.id).name,
}));
this.results = {
success: results.filter(link => link.status === 'success'),
error: results.filter(link => link.status === 'error'),
}
this.resetFile();
});
},
link(providerId) {
if (confirm('Do you want to link your account? You will be logged out and redirected to the IdP to login.')) {
document.location.href = '/links/' + providerId + '/link';
}
},
unlink(providerId) {
if (confirm('Are you sure you want to unlink this account?')) {
document.location.href = '/links/' + providerId + '/unlink';
}
}
}));
});
</script>
The root template defines the layout for the home page if the user is authenticated.
Finally, create a new file in the templates
directory called login.ejs
and add the following code:
<p class="card-text">You are not logged in.</p>
<a href="/auth/login" class="btn btn-primary">Login now</a>
The login template defines the layout for the login page.
To render the views, we’ll need to update the src/routes/root.ts
file:
import type { FastifyPluginAsync } from "fastify";
import { LINKS } from "../links";
import { ALLOWED_MIME_TYPES, faClient } from "../utils";
const root: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get("/", async (request, reply) => {
if (!request.isAuthenticated()) {
return reply.view("login.ejs");
}
// Check if the user has any links
const identityProviderLinks = await faClient.retrieveUserLinksByUserId(
"",
request.user?.id ?? "",
);
const links = LINKS.map((social) => ({
...social,
disabled: !identityProviderLinks.response.identityProviderLinks?.some(
(link) => link.identityProviderId === social.providerId,
),
}));
return reply.view("root.ejs", {
user: request.user?.displayName,
links,
mimeTypes: ALLOWED_MIME_TYPES,
});
});
};
export default root;
Setting Up Authentication
We’ll use Passport to handle authentication.
Passport is a popular authentication library for Node.js that provides a simple, yet powerful API for building authentication and authorization systems.
Create a new file in the src/plugins
directory called auth.ts
and add the following code:
import fastifyCookie from "@fastify/cookie";
import { Authenticator } from "@fastify/passport";
import fastifySession from "@fastify/session";
import fp from "fastify-plugin";
import { decodeJwt } from "jose";
import OAuth2Strategy, { type VerifyCallback } from "passport-oauth2";
import { env } from "../utils";
export const fastifyAuthenticator = new Authenticator();
class FusionAuthStrategy extends OAuth2Strategy {
// biome-ignore lint/suspicious/noExplicitAny: We need to use any here because the base class doesn't have a type for the options parameter
authenticate(req: any, options?: any) {
if (req.params?.providerId) {
options.idp_hint = req.params.providerId;
}
super.authenticate(req, options);
}
// biome-ignore lint/suspicious/noExplicitAny: We need to use any here because the base class doesn't have a type for the options parameter
authorizationParams(options: any): object {
options = options || {};
const params = Object.assign({}, options);
delete params.idp_hint;
if (options.idp_hint && typeof options.idp_hint === "string") {
params.idp_hint = options.idp_hint;
}
return params;
}
}
export default fp(async (fastify) => {
fastify.register(fastifyCookie);
fastify.register(fastifySession, {
secret: env.SESSION_SECRET,
cookieName: "user-session",
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === "production", // true in production, false in development
httpOnly: true, // Prevents JavaScript access to the cookie
sameSite: "lax", // Protects against CSRF attacks
maxAge: 604_800_000, // 7 days
},
});
fastify.register(fastifyAuthenticator.initialize());
fastify.register(fastifyAuthenticator.secureSession());
fastifyAuthenticator.registerUserSerializer<
{
sub: string;
given_name: string;
family_name: string;
email: string;
},
{ id: string; displayName: string }
>(async (user) => {
const { sub, given_name, family_name, email } = user;
return {
id: sub,
displayName:
given_name || family_name
? `${given_name ?? ""} ${family_name ?? ""}`
: email,
};
});
fastifyAuthenticator.registerUserDeserializer(async (userFromSession) => {
return userFromSession;
});
fastifyAuthenticator.use(
"fusionauth",
new FusionAuthStrategy(
{
authorizationURL: `${env.FUSIONAUTH_URL}/oauth2/authorize`,
tokenURL: `${env.FUSIONAUTH_URL}/oauth2/token`,
clientID: env.FUSIONAUTH_CLIENT_ID,
clientSecret: env.FUSIONAUTH_CLIENT_SECRET,
callbackURL: env.FUSIONAUTH_REDIRECT_URI,
scope: "openid profile email offline_access",
pkce: true,
state: true,
},
(
_accessToken: string,
_refreshToken: string,
results: {
id_token: string;
},
_profile: object,
cb: VerifyCallback,
) => {
const { id_token } = results;
return cb(null, decodeJwt(id_token));
},
),
);
});
declare module "fastify" {
interface PassportUser {
id: string;
displayName: string;
}
}
The plugin registers the Passport OAuth2 strategy with Fastify and configures the OAuth2 settings.
Next, create a new file in the src/routes
directory called auth.ts
and add the following code:
import type { FastifyPluginAsync } from "fastify";
import { fastifyAuthenticator } from "../plugins/auth";
import { env } from "../utils";
const auth: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get(
"/auth/login",
fastifyAuthenticator.authenticate("fusionauth", {
authInfo: false,
failWithError: true,
}),
);
fastify.get("/auth/logout", async (request, reply) => {
await request.logOut();
reply.redirect(
`${env.FUSIONAUTH_URL}/oauth2/logout?client_id=${env.FUSIONAUTH_CLIENT_ID}`,
);
});
fastify.get(
"/auth/callback",
fastifyAuthenticator.authenticate("fusionauth", {
authInfo: false,
successRedirect: "/",
failWithError: true,
}),
);
fastify.get(
"/auth/idp/:providerId",
fastifyAuthenticator.authenticate("fusionauth", {
authInfo: false,
successRedirect: "/",
failWithError: true,
}),
);
};
export default auth;
The routes handle the authentication flow and redirect the user to the login page.
Setting Up Uploads
We’ll use Fastify Multipart to handle file uploads.
Create a new file in the src/plugins
directory called upload.ts
and add the following code:
import multipart, { type FastifyMultipartOptions } from "@fastify/multipart";
import fp from "fastify-plugin";
/**
* This plugins adds some utilities to handle http errors
*
* @see https://github.com/fastify/fastify-sensible
*/
export default fp<FastifyMultipartOptions>(async (fastify) => {
fastify.register(multipart);
});
The plugin registers the Fastify Multipart plugin with Fastify and configures the upload settings.
Next, create a new file in the src/routes
directory called upload.ts
and add the following code:
import * as fs from "node:fs";
import { unlink } from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import type { FastifyPluginAsync } from "fastify";
import { LINKS } from "../links";
import { ALLOWED_MIME_TYPES, checkAuthenticated, faClient } from "../utils";
const file: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.post(
"/upload",
{
preValidation: checkAuthenticated,
},
async (req, rep) => {
const data = await req.file({
limits: {
fileSize: 25 * 1024 * 1024, // 25MB limit
},
});
if (!data) {
return rep.status(400).send({
error: "No file provided",
});
}
// Validate file type
if (!ALLOWED_MIME_TYPES.includes(data.mimetype)) {
return rep.status(400).send({
error: "Invalid file type. Only video files are allowed.",
allowedTypes: ALLOWED_MIME_TYPES,
});
}
const newFileName = `${Date.now()}-${data.filename}`;
await pipeline(data.file, fs.createWriteStream(`./files/${newFileName}`));
req.session.set("file", `./files/${newFileName}`);
rep.send({
file: newFileName,
});
},
);
fastify.post<{
Body: { links: string[]; metadata?: Record<string, object> };
}>(
"/upload/post",
{
schema: {
body: {
type: "object",
properties: {
links: {
type: "array",
items: {
type: "string",
},
},
metadata: {
type: "object",
properties: {
twitter: {
type: "object",
properties: {
text: { type: "string" },
},
},
youtube: {
type: "object",
properties: {
title: { type: "string" },
description: { type: "string" },
privacyStatus: {
type: "string",
enum: ["public", "unlisted", "private"],
},
},
},
},
},
},
},
},
preValidation: checkAuthenticated,
},
async (req, rep) => {
const file = req.session.get("file") as string;
if (!file) {
return rep.status(400).send({
message: "No file uploaded",
});
}
const { links, metadata = {} } = req.body;
if (!links.length) {
return rep.status(400).send({
message: "No links selected",
});
}
const activeLinks = LINKS.filter((social) => links.includes(social.id));
const promises = activeLinks.map(async (social) => {
return await faClient
.retrieveUserLinksByUserId(social.providerId, req.user?.id ?? "")
.then((response) => {
if (!response.response.identityProviderLinks?.length) {
return;
}
const { token } = response.response.identityProviderLinks[0];
if (!token) {
return;
}
// Pass platform-specific metadata to the upload method
const platformMetadata = metadata[social.id];
return social.upload(token, file, platformMetadata);
});
});
// Process all uploads and prepare response
const results = await Promise.allSettled(promises).then((results) => {
return results.map((result, index) => {
if (result.status === "fulfilled") {
return {
id: activeLinks[index].id,
status: "success",
url: result.value,
};
} else {
return {
id: activeLinks[index].id,
status: "error",
message: result.reason,
};
}
});
});
// Clean up the file after processing
try {
await unlink(file);
console.log(`File ${file} has been deleted after processing`);
// Clear the file path from the session
req.session.set("file", null);
} catch (err) {
console.error(`Error deleting file ${file}:`, err);
// Continue with the response even if file deletion fails
}
rep.send(results);
},
);
};
export default file;
The routes handle the file uploads and posts to the appropriate video platforms.
Setting Up Identity Providers
Now, let’s configure the identity providers we’ll be using: Google and X.com (Twitter).
Setting Up OpenID Connect for YouTube API Access
To set up YouTube API access in FusionAuth, we need to use the OpenID Connect identity provider instead of the standard Google identity provider.
The standard Google identity provider in FusionAuth stores an id_token in the identityProviderLink object that cannot be used for API access. For YouTube API integration, you must use the OpenID Connect identity provider configured for Google.
The key difference is that the OpenID Connect identity provider can be configured to request and store a refresh token, which can be exchanged for access tokens to make API calls to YouTube.
Enabling the YouTube Data API
Before you can upload videos to YouTube, you need to enable the YouTube Data API in your Google Cloud project:
- Go to the Google Cloud Console
- Create a new project or select an existing one
- Navigate to APIs & Services -> Library
- Search for
YouTube Data API v3
and enable it
Creating an Application in Google Cloud
After enabling the YouTube Data API, you need to create an OAuth application in Google Cloud:
- In the Google Cloud Console, navigate to APIs & Services -> OAuth consent screen
- Select External as the user type (unless you have a Google Workspace)
- Fill in the required information:
- App name (e.g.,
Video Upload App
) - User support email
- Developer contact information
- Click Save and Continue
- Search for YouTube Data API v3 and add click Create Credentials -> User data -> Add or remove scopes to add the following scopes:
https://www.googleapis.com/auth/youtube.upload
https://www.googleapis.com/auth/youtube
https://www.googleapis.com/auth/youtube.readonly
- Click Save and Continue
Creating Google OAuth 2.0 Credentials with YouTube Scopes
Now you need to create OAuth 2.0 credentials that your application will use to authenticate with Google:
- In the Google Cloud Console, navigate to APIs & Services -> Credentials
- Click Create Credentials and select OAuth client ID
- Select Web application as the application type
- Enter a name for your client (e.g.,
Video Upload Client
) - Add authorized JavaScript origins:
http://localhost:3000
(for development)
- Add authorized redirect URIs:
http://localhost:9011/oauth2/callback
(for FusionAuth)http://localhost:3000/auth/callback
(for your application)
- Click Create
- Note the Client ID and Client Secret that are generated
Configuring the OpenID Connect Identity Provider for Google in FusionAuth
Now that you have your Google OAuth credentials, you need to configure an OpenID Connect identity provider in FusionAuth:
- In FusionAuth, go to Settings -> Identity Providers
- Click Add OpenID Connect
- Enter a name like
YouTube
- Enter the Client ID and Client Secret from Google
- Set the following endpoints:
- Authorization endpoint :
https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&prompt=consent
- Token endpoint :
https://oauth2.googleapis.com/token
- Userinfo endpoint :
https://openidconnect.googleapis.com/v1/userinfo
- Set Discover endpoints to
No
since we’re manually entering them - In the scope field, add:
https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile openid https://www.googleapis.com/auth/youtube.upload
- Set the button text to
Login with YouTube
- Set Linking strategy to
Link on email. Create the user if they do not exist.
to connect users based on their email address and if a new user logs in, create them automatically - Configure the claims mapping in the Options section:
- Unique ID claim :
sub
- Email claim :
email
- Email verified claim :
email_verified
- Username claim :
preferred_username
- Select the applications that should use this identity provider
For YouTube API access, you’ll need to ensure your Google Cloud project has been verified by Google if you plan to publish your application. During development, you can test with unverified apps, but they have limitations on the number of users.
Adding the YouTube Identity Provider to the App
Now that we have the identity provider configured, we need to add it to our application.
Let’s start by installing the necessary dependencies:
npm i google-auth-library googleapis
Then create a new file in the src/links
directory called youtube.ts
and add the following code:
import { createReadStream } from "node:fs";
import path from "node:path";
import { OAuth2Client } from "google-auth-library";
import { google } from "googleapis";
import { env } from "../utils";
import type { LinkDefinition } from "./";
export type YouTubeMetadata = {
title?: string; // Video title
description?: string; // Video description
privacyStatus?: "public" | "unlisted" | "private"; // Video privacy setting
};
export const YoutubeLinkDefinition: LinkDefinition<YouTubeMetadata> = {
id: "youtube",
providerId: env.GOOGLE_PROVIDER_ID,
name: "Youtube",
icon: "bi bi-youtube",
url: "https://www.youtube.com",
color: "#ff0000",
upload: async (token, file, metadata?: YouTubeMetadata) => {
try {
const authClient = new OAuth2Client({
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
redirectUri: env.FUSIONAUTH_REDIRECT_URI,
});
authClient.setCredentials({
refresh_token: token,
});
google.options({ auth: authClient });
// Use custom metadata if provided, or fall back to defaults
const {
title = "Video Upload",
description = "Uploaded video",
privacyStatus = "private",
} = metadata ?? {};
const insert = await google.youtube("v3").videos.insert({
part: ["snippet", "status"],
requestBody: {
snippet: {
title: title,
description: description,
},
status: {
privacyStatus: privacyStatus,
},
},
media: {
body: createReadStream(path.resolve(file)),
},
});
return `https://www.youtube.com/watch?v=${insert.data.id}`;
} catch (e) {
// Log the original error for debugging purposes
console.error("YouTube upload error:", e);
// Determine the type of error and provide a user-friendly message
if (e instanceof Error) {
// Check for specific error types
if (
e.message.includes("authentication") ||
e.message.includes("auth")
) {
throw new Error(
"Authentication failed with YouTube. Please try reconnecting your account.",
);
} else if (e.message.includes("quota") || e.message.includes("limit")) {
throw new Error("YouTube quota exceeded. Please try again later.");
} else if (
e.message.includes("invalid") &&
e.message.includes("video")
) {
throw new Error(
"The video format is not supported by YouTube. Please try a different file.",
);
} else if (e.message.includes("size")) {
throw new Error(
"The video file is too large for YouTube. Please try a smaller file.",
);
}
}
// Generic error message that doesn't expose internal details
throw new Error("Failed to upload to YouTube. Please try again later.");
}
},
};
The youtube.ts
file contains the logic for interacting with the YouTube API.
Setting Up X.com (Twitter) as an Identity Provider
To set up X.com (Twitter) as an identity provider with media upload capabilities:
- Create a X.com (Twitter) developer account
- Apply for Elevated access (required for media uploads)
- Create a X.com (Twitter) app with appropriate permissions
- Configure the authentication settings
- Create a X.com (Twitter) Identity Provider in FusionAuth
For detailed instructions on the basic setup, refer to the X.com (Twitter) Identity Provider documentation.
Applying for Elevated Access
X.com (Twitter)‘s standard API access doesn’t allow for media uploads. You’ll need to apply for Elevated access:
- Go to the X.com (Twitter) Developer Portal
- Navigate to the Projects & Apps section
- Apply for Elevated access by clicking on Apply for Elevated
- Complete the application form, explaining your use case for media uploads
- Wait for approval (this can take a few days)
Creating a X.com (Twitter) App with Media Upload Permissions
Once you have Elevated access, you need to create a X.com (Twitter) app with the appropriate permissions:
- In the X.com (Twitter) Developer Portal, create a new app (e.g.,
Video Upload App
) - Under App permissions , select Read and Write (required for media uploads)
- Under Authentication settings , enable 3-legged OAuth
- Add a callback URL :
http://localhost:9011/oauth2/callback
- Enable Request email from users
- Note the API Key and API Secret Key
Configuring the X.com (Twitter) Identity Provider in FusionAuth
Now you need to configure the X.com (Twitter) identity provider in FusionAuth:
- In FusionAuth, go to Settings -> Identity Providers
- Click Add X.com (Twitter)
- Enter the Consumer Key
API Key
and Consumer SecretAPI Secret Key
- Configure the button text (e.g.,
Login with X.com (Twitter)
) - Select the applications that should use this identity provider
X.com (Twitter) has rate limits for media uploads. For production applications, you’ll need to implement appropriate rate limiting and error handling.
Adding the X.com (Twitter) Identity Provider to the App
Now that we have the identity provider configured, we need to add it to our application.
Let’s again first install the necessary dependencies:
```bash
npm i twitter-api-v2
Then create a new file in the src/links
directory called twitter.ts
and add the following code:
import https from "node:https";
import path from "node:path";
import qs from "qs";
import TwitterApi, { type TwitterApiTokens } from "twitter-api-v2";
import { env } from "../utils";
import type { LinkDefinition } from "./";
export type TwitterMetadata = {
text?: string; // Tweet text
};
export const TwitterLinkDefinition: LinkDefinition<TwitterMetadata> = {
id: "twitter",
providerId: env.TWITTER_PROVIDER_ID,
name: "Twitter",
icon: "bi bi-twitter",
url: "https://twitter.com",
color: "#1da1f2",
upload: async (token, file, metadata?: TwitterMetadata) => {
try {
const { oauth_token, oauth_token_secret } = qs.parse(token);
const twitterClient = new TwitterApi(
{
appKey: env.TWITTER_API_KEY,
appSecret: env.TWITTER_API_SECRET,
accessToken: oauth_token,
accessSecret: oauth_token_secret,
} as TwitterApiTokens,
{ httpAgent: new https.Agent({ keepAlive: false }) },
);
const mediaId = await twitterClient.v1.uploadMedia(path.resolve(file));
// Use custom tweet text from metadata if provided, or fall back to default
const { text = "Check out my video!" } = metadata ?? {};
const tweet = await twitterClient.v2.tweet(text, {
media: {
media_ids: [mediaId],
},
});
return `https://twitter.com/i/statuses/${tweet.data.id}`;
} catch (e) {
// Log the original error for debugging purposes
console.error("Twitter upload error:", e);
// Determine the type of error and provide a user-friendly message
if (e instanceof Error) {
// Check for specific error types
if (e.message.includes("authentication")) {
throw new Error(
"Authentication failed with Twitter. Please try reconnecting your account.",
);
} else if (e.message.includes("rate limit")) {
throw new Error(
"Twitter rate limit exceeded. Please try again later.",
);
} else if (e.message.includes("media")) {
throw new Error(
"There was a problem with your media file. Please try a different file.",
);
}
}
// Generic error message that doesn't expose internal details
throw new Error("Failed to post to Twitter. Please try again later.");
}
},
};
Stitching It All Together
Create a new file in the src/links
directory called index.ts
and add the following code:
import { TwitterLinkDefinition } from "./twitter";
import { YoutubeLinkDefinition } from "./youtube";
export type LinkDefinition<TMetadata = undefined> = {
id: "twitter" | "youtube";
providerId: string;
name: string;
icon: string;
url: string;
color: string;
// Add optional metadata parameter with default empty object
upload: (
token: string,
file: string,
metadata?: TMetadata,
) => Promise<string>;
};
export const LINKS = [TwitterLinkDefinition, YoutubeLinkDefinition];
The index.ts
file combines all of the identity provider logic into a single file.
Using Identity Provider Links
Now that we have our identity providers set up, let’s explore how to use the Identity Provider Links API.
Understanding Token Storage in Identity Provider Links
When working with Identity Provider Links for API access, different identity providers store tokens differently:
- The OpenID Connect identity provider configured for Google stores a refresh_token that can be exchanged for access tokens
- X.com (Twitter) stores access token and secret information that can be parsed for API access
When implementing API access using Identity Provider Links, you need to:
- Configure the appropriate identity provider
- Extract the token from the identityProviderLink object
- For Google/YouTube, exchange the refresh token for an access token before making API calls
- For X.com (Twitter), parse the token string to extract the access token and secret
The Links API
FusionAuth provides a comprehensive API for managing identity provider links:
- Create a Link - Link a user to an identity provider
POST /api/identity-provider/link
- Retrieve Links - Get all identity provider links for a user
GET /api/identity-provider/link?userId=<userId>
- Delete a Link - Remove a link between a user and an identity provider
DELETE /api/identity-provider/link?identityProviderId=<identityProviderId>&identityProviderUserId=<identityProviderUserId>&userId=<userId>
FusionAuth is handling the 1. Link a User through the Identity Provider configuration.
In our Fastify application, we’ve implemented the other two API calls with the help of the FusionAuth TypeScript Client library by using retrievUserLinksByUserId
and deleteUserLink
:
- Retrieve Links: Get information about existing links to determine available upload platforms
- Unlink a User: Remove a link between a FusionAuth user and a social identity
Let’s look at how to use these endpoints in our application.
Add Links to the Application
To add linking functionality to the application, create a new file in the src/routes
directory called links.ts
and add the following code:
import type { FastifyPluginAsync } from "fastify";
import { checkAuthenticated, env, faClient } from "../utils";
const links: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get<{ Params: { id: string } }>(
"/links/:id/link",
{
preValidation: checkAuthenticated,
},
async (req, rep) => {
await req.logOut();
rep.redirect(
`${env.FUSIONAUTH_URL}/oauth2/logout?client_id=${env.FUSIONAUTH_CLIENT_ID}&post_logout_redirect_uri=` +
encodeURI(`http://localhost:3000/auth/idp/${req.params.id}`),
);
},
);
fastify.get<{ Params: { id: string } }>(
"/links/:id/unlink",
{
preValidation: checkAuthenticated,
},
async (req, rep) => {
const { id } = req.params;
const link = await faClient.retrieveUserLinksByUserId(
id,
req.user?.id ?? "",
);
if (link.response.identityProviderLinks?.length) {
const { identityProviderId, identityProviderUserId, userId } =
link.response.identityProviderLinks[0];
if (identityProviderId && identityProviderUserId && userId) {
await faClient.deleteUserLink(
identityProviderId,
identityProviderUserId,
userId,
);
}
}
return rep.redirect("/");
},
);
};
export default links;
The route handles linking and unlinking a user and an identity provider.
Testing the Implementation
To test our implementation:
- Clone the example repository:
git clone https://github.com/FusionAuth/fusionauth-example-using-links-fastify
- Install dependencies:
npm install
- Create a
.env
file with your configuration - Start the application:
npm run dev
- Navigate to
http://localhost:3000
in your browser - Log in with your FusionAuth credentials
- Link your Google and X.com (Twitter) accounts
- Upload a video file and select which platforms to post to
- View the results showing links to your uploaded content
Conclusion
Identity Provider Links are a powerful feature of FusionAuth that enable you to build sophisticated multi-platform applications like our video uploader. By connecting multiple social identities to a single user account, you can provide a seamless experience for content creators.
The example application demonstrates several key concepts:
- Single Sign-On with Multiple Providers: Users can log in once with FusionAuth and access multiple platforms
- Token Management: The application securely stores and uses tokens for API access
- Cross-Platform Publishing: Videos can be uploaded to multiple platforms with a single action
- User Experience: The interface allows users to easily manage their connected accounts
By leveraging Identity Provider Links, you can create a unified video publishing experience that simplifies the workflow for content creators. This approach opens up possibilities for more advanced features like:
- Consolidated analytics dashboards showing performance across platforms
- Synchronized comment moderation across YouTube and X.com (Twitter)
- Coordinated publishing schedules and content strategies
- Backup options if one platform experiences issues
The complete source code for this example is available on GitHub, allowing you to explore the implementation details and adapt it to your own applications.
For more information, check out the FusionAuth documentation on Identity Provider Links and the guides for setting up OIDC for Google and X.com (Twitter) identity providers.