Skip to main content
Cypress应用

Auth0认证

info
你将学习到
  • 如何在Cypress测试中使用Auth0进行认证
  • 如何为测试适配Auth0应用
  • Auth0速率限制的注意事项

本指南针对使用经典通用登录体验Auth0单页应用测试进行设置。此配置推荐用于自动化端到端测试的"测试租户"和/或"测试API"设置。

Auth0应用设置

要开始使用Auth0,需要通过以下步骤在Auth0仪表板中设置应用:

  1. 访问Auth0仪表板并点击"创建应用"按钮。
  2. 输入应用的名称。
  3. 选择"单页应用"

应用创建后,访问应用下的应用设置标签页,并在以下部分添加本地开发URL和端口(例如http://localhost:3000):

  • 允许的回调URL
  • 允许的登出URL
  • 允许的Web来源
  • 允许的来源(CORS)

应用设置底部,点击显示高级设置,选择"授权类型"标签页并勾选"密码"(默认未勾选)。

接下来,点击租户图标(右上角头像菜单)进入租户设置。在常规标签页中,前往API授权设置

  • 将"默认受众"设置为测试应用的受众URL(例如https://your-api-id.auth0.com/api/v2/
  • 将"默认目录"设置为**"用户名-密码认证"**
info

参考Auth0租户设置文档获取更多详情。

最后,在Auth0用户存储中为Cypress测试创建一个用户。这个专用于测试的目标用户将在测试规范中登录你的应用。如果测试需要,你可以创建多个用户来测试特定应用。

在Cypress中设置Auth0应用凭证

为了在测试中访问测试用户凭证,我们需要配置Cypress使用.env文件中设置的Auth0环境变量。

const { defineConfig } = require('cypress')
// 从.env文件填充process.env
require('dotenv').config()

module.exports = defineConfig({
env: {
auth0_username: process.env.AUTH0_USERNAME,
auth0_password: process.env.AUTH0_PASSWORD,
auth0_domain: process.env.REACT_APP_AUTH0_DOMAIN,
auth0_audience: process.env.REACT_APP_AUTH0_AUDIENCE,
auth0_scope: process.env.REACT_APP_AUTH0_SCOPE,
auth0_client_id: process.env.REACT_APP_AUTH0_CLIENTID,
auth0_client_secret: process.env.AUTH0_CLIENT_SECRET,
},
})

注意auth0_client_secret仅用于编程式登录

Auth0认证的自定义命令

有两种方式可以认证到Auth0:

使用cy.origin()登录

接下来,我们将编写一个名为loginToAuth0的自定义命令来执行Auth0登录。该命令将使用cy.origin()来:

  1. 导航到Auth0登录页
  2. 输入用户凭证
  3. 登录并重定向回Cypress Real World App
  4. 使用cy.session()缓存结果
cypress/support/auth-provider-commands/auth0.ts
function loginViaAuth0Ui(username: string, password: string) {
// 应用登录页重定向到Auth0
cy.visit('/')

// 在Auth0上登录
cy.origin(
Cypress.env('auth0_domain'),
{ args: { username, password } },
({ username, password }) => {
cy.get('input#username').type(username)
cy.get('input#password').type(password, { log: false })
cy.contains('button[value=default]', 'Continue').click()
}
)

// 确保Auth0已将我们重定向回RWA
cy.url().should('equal', 'http://localhost:3000/')
}

Cypress.Commands.add('loginToAuth0', (username: string, password: string) => {
const log = Cypress.log({
displayName: 'AUTH0 LOGIN',
message: [`🔐 认证中 | ${username}`],
// @ts-ignore
autoEnd: false,
})
log.snapshot('before')

loginViaAuth0Ui(username, password)

log.snapshot('after')
log.end()
})

现在,我们可以在测试中使用loginToAuth0命令。以下是通过Auth0登录用户并运行基本健全性检查的测试。

auth.cy.js
describe('Auth0', function () {
beforeEach(function () {
cy.task('db:seed')
cy.intercept('POST', '/graphql').as('createBankAccount')
cy.loginToAuth0(
Cypress.env('auth0_username'),
Cypress.env('auth0_password')
)
cy.visit('/')
})

it('显示引导页面', function () {
cy.contains('Get Started').should('be.visible')
})
})

最后,我们可以重构登录命令以利用cy.session()存储已登录用户,这样我们就不必在每次测试前重新认证。

cypress/support/commands.js
Cypress.Commands.add('loginToAuth0', (username: string, password: string) => {
const log = Cypress.log({
displayName: 'AUTH0 LOGIN',
message: [`🔐 认证中 | ${username}`],
// @ts-ignore
autoEnd: false,
})
log.snapshot('before')

cy.session(
`auth0-${username}`,
() => {
loginViaAuth0Ui(username, password)
},
{
validate: () => {
// 验证localStorage中是否存在访问令牌
cy.wrap(localStorage)
.invoke('getItem', 'authAccessToken')
.should('exist')
},
}
)

log.snapshot('after')
log.end()
})

编程式登录

以下是使用/oauth/token端点编程式登录Auth0的命令,并在localStorage中设置一个包含认证用户详情的项,我们将在应用代码中使用它来验证测试中的认证状态。

loginByAuth0Api命令将执行以下步骤:

  1. 使用/oauth/token端点执行编程式登录。
  2. 最后将access tokenid_token和用户配置文件设置为localStorage中的auth0Cypress项。
cypress/support/commands.js
Cypress.Commands.add(
'loginByAuth0Api',
(username: string, password: string) => {
cy.log(`${username}身份登录`)
const client_id = Cypress.env('auth0_client_id')
const client_secret = Cypress.env('auth0_client_secret')
const audience = Cypress.env('auth0_audience')
const scope = Cypress.env('auth0_scope')

cy.request({
method: 'POST',
url: `https://${Cypress.env('auth0_domain')}/oauth/token`,
body: {
grant_type: 'password',
username,
password,
audience,
scope,
client_id,
client_secret,
},
}).then(({ body }) => {
const claims = jwt.decode(body.id_token)
const {
nickname,
name,
picture,
updated_at,
email,
email_verified,
sub,
exp,
} = claims

const item = {
body: {
...body,
decodedToken: {
claims,
user: {
nickname,
name,
picture,
updated_at,
email,
email_verified,
sub,
},
audience,
client_id,
},
},
expiresAt: exp,
}

window.localStorage.setItem('auth0Cypress', JSON.stringify(item))

cy.visit('/')
})
}
)

通过正确设置Auth0开发者控制台中的应用、必要的环境变量以及实现loginByAuth0Api命令,我们能够在测试应用时使用Auth0进行认证。以下是通过Auth0登录用户、完成引导流程并登出的测试。

auth.cy.js
describe('Auth0', function () {
beforeEach(function () {
cy.task('db:seed')
cy.loginByAuth0Api(
Cypress.env('auth0_username'),
Cypress.env('auth0_password')
)
})

it('显示引导页面', function () {
cy.contains('Get Started').should('be.visible')
})
})

为测试适配Auth0应用

info
注意

前几节重点介绍了Cypress测试中推荐的Auth0认证实践。要使用此实践,假设你正在测试一个已正确构建或适配为使用Auth0的应用。

以下部分提供了构建或适配应用以使用Auth0认证的指南。请注意,如果你正在使用cy.origin()登录且你的应用已成功集成Auth0,则无需对应用进行任何更改,本指南的其余部分应仅作为信息参考。

真实世界应用(RWA)被使用,并为React SPA和Express后端提供了配置和可运行代码。

前端使用auth0-react SDK用于React单页应用(SPA),其底层使用auth0-spa-js SDK。后端使用express-jwt来验证针对Auth0的JWT。

info
注意

启动Cypress Real World App时使用yarn dev:auth0命令。

适配后端

为了验证来自前端的API请求,我们安装express-jwtjwks-rsa并配置验证来自Auth0的JWT。

backend/helpers.ts
import jwt from 'express-jwt'
import jwksRsa from 'jwks-rsa'

dotenv.config()

const auth0JwtConfig = {
secret: jwksRsa.expressJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/.well-known/jwks.json`,
}),

// 验证受众和发行者
audience: process.env.REACT_APP_AUTH0_AUDIENCE,
issuer: `https://${process.env.REACT_APP_AUTH0_DOMAIN}/`,
algorithms: ['RS256'],
}

接下来,我们将定义一个Express中间件函数,用于在我们的路由中验证前端API请求发送的Auth0 JWT作为Bearer令牌。

backend/helpers.ts
// ...

export const checkJwt = jwt(auth0JwtConfig).unless({ path: ['/testData/*'] })

定义此辅助函数后,我们可以全局应用它到所有路由:

backend/app.ts
// 初始导入...
import { checkJwt } from './helpers'

// ...

if (process.env.REACT_APP_AUTH0) {
app.use(checkJwt)
}

// 路由...

适配前端

我们需要更新前端React应用以允许使用Auth0进行认证。如上所述,使用auth0-react SDK用于React单页应用(SPA)。

首先,我们创建一个AppAuth0.tsx容器来渲染我们的应用,因为它已通过Auth0认证。该组件与App.tsx组件相同,但使用了useAuth0 React Hook,移除了注册和登录路由的需求,并用withAuthenticationRequired高阶函数(HOC)包装了组件。

添加了一个useEffect钩子来获取认证用户的访问令牌,并发送一个带有usertoken对象的AUTH0事件,以与现有的认证层(authMachine.ts)一起工作。

containers/AppAuth0.tsx
// 初始导入...

import { withAuthenticationRequired, useAuth0 } from '@auth0/auth0-react'

// ...

const AppAuth0 = () => {
const { isAuthenticated, user, getAccessTokenSilently } = useAuth0()

// ...

useEffect(() => {
;(async function waitForToken() {
const token = await getAccessTokenSilently()
authService.send('AUTH0', { user, token })
})()
}, [user, getAccessTokenSilently])

// ...

const isLoggedIn =
isAuthenticated &&
(authState.matches('authorized') ||
authState.matches('refreshing') ||
authState.matches('updating'))

return <div className={classes.root}>// ...</div>
}

export default withAuthenticationRequired(AppAuth0)

注意:完整的AppAuth0.tsx组件真实世界应用(RWA)中。

接下来,我们更新入口点(index.tsx),用auth0-react SDK<Auth0Provider>包装我们的应用,提供一个自定义的onRedirectCallback。我们传递上面.env中设置的Auth0环境变量的props,并渲染我们的<AppAuth0>组件作为应用。

index.tsx
// 初始导入...

import AppAuth0 from "./containers/AppAuth0";

// ..

const onRedirectCallback = (appState: any) => {
history.replace((appState && appState.returnTo) || window.location.pathname);
};

if (process.env.REACT_APP_AUTH0) {
ReactDOM.render(
<Auth0Provider
domain={process.env.REACT_APP_AUTH0_DOMAIN!}
clientId={process.env.REACT_APP_AUTH0_CLIENTID!}
redirectUri={window.location.origin}
audience={process.env.REACT_APP_AUTH0_AUDIENCE}
scope={process.env.REACT_APP_AUTH0_SCOPE}
onRedirectCallback={onRedirectCallback}
>
<Router history={history}>
<ThemeProvider theme={theme}>
<AppAuth0 />
</ThemeProvider>
</Router>
</Auth0Provider>,
document.getElementById("root")
);
} else {
// 渲染passport-local App.tsx
}

需要更新我们的AppAuth0.tsx组件以有条件地使用auth0Cypress localStorage项。

在下面的代码中,我们基于在Cypress下测试(使用window.Cypress)有条件地应用useEffect块。

此外,我们将更新导出,如果不在Cypress测试中,则用withAuthenticationRequired包装。这允许我们的应用在开发/生产中使用Auth0重定向登录流程,但在Cypress测试中不使用。

containers/AppAuth0.tsx
// 初始导入...

import { withAuthenticationRequired, useAuth0 } from "@auth0/auth0-react";

// ...

const AppAuth0 = () => {
const { isAuthenticated, user, getAccessTokenSilently } = useAuth0();

// ...

useEffect(() => {
(async function waitForToken() {
const token = await getAccessTokenSilently();
authService.send("AUTH0", { user, token });
})();
}, [user, getAccessTokenSilently]);

// 如果在Cypress测试中,从"auth0Cypress" localstorage获取凭证
// 并向我们的状态管理发送事件以将用户登录到SPA
if (window.Cypress) {
useEffect(() => {
const auth0 = JSON.parse(localStorage.getItem("auth0Cypress")!);
authService.send("AUTH0", {
user: auth0.body.decodedToken.user,
token: auth0.body.access_token,
});
}, []);
} else {
useEffect(() => {
(async function waitForToken() {
const token = await getAccessTokenSilently();
authService.send("AUTH0", { user, token });
})();
}, [isAuthenticated, user, getAccessTokenSilently]);
}

// ...

const isLoggedIn =
isAuthenticated &&
(authState.matches("authorized") ||
authState.matches("refreshing") ||
authState.matches("updating"));

return (
<div className={classes.root}>
// ...
</div>
);
};

// 如果不在Cypress测试中,则有条件导出用`withAuthenticationRequired`包装
let appAuth0 = window.Cypress ? AppAuth0 : withAuthenticationRequired(AppAuth0);
export default appAuth0

Auth0登录速率限制

注意Auth0文档中的速率限制 -

随着测试套件规模的增大以及启用并行运行以加快测试运行时间,可能会达到此限制。

如果遇到此速率限制,可以在测试运行前向loginByAuth0命令添加编程式方法来清除被阻止的IP。

你需要获取一个API令牌来与Auth0管理API交互。此令牌是一个JSON Web令牌(JWT),它包含API的特定授予权限。

将此令牌作为环境变量AUTH0_MGMT_API_TOKEN添加到我们的真实世界应用(RWA) .env文件中,使用你的API令牌。

.env
// ... 其他键
AUTH0_MGMT_API_TOKEN = '你的管理API令牌'

有了这个令牌,我们可以向Auth0异常删除被阻止IP地址端点添加交互到我们的loginByAuth0Api命令。这将向Auth0管理API异常端点发送删除请求,以解除可能在测试运行期间被阻止的IP。

info

icanhazip.com是一个免费的托管服务,用于查找系统的当前外部IP地址。

cypress/support/commands.js
Cypress.Commands.add('loginByAuth0Api', (username, password) => {
// 当被Auth0速率限制时有用
cy.exec('curl -4 icanhazip.com')
.its('stdout')
.then((ip) => {
cy.request({
method: 'DELETE',
url: `https://${Cypress.env(
'auth0_domain'
)}/api/v2/anomaly/blocks/ips/${ip}`,
auth: {
bearer: Cypress.env('auth0_mgmt_api_token'),
},
})
})

// ... loginByAuth0Api命令的剩余部分
})