Adding a New OAuth Integration

SkillSecurity

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

Available today. Use it from your connected AI after setup.

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