├── .gitignore ├── LICENSE ├── README.md ├── app.json ├── media ├── msnJournals.png └── oauth2.gif └── src ├── OAuth2.0 ├── GrantType.Enum.al ├── OAuth20Application.Codeunit.al ├── OAuth20Application.Page.al ├── OAuth20Application.Table.al ├── OAuth20Applications.Page.al ├── OAuth20Authorization.Codeunit.al └── OAuth20ConsentDialog.Page.al ├── OAuth2ControlAddIn ├── OAuth20Integration.ControlAddIn.al └── js │ └── OAuthIntegration.js └── OAuth2Test └── OAuth20Test.Codeunit.al /.gitignore: -------------------------------------------------------------------------------- 1 | .alpackages 2 | .vscode 3 | *.app -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 MSN Raju 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Generic OAuth2 Library for Business Central 2 | 3 | Generic OAuth2 Library for Business Central is to acquire Access Token from Azure AD, Google, Facebook etc. OAuth is most commonly used authorization method across all platforms. Acquiring Access Token is a little difficult in Business Central, though there is a Codeunit called OAuth2 available in the system. To help the Business Central developers' community, I thought of creating this generic library for OAuth2, so that developers can use this in their applications. 4 | 5 | ## Code in Action 6 | ![OAuth2](/media/oauth2.gif) 7 | 8 | To know more details goto [www.msnJournals.com](https://www.msnjournals.com/post/generic-oauth2-library-for-business-central) 9 | 10 | 11 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "abffecdf-7bf2-4321-ab3f-6ee7fc3b9921", 3 | "name": "Generic OAuth 2.0 Authorization", 4 | "publisher": "MSN Raju", 5 | "version": "1.0.0.0", 6 | "brief": "", 7 | "description": "Generic OAuth 2.0 Authorization Library", 8 | "privacyStatement": "", 9 | "EULA": "https://github.com/msnraju/BC-OAuth-2.0-Authorization/blob/master/LICENSE", 10 | "help": "", 11 | "url": "https://www.msnjournals.com/post/generic-oauth2-library-for-business-central", 12 | "logo": "media\\msnJournals.png", 13 | "dependencies": [ 14 | { 15 | "id": "63ca2fa4-4f03-4f2b-a480-172fef340d3f", 16 | "publisher": "Microsoft", 17 | "name": "System Application", 18 | "version": "16.0.0.0" 19 | }, 20 | { 21 | "id": "437dbf0e-84ff-417a-965d-ed2bb9650972", 22 | "publisher": "Microsoft", 23 | "name": "Base Application", 24 | "version": "16.0.0.0" 25 | } 26 | ], 27 | "screenshots": [], 28 | "platform": "16.0.0.0", 29 | "idRanges": [ 30 | { 31 | "from": 50100, 32 | "to": 50149 33 | } 34 | ], 35 | "contextSensitiveHelpUrl": "https://www.msnjournals.com/post/generic-oauth2-library-for-business-central", 36 | "showMyCode": true, 37 | "runtime": "5.0" 38 | } -------------------------------------------------------------------------------- /media/msnJournals.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msnraju/BC-OAuth-2.0-Authorization/27c4e022427d2f75b871964ea90c1e6eb0c89ae8/media/msnJournals.png -------------------------------------------------------------------------------- /media/oauth2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/msnraju/BC-OAuth-2.0-Authorization/27c4e022427d2f75b871964ea90c1e6eb0c89ae8/media/oauth2.gif -------------------------------------------------------------------------------- /src/OAuth2.0/GrantType.Enum.al: -------------------------------------------------------------------------------- 1 | enum 50100 "Auth. Grant Type" 2 | { 3 | value(0; "Authorization Code") 4 | { 5 | Caption = 'Authorization Code'; 6 | } 7 | value(1; "Password Credentials") 8 | { 9 | Caption = 'Password Credentials'; 10 | } 11 | value(2; "Client Credentials") 12 | { 13 | Caption = 'Client Credentials'; 14 | } 15 | } -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20Application.Codeunit.al: -------------------------------------------------------------------------------- 1 | codeunit 50101 "OAuth 2.0 App. Helper" 2 | { 3 | var 4 | OAuth2Authorization: Codeunit "OAuth 2.0 Authorization"; 5 | 6 | procedure RequestAccessToken(var Application: Record "OAuth 2.0 Application"; var MessageTxt: Text): Boolean 7 | var 8 | IsSuccess: Boolean; 9 | JAccessToken: JsonObject; 10 | RefreshToken: Text; 11 | ElapsedSecs: Integer; 12 | begin 13 | if Application.Status = Application.Status::Connected then begin 14 | ElapsedSecs := Round((CurrentDateTime() - Application."Authorization Time") / 1000, 1, '>'); 15 | if ElapsedSecs < Application."Expires In" then 16 | exit(true) 17 | else 18 | if RefreshAccessToken(Application, MessageTxt) then 19 | exit(true); 20 | end; 21 | 22 | Application."Authorization Time" := CurrentDateTime(); 23 | IsSuccess := OAuth2Authorization.AcquireAuthorizationToken( 24 | Application."Grant Type", 25 | Application."User Name", 26 | Application.Password, 27 | Application."Client ID", 28 | Application."Client Secret", 29 | Application."Authorization URL", 30 | Application."Access Token URL", 31 | Application."Redirect URL", 32 | Application."Auth. URL Parms", 33 | Application.Scope, 34 | JAccessToken); 35 | 36 | if IsSuccess then begin 37 | ReadTokenJson(Application, JAccessToken); 38 | Application.Status := Application.Status::Connected; 39 | end else begin 40 | MessageTxt := GetErrorDescription(JAccessToken); 41 | Application.Status := Application.Status::Error; 42 | end; 43 | 44 | Application.Modify(); 45 | exit(IsSuccess); 46 | end; 47 | 48 | procedure RefreshAccessToken(var Application: Record "OAuth 2.0 Application"; var MessageTxt: Text): Boolean 49 | var 50 | JAccessToken: JsonObject; 51 | RefreshToken: Text; 52 | IsSuccess: Boolean; 53 | begin 54 | RefreshToken := GetRefreshToken(Application); 55 | if RefreshToken = '' then 56 | exit; 57 | 58 | Application."Authorization Time" := CurrentDateTime(); 59 | IsSuccess := OAuth2Authorization.AcquireTokenByRefreshToken( 60 | Application."Access Token URL", 61 | Application."Client ID", 62 | Application."Client Secret", 63 | Application."Redirect URL", 64 | RefreshToken, 65 | JAccessToken); 66 | 67 | if IsSuccess then begin 68 | ReadTokenJson(Application, JAccessToken); 69 | Application.Status := Application.Status::Connected; 70 | end else begin 71 | MessageTxt := GetErrorDescription(JAccessToken); 72 | Application.Status := Application.Status::Error; 73 | end; 74 | 75 | Application.Modify(); 76 | exit(IsSuccess); 77 | end; 78 | 79 | procedure GetAccessToken(var Application: Record "OAuth 2.0 Application"): Text 80 | var 81 | IStream: InStream; 82 | Buffer: TextBuilder; 83 | Line: Text; 84 | begin 85 | Application.CalcFields("Access Token"); 86 | if Application."Access Token".HasValue then begin 87 | Application."Access Token".CreateInStream(IStream, TextEncoding::UTF8); 88 | while not IStream.EOS do begin 89 | IStream.ReadText(Line, 1024); 90 | Buffer.Append(Line); 91 | end; 92 | end; 93 | 94 | exit(Buffer.ToText()) 95 | end; 96 | 97 | procedure GetRefreshToken(var Application: Record "OAuth 2.0 Application"): Text 98 | var 99 | IStream: InStream; 100 | Buffer: TextBuilder; 101 | Line: Text; 102 | begin 103 | Application.CalcFields("Refresh Token"); 104 | if Application."Refresh Token".HasValue then begin 105 | Application."Refresh Token".CreateInStream(IStream, TextEncoding::UTF8); 106 | while not IStream.EOS do begin 107 | IStream.ReadText(Line, 1024); 108 | Buffer.Append(Line); 109 | end; 110 | end; 111 | 112 | exit(Buffer.ToText()) 113 | end; 114 | 115 | local procedure GetErrorDescription(JAccessToken: JsonObject): Text 116 | var 117 | JToken: JsonToken; 118 | begin 119 | if (JAccessToken.Get('error_description', JToken)) then 120 | exit(JToken.AsValue().AsText()); 121 | end; 122 | 123 | local procedure ReadTokenJson(var Application: Record "OAuth 2.0 Application"; JAccessToken: JsonObject) 124 | var 125 | TempBlob: Codeunit "Temp Blob"; 126 | JToken: JsonToken; 127 | Property: Text; 128 | OStream: OutStream; 129 | begin 130 | foreach Property in JAccessToken.Keys() do begin 131 | JAccessToken.Get(Property, JToken); 132 | case Property of 133 | 'token_type', 134 | 'scope': 135 | ; 136 | 'expires_in': 137 | Application."Expires In" := JToken.AsValue().AsInteger(); 138 | 'ext_expires_in': 139 | Application."Ext. Expires In" := JToken.AsValue().AsInteger(); 140 | 'access_token': 141 | begin 142 | Application."Access Token".CreateOutStream(OStream, TextEncoding::UTF8); 143 | OStream.WriteText(JToken.AsValue().AsText()); 144 | end; 145 | 'refresh_token': 146 | begin 147 | Application."Refresh Token".CreateOutStream(OStream, TextEncoding::UTF8); 148 | OStream.WriteText(JToken.AsValue().AsText()); 149 | end; 150 | else 151 | Error('Invalid Access Token Property %1, Value: %2', Property, JToken.AsValue().AsText()); 152 | end; 153 | end; 154 | end; 155 | } -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20Application.Page.al: -------------------------------------------------------------------------------- 1 | page 50101 "OAuth 2.0 Application" 2 | { 3 | Caption = 'OAuth 2.0 Application'; 4 | LinksAllowed = false; 5 | ShowFilter = false; 6 | SourceTable = "OAuth 2.0 Application"; 7 | 8 | layout 9 | { 10 | area(content) 11 | { 12 | group(General) 13 | { 14 | Caption = 'General'; 15 | field(Code; Code) 16 | { 17 | ApplicationArea = Basic, Suite; 18 | ToolTip = 'Specifies the description.'; 19 | } 20 | field(Description; Description) 21 | { 22 | ApplicationArea = Basic, Suite; 23 | ToolTip = 'Specifies the description.'; 24 | } 25 | field("Client ID"; "Client ID") 26 | { 27 | Caption = 'Application / Client ID'; 28 | ApplicationArea = Basic, Suite; 29 | ToolTip = 'Specifies the client id.'; 30 | } 31 | field("Client Secret"; "Client Secret") 32 | { 33 | ApplicationArea = Basic, Suite; 34 | ExtendedDataType = Masked; 35 | ToolTip = 'Specifies the client secret.'; 36 | } 37 | field("Grant Type"; "Grant Type") 38 | { 39 | ApplicationArea = Basic, Suite; 40 | ToolTip = 'Specifies the grant type.'; 41 | } 42 | field("Redirect URL"; "Redirect URL") 43 | { 44 | ApplicationArea = Basic, Suite; 45 | ToolTip = 'Specifies the redirect url.'; 46 | } 47 | field(Scope; Scope) 48 | { 49 | ApplicationArea = Basic, Suite; 50 | ToolTip = 'Specifies the scope.'; 51 | } 52 | } 53 | group("Password Credentials") 54 | { 55 | Caption = 'Password Credentials'; 56 | Visible = "Grant Type" = "Grant Type"::"Password Credentials"; 57 | 58 | field("User Name"; "User Name") 59 | { 60 | Caption = 'User Name'; 61 | ApplicationArea = Basic, Suite; 62 | ToolTip = 'Specifies the user name.'; 63 | } 64 | field("Password"; "Password") 65 | { 66 | ApplicationArea = Basic, Suite; 67 | ExtendedDataType = Masked; 68 | ToolTip = 'Specifies the password.'; 69 | } 70 | } 71 | group("Endpoints") 72 | { 73 | Caption = 'Endpoints'; 74 | 75 | field("Authorization URL"; "Authorization URL") 76 | { 77 | ApplicationArea = Basic, Suite; 78 | ToolTip = 'Specifies the authorization url.'; 79 | } 80 | field("Access Token URL"; "Access Token URL") 81 | { 82 | ApplicationArea = Basic, Suite; 83 | ToolTip = 'Specifies the access token url.'; 84 | } 85 | field("Auth. URL Parms"; "Auth. URL Parms") 86 | { 87 | ApplicationArea = Basic, Suite; 88 | ToolTip = 'Specifies the resource url.'; 89 | } 90 | } 91 | } 92 | } 93 | 94 | actions 95 | { 96 | area(processing) 97 | { 98 | action(RequestAccessToken) 99 | { 100 | ApplicationArea = Basic, Suite; 101 | Caption = 'Request Access Token'; 102 | Image = EncryptionKeys; 103 | Promoted = true; 104 | PromotedCategory = Process; 105 | PromotedOnly = true; 106 | ToolTip = 'Open the service authorization web page. Login credentials will be prompted. The authorization code must be copied into the Enter Authorization Code field.'; 107 | 108 | trigger OnAction() 109 | var 110 | MessageTxt: Text; 111 | begin 112 | if not OAuth20AppHelper.RequestAccessToken(Rec, MessageTxt) then begin 113 | Commit(); // save new "Status" value 114 | Error(MessageTxt); 115 | end else 116 | Message(SuccessfulMsg); 117 | end; 118 | } 119 | action(RefreshAccessToken) 120 | { 121 | ApplicationArea = Basic, Suite; 122 | Caption = 'Refresh Access Token'; 123 | Image = Refresh; 124 | Promoted = true; 125 | PromotedCategory = Process; 126 | PromotedOnly = true; 127 | ToolTip = 'Refresh the access and refresh tokens.'; 128 | 129 | trigger OnAction() 130 | var 131 | MessageText: Text; 132 | begin 133 | if OAuth20AppHelper.GetRefreshToken(Rec) = '' then 134 | Error(NoRefreshTokenErr); 135 | 136 | if not OAuth20AppHelper.RefreshAccessToken(Rec, MessageText) then begin 137 | Commit(); // save new "Status" value 138 | Error(MessageText); 139 | end else 140 | Message(SuccessfulMsg); 141 | end; 142 | } 143 | } 144 | } 145 | 146 | var 147 | OAuth20AppHelper: Codeunit "OAuth 2.0 App. Helper"; 148 | SuccessfulMsg: Label 'Access Token updated successfully.'; 149 | NoRefreshTokenErr: Label 'No Refresh Token avaiable'; 150 | } 151 | 152 | -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20Application.Table.al: -------------------------------------------------------------------------------- 1 | table 50100 "OAuth 2.0 Application" 2 | { 3 | Caption = 'OAuth 2.0 Application'; 4 | DrillDownPageId = "OAuth 2.0 Applications"; 5 | LookupPageId = "OAuth 2.0 Applications"; 6 | 7 | fields 8 | { 9 | field(1; "Code"; Code[20]) 10 | { 11 | Caption = 'Code'; 12 | NotBlank = true; 13 | } 14 | field(2; Description; Text[250]) 15 | { 16 | Caption = 'Description'; 17 | } 18 | field(3; "Client ID"; Text[250]) 19 | { 20 | Caption = 'Client ID'; 21 | DataClassification = EndUserIdentifiableInformation; 22 | } 23 | field(4; "Client Secret"; Text[250]) 24 | { 25 | Caption = 'Client Secret'; 26 | DataClassification = EndUserIdentifiableInformation; 27 | } 28 | field(5; "Redirect URL"; Text[250]) 29 | { 30 | Caption = 'Redirect URL'; 31 | } 32 | field(6; "Auth. URL Parms"; Text[250]) 33 | { 34 | Caption = 'Auth. URL Parms'; 35 | } 36 | field(7; Scope; Text[250]) 37 | { 38 | Caption = 'Scope'; 39 | } 40 | field(8; "Authorization URL"; Text[250]) 41 | { 42 | Caption = 'Authorization URL'; 43 | 44 | trigger OnValidate() 45 | var 46 | WebRequestHelper: Codeunit "Web Request Helper"; 47 | begin 48 | if "Authorization URL" <> '' then 49 | WebRequestHelper.IsSecureHttpUrl("Authorization URL"); 50 | end; 51 | } 52 | field(9; "Access Token URL"; Text[250]) 53 | { 54 | Caption = 'Access Token URL'; 55 | 56 | trigger OnValidate() 57 | var 58 | WebRequestHelper: Codeunit "Web Request Helper"; 59 | begin 60 | if "Access Token URL" <> '' then 61 | WebRequestHelper.IsSecureHttpUrl("Access Token URL"); 62 | end; 63 | } 64 | field(10; Status; Option) 65 | { 66 | Caption = 'Status'; 67 | OptionCaption = ' ,Enabled,Disabled,Connected,Error'; 68 | OptionMembers = " ",Enabled,Disabled,Connected,Error; 69 | } 70 | field(11; "Access Token"; Blob) 71 | { 72 | Caption = 'Access Token'; 73 | DataClassification = EndUserIdentifiableInformation; 74 | } 75 | field(12; "Refresh Token"; Blob) 76 | { 77 | Caption = 'Refresh Token'; 78 | DataClassification = EndUserIdentifiableInformation; 79 | } 80 | field(13; "Authorization Time"; DateTime) 81 | { 82 | Caption = 'Authorization Time'; 83 | Editable = false; 84 | DataClassification = EndUserIdentifiableInformation; 85 | } 86 | field(14; "Expires In"; Integer) 87 | { 88 | Caption = 'Expires In'; 89 | Editable = false; 90 | DataClassification = EndUserIdentifiableInformation; 91 | } 92 | field(15; "Ext. Expires In"; Integer) 93 | { 94 | Caption = 'Ext. Expires In'; 95 | Editable = false; 96 | DataClassification = EndUserIdentifiableInformation; 97 | } 98 | field(16; "Grant Type"; Enum "Auth. Grant Type") 99 | { 100 | Caption = 'Grant Type'; 101 | DataClassification = EndUserIdentifiableInformation; 102 | } 103 | field(17; "User Name"; Text[80]) 104 | { 105 | Caption = 'User Name'; 106 | } 107 | field(18; Password; Text[20]) 108 | { 109 | Caption = 'Password'; 110 | } 111 | } 112 | 113 | keys 114 | { 115 | key(PK; Code) 116 | { 117 | Clustered = true; 118 | } 119 | } 120 | 121 | trigger OnModify() 122 | begin 123 | Status := Status::" "; 124 | Clear("Access Token"); 125 | Clear("Refresh Token"); 126 | "Expires In" := 0; 127 | "Ext. Expires In" := 0; 128 | "Authorization Time" := 0DT; 129 | end; 130 | } -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20Applications.Page.al: -------------------------------------------------------------------------------- 1 | page 50100 "OAuth 2.0 Applications" 2 | { 3 | ApplicationArea = Basic, Suite, Service; 4 | Caption = 'OAuth 2.0 Applications'; 5 | CardPageID = "OAuth 2.0 Application"; 6 | Editable = false; 7 | PageType = List; 8 | RefreshOnActivate = true; 9 | SourceTable = "OAuth 2.0 Application"; 10 | UsageCategory = Lists; 11 | 12 | layout 13 | { 14 | area(content) 15 | { 16 | repeater(Control1) 17 | { 18 | ShowCaption = false; 19 | 20 | field(Code; Code) 21 | { 22 | ApplicationArea = All; 23 | } 24 | field(Description; Description) 25 | { 26 | ApplicationArea = All; 27 | } 28 | 29 | field(Status; Status) 30 | { 31 | ApplicationArea = All; 32 | } 33 | } 34 | } 35 | } 36 | 37 | actions 38 | { 39 | area(processing) 40 | { 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20Authorization.Codeunit.al: -------------------------------------------------------------------------------- 1 | codeunit 50100 "OAuth 2.0 Authorization" 2 | { 3 | var 4 | DotNetUriBuilder: Codeunit DotNet_Uri; 5 | 6 | procedure AcquireAuthorizationToken( 7 | GrantType: Enum "Auth. Grant Type"; 8 | UserName: Text; 9 | Password: Text; 10 | ClientId: Text; 11 | ClientSecret: Text; 12 | AuthorizationURL: Text; 13 | AccessTokenURL: Text; 14 | RedirectURL: Text; 15 | AuthURLParms: Text; 16 | Scope: Text; 17 | JAccessToken: JsonObject): Boolean 18 | var 19 | AuthRequestURL: Text; 20 | AuthCode: Text; 21 | State: Text; 22 | IsSuccess: Boolean; 23 | begin 24 | if GrantType = GrantType::"Authorization Code" then begin 25 | AuthCode := AcquireAuthorizationCode( 26 | ClientId, 27 | ClientSecret, 28 | AuthorizationURL, 29 | RedirectURL, 30 | AuthURLParms, 31 | Scope); 32 | 33 | if AuthCode = '' then 34 | exit; 35 | end; 36 | 37 | exit( 38 | AcquireToken( 39 | GrantType, 40 | AuthCode, 41 | UserName, 42 | Password, 43 | ClientId, 44 | ClientSecret, 45 | Scope, 46 | RedirectURL, 47 | AccessTokenURL, 48 | JAccessToken)); 49 | end; 50 | 51 | local procedure AcquireAuthorizationCode( 52 | ClientId: Text; 53 | ClientSecret: Text; 54 | AuthorizationURL: Text; 55 | RedirectURL: Text; 56 | AuthURLParms: Text; 57 | Scope: Text): Text 58 | var 59 | OAuth20ConsentDialog: Page "OAuth 2.0 Consent Dialog"; 60 | State: Text; 61 | AuthRequestURL: Text; 62 | begin 63 | State := Format(CreateGuid(), 0, 4); 64 | 65 | AuthRequestURL := GetAuthRequestURL( 66 | ClientId, 67 | ClientSecret, 68 | AuthorizationURL, 69 | RedirectURL, 70 | State, 71 | Scope, 72 | AuthURLParms); 73 | 74 | if AuthRequestURL = '' then 75 | exit; 76 | 77 | OAuth20ConsentDialog.SetOAuth2CodeFlowGrantProperties(AuthRequestURL, State); 78 | OAuth20ConsentDialog.RunModal(); 79 | 80 | exit(OAuth20ConsentDialog.GetAuthCode()); 81 | end; 82 | 83 | local procedure AcquireToken( 84 | GrantType: Enum "Auth. Grant Type"; 85 | AuthorizationCode: Text; 86 | UserName: Text; 87 | Password: Text; 88 | ClientId: Text; 89 | ClientSecret: Text; 90 | Scope: Text; 91 | RedirectURL: Text; 92 | TokenEndpointURL: Text; 93 | JAccessToken: JsonObject): Boolean; 94 | var 95 | Client: HttpClient; 96 | Request: HttpRequestMessage; 97 | Response: HttpResponseMessage; 98 | Content: HttpContent; 99 | ContentHeaders: HttpHeaders; 100 | ContentText: Text; 101 | ResponseText: Text; 102 | IsSuccess: Boolean; 103 | begin 104 | case GrantType of 105 | GrantType::"Authorization Code": 106 | ContentText := 'grant_type=authorization_code' + 107 | '&code=' + AuthorizationCode + 108 | '&redirect_uri=' + DotNetUriBuilder.EscapeDataString(RedirectURL) + 109 | '&client_id=' + DotNetUriBuilder.EscapeDataString(ClientId) + 110 | '&client_secret=' + DotNetUriBuilder.EscapeDataString(ClientSecret); 111 | GrantType::"Password Credentials": 112 | ContentText := 'grant_type=password' + 113 | '&username=' + DotNetUriBuilder.EscapeDataString(UserName) + 114 | '&password=' + DotNetUriBuilder.EscapeDataString(Password) + 115 | '&client_id=' + DotNetUriBuilder.EscapeDataString(ClientId) + 116 | '&client_secret=' + DotNetUriBuilder.EscapeDataString(ClientSecret) + 117 | '&scope=' + DotNetUriBuilder.EscapeDataString(Scope); 118 | GrantType::"Client Credentials": 119 | ContentText := 'grant_type=client_credentials' + 120 | '&client_id=' + DotNetUriBuilder.EscapeDataString(ClientId) + 121 | '&client_secret=' + DotNetUriBuilder.EscapeDataString(ClientSecret) + 122 | '&scope=' + DotNetUriBuilder.EscapeDataString(Scope); 123 | end; 124 | Content.WriteFrom(ContentText); 125 | 126 | Content.GetHeaders(ContentHeaders); 127 | ContentHeaders.Remove('Content-Type'); 128 | ContentHeaders.Add('Content-Type', 'application/x-www-form-urlencoded'); 129 | 130 | Request.Method := 'POST'; 131 | Request.SetRequestUri(TokenEndpointURL); 132 | Request.Content(Content); 133 | 134 | if Client.Send(Request, Response) then 135 | if Response.IsSuccessStatusCode() then begin 136 | if Response.Content.ReadAs(ResponseText) then 137 | IsSuccess := JAccessToken.ReadFrom(ResponseText); 138 | end else 139 | if Response.Content.ReadAs(ResponseText) then 140 | JAccessToken.ReadFrom(ResponseText); 141 | 142 | exit(IsSuccess); 143 | end; 144 | 145 | 146 | procedure AcquireTokenByRefreshToken( 147 | TokenEndpointURL: Text; 148 | ClientId: Text; 149 | ClientSecret: Text; 150 | RedirectURL: Text; 151 | RefreshToken: Text; 152 | JAccessToken: JsonObject): Boolean 153 | var 154 | Client: HttpClient; 155 | Request: HttpRequestMessage; 156 | Response: HttpResponseMessage; 157 | Content: HttpContent; 158 | ContentHeaders: HttpHeaders; 159 | ContentText: Text; 160 | ResponseText: Text; 161 | IsSuccess: Boolean; 162 | begin 163 | ContentText := 'grant_type=refresh_token' + 164 | '&refresh_token=' + DotNetUriBuilder.EscapeDataString(RefreshToken) + 165 | '&redirect_uri=' + DotNetUriBuilder.EscapeDataString(RedirectURL) + 166 | '&client_id=' + DotNetUriBuilder.EscapeDataString(ClientId) + 167 | '&client_secret=' + DotNetUriBuilder.EscapeDataString(ClientSecret); 168 | Content.WriteFrom(ContentText); 169 | 170 | Content.GetHeaders(ContentHeaders); 171 | ContentHeaders.Remove('Content-Type'); 172 | ContentHeaders.Add('Content-Type', 'application/x-www-form-urlencoded'); 173 | 174 | Request.Method := 'POST'; 175 | Request.SetRequestUri(TokenEndpointURL); 176 | Request.Content(Content); 177 | 178 | if Client.Send(Request, Response) then 179 | if Response.IsSuccessStatusCode() then begin 180 | if Response.Content.ReadAs(ResponseText) then 181 | IsSuccess := JAccessToken.ReadFrom(ResponseText); 182 | end else 183 | if Response.Content.ReadAs(ResponseText) then 184 | JAccessToken.ReadFrom(ResponseText); 185 | 186 | exit(IsSuccess); 187 | end; 188 | 189 | procedure GetOAuthProperties(AuthorizationCode: Text; var CodeOut: Text; var StateOut: Text) 190 | begin 191 | if AuthorizationCode = '' then begin 192 | exit; 193 | end; 194 | 195 | ReadAuthCodeFromJson(AuthorizationCode); 196 | CodeOut := GetPropertyFromCode(AuthorizationCode, 'code'); 197 | StateOut := GetPropertyFromCode(AuthorizationCode, 'state'); 198 | end; 199 | 200 | local procedure GetAuthRequestURL( 201 | ClientId: Text; 202 | ClientSecret: Text; 203 | AuthRequestURL: Text; 204 | RedirectURL: Text; 205 | State: Text; 206 | Scope: Text; 207 | AuthURLParms: Text): Text 208 | begin 209 | if (ClientId = '') or (RedirectURL = '') or (state = '') then 210 | exit(''); 211 | 212 | AuthRequestURL := AuthRequestURL + '?' + 213 | 'client_id=' + DotNetUriBuilder.EscapeDataString(ClientId) + 214 | '&redirect_uri=' + DotNetUriBuilder.EscapeDataString(RedirectURL) + 215 | '&state=' + DotNetUriBuilder.EscapeDataString(State) + 216 | '&scope=' + DotNetUriBuilder.EscapeDataString(Scope) + 217 | '&response_type=code'; 218 | 219 | if AuthURLParms <> '' then 220 | AuthRequestURL := AuthRequestURL + '&' + AuthURLParms; 221 | 222 | exit(AuthRequestURL); 223 | end; 224 | 225 | local procedure ReadAuthCodeFromJson(var AuthorizationCode: Text) 226 | var 227 | JObject: JsonObject; 228 | JToken: JsonToken; 229 | begin 230 | if not JObject.ReadFrom(AuthorizationCode) then 231 | exit; 232 | 233 | if not JObject.Get('code', JToken) then 234 | exit; 235 | 236 | if not JToken.IsValue() then 237 | exit; 238 | 239 | if not JToken.WriteTo(AuthorizationCode) then 240 | exit; 241 | 242 | AuthorizationCode := AuthorizationCode.TrimStart('"').TrimEnd('"'); 243 | end; 244 | 245 | local procedure GetPropertyFromCode(CodeTxt: Text; Property: Text): Text 246 | var 247 | PosProperty: Integer; 248 | PosValue: Integer; 249 | PosEnd: Integer; 250 | begin 251 | PosProperty := StrPos(CodeTxt, Property); 252 | if PosProperty = 0 then 253 | exit(''); 254 | 255 | PosValue := PosProperty + StrPos(CopyStr(Codetxt, PosProperty), '='); 256 | PosEnd := PosValue + StrPos(CopyStr(CodeTxt, PosValue), '&'); 257 | 258 | if PosEnd = PosValue then 259 | exit(CopyStr(CodeTxt, PosValue, StrLen(CodeTxt) - 1)); 260 | 261 | exit(CopyStr(CodeTxt, PosValue, PosEnd - PosValue - 1)); 262 | end; 263 | } -------------------------------------------------------------------------------- /src/OAuth2.0/OAuth20ConsentDialog.Page.al: -------------------------------------------------------------------------------- 1 | page 50102 "OAuth 2.0 Consent Dialog" 2 | { 3 | Extensible = false; 4 | Caption = 'Waiting for a response - do not close this page'; 5 | DeleteAllowed = false; 6 | Editable = false; 7 | InsertAllowed = false; 8 | LinksAllowed = false; 9 | ModifyAllowed = false; 10 | 11 | 12 | layout 13 | { 14 | area(Content) 15 | { 16 | usercontrol(OAuthIntegration; "OAuth 2.0 Integration") 17 | { 18 | ApplicationArea = All; 19 | 20 | trigger AuthorizationCodeRetrieved(code: Text) 21 | var 22 | StateOut: Text; 23 | begin 24 | OAuth20Authorization.GetOAuthProperties(code, AuthCode, StateOut); 25 | 26 | if AuthCode = '' then begin 27 | AuthCodeError := NoAuthCodeErr; 28 | end; 29 | if State = '' then begin 30 | AuthCodeError := AuthCodeError + NoStateErr; 31 | end else 32 | if StateOut <> State then begin 33 | AuthCodeError := AuthCodeError + NotMatchingStateErr; 34 | end; 35 | 36 | CurrPage.Close(); 37 | end; 38 | 39 | trigger AuthorizationErrorOccurred(error: Text; desc: Text); 40 | begin 41 | AuthCodeError := StrSubstNo(AuthCodeErrorLbl, error, desc); 42 | CurrPage.Close(); 43 | end; 44 | 45 | trigger ControlAddInReady(); 46 | begin 47 | CurrPage.OAuthIntegration.StartAuthorization(OAuthRequestUrl); 48 | end; 49 | } 50 | } 51 | } 52 | 53 | procedure SetOAuth2CodeFlowGrantProperties(AuthRequestUrl: Text; AuthInitialState: Text) 54 | begin 55 | OAuthRequestUrl := AuthRequestUrl; 56 | State := AuthInitialState; 57 | end; 58 | 59 | procedure GetAuthCode(): Text 60 | begin 61 | exit(AuthCode); 62 | end; 63 | 64 | procedure GetAuthCodeError(): Text 65 | begin 66 | exit(AuthCodeError); 67 | end; 68 | 69 | var 70 | OAuth20Authorization: Codeunit "OAuth 2.0 Authorization"; 71 | OAuthRequestUrl: Text; 72 | State: Text; 73 | AuthCode: Text; 74 | AuthCodeError: Text; 75 | NoAuthCodeErr: Label 'No authorization code has been returned'; 76 | NoStateErr: Label 'No state has been returned'; 77 | NotMatchingStateErr: Label 'The state parameter value does not match.'; 78 | AuthCodeErrorLbl: Label 'Error: %1, description: %2', Comment = '%1 = The authorization error message, %2 = The authorization error description'; 79 | } -------------------------------------------------------------------------------- /src/OAuth2ControlAddIn/OAuth20Integration.ControlAddIn.al: -------------------------------------------------------------------------------- 1 | controladdin "OAuth 2.0 Integration" 2 | { 3 | Scripts = 'src\OAuth2ControlAddIn\js\OAuthIntegration.js'; 4 | RequestedWidth = 0; 5 | RequestedHeight = 0; 6 | HorizontalStretch = false; 7 | VerticalStretch = false; 8 | 9 | procedure StartAuthorization(AuthRequestUrl: Text); 10 | 11 | event AuthorizationCodeRetrieved(AuthCode: Text); 12 | event AuthorizationErrorOccurred(AuthError: Text; AuthErrorDescription: Text); 13 | event ControlAddInReady(); 14 | } 15 | -------------------------------------------------------------------------------- /src/OAuth2ControlAddIn/js/OAuthIntegration.js: -------------------------------------------------------------------------------- 1 | /* 2 | This addin communicates with OAuthLanding.htm using localStorage. The keys used in the localStorage must be the same in both the files. 3 | */ 4 | var AuthStatusKey = "NavOauthStatus"; 5 | var RegistrationStatusKey = "NavRegistrationStatus"; 6 | 7 | function StartAuthorization(url) { 8 | 9 | OauthLandingHelper(url, AuthStatusKey, handler); 10 | 11 | function handler(data) { 12 | if (data.code) { 13 | notifySuccess(data.code); 14 | } else if (data.error) { 15 | notifyError(data.error, data.desc); 16 | } 17 | } 18 | 19 | function notifySuccess(code) { 20 | Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('AuthorizationCodeRetrieved', [code]); 21 | } 22 | 23 | function notifyError(error, desc) { 24 | Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('AuthorizationErrorOccurred', [error, desc]); 25 | } 26 | } 27 | 28 | function Authorize(url, linkName, linkTooltip) { 29 | var a = createHyperlink(url, linkName, linkTooltip); 30 | 31 | a.onclick = function () { 32 | StartAuthorization(url); 33 | } 34 | } 35 | 36 | function RegisterApp(url, linkName, linkTooltip) { 37 | var a = createHyperlink(url, linkName, linkTooltip); 38 | 39 | a.onclick = function () { 40 | if (!isFeatureSupported()) { 41 | Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('AppRegistrationErrorOccurred', ['NotSupported', '']); 42 | return; 43 | } 44 | 45 | OauthLandingHelper(url, RegistrationStatusKey, handler); 46 | } 47 | 48 | function isFeatureSupported() { 49 | var isSupported = false; 50 | switch (Microsoft.Dynamics.NAV.GetEnvironment().Platform) { 51 | case 0: // windows 52 | case 1: // web 53 | switch (Microsoft.Dynamics.NAV.GetEnvironment().DeviceCategory) { 54 | case 0: // desktop 55 | case 1: // tablet 56 | isSupported = true; 57 | } 58 | } 59 | return isSupported; 60 | } 61 | 62 | function handler(data) { 63 | if (data.clientId && data.clientSecret) { 64 | top.window.localStorage.setItem(RegistrationStatusKey, 'success'); 65 | Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('AppRegistrationInformationRetrieved', [data.clientId, data.clientSecret]); 66 | } 67 | } 68 | } 69 | 70 | function OauthLandingHelper(url, key, callback) { 71 | var w = top.window; 72 | var aadWindow = w.open(url, '_blank', 'width=972,height=904,location=no'); 73 | 74 | function storageEvent(e) { 75 | if (e.key === key && e.newValue) { 76 | w.removeEventListener('storage', storageEvent, false); 77 | action(e.newValue); 78 | } 79 | } 80 | 81 | function messageEvent(e) { 82 | if (e.data.clientId) { 83 | w.removeEventListener("message", messageEvent, false); 84 | action(e.data); 85 | } 86 | } 87 | 88 | function action(data) { 89 | var obj = data; 90 | if (typeof data === 'string') { 91 | obj = JSON.parse(data); 92 | } 93 | callback(obj); 94 | closeWindow(); 95 | } 96 | 97 | function closeWindow() { 98 | try { 99 | w.removeEventListener("message", messageEvent, false); 100 | w.removeEventListener('storage', storageEvent, false); 101 | 102 | try { 103 | if (aadWindow.onbeforeunload) { 104 | aadWindow.onbeforeunload = null; 105 | } 106 | } catch (e) { } 107 | 108 | if (w.localStorage.getItem(key)) { 109 | w.localStorage.removeItem(key); 110 | } 111 | 112 | aadWindow.close(); 113 | } catch (ex) { } 114 | } 115 | 116 | function isCordova(win) { 117 | if (typeof win !== 'undefined' && win) { 118 | try { 119 | // this can throw a 'Permission denied" exception in IE11 120 | if (win.executeScript) { // if cordova. Is there a better way to detect? 121 | return true; 122 | } 123 | } 124 | catch (e) { 125 | return false; 126 | } 127 | } 128 | 129 | return false; 130 | } 131 | 132 | if (isCordova(aadWindow)) { 133 | aadWindow.addEventListener("loadstop", function () { 134 | function getDataFromWindow() { 135 | aadWindow.executeScript( 136 | { code: "localStorage.getItem('" + AuthStatusKey + "');" }, 137 | function (data) { 138 | if (data && data.length > 0 && data[0]) { 139 | var value = data[0]; 140 | clearInterval(loop); 141 | action(value); 142 | } 143 | } 144 | ); 145 | }; 146 | var loop = setInterval(getDataFromWindow, 1000); 147 | }); 148 | } else { 149 | w.removeEventListener('storage', storageEvent, false); 150 | w.addEventListener('storage', storageEvent, false); 151 | w.removeEventListener('message', messageEvent, false); 152 | w.addEventListener("message", messageEvent, false); 153 | } 154 | } 155 | 156 | function createHyperlink(url, linkName, linkTooltip) { 157 | var a = document.createElement('a'); 158 | var linkText = document.createTextNode(linkName); 159 | a.appendChild(linkText); 160 | a.title = linkTooltip; 161 | a.href = "#"; 162 | a.className = getLinkClassName(); 163 | 164 | document.getElementById('controlAddIn').appendChild(a); 165 | return a; 166 | } 167 | 168 | function getClassNameSuffix() { 169 | switch (Microsoft.Dynamics.NAV.GetEnvironment().Platform) { 170 | case 0: 171 | default: 172 | return '-windows'; 173 | 174 | case 3: 175 | return '-outlook'; 176 | 177 | case 1: 178 | case 2: 179 | switch (Microsoft.Dynamics.NAV.GetEnvironment().DeviceCategory) { 180 | case 0: 181 | default: 182 | return "-desktop"; 183 | case 1: 184 | return '-tablet'; 185 | case 2: 186 | return '-phone'; 187 | } 188 | } 189 | } 190 | 191 | function getLinkClassName() { 192 | return 'addInLink' + getClassNameSuffix(); 193 | } 194 | 195 | Microsoft.Dynamics.NAV.InvokeExtensibilityMethod('ControlAddInReady'); 196 | -------------------------------------------------------------------------------- /src/OAuth2Test/OAuth20Test.Codeunit.al: -------------------------------------------------------------------------------- 1 | codeunit 50110 "OAuth 2.0 Test" 2 | { 3 | local procedure GetGoogleAccessToken() 4 | var 5 | OAuth20Appln: Record "OAuth 2.0 Application"; 6 | OAuth20AppHelper: Codeunit "OAuth 2.0 App. Helper"; 7 | MessageText: Text; 8 | begin 9 | OAuth20Appln.Get('GOOGLE'); 10 | if not OAuth20AppHelper.RequestAccessToken(OAuth20Appln, MessageText) then 11 | Error(MessageText); 12 | 13 | Message('%1', OAuth20AppHelper.GetAccessToken(OAuth20Appln)); 14 | end; 15 | } --------------------------------------------------------------------------------