How to test single page application with Cypress and Auth0 - auth0

I am having a single page application hidden behind Auth0 lock, using #auth0/auth0-spa-js. I would like to test it using Cypress, so I have decided to follow the official Auth0 blog post, as well as Johnny Reilly blog post.
I am able to successfully retrieve valid JWT token from auth0 using suggested request. I have no idea what to do with it :(
The trouble I am facing is that both of the above approaches are relying on the app to store the JWT token locally (either in cookie or localstorage). The #auth0/auth0-spa-js is, however, using a different approach, and I assume all the relevant cookies/localstorage is stored on auth0 domains.
Do you have any idea, if there is a way to get around it?
There is a similar issue reported here raised in July 2018, not really providing any solution

I found a resolved issue on #auth0/auth0-spa-js github. The approach suggested by cwmrowe seems to be working
The solution is to mock the response of oauth/token endpoint with token generated on e2e test side.
The approach seems to be working for us
I am copying over the sample code cwmrowe has provided
Cypress.Commands.add(
'login',
(username, password, appState = { target: '/' }) => {
cy.log(`Logging in as ${username}`);
const options = {
method: 'POST',
url: Cypress.env('Auth0TokenUrl'),
body: {
grant_type: 'password',
username,
password,
audience: Cypress.env('Auth0Audience'),
scope: 'openid profile email',
client_id: Cypress.env('Auth0ClientId'),
client_secret: Cypress.env('Auth0ClientSecret')
}
};
cy.request(options).then(({ body }) => {
const { access_token, expires_in, id_token } = body;
cy.server();
// intercept Auth0 request for token and return what we have
cy.route({
url: 'oauth/token',
method: 'POST',
response: {
access_token,
expires_in,
id_token,
token_type: 'Bearer'
}
});
// Auth0 SPA SDK will check for value in cookie to get appState
// and validate nonce (which has been removed for simplicity)
const stateId = 'test';
const encodedAppState = encodeURI(JSON.stringify(appState));
cy.setCookie(
`a0.spajs.txs.${stateId}`,
`{%22appState%22:${encodedAppState}%2C%22scope%22:%22openid%20profile%20email%22%2C%22audience%22:%22default%22}`
);
const callbackUrl = `/auth/callback?code=test-code&state=${stateId}`;
return cy.visit(callbackUrl);
});
}
);
declare namespace Cypress {
interface Chainable<Subject> {
login(
username: string,
password: string,
appState?: any
): Chainable<Subject>;
}
}

Whilst it's not recommended to use the UI to login I do this myself once prior to all tests and then use the silent auth for the tests:- cy.visit("/") silent auths and allows access to the app.
integration/app.js
describe("App", () => {
before(() => {
Cypress.config("baseUrl", "http://localhost:3000");
cy.login();
});
/** Uses silent auth for successive tests */
beforeEach(() => {
cy.restoreLocalStorage();
});
afterEach(() => {
cy.saveLocalStorage();
});
/** tests */
support/commands.js
/**
* Auth0 login
* https://github.com/cypress-io/cypress/issues/461#issuecomment-392070888
*
* Allows silent auth login between tests
*/
let LOCAL_STORAGE_MEMORY = {};
Cypress.Commands.add("saveLocalStorage", () => {
Object.keys(localStorage).forEach(key => {
LOCAL_STORAGE_MEMORY[key] = localStorage[key];
});
});
Cypress.Commands.add("restoreLocalStorage", () => {
Object.keys(LOCAL_STORAGE_MEMORY).forEach(key => {
localStorage.setItem(key, LOCAL_STORAGE_MEMORY[key]);
});
});
Cypress.Commands.add("clearLocalStorage", () => {
LOCAL_STORAGE_MEMORY = {};
});

For those who has issue with Google Sign in for Cypress look at the plugin: https://github.com/lirantal/cypress-social-logins/
it('Login through Google', () => {
const username = Cypress.env('googleSocialLoginUsername')
const password = Cypress.env('googleSocialLoginPassword')
const loginUrl = Cypress.env('loginUrl')
const cookieName = Cypress.env('cookieName')
const socialLoginOptions = {
username,
password,
loginUrl,
headless: false,
isPopup: true,
logs: false,
loginSelector: 'a[href="/auth/auth0/google-oauth2"]',
postLoginSelector: '.account-panel'
}
return cy.task('GoogleSocialLogin', socialLoginOptions).then(({cookies}) => {
cy.clearCookies()
const cookie = cookies.filter(cookie => cookie.name === cookieName).pop()
if (cookie) {
cy.setCookie(cookie.name, cookie.value, {
domain: cookie.domain,
expiry: cookie.expires,
httpOnly: cookie.httpOnly,
path: cookie.path,
secure: cookie.secure
})
Cypress.Cookies.defaults({
whitelist: cookieName
})
}
})
});

Related

How to set cookies in Cypress before visiting react app running on localhost

The steps I want to take are:
Start the Cypress test-suite and use cy.setCookie to set the JSESSIONID cookie (already acquired and up-to-date)
After the cookie is set, then use cy.visit to access the running app
The issue:
The cookie is not set before cy.visit runs and this causes the app redirect to an unauthorized page
What I have done so far:
Cypress.Cookies.defaults({
preserve: 'JSESSIONID'
})
cy.setCookie('JSESSIONID', Cypress.env('JSESSIONID'), {
path: '/',
domain: '<domain.name>',
httpOnly: true,
secure: true,
sameSite: 'no_restriction',
log: true,
}).then(() => cy.visit('localhost:3000/<authenticated-route>')
It might be worth mentioning that <domain.name> is of the form www.staging.etc.com whereas is running locally: localhost:3000/
Any help would be greatly appreciated. Thanks in advance
I solved the issue by doing a cy.request to login before using cy.visit.
Code looks something like this:
const login = () => {
const headers = new Headers()
headers.append("Content", "application/x-www-form-urlencoded")
headers.append("Accept-Encoding", "gzip:deflate")
headers.append("Content-Type", "application/x-www-form-urlencoded")
cy.request({
url: Cypress.env("LOGIN_URL"),
method: 'POST',
form: true,
headers,
body: {
"email": Cypress.env("EMAIL"),
"password": Cypress.env("PASSWORD")
}
}).then((response) => {
console.log(response)
console.log(response.body)
setCookie(response.COOKIE)
})
}
export const loginAndStartTests = () => {
login()
cy.visit('/<homepage>')
}
Take a look at Provide an onBeforeLoad callback function
The example recipe mentioned (code is here) is setting a token in local storage, but should apply as well to cookies
cy.visit('http://localhost:3000/#dashboard', {
onBeforeLoad: (contentWindow) => {
cy.setCookie('JSESSIONID', Cypress.env('JSESSIONID'), {
path: '/',
...
})
}
})
I think the problem is that cy.visit() is one of the places where everything is cleared down, but the hook is provided to get around that. Although, I would expect preserve to work as well.
You need to set a cookie from the argument contentWindow:
A cookie setter util:
export const setCookieToContentWindow = (
contentWindow,
name,
value,
{ expireMinutes = 1 } = {},
) => {
const date = new Date();
const expireTime = expireMinutes * 60 * 1000;
date.setTime(date.getTime() + expireTime);
const assignment = `${name}=${encodeURIComponent(value)}`;
const expires = `expires=${date.toGMTString()}`;
const path = 'path=/';
contentWindow.document.cookie = [assignment, expires, path].join(';');
};
Using onBeforeLoad:
cy.visit('http://localhost:3000/#dashboard', {
onBeforeLoad: (contentWindow) => {
setCookieToContentWindow(contentWindow, 'COOKIE_NAME', 'COOKIE_VALUE');
},
});

Oauth2 Google Authentication flow - Next.JS / Express

I am using a React/Next.Js Frontend and am trying to implement authentication with the Oauth2 strategy with Google.
I am very confused by the process.
Currently on the client, I have a Google sign in component that has a Client ID with in it and can retrieve an access token.
<GoogleLogin
clientId="myclientid"
buttonText="Login"
onSuccess={userLogin}
onFailure={userLogin}
cookiePolicy={'single_host_origin'}
/>
I then have a function, which on success sends a post message to my backend with an access token, such as this:
export function googleAuthenticate(accessToken : string) : any{
axios({
method: 'post',
url: "http://localhost:4000/auth/google",
data: {
accessToken: accessToken
}
})
.then(res => {
console.log(res);
})
.catch(err => {
console.log("Failure!");
console.log(err);
})
};
On the backend I am using passport, and the routes look like this:
import express from 'express';
import passport from 'passport';
import Logger from '../logger/index';
const router = express.Router();
export function isAuthenticated(req:express.Request, res:express.Response, next : any) {
return req.isAuthenticated() ?
next() :
res.sendStatus(401);
}
router.get('/fail', (_req:express.Request, res:express.Response) => {
res.json({ loginFailed: true });
});
router.post('/google', passport.authenticate('google', { scope: ['profile']}), (_req:express.Request, _res:express.Response) => {
Logger.info("GET Request at Google Authentication endpoint received.");
});
router.get(
'/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(_req:express.Request, res:express.Response) => {
res.redirect('/graphql');
}
);
export default router;
My passport module looks like this:
module.exports = function(passport : any, GoogleStrategy : any){
passport.use(new GoogleStrategy({
clientID: config.google.client_id,
clientSecret: config.google.client_secret,
callbackURL: config.google.redirect_url
},
function(accessToken : string, profile : Profile, refreshToken : string, cb : any) {
return cb(null, {
id: profile.googleId,
username: profile.email,
image: profile.imageUrl,
firstName: profile.givenName,
surname: profile.familyName,
accessToken: accessToken,
refreshToken: refreshToken
})
}
));
}
Since Next.js is a server side rendered, I am not able to use save a token. I understand I have to use a cookie. But how does this work? I cannot redirect the client browser from the express backend.
Currently I'm just seeing these 2 errors:
OPTIONS https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2localhost:3000%2Fdashboard&scope=profile&client_id=687602672235-l0uocpfchbjp34j1jjlv8tqv7jadb8og.apps.googleusercontent.com 405
Access to XMLHttpRequest at 'https://accounts.google.com/o/oauth2/v2/auth?response_type=code&redirect_uri=http%3A%2F%2Flocalhost%3A4000%2Fbackoffice.dev.myos.co%2Fdashboard&scope=profile&client_id=687602672235-l0uocpfchbjp34j1jjlv8tqv7jadb8og.apps.googleusercontent.com' (redirected from 'http://localhost:4000/auth/google') from origin 'null' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
Firstly i think google auth will not work on localhost.
If i understand correctly in your serverside logic you can easily save your token as a cookie and then read them in the client.
Not sure with passport, but you can do something similar to this :
(my app is working with an implementation of this code)
frontend :
<GoogleLogin
clientId="myclientid"
buttonText="Login"
onSuccess={userLogin}
onFailure={userLogin}
cookiePolicy={'single_host_origin'}
/>
userLogin:
async userLogin(response){
var url = '/google-login/'+response.tokenObj.id_token
fetch(url).then(/* i will handle response*/)
}
Then in the backend you can use google-auth-library to login or register.
server.js:
const {OAuth2Client} = require('google-auth-library');
const GOOGLEID = "mygoogleid.apps.googleusercontent.com"
const client = new OAuth2Client(GOOGLEID);
var cookieParser = require('cookie-parser')
async function verify(userToken) {
const ticket = await client.verifyIdToken({
idToken: userToken,
audience: "clientid.apps.googleusercontent.com", // Specify the CLIENT_ID of the app that accesses the backend
// Or, if multiple clients access the backend:
//[CLIENT_ID_1, CLIENT_ID_2, CLIENT_ID_3]
});
const payload = ticket.getPayload();
const userid = payload['sub'];
return payload
// If request specified a G Suite domain:
//const domain = payload['hd'];
}
In server.js a route similar to this :
server.get('/google-login/:token',(req,res) => {
const userToken = req.params.token
var result = verify(userToken).then(function(result){
var userName = result.given_name
var userSurname = result.family_name
var userEmail = result.email
/*
Now user is authenticated i can send to the frontend
user info or user token o save the token to session
*/
}).catch(function(err){
// error handling
})
})
You could use NextAuth.js to handle this for you.
In order to test localhost you should use ngrok to expose your localhost server to the web and configure the given url in google platform

Get AWS Cognito user from ID Token retrieved from Token Endpoint

I am building a React Native app using Expo and AWS Cognito with AWS Amplify, and I am trying to enable signing in with Facebook, Google, etc. using AWS
I can create a user and sign in using Cognito APIs without any issue.
Using third-parties, though, requires using the Expo AuthSession functionality.
The functionality itself works fine, and I am able to get all the way to retrieving the proper tokens from my /oauth2/token endpoint.
However, as far as Amplify is concerned (and I am aware), the user is not signed in, so when I try to get Auth.currentAuthenticatedUser(), null is returned.
// Open URL in a browser
openURL = async (url) => {
let result = await AuthSession.startAsync({ authUrl: url })
this.getTokenbyCode(result.params.code)
};
getTokenbyCode = async (code) => {
const details = {
grant_type: 'authorization_code',
code,
client_id: '10eavoe3ufj2d70m5m3m2hl4pl',
redirect_uri: AuthSession.getRedirectUrl()
}
const formBody = Object.keys(details)
.map(
key => `${encodeURIComponent(key)}=${encodeURIComponent(details[key])}`
)
.join("&");
await fetch(
'https://presentor.auth.us-west-2.amazoncognito.com/oauth2/token',
{
method: "POST",
headers: {
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: formBody
}
)
.then(async (res) => {
console.log('res: ', res);
let resJSON = await res.json();
let idToken = await resJSON.id_token;
let decodedToken = jwt(idToken);
let userData = {
Username : decodedToken["cognito:username"],
Pool : Auth.userPool
}
})
.catch(error => {
console.log('error: ', error);
});
}
When I decode the token, I see the payload as I expect, but if I want to, for example, utilize the APIs to refresh the token if it expires, I have to workaround manually (check for expiration and retrieve a new token if it's expired).
Am I missing something basic?
Ok, I figured it out. Not sure if this is the right path, but it's pretty clean and it works, so I'm good with it.
Create CognitoIdToken, CognitoAccessToken, and CognitoRefreshToken objects using amazon-cognito-identity-js
Create a user session from those tokens
Create a user from that user session
await fetch(
'TOKEN ENDPOINT',
{
method: "POST",
headers: {
'Content-type': 'application/x-www-form-urlencoded;charset=UTF-8'
},
body: formBody
}
)
.then(async (res) => {
const IdToken = new CognitoIdToken({ IdToken: tokenRequestJson.id_token });
const AccessToken = new CognitoAccessToken({ AccessToken: tokenRequestJson.access_token });
const RefreshToken = new CognitoRefreshToken({ RefreshToken: tokenRequestJson.refresh_token })
try {
let userSession = new CognitoUserSession({ IdToken, AccessToken, RefreshToken });
console.log('userSession: ', userSession);
const userData = {
Username: userSession.idToken.payload.email,
Pool: userPool
};
console.log('userData: ', userData);
cognitoUser = new CognitoUser(userData);
cognitoUser.setSignInUserSession(userSession);
cognitoUser.getSession((err, session) => { // You must run this to verify that session (internally)
if (session.isValid()) {
console.log('session is valid');
this.setState({user: cognitoUser})
this.props.navigation.navigate('AuthLoading')
} else {
console.log('session is not valid: ', session);
}
})
}
catch (FBSignInError) {
console.log('FBSignInError: ', FBSignInError)
}
})
.catch(fetchError => console.log('fetchError: ', fetchError))

How do I configure Amplify to to use multiple AppSync endpoints?

I need to support authenticated and unauthenticated AppSync requests in a React Native app. Since AppSync only allows one authorization type per API, I am setting up two APIs: one for authenticated users (Cognito User Pools), and one for guests (API Key).
I think to make this work I need to have two distinct AWSAppSyncClient configs in the same app.
// authenticated user
const appSyncAuthenticatedClient = new AWSAppSyncClient({
url: Config.APPSYNC_AUTHENTICATED_ENDPOINT,
region: Config.APPSYNC_REGION,
auth: {
type: 'AMAZON_COGNITO_USER_POOLS',
jwtToken: async () =>
(await Auth.currentSession()).getAccessToken().getJwtToken()
}
});
// guest
const appSyncUnauthenticatedClient = new AWSAppSyncClient({
url: Config.APPSYNC_UNAUTHENTICATED_ENDPOINT,
region: Config.APPSYNC_REGION,
auth: {
type: 'API_KEY',
apiKey: Config.APPSYNC_API_ID
}
});
and then determine which to use based on whether or not they are logged in
Auth.currentAuthenticatedUser()
.then(user => this.appSyncRunningClient = appSyncAuthenticatedClient)
.catch(err => this.appSyncRunningClient = appSyncUnauthenticatedClient);
const App = props => {
return (
<ApolloProvider client={this.appSyncRunningClient}>
<Rehydrated>
<RootStack/>
</Root>
</Rehydrated>
</ApolloProvider>
);
};
export default App;
This fails because currentAuthenticatedUser returns a promise, and I'm stuck at how to resolve a promise at this top level instantiation of the app. I'll also need to swap out this config during auth events.
In what way can I dynamically select and change the ApolloProvider config at startup and authentication events?
This is currently not possible. Until top-level await is officially supported you should create two Apollo clients one for the API and one for the Cognito.
for example: in your App.js
export default function App(props) {
const [client, setClient] = useState(null);
useEffect(() => {
checkAuth()
}, []);
function checkAuth() {
Auth.currentSession().then(session => {
const token = session.getIdToken();
const jwtToken = token.getJwtToken();
if (typeof jwtToken == "string") {
const authClientConfig = {
url: awsmobile.aws_appsync_graphqlEndpoint,
region: awsmobile.aws_appsync_region,
disableOffline: true,
auth: {
type: AUTH_TYPE.AMAZON_COGNITO_USER_POOLS,
jwtToken: jwtToken
}
}
const link = ApolloLink.from([createAuthLink(authClientConfig), createSubscriptionHandshakeLink(authClientConfig)]);
const authClient = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }) });
setClient(authClient);
} else {
throw "error";
}
}).catch(e => {
console.log(e);
const config = {
url: awsmobile.aws_appsync_graphqlEndpoint,
region: awsmobile.aws_appsync_region,
disableOffline: true,
auth: {
type: AUTH_TYPE.API_KEY,
apiKey: awsmobile.aws_appsync_apiKey
}
}
const link = ApolloLink.from([createAuthLink(config), createSubscriptionHandshakeLink(config)]);
const authClient = new ApolloClient({ link, cache: new InMemoryCache({ addTypename: false }) });
setClient(authClient);
})
}
if (!client) {
return "Loading..."
}
return (
<ApolloProvider client={client}>
...
</ApolloProvider>
);
}`
Things may have moved on as AppSync now supports multiple authentication types per API; however providing an answer as to how to auth/unauth on same endpoint for prosperity. Doesn't answer the how-to multiple endpoints question which is what led me here, but that's no longer required in OPs scenario.
Note: This answer applies to typescript - I'm not overly familiar with react but I think it will work exactly the same...
Unauthenticated access uses AWS_IAM / i.e. CognitoIdentityPool
(configured to allow unauthenticated access)
Authenticated Access users AMAZON_COGNITO_USER_POOLS authentication.
To switch between unauthenticated and authenticated API.graphql() calls. You need to test the current authentication status and use that to override the authMode as in the arguments to the API.graphql() call.
As a prerequisite:
The types in graphql must be setup to allow access via both #aws_iam and #aws_cognito_user_pools (see sample below)
The AppSync API must be configured to allow both authentication types (The code below assumes the API is configured for AWS_IAM by default, but allowed CognitoUserPools as an additional authentication type). This can be configured in console, or via cloudFormation.
Sample code for API call
let authMode;
try {
authMode = (await Auth.currentUserPoolUser()) ? GRAPHQL_AUTH_MODE.AMAZON_COGNITO_USER_POOLS : undefined;
} catch (err) { }
const result = await API.graphql({
...graphqlOperation(statement, gqlAPIServiceArguments),
authMode
});
Example grqphql type
type Profile #aws_iam #aws_cognito_user_pools {
username: ID!
stuff: String!
}
My Amplify Configuration
{
aws_project_region: 'VALUE_HERE',
aws_appsync_graphqlEndpoint: 'https://VALUE_HERE/graphql',
aws_appsync_region: 'VALUE_HERE',
aws_appsync_authenticationType: 'AWS_IAM',
aws_appsync_apiKey: 'XXXXXXXXXXXXXXXXXXXXXXXXXX', // This field seems to be required, but the value is ignored.
Auth: {
identityPoolId: 'VALUE_HERE',
region: 'VALUE_HERE',
userPoolId: 'VALUE_HERE',
userPoolWebClientId: 'VALUE_HERE',
oauth: {
domain: 'VALUE_HERE',
redirectSignIn: 'VALUE_HERE',
redirectSignOut: 'VALUE_HERE',
scope: ['email', 'openid', 'profile', 'aws.cognito.signin.user.admin'],
responseType: 'code'
}
}
};

JWT authentication with AXIOS

Using Vue webpack template, trying to make JWT authentication. What I've done so far:
"src/auth/index.js":
// Send a request to the login URL and save the returned JWT
login (creds, redirect) {
axios.post(LOGIN_URL, creds, (data) => {
localStorage.setItem('access_token', data.access_token)
this.user.authenticated = true
// Redirect to a specified route
if (redirect) {
router.push(redirect)
}
}).error((err) => {
context.error = err
})
},
I'm calling this function from LoginPage.vue:
methods: {
login () {
var credentials = {
username: this.credentials.username,
password: this.credentials.password
}
// We need to pass the component's this context
// to properly make use of http in the auth service
auth.login(this, credentials, 'requests')
}
}
When I'm submitting the form, data is submitted, but I get the following error in a console:
TypeError: __WEBPACK_IMPORTED_MODULE_1_axios___default.a.post(...).error is not a function
Also JWT token is not saving in my local storage, what am I doing wrong?
Rewrote login function:
login (context, creds, redirect) {
axios.post(LOGIN_URL, creds)
.then((response) => {
localStorage.setItem('access_token', response.data.access_token)
this.user.authenticated = true
if (redirect) {
router.push(redirect)
}
}).catch((err) => {
context.error = err.response.data
})
},
Everything is working now.