Skip to content

Commit cc1c051

Browse files
committed
feat: add support for MS token exchange
1 parent 0902f1a commit cc1c051

File tree

4 files changed

+240
-11
lines changed

4 files changed

+240
-11
lines changed

shard.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ shards:
219219

220220
placeos-models:
221221
git: https://github.com/placeos/models.git
222-
version: 9.76.2
222+
version: 9.79.0
223223

224224
placeos-resource:
225225
git: https://github.com/place-labs/resource.git

src/placeos-rest-api/controllers/users.cr

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@ module PlaceOS::Api
104104
end
105105

106106
protected def get_user_token(current_user) : AccessToken
107+
authority = current_authority
108+
raise Error::NotFound.new("no valid authority") unless authority
109+
110+
self.class.get_user_token(current_user, authority)
111+
end
112+
113+
def self.get_user_token(current_user : Model::User, authority : Model::Authority) : AccessToken
107114
expired = true
108115

109116
if access_token = current_user.access_token.presence
@@ -121,8 +128,6 @@ module PlaceOS::Api
121128
end
122129

123130
raise Error::NotFound.new("no refresh token available") unless current_user.refresh_token.presence
124-
authority = current_authority
125-
raise Error::NotFound.new("no valid authority") unless authority
126131

127132
begin
128133
internals = authority.internals

src/placeos-rest-api/utilities/current-user.cr

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ require "placeos-models/user"
55
require "placeos-models/user_jwt"
66
require "placeos-models/api_key"
77

8+
require "./ms-token-exchange"
9+
810
module PlaceOS::Api
911
# Helper to grab user and authority from a request
1012

@@ -38,16 +40,36 @@ module PlaceOS::Api
3840
raise Error::Unauthorized.new unless token
3941

4042
begin
41-
user_token = ::PlaceOS::Model::UserJWT.decode(token)
42-
if !user_token.guest_scope? && (user_model = ::PlaceOS::Model::User.find(user_token.id))
43-
logged_out_at = user_model.logged_out_at
44-
if logged_out_at && (logged_out_at >= user_token.iat)
45-
raise JWT::Error.new("logged out")
43+
# peek the token to determine type
44+
token_info = Utils::MSTokenExchange.peek_token_info(token)
45+
if token_info.is_ms_token?
46+
user = Utils::MSTokenExchange.obtain_place_user(token, token_info)
47+
raise "MS token could not be exchanged" unless user
48+
@current_user = user
49+
@user_token = user_token = Model::UserJWT.new(
50+
iss: Model::UserJWT::ISSUER,
51+
iat: 5.minutes.ago,
52+
exp: 1.hour.from_now,
53+
domain: user.authority.as(Model::Authority).domain,
54+
id: user.id.as(String),
55+
user: Model::UserJWT::Metadata.new(
56+
name: user.name.as(String),
57+
email: user.email.to_s,
58+
# non admin permissions and no roles
59+
),
60+
)
61+
else
62+
user_token = ::PlaceOS::Model::UserJWT.decode(token)
63+
if !user_token.guest_scope? && (user_model = ::PlaceOS::Model::User.find(user_token.id))
64+
logged_out_at = user_model.logged_out_at
65+
if logged_out_at && (logged_out_at >= user_token.iat)
66+
raise JWT::Error.new("logged out")
67+
end
68+
@current_user = user_model
4669
end
47-
@current_user = user_model
48-
end
4970

50-
@user_token = user_token
71+
@user_token = user_token
72+
end
5173
rescue e : JWT::Error
5274
Log.warn(exception: e) { {message: "bearer invalid", action: "authorize!"} }
5375
# Request bearer was malformed
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
require "uri"
2+
require "jwt"
3+
require "jwt/jwks"
4+
require "placeos-models/authority"
5+
require "placeos-models/user"
6+
require "placeos-models/user_jwt"
7+
8+
module PlaceOS::Api
9+
# Helper to authenticate using an MS token
10+
# * check the token is valid
11+
module Utils::MSTokenExchange
12+
extend self
13+
14+
enum TokenVersion
15+
V1
16+
V2
17+
end
18+
19+
record PeekInfo,
20+
aud_raw : String,
21+
aud_host : String,
22+
email : String?,
23+
tid : String?,
24+
iss : String?,
25+
iss_host : String?,
26+
version : TokenVersion,
27+
kid : String? do
28+
# Basic heuristic to detect Microsoft Entra / Azure AD issuers
29+
def is_ms_token? : Bool
30+
iss_val = iss_host
31+
return false unless iss_val
32+
iss_val = iss_val.downcase
33+
iss_val.ends_with?("microsoftonline.com") ||
34+
iss_val.ends_with?("sts.windows.net") ||
35+
iss_val.ends_with?("login.windows.net") ||
36+
iss_val.ends_with?("login.chinacloudapi.cn") || # China cloud
37+
iss_val.ends_with?("login.microsoftonline.de") || # Germany
38+
iss_val.ends_with?("login.partner.microsoftonline.cn") || # 21V
39+
iss_val.ends_with?("login-us.microsoftonline.com") # GCC/DoD
40+
end
41+
42+
def token_endpoint : URI?
43+
case version
44+
in .v1?
45+
URI.parse("https://login.microsoftonline.com/#{tid}/oauth2/token")
46+
in .v2?
47+
URI.parse("https://login.microsoftonline.com/#{tid}/oauth2/v2.0/token")
48+
end
49+
end
50+
end
51+
52+
# ---------- Peek (safe decode, no signature validation) ----------
53+
54+
def peek_token_info(token : String) : PeekInfo
55+
payload, header = JWT.decode(token, verify: false, validate: false)
56+
57+
aud_raw = payload["aud"]?.try(&.as_s) || raise "missing aud"
58+
iss = payload["iss"]?.try(&.as_s) || raise "missing iss"
59+
email = payload["upn"]?.try(&.as_s)
60+
tid = payload["tid"]?.try(&.as_s)
61+
kid = header["kid"]?.try(&.as_s)
62+
63+
version = detect_token_version(payload, iss)
64+
aud_host = extract_aud_host(aud_raw)
65+
iss_host = extract_issuer_host(iss)
66+
67+
PeekInfo.new(
68+
aud_raw: aud_raw,
69+
aud_host: aud_host,
70+
email: email,
71+
tid: tid,
72+
iss: iss,
73+
iss_host: iss_host,
74+
version: version,
75+
kid: kid
76+
)
77+
end
78+
79+
# obtain MS Graph API token - this is a simple way to validate its authenticity
80+
def obtain_place_user(token : String, token_info : PeekInfo? = nil) : Model::User?
81+
info = token_info || peek_token_info(token)
82+
tenant = info.tid
83+
email = info.email
84+
return unless tenant && email
85+
oauth = Model::OAuthAuthentication.find_by?(client_id: info.aud_host)
86+
return unless oauth
87+
88+
# ensure Tenant ID matches our authentication source
89+
return unless oauth.token_url.includes?(tenant)
90+
91+
# validate the MS token
92+
payload = validate_token_with_jwks(token, token_info: info)
93+
94+
# find the place user or create a new one
95+
user = Model::User.find_by?(authority_id: oauth.authority_id, email: email.downcase) || create_place_user(oauth, payload)
96+
97+
# ensure there is a valid MS Graph API access token in place
98+
# as we maybe attempting to perform graph actions on behalf of the user
99+
ensure_valid_token(oauth, user, token, info)
100+
101+
# return the user
102+
user
103+
end
104+
105+
def create_place_user(oauth : Model::OAuthAuthentication, payload : JSON::Any) : Model::User
106+
Model::User.create!(
107+
name: payload["name"].as_s,
108+
last_name: payload["family_name"].as_s,
109+
first_name: payload["given_name"].as_s,
110+
email: Model::Email.new(payload["upn"].as_s),
111+
authority_id: oauth.authority_id
112+
)
113+
end
114+
115+
def ensure_valid_token(oauth : Model::OAuthAuthentication, user : Model::User, token : String, token_info : PeekInfo)
116+
# return if there is an existing token and valid
117+
existing = Api::Users.get_user_token(user, oauth.authority.as(Model::Authority)) rescue nil
118+
return if existing
119+
120+
# if not existing or refresh failed, get a token using this token and on behalf of
121+
# https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow#example
122+
form = URI::Params.build do |form|
123+
form.add "grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"
124+
form.add "client_id", oauth.client_id
125+
form.add "client_secret", oauth.client_secret
126+
form.add "assertion", token
127+
form.add "scope", oauth.scope
128+
form.add "requested_token_use", "on_behalf_of"
129+
end
130+
131+
uri = token_info.token_endpoint
132+
133+
client = HTTP::Client.new(uri, tls: true)
134+
client.basic_auth(oauth.client_id, oauth.client_secret)
135+
response = HTTP::Client.post(
136+
uri.request_target,
137+
headers: HTTP::Headers{
138+
"Accept" => "application/json",
139+
},
140+
form: form
141+
)
142+
143+
if !response.success?
144+
Log.warn { "failed with #{response.status_code} to obtain token on behalf of #{user.name} (#{user.id})\nbody: #{response.body}" }
145+
return
146+
end
147+
148+
# update the user model with the graph API access token
149+
token = OAuth2::AccessToken.from_json(response.body)
150+
user.access_token = token.access_token
151+
user.refresh_token = token.refresh_token if token.refresh_token
152+
user.expires_at = Time.utc.to_unix + token.expires_in.not_nil!
153+
user.save!
154+
end
155+
156+
def detect_token_version(payload : JSON::Any, iss : String) : TokenVersion
157+
ver = payload["ver"]?.try &.as_s?
158+
return TokenVersion::V2 if ver == "2.0" || iss.includes?("/v2.0")
159+
TokenVersion::V1
160+
end
161+
162+
# ---------- Audience Parsing ----------
163+
164+
def extract_aud_host(aud_raw : String) : String
165+
begin
166+
uri = URI.parse(aud_raw)
167+
uri.host || aud_raw
168+
rescue
169+
aud_raw
170+
end
171+
end
172+
173+
# ---------- Issuer Parsing ----------
174+
175+
def extract_issuer_host(iss_raw : String) : String?
176+
begin
177+
uri = URI.parse(iss_raw)
178+
uri.host
179+
rescue
180+
nil
181+
end
182+
end
183+
184+
# ---------- Validation (JWKS) ----------
185+
186+
class_getter jwks : JWT::JWKS { JWT::JWKS.new }
187+
188+
def validate_token_with_jwks(
189+
token : String,
190+
token_info : PeekInfo? = nil,
191+
) : JSON::Any
192+
info = token_info || peek_token_info(token)
193+
jwks = MSTokenExchange.jwks
194+
payload = jwks.validate(
195+
token,
196+
validate_claims: true
197+
) || raise "token validation failed"
198+
199+
payload
200+
end
201+
end
202+
end

0 commit comments

Comments
 (0)