{"slug":"sign-in-with-llamapress-sso","meta":{"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."}]},"body":"# Sign in with your LlamaPress.ai Account (SSO)\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nYour app ships with a Devise sign-in page. The owner of this box already has a\nllamapress.ai account. This pattern adds one button — **\"Sign in with your LlamaPress.ai\naccount\"** — that signs them into your Rails app using that account. They never type an\napp password. No password is stored for them, and no secret is stored in your app.\n\nThe platform does the hard part. LlamaPress.ai mints a short-lived, single-use **login\ngrant**, your box redeems it server-to-server, and the `llama_bot_rails` gem signs the\nmatching Devise user in. Your job is **two things**: render the button, and make sure a\nDevise user exists that the grant can map to.\n\n\u003e **When to use:** the box owner (and the teammates LlamaPress authorizes on this\n\u003e instance) are the people who sign in — internal tools, admin apps, client dashboards\n\u003e you operate.\n\u003e **When not to:** your app's own end users. This button only signs in a LlamaPress.ai\n\u003e user who is **authorized on this instance** (owner, LlamaPress admin, or assigned\n\u003e operator). It is not a public \"Sign in with Google\"-style identity provider for your\n\u003e customers. Keep the password form for them.\n\n---\n\n## Before you build: five things must be true\n\nThis feature spans your app, the box's LlamaBot runtime, and the mothership. Check all\nfive first — four are on the box, one is not yours to change.\n\n| # | Requirement | How to check |\n|---|---|---|\n| 1 | **LlamaBot ≥ 0.6.0** on this box (it redeems the grant) | `grep 'kody06/llamabot' docker-compose.yml` |\n| 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` |\n| 3 | **`users.llamapress_user_guid` column exists** | `bin/rails runner 'puts User.column_names.grep(/guid/).inspect'` |\n| 4 | **`MOTHERSHIP_URL` + `MOTHERSHIP_INSTANCE_NAME` in the Rails container** | `docker compose exec -T llamapress env \\| grep MOTHERSHIP_` |\n| 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. |\n\nRequirement 5 is a platform setting (`unified_login_mode`), not a box setting. It is\neither `canary` with your box's name on a list, or `version_gated` (every box that meets\nrequirement 1). If it is off for your box, **build the rest anyway** — the button falls\nback to the plain sign-in page, which is exactly today's behavior, and it starts working\nthe moment the platform enables the box.\n\n---\n\n## The 80/20 in one breath\n\n1. Check the five requirements above.\n2. Add **one line** to `app/views/devise/sessions/new.html.erb` that renders the CTA.\n3. Make sure the Devise user carries the owner's `llamapress_user_guid` — either ask\n   LlamaPress to provision it, or install the resolver initializer in Layer 2.\n4. Restart the Rails container **only if you added the initializer** (initializers do not\n   hot-reload; views do).\n5. Load `/users/sign_in` and confirm the button renders with an `href` ending in\n   `/sso/leo/\u003cyour-instance-name\u003e`.\n\n---\n\n## Layer 1 — The view (one line)\n\nThe button's markup, URL, and frame-detection script all live in the gem, so your view\nholds a single call. The `respond_to?` guard matters: on an older image the constant\nexists without the method, and an unguarded call raises a **500 on your sign-in page** —\nthe one page that must never break.\n\n```erb\n\u003c%# app/views/devise/sessions/new.html.erb — put this ABOVE your password form %\u003e\n\u003c%= LlamaBotRails::SsoHelper.respond_to?(:cta_html) ? LlamaBotRails::SsoHelper.cta_html : \"\" %\u003e\n\n\u003c%# ...your existing Devise email/password form stays exactly as it is... %\u003e\n```\n\nThat renders a link to `https://llamapress.ai/sso/leo/\u003cinstance_name\u003e` with\n`target=\"_top\"`, plus a small script that appends `surface=rails_app` when the page is\n**not** inside the chat's preview iframe. That parameter is what sends the user back to\nyour Rails app after login instead of to the chat.\n\n**Use the helper method, not a partial.** Boxes run Rails in `development` mode, where an\nengine's view paths load lazily — a `render \"llama_bot_rails/...\"` is unresolvable for the\nfirst several requests after a restart or a wake, so the button would silently vanish\nexactly when someone is trying to sign in. A class method autoloads on request one.\n\nWant your own styling? Build the link yourself from the same URL, and keep the\nframe-detection script:\n\n```erb\n\u003c%# app/views/devise/sessions/new.html.erb — custom-styled variant %\u003e\n\u003c% sso_url = LlamaBotRails::SsoHelper.respond_to?(:sign_in_url) \u0026\u0026 LlamaBotRails::SsoHelper.sign_in_url %\u003e\n\u003c% if sso_url.present? %\u003e\n  \u003ca id=\"lp-sso-cta\" target=\"_top\" href=\"\u003c%= sso_url %\u003e\" class=\"btn btn-primary w-full\"\u003e\n    Sign in with your LlamaPress.ai account\n  \u003c/a\u003e\n  \u003cscript\u003e\n    (function () {\n      if (window.self === window.top) {\n        var a = document.getElementById(\"lp-sso-cta\");\n        if (a) a.href += (a.href.indexOf(\"?\") === -1 ? \"?\" : \"\u0026\") + \"surface=rails_app\";\n      }\n    })();\n  \u003c/script\u003e\n\u003c% end %\u003e\n```\n\n`sign_in_url` returns `nil` when `MOTHERSHIP_URL` or `MOTHERSHIP_INSTANCE_NAME` is\nmissing, so a self-hosted copy of your app renders the password form alone. Do not\nhard-code the URL — a restored or renamed box would keep pointing at the old name.\n\n---\n\n## Layer 2 — The user the grant maps to (the step everyone misses)\n\nThe grant identifies the person by a stable **GUID**, never by email. The gem's default\nresolver **only finds a Devise user that already carries that GUID in\n`llamapress_user_guid`. It never creates one, and it never matches on email.** On a fresh\nbox every row has `NULL` there, so the first sign-in redeems the grant, finds nobody, and\nquietly drops the user back on the sign-in page.\n\nYou have two ways to fix it. Pick one.\n\n### Option A — ask LlamaPress to provision (no code)\n\nLlamaPress can stamp the GUID onto your Devise user from the admin side (\"Provision SSO\nusers\" on the instance page). This links the box's authorized users and needs nothing in\nyour app. Best when the app has a small, fixed set of operators.\n\n### Option B — create-or-link on first sign-in (an initializer)\n\nOverride the resolver so the first successful SSO signs the person in and links them.\nDrop the file in `config/initializers/custom/` — that directory is bind-mounted into the\ncontainer on current images, so your file is actually loaded.\n\n```ruby\n# config/initializers/custom/unified_login_provisioning.rb\n#\n# Create-or-link the Devise user on first SSO sign-in.\n# Identity is the GUID. Email is used ONCE, only to adopt a user that has no GUID yet.\nLlamaBotRails.guid_user_resolver = -\u003e(guid, payload) do\n  begin\n    next nil if guid.blank?\n\n    user_class = Devise.mappings[Devise.default_scope].to\n    next nil unless user_class.column_names.include?(\"llamapress_user_guid\")\n\n    found = user_class.find_by(llamapress_user_guid: guid)\n    next found if found\n\n    user_payload = payload[\"user\"] || payload[:user] || {}\n    email = (user_payload[\"email\"] || user_payload[:email]).to_s.strip.downcase\n    next nil if email.empty?\n\n    # One-time adoption of a hand-created account. GUID-less rows only.\n    claimable = user_class.where(llamapress_user_guid: nil).find_by(email: email)\n    if claimable\n      claimable.update_column(:llamapress_user_guid, guid)\n      Rails.logger.info(\"[unified_login] linked existing user id=#{claimable.id}\")\n      next claimable\n    end\n\n    created = user_class.create!(\n      email: email,\n      password: SecureRandom.hex(24),   # unusable by design; they sign in via SSO\n      llamapress_user_guid: guid\n    )\n    Rails.logger.info(\"[unified_login] provisioned new user id=#{created.id}\")\n    created\n  rescue =\u003e e\n    Rails.logger.error(\"[unified_login] provisioning failed: #{e.class}: #{e.message}\")\n    nil   # never raise — a nil here degrades to the normal sign-in page\n  end\nend\n```\n\nThen restart Rails, because initializers do not hot-reload:\n\n```bash\ndocker compose restart llamapress\n```\n\n**Read the email rule before you change it.** Only a row with **no** GUID may be adopted\nby email, exactly once. After that the GUID wins forever. Matching a GUID-carrying row by\nemail would let a recycled address take over an existing account.\n\nIf your app has extra required columns on `User` (a name, a role, an\norganization), add them to the `create!` — otherwise the create raises, the rescue\nreturns `nil`, and the user lands on the sign-in page with only a log line to show for it.\n\n---\n\n## How the flow actually runs\n\n```\nBrowser                     llamapress.ai                     LlamaBot (box)        Your Rails app\n  | click the CTA                |                                  |                    |\n  |-----------------------------\u003e|  is this user authorized on       |                    |\n  |                              |  this instance? is SSO on?        |                    |\n  |                              |  -\u003e mint grant (5 min, single use)|                    |\n  |  302 rails-\u003cbox\u003e/llamapress_auth/consume?token=RAW\u0026return_to=/   |                    |\n  |----------------------------------------------------------------------------------\u003e|  |\n  |                              |     redeem the grant, server-to-server (no secret in Rails)\n  |                              |\u003c---------------------------------|\u003c-------------------|\n  |                              |  {user: {guid, email, name}, role}-------------------\u003e|\n  |                              |                                  |  guid_user_resolver -\u003e User\n  |                              |                                  |  warden sign-in + session cookie\n  |  302 /  — signed in          |                                  |                    |\n  |\u003c----------------------------------------------------------------------------------|  |\n```\n\nYour app never holds a mothership credential. It receives a token in a URL, hands it to\nLlamaBot, and gets back a verified identity.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **\"It redirects successfully and I am still logged out.\"** This is the default resolver\n  finding nobody. The proof is one line in `docker compose logs llamapress`:\n  `[LlamaBot] unified_login consume: no host user for guid=\"...\" — degrading to /`.\n  The HTTP response is a normal `302` with no error — nothing else tells you. Fix it with\n  Layer 2.\n- **The endpoint is never allowed to be a wall.** Every failure — bad token, expired\n  token, no user — redirects to `return_to` instead of erroring. That is deliberate, and\n  it means **silent** failure is the normal failure. Debug from the logs, never from the\n  status code.\n- **The button renders but bounces back to sign-in.** The box is not enabled for SSO on\n  the platform (requirement 5) or its LlamaBot is below 0.6.0. The grant is never minted,\n  and the fallback is your plain app URL. Nothing is logged on the box — this failure is\n  invisible from your side.\n- **Initializers do not hot-reload; views do.** Editing the sign-in view is live\n  immediately. Adding or editing the resolver initializer needs\n  `docker compose restart llamapress`. Verify by rendering, not by reading the file.\n- **Only `config/initializers/custom/` is a safe place for a new initializer.** The\n  container mounts specific initializer files plus that one directory. An initializer you\n  create anywhere else in `config/initializers/` exists on disk, greps fine, and is\n  **never loaded**.\n- **Grants last 5 minutes and are single-use.** A refreshed or bookmarked consume URL will\n  not work twice, by design. Do not build retry logic around it — send the user back to\n  the CTA, which mints a fresh grant.\n- **`return_to` must be a path, not a URL.** The gem rejects anything absolute,\n  protocol-relative, or scheme-bearing and falls back to `/`. This is an open-redirect\n  guard; do not work around it.\n- **Two sign-in pages exist on a box, fed by different config.** The chat's own login page\n  reads `.leonardo/instance.json`; your Rails page reads the `MOTHERSHIP_*` environment\n  variables. The Rails button can work while the chat button is missing, and the reverse.\n- **Do not remove the password form.** Nothing about the app's password login changes, and\n  it is your way back in when SSO is unavailable — a sleeping box, a platform setting, an\n  expired grant.\n- **Custom domains are out of scope.** The flow assumes the box's own\n  `rails-\u003cname\u003e.…llamapress.ai` host. On a customer domain inside the chat iframe,\n  third-party cookie rules break the session.\n\n---\n\n## Files this pattern touches\n\n```\napp/views/devise/sessions/new.html.erb                     # the CTA (one line)\nconfig/initializers/custom/unified_login_provisioning.rb   # optional: create-or-link on first SSO\n```\n\nEverything else — the route `/llamapress_auth/consume`, the redemption call, the sign-in\nitself — ships inside the `llama_bot_rails` gem. Do not re-implement any of it, and do not\nadd your own route at that path.\n\n---\n\n## Verify it\n\nRun these on the box, in order. Each one answers a different question.\n\n```bash\n# 1. Does the button render, and does it point at THIS instance?\ncurl -s http://127.0.0.1:3000/users/sign_in | grep -o 'href=\"[^\"]*sso/leo[^\"]*\"'\n\n# 2. Does the redemption endpoint exist?\ndocker compose exec -T llamapress bin/rails runner \\\n  'puts Rails.application.routes.routes.map { |r| r.path.spec.to_s }.grep(/llamapress_auth/).inspect'\n\n# 3. Is anyone actually linked yet? (all-nil means the first sign-in will fail)\ndocker compose exec -T llamapress bin/rails runner \\\n  'puts User.pluck(:id, :email, :llamapress_user_guid).inspect'\n\n# 4. Click the button in a browser, then read what happened:\ndocker compose logs --tail=50 llamapress | grep -i unified_login\n```\n\nA signed-in session looks like this: the consume request returns `302` **and** sets a\nsession cookie, and `/users/sign_in` then redirects away instead of rendering the form.\n\n---\n\n## How to adapt to your schema\n\n1. **Different Devise scope** (`Admin` instead of `User`): the resolver reads\n   `Devise.mappings[Devise.default_scope].to`, so it follows your default scope. If SSO\n   should sign in a non-default scope, name that class directly in the resolver.\n2. **No `llamapress_user_guid` column**: add it before anything else —\n   `add_column :users, :llamapress_user_guid, :string` plus a unique index where the value\n   is not null. Without the column the resolver logs a warning and gives up.\n3. **Extra required columns on your user**: add them to the `create!`, and give them safe\n   defaults. A validation failure here reads to the user as \"SSO does not work\".\n4. **Roles**: the verified payload carries a `role` (`owner`, `admin`, `operator`,\n   `member`). Map it to your own roles inside the resolver if your app has them. Trust it\n   — it comes from the platform, not the browser.\n5. **Safe to drop**: the custom-styled variant in Layer 1 (use `cta_html` and take the\n   default look), and Option B entirely if LlamaPress provisions your users for you.\n"}