Claude-skill-registry lightfriend-add-integration

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

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/lightfriend-add-integration" ~/.claude/skills/majiayu000-claude-skill-registry-lightfriend-add-integration && rm -rf "$T"
manifest: skills/data/lightfriend-add-integration/SKILL.md
source content

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