Module: OpenLoam::Sso

Defined in:
lib/open_loam/sso.rb,
lib/open_loam/sso/claims.rb,
lib/open_loam/sso/http_client.rb,
lib/open_loam/sso/fake_provider.rb,
lib/open_loam/sso/oidc_provider.rb

Overview

Single sign-on. SHIPPED: OIDC Authorization Code flow end-to-end — home-realm discovery by email domain, just-in-time user provisioning, IdP group -> role mapping, and account linking to an existing User. SEAMS (documented, not built): SAML (another protocol behind the same interface, raises NotImplementedError until implemented) and SCIM 2.0 provisioning (see docs/_foundation/overview.md). See OpenLoam::SsoProvider for the per-tenant config.

A protocol provider implements two methods:

authorization_url(state:, login_hint: nil) -> the IdP URL to redirect to
exchange(code:)                             -> OpenLoam::Sso::Claims

The provider is built through a swappable builder so tests and the demo inject a FakeProvider and NOTHING touches the network. OpenLoam::Sso::OidcProvider is the real one; it is never constructed in the test suite.

Defined Under Namespace

Modules: HttpClient Classes: Claims, DomainMismatchError, Error, FakeProvider, OidcProvider, UnverifiedDomainError, UnverifiedEmailError

Class Attribute Summary collapse

Class Method Summary collapse

Class Attribute Details

.builder ⇒ Object



43
44
45
# File 'lib/open_loam/sso.rb', line 43

def builder
  @builder ||= method(:default_builder)
end

Class Method Details

.build(record, redirect_uri:) ⇒ Object

Wrap a OpenLoam::SsoProvider record in its protocol provider.



48
49
50
# File 'lib/open_loam/sso.rb', line 48

def build(record, redirect_uri:)
  builder.call(record, redirect_uri)
end

.default_builder(record, redirect_uri) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
# File 'lib/open_loam/sso.rb', line 52

def default_builder(record, redirect_uri)
  case record.protocol
  when "oidc"
    OidcProvider.new(record, redirect_uri: redirect_uri)
  when "saml"
    raise NotImplementedError,
          "SAML SSO is a documented seam — implement OpenLoam::Sso::SamlProvider behind this interface."
  else
    raise Error, "Unsupported SSO protocol: #{record.protocol.inspect}"
  end
end

.provider_for(email:) ⇒ Object

Home-realm discovery. THE blessed cross-tenant lookup for SSO (like OpenLoam::Membership.tenants_for): "which tenant's IdP owns this email domain?" is asked at the sign-in page, before any tenant is chosen, so it reaches across tenants via unscoped — something host code must never do.

Verified providers only: the domain is manager-typed, so an unverified claim would capture every sign-in on a domain the tenant does not own.



71
72
73
74
75
76
# File 'lib/open_loam/sso.rb', line 71

def provider_for(email:)
  domain = email.to_s.split("@").last.to_s.strip.downcase
  return nil if domain.blank?

  OpenLoam::SsoProvider.unscoped.domain_verified.where(domain: domain, active: true).first
end

.provision(provider, claims) ⇒ Object

Resolve verified IdP claims to a signed-in User, in the provider's tenant. MUST be called inside OpenLoam.as_tenant(provider.tenant). Order matters: match the durable (provider, sub) identity first, then an existing User by verified email (link), else just-in-time create — always with a tenant membership at the mapped role.



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# File 'lib/open_loam/sso.rb', line 83

def provision(provider, claims)
  raise UnverifiedEmailError, "the identity provider did not verify #{claims.email.inspect}" unless claims.email_verified?

  # The provider only vouches for its own domain. Check this BEFORE any
  # lookup, link, or create — an email on another domain is refused
  # outright (no session, nothing touched).
  email_domain = claims.email.to_s.split("@").last.to_s.downcase
  unless email_domain.present? && email_domain == provider.domain.to_s.downcase
    raise DomainMismatchError,
          "verified email domain #{email_domain.inspect} is not this provider's domain #{provider.domain.inspect}"
  end

  # Resolve the user: the durable (provider, sub) identity first, then an
  # existing User by verified email (link), else just-in-time create.
  identity = OpenLoam::SsoIdentity.find_by(sso_provider_id: provider.id, sub: claims.sub)
  user = identity&.user || link_or_create_user(provider, claims)

  # Re-map claims -> role on EVERY login (including a returning identity),
  # so an IdP role change takes effect.
  ensure_membership(provider, user, claims)
  OpenLoam::SsoIdentity.create!(user: user, sso_provider: provider, sub: claims.sub) unless identity
  user
end

.role_for(provider, claims) ⇒ Object

IdP group -> role; first configured group that the user carries wins, otherwise the provider's default role.



109
110
111
112
113
# File 'lib/open_loam/sso.rb', line 109

def role_for(provider, claims)
  map = provider.group_roles
  match = Array(claims.groups).map(&:to_s).find { |group| map.key?(group) }
  match ? map[match] : provider.jit_role
end