SSO on Microsoft Edge not working but does work on IE11, Chrome, Safari, Firefox - via Office.js add-in - single-sign-on

The following oAuth2 SSO code in my Office.js addin application works great in IE11, Chrome, Safari and Firefox, but doesn't work in Microsoft Edge. I can see the bearer token is being returned to the pop-up dialog via the url:
https://localhost:3000/login?access_token=ya29.ImG6By-0ZWPQB4MsYxxxxxxxxxxxxxxxxxxxxxxxxxxxxE5XsM9v7SBi-OaUBBQucO05luKVP0pYoSrcYzbaUKAAX&token_type=Bearer
I can also see that the asyncResult.status == succeeded, i.e.
[object Object]: {status: "succeeded", value: Object}
status: "succeeded"
value: Object
addEventHandler: function (){var d=OSF.DDA.SyncMethodCalls[OSF.DDA.SyncMethodNames.AddMessageHandler.id],c=d.verifyAndExtractCall(arguments,a,b),e=c[Microsoft.Office.WebExtension.Parameters.EventType],f=c[Microsoft.Office.WebExtension.Parameters.Handler];return b.addEventHandlerAndFireQueuedEvent(e,f)}
arguments: null
caller: null
length: 0
name: "value"
prototype: Object
proto: function() { [native code] }
close: function (){var c=OSF._OfficeAppFactory.getHostFacade()[OSF.DDA.DispIdHost.Methods.CloseDialog];c(arguments,g,b,a)}
sendMessage: function (){var c=OSF._OfficeAppFactory.getHostFacade()[OSF.DDA.DispIdHost.Methods.SendMessage];return c(arguments,b,a)}
proto: Object
proto: Object
However, the "console.log('hello');" doesn't get called when Microsoft Edge is running the sidebar/add-in.
The pop-up dialog is showing this in the F12 debug console:
HTTP403: FORBIDDEN - The server understood the request, but is refusing to fulfill it.
(XHR)POST - https://browser.pipe.aria.microsoft.com/Collector/3.0/?qsp=true&content-type=application%2Fbond-compact-binary&client-id=NO_AUTH&sdk-version=AWT-Web-JS-1.1.1&x-apikey=a387cfcf60114a43a7699f9fbb49289e-9bceb9fe-1c06-460f-96c5-6a0b247358bc-7238&client-time-epoch-millis=1579626709267&time-delta-to-apply-millis=961
Any ideas?
export function loginUsingOAuth() {
try {
const sealUrl = getFromStorage('seal_url', STORAGE_TYPE.LOCAL_STORAGE);
const redirectUrl = `${window.location.protocol}//${window.location.host}/login`;
let displayInIframe = false;
let promptBeforeOpen = false;
if (typeof sealUrl !== 'undefined' && sealUrl) {
const oAuthUrl = `${sealUrl}/seal-ws/oauth2/login?redirect_uri=${redirectUrl}`;
Office.context.ui.displayDialogAsync(
oAuthUrl,
{
height: 80,
width: 80,
displayInIframe,
promptBeforeOpen
},
asyncResult => {
console.log('asyncResult');
console.log(asyncResult);
addLog(LOG_TYPE.INFO, 'authentication.loginUsingOAuth', asyncResult);
if (asyncResult.status !== 'failed') {
const dialog = asyncResult.value;
dialog.addEventHandler(Office.EventType.DialogMessageReceived, args => {
console.log('hello');
Maybe this is actually a routing issue when executing in Edge? The "/login" callback is routed to the AuthCallback.js component:
const Routes = () => (
<BrowserRouter>
<Switch>
<Route exact path="/login" component={AuthCallback} />
<Route path="/" component={BaseLayout} />
</Switch>
</BrowserRouter>
);
The constructor of the AuthCallback.js component calls messageParent after a short pause:
constructor(props) {
super(props);
const paramsObj = queryString.parse(props.location.search);
const paramsStr = JSON.stringify(paramsObj);
setTimeout(() => {
Office.context.ui.messageParent(paramsStr);
}, 1200);
}
I'm starting to wonder if Edge is messing with the redirect. In the image below you can see that IE and Edge are returning different status codes for the same sign-on operation:

There seems to be two problems with the Edge browser.
The redirect/callback is not calling the components constructor when displayInIframe=false when running on Microsoft Edge. All other browsers work as expected. I've added conditional logic to set displayInIframe=true for the Edge browser use-case
The messageParent method also does not work for the Edge browser when displayInIframe=true. Therefore I've had to extract the auth token in the pop-up dialog callback and stash it away in the local_storage. The parent (the sidebar) is then polling the local_storage to detect that the sign-in has completed. Again, Chrome, Firefox, Safari, IE11 (both Mac and PC) are all fine - its just the Edge browser that is failing.
Whilst this is an ugly solution to the problem it is also imperfect because IF the end-user is not already signed-in to SSO then the Google [Account Selector] dialog is shown, which is a problem when displayInIframe=true as this throws an iframe exception.
I don't see any other option open to us, because the O/S build number and MSWord version dictates which browser is used to render the sidebar. The inability to choose whether IE11 or Edge is used would be bearable if Edge didn't have these functional deficits.

Related

Can't login by Goggle accounts connect or by Google auth API in Cypress 10x

Current behavior
I've tried to connect to Google account when my tested application redirects to Google accounts connect for let the end-user send emails by the application but I'm not able to do it not by Google Auth API according to your guidelines:
https://docs.cypress.io/guides/end-to-end-testing/google-authentication#Custom-Command-for-Google-Authentication
and not by cy.origin() from the UI.
In the first attempt by the API it's ignore of these authentication and popup the dialog to connect by google account as usually even all the credentials and token are valid and return 200 ok.
In the second attempt by cy.origin() it's keep to load the page after the redirect and always reach to timeout and yell about to increase the timeout even the page seems like it was fully loaded after a few seconds.
I've tried to increase the timeout to 90 seconds and use wait() before and after the redirect and look for some hidden iframes and tried every versa of google domain but nothing help.
it always return errors over there.
all the examples are below.
This is the error when trying to use cy.origin()::
Timed out after waiting 30000ms for your remote page to load on origin(s):
- https://google.com
A cross-origin request for https://accounts.google.com/o/oauth2/v2/auth?access_type=offline&scope=https%3A%2F%2Fmail.google.com&include_granted_scopes=true&state=%7B%22redirectUri%22%3A%22https%3A%2F%2Fmyappurl.com%2Fapp%2Fpipeline%2F9some-token-here-b96b599154ac%3Ftab%3Doverview%22%2C%22clientToken%22%3A%mytokenishere-1234567890%22%7D&prompt=consent&response_type=code&client_id=1234567890-aehhht36f7a01d38bmsvvpjrh915i86v.apps.googleusercontent.com&redirect_uri=https%3A%2F%2Fmyredreictedappurl.com%2FusersManagerSrvGoogleLogin was detected.
A command that triggers cross-origin navigation must be immediately followed by a cy.origin() command:
cy.origin('https://google.com', () => {
<commands targeting https://accounts.google.com go here>
})
If the cross-origin request was an intermediary state, you can try increasing the pageLoadTimeout value in Users/myname/repos/myreponame/cypress.config.ts to wait longer.
Browsers will not fire the load event until all stylesheets and scripts are done downloading.
When this load event occurs, Cypress will continue running commands.[Learn more](https://on.cypress.io/origin)
Desired behavior
No response
Test code to reproduce
commands.ts
Cypress.Commands.add('loginByGoogleApi', () => {
cy.log('Logging in to Google')
cy.request({
method: 'POST',
url: 'https://www.googleapis.com/oauth2/v4/token',
body: {
grant_type: 'refresh_token',
client_id: Cypress.env('googleClientId'),
client_secret: Cypress.env('googleClientSecret'),
refresh_token: Cypress.env('googleRefreshToken'),
},
}).then(({ body }) => {
const { access_token, id_token } = body
cy.request({
method: 'GET',
url: 'https://www.googleapis.com/oauth2/v3/userinfo',
headers: { Authorization: `Bearer ${access_token}` },
}).then(({ body }) => {
cy.log(body)
const userItem = {
token: id_token,
user: {
googleId: body.sub,
email: body.email,
givenName: body.given_name,
familyName: body.family_name,
imageUrl: body.picture,
},
}
window.localStorage.setItem('googleCypress', JSON.stringify(userItem))
cy.visit('/')
})
})
})
test-file.cy.ts
it.only('Send email to a user - is shown in the activity', () => {
cy.loginByGoogleApi();
cy.get(loc.sideNavBar.buyersPipeline).should('be.visible').click();
cy.get(loc.pipelineBuyer.nameColumn)
.eq(4)
.should('be.visible')
.click({ force: true });
cy.get(loc.buyerDetails.basicCard).should('be.visible');
cy.get(loc.buyerDetails.timelineSendEmailIcon)
.should('be.visible')
.click();
cy.get('div[role="dialog"]').find('button.MuiButton-root').should('be.visible').click();
})
})
By cy.origin() by the UI:
test-file.cy.ts
it.only('Send email to a user - is shown in the activity', () => {
// cy.loginByGoogleApi();
cy.get(loc.sideNavBar.buyersPipeline).should('be.visible').click();
cy.get(loc.pipelineBuyer.nameColumn)
.eq(4)
.should('be.visible')
.click({ force: true });
cy.get(loc.buyerDetails.basicCard).should('be.visible');
cy.get(loc.buyerDetails.timelineSendEmailIcon)
.should('be.visible')
.click();
cy.get('div[role="dialog"]').find('button.MuiButton-root').should('be.visible').click();
cy.wait(5000);
cy.origin('https://accounts.google.com', () => {
cy.wait(5000);
expect(window.origin).contains('google.com')
cy.get('input[type="email"]', {timeout: 60000}).should('be.visible', {timeout: 60000}).type('111');
})
});
````
### Cypress Version
10.7.0
### Node version
v14.19.1
### Operating System
macOS Montery 12.3.1

Disable redirect in fetch request using React native

I'm trying to crawl a web using React Native which has no API. It's written in PHP.
To log an user, a POST request must be sent. The response returns a cookie with a PHPSessid cookie which I must capture to use in subsequent requests.
I would like to capture the cookie value, buy the POST response is a 302 and the redirection is followed automatically, so I can't see the cookie. In node I was able to do it with redirect:manual, but it does not work in react native.
The cookie is sent automatically in subsequent requests, buy I'm trying to manage cookies by hand with react-native-cookie and I'd like to know if it's possible.
Do you know a way to stop the redirection?
I've been checking the code and what I did was the following:
Clear all cookies
Launch an empty login request
Capture the PHPSessID coookie
Launch a login request with that PHPSessID
After that, the subsequent fetch requests would have automatically a PHPSessID cookie with a valid logged in user, so we can use the site with simple fetchs
Here is some code, but the important thing is that you do a first empty login request, capture the PHPSessid and launch the real login request with that PHPSessid.
This would be the main function:
import Cookie from 'react-native-cookie';
// I think this is used only to clear the cookies
function login(user, pass){
// clear all cookies for all domains
// We need to start withouth authorization token
Cookie.clear();
const makeLoginRequest = (sessid) =>
makeLoginRequestForUserAndPass(user,pass,sessid);
return makeInitialRequest()
.then(getSessionIDFromResponse)
.then(makeLoginRequest)
.then(checkIfLoggedAndGetSessionID);
}
The initial request is a request to the login script. Note that I used GET because it worked with my site, perhaps an empty post would be necessary:
function makeInitialRequest() {
const INIT_PATH = '/index.php?r=site/login';
const INIT_URL = site + INIT_PATH;
const request = new Request(INIT_URL, options....);
return fetch(request);
}
We have the session ID in the response. I used a simple regex to extract it. Note that we are not logged in; PHP has created a session and that's what we have here:
function getSessionIDFromResponse(response) {
return getPHPSessIdFromCookie(response.headers.get('set-cookie'));
}
function getPHPSessIdFromCookie(header) {
const regex = /PHPSESSID=(\w*)/;
const match = regex.exec(header);
return match ? match[1] : '';
}
Now the login request. Note that I can't stop redirection here, but I't have to do it because we can have PHPSessid later. Redirection must be set to manual in POST request:
function makeLoginRequestForUserAndPass(user, pass, sessid) {
const request = buildLoginRequest(user, pass, sessid);
return fetch(request);
}
// This is where we build the real login request
function buildLoginRequest(user, pass, sessid) {
const LOGIN_PATH = '/index.php?r=site/login';
const LOGIN_URL = site + LOGIN_PATH;
const fields = [
{name: 'LoginForm[username]', value: user},
{name: 'LoginForm[password]', value: pass},
etc...
];
const data = translateFieldsToURLEncodedData(fields);
const headers = {
'Content-type': 'application/x-www-form-urlencoded',
Cookie: `PHPSESSID=${sessid}`, // HERE is where you put the data
};
const options = { method: 'POST',
headers: headers,
mode: 'cors',
cache: 'default',
agent: proxy,
body: data,
redirect: 'manual' // VERY IMPORTANT: if you don't do it, the cookie is lost
};
return new Request(LOGIN_URL, options);
}
// Simple utility function
function translateFieldsToURLEncodedData(fields){
let pairs = fields.map( (field) => {
return encodeURIComponent(field.name) + '=' + encodeURIComponent(field.value);
});
return pairs.join('&');
}
This is the last part. To see if I was logged in I checked if the response had text belonging to login error's page. I also got the PHPSessid (I think it changed after login, not sure, it was a year ago) but I don't know if I used it, I believe it was included automatically in subsequent requests. I think this part could be simplified an improved:
function checkIfLoggedAndGetSessionID(response) {
return (
checkIfLoggedOK(response)
.then(() => getSessionIDFromResponse(response))
);
}
function checkIfLoggedOK(response){
return getTextFromResponse(response)
.then(throwErrorIfNotLogedOk);
}
function getTextFromResponse(response) {
return response.text();
}
function throwErrorIfNotLogedOk(page) {
if(isErrorPage(page)) throw new Error("Login failed");
}
function isErrorPage(text) {
const ERROR_MESSAGE = 'Something that appears in login failed page of your site';
let n = text.search(ERROR_MESSAGE);
return n !== -1;
}
Hope this can be useful.

conv.askToDeepLink is not a function

The old way of invoke the askToDeepLink are no more working in DialogFlow even in V1.
app.askToDeepLink('Great! Looks like we can do that in the Android app.', 'tool for Android',
'sample://scheduleMeeting', 'com.sample', 'handle this for you');
Anybody know the way to invoke askToDeepLink in V2?
If you are trying to connect one of your Android apps with your Assistant app, you should try this.
app.intent('ask_to_deep_link_detail', (conv) => {
const options = {
destination: 'Google',
url: 'example://gizmos',
package: 'com.example.gizmos',
reason: 'handle this for you',
};
conv.ask('Great! looks like maybe we can do that in the app.');
conv.ask(new DeepLink(options));
});
Getting the results of the helper
If the user accepts the link, the dialog with your app will end and you will not receive further requests. If they reject the link, you will receive another request with the intent actions.intent.LINK and a status argument:
app.intent('ask_to_deep_link_confirmation', (conv, params, response) => {
conv.ask('Okay maybe we can take care of that another time.');
});
I hope this helps.

Puppeteer's page.cookies() not retrieving all cookies shown in the Chrome dev tools

Using puppeteer, I am trying to retrieve all cookies for a specific web site (i.e. https://google.com) from Node.js.
My code is:
// Launch browser and open a new page
const browser = await puppeteer.launch({ headless: true, args: ['--disable-dev-shm-usage'] });
const page = await browser.newPage();
await page.goto(url, { waitUntil: 'networkidle2' });
var cookies = await page.cookies();
console.log(cookies);
await browser.close();
It only retrieves 2 cookies, named 1P_JAR and NID. However, when I open the Chrome Dev tools, it shows a lot more.
I tried using the Chrome Dev Tools directly instead of puppeteer but I am getting the same results.
Is there another function I should call? Am I doing it correctly?
The page.cookies() call only gets cookies that are available to JavaScript applications inside the browser, and not the ones marked httpOnly, which you see in the Chrome DevTools. The solution is to ask for all available cookies through the Devtools protocol and then filter for the site you're interested in.
var data = await page._client.send('Network.getAllCookies');
You can utilise Chrome DevTools Protocol -> getAllCookies
To get all browser cookies, regardless of any flags.
const client = await page.target().createCDPSession();
const cookies = (await client.send('Network.getAllCookies')).cookies;
This will also play nice with typescript and tslint since something like
const cookies = await page._client.send('Network.getAllCookies');
Will raise an error TS2341: Property '_client' is private and only accessible within class 'Page'..
Thanks #try-catch-finally. I got it resolved and it was a simple rookie mistake.
I was comparing cookies in my own Google Chrome instance with the Puppeteer instance. However, in my instance, I was logged in to my Google account and Puppeteer (obviously) was not.
Google uses 2 cookies when you are NOT logged in and 12 when you are logged in.
If you use Playwright in place of Puppeteer, httponly cookies are readily accessible:
const { chromium } = require('playwright')
(async () => {
const browser = await chromium.launch()
const context = await browser.newContext()
const page = await context.newPage()
await page.goto('https://google.com', { waitUntil: 'networkidle' })
let allCookies = await context.cookies()
console.log (allCookies)
})();
returns:
[
{
sameSite: 'None',
name: '1P_JAR',
value: '2021-01-27-19',
domain: '.google.com',
path: '/',
expires: 1614369040.389115,
httpOnly: false,
secure: true
},
{
sameSite: 'None',
name: 'NID',
value: '208=VXtmbaUL...',
domain: '.google.com',
path: '/',
expires: 1627588239.572781,
httpOnly: true,
secure: false
}
]
Just use it await page.goto('https://google.com', { waitUntil: 'networkidle2' }). And you can get all the cookies related.

RESTful Chromless implementation

I am looking for a way to use headless chrome similar to what chromeless does but instead of being implemented as a nodejs endpoint, allowing restful requests with the html content as a payload.
I want to run this service on aws lambda being triggered through API Gateway. Does anyone have experience with this usecase?
There's nothing keeping you from using Chromeless in your use-case. Chromeless can be used within an AWS Lambda function. You can take a (RESTful) request coming from AWS API Gateway and then do something with it and Chromeless. You can combine the #serverless-chrome/lambda package with Chromeless to get headless Chrome running within Lambda so that Chrome is available to Chromeless. The Chromeless Proxy works in a similar way. For example, your Lambda function's code might look like (this is untested code I just cobbled together, but should convey the idea):
const launchChrome = require('#serverless-chrome/lambda')
const Chromeless = require('chromeless').Chromeless
module.exports.handler = function handler (event, context, callback) {
const body = JSON.parse(event.body) // event.body coming from API Gateway
const url = body.url
const evaluateJs = body.evaluateJs
launchChrome({
flags: ['--window-size=1280x1696', '--hide-scrollbars'],
})
.then((chrome) => {
// Chrome is now running on localhost:9222
const chromeless = new Chromeless({
launchChrome: false,
})
chromeless
.goto(url)
.wait('body')
.evaluate(() => `
// this will be executed in headless chrome
${evaluateJs}
`)
.then((result) => {
chromeless
.end()
.then(chrome.kill) // https://github.com/adieuadieu/serverless-chrome/issues/41#issuecomment-317989508
.then(() => {
callback(null, {
statusCode: 200,
body: JSON.stringify({ result })
})
})
})
.catch(callback)
})
.catch((error) => {
// Chrome didn't launch correctly
callback(error)
})
}
You'll find a similar thread on the Chromeless Issue tracker here.
Disclosure: I'm a collaborator/author of these packages.