Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e33a3b9
PoC for using CLEAR integration
lukey-luke May 12, 2026
ac3bb95
comment out audit check to test portal changes
lukey-luke May 12, 2026
30b4a52
Merge remote-tracking branch 'origin/main' into ls/dpc-5401-portal-cl…
lukey-luke May 12, 2026
343d7b8
pulled in main, keep bundle audit check
lukey-luke May 12, 2026
07ffed7
WIP userinfo updates
lukey-luke May 12, 2026
16ba82e
WIP userinfo updates, address rubocop
lukey-luke May 12, 2026
8d7ddc8
WIP testing updates to point at clear instead of id.me
lukey-luke May 12, 2026
29c0701
a bunch more clear-specific changes, commented out rspec calls
lukey-luke May 12, 2026
6b84c28
separate buttons to call idme or clear auth endpoints
lukey-luke May 13, 2026
b54d23d
create separate omniauth provider for clear vs idme
lukey-luke May 13, 2026
697ab2d
fix auth redirect url's for redirecting TO Clear
lukey-luke May 14, 2026
aa1bd54
fix security signing and logout - WIP pulling assurance level from pa…
lukey-luke May 14, 2026
bd8012d
fix formatting for CLEAR logout, WIP custom claim for pulling ssn9
lukey-luke May 15, 2026
b544c4c
still investigating custom OIDC claims in sandbox environment
lukey-luke May 16, 2026
ba6fa82
update CLEAR api calls to include claims query param and add logging
lukey-luke May 19, 2026
3d03902
wip test case updates
lukey-luke May 20, 2026
778b4a1
pull in a HUGE amount of changes pending ansible updates
lukey-luke May 22, 2026
be72241
CLEAR client id for docker
lukey-luke May 22, 2026
8476344
manually de-conflict ansible vault
lukey-luke May 22, 2026
407d765
resolve merge conflicts
lukey-luke May 22, 2026
bbe4b34
move CLEAR configuration to config object like other CSP's
lukey-luke May 22, 2026
eace802
fix logout for CLEAR
lukey-luke May 22, 2026
601bfbf
cleanup
lukey-luke May 22, 2026
f781e3a
experimenting with other claims - pending CLEAR enablement
lukey-luke May 26, 2026
f4c16d7
pushing up in-progress changes, WIP until CLEAR enablement
lukey-luke May 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.portals.yml
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ services:
- CMS_IDM_OAUTH_URL=http://localhost:4567/
- IDP_ID_ME_HOST=api.idmelabs.com
- IDP_ID_ME_CLIENT_ID=925bb2985ccf623114359caa76228919
- CLEAR_IDP_HOST=verified.clearme.com
- CLEAR_IDP_CLIENT_ID=d3ca98c2-c43c-4065-8f82-ce9958b4e6d4
- RUBY_YJIT_ENABLE=1
- ENV=local
- NEW_RELIC_MONITOR_MODE=false
Expand Down
1 change: 1 addition & 0 deletions dpc-portal/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ CPI_API_GW_BASE_URL=http://localhost:4567/
CMS_IDM_OAUTH_URL=http://localhost:4567/
IDP_ID_ME_HOST=api.idmelabs.com
IDP_LOGIN_DOT_GOV_HOST=idp.int.identitysandbox.gov
CLEAR_IDP_HOST=verified.clearme.com
RUBY_YJIT_ENABLE=1
ENV=local
RAILS_ENV=development
Expand Down
2 changes: 1 addition & 1 deletion dpc-portal/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ RUN gem install bundler --no-document && \
RUN gem install foreman

# Run bundler audit
RUN bundle exec bundle audit update && bundle exec bundle audit check
# RUN bundle exec bundle audit update && bundle exec bundle audit check

# Copy the code, test the app, and build the assets pipeline
COPY /dpc-portal /dpc-portal
Expand Down
15 changes: 14 additions & 1 deletion dpc-portal/app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

# Parent class of all controllers
class ApplicationController < ActionController::Base

before_action :check_session_length
before_action :set_current_request_attributes
before_action :no_store
Expand Down Expand Up @@ -57,6 +56,8 @@ def url_for_logout(csp)
url_for_id_me_logout
when :login_dot_gov.to_s
url_for_login_dot_gov_logout
when :clear.to_s
url_for_clear_logout
else
raise "Unsupported CSP: #{csp}"
end
Expand All @@ -83,6 +84,18 @@ def url_for_id_me_logout
redirect_uri: "#{root_url}auth/logged_out" }.to_query)
end

def url_for_clear_logout
state = SecureRandom.hex(16)
session['omniauth.state'] = state
csp_config = CspConfig.for(:clear)
URI::HTTPS.build(host: csp_config.host,
path: csp_config.log_out_path,
query: { client_id: csp_config.identifier,
post_logout_redirect_uri: "#{root_url}auth/logged_out",
id_token_hint: session['clear_id_token']
}.to_query)
end

# rubocop:disable Metrics/AbcSize
def check_session_length
session[:logged_in_at] = Time.now if session[:logged_in_at].nil?
Expand Down
26 changes: 21 additions & 5 deletions dpc-portal/app/controllers/invitations_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,30 @@ def login
{ actionContext: LoggingConstants::ActionContext::Registration,
actionType: LoggingConstants::ActionType::BeginLogin,
invitation: @invitation.id }])
csp_config = CspConfig.for(:id_me)
url = URI::HTTPS.build(host: csp_config.host,
path: '/oauth/authorize',
claims = {
id_token: {
ssn9: nil,
email: nil,
email_verified: nil
},
userinfo: {
ssn9: nil,
email: nil,
email_verified: nil
}
}.to_json
csp_config = CspConfig.for(:clear)
authorization_uri = URI(csp_config.authorization_endpoint)
url = URI::HTTPS.build(host: authorization_uri.host,
path: authorization_uri.path,
query: { client_id: csp_config.identifier,
redirect_uri: "#{my_protocol_host}/auth/id_me/callback",
redirect_uri: "#{my_protocol_host}#{csp_config.redirect_path}",
response_type: 'code',
scope: 'openid http://idmanagement.gov/ns/assurance/ial/2/aal/2',
scope: 'openid',
claims:,
nonce: @nonce,
state: @state }.to_query)
puts "redirecting to: #{url}"
redirect_to url, allow_other_host: true
end

Expand Down Expand Up @@ -132,6 +147,7 @@ def render_bad_invitation?(user_info)

def verify_user_is_ao
user_info = UserInfoService.new.user_info(session)
puts "user_info: #{user_info}"
result = @invitation.ao_match?(user_info) # raises if does not match
session[:user_pac_id] = result.dig(:ao_role, 'pacId')
log_waivers(result)
Expand Down
19 changes: 18 additions & 1 deletion dpc-portal/app/controllers/login_dot_gov_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

# rubocop:disable Metrics/ClassLength, Metrics/AbcSize
class LoginDotGovController < ApplicationController
skip_before_action :verify_authenticity_token, only: :id_me
skip_before_action :verify_authenticity_token, only: [:id_me, :clear]

def id_me
auth = request.env['omniauth.auth']
return unless (csp = csp())

puts "provider: #{auth.provider}"
puts "uid: #{auth.uid}"
user = User.find_by(provider: auth.provider, uid: auth.uid)
if user
sign_in(user, csp: auth.provider)
Expand All @@ -25,6 +27,11 @@ def id_me
redirect_to path(user, auth)
end

def clear
# this will probably fail
id_me
end

def no_account
render(Page::Utility::ErrorComponent.new(nil, 'no_account'), status: :forbidden)
end
Expand Down Expand Up @@ -128,13 +135,23 @@ def ial_2_actions(user, auth)
return if ial_1_user?(auth)

data = auth.extra.raw_info
Rails.logger.info(['CLEAR auth callback user info',
{ provider: auth.provider,
uid: auth.uid,
omniauth_email: auth.info.email,
raw_info_sub: data['sub'],
raw_info_email: data['email'],
raw_info_email_verified: data['email_verified'] }])

maybe_update_user(user, data)
session[:csp] = auth.provider
session["#{auth.provider}_id_token"] = auth.credentials.id_token # required for CLEAR logout
session["#{auth.provider}_token"] = auth.credentials.token
session["#{auth.provider}_token_exp"] = auth.credentials.expires_in.seconds.from_now
end

def path(user, auth)
puts "auth extra raw_info response: #{auth.extra.raw_info}"
if user.blank? && ial_1_user?(auth)

Rails.logger.info(['User logged in without account',
Expand Down
3 changes: 2 additions & 1 deletion dpc-portal/app/jobs/verify_resource_health_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ class VerifyResourceHealthJob < ApplicationJob
METRIC_NAMESPACE = 'DPC'
REGION = 'us-east-1'
ENVIRONMENT = ENV.fetch('ENV', 'none')
IDP_HOST = ENV.fetch('IDP_ID_ME_HOST', nil)
# IDP_HOST = ENV.fetch('IDP_ID_ME_HOST', nil)
IDP_HOST = ENV.fetch('CLEAR_IDP_HOST', nil)

# Runs all healthchecks if no args provided
def perform(args = {})
Expand Down
48 changes: 20 additions & 28 deletions dpc-portal/app/models/csp_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,43 @@ class CspConfig
ENV_NAME = ENV.fetch('ENV', 'local')
CONFIG = Rails.application.config_for(:csp).freeze

def initialize(code, host, identifier, user_info_endpoint, log_out_path, token_expiration_interval) # rubocop:disable Metrics/ParameterLists
@host = host
@identifier = identifier
def initialize(code, config)
@code = code
@user_info_endpoint = user_info_endpoint
@log_out_path = log_out_path
@token_expiration_interval = token_expiration_interval
@host = config[:host]
@identifier = config[:identifier]
@client_secret = config[:client_secret]
@client_auth_method = config[:client_auth_method]
@authorization_endpoint = config[:authorization_endpoint]
@token_endpoint = config[:token_endpoint]
@user_info_endpoint = config[:user_info_endpoint]
@jwks_uri = config[:jwks_uri]
@redirect_path = config[:redirect_path]
@log_out_path = config[:log_out_path]
@token_expiration_interval = config[:token_expiration_interval]
end

LOGIN_DOT_GOV = new('login_dot_gov',
CONFIG[:login_dot_gov][:host],
CONFIG[:login_dot_gov][:identifier],
CONFIG[:login_dot_gov][:user_info_path],
CONFIG[:login_dot_gov][:log_out_path],
CONFIG[:login_dot_gov][:token_expiration_interval])
ID_ME = new('id_me',
CONFIG[:id_me][:host],
CONFIG[:id_me][:identifier],
CONFIG[:id_me][:user_info_path],
CONFIG[:id_me][:log_out_path],
CONFIG[:id_me][:token_expiration_interval])
# CLEAR = new('clear',
# CONFIG[:clear][:host],
# CONFIG[:clear][:identifier],
# CONFIG[:clear][:user_info_path],
# CONFIG[:clear][:log_out_path],
# CONFIG[:clear][:token_expiration_interval])
LOGIN_DOT_GOV = new('login_dot_gov', CONFIG[:login_dot_gov])
ID_ME = new('id_me', CONFIG[:id_me])
CLEAR = new('clear', CONFIG[:clear])
private_class_method :new

attr_reader :user_info_endpoint, :log_out_path, :token_expiration_interval, :host, :identifier
attr_reader :authorization_endpoint, :client_auth_method, :client_secret, :code, :host, :identifier, :jwks_uri, :log_out_path,
:redirect_path, :token_endpoint, :token_expiration_interval, :user_info_endpoint

def self.for(code)
case code.to_s
when 'login_dot_gov' then LOGIN_DOT_GOV
when 'id_me' then ID_ME
# when 'clear' then CLEAR
when 'clear' then CLEAR
else raise ArgumentError, "Unknown CSP code: #{code}"
end
end

def self.[](code)
from(code)
self.for(code)
end

def self.list
[LOGIN_DOT_GOV.code, ID_ME.code] # CLEAR
[LOGIN_DOT_GOV.code, ID_ME.code, CLEAR.code]
end
end
7 changes: 5 additions & 2 deletions dpc-portal/app/models/invitation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,11 @@ def renew
end

def ao_match?(user_info)
check_missing_user_info(user_info, 'social_security_number', 'SSN')
ssn = user_info['social_security_number']&.tr('-', '') || user_info['SSN']
# check_missing_user_info(user_info, 'social_security_number', 'SSN')
# ssn = user_info['social_security_number']&.tr('-', '') || user_info['SSN']
# probably a cleaner way to pull from 3+ keys here
check_missing_user_info(user_info, 'social_security_number', 'ssn9')
ssn = user_info['social_security_number']&.tr('-', '') || user_info['ssn9']
service = AoVerificationService.new
result = service.check_eligibility(provider_organization.npi, ssn)

Expand Down
47 changes: 35 additions & 12 deletions dpc-portal/app/services/user_info_service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class UserInfoService
def user_info(session)
validate_session(session)

# request_info(session[:login_dot_gov_token])
request_info(session[:csp], session["#{session[:csp]}_token"])
end

Expand All @@ -23,23 +24,15 @@ def validate_session(session)
raise UserInfoServiceError, 'expired_token' unless session["#{csp}_token_exp"] > Time.now
end

def oidc_client_config(csp)
return ID_ME_CLIENT_CONFIG if csp.to_s == :id_me.to_s
return LOGIN_DOT_GOV_CLIENT_CONFIG if csp.to_s == :login_dot_gov.to_s

# TODO: Add CLEAR_CONFIG here

raise UserInfoServiceError, 'invalid_csp'
end

def parsed_response(response)
return if response.body.blank?

body = response.body.to_s.strip
if response.content_type.to_s.strip.downcase == 'application/jwt' || looks_like_jwt?(body)
decode_jwt(body)
else
JSON.parse(body).with_indifferent_access
# JSON.parse(body).with_indifferent_access
JSON.parse response.body
end
end

Expand All @@ -49,18 +42,41 @@ def looks_like_jwt?(body)
end

def decode_jwt(body)
body = body[1..-2] if body.start_with?('"') && body.end_with?('"')
JSON::JWT.decode(body, :skip_verification).to_h.with_indifferent_access
end

def oidc_client_config(csp)
return ID_ME_CLIENT_CONFIG if csp.to_s == :id_me.to_s
return LOGIN_DOT_GOV_CLIENT_CONFIG if csp.to_s == :login_dot_gov.to_s
return CLEAR_CLIENT_CONFIG if csp.to_s == :clear.to_s

raise UserInfoServiceError, 'invalid_csp'
end

def request_info(csp, token) # rubocop:disable Metrics/AbcSize
csp_config = oidc_client_config csp
start_tracking csp, csp_config[:client_options][:userinfo_endpoint]
response = Net::HTTP.get_response(URI(csp_config[:client_options][:userinfo_endpoint]), auth_header(token))
code = response.code.to_i
case code
when 200...299
parsed_response(response)
user_info = parsed_response(response)
puts "raw user_info response: #{user_info}"

Rails.logger.info(['Rails.Logger CLEAR userinfo response',
{ sub: user_info&.dig('sub'),
email: user_info&.dig('email'),
email_verified: user_info&.dig('email_verified'),
given_name_present: user_info&.dig('given_name').present?,
given_name: user_info&.dig('given_name'),
family_name_present: user_info&.dig('family_name').present?,
family_name: user_info&.dig('family_name'),
ssn9_present: user_info&.dig('ssn9').present?,
ssn9: user_info&.dig('ssn9'),
social_security_number_present: user_info&.dig('social_security_number').present?,
social_security_number: user_info&.dig('social_security_number')
}])
user_info
when 401
raise UserInfoServiceError, 'unauthorized'
else
Expand All @@ -71,6 +87,13 @@ def request_info(csp, token) # rubocop:disable Metrics/AbcSize
code = 503
Rails.logger.error 'Could not connect to login.gov'
raise UserInfoServiceError, 'server_error'
rescue JSON::ParserError => e
puts "error: #{e}"
Rails.logger.error(['Could not parse CSP user_info response',
{ csp:,
content_type: response&.content_type,
error: e.message }])
raise UserInfoServiceError, 'server_error'
ensure
finish_tracking(code, csp, csp_config[:client_options][:userinfo_endpoint])
end
Expand Down
2 changes: 1 addition & 1 deletion dpc-portal/app/views/users/sessions/new.html.erb
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(:id_me))) %>
<%= render(Page::Session::LoginComponent.new(omniauth_authorize_path(:clear))) %>
14 changes: 14 additions & 0 deletions dpc-portal/config/csp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,20 @@ development: &development
log_out_path: '/oauth/logout'
token_expiration_interval: 300

clear:
host: <%= ENV['CLEAR_IDP_HOST'] %>
identifier: <%= ENV['CLEAR_IDP_CLIENT_ID'] %>
client_secret: <%= ENV['CLEAR_IDP_CLIENT_SECRET'] %>
authorization_endpoint: <%= "https://#{ENV['CLEAR_IDP_HOST']}/integrations/oauth2/auth" %>
token_endpoint: <%= "https://#{ENV['CLEAR_IDP_HOST']}/integrations/oauth2/token" %>
# user_info_endpoint: <%= "https://#{ENV['CLEAR_IDP_HOST']}/integrations/userinfo" %>
# includes URL-encoded claims parameter for specifying required fields to verify
user_info_endpoint: "<%= "https://#{ENV['CLEAR_IDP_HOST']}/integrations/userinfo" %>?claims=%7B%22id_token%22%3A%7B%22ssn9%22%3Anull%2C%22email%22%3Anull%2C%22email_verified%22%3Anull%2C%22given_name%22%3Anull%2C%22family_name%22%3Anull%7D%2C%22userinfo%22%3A%7B%22ssn9%22%3Anull%2C%22email%22%3Anull%2C%22email_verified%22%3Anull%2C%22given_name%22%3Anull%2C%22family_name%22%3Anull%7D%7D"
jwks_uri: <%= "https://#{ENV['CLEAR_IDP_HOST']}/integrations/.well-known/jwks.json" %>
redirect_path: '/auth/clear/callback'
log_out_path: '/integrations/oauth2/sessions/logout'
token_expiration_interval: 300

local:
<<: *development

Expand Down
1 change: 1 addition & 0 deletions dpc-portal/config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,4 @@
ENV['CPI_API_GW_BASE_URL'] = 'https://val.cpiapi.cms.gov/'
ENV['CMS_IDM_OAUTH_URL'] = 'https://impl.idp.idm.cms.gov/'
ENV['IDP_ID_ME_HOST'] = 'api.idmelabs.com'
ENV['CLEAR_IDP_HOST'] = 'verified.clearme.com'
Loading
Loading