Module: Legion::Gaia::Channels::Teams::BotFrameworkAuth

Extended by:
Logging::Helper
Defined in:
lib/legion/gaia/channels/teams/bot_framework_auth.rb

Constant Summary collapse

OPENID_METADATA_URL =
'https://login.botframework.com/v1/.well-known/openidconfiguration'
BOT_FRAMEWORK_ISSUER =
'https://api.botframework.com'
EMULATOR_ISSUER =
'https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/'
JWKS_CACHE_TTL =
3600

Class Method Summary collapse

Class Method Details

.check_expiry(payload) ⇒ Object



138
139
140
141
142
143
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 138

def check_expiry(payload)
  now = Time.now.to_i
  return { valid: false, error: :token_expired } if payload['exp'] && payload['exp'].to_i < now

  { valid: false, error: :token_not_yet_valid }
end

.check_issuer(payload, _allow_emulator) ⇒ Object



152
153
154
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 152

def check_issuer(payload, _allow_emulator)
  { valid: false, error: :invalid_issuer, issuer: payload['iss'] }
end

.decode_base64url(segment) ⇒ Object



124
125
126
127
128
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 124

def decode_base64url(segment)
  remainder = segment.length % 4
  padded = remainder.zero? ? segment : segment + ('=' * (4 - remainder))
  Base64.urlsafe_decode64(padded)
end

.decode_jwt_segment(segment) ⇒ Object



69
70
71
72
73
74
75
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 69

def decode_jwt_segment(segment)
  decoded = decode_base64url(segment)
  ::JSON.parse(decoded)
rescue StandardError => e
  handle_exception(e, level: :debug, operation: 'gaia.channels.teams.bot_framework_auth.decode_jwt_segment')
  nil
end

.extract_identity(activity) ⇒ Object



58
59
60
61
62
63
64
65
66
67
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 58

def extract_identity(activity)
  from = activity['from'] || activity[:from] || {}
  {
    aad_object_id: from['aadObjectId'] || from[:aadObjectId],
    user_id: from['id'] || from[:id],
    user_name: from['name'] || from[:name],
    tenant_id: activity.dig('channelData', 'tenant', 'id') ||
      activity.dig(:channelData, :tenant, :id)
  }
end

.fetch_json(url) ⇒ Object



116
117
118
119
120
121
122
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 116

def fetch_json(url)
  uri = URI(url)
  response = Net::HTTP.get_response(uri)
  raise "HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)

  ::JSON.parse(response.body)
end

.issuer_valid?(payload, allow_emulator) ⇒ Boolean

Returns:

  • (Boolean)


145
146
147
148
149
150
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 145

def issuer_valid?(payload, allow_emulator)
  issuer = payload['iss']
  valid_issuers = [BOT_FRAMEWORK_ISSUER]
  valid_issuers << EMULATOR_ISSUER if allow_emulator
  valid_issuers.any? { |i| issuer&.start_with?(i) || issuer == i }
end

.jwks_keysObject



104
105
106
107
108
109
110
111
112
113
114
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 104

def jwks_keys
  now = Time.now.to_i
  cache = @jwks_cache
  return cache[:keys] if cache && cache[:expires_at] > now

   = fetch_json(OPENID_METADATA_URL)
  jwks_uri = ['jwks_uri']
  keys = fetch_json(jwks_uri).fetch('keys', [])
  @jwks_cache = { keys: keys, expires_at: now + JWKS_CACHE_TTL }
  keys
end

.public_key_for(header) ⇒ Object



91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 91

def public_key_for(header)
  kid = header['kid']
  return nil if kid.to_s.empty?

  key = jwks_keys.find { |candidate| candidate['kid'] == kid }
  return nil unless key

  certificate = Array(key['x5c']).first
  return nil unless certificate

  OpenSSL::X509::Certificate.new(Base64.decode64(certificate)).public_key
end

.signature_valid?(header, parts) ⇒ Boolean

Returns:

  • (Boolean)


77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 77

def signature_valid?(header, parts)
  return false unless header['alg'] == 'RS256'

  public_key = public_key_for(header)
  return false unless public_key

  signature = decode_base64url(parts[2])
  signing_input = parts[0, 2].join('.')
  public_key.verify(OpenSSL::Digest.new('SHA256'), signature, signing_input)
rescue StandardError => e
  handle_exception(e, level: :debug, operation: 'gaia.channels.teams.bot_framework_auth.signature_valid')
  false
end

.token_time_valid?(payload) ⇒ Boolean

Returns:

  • (Boolean)


130
131
132
133
134
135
136
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 130

def token_time_valid?(payload)
  now = Time.now.to_i
  return false if payload['exp'] && payload['exp'].to_i < now
  return false if payload['nbf'] && payload['nbf'].to_i > now + 300

  true
end

.validate_claims(payload, app_id:, allow_emulator: false) ⇒ Object



50
51
52
53
54
55
56
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 50

def validate_claims(payload, app_id:, allow_emulator: false)
  return check_expiry(payload) unless token_time_valid?(payload)
  return check_issuer(payload, allow_emulator) unless issuer_valid?(payload, allow_emulator)
  return { valid: false, error: :invalid_audience, audience: payload['aud'] } unless payload['aud'] == app_id

  { valid: true }
end

.validate_token(token, app_id:, allow_emulator: false) ⇒ Object



24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# File 'lib/legion/gaia/channels/teams/bot_framework_auth.rb', line 24

def validate_token(token, app_id:, allow_emulator: false)
  return { valid: false, error: :missing_token } if token.nil? || token.empty?

  parts = token.split('.')
  return { valid: false, error: :malformed_jwt } unless parts.size == 3

  header = decode_jwt_segment(parts[0])
  payload = decode_jwt_segment(parts[1])

  return { valid: false, error: :decode_failed } unless header && payload

  validation = validate_claims(payload, app_id: app_id, allow_emulator: allow_emulator)
  return validation unless validation[:valid]

  return { valid: false, error: :invalid_signature } unless signature_valid?(header, parts)

  {
    valid: true,
    claims: payload,
    entra_oid: payload['oid'],
    app_id: payload['appid'] || payload['azp'],
    tenant_id: payload['tid'],
    service_url: payload['serviceurl']
  }
end