Skip to main content
Cypress应用

Okta认证

info
您将学习到
  • 如何在Cypress中测试Okta认证
  • 如何在Cypress中设置Okta凭证
  • 如何为测试适配Okta应用

本指南的范围仅演示针对Okta通用目录的认证。

Okta开发者控制台设置

如果尚未设置,您需要在Okta开发者控制台中创建一个Okta应用。创建Okta应用后,Okta开发者控制台将提供一个客户端ID,该ID将与您的Okta域名一起用于配置Okta SDK,如本指南后续部分所示。

在Cypress中设置Okta应用凭证

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

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

module.exports = defineConfig({
env: {
auth_username: process.env.AUTH_USERNAME,
auth_password: process.env.AUTH_PASSWORD,
okta_domain: process.env.REACT_APP_OKTA_DOMAIN,
okta_client_id: process.env.REACT_APP_OKTA_CLIENTID,
},
})

Okta认证的自定义命令

有两种方式可以进行Okta认证:

使用cy.origin()登录

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

  1. 导航到Okta源
  2. 输入用户凭证
  3. 登录并重定向回Cypress Real World App
  4. 使用cy.session()缓存结果
cypress/support/auth-provider-commands/okta.ts
// Okta
const loginToOkta = (username: string, password: string) => {
Cypress.log({
displayName: 'OKTA LOGIN',
message: [`🔐 Authenticating | ${username}`],
autoEnd: false,
})

cy.visit('/')
cy.origin(
Cypress.env('okta_domain'),
{ args: { username, password } },
({ username, password }) => {
cy.get('input[name="identifier"]').type(username)
cy.get('input[name="credentials.passcode"]').type(password, {
log: false,
})
cy.get('[type="submit"]').click()
}
)

cy.get('[data-test="sidenav-username"]').should('contain', username)
}
// 目前我们的自定义命令很简单。稍后会更多!
Cypress.Commands.add('loginByOkta', (username: string, password: string) => {
return loginToOkta(username, password)
})

现在,我们可以在测试中使用loginByOkta命令。以下是通过Okta以用户身份登录并运行一些基本检查的测试。

auth.cy.js
describe('Okta', function () {
beforeEach(function () {
cy.task('db:seed')
cy.loginByOkta(Cypress.env('okta_username'), Cypress.env('okta_password'))
})

it('验证登录用户没有银行账户', function () {
cy.get('[data-test="sidenav-bankaccounts"]').click()
cy.get('[data-test="empty-list-header"]').should('be.visible')
})
})

最后,我们可以重构我们的登录命令,利用cy.session()来存储我们的登录用户,这样我们就不需要在每个测试中重新认证。

cypress/support/commands.js
Cypress.Commands.add('loginByOkta', (username: string, password: string) => {
cy.session(
`okta-${username}`,
() => {
return loginToOkta(username, password)
},
{
validate() {
cy.visit('/')
cy.get('[data-test="sidenav-username"]').should('contain', username)
},
}
)
})

编程式登录

接下来,我们将编写一个名为loginByOktaApi的命令,以编程方式登录Okta并在localStorage中设置一个包含认证用户详细信息的项,我们将在应用程序代码中使用它来验证我们在测试中是否已认证。

为了确保这在真实世界应用(RWA)中启用,将REACT_APP_OKTA_PROGRAMMATIC环境变量设置为true

loginByOktaApi命令将执行以下步骤:

  1. 使用Okta认证API执行编程式登录。
  2. 使用Okta Auth SDK中的OktaAuth客户端实例在获取会话令牌后获取id_token
  3. 最后,oktaCypress localStorage项被设置为包含access token和用户配置文件。
cypress/support/commands.js
import { OktaAuth } from '@okta/okta-auth-js'

// Okta
Cypress.Commands.add('loginByOktaApi', (username, password) => {
cy.request({
method: 'POST',
url: `https://${Cypress.env('okta_domain')}/api/v1/authn`,
body: {
username,
password,
},
}).then(({ body }) => {
const user = body._embedded.user
const config = {
issuer: `https://${Cypress.env('okta_domain')}/oauth2/default`,
clientId: Cypress.env('okta_client_id'),
redirectUri: 'http://localhost:3000/implicit/callback',
scopes: ['openid', 'email', 'profile'],
}

const authClient = new OktaAuth(config)

return authClient.token
.getWithoutPrompt({ sessionToken: body.sessionToken })
.then(({ tokens }) => {
const userItem = {
token: tokens.accessToken.value,
user: {
sub: user.id,
email: user.profile.login,
given_name: user.profile.firstName,
family_name: user.profile.lastName,
preferred_username: user.profile.login,
},
}

window.localStorage.setItem('oktaCypress', JSON.stringify(userItem))

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

在Okta开发者控制台中正确设置Okta应用,放置必要的环境变量,并实现loginByOktaApi命令后,我们可以在测试应用程序时使用Okta进行认证。以下是通过Okta以用户身份登录、完成引导流程并注销的测试。

auth.cy.js
describe('Okta', function () {
beforeEach(function () {
cy.task('db:seed')
cy.loginByOktaApi(
Cypress.env('auth_username'),
Cypress.env('auth_password')
)
})

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

为测试适配Okta应用

info
注意

上一节重点介绍了在Cypress测试中使用编程式Okta认证的实践。要使用此实践,假设您正在测试一个适当构建或适配以使用Okta的应用程序。

与编程式登录不同,使用cy.origin()进行认证不需要适配应用程序即可工作。此步骤仅在实现编程式登录时需要。

以下部分提供了构建或适配应用程序以使用Okta认证的指导。

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

前端使用Okta React SDK用于React单页应用程序(SPA),其底层使用Okta Auth SDK。后端使用Okta JWT Verifier for Node.js来验证来自Okta的JWT。

info

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

适配后端

为了验证来自前端的API请求,我们安装Okta JWT Verifier for Node.js并使用创建Okta应用后提供的Okta域名和客户端ID进行配置。

backend/helpers.ts
import OktaJwtVerifier from '@okta/jwt-verifier'

dotenv.config()

// Okta验证JWT签名
const oktaJwtVerifier = new OktaJwtVerifier({
issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`,
clientId: process.env.REACT_APP_OKTA_CLIENTID,
assertClaims: {
aud: 'api://default',
cid: process.env.REACT_APP_OKTA_CLIENTID,
},
})

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

backend/helpers.ts
// ...

export const verifyOktaToken = (req, res, next) => {
const bearerHeader = req.headers['authorization']

if (bearerHeader) {
const bearer = bearerHeader.split(' ')
const bearerToken = bearer[1]

oktaJwtVerifier
.verifyAccessToken(bearerToken, 'api://default')
.then((jwt) => {
// 令牌有效
req.user = {
// @ts-ignore
sub: jwt.sub,
}

return next()
})
.catch((err) => {
// 验证失败,检查错误
console.log('error', err)
})
} else {
res.status(401).send({
error: 'Unauthorized',
})
}
}

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

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

// ...

if (process.env.REACT_APP_OKTA) {
app.use(verifyOktaToken)
}

// 路由 ...

适配前端

我们需要更新我们的前端React应用程序,以允许使用Okta React SDK进行Okta认证。

首先,我们基于App.tsx组件创建一个AppOkta.tsx容器。

AppOkta.tsx使用useOktaAuth React Hook,用SecureRouteLoginCallback替换Sign Up和Sign In路由,并用withOktaAuth高阶组件(HOC)包装组件。

添加一个useEffect钩子以获取认证用户的访问令牌,并向现有的认证层(authMachine.ts)发送带有usertoken对象的OKTA事件。我们为implicit/callback定义一个路由以渲染LoginCallback组件,并为根路径定义一个SecureRoute

src/containers/AppOkta.tsx
// 初始导入 ...
import {
LoginCallback,
SecureRoute,
useOktaAuth,
withOktaAuth,
} from '@okta/okta-react'

// ...

const AppOkta: React.FC = () => {
const { authState, oktaAuth } = useOktaAuth()

// ...

useEffect(() => {
if (authState.isAuthenticated) {
oktaAuth.getUser().then((user) => {
authService.send('OKTA', { user, token: oktaAuthState.accessToken })
})
}
}, [authState, oktaAuth])

// ...

return (
<div className={classes.root}>
// ...
{authState.matches('unauthorized') && (
<>
<Route path="/implicit/callback" component={LoginCallback} />
<SecureRoute exact path="/" />
</>
)}
// ...
</div>
)
}

export default withOktaAuth(AppOkta)

接下来,我们更新入口点(index.tsx),用Okta React SDK<Security>组件包装我们的应用程序,提供issuerclientId(来自我们的Okta应用)以及redirectUri作为props,使用.env中定义的REACT_APP_OKTA变量。

src/index.tsx
// 初始导入 ...
import { OktaAuth } from '@okta/okta-auth-js'
import { Security } from '@okta/okta-react'
import AppOkta from './containers/AppOkta'

// ...

const oktaAuth = new OktaAuth({
issuer: `https://${process.env.REACT_APP_OKTA_DOMAIN}/oauth2/default`,
clientId: process.env.REACT_APP_OKTA_CLIENTID,
redirectUri: window.location.origin + '/implicit/callback',
})

ReactDOM.render(
<Router history={history}>
<ThemeProvider theme={theme}>
{process.env.REACT_APP_OKTA ? (
<Security oktaAuth={oktaAuth}>
<AppOkta />
</Security>
) : (
<App />
)}
</ThemeProvider>
</Router>,
document.getElementById('root')
)

需要对我们的AppOkta.tsx组件进行更新,以有条件地使用oktaCypress localStorage项。

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

此外,我们将更新导出,仅在不在Cypress测试中时用withOktaAuth高阶组件包装。这允许我们的应用程序在开发/生产中使用Okta重定向登录流程,但在Cypress测试中不使用。

src/containers/AppOkta.tsx
// 初始导入 ...
import { LoginCallback, SecureRoute, useOktaAuth, withOktaAuth } from "@okta/okta-react";

// ...

const AppOkta: React.FC = () => {
const { authState, oktaAuth } = useOktaAuth();

// ...

// 如果在Cypress下测试,从"oktaCypress" localStorage项获取凭证并发送事件到我们的状态管理以登录SPA
if (window.Cypress) {
useEffect(() => {
const okta = JSON.parse(localStorage.getItem("oktaCypress")!);
authService.send("OKTA", {
user: okta.user,
token: okta.token,
});
}, []);
} else {
useEffect(() => {
if (authState.isAuthenticated) {
oktaAuth.getUser().then((user) => {
authService.send("OKTA", { user, token: oktaAuthState.accessToken });
});
}
}, [authState, oktaAuth]);
}

// ...

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

// ...

{authState.matches("unauthorized") && (
<>
<Route path="/implicit/callback" component={LoginCallback} />
<SecureRoute exact path="/" />
</>
)}

// ...
</div>
);
};

// 如果不在Cypress下测试,有条件地用`withOktaAuth`包装导出
let appOkta = window.Cypress ? AppOkta : withOktaAuth(AppOkta);
export default appOkta;