---
title: "Sign in with your LlamaPress.ai Account (SSO)"
slug: sign-in-with-llamapress-sso
category: Auth
summary: Put the "Sign in with your LlamaPress.ai account" button on your Devise sign-in page so the box owner signs in with their llamapress.ai identity instead of a password — one view line, plus the user-provisioning step the gem does not do for you.
tags: [devise, auth, sso, unified-login, llamapress]
status: stable
visibility: public
source_project: qa-candidate.leo.llamapress.ai
layers: [view, controller]
related:
  - title: One-Click Demo Sign-In Button (Devise)
    url: /cookbook/one-click-demo-sign-in
    summary: The other Devise sign-in page upgrade — a shared demo account behind one button.
  - title: Password Show/Hide Toggle
    url: /cookbook/password-show-hide-toggle
    summary: Keep the password form usable for the people who still sign in with one.
---

# Sign in with your LlamaPress.ai Account (SSO)

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

Your app ships with a Devise sign-in page. The owner of this box already has a
llamapress.ai account. This pattern adds one button — **"Sign in with your LlamaPress.ai
account"** — that signs them into your Rails app using that account. They never type an
app password. No password is stored for them, and no secret is stored in your app.

The platform does the hard part. LlamaPress.ai mints a short-lived, single-use **login
grant**, your box redeems it server-to-server, and the `llama_bot_rails` gem signs the
matching Devise user in. Your job is **two things**: render the button, and make sure a
Devise user exists that the grant can map to.

> **When to use:** the box owner (and the teammates LlamaPress authorizes on this
> instance) are the people who sign in — internal tools, admin apps, client dashboards
> you operate.
> **When not to:** your app's own end users. This button only signs in a LlamaPress.ai
> user who is **authorized on this instance** (owner, LlamaPress admin, or assigned
> operator). It is not a public "Sign in with Google"-style identity provider for your
> customers. Keep the password form for them.

---

## Before you build: five things must be true

This feature spans your app, the box's LlamaBot runtime, and the mothership. Check all
five first — four are on the box, one is not yours to change.

| # | Requirement | How to check |
|---|---|---|
| 1 | **LlamaBot ≥ 0.6.0** on this box (it redeems the grant) | `grep 'kody06/llamabot' docker-compose.yml` |
| 2 | **llamapress-simple ≥ 0.6.4** (ships `LlamaBotRails::SsoHelper`) | `docker compose exec -T llamapress ls /rails/vendor/llama_bot_rails/app/helpers/llama_bot_rails/sso_helper.rb` |
| 3 | **`users.llamapress_user_guid` column exists** | `bin/rails runner 'puts User.column_names.grep(/guid/).inspect'` |
| 4 | **`MOTHERSHIP_URL` + `MOTHERSHIP_INSTANCE_NAME` in the Rails container** | `docker compose exec -T llamapress env \| grep MOTHERSHIP_` |
| 5 | **LlamaPress.ai has SSO enabled for this box** — you cannot do this from the box | Ask your LlamaPress contact. Symptom if it is off: the button works but lands you back on the sign-in page. |

Requirement 5 is a platform setting (`unified_login_mode`), not a box setting. It is
either `canary` with your box's name on a list, or `version_gated` (every box that meets
requirement 1). If it is off for your box, **build the rest anyway** — the button falls
back to the plain sign-in page, which is exactly today's behavior, and it starts working
the moment the platform enables the box.

---

## The 80/20 in one breath

1. Check the five requirements above.
2. Add **one line** to `app/views/devise/sessions/new.html.erb` that renders the CTA.
3. Make sure the Devise user carries the owner's `llamapress_user_guid` — either ask
   LlamaPress to provision it, or install the resolver initializer in Layer 2.
4. Restart the Rails container **only if you added the initializer** (initializers do not
   hot-reload; views do).
5. Load `/users/sign_in` and confirm the button renders with an `href` ending in
   `/sso/leo/<your-instance-name>`.

---

## Layer 1 — The view (one line)

The button's markup, URL, and frame-detection script all live in the gem, so your view
holds a single call. The `respond_to?` guard matters: on an older image the constant
exists without the method, and an unguarded call raises a **500 on your sign-in page** —
the one page that must never break.

```erb
<%# app/views/devise/sessions/new.html.erb — put this ABOVE your password form %>
<%= LlamaBotRails::SsoHelper.respond_to?(:cta_html) ? LlamaBotRails::SsoHelper.cta_html : "" %>

<%# ...your existing Devise email/password form stays exactly as it is... %>
```

That renders a link to `https://llamapress.ai/sso/leo/<instance_name>` with
`target="_top"`, plus a small script that appends `surface=rails_app` when the page is
**not** inside the chat's preview iframe. That parameter is what sends the user back to
your Rails app after login instead of to the chat.

**Use the helper method, not a partial.** Boxes run Rails in `development` mode, where an
engine's view paths load lazily — a `render "llama_bot_rails/..."` is unresolvable for the
first several requests after a restart or a wake, so the button would silently vanish
exactly when someone is trying to sign in. A class method autoloads on request one.

Want your own styling? Build the link yourself from the same URL, and keep the
frame-detection script:

```erb
<%# app/views/devise/sessions/new.html.erb — custom-styled variant %>
<% sso_url = LlamaBotRails::SsoHelper.respond_to?(:sign_in_url) && LlamaBotRails::SsoHelper.sign_in_url %>
<% if sso_url.present? %>
  <a id="lp-sso-cta" target="_top" href="<%= sso_url %>" class="btn btn-primary w-full">
    Sign in with your LlamaPress.ai account
  </a>
  <script>
    (function () {
      if (window.self === window.top) {
        var a = document.getElementById("lp-sso-cta");
        if (a) a.href += (a.href.indexOf("?") === -1 ? "?" : "&") + "surface=rails_app";
      }
    })();
  </script>
<% end %>
```

`sign_in_url` returns `nil` when `MOTHERSHIP_URL` or `MOTHERSHIP_INSTANCE_NAME` is
missing, so a self-hosted copy of your app renders the password form alone. Do not
hard-code the URL — a restored or renamed box would keep pointing at the old name.

---

## Layer 2 — The user the grant maps to (the step everyone misses)

The grant identifies the person by a stable **GUID**, never by email. The gem's default
resolver **only finds a Devise user that already carries that GUID in
`llamapress_user_guid`. It never creates one, and it never matches on email.** On a fresh
box every row has `NULL` there, so the first sign-in redeems the grant, finds nobody, and
quietly drops the user back on the sign-in page.

You have two ways to fix it. Pick one.

### Option A — ask LlamaPress to provision (no code)

LlamaPress can stamp the GUID onto your Devise user from the admin side ("Provision SSO
users" on the instance page). This links the box's authorized users and needs nothing in
your app. Best when the app has a small, fixed set of operators.

### Option B — create-or-link on first sign-in (an initializer)

Override the resolver so the first successful SSO signs the person in and links them.
Drop the file in `config/initializers/custom/` — that directory is bind-mounted into the
container on current images, so your file is actually loaded.

```ruby
# config/initializers/custom/unified_login_provisioning.rb
#
# Create-or-link the Devise user on first SSO sign-in.
# Identity is the GUID. Email is used ONCE, only to adopt a user that has no GUID yet.
LlamaBotRails.guid_user_resolver = ->(guid, payload) do
  begin
    next nil if guid.blank?

    user_class = Devise.mappings[Devise.default_scope].to
    next nil unless user_class.column_names.include?("llamapress_user_guid")

    found = user_class.find_by(llamapress_user_guid: guid)
    next found if found

    user_payload = payload["user"] || payload[:user] || {}
    email = (user_payload["email"] || user_payload[:email]).to_s.strip.downcase
    next nil if email.empty?

    # One-time adoption of a hand-created account. GUID-less rows only.
    claimable = user_class.where(llamapress_user_guid: nil).find_by(email: email)
    if claimable
      claimable.update_column(:llamapress_user_guid, guid)
      Rails.logger.info("[unified_login] linked existing user id=#{claimable.id}")
      next claimable
    end

    created = user_class.create!(
      email: email,
      password: SecureRandom.hex(24),   # unusable by design; they sign in via SSO
      llamapress_user_guid: guid
    )
    Rails.logger.info("[unified_login] provisioned new user id=#{created.id}")
    created
  rescue => e
    Rails.logger.error("[unified_login] provisioning failed: #{e.class}: #{e.message}")
    nil   # never raise — a nil here degrades to the normal sign-in page
  end
end
```

Then restart Rails, because initializers do not hot-reload:

```bash
docker compose restart llamapress
```

**Read the email rule before you change it.** Only a row with **no** GUID may be adopted
by email, exactly once. After that the GUID wins forever. Matching a GUID-carrying row by
email would let a recycled address take over an existing account.

If your app has extra required columns on `User` (a name, a role, an
organization), add them to the `create!` — otherwise the create raises, the rescue
returns `nil`, and the user lands on the sign-in page with only a log line to show for it.

---

## How the flow actually runs

```
Browser                     llamapress.ai                     LlamaBot (box)        Your Rails app
  | click the CTA                |                                  |                    |
  |----------------------------->|  is this user authorized on       |                    |
  |                              |  this instance? is SSO on?        |                    |
  |                              |  -> mint grant (5 min, single use)|                    |
  |  302 rails-<box>/llamapress_auth/consume?token=RAW&return_to=/   |                    |
  |---------------------------------------------------------------------------------->|  |
  |                              |     redeem the grant, server-to-server (no secret in Rails)
  |                              |<---------------------------------|<-------------------|
  |                              |  {user: {guid, email, name}, role}------------------->|
  |                              |                                  |  guid_user_resolver -> User
  |                              |                                  |  warden sign-in + session cookie
  |  302 /  — signed in          |                                  |                    |
  |<----------------------------------------------------------------------------------|  |
```

Your app never holds a mothership credential. It receives a token in a URL, hands it to
LlamaBot, and gets back a verified identity.

---

## Gotchas (the hard-won stuff)

- **"It redirects successfully and I am still logged out."** This is the default resolver
  finding nobody. The proof is one line in `docker compose logs llamapress`:
  `[LlamaBot] unified_login consume: no host user for guid="..." — degrading to /`.
  The HTTP response is a normal `302` with no error — nothing else tells you. Fix it with
  Layer 2.
- **The endpoint is never allowed to be a wall.** Every failure — bad token, expired
  token, no user — redirects to `return_to` instead of erroring. That is deliberate, and
  it means **silent** failure is the normal failure. Debug from the logs, never from the
  status code.
- **The button renders but bounces back to sign-in.** The box is not enabled for SSO on
  the platform (requirement 5) or its LlamaBot is below 0.6.0. The grant is never minted,
  and the fallback is your plain app URL. Nothing is logged on the box — this failure is
  invisible from your side.
- **Initializers do not hot-reload; views do.** Editing the sign-in view is live
  immediately. Adding or editing the resolver initializer needs
  `docker compose restart llamapress`. Verify by rendering, not by reading the file.
- **Only `config/initializers/custom/` is a safe place for a new initializer.** The
  container mounts specific initializer files plus that one directory. An initializer you
  create anywhere else in `config/initializers/` exists on disk, greps fine, and is
  **never loaded**.
- **Grants last 5 minutes and are single-use.** A refreshed or bookmarked consume URL will
  not work twice, by design. Do not build retry logic around it — send the user back to
  the CTA, which mints a fresh grant.
- **`return_to` must be a path, not a URL.** The gem rejects anything absolute,
  protocol-relative, or scheme-bearing and falls back to `/`. This is an open-redirect
  guard; do not work around it.
- **Two sign-in pages exist on a box, fed by different config.** The chat's own login page
  reads `.leonardo/instance.json`; your Rails page reads the `MOTHERSHIP_*` environment
  variables. The Rails button can work while the chat button is missing, and the reverse.
- **Do not remove the password form.** Nothing about the app's password login changes, and
  it is your way back in when SSO is unavailable — a sleeping box, a platform setting, an
  expired grant.
- **Custom domains are out of scope.** The flow assumes the box's own
  `rails-<name>.…llamapress.ai` host. On a customer domain inside the chat iframe,
  third-party cookie rules break the session.

---

## Files this pattern touches

```
app/views/devise/sessions/new.html.erb                     # the CTA (one line)
config/initializers/custom/unified_login_provisioning.rb   # optional: create-or-link on first SSO
```

Everything else — the route `/llamapress_auth/consume`, the redemption call, the sign-in
itself — ships inside the `llama_bot_rails` gem. Do not re-implement any of it, and do not
add your own route at that path.

---

## Verify it

Run these on the box, in order. Each one answers a different question.

```bash
# 1. Does the button render, and does it point at THIS instance?
curl -s http://127.0.0.1:3000/users/sign_in | grep -o 'href="[^"]*sso/leo[^"]*"'

# 2. Does the redemption endpoint exist?
docker compose exec -T llamapress bin/rails runner \
  'puts Rails.application.routes.routes.map { |r| r.path.spec.to_s }.grep(/llamapress_auth/).inspect'

# 3. Is anyone actually linked yet? (all-nil means the first sign-in will fail)
docker compose exec -T llamapress bin/rails runner \
  'puts User.pluck(:id, :email, :llamapress_user_guid).inspect'

# 4. Click the button in a browser, then read what happened:
docker compose logs --tail=50 llamapress | grep -i unified_login
```

A signed-in session looks like this: the consume request returns `302` **and** sets a
session cookie, and `/users/sign_in` then redirects away instead of rendering the form.

---

## How to adapt to your schema

1. **Different Devise scope** (`Admin` instead of `User`): the resolver reads
   `Devise.mappings[Devise.default_scope].to`, so it follows your default scope. If SSO
   should sign in a non-default scope, name that class directly in the resolver.
2. **No `llamapress_user_guid` column**: add it before anything else —
   `add_column :users, :llamapress_user_guid, :string` plus a unique index where the value
   is not null. Without the column the resolver logs a warning and gives up.
3. **Extra required columns on your user**: add them to the `create!`, and give them safe
   defaults. A validation failure here reads to the user as "SSO does not work".
4. **Roles**: the verified payload carries a `role` (`owner`, `admin`, `operator`,
   `member`). Map it to your own roles inside the resolver if your app has them. Trust it
   — it comes from the platform, not the browser.
5. **Safe to drop**: the custom-styled variant in Layer 1 (use `cta_html` and take the
   default look), and Option B entirely if LlamaPress provisions your users for you.
