Fireauth

Firebase Auth helpers for Elixir apps:

Install

Add to your mix.exs:

{:fireauth, "~> 0.7.0"},

You can also feed the LLM_SETUP.md file into your agent to automate setup.

Configuration

Project ID (required)

config :fireauth, firebase_project_id: "your-project-id"

Or via env var: FIREBASE_PROJECT_ID.

API Key (required for server-owned OAuth and email-link flows)

config :fireauth, firebase_web_config: %{"apiKey" => "AIza..."}

Or via env var: FIREBASE_API_KEY.

Admin Service Account (required for session cookies)

config :fireauth, firebase_admin_service_account: %{
  "client_email" => "firebase-adminsdk-...@your-project.iam.gserviceaccount.com",
  "private_key" => "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "project_id" => "your-project-id"
}

Or via env var (JSON or base64-encoded JSON): FIREBASE_ADMIN_SERVICE_ACCOUNT.

Auth Flows

1) Popup Flow

The simplest integration. The client signs in with the Firebase JS SDK popup, obtains an ID token, and sends it to your backend. No hosted auth files needed.

Option A: Bearer header — client sends Authorization: Bearer <idToken> on each request. Stateless, no admin credentials required.

defmodule MyApp.Router do
  use Plug.Router

  plug :match
  plug Fireauth.Plug, on_invalid_token: :unauthorized
  plug :dispatch

  get "/protected" do
    case conn.assigns[:fireauth] do
      %{user: user} -> send_resp(conn, 200, "Hello #{user.email}")
      _ -> send_resp(conn, 401, "unauthorized")
    end
  end
end

Option B: Session cookie — client POSTs the ID token once, backend mints an httpOnly cookie. Better for Phoenix/LiveView. Requires admin credentials.

# Mount session endpoints (outside your :browser pipeline to avoid Phoenix CSRF conflicts)
forward "/auth/firebase",
  to: Fireauth.Plug.SessionRouter,
  init_opts: [cookie_secure: false]  # true in production

# Verify session cookie on every request
plug Fireauth.Plug.SessionCookie, on_invalid_cookie: :unauthorized

The client exchanges its ID token for a session cookie:

// After signInWithPopup succeeds:
const idToken = await user.getIdToken();
const csrf = await fetch("/auth/firebase/csrf").then(r => r.json());
await fetch("/auth/firebase/session", {
  method: "POST",
  headers: { "content-type": "application/json", "x-csrf-token": csrf.csrfToken },
  body: JSON.stringify({ idToken, csrfToken: csrf.csrfToken })
});

2) Redirect Flow

Uses Firebase's signInWithRedirect. Requires serving Firebase's hosted auth files from your domain (modern browsers block third-party cookies). Fireauth provides Fireauth.Snippets.client/1 to wire the client-side start/verify flow without a bundler.

Endpoint setup — serve hosted auth files before your router:

# In your Endpoint (before the router)
plug Fireauth.Plug,
  callback_overrides: %{
    "/__/auth/handler" => Fireauth.HostedController,
    "/__/auth/iframe" => Fireauth.HostedController,
    "/__/auth/handler.js" => Fireauth.HostedController,
    "/__/auth/iframe.js" => Fireauth.HostedController,
    "/__/auth/experiments.js" => Fireauth.HostedController,
    "/__/firebase/init.json" => Fireauth.ProxyController
  }

Session endpoints + cookie verification (same as popup Option B):

forward "/auth/firebase",
  to: Fireauth.Plug.SessionRouter,
  init_opts: [cookie_secure: false]

plug Fireauth.Plug.SessionCookie

Start page — embed the snippet and trigger redirect:

# In your template (HEEx)
{Fireauth.Snippets.client(return_to: @return_to, session_base: "/auth/firebase", debug: true)}

<script>
  fireauth.start(
    { provider: "google.com", ready: () => !!window.myFirebaseAuth },
    function (providerId) {
      const auth = firebase.auth.getAuth();
      return firebase.auth.signInWithRedirect(auth, new firebase.auth.GoogleAuthProvider());
    }
  )
  .error(s => console.warn("start error", s.code, s.message))
  .onStateChange(s => console.debug("state", s.stage));
</script>

Verify page — resolves the returning user and exchanges the token:

{Fireauth.Snippets.client(return_to: @return_to, session_base: "/auth/firebase")}

<script>
  fireauth.verify(
    { requireVerified: true, getAuth: () => firebase.auth.getAuth() },
    function (s) {
      if (s.type === "error") showError(s.message);
      if (s.loading) showLoading(s.message);
    }
  )
  .success(() => showLoading("Login successful. Redirecting..."))
  .error(s => showError(s.message));
</script>

3) Server-Owned OAuth Flow

The server drives the OAuth redirect directly via Identity Platform APIs. No Firebase JS SDK is needed for the auth flow itself — useful when you want full control over the login UX without Firebase's redirect/popup screens.

# 1. Start: build provider redirect URL
{:ok, start_result} =
  Fireauth.start_oauth_sign_in("google.com", "https://example.com/auth/callback/google",
    otp_app: :my_app
  )

# Redirect browser to the provider
redirect(conn, external: start_result.auth_uri)
# Save start_result.session_id (e.g. in the session) for the callback
# 2. Callback: exchange the provider response for a Firebase ID token
{:ok, sign_in_result} =
  Fireauth.finish_oauth_sign_in(
    # The full callback URL including query params
    "https://example.com/auth/callback/google?code=abc",
    session_id,  # from step 1
    nil,
    otp_app: :my_app
  )

# 3. Optionally mint a session cookie
{:ok, session_cookie} =
  Fireauth.create_session_cookie(sign_in_result.firebase_id_token,
    otp_app: :my_app,
    valid_duration_s: 60 * 60 * 24 * 14
  )

finish_oauth_sign_in/4 returns a %Fireauth.ServerAuth.SignInResult{} with firebase_id_token, email, display_name, is_new_user, and more.

Helpers

Token & Session

# Verify a Firebase ID token (RS256)
{:ok, claims} = Fireauth.verify_id_token(id_token)

# Verify a Firebase session cookie
{:ok, claims} = Fireauth.verify_session_cookie(cookie)

# Exchange an ID token for a session cookie (requires admin service account)
{:ok, cookie} = Fireauth.create_session_cookie(id_token,
  valid_duration_s: 60 * 60 * 24 * 14  # max 14 days, default 5 days
)

User & Identity

# Convert claims to a user struct
user = Fireauth.claims_to_user_attrs(claims)
# => %Fireauth.User{firebase_uid: "...", email: "...", name: "...", ...}

# Check provider identities (works with claims, user, or %Fireauth{} struct)
Fireauth.has_identity?(user, "google.com")  # => true/false
Fireauth.identity(user, "google.com")       # => "google-uid" or nil
Fireauth.identities(user)                   # => %{"google.com" => ["..."], ...}

Server-Owned Auth

# Start a server-owned OAuth sign-in
{:ok, %Fireauth.ServerAuth.StartResult{auth_uri: url, session_id: sid}} =
  Fireauth.start_oauth_sign_in("google.com", callback_url, otp_app: :my_app)

# Finish the OAuth callback
{:ok, %Fireauth.ServerAuth.SignInResult{firebase_id_token: token}} =
  Fireauth.finish_oauth_sign_in(request_uri, session_id, post_body, otp_app: :my_app)

# Send an email-link sign-in email
{:ok, %Fireauth.EmailLinkSender.Result{email: email}} =
  Fireauth.send_email_sign_in_link("user@example.com", continue_url, otp_app: :my_app)

send_email_sign_in_link/3 sends the Firebase email-link sign-in email via Identity Toolkit (accounts:sendOobCode). Completing the sign-in still uses the Firebase Web SDK on your verify page via signInWithEmailLink(...).

Plugs

Plug Purpose
Fireauth.Plug Verifies Authorization: Bearer <idToken> and serves hosted auth files via callback_overrides
Fireauth.Plug.SessionRouter Mounts GET /csrf, POST /session, POST /logout endpoints for session cookie flow
Fireauth.Plug.SessionCookie Verifies the httpOnly session cookie and attaches conn.assigns.fireauth
Fireauth.Plug.FirebaseAuthProxy Transparent reverse proxy for Firebase hosted auth files

Fireauth.Plug options:

Fireauth.Plug.SessionRouter options:

Fireauth.Plug.SessionCookie options:

Snippets

Fireauth.Snippets provides HEEx-embeddable helpers (depends on phoenix_html, not phoenix):

Function Purpose
client(opts) Embeds the window.fireauth client API (start + verify)
hosted_auth_handler_bootstrap/0 Firebase bootstrap <script> tags for /__/auth/handler
hosted_auth_handler_document/0 Full HTML document for /__/auth/handler
hosted_auth_iframe_bootstrap/0 Firebase bootstrap <script> tags for /__/auth/iframe
hosted_auth_iframe_document/0 Full HTML document for /__/auth/iframe

client/1 options:

window.fireauth API:

Hosted Auth Routing

To support redirect-mode auth, serve Firebase's helper files from your domain using callback_overrides. Two controller options:

License

MIT


Appendix: Custom Auth Handler

Firebase's default /__/auth/handler page shows its own loading indicators and UI during the redirect flow. If you want to replace that with your own branded page, override the path with a {Module, :action} tuple in callback_overrides:

plug Fireauth.Plug,
  callback_overrides: %{
    "/__/auth/handler" => {MyAppWeb.FirebaseHostedAuthController, :handler},
    "/__/auth/handler.js" => Fireauth.ProxyController,
    "/__/firebase/init.json" => Fireauth.HostedController
  }

Your controller must include Fireauth.Snippets.hosted_auth_handler_bootstrap/0 to preserve Firebase's auth relay behavior. You can then hide Firebase's injected containers with CSS and render your own UI:

defmodule MyAppWeb.FirebaseHostedAuthController do
  use MyAppWeb, :controller

  def handler(conn, _params) do
    body = """
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      #{Fireauth.Snippets.hosted_auth_handler_bootstrap()}
      <style>
        /* Hide Firebase&#39;s injected UI */
        #pending-screen, #continue-screen, #error-screen,
        .firebase-container { display: none !important; }
      </style>
    </head>
    <body>
      <main>Completing authentication...</main>
    </body>
    </html>
    """

    conn
    |> put_resp_content_type("text/html")
    |> send_resp(200, body)
    |> halt()
  end
end

If you just want the default Firebase handler served from your own controller without customization:

def handler(conn, _params) do
  conn
  |> put_resp_content_type("text/html")
  |> send_resp(200, Fireauth.Snippets.hosted_auth_handler_document())
  |> halt()
end