March 25, 2026

Using Azure Notification Hub REST from Bruno

Task

Having migrated away from rather bloated Postman to using Bruno, I needed to make REST API calls to Azure Notification Hub. It requires specific Authentication heared and you need to build it in a Pre Request Script.

Solution

Here's the script. Use Header: Authorization with value {{azure-authorization}}. Enjoy!

const CryptoJS = require('crypto-js');

function getAuthHeader(targetUri, ruleId, sharedKey,

expiresInMins) {

targetUri = encodeURIComponent(targetUri.toLowerCase()).toLowerCase();

// Set expiration in seconds

var expireOnDate = new Date();

expireOnDate.setMinutes(expireOnDate.getMinutes() + expiresInMins);

var expires = Date.UTC(expireOnDate.getUTCFullYear(), expireOnDate

.getUTCMonth(), expireOnDate.getUTCDate(), expireOnDate

.getUTCHours(), expireOnDate.getUTCMinutes(), expireOnDate

.getUTCSeconds()) / 1000;

var tosign = targetUri + '\n' + expires;

// using CryptoJS

var signature = CryptoJS.HmacSHA256(tosign, sharedKey);

var base64signature = signature.toString(CryptoJS.enc.Base64);

var base64UriEncoded = encodeURIComponent(base64signature);

// construct autorization string

var token = "SharedAccessSignature sr=" + targetUri + "&sig="

+ base64UriEncoded + "&se=" + expires + "&skn=" + ruleId;

//console.log("signature:" + token);

return token;

}

const baseUrl = bru.getFolderVar('url');

const currentTemplateUrl = req.getUrl();

// Replace the template with the real value

const resolvedUrl = currentTemplateUrl.replace("{{url}}", baseUrl);


bru.setVar('azure-authorization', getAuthHeader(resolvedUrl, "<<SharedAccessKeyName>>", "<<SharedAccessKey>>", 1));

bru.setVar('current-date',new Date().toUTCString());

January 1, 2026

Logging Ubiquiti EdgeRouter firewall events to Wazuh

Problem

Logging EdgeRouter firewall events to Wazuh needs custom decoder and rules.


Solution

In /var/ossec/etc/rules/edgerouter-fw_rules.xml: 

<group name="local,edgerouter,">

<rule id="900100" level="3">

    <decoded_as>edgerouter-fw-tcp</decoded_as>

    <description>EdgeRouter TCP packet: $(srcip):$(srcport) → $(dstip):$(dstport) via $(in_iface)</description>

    <group>edgerouter,tcp,firewall,</group>

</rule>

<rule id="900110" level="3">

    <decoded_as>edgerouter-fw-udp</decoded_as>

    <description>EdgeRouter UDP packet: $(srcip):$(srcport) → $(dstip):$(dstport) via $(in_iface), payload $(payload_len) bytes</description>

    <group>edgerouter,udp,firewall,</group>

</rule>

<rule id="900120" level="3">

    <decoded_as>edgerouter-fw-icmp</decoded_as>

    <description>EdgeRouter ICMP packet: $(srcip) → $(dstip), type $(icmp_type) code $(icmp_code)</description>

    <group>edgerouter,icmp,firewall,</group>

</rule>

<rule id="900200" level="5">

    <if_sid>900100</if_sid>

    <match>SYN</match>

    <description>TCP SYN from $(srcip):$(srcport) to $(dstip):$(dstport) blocked by $(chain)</description>

    <group>edgerouter,tcp,syn,</group>

</rule>

<rule id="900210" level="10" frequency="10" timeframe="60">

    <if_matched_sid>900200</if_matched_sid>

    <same_source_ip />

    <description>Possible TCP SYN scan: $(srcip) sent repeated SYN packets to multiple ports</description>

    <group>edgerouter,tcp,scan,</group>

</rule>

<rule id="900300" level="4">

    <if_sid>900110</if_sid>

    <description>UDP traffic: $(srcip):$(srcport) → $(dstip):$(dstport)</description>

    <group>edgerouter,udp,event,</group>

</rule>

<rule id="900310" level="10" frequency="20" timeframe="30">

    <if_matched_sid>900300</if_matched_sid>

    <same_source_ip />

    <description>Possible UDP flood: $(srcip) sent $(frequency) packets in $(timeframe)s</description>

    <group>edgerouter,udp,flood,</group>

</rule>

<rule id="900400" level="4">
    <if_sid>900120</if_sid>
    <match>TYPE=8</match>
    <description>ICMP Echo Request (ping) from $(srcip) to $(dstip), seq $(icmp_seq)</description>
    <group>edgerouter,icmp,echo,</group>
</rule>

<rule id="900410" level="10" frequency="20" timeframe="30">
    <if_matched_sid>900400</if_matched_sid>
    <same_source_ip />
    <description>Possible ICMP flood: $(srcip) sent $(frequency) echo requests in $(timeframe)s</description>
    <group>edgerouter,icmp,flood,</group>
</rule>

<rule id="900500" level="3">
    <match>WAN_LOCAL</match>
    <description>EdgeRouter WAN_LOCAL rule matched: $(srcip) → $(dstip) on $(proto)</description>
    <group>edgerouter,wan_local,</group>
</rule>

<rule id="900600" level="5">
    <match>WAN_LOCAL-default-D</match>
    <description>Inbound traffic blocked by WAN_LOCAL: $(srcip):$(srcport) → $(dstip):$(dstport) proto $(proto)</description>
    <group>edgerouter,blocked,wan_local,</group>
</rule>

<rule id="900700" level="8">
    <if_sid>900200</if_sid>
    <match>WAN_LOCAL-default-D</match>
    <description>Blocked TCP SYN: $(srcip):$(srcport) → $(dstip):$(dstport) flags $(flags)</description>
    <group>edgerouter,tcp,blocked,scan,</group>
</rule>

<rule id="900710" level="8" frequency="15" timeframe="60">
    <if_matched_group>blocked</if_matched_group>
    <same_source_ip />
    <description>Repeated blocked traffic from $(srcip): $(frequency) events in $(timeframe)s</description>
    <group>edgerouter,blocked,scan,</group>
</rule>

</group>


In /var/ossec/etc/decoders/edgerouter-fw.xml. Make sure there are no line breaks inside <regex> node:

<decoder name="edgerouter-fw-tcp">

    <parent>kernel</parent>

    <regex type="pcre2">\[([^\]]+)\]IN=(\S*)\s+OUT=(\S*)\s+MAC=([0-9A-Fa-f:]+)\s+SRC=([0-9.]+)\s+DST=([0-9.]+)\s+LEN=(\d+)\s+TOS=(\S+)\s+PREC=(\S+)\s+TTL=(\d+)\s+ID=(\d+)(?:\s+DF)?\s+PROTO=TCP\s+SPT=(\d+)\s+DPT=(\d+)\s+WINDOW=(\d+)\s+RES=(\S+)\s+(\S+)\s+URGP=(\d+)</regex>

    <order>chain,in_iface,out_iface,mac,srcip,dstip,len,tos,prec,ttl,id,srcport,dstport,window,res,flags,urgp</order>

</decoder>


<decoder name="edgerouter-fw-udp">

    <parent>kernel</parent>

    <regex type="pcre2">\[([^\]]+)\]IN=(\S*)\s+OUT=(\S*)\s+MAC=([0-9A-Fa-f:]+)\s+SRC=([0-9.]+)\s+DST=([0-9.]+)\s+LEN=(\d+)\s+TOS=(\S+)\s+PREC=(\S+)\s+TTL=(\d+)\s+ID=(\d+)\s+PROTO=UDP\s+SPT=(\d+)\s+DPT=(\d+)\s+LEN=(\d+)</regex>

    <order>chain,in_iface,out_iface,mac,srcip,dstip,len,tos,prec,ttl,id,srcport,dstport,payload_len</order>

</decoder>


<decoder name="edgerouter-fw-icmp">

    <parent>kernel</parent>

    <regex type="pcre2">\[([^\]]+)\]IN=(\S*)\s+OUT=(\S*)\s+MAC=([0-9A-Fa-f:]+)\s+SRC=([0-9.]+)\s+DST=([0-9.]+)\s+LEN=(\d+)\s+TOS=(\S+)\s+PREC=(\S+)\s+TTL=(\d+)\s+ID=(\d+)(?:\s+DF)?\s+PROTO=ICMP\s+TYPE=(\d+)\s+CODE=(\d+)\s+ID=(\d+)\s+SEQ=(\d+)</regex>

    <order>chain,in_iface,out_iface,mac,srcip,dstip,len,tos,prec,ttl,id,icmp_type,icmp_code,icmp_id,icmp_seq</order>

</decoder>


September 11, 2025

SharePoint: Connect using access tokens in PnP PowerShell

Task

I had an string array of SPO site collection URLs and I needed to execute PowerShell script that fetches comments and likes of app pages across all of these site collections. For getting actual page specific likes and comments, there is PnP PowerShell commands Get-PnPPageLikedByInformation and Get-PnPListItemComment, and you get nice object array including date, and even comment content. No problem there.

However, in my one-time executable script I didn't want to do interactive login for each of those site collections, so I needed a way to somehow persist the credentials and reuse them every time I called Connect-PnPOnline towards each site collection.

Back in the days, all they way in on-prem SharePoint, you could use Get-Credential once and store the credentials in a variable and include that as the parameter in the connection PS command, whatever that was. It, however, hasn't worked in a long time when there are MFA and other requirements towards signing in to online services such as SharePoint Online.

Good thing there is a solution.

Solution

Access tokens to the rescue! 

# first connect normally to SPO, but remember to return the connection using ReturnConnection parameter
$conn = Connect-PnPOnline -ClientId [YOUR_CLIENT_ID] -Url [YOUR_SPO_URL] -ReturnConnection

# magic is here, first get the access token, you only need to do this once (note that token will expire at some point)
$token = Get-PnPAccessToken -Connection $conn -ResourceTypeName SharePoint

# then whenever you want to connect to new site collection, use the token instead of ClientId
$newConn = Connect-PnPOnline -AccessToken $token -Url [NEW_SPO_URL] -ReturnConnection

January 17, 2025

SharePoint: How to create direct link to list view including search query

Problem

While going back to basics, that is on-prem SharePoint, I needed to create list view search box to another page. Meaning that the other page (not the the list view page) needed to have search box and button. Typing in query and pressing the button would redirect to the list view and do the search like user would've typed in the search in the list view search box.

I was certain there was some querystring parameter one could define for this to work, but couldn't find, so after some trial and error I found the correct one!

Solution 

In your link that points to list view, use querystring parameters View and InplaceSearchQuery. View contains the GUID of the view you want to show, and InplaceSearchQuery contains the query that is executed towards that view.

So a simplest example of this is to add this to Script Editor Web Part:

    <input type="text" id="inputBox" placeholder="Enter search query..." style="width:300px">
    <button onclick="redirectToURL()" type="button">Search</button>

    <script>
        function redirectToURL() {
            var inputValue = document.getElementById('inputBox').value;
            var baseURL = 'http://intranet.company.com/Lists/RequestForOffer/AllItems.aspx?view={DD04F0F8-6441-4DD9-9515-29EE952C9306}&InplaceSearchQuery=';
            var fullURL = baseURL + encodeURIComponent(inputValue);
            window.location.href = fullURL;
        }
    </script>

Only downside here is that the list view search box doesn't show the query text, but that you could do by some JavaScript magic injected to the list view page.

November 28, 2024

How to use Files.SelectedOperations.Selected permission for SharePoint and OneDrive content

Problem

I needed to grant permission for an Entra Id application to access SharePoint Online and OneDrive folder. I didn't want to grant the broadest Sites.ReadWrite.All permission to all site collections, nor the second broadest permission Sites.Selected to specific site collection. Instead I needed to go very granular, so limit access to specific folder and use the Files.SelectedOperations.Selected permission.

Backgound: Granting permission to whole site collection

Before realizing Sites.Selected was too broad, I tested using it, and it worked great. In order to use the following commands, I used Graph Explorer to which I granted Sites.FullControl.All permission so that it could modify the permissions for SPO items.

1. Get the site collection

First I queried the site (collection) ID for my SharePoint site collection:

HTTP GET https://graph.microsoft.com/v1.0/sites/xyz.sharepoint.com:/sites/jussi


Then with the site ID (it looks something like this: "xyz.sharepoint.com,8e099463-6a83-48c3-9c0e-59c3ca071054,15a706b9-af56-49dd-97ad-51325cee3b66"), I queried current permissions for the site:

HTTP GET https://graph.microsoft.com/v1.0/sites/[SITE_ID]/permissions

No permissions were found, just as expected.

2. Add permissions

To add permissions, you use the permissions endpoint:

HTTP POST https://graph.microsoft.com/v1.0/sites/[SITE_ID]/permissions

with the following payload in the body section of Graph Explorer:

{
  "roles": [
    "write"
  ],
  "grantedToIdentities": [
    {
      "application": {
        "id": "10facd40-ec88-4c62-b5dc-8170a2ccaba1"
      }
    }
  ]
}

3. Removing permissions

You can remove the permissions too, but for that you need the permission ID. You got the permission ID from the result of the query you used when you added the permission earlier, but in case you missed it, you can list the permissions:

HTTP GET https://graph.microsoft.com/v1.0/sites/[SITE_ID]/permissions

Permission ID is the long non-GUID string like this:

"id": "aTowaS50fG1zLnNwLmV4dHwyMGZhY2Q0NC1lYzg4LTRjNjItYjVkYy04MTcwYTJjY2FiYTFAOTJlNTE2MTUtNDkwOS00OGUzLWJhMDYtMmE1ZmMyMzNiNGJi"

So in order to remove the permission:

HTTP DELETE https://graph.microsoft.com/v1.0/sites/[SITE_ID]/permissions/[PERMISSION_ID]


Granting permission to folder level

Alright, I removed the site collection level permissions and started to grant folder level permissions. 

1. Get the drive ID for the library

https://graph.microsoft.com/v1.0/sites/[SITE_ID]/drives

Drive ID is the non-GUID string like:

"id": "b!Y6QJjoNqw0icDlnDygcQVLkGpxVWr91Jl61RMlzuO2a6mn6WqINZRLEDcG_BOOKl"

2. Get the folder ID from the drive by querying all the children items of the folder

HTTP GET https://graph.microsoft.com/v1.0/sites/[SITE_ID]/drives/[DRIVE_ID]/root/children

So, what I now got was the id 01QHBNZNLH6DKOS3GO3FHKHKIC6AGODYML of the folder MyTemplates inside the library Shared Documents, that's the one.

            "createdDateTime": "2024-11-27T07:26:15Z",
            "eTag": "\"{E9D4F067-CE6C-4ED9-A3A9-02F00CE1E18B},1\"",
            "id": "01QHBNZNLH6DKOS3GO3FHKHKIC6AGODYML",
            "lastModifiedDateTime": "2024-11-27T07:26:15Z",
            "name": "MyTemplates",
            "webUrl": "https://xyz.sharepoint.com/sites/jussi/Shared%20Documents/MyTemplates",
            "cTag": "\"c:{E9D4F067-CE6C-4ED9-A3A9-02F00CE1E18B},0\"",
            "size": 20558,
            "createdBy": {
                "user": {
                    "email": "admin@xyz.onmicrosoft.com",
                    "id": "45a44407-f372-45f5-a69d-9f4dd4f704fa",
                    "displayName": "xyz Admin"
                }
            },


3. Add permissions

Following my earlier steps when granting permission to site level, I made call:

HTTP POST https://graph.microsoft.com/v1.0/sites/[SITE_ID]/drives/[DRIVE_ID]/items/[FOLDER_ID]/permissions

using same payload as earlier

{
  "roles": [
    "read"
  ],
  "grantedToIdentities": [
    {
      "application": {
        "id": "10facd40-ec88-4c62-b5dc-8170a2ccaba1"
      }
    }
  ]
}

╯︿╰   I get "Invalid request" error:

{
    "error": {
        "code": "invalidRequest",
        "message": "Invalid request",
        "innerError": {
            "date": "2024-11-28T08:12:45",
            "request-id": "62a82827-5c26-4883-934e-7eabeceead88",
            "client-request-id": "f6cd7714-2199-3a94-3a4b-5df804f1ec37"
        }
    }
}

What is this? Documentation is rather brief. There was a great article by Vasil that discusses permissions that indicated that this should (or at least was) possible, so I started testing further, and finally found a solution.

Solution

When granting application permissions to folder objects (probably same goes for files and lists), the payload you send is slightly different. You don't use grantedToIdentities array, but instead grantedTo object:

{
  "roles": [
    "read"
  ],
  "grantedTo": {
    "application": {
      "id": "10facd40-ec88-4c62-b5dc-8170a2ccaba1"
    }
  }
}

NOTE! This payload doesn't work when granting permission on a site level.


December 14, 2023

SharePoint: How to find out if you have legacy add-ins that are affected by the SharePoint Add-in model retirement

Question

How to find out if my SharePoint Online tenant has legacy add-ins that stop working when the legacy add-in model is retiring?

Answer

In order to list add-ins, you can use few techniques:

Easiest in my opinion is the CLI for Microsoft 365, as it will give you the result in just a two commands.  If you wish to build further automation, you will want to pick the PnP PowerShell library to use in your PowerShell script or the PnP CSOM if you're using .NET.

To get a quick list or add-ins using the CLI for Microsoft 365, first login to your tenant with command:

m365 login

then list the add-ins with command:

m365 spo app list

in order to output the add-in JSON array to a file, use command:

m365 spo app list > add-ins.json

The output will contain array of add-ins, and the property IsClientSideSolution will tell you if the application is legacy and will be retiring. 

If IsClientSideSolution is false, it is legacy and will be retired.

If IsClientSideSolution is true, it is modern and will NOT be retired.



January 12, 2022

Kuluttajariitalautakunnan päätös: Puutteet auton varusteissa

Kuluttajariitalautakunta käsitteli tapaukseni kun tammikuussa 2020 ostamastani autosta puuttui varusteita, jotka tilaushetkellä oli varustelistauksessa. Lautakunta päätyi suosittamaan hyvitystä. Hyvitysvaateissanne voitte viitata Kuluttajariitalautakunnan julkiseen päätökseen Dnro 6279/33/2020.

Päätöksen PDF:n saa Kuluttajariitalautakunnalta tai allekirjoittaneelta pyydettäessä. Alla copy&paste julkisen päätöksen tekstisisällöstä. 


KULUTTAJARIITALAUTAKUNTA PÄÄTÖS Dnro 6279/33/2020

Esitelty 28.10.2021

IVa jaosto 


Myyjän vastuu auton puuttuvista varusteista


Lautakunnan ratkaisu 

Kuluttajariitalautakunta suosittaa, että Autokeskus Oy maksaa N.Nlle 300 euroa. 


Asiaselostus 

Kuluttaja osti 26.10.2019 myyjäliikkeeltä uuden Skoda Superb henkilöauton 53 136,95 

eurolla. Autolle annettiin kahden vuoden pituinen takuu. Auto toimitettiin kuluttajalle 

29.1.2020.

Kuluttajan mukaan autosta puuttuu sellaisia varusteita, joiden kuuluisi olla siinä kaupassa 

sovitun mukaisesti. Lisäksi Infotainment-järjestelmä toimii hitaasti. Osapuolet ovat eri mieltä 

siitä, onko kyseessä myyjän vastuulla oleva kaupan kohteen virhe, ja mikä on 

hinnanalennuksen määrä.


Ostajan vaatimukset perusteluineen

Kuluttaja vaatii myyjäliikkeeltä hyvityksenä 3 000 euroa. Vaatimus perustuu kaupan 

kohteessa ilmenneeseen varusteiden ongelmiin ja siihen sisältyy 2 500 euroa puuttuvista 

varusteista, 400 euroa puutteellisesti toimivista varusteista ja 100 euroa puhelinkuluista ja 

huoltokäynneistä. 

Joitain varusteita puuttuu ja jotkin toimivat puutteellisesti. Kuluttaja totesi virheen keväällä 

2020 ja ilmoitti siitä 9.9.2020. Kuluttaja viittaa vastaukseen ja toteaa, että Personalisointi ei 

toimi eikä ole toiminut syyskuusta 2020 alkaen.

Kuluttaja on myöhemmin todennut, että mahdollisesti schuko-latausjohdon ongelma saatiin 

poistettua 28.12.2020 eli 11 kuukautta luovutuksesta. Kuluttaja ei pidä järkevinä ratkaisuna 

varusteiden ongelmiin korvata integroidut järjestelmät irrallisilla laitteilla tai manuaalisilla 

toiminnoilla. Muuten ongelmiin ei ole tarjottu ratkaisua. GPS-antenni uusittiin 12.1.2021, 

mikä ei poistanut ongelmia. Infotainment järjestelmälle on lupailtu päivitystä useita kertoja 

turhaan.

Ratkaisupyynnön liitteenä on ajoneuvon käyttöohjekirja.


Myyjän vastaus perusteluineen

Myyjäliike ja maahantuoja ovat antaneet asiassa yhteisen vastauksen, ja toteavat voivansa 

maksaa 300 euroa hyvityksenä puuttuvista varusteista.


Maahantuojan toimittamaan myyntimateriaaliin oli jäänyt virheellisesti aiemman

Infotaiment järjestelmän tiedot. Puuttuvia varusteita ovat DVD-soitin 2 SD-korttipaikkaa In 

Car Commonication ja Personalisation 1.1. 


Autossa on uuden sukupolven Infotainment järjestelmä, jossa on lataushybridijärjestelmälle 

tärkeitä ominaisuuksia kuten akun lataukseen liittyvät toiminnot. Järjestelmässä 

karttapäivitykset tehdään internetyhteydellä. In Car Communication vahvistaa kuljettajan 

ääntä takapenkille ja on tarpeen etenkin seitsenpaikkaisessa autossa. Personalisointi 1.1 

tallentaa kuljettajan istuimen säädöt ym. avaimeen. Tässä autossa tallennus voidaan tehdä 

istuimen muistipaikkapainikkeisiin ja se on ollut käytettävissä Connect-palvelun kautta 

viimeistään syyskuusta 2020 alkaen.


Mode 2 -latausjohtoa varten on olemassa ohjelmistopäivitys veloituksetta. Sen jälkeen 

voidaan käyttää täyttä 8A latausvirtaa (n. 1,8 kW). Ennen päivitystä latausvirta oli enintään 

6A ja muut lataustavat olivat normaalisti käytettävissä. Asiakkaalle on lähetetty tästä tieto 

3.12.2020 ja 11.12.2020. Mahdollisten Infotainment-järjestelmän ongelmien vuoksi asiakasta

on pyydetty tuomaan auto huoltoon tarkistettavaksi 16.9. ja 3.12.2020 sekä myös 

11.12.2020. GPS-antenni on uusittu, koska Connect-yhteys oli pätkinyt. Infotainmentjärjestelmälle on tulossa päivitys viikkoon 29 mennessä vuonna 2021.

Vastauksen liitteenä on Autokeskuksen työmääräyksiä. 


Ratkaisun perustelut

Virheellisyyden arviointi 

Virheen arvioinnin lähtökohta on osapuolten välisen sopimuksen sisältö. Uuden auton 

kaupassa virheen olemassa oloa arvioidaan kuluttajansuojalain 5 luvun 12 §:n mukaan. 

Virhearvioinnin perustana ovat ostajan aiheelliset odotukset.

Asiassa on riidatonta, että autosta on puuttunut siihen kuuluneita varusteita. Kuluttaja on 

perustellusti voinut edellyttää, että autossa on hänelle ilmoitetut varusteet, joten kaupassa 

on tällä perusteella kuluttajansuojalain 5 luvun 12 §:n mukainen virhe. Kauppa ei ole kaikilta 

osin vastannut sitä, mistä asiassa on sovittu.


Infotainment-järjestelmän osalta autoon on tehty päivityksiä, ja tältä osin asiassa ei ole 

tarkempaa tietoa siitä, mikä on järjestelmän tilanne päivitysten jälkeen. Asiassa ei ole 

esitetty ulkopuolista selvitystä siitä, voidaanko kuluttajan esiin nostama ongelma katsoa 

kuluttajansuojalain mukaiseksi virheeksi. Tältä osin asiassa ei esitetyn näytön valossa ole 

todettavissa virhettä.


Virheen seuraamukset

Myyjäliike saa omalla kustannuksellaan korjata virheen, jos se tarjoutuu tekemään

korjauksen viipymättä saatuaan tietää virheestä. Myyjällä on oikeus osoittaa ostajalle myyjän

lukuun tehtävän korjauksen paikka. Ostaja saa kieltäytyä virheen korjaamisesta vain

erityisestä syystä, esimerkiksi, jos siitä aiheutuisi hänelle olennaista haittaa. 

Tässä tapauksessa virheen korjaaminen ei ole mahdollista, koska ajoneuvoon ei ole

asennettu puuttuvia varusteita, vaan ne on tarjottu käytettäviksi ulkoisina järjestelminä, joita

ei ole integroitu autoon. Näin ollen tarjottu korjaustapa ei ole asianmukainen, ja asiaa on

arvioitava hinnanalennukseen perustuen.


Hyvityksen määrän arviointi perustuu ilmenneen virheen laadun ja merkityksen ohella

kauppahintaan sekä ostajan aiheellisiin odotuksiin.

Nämä seikat huomioon otettuna lautakunta katsoo, että myyjäliikkeen tulee suorittaa

ostajalle hinnanalennuksena ja korvauksena tarpeellisista kuluista yhteensä 300 euroa.

Päätös oli yksimielinen.

November 2, 2021

SharePoint: Transport-level error has occurred when receiving results from the server

Problem

SharePoint 2016 (on-prem) farm had weird issues on few application and WFE servers. Namely, the servers couldn't connect to SQL Server, but ULS logs showed error:

Unknown SQL Exception 64 occurred. Additional error information from SQL Server is included below.  A transport-level error has occurred when receiving results from the server. (provider: TCP Provider, error: 0 - The specified network name is no longer available.)

When testing the connection with UDL file, the behavior was also strange, i.e., when testing the connection to server without defining any specific database, the test succeeded:


Also when defining a specific database, the test succeeded:



However, when attempting to list the databases by expanding the "Select the database on the server" dropdown, there was first "Unspesified error" error:


Followed by error "Microsoft Data Link
Login failed. Catalog information cannot be retrieved.":


Solution

Solution was (eventually) to fix the Jumbo Packet setting on the servers having the issue. Other servers had value 1514, while the problematic servers had value 9014 and changing value to 1514 made all the SharePoint servers immediately connect to SQL Server, and also the UDL DB listing started working.



September 29, 2021

Edge: Reverting to classic authentication dialog a.k.a disable Windows Hello for HTTP authentication

Problem

In recent Microsoft Edge browser versions 90+, the classic authentication dialog (or NTLM authentication dialog, or Windows authentication prompt) has been replaced by Windows Hello authentication prompt. It's all nice and secure, but at the moment at least, browser password vault extensions such as 1Password cannot fill in the credentials to that modern prompt. What it means is that you need to close the Windows Hello prompt, open password extension, copy username/password to notepad, refresh browser window, paste credentials from notepad to Windows Hello prompt. *yawn*

This is cumbersome in enterprise scenarios with various internal systems such as SharePoint that may require you to login with different credentials from the one you're currently logged into Windows.



Solution

For now the only solution is to disable the Windows Hello prompt in Edge. It will require using Group Policies either on AD level, or on individual machine. The following steps are for individual machine, but if you're an AD admin, you can pick the essential pieces from the instructions and do the same on AD level policy.

  1. First download MS Edge policy file from https://aka.ms/EdgeEnterprise, from the drop-downs, select the version of your Edge, then press GET POLICY FILES


  2. Extract the .cab, and .zip 🙄
  3. Navigate to .\MicrosoftEdgePolicyTemplates\windows\admx folder
  4. Copy msedge.admx to C:\Windows\PolicyDefinitions
  5. Navigate to .\MicrosoftEdgePolicyTemplates\windows\admx\en-US folder (NOTE! or the language of your Windows installation, if not en-US)
  6. Copy msedge.adml to C:\Windows\PolicyDefinitions\en-US
  7. Open Local Group Policy Editor, and navigate to Computer Configuration / Administrative Templates / Microsoft Edge / HTTP Authentication
  8. Edit Windows Hello For HTTP Auth Enabled setting, and set it to Disabled


  9. Click OK to confirm policy setting, and refresh page in Edge - no restart needed
  10. Applauds! Classic authentication prompt is back and you can also access the browser extension

September 20, 2021

Auth0: Invalid RSAES-OAEP padding

Problem

After configuring Auth0 with custom certificates via API, you get Access Denied error when attempting to login.

{ "error": "access_denied", "error_description": "Invalid RSAES-OAEP padding." }


Solution

Add an additional  decryptionKey to the connection's options with the following format.

options: {
  //... other options
  "decryptionKey" : {
        "key": "-----BEGIN PRIVATE KEY-----\n...",
        "cert": "-----BEGIN CERTIFICATE-----\n..."
    }
}

Keep in mind that options are replaced, not merged - so you'll need to send the whole options object to the PATCH call.

April 28, 2021

Azure B2C: Adding missing translations on Page Layouts

Problem

After starting to use Azure B2C custom Page Layout versions newer than 2.0.0, you will find translations are missing on many controls. Documentation is lacking behind, so it will take some trial and error to figure out some of the translation IDs. In the following pictures, you see missing translations marked with beautiful hand drawn red arrows.



Solution

As B2C _should_ already include these translations, the only workaround currently is to manually provide the missing strings in your Custom Policy. The following will work for urn:com:microsoft:aad:b2c:elements:contract:unifiedssp:2.1.4 and urn:com:microsoft:aad:b2c:elements:contract:selfasserted:2.1.4.

So first of all, add LocalizedResourceReference elements in the ContentDefinition elements.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<ContentDefinition Id="api.signuporsignin">
  <LoadUri>https://xyz.blob.core.windows.net/customui/ocean_blue/unified.html</LoadUri>
  <RecoveryUri>https://xyz.blob.core.windows.net/customui/ocean_blue/exception.html</RecoveryUri>
  <DataUri>urn:com:microsoft:aad:b2c:elements:contract:unifiedssp:2.1.4</DataUri>
  <Metadata>
    <Item Key="DisplayName">Signin and Signup</Item>
  </Metadata>
  <LocalizedResourcesReferences MergeBehavior="Prepend">
    <LocalizedResourcesReference Language="fi" LocalizedResourcesReferenceId="api.signuporsignin.fi" />
  </LocalizedResourcesReferences>
</ContentDefinition>
<ContentDefinition Id="api.selfasserted">
  <LoadUri>https://xyz.blob.core.windows.net/customui/ocean_blue/selfAsserted.html</LoadUri>
  <RecoveryUri>https://xyz.blob.core.windows.net/customui/ocean_blue/exception.html</RecoveryUri>
  <DataUri>urn:com:microsoft:aad:b2c:elements:contract:selfasserted:2.1.4</DataUri>
  <Metadata>
    <Item Key="DisplayName">Collect information from user page</Item>
  </Metadata>
  <LocalizedResourcesReferences MergeBehavior="Prepend">
    <LocalizedResourcesReference Language="fi" LocalizedResourcesReferenceId="api.localaccountpasswordreset.fi" />
  </LocalizedResourcesReferences>
</ContentDefinition>

Then the actual strings you will add in the Localization element.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
<LocalizedResources Id="api.signuporsignin.fi">
  <LocalizedStrings>
    <LocalizedString ElementType="ClaimType" ElementId="signInName" StringId="DisplayName">Sähköpostiosoite</LocalizedString>
    <LocalizedString ElementType="ClaimType" ElementId="password" StringId="DisplayName">Salasana</LocalizedString>
    <LocalizedString ElementType="UxElement" StringId="local_intro_generic">Kirjaudu sisään aiemmin luodulla tililläsi</LocalizedString>
  </LocalizedStrings>
</LocalizedResources>
<LocalizedResources Id="api.localaccountpasswordreset.fi">
  <LocalizedStrings>
    <LocalizedString ElementType="ClaimType" ElementId="email" StringId="DisplayName">Sähköpostiosoite</LocalizedString>
    <LocalizedString ElementType="ClaimType" ElementId="VerificationCode" StringId="DisplayName">Vahvistuskoodi</LocalizedString>
    <LocalizedString ElementType="ClaimType" ElementId="signInNames.emailAddress" StringId="DisplayName">Sähköpostiosoite</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="email">Sähköpostiosoite</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="ver_input">Vahvistuskoodi</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="verificationcode">Vahvistuskoodi</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="intro_msg">Syötä sähköpostiosoitteesi ja paina Lähetä vahvistuskoodi -painiketta.</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="but_send_code">Lähetä vahvistuskoodi</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="but_verify_code">Vahvista koodi</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="but_send_new_code">Lähetä uusi koodi</LocalizedString>
    <LocalizedString ElementType="DisplayControl" ElementId="emailVerificationSSPRControl" StringId="success_send_code_msg">Vahvistuskoodi on lähetetty sähköpostiisi. Kopioi se alla olevaan syöteruutuun ja paina Vahvista koodi -painiketta.</LocalizedString>
  </LocalizedStrings>
</LocalizedResources>

Please note that this is not a comprehensive list of missing translations, so feel free to comment below if you happen to have a full tested list of translations that will work with the new Page Layouts.

March 23, 2021

Office Add-In: Empty group label

Problem

I needed to create Outlook Add-In Ribbon button without Group label, like the Insights Add-In does.



Solution

As the Group element requires Label, and the String of the Label requires DefaultValue to have some value, the workaround was to set the DefaultValue as one space character.



Ta-daa!



February 26, 2021

WebView2: How to hide scrollbars

Problem

When using the new Microsoft Edge WebView2 control, it often displays scroll bars and the control doesn't have any explicit property to hide the scroll bars.



Solution

You need to use custom Javascript at the NavigationCompleted event to hide the scrollbars. 

Simply add a new NavigationCompleted event handler for the WebView2 control and use the ExecuteScriptAsync method to run a Javascript that hides the scroll bars.

1
2
3
4
5
6
7
private void WebView2_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
{
    if (e.IsSuccess)
    {
        ((WebView2)sender).ExecuteScriptAsync("document.querySelector('body').style.overflow='hidden'");
    }
}

Note! Code above hides scrollbars AND disables scrolling. If you would like to hide scrollbars but retain scrolling (with touch for example), please use this code instead.

1
2
3
4
5
6
7
private void WebView2_NavigationCompleted(object sender, CoreWebView2NavigationCompletedEventArgs e)
{
    if (e.IsSuccess)
    {
        ((WebView2)sender).ExecuteScriptAsync("document.querySelector('body').style.overflow='scroll';var style=document.createElement('style');style.type='text/css';style.innerHTML='::-webkit-scrollbar{display:none}';document.getElementsByTagName('body')[0].appendChild(style)");
    }
}

January 27, 2021

Azure Managed Identity: Obtaining token gives error ‘invalid_client’

Problem

One of our Azure App Services suddenly started behaving badly and throwing HTTP 400 errors. From Application Insights we could see the error was coming from a call to LOCALHOST:PORT/MSI/token which is the location where access token is requested in case your code wants to access other Azure resources using Managed Identity (formerly MSI).

Troubleshooting

I went to Kudu PowerShell console of the given App Service and tried to manually get the access_token, but couldn’t.

Command for that is:
Invoke-WebRequest -Uri 'http://127.0.0.1:41332/MSI/token/?resource=https://management.azure.com/&api-version=2017-09-01' -Method GET -Headers @{Metadata="true";Secret="$env:MSI_SECRET"} -UseBasicParsing

Note! Port in the URL is different in your App Service, you can get it via @env:MSI_ENDPOINT.

All I got was HTTP 401 error with ‘invalid_client’ error code. Strange. In respective DEV App Service there was no errors and access_code was returned nicely.

By the way, details of the Uri and other parameters can be found here. Header is different if you’re using more recent api-version.

By the way, if you just run the Invoke-WebRequest, you will get error:

Win32 internal error "The handle is invalid" 0x6 occurred while reading the console output buffer. Contact Microsoft Customer Support Services.

No point in contacting MS Support, just run the following command and retry:

$ProgressPreference="SilentlyContinue"

Solution

Now, for the solution…good old IISRESET. Of course in Azure you restart the App Service in question. After restarting the App Service, you can re-run the Invoke-WebRequest, and access_token is returned correctly, and App Service works.

June 26, 2020

MS Flow: Simplest retry logic for SharePoint Online HTTP 400 errors

Problem

When setting SharePoint Online document properties from Flow, you will run into issues if the document is locked, i.e., someone has it open. In this case, SharePoint throws HTTP 400 that cannot be caught by the built-in retry-logic of the Update Item Flow action.

Solution

Simplest Do Until loop I came up with can be seen below. I didn’t find using Scope action necessary. In case you need to re-use this elsewhere in your Flow, it is quite straight forward to copy the Do Until action and paste it elsewhere. Just remember to add Set Variable action before each Do Until and set the fileLocked variable to true.

At first, the two Set variable actions were a bit confusing, the first one is only set to run after the SharePoint Update Item action has succeeded (and in that you set the fileLocked to false). The second one, however, is set to be run if the previous Set variable action is skipped (and in that you set the fileLocked to true), and as the first one is skipped if the Update Item fails, we then know it did NOT succeed.

It feels a bit weird to have the second Set variable (Set variable 2) as fileLocked variable value is not changing, but this is the high level logic people seem to do this so there may be some room for further improvement.

flowretry

May 14, 2020

Microsoft Flow: Using HTTP Webhook action with Azure Automation Runbook

Task

I needed to create new SharePoint Online Document Library and amongst other things set a Retention Label on that newly created Document Library using Microsoft Flow. Creating new doclib is straightforward using Flow, but I just couldn’t set the the Retention Label via REST from Flow. There is an API for that, but due to reasons I couldn’t get it to work from Flow.

We had an Azure Automation Runbook that was called at the end of the Flow anyway, so I decided to use that to set the Label on the SPO Library using SharePoint Online PnP’s Set-PnPLabel. No problem. However, it takes a while for the Label to be applied to the Library and as email was sent to users at the end of the Flow, they found themselves in the library too early, i.e., the Label was not yet set.

Solution

FLOW

I could’ve used  a “Do Until” loop in Flow and poll the list but that’s not something we like to do, right? We like events and triggers, so why not use the HTTP Webhook action, sounds exciting!


In the screenshot above, I’m calling the Azure Automation Runbook, but you can really call anything that is capable of listening to your request and at the and making a HTTP request back to the callback URL you define. You define your endpoint address in the Subscribe - URI field.

In the Subscribe - Body you must at least pass in the listCallbackUrl() so that you know the endpoint of this specific Flow instance you need to call in your backend code. Note that listCallbackUrl() is generated automatically, is specific to this running instance of the Flow, and can only be called once. Flow processing will halt at this action and it will continue when your backend code calls the listCallbackUrl(). You can define timeout of how long the action waits for the callback in the action settings.

Here I’m also passing in the title of the SharePoint Library as I’m also doing some tricks on the library but you would pass in anything you need in your scenario.

BACKEND

In the backend code you will do whatever you need, but when you’re done, make HTTP POST call to the URL you received as a parameter in your backend code, in my case that would look like this in PowerShell.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
param(
    [Parameter (Mandatory = $true)]
    [object]$webhookData
)

# If runbook was called from Webhook, WebhookData will not be null.
if ($WebhookData) {
    # Retrieve VMs from Webhook request body
    $body = (ConvertFrom-Json -InputObject $WebhookData.RequestBody)

    ####
    # Do something in your code...
    ###
    
    # ...and when you're done, call the callback URL
    Invoke-WebRequest $body.CallbackUrl -Method POST -UseBasicParsing
}

If you look at the Flow when it is running, you see it pause at the HTTP Webhook action, and continue as soon as the backend calls the callback URL. You can also manually call the callback URL using e.g., Postman, just paste in the callback URL and make sure method is POST. You will get HTTP 200 when the callback call succeeds.


April 14, 2020

Cloning OneNote Tab without /clone REST endpoint

Series

This is series of blog articles showing how to clone Teams Channels and Tabs without using the Clone REST endpoint or direct REST queries. Except we use the Microsoft.Graph (3.1.0). First post discusses things in general, details about different tab types are separated to individual articles.

  1. Cloning Teams Channels and Tabs without /clone REST endpoint
  2. Cloning Planner Tab without /clone REST endpoint
  3. Cloning OneNote Tab without /clone REST endpoint <<YOU ARE HERE>>
  4. Cloning Web Tab without /clone REST endpoint (TBD)
  5. Copying Teams Files tab content using MoveCopyUtil

Solution

In this piece of code, we first determine current source tab is of type OneNote. We must first create new Notebook for the tab, and for this we add simple retry logic as you cannot have Notebooks with duplicate name. This retry logic adds running integer to Notebook name until the creation succeeds.

Now, after we have successfully created the Notebook, it is time to create the Tab. Do note the special format of the EntityId parameter for the tab, as well as rather identical URLs.

Finally, note that you may end up getting ServiceException although tab creation succeeds, thus the scary Exception swallowing.

Using term cloning when it comes to this tab type can be a bit misleading, as we’re not cloning the tab content, but only the tab and creating new content. Perhaps think of this as cloning from the Team perspective, while the individual tabs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
if (sourceTab.TeamsApp.Id.Equals(TeamsAppId.OneNote))
{
logger?.LogInformation($"Creating OneNote. Channel: {sourceChannel.DisplayName}. {newTeamId}");

var newNotebook = await retry.ExecuteAsync(async () =>
{
    var i = 0;
    Notebook nb = null;
    string nbName;

    while (i < 100 && null == nb)
    {
        nbName = $"{TextTools.RemoveSpecialCharactersForNotebook(requestData.title)}{(i > 0 ? $" {i}" : "")} Notebook";

        try
        {
            nb = await graphClient.Groups[newGroup.Id].Onenote.Notebooks.Request().AddAsync(new Notebook()
            {
                DisplayName = nbName
            });

            break;
        }
        catch (ServiceException ex)
        {
            if ((int)ex.StatusCode == 409)
            {
                // this is fine, Notebook with such name already exists, let's find first available by appending integers to name
                logger?.LogWarning($"Notebook with name '{nbName}' already exists, trying next integer. Channel: {sourceChannel.DisplayName}. {newTeamId}");
            }
        }
        finally
        {
            i++;
        }
    }

    return nb;
});

try
{
    logger?.LogInformation($"Adding OneNote tab. Channel: {sourceChannel.DisplayName}. {newTeamId}");

    await retry.ExecuteAsync(async () =>
    {
        await graphClient.Teams[newTeamId].Channels[channelId].Tabs.Request().AddAsync(new TeamsTab()
        {
            ODataType = null,
            DisplayName = newNotebook.DisplayName,
            Configuration = new TeamsTabConfiguration()
            {
                EntityId = $"{Guid.NewGuid()}_{newNotebook.Id}",
                ContentUrl = $"https://www.onenote.com/teams/TabContent?entityid=%7BentityId%7D&subentityid=%7BsubEntityId%7D&auth_upn=%7Bupn%7D&notebookSource=New&notebookSelfUrl=https%3A%2F%2Fwww.onenote.com%2Fapi%2Fv1.0%2FmyOrganization%2Fgroups%2F{{groupId}}%2Fnotes%2Fnotebooks%2F{newNotebook.Id}&oneNoteWebUrl={newNotebook.Links.OneNoteWebUrl.Href}&notebookName={newNotebook.DisplayName}&ui={{locale}}&tenantId={{tid}}",
                RemoveUrl = $"https://www.onenote.com/teams/TabRemove?entityid=%7BentityId%7D&subentityid=%7BsubEntityId%7D&auth_upn=%7Bupn%7D&notebookSource=New&notebookSelfUrl=https%3A%2F%2Fwww.onenote.com%2Fapi%2Fv1.0%2FmyOrganization%2Fgroups%2F{{groupId}}%2Fnotes%2Fnotebooks%2F{newNotebook.Id}&oneNoteWebUrl={newNotebook.Links.OneNoteWebUrl.Href}&notebookName={newNotebook.DisplayName}&ui={{locale}}&tenantId={{tid}}",
                WebsiteUrl = $"https://www.onenote.com/teams/TabRedirect?redirectUrl={newNotebook.Links.OneNoteWebUrl.Href}"
            },
            AdditionalData = new Dictionary<string, object>()
            {
                {
                    "teamsApp@odata.bind", $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{sourceTab.TeamsApp.Id}"
                }
            }
        });
    });
}
catch (ServiceException ex)
{
    HandleTabCreationException(ex, logger, sourceTab, newTeamId);
}
}