Skip to content
lightfriend-add-integration logo

Adding a New OAuth Integration

lightfriend-add-integration

Step-by-step guide for adding new OAuth integrations to Lightfriend

SKILL.md

Full skill instructions

Adding a New OAuth Integration

This skill guides you through adding a new OAuth service integration (e.g., Google Calendar, Spotify, GitHub) to Lightfriend.

Overview

A complete integration includes:

  • Backend OAuth flow (authorization + callback)
  • Database migration for storing encrypted tokens
  • Repository methods for token management
  • Frontend UI for connection management
  • Protected API endpoints for using the integration

Step-by-Step Process

1. Create Database Migration

First, create a table to store the OAuth credentials:

cd backend && diesel migration generate add_{service}_connection

Edit up.sql:

CREATE TABLE {service}_connection (
    id INTEGER PRIMARY KEY NOT NULL,
    user_id INTEGER NOT NULL,
    access_token TEXT NOT NULL,        -- Encrypted
    refresh_token TEXT,                -- Encrypted (if applicable)
    expires_at TEXT,                   -- ISO 8601 timestamp
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);

CREATE INDEX idx_{service}_connection_user_id ON {service}_connection(user_id);

Edit down.sql:

DROP TABLE {service}_connection;

Run migration:

cd backend && diesel migration run
cd backend && diesel print-schema > src/​schema.rs

2. Add Diesel Model

In backend/​src/​models/​user_models.rs, add:

#[derive(Queryable, Insertable, Debug, Clone)]
#[diesel(table_name = {service}_connection)]
pub struct {Service}Connection {
    pub id: i32,
    pub user_id: i32,
    pub access_token: String,      // Encrypted
    pub refresh_token: Option<String>,  // Encrypted
    pub expires_at: Option<String>,
    pub created_at: String,
}

#[derive(Insertable)]
#[diesel(table_name = {service}_connection)]
pub struct New{Service}Connection {
    pub user_id: i32,
    pub access_token: String,
    pub refresh_token: Option<String>,
    pub expires_at: Option<String>,
}

3. Add Repository Methods

In backend/​src/​repositories/​connection_auth.rs, add methods:

use crate::schema::{service}_connection;
use crate::models::user_models::{Service}Connection, New{Service}Connection;

pub fn create_{service}_connection(
    conn: &mut SqliteConnection,
    new_connection: New{Service}Connection,
) -> Result<{Service}Connection, diesel::result::Error> {
    diesel::insert_into({service}_connection::table)
        .values(&new_connection)
        .get_result(conn)
}

pub fn get_{service}_connection(
    conn: &mut SqliteConnection,
    user_id: i32,
) -> Result<{Service}Connection, diesel::result::Error> {
    {service}_connection::table
        .filter({service}_connection::user_id.eq(user_id))
        .first(conn)
}

pub fn update_{service}_tokens(
    conn: &mut SqliteConnection,
    user_id: i32,
    access_token: &str,
    refresh_token: Option<&str>,
    expires_at: Option<&str>,
) -> Result<usize, diesel::result::Error> {
    diesel::update({service}_connection::table)
        .filter({service}_connection::user_id.eq(user_id))
        .set((
            {service}_connection::access_token.eq(access_token),
            {service}_connection::refresh_token.eq(refresh_token),
            {service}_connection::expires_at.eq(expires_at),
        ))
        .execute(conn)
}

pub fn delete_{service}_connection(
    conn: &mut SqliteConnection,
    user_id: i32,
) -> Result<usize, diesel::result::Error> {
    diesel::delete({service}_connection::table)
        .filter({service}_connection::user_id.eq(user_id))
        .execute(conn)
}

4. Create OAuth Handler

Create backend/​src/​handlers/​{service}_auth.rs:

use axum::{Extension, extract::Query, response::Redirect, http::StatusCode};
use serde::Deserialize;
use crate::repositories::connection_auth;
use crate::utils::encryption::{encrypt_data, decrypt_data};
use crate::models::AppState;

#[derive(Deserialize)]
pub struct {Service}AuthQuery {
    code: String,
    state: Option<String>,
}

// Step 1: Redirect to OAuth provider
pub async fn {service}_oauth_start(
    Extension(user_id): Extension<i32>,
) -> Result<Redirect, StatusCode> {
    let client_id = std::env::var("{SERVICE}_CLIENT_ID")
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let redirect_uri = format!(
        "{}/​api/​{service}/​oauth/​callback",
        std::env::var("BACKEND_URL").unwrap_or_default()
    );

    let auth_url = format!(
        "https://{service}.com/​oauth/​authorize?client_id={}&redirect_uri={}&response_type=code&scope={}",
        client_id,
        urlencoding::encode(&redirect_uri),
        "read write"  // Adjust scopes as needed
    );

    Ok(Redirect::to(&auth_url))
}

// Step 2: Handle OAuth callback
pub async fn {service}_oauth_callback(
    Extension(user_id): Extension<i32>,
    Extension(state): Extension<AppState>,
    Query(query): Query<{Service}AuthQuery>,
) -> Result<Redirect, StatusCode> {
    let client_id = std::env::var("{SERVICE}_CLIENT_ID")
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let client_secret = std::env::var("{SERVICE}_CLIENT_SECRET")
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    // Exchange code for tokens
    let client = reqwest::Client::new();
    let token_response = client
        .post("https://{service}.com/​oauth/​token")
        .form(&[
            ("grant_type", "authorization_code"),
            ("code", &query.code),
            ("client_id", &client_id),
            ("client_secret", &client_secret),
            ("redirect_uri", &format!("{}/​api/​{service}/​oauth/​callback",
                std::env::var("BACKEND_URL").unwrap_or_default())),
        ])
        .send()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
        .json::<serde_json::Value>()
        .await
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let access_token = token_response["access_token"]
        .as_str()
        .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
    let refresh_token = token_response["refresh_token"].as_str();
    let expires_in = token_response["expires_in"].as_i64();

    // Encrypt tokens
    let encrypted_access = encrypt_data(access_token)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
    let encrypted_refresh = refresh_token
        .map(|t| encrypt_data(t))
        .transpose()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let expires_at = expires_in.map(|sec| {
        chrono::Utc::now()
            .checked_add_signed(chrono::Duration::seconds(sec))
            .unwrap()
            .to_rfc3339()
    });

    // Store in database
    let mut conn = state.pool.get()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    let new_connection = connection_auth::New{Service}Connection {
        user_id,
        access_token: encrypted_access,
        refresh_token: encrypted_refresh,
        expires_at,
    };

    connection_auth::create_{service}_connection(&mut conn, new_connection)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(Redirect::to(&format!("{}/​connections",
        std::env::var("FRONTEND_URL").unwrap_or_default())))
}

// Disconnect
pub async fn {service}_disconnect(
    Extension(user_id): Extension<i32>,
    Extension(state): Extension<AppState>,
) -> Result<StatusCode, StatusCode> {
    let mut conn = state.pool.get()
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    connection_auth::delete_{service}_connection(&mut conn, user_id)
        .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;

    Ok(StatusCode::OK)
}

5. Add Routes to Main

In backend/​src/​main.rs, add routes:

use handlers::{service}_auth;

// In the router setup:
.route("/​api/​{service}/​oauth/​start",
    get({service}_auth::{service}_oauth_start)
    .layer(middleware::from_fn_with_state(app_state.clone(), require_auth)))
.route("/​api/​{service}/​oauth/​callback",
    get({service}_auth::{service}_oauth_callback)
    .layer(middleware::from_fn_with_state(app_state.clone(), require_auth)))
.route("/​api/​{service}/​disconnect",
    post({service}_auth::{service}_disconnect)
    .layer(middleware::from_fn_with_state(app_state.clone(), require_auth)))

6. Create Frontend Component

Create frontend/​src/​connections/​{service}.rs:

use yew::prelude::*;
use gloo_net::http::Request;
use crate::config;

#[function_component(ServiceConnection)]
pub fn {service}_connection() -> Html {
    let connected = use_state(|| false);
    let loading = use_state(|| true);

    // Check connection status on mount
    {
        let connected = connected.clone();
        let loading = loading.clone();
        use_effect_with((), move |_| {
            wasm_bindgen_futures::spawn_local(async move {
                // Check if connected via API call
                // Set connected state accordingly
                loading.set(false);
            });
        });
    }

    let on_connect = {
        let token = /​* get from context */;
        Callback::from(move |_| {
            let backend_url = config::get_backend_url();
            web_sys::window()
                .unwrap()
                .location()
                .set_href(&format!("{}/​api/​{service}/​oauth/​start", backend_url))
                .unwrap();
        })
    };

    let on_disconnect = {
        let connected = connected.clone();
        let token = /​* get from context */;
        Callback::from(move |_| {
            let connected = connected.clone();
            wasm_bindgen_futures::spawn_local(async move {
                let _ = Request::post(&format!("{}/​api/​{service}/​disconnect", config::get_backend_url()))
                    .header("Authorization", &format!("Bearer {}", token))
                    .send()
                    .await;
                connected.set(false);
            });
        })
    };

    html! {
        <div class="connection-card">
            <h3>{"Service Integration"}</​h3>
            if *loading {
                <p>{"Loading..."}</​p>
            } else if *connected {
                <button onclick={on_disconnect}>{"Disconnect"}</​button>
            } else {
                <button onclick={on_connect}>{"Connect"}</​button>
            }
        </​div>
    }
}

7. Add Frontend Route

In frontend/​src/​main.rs, add the connection component to the connections page or create a dedicated route.

8. Add Environment Variables

In backend/​.env:

{SERVICE}_CLIENT_ID=your_client_id
{SERVICE}_CLIENT_SECRET=your_client_secret

Testing Checklist

  • OAuth flow redirects correctly
  • Tokens are encrypted in database
  • Connection appears in frontend
  • Disconnect removes connection
  • Token refresh works (if applicable)
  • Error handling for failed OAuth

Common Patterns

Token Refresh

pub async fn refresh_{service}_token(
    user_id: i32,
    state: &AppState,
) -> Result<String, Box<dyn std::error::Error>> {
    let mut conn = state.pool.get()?;
    let connection = connection_auth::get_{service}_connection(&mut conn, user_id)?;

    let refresh_token = decrypt_data(&connection.refresh_token.unwrap())?;

    // Exchange refresh token for new access token
    // Update database with new tokens
    // Return decrypted access token
}

API Calls with Integration

pub async fn call_{service}_api(
    user_id: i32,
    state: &AppState,
    endpoint: &str,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    let mut conn = state.pool.get()?;
    let connection = connection_auth::get_{service}_connection(&mut conn, user_id)?;
    let access_token = decrypt_data(&connection.access_token)?;

    let client = reqwest::Client::new();
    let response = client
        .get(&format!("https://api.{service}.com{}", endpoint))
        .bearer_auth(access_token)
        .send()
        .await?
        .json()
        .await?;

    Ok(response)
}

Security Notes

  • Always encrypt tokens using utils/​encryption.rs
  • Use HTTPS for OAuth redirects in production
  • Validate state parameter to prevent CSRF
  • Set minimum scopes required for functionality
  • Implement token refresh before expiration
  • Handle revoked tokens gracefully

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View