Lockness Socialite

Lockness Socialite

VIEW

OAuth2/OIDC social authentication with built-in providers for Google, GitHub, and Discord.

Overview

@lockness/socialite provides OAuth2/OIDC authentication with CSRF state protection, extensible driver system, and full TypeScript type safety. Zero external dependencies.

Installation

typescript
import { configureSocialite, socialite } from '@lockness/socialite'

Configuration

typescript
configureSocialite({
    google: {
        clientId: Deno.env.get('GOOGLE_CLIENT_ID')!,
        clientSecret: Deno.env.get('GOOGLE_CLIENT_SECRET')!,
        redirectUri: 'http://localhost:3000/auth/google/callback',
        scopes: ['openid', 'email', 'profile'], // optional
    },
    github: {
        clientId: Deno.env.get('GITHUB_CLIENT_ID')!,
        clientSecret: Deno.env.get('GITHUB_CLIENT_SECRET')!,
        redirectUri: 'http://localhost:3000/auth/github/callback',
        scopes: ['read:user', 'user:email'], // optional
    },
    discord: {
        clientId: Deno.env.get('DISCORD_CLIENT_ID')!,
        clientSecret: Deno.env.get('DISCORD_CLIENT_SECRET')!,
        redirectUri: 'http://localhost:3000/auth/discord/callback',
        scopes: ['identify', 'email'], // optional
    },
})

Basic Usage

Simple OAuth Flow

typescript
import { Controller, Get } from '@lockness/core'
import { socialite } from '@lockness/socialite'
import type { Context } from '@lockness/core'

@Controller('/auth')
class AuthController {
    // Redirect to Google OAuth
    @Get('/google')
    google() {
        return socialite('google').redirect()
    }

    // Handle callback
    @Get('/google/callback')
    async googleCallback(c: Context) {
        const user = await socialite('google').user(c)

        // user.id, user.email, user.name, user.avatar
        // Store in session, create account, etc.

        return c.json({ user })
    }
}

With CSRF Protection

typescript
import { generateState } from '@lockness/socialite'
import { session } from '@lockness/session'

@Controller('/auth')
class AuthController {
    @Get('/google')
    async google(c: Context) {
        const state = generateState()

        // Store state in session
        session(c).set('oauth_state', state)

        return socialite('google').redirect(state)
    }

    @Get('/google/callback')
    async googleCallback(c: Context) {
        const state = c.req.query('state')
        const sessionState = session(c).get('oauth_state')

        if (state !== sessionState) {
            return c.text('Invalid state', 400)
        }

        session(c).delete('oauth_state')

        const user = await socialite('google').user(c)
        return c.json({ user })
    }
}

User Object

The socialite().user(c) method returns a normalized user object:

typescript
interface SocialUser {
    id: string // Provider-specific ID
    email: string // User's email
    name: string // Display name
    avatar: string | null // Avatar URL
    accessToken: string // OAuth access token
    refreshToken: string | null // OAuth refresh token (if available)
    expiresIn: number | null // Token expiration in seconds
    raw: Record<string, unknown> // Raw provider response
}

Providers

Google

Get credentials: Google Cloud Console

typescript
configureSocialite({
    google: {
        clientId: 'your-client-id',
        clientSecret: 'your-client-secret',
        redirectUri: 'http://localhost:3000/auth/google/callback',
        scopes: ['openid', 'email', 'profile'], // default
    },
})

GitHub

Get credentials: GitHub OAuth Apps

typescript
configureSocialite({
    github: {
        clientId: 'your-client-id',
        clientSecret: 'your-client-secret',
        redirectUri: 'http://localhost:3000/auth/github/callback',
        scopes: ['read:user', 'user:email'], // default
    },
})

Discord

Get credentials: Discord Developer Portal

typescript
configureSocialite({
    discord: {
        clientId: 'your-client-id',
        clientSecret: 'your-client-secret',
        redirectUri: 'http://localhost:3000/auth/discord/callback',
        scopes: ['identify', 'email'], // default
    },
})

Common Use Cases

Social Login with Account Creation

typescript
import { auth } from '@lockness/auth'
import { socialite } from '@lockness/socialite'

@Controller('/auth')
export class AuthController {
    @Get('/google/callback')
    async googleCallback(c: Context) {
        const socialUser = await socialite('google').user(c)

        // Find or create user
        let user = await db.query.users.findFirst({
            where: eq(users.email, socialUser.email),
        })

        if (!user) {
            // Create new user
            user = await db.insert(users).values({
                email: socialUser.email,
                name: socialUser.name,
                avatar: socialUser.avatar,
                googleId: socialUser.id,
            }).returning()
        } else if (!user.googleId) {
            // Link existing account
            await db.update(users)
                .set({ googleId: socialUser.id })
                .where(eq(users.id, user.id))
        }

        // Log user in
        await auth(c).loginById(user.id)

        return c.redirect('/dashboard')
    }
}

Multiple OAuth Providers

typescript
@Controller('/auth')
export class AuthController {
    @Get('/google')
    google() {
        return socialite('google').redirect()
    }

    @Get('/github')
    github() {
        return socialite('github').redirect()
    }

    @Get('/discord')
    discord() {
        return socialite('discord').redirect()
    }

    @Get('/google/callback')
    async googleCallback(c: Context) {
        return await this.handleCallback(c, 'google')
    }

    @Get('/github/callback')
    async githubCallback(c: Context) {
        return await this.handleCallback(c, 'github')
    }

    @Get('/discord/callback')
    async discordCallback(c: Context) {
        return await this.handleCallback(c, 'discord')
    }

    private async handleCallback(
        c: Context,
        provider: 'google' | 'github' | 'discord',
    ) {
        const socialUser = await socialite(provider).user(c)

        let user = await findOrCreateUser(socialUser, provider)

        await auth(c).loginById(user.id)

        return c.redirect('/dashboard')
    }
}

Account Linking

typescript
@Controller('/settings')
export class SettingsController {
    @Get('/link/github')
    @AuthRequired()
    linkGithub(c: Context) {
        const state = generateState()
        session(c).set('link_state', state)
        session(c).set('link_user_id', c.get('auth').user.id)

        return socialite('github').redirect(state)
    }

    @Get('/link/github/callback')
    async linkGithubCallback(c: Context) {
        const state = c.req.query('state')
        const sessionState = session(c).get('link_state')
        const userId = session(c).get('link_user_id')

        if (state !== sessionState || !userId) {
            return c.text('Invalid state', 400)
        }

        const socialUser = await socialite('github').user(c)

        // Link GitHub account
        await db.update(users)
            .set({ githubId: socialUser.id })
            .where(eq(users.id, userId))

        session(c).delete('link_state')
        session(c).delete('link_user_id')
        session(c).flash('success', 'GitHub account linked successfully')

        return c.redirect('/settings')
    }
}

Custom Providers

Extend BaseOAuth2Driver to add custom providers:

typescript
import { BaseOAuth2Driver, registerSocialiteDriver } from '@lockness/socialite'
import type { OAuthTokens, SocialUser } from '@lockness/socialite'

class LinkedInDriver extends BaseOAuth2Driver {
    protected authUrl = 'https://www.linkedin.com/oauth/v2/authorization'
    protected tokenUrl = 'https://www.linkedin.com/oauth/v2/accessToken'
    protected userInfoUrl = 'https://api.linkedin.com/v2/me'
    protected defaultScopes = ['r_liteprofile', 'r_emailaddress']

    async getUserFromTokens(tokens: OAuthTokens): Promise<SocialUser> {
        const response = await fetch(this.userInfoUrl, {
            headers: {
                'Authorization': `Bearer ${tokens.access_token}`,
            },
        })
        const data = await response.json()

        // Get email separately (LinkedIn specific)
        const emailResponse = await fetch(
            'https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))',
            {
                headers: {
                    'Authorization': `Bearer ${tokens.access_token}`,
                },
            },
        )
        const emailData = await emailResponse.json()
        const email = emailData.elements?.[0]?.['handle~']?.emailAddress

        return {
            id: data.id,
            email: email,
            name: `${data.localizedFirstName} ${data.localizedLastName}`,
            avatar: null,
            accessToken: tokens.access_token,
            refreshToken: tokens.refresh_token || null,
            expiresIn: tokens.expires_in || null,
            raw: data,
        }
    }
}

// Register driver
registerSocialiteDriver('linkedin', LinkedInDriver)

// Configure
configureSocialite({
    linkedin: {
        clientId: 'your-client-id',
        clientSecret: 'your-client-secret',
        redirectUri: 'http://localhost:3000/auth/linkedin/callback',
    },
})

// Use
socialite('linkedin').redirect()

Best Practices

  • Always use CSRF state protection in production
  • Store OAuth state in session, not cookies
  • Validate state parameter on callback
  • Use environment variables for client credentials
  • Set up proper redirect URIs in provider settings
  • Handle OAuth errors gracefully
  • Store access tokens securely if needed
  • Consider token refresh for long-lived sessions
  • Use HTTPS in production for secure OAuth flow
  • Implement proper error handling for network failures
  • Log OAuth errors for debugging
  • Consider rate limiting on OAuth endpoints

Environment Variables

bash
# .env

# Google OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret

# GitHub OAuth
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret

# Discord OAuth
DISCORD_CLIENT_ID=your-discord-client-id
DISCORD_CLIENT_SECRET=your-discord-client-secret

Error Handling

typescript
@Get('/google/callback')
async googleCallback(c: Context) {
    try {
        const socialUser = await socialite('google').user(c)
        
        // Process user...
        
        return c.redirect('/dashboard')
    } catch (error) {
        console.error('OAuth error:', error)
        
        session(c).flash('error', 'Authentication failed. Please try again.')
        return c.redirect('/login')
    }
}