Stacks Socials
OAuth2-based social authentication with 4 built-in providers.
Key Paths
- Core package:
storage/framework/core/socials/src/
Source Files
socials/src/
├── index.ts # re-exports from drivers/
├── abstract.ts # AbstractProvider base class
├── token.ts # Token class
├── types.ts # SocialUser, provider-specific interfaces
├── exceptions.ts # InvalidStateException, ConfigException
└── drivers/
├── index.ts # re-exports all 4 providers
├── github.ts # GitHub OAuth provider
├── google.ts # Google OAuth provider
├── facebook.ts # Facebook OAuth provider
└── twitter.ts # Twitter/X OAuth2 + PKCE provider
AbstractProvider Base Class
All providers extend AbstractProvider which implements ProviderInterface. The constructor takes a ProviderConfig:
interface ProviderConfig {
clientId: string
clientSecret: string
redirectUrl: string
guzzle?: Record<string, any>
}
Properties and methods on AbstractProvider:
abstract class AbstractProvider implements ProviderInterface {
// Protected state
protected clientId: string
protected clientSecret: string
protected redirectUrl: string
protected parameters: Record<string, any> = {}
protected _scopes: string[] = []
protected scopeSeparator: string = ','
protected _stateless: boolean = false
protected _usesPKCE: boolean = false
protected user: SocialUser | null = null
// Abstract methods (each provider implements)
abstract getAuthUrl(): Promise<string> // public, returns Promise
protected abstract getTokenUrl(): string // protected
abstract getAccessToken(code: string): Promise<string>
abstract getUserByToken(token: string): Promise<SocialUser>
// Scope management
scopes(scopes: string | string[]): this // merge into existing (deduplicates via Set)
setScopes(scopes: string | string[]): this // replace all scopes (deduplicates via Set)
getScopes(): string[]
// Configuration
setRedirectUrl(url: string): this
stateless(): this // sets _stateless = true
enablePKCE(): this // sets _usesPKCE = true
with(parameters: Record<string, any>): this // replaces custom query parameters
// User retrieval
async userFromToken(token: string): Promise<SocialUser> // calls getUserByToken, adds token
// URL building (protected)
protected buildAuthUrlFromBase(url: string, state: string | null): string
protected getCodeFields(state: string | null = null): Record<string, any>
protected formatScopes(scopes: string[], scopeSeparator: string): string
// State helpers (protected)
protected usesState(): boolean // returns !_stateless
protected isStateless(): boolean // returns _stateless
protected getState(): string // 20 random bytes -> 40 hex chars
// PKCE helpers (protected)
protected usesPKCE(): boolean
protected getCodeVerifier(): string // 48 random bytes -> 96 hex chars
protected async getCodeChallenge(): Promise<string> // SHA-256 of verifier, base64url encoded
protected getCodeChallengeMethod(): string // always 'S256'
}
The getCodeFields() method builds the query parameters for the authorization URL:
- Always includes:
client_id,redirect_uri,scope(joined byscopeSeparator),response_type: 'code' - Conditionally adds
statewhenusesState()is true - Conditionally adds
code_challengeandcode_challenge_methodwhenusesPKCE()is true - Merges in any custom
parametersset viawith()
Provider Implementations
GitHub Provider
class GitHubProvider extends AbstractProvider {
protected baseUrl = 'https://github.com'
protected apiUrl = 'https://api.github.com'
}
- Config source:
config.services.github(clientId, clientSecret, redirectUrl, scopes) - Default scopes:
['read:user', 'user:email'] - Auth URL:
https://github.com/login/oauth/authorizewith scopes joined by space - Token URL:
https://github.com/login/oauth/access_token(POST) - User API: Fetches
/userand/user/emailsin parallel viaPromise.all - Email resolution: Primary email first, then verified, then first available
- Uses
@stacksjs/apifetcherfor HTTP requests - Throws
ConfigExceptionif clientId, clientSecret, or redirectUrl missing - GitHub token response:
GitHubTokenResponse { access_token, error?, error_description? }
Google Provider
class GoogleProvider extends AbstractProvider {
protected baseUrl = 'https://accounts.google.com'
protected apiUrl = 'https://www.googleapis.com'
}
- Config source:
config.services.google - Default scopes:
['https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/userinfo.email'] - Auth URL:
https://accounts.google.com/o/oauth2/v2/auth(includesaccess_type: 'offline',prompt: 'consent') - Token URL:
https://accounts.google.com/oauth2/v4/token(POST withgrant_type: 'authorization_code') - User API:
https://www.googleapis.com/oauth2/v2/userinfowith Bearer token - Maps
given_nametonickname,picturetoavatar - Google user type:
{ id, email, verified_email, name, given_name, family_name, picture, locale }
Facebook Provider
class FacebookProvider extends AbstractProvider {
protected baseUrl = 'https://www.facebook.com'
protected apiUrl = 'https://graph.facebook.com'
}
- Config source:
config.services.facebook - Default scopes:
['email', 'public_profile'] - Auth URL:
https://www.facebook.com/v18.0/dialog/oauthwith scopes joined by comma - Token URL:
https://graph.facebook.com/v18.0/oauth/access_token(GET request, not POST) - User API:
https://graph.facebook.com/v18.0/me?fields=id,name,email,picture - Token is passed as query parameter
access_token, not in Authorization header - Always sets
nickname: null(Facebook does not expose usernames) - Avatar extracted from
picture.data.url - Error response shape:
{ error: { message, type, code } }(nested object, unlike other providers)
Twitter/X Provider
class TwitterProvider extends AbstractProvider {
protected baseUrl = 'https://twitter.com'
protected apiUrl = 'https://api.twitter.com'
private codeVerifier: string | null = null
}
- Config source:
config.services.twitter - Default scopes:
['users.read', 'tweet.read'] - Uses OAuth 2.0 with PKCE (not OAuth 1.0a)
- Auth URL:
https://twitter.com/i/oauth2/authorizewith PKCE code challenge - Token URL:
https://api.twitter.com/2/oauth2/token(POST with Basic auth) - User API:
https://api.twitter.com/2/users/me?user.fields=profile_image_urlwith Bearer token - PKCE implementation uses its own
generateCodeVerifier()andgenerateCodeChallenge()methods (usingnode:cryptorandomBytesandcreateHash), not the base class PKCE - Code verifier:
randomBytes(32)-> base64 -> alphanumeric only -> max 128 chars - Token exchange uses Basic auth:
Buffer.from(clientId:clientSecret).toString('base64') getAccessToken()throws ifcodeVerifieris null --getAuthUrl()must be called first- Maps
usernametonickname,profile_image_urltoavatar
SocialUser Interface
interface SocialUser {
id: string
nickname: string | null
name: string
email: string | null
avatar: string | null
token: string
raw?: any // full provider API response
}
ProviderInterface
interface ProviderInterface {
getAuthUrl: () => Promise<string>
getAccessToken: (code: string) => Promise<string>
getUserByToken: (token: string) => Promise<SocialUser>
}
Token Class
class Token {
constructor(
public accessToken: string,
public refreshToken: string | null = null,
public expiresIn: number | null = null,
public approvedScopes: string[] = [],
) {}
}
Provider-Specific Types
// GitHub
interface GitHubUser { id: number, login: string, name: string | null, avatar_url: string | null, [key: string]: any }
interface GitHubEmail { email: string, primary: boolean, verified: boolean }
interface GitHubTokenResponse { access_token: string, error?: string, error_description?: string }
// Twitter/X
interface TwitterUser { id: string, username: string, name: string, email?: string, profile_image_url?: string }
interface TwitterTokenResponse { access_token: string, token_type: string, expires_in: number, scope: string, error?: string, error_description?: string }
// Google (local to google.ts)
interface GoogleUser { id: string, email: string, verified_email: boolean, name: string, given_name: string, family_name: string, picture: string, locale: string }
interface GoogleTokenResponse { access_token: string, token_type: string, expires_in: number, error?: string, error_description?: string }
// Facebook (local to facebook.ts)
interface FacebookUser { id: string, email?: string, name: string, picture?: { data: { url: string } } }
interface FacebookTokenResponse { access_token: string, token_type: string, expires_in: number, error?: { message: string, type: string, code: number } }
Exceptions
class InvalidStateException extends Error { name = 'InvalidStateException' }
class ConfigException extends Error { name = 'ConfigException' }
Providers throw ConfigException when clientId, clientSecret, or redirectUrl are missing.
OAuth2 Flow
1. Get Auth URL
const github = new GitHubProvider({ clientId: '', clientSecret: '', redirectUrl: '' })
const authUrl = await github.scopes(['user:email', 'read:org']).getAuthUrl()
// Each provider reads actual config from config.services.<provider>
// The constructor config is overridden by getConfig() in each provider
2. Handle Callback
const token = await github.getAccessToken(code)
const user = await github.getUserByToken(token)
// user: { id, nickname, name, email, avatar, token, raw }
3. User from Existing Token
const user = await github.userFromToken(existingToken)
// Calls getUserByToken internally, adds token to result
Configuration Source
All providers read from the Stacks config object:
config.services.github // { clientId, clientSecret, redirectUrl, scopes }
config.services.google // { clientId, clientSecret, redirectUrl, scopes }
config.services.facebook // { clientId, clientSecret, redirectUrl, scopes }
config.services.twitter // { clientId, clientSecret, redirectUrl, scopes }
Each provider's getConfig() method reads these and also calls this.setScopes() with the configured scopes.
Dependencies
@stacksjs/api--fetcherused for all HTTP requests (provides.withHeaders(),.get(),.post())@stacksjs/config-- providesconfig.services.*for provider credentialsnode:bufferandnode:crypto-- used by Twitter provider for PKCE and Basic auth
Gotchas
- The
ProviderConfigconstructor params are largely ignored -- each provider'sgetConfig()reads fromconfig.services.*instead getAuthUrl()returnsPromise<string>(notstring) -- it is asyncgetTokenUrl()isprotected-- not part of the publicProviderInterface- GitHub joins scopes with space, Facebook with comma, Google with space, Twitter with space
- Facebook uses GET for token exchange; all others use POST
- Facebook
nicknameis alwaysnull - Twitter requires
getAuthUrl()beforegetAccessToken()because the code verifier is stored as instance state - Twitter uses its own PKCE implementation via
node:crypto, not the base classgetCodeVerifier()/getCodeChallenge() - Google auth URL includes
access_type: 'offline'andprompt: 'consent'for refresh token support - The base class state token is 40 hex chars (20 bytes), not the 20 chars stated in some docs
userFromToken()spreads the user object and adds/overwrites thetokenfield- All providers include the full API response in
SocialUser.raw - Errors from GitHub/Google/Twitter are flat objects with
error+error_description; Facebook nests it undererror.message