diff --git a/README.md b/README.md index 3ba5589..25fe346 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ Implements the Microsoft Office365 Graph API for the follow - List channel messages - Get channel message - Send channel message +* Application + - List Applications + - Get Application + - Create Application + - Update Application + - Delete Application + - Application Add Password + - Application Add/Get Service Principal + - Application API Permissions grant Oauth2AdminGrant + - Application API Permissions grant admin consent ## Installation diff --git a/shard.yml b/shard.yml index 9e5a577..8758e7a 100644 --- a/shard.yml +++ b/shard.yml @@ -1,5 +1,5 @@ name: office365 -version: 1.25.6 +version: 1.25.7 crystal: ">= 0.36.1" diff --git a/spec/applications/applications_spec.cr b/spec/applications/applications_spec.cr new file mode 100644 index 0000000..f99f1a4 --- /dev/null +++ b/spec/applications/applications_spec.cr @@ -0,0 +1,42 @@ +require "../spec_helper" + +describe Office365::Application do + it "List applications" do + SpecHelper.mock_client_auth + SpecHelper.mock_list_applications + + client = Office365::Client.new(**SpecHelper.mock_credentials) + list = client.list_applications + list.size.should eq(1) + end + + it "creates application" do + SpecHelper.mock_client_auth + SpecHelper.mock_create_applications + + client = Office365::Client.new(**SpecHelper.mock_credentials) + app = Office365::Application.new(display_name: "Display Name") + created_app = client.create_application(app) + created_app.should_not be_nil + created_app.sign_in_audience.should eq(Office365::SignInAud::AzureADandPersonalMicrosoftAccount) + end + + it "add password to existing application" do + SpecHelper.mock_client_auth + SpecHelper.mock_applications_add_pwd + + client = Office365::Client.new(**SpecHelper.mock_credentials) + pwd = client.application_add_pwd("my-app", "Password friendly name") + pwd.should_not be_nil + pwd.display_name.should eq("Password friendly name") + end + + it "get partial application contents" do + SpecHelper.mock_client_auth + SpecHelper.mock_get_application_id_and_web + client = Office365::Client.new(**SpecHelper.mock_credentials) + app = client.get_application("my-app", "id,web") + app.web.should_not be_nil, + app.web.not_nil!.redirect_uris.try &.size.should eq(2) + end +end diff --git a/spec/chat_messages/channel_messages_spec.cr b/spec/chat_messages/channel_messages_spec.cr index b71fa64..abf94f3 100644 --- a/spec/chat_messages/channel_messages_spec.cr +++ b/spec/chat_messages/channel_messages_spec.cr @@ -38,8 +38,8 @@ describe Office365::Places do channel_id = "19:4a95f7d8db4c4e7fae857bcebe0623e6@thread.tacv2" client = Office365::Client.new(**SpecHelper.mock_credentials) - resp = client.send_channel_message(team_id, channel_id, "Hello World") - resp.status_code.should eq(201) + resp = client.send_channel_message(team_id, channel_id, "test") + resp.body.content.should eq("test") end end end diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 9c7805b..d85edd7 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -710,8 +710,151 @@ module SpecHelper def mock_channel_send_msg WebMock.stub(:post, "https://graph.microsoft.com/v1.0/teams/fbe2bf47-16c8-47cf-b4a5-4b9b187c508b/channels/19%3A4a95f7d8db4c4e7fae857bcebe0623e6%40thread.tacv2/messages") - .with(body: "{\"body\":{\"content\":\"Hello World\",\"contentType\":\"TEXT\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""}) - .to_return(status: 201, body: "") + .with(body: "{\"body\":{\"content\":\"test\",\"contentType\":\"TEXT\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""}) + .to_return(status: 201, body: mock_get_channel_message) + end + + def mock_list_applications + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/applications") + .to_return(body: mock_list_applications_resp) + end + + def mock_create_applications + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/applications") + .to_return(body: mock_create_application_resp) + end + + def mock_applications_add_pwd + WebMock.stub(:post, "https://graph.microsoft.com/v1.0/applications%28appId%3D%27my-app%27%29/addPassword") + .with(body: "{\"passwordCredential\":{\"displayName\":\"Password friendly name\"}}", headers: {"Authorization" => "Bearer access_token", "Content-Type" => "application/json", "Prefer" => "IdType=\"ImmutableId\""}) + .to_return(body: mock_application_add_pwd_resp) + end + + def mock_get_application_id_and_web + WebMock.stub(:get, "https://graph.microsoft.com/v1.0/applications%28appId%3D%27my-app%27%29?%24select=id%2Cweb") + .to_return(body: mock_get_app_id_and_web_resp) + end + + def mock_list_applications_resp + %( +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications", + "value": [ + { + "appId": "00000000-0000-0000-0000-000000000000", + "identifierUris": [ "http://contoso/" ], + "displayName": "My app", + "publisherDomain": "contoso.com", + "signInAudience": "AzureADMyOrg" + } + ] +} + ) + end + + def mock_create_application_resp + %( +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications/$entity", + "id": "03ef14b0-ca33-4840-8f4f-d6e91916010e", + "deletedDateTime": null, + "isFallbackPublicClient": null, + "appId": "631a96bc-a705-4eda-9f99-fdaf9f54f6a2", + "applicationTemplateId": null, + "identifierUris": [], + "createdDateTime": "2019-09-17T19:10:35.2742618Z", + "displayName": "Display name", + "isDeviceOnlyAuthSupported": null, + "groupMembershipClaims": null, + "optionalClaims": null, + "addIns": [], + "publisherDomain": "contoso.com", + "samlMetadataUrl": "https://graph.microsoft.com/2h5hjaj542de/app", + "signInAudience": "AzureADandPersonalMicrosoftAccount", + "tags": [], + "tokenEncryptionKeyId": null, + "api": { + "requestedAccessTokenVersion": 2, + "acceptMappedClaims": null, + "knownClientApplications": [], + "oauth2PermissionScopes": [], + "preAuthorizedApplications": [] + }, + "appRoles": [], + "publicClient": { + "redirectUris": [] + }, + "info": { + "termsOfServiceUrl": null, + "supportUrl": null, + "privacyStatementUrl": null, + "marketingUrl": null, + "logoUrl": null + }, + "keyCredentials": [], + "parentalControlSettings": { + "countriesBlockedForMinors": [], + "legalAgeGroupRule": "Allow" + }, + "passwordCredentials": [], + "requiredResourceAccess": [], + "web": { + "redirectUris": [], + "homePageUrl": null, + "logoutUrl": null, + "implicitGrantSettings": { + "enableIdTokenIssuance": false, + "enableAccessTokenIssuance": false + } + } +} + +) + end + + def mock_application_add_pwd_resp + %( +{ + "customKeyIdentifier": null, + "endDateTime": "2021-09-09T19:50:29.3086381Z", + "keyId": "f0b0b335-1d71-4883-8f98-567911bfdca6", + "startDateTime": "2019-09-09T19:50:29.3086381Z", + "secretText": "[6gyXA5S20@MN+WRXAJ]I-TO7g1:h2P8", + "hint": "[6g", + "displayName": "Password friendly name" +} +) + end + + def mock_get_app_id_and_web_resp + %( +{ + "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#applications(id,web)/$entity", + "id": "870cf357-927e-4d71-9f81-6ca278227636", + "web": { + "homePageUrl": null, + "logoutUrl": "https://localhost/auth/logout", + "redirectUris": [ + "https://example.com", + "https://mydomain.com/auth/login" + ], + "implicitGrantSettings": { + "enableAccessTokenIssuance": false, + "enableIdTokenIssuance": false + }, + "redirectUriSettings": [ + { + "uri": "https:/localhost:8843", + "index": null + }, + { + "uri": "https://localhost:8843/auth/login", + "index": null + } + ] + } +} +) end end diff --git a/src/applications.cr b/src/applications.cr new file mode 100644 index 0000000..166184f --- /dev/null +++ b/src/applications.cr @@ -0,0 +1,133 @@ +module Office365::Applications + def list_applications(filter : String? = nil) : Array(Application) + params = URI::Params.new + params["$select"] = filter.to_s if filter + path = "/v1.0/applications" + response = graph_request(graph_http_request(request_method: "GET", path: path, query: params)) + if response.success? + Array(Application).from_json(response.body, "value") + else + raise "error listing applications #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def get_application(app_id : String, filter : String? = nil) : Application + params = URI::Params.new + params["$select"] = filter.to_s if filter + path = "/v1.0/applications(appId='#{app_id}')" + response = graph_request(graph_http_request(request_method: "GET", path: path, query: params)) + if response.success? + Application.from_json(response.body) + else + raise "error getting application #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def create_application(app : Application) : Application + request = graph_http_request(request_method: "POST", path: "/v1.0/applications", data: app.to_json) + response = graph_request(request) + if response.success? + Application.from_json(response.body) + else + raise "error creating application #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def delete_application(app_id : String) : Nil + path = "/v1.0/applications(appId='#{app_id}')" + response = graph_request(graph_http_request(request_method: "DELETE", path: path)) + raise "error deleting application #{response.status} (#{response.status_code}\n#{response.body}" unless response.success? + end + + def update_application(app_id : String, body : String) : Nil + path = "/v1.0/applications(appId='#{app_id}')" + response = graph_request(graph_http_request(request_method: "PATCH", path: path, data: body)) + raise "error patching application #{response.status} (#{response.status_code}\n#{response.body}" unless response.status_code == 204 + end + + def application_add_pwd(app_id : String, display_name : String, start_date_time : Time? = nil, end_date_time : Time? = nil) + path = "/v1.0/applications(appId='#{app_id}')/addPassword" + creds = AppPasswordCredential.new(display_name, start_date_time, end_date_time) + body = {"passwordCredential": creds} + request = graph_http_request(request_method: "POST", path: path, data: body.to_json) + response = graph_request(request) + if response.success? + AppPasswordCredential.from_json(response.body) + else + raise "error adding application password #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def application_create_sp(app_id : String) : String + path = "/v1.0/servicePrincipals" + body = {"appId": app_id} + request = graph_http_request(request_method: "POST", path: path, data: body.to_json) + response = graph_request(request) + if response.success? + JSON.parse(response.body).as_h["id"].as_s + else + raise "error creating application service principal #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def application_get_sp(app_id : String) : String + params = URI::Params.new + params["$filter"] = "appId eq '#{app_id}'" + path = "/v1.0/servicePrincipals" + response = graph_request(graph_http_request(request_method: "GET", path: path, query: params)) + if response.success? + JSON.parse(response.body).as_h["value"].as_a.first.as_h["id"].as_s + else + raise "error getting application service principal #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def application_upsert_sp(app_id : String) : String + application_get_sp(app_id) rescue application_create_sp(app_id) + end + + def application_add_app_role_assignment(app_id : String, role_id : String) : Hash(String, JSON::Any) + app_sp_id = application_upsert_sp(app_id) + graph_resource_id = application_get_sp("00000003-0000-0000-c000-000000000000") + application_add_app_role_assignment(app_sp_id, graph_resource_id, role_id) + end + + def application_add_app_role_assignment(app_sp_id : String, graph_sp_id : String, role_id : String) : Hash(String, JSON::Any) + body = { + "principalId": app_sp_id, + "resourceId": graph_sp_id, + "appRoleId": role_id, + } + path = "/v1.0/servicePrincipals/#{app_sp_id}/appRoleAssignments" + request = graph_http_request(request_method: "POST", path: path, data: body.to_json) + response = graph_request(request) + if response.success? + JSON.parse(response.body).as_h + else + raise "error creating application service app role assignment #{response.status} (#{response.status_code}\n#{response.body}" + end + end + + def application_add_oauth2_permission_grant(app_id : String, scope : String) : Hash(String, JSON::Any) + app_sp_id = application_upsert_sp(app_id) + graph_resource_id = application_get_sp("00000003-0000-0000-c000-000000000000") + application_add_oauth2_permission_grant(app_sp_id, graph_resource_id, scope) + end + + def application_add_oauth2_permission_grant(app_sp_id : String, graph_sp_id : String, scope : String) : Hash(String, JSON::Any) + body = { + "clientId": app_sp_id, + "consentType": "AllPrincipals", + "resourceId": graph_sp_id, + "scope": scope, + } + path = "/v1.0/oauth2PermissionGrants" + request = graph_http_request(request_method: "POST", path: path, data: body.to_json) + response = graph_request(request) + if response.success? + JSON.parse(response.body).as_h + else + raise "error granting admin consent to delegated permissions #{response.status} (#{response.status_code}\n#{response.body}" + end + end +end diff --git a/src/client.cr b/src/client.cr index 919004a..ab31b80 100644 --- a/src/client.cr +++ b/src/client.cr @@ -10,6 +10,7 @@ require "./odata" require "./password_credentials" require "./places" require "./channel_messages" +require "./applications" module Office365 USERS_BASE = "/v1.0/users" @@ -29,6 +30,7 @@ module Office365 include Office365::PasswordCredentials include Office365::Places include Office365::ChannelMessages + include Office365::Applications LOGIN_URI = URI.parse("https://login.microsoftonline.com") GRAPH_URI = URI.parse("https://graph.microsoft.com/") @@ -166,6 +168,7 @@ module Office365 property http_body : String def initialize(@http_status, @http_body, @message = nil) + super("#{@http_status} #{@message} reason: #{@http_body}") end end end diff --git a/src/models/application.cr b/src/models/application.cr new file mode 100644 index 0000000..d31b13a --- /dev/null +++ b/src/models/application.cr @@ -0,0 +1,173 @@ +module Office365 + class RequiredResourceAccess + include JSON::Serializable + + @[JSON::Field(key: "resourceAppId")] + property resource_app_id : String + + @[JSON::Field(key: "resourceAccess")] + property resource_access : Array(NamedTuple(id: String, type: String)) + + def initialize(@resource_app_id, @resource_access) + end + + def self.graph_resource_access + new("00000003-0000-0000-c000-000000000000", Array(NamedTuple(id: String, type: String)).new) + end + + delegate :<<, to: @resource_access + delegate :each, to: @resource_access + end + + class Web + include JSON::Serializable + + @[JSON::Field(key: "homePageUrl")] + property homepage_url : String? + + @[JSON::Field(key: "implicitGrantSettings")] + property implicit_grant_settings : Hash(String, JSON::Any)? + + @[JSON::Field(key: "redirectUris")] + property redirect_uris : Array(String)? + + @[JSON::Field(key: "logoutUrl")] + property logout_url : String? + + def initialize(@homepage_url = nil, @implicit_grant_settings = nil, @redirect_uris = nil, @logout_url = nil) + end + + def add_redirect_uri(url : String) + @redirect_uris ||= Array(String).new + redirect_uris.not_nil!.push(url) + end + end + + class AppPasswordCredential + include JSON::Serializable + + @[JSON::Field(key: "customKeyIdentifier")] + property custom_key_identifier : String? + + @[JSON::Field(key: "endDateTime")] + property end_date_time : Time? + + @[JSON::Field(key: "keyId")] + property key_id : String? + + @[JSON::Field(key: "secretText")] + property secret_text : String? + + @[JSON::Field(key: "startDateTime")] + property start_date_time : Time? + + @[JSON::Field(key: "displayName")] + property display_name : String? + + def initialize(@display_name = nil, @start_date_time = nil, @end_date_time = nil) + end + end + + class KeyCredential + include JSON::Serializable + + @[JSON::Field(key: "customKeyIdentifier")] + property custom_key_identifier : String? + + @[JSON::Field(key: "endDateTime")] + property end_date_time : String? + + @[JSON::Field(key: "keyId")] + property key_id : String? + + @[JSON::Field(key: "type")] + property type : String? + + @[JSON::Field(key: "usage")] + property usage : String? + + @[JSON::Field(key: "key")] + property key : String? + end + + enum SignInAud + AzureADMyOrg + AzureADMultipleOrgs + AzureADandPersonalMicrosoftAccount + PersonalMicrosoftAccount + + def to_json(json : JSON::Builder) + json.string(to_s) + end + end + + class Application + include JSON::Serializable + + property id : String? + @[JSON::Field(key: "appId")] + property app_id : String? + @[JSON::Field(key: "displayName")] + property display_name : String? + @[JSON::Field(key: "uniqueName")] + property unique_name : String? + @[JSON::Field(key: "groupMembershipClaims")] + property group_membership_claims : String? + + @[JSON::Field(key: "identifierUris")] + property identifier_uris : Array(String)? + property api : Hash(String, JSON::Any)? + @[JSON::Field(key: "appRoles")] + property app_roles : Array(JSON::Any)? + @[JSON::Field(key: "signInAudience")] + property sign_in_audience : SignInAud? + @[JSON::Field(key: "requiredResourceAccess")] + property required_resource_access : Array(RequiredResourceAccess)? + property web : Web? + property homepage : String? + property tags : Array(String)? + property notes : String? + property info : Hash(String, JSON::Any)? + @[JSON::Field(key: "isDeviceOnlyAuthSupported")] + property is_device_only_auth_supported : Bool? + @[JSON::Field(key: "publisherDomain")] + property publisher_domain : String? + @[JSON::Field(key: "verifiedPublisher")] + property verified_publisher : Hash(String, JSON::Any)? + property owners : Array(String)? + @[JSON::Field(key: "createdDateTime")] + property created_date_time : String? + @[JSON::Field(key: "optionalClaims")] + property optional_claims : Hash(String, JSON::Any)? + @[JSON::Field(key: "keyCredentials")] + property key_credentials : Array(KeyCredential)? + @[JSON::Field(key: "passwordCredentials")] + property password_credentials : Array(AppPasswordCredential)? + + def initialize + end + + def initialize(@display_name = nil, @sign_in_audience = nil, @web = nil, @required_resource_access = nil) + end + + def self.single_tenant_app(display_name : String) + new(display_name, SignInAud::AzureADMyOrg) + end + + def self.multi_tenant_app(display_name : String) + new(display_name, SignInAud::AzureADMultipleOrgs) + end + + def add_required_resource(resource : RequiredResourceAccess) + @required_resource_access ||= Array(RequiredResourceAccess).new + required_resource_access.not_nil! << resource + self + end + + def add_web_redirect_uri(url : String) + @web ||= Web.new + web.not_nil!.add_redirect_uri(url) + self + end + end +end