Back to flin
flin

OAuth2 and Social Authentication

How FLIN provides built-in OAuth2 functions for Google, GitHub, Discord, Apple, LinkedIn, and Telegram -- PKCE flows, state validation, and user creation in a standardized pattern.

Thales & Claude | March 25, 2026 7 min flin
flinoauth2social-authgooglegithub

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:

flin// app/login.flin -- Step 1: Generate auth URL

baseUrl = 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
<a href={googleAuth.url} class="social-btn">
    Continue with Google
</a>

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

flin// app/auth/google/callback.flin -- Step 2: Handle callback

layout = "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}
    <h2>Welcome!</h2>
    <script>setTimeout(function() { window.location.href = "/tasks"; }, 1000);</script>
{else}
    <h2>Authentication failed</h2>
    <a href="/login">Try again</a>
{/if}

GitHub OAuth

GitHub follows the same pattern with two key differences: private email handling and username availability.

flin// app/login.flin
githubAuth = auth_github_login(baseUrl)
session.githubState = githubAuth.state
session.githubRedirectUri = githubAuth.redirect_uri
session.githubCodeVerifier = githubAuth.code_verifier

<a href={githubAuth.url} class="social-btn">
    Continue with GitHub
</a>

GitHub users can hide their email address. The callback must handle this:

flin// app/auth/github/callback.flin

result = 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:

flin// Setup
discordAuth = auth_discord_login(baseUrl)
session.discordState = discordAuth.state
session.discordRedirectUri = discordAuth.redirect_uri

<a href={discordAuth.url} class="social-btn">
    Continue with Discord
</a>

// 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.

flin// Setup
appleAuth = auth_apple_login(baseUrl)
session.appleState = appleAuth.state

<a href={appleAuth.url} class="social-btn">
    Continue with Apple
</a>

// 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:

flinlinkedinAuth = auth_linkedin_login(baseUrl)
session.linkedinState = linkedinAuth.state

<a href={linkedinAuth.url} class="social-btn">
    Continue with LinkedIn
</a>

// 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:

flin// 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:

flin// 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=xxx

GITHUB_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:

flingoogleAuth = auth_google_login(baseUrl)

{if googleAuth != none}
    <a href={googleAuth.url}>Continue with Google</a>
{/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.

rustfn 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_<em>_login() and auth_</em>_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

Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles