Adding a New OAuth Integration
SkillSecurityStep-by-step guide for adding new OAuth integrations to Lightfriend
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Adding a New OAuth Integration skill
What this skill tells your AI
The instructions your AI receives, as published by ahtavarasmus/lightfriend in .agents/skills/lightfriend-add-integration/SKILL.md and read by ahel’s review.
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
Signals
- GitHub stars
- 78
- Forks
- 9
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
lightfriend-add-integration- Source
- github.com/ahtavarasmus/lightfriend