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.mdsource 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