Social login is expected on every modern web application. Users want to click "Continue with Google" and be authenticated in seconds, without creating another password. Developers want to offer this, but the implementation is notoriously complex: OAuth2 authorization codes, redirect URIs, state parameters, PKCE code verifiers, token exchanges, and provider-specific quirks.
In a Node.js application, implementing Google OAuth requires passport, passport-google-oauth20, session configuration, callback routes, and provider-specific configuration. GitHub requires passport-github2 with different scopes and different user profile fields. Each provider is a separate package with its own API.
FLIN provides built-in functions for six OAuth providers: Google, GitHub, Discord, Apple, LinkedIn, and Telegram. Each provider follows the same three-step pattern: generate the auth URL, handle the callback, and create or log in the user.
The Universal Pattern
Every OAuth provider in FLIN follows the same flow:
Step 1: Generate auth URL (login page)
auth = auth_PROVIDER_login(baseUrl)
session.PROVIDERState = auth.state
<a href={auth.url}>Continue with PROVIDER</a>Step 2: Handle callback (callback page) result = auth_PROVIDER_callback(query.code, query.state, session.PROVIDERState) session.PROVIDERState = none
Step 3: Find or create user if result.ok { existing = User.where(email == result.user.email).first if existing != none { log in } else { create new user } } ```
The function names, the session storage pattern, and the find-or-create logic are identical across providers. Learn one, and you know them all.
Google OAuth with PKCE
Google is the most commonly used OAuth provider. FLIN implements it with PKCE (Proof Key for Code Exchange), the recommended security extension for public clients:
// app/login.flin -- Step 1: Generate auth URLbaseUrl = env("BASE_URL") || "http://localhost:3000" googleAuth = auth_google_login(baseUrl)
// Store state and PKCE verifier in session session.googleState = googleAuth.state session.googleRedirectUri = googleAuth.redirect_uri session.googleCodeVerifier = googleAuth.code_verifier
// Render the login button Continue with Google ```
The auth_google_login() function generates:
- A random state parameter (CSRF protection)
- A code_verifier and code_challenge (PKCE)
- The complete authorization URL with scopes, redirect URI, and response type
// app/auth/google/callback.flin -- Step 2: Handle callbacklayout = "auth"
result = auth_google_callback(query.code, query.state, session.googleState) session.googleState = none
authOk = false
fn processGoogleAuth() { if result.ok { existing = User.where(email == result.user.email && role == "User").first if existing != none { session.user = existing.email session.userName = existing.name || result.user.name session.userId = to_text(existing.id) } else { newUser = User { email: result.user.email, name: result.user.name, provider: "Google", providerId: to_text(result.user.id), avatar: result.user.avatar || "", emailVerified: true } save newUser session.user = newUser.email session.userName = newUser.name session.userId = to_text(newUser.id) } authOk = true } } processGoogleAuth()
{if authOk}
Welcome!
{else}Authentication failed
Try again {/if} ```GitHub OAuth
GitHub follows the same pattern with two key differences: private email handling and username availability.
// app/login.flin
githubAuth = auth_github_login(baseUrl)
session.githubState = githubAuth.state
session.githubRedirectUri = githubAuth.redirect_uri
session.githubCodeVerifier = githubAuth.code_verifierGitHub users can hide their email address. The callback must handle this:
// app/auth/github/callback.flinresult = auth_github_callback(query.code, query.state, session.githubState) session.githubState = none
fn processGithubAuth() { if result.ok { // Handle private email githubEmail = result.user.email || to_text(result.user.id) + "@github.flin" githubName = result.user.name || result.user.login || "GitHub User"
existing = User.where(email == githubEmail && role == "User").first if existing != none { session.user = existing.email session.userName = existing.name || githubName session.userId = to_text(existing.id) } else { newUser = User { email: githubEmail, name: githubName, provider: "GitHub", providerId: to_text(result.user.id), avatar: result.user.avatar_url || "", emailVerified: result.user.email != none } save newUser session.user = newUser.email session.userName = newUser.name session.userId = to_text(newUser.id) } authOk = true } } ```
The fallback email {id}@github.flin ensures that users without a public email can still register. The emailVerified flag is set based on whether we got a real email from GitHub.
Discord OAuth
Discord uses standard OAuth2 with the identify email scopes:
// Setup
discordAuth = auth_discord_login(baseUrl)
session.discordState = discordAuth.state
session.discordRedirectUri = discordAuth.redirect_uri// Callback result = auth_discord_callback(query.code, query.state, session.discordState) session.discordState = none // ... same find-or-create pattern ```
Apple Sign-In
Apple Sign-In has unique requirements: it sends user data only on the first authentication, uses a POST callback instead of GET, and requires a client secret generated from a private key.
// Setup
appleAuth = auth_apple_login(baseUrl)
session.appleState = appleAuth.state// Callback (handles POST from Apple) result = auth_apple_callback(body.code, body.state, session.appleState) ```
FLIN handles Apple's quirks internally. The developer uses the same pattern as every other provider.
LinkedIn OAuth
LinkedIn uses OAuth 2.0 with the openid profile email scopes:
linkedinAuth = auth_linkedin_login(baseUrl)
session.linkedinState = linkedinAuth.state// Callback result = auth_linkedin_callback(query.code, query.state, session.linkedinState) ```
Telegram Login Widget
Telegram uses a different mechanism: a JavaScript login widget that sends a signed hash to your callback URL. FLIN verifies the hash using your bot token:
// Callback (Telegram sends data as query parameters)
result = auth_telegram_callback(query)fn processTelegramAuth() { if result.ok { telegramEmail = result.user.email || to_text(result.user.id) + "@telegram.flin" // ... same find-or-create pattern } } ```
The OAuth Result Object
Every auth_*_callback() function returns the same structure:
// Success
result.ok // true
result.user.id // Provider's user ID
result.user.email // Email (may be none for some providers)
result.user.name // Display name
result.user.avatar // Profile picture URL
result.user.provider // "google", "github", etc.// Failure result.ok // false result.error // Error description ```
This consistent interface means the find-or-create logic is identical for every provider. The only differences are in the session variable names and the login page setup.
Environment Variables
Each provider requires credentials from their developer console:
# .env
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=xxxGITHUB_CLIENT_ID=xxx GITHUB_CLIENT_SECRET=xxx
DISCORD_CLIENT_ID=xxx DISCORD_CLIENT_SECRET=xxx
APPLE_CLIENT_ID=com.example.app APPLE_TEAM_ID=xxx APPLE_KEY_ID=xxx APPLE_PRIVATE_KEY_PATH=.secrets/apple-auth-key.p8
LINKEDIN_CLIENT_ID=xxx LINKEDIN_CLIENT_SECRET=xxx
TELEGRAM_BOT_TOKEN=xxx:xxx ```
If a provider's credentials are not configured, the auth_*_login() function returns none and the login button should be hidden:
googleAuth = auth_google_login(baseUrl){if googleAuth != none} Continue with Google {/if} ```
Security: State and PKCE
Every OAuth flow in FLIN is protected by two mechanisms:
State parameter. A random string stored in the session and included in the authorization URL. The callback verifies that the state in the response matches the state in the session. This prevents CSRF attacks where an attacker tricks a user into authenticating with the attacker's account.
PKCE (Proof Key for Code Exchange). A random code verifier is generated and its SHA-256 hash (the code challenge) is sent with the authorization request. When exchanging the authorization code for tokens, the original code verifier is sent. The authorization server verifies that the hash matches. This prevents authorization code interception attacks.
fn generate_pkce() -> (String, String) {
let verifier = generate_random_string(64);
let challenge = base64url_encode(&sha256(verifier.as_bytes()));
(verifier, challenge)
}Both protections are generated and verified automatically by the auth__login() and auth__callback() functions. The developer stores the state and verifier in the session but never needs to understand why.
Why Built-In OAuth Matters
OAuth2 is a standard, but the implementation details vary wildly between providers. Google requires PKCE. Apple sends a POST. GitHub might not return an email. Discord uses different scope names. LinkedIn has deprecated multiple API versions.
By absorbing these differences into built-in functions, FLIN ensures that: 1. Every OAuth implementation follows the standard correctly. 2. Security measures (state, PKCE) are always applied. 3. Provider quirks are handled internally. 4. The developer interface is consistent across all providers.
The alternative -- installing a separate library for each provider, configuring each one differently, and hoping they all handle security correctly -- is exactly the kind of complexity that FLIN was designed to eliminate.
In the next article, we cover WhatsApp OTP authentication -- the authentication method designed specifically for the African market where phone-based identity is more common than email.
---
This is Part 111 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [110] Two-Factor Authentication (TOTP) - [111] OAuth2 and Social Authentication (you are here) - [112] WhatsApp OTP Authentication for Africa - [113] Request Body Validators