Okta认证
您将学习到
- 如何在Cypress中测试Okta认证
- 如何在Cypress中设置Okta凭证
- 如何为测试适配Okta应用
本指南的范围仅演示针对Okta通用目录的认证。
Okta开发者控制台设置
如果尚未设置,您需要在Okta开发者控制台中创建一个Okta应用。创建Okta应用后,Okta开发者控制台将提供一个客户端ID,该ID将与您的Okta域名一起用于配置Okta SDK,如本指南后续部分所示。
在Cypress中设置Okta应用凭证
为了在测试中访问测试用户凭证,我们需要配置Cypress使用.env
文件中设置的Okta环境变量。
- cypress.config.js 文件
- cypress.config.ts 文件
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,
},
})
import { defineConfig } from 'cypress'
// 从.env文件填充process.env
require('dotenv').config()
export default 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()
来:
- 导航到Okta源
- 输入用户凭证
- 登录并重定向回Cypress Real World App
- 使用
cy.session()
缓存结果
// 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以用户身份登录并运行一些基本检查的测试。
该测试的可运行版本在真实世界应用(RWA)中。
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.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
命令将执行以下步骤:
- 使用Okta认证API执行编程式登录。
- 使用Okta Auth SDK中的
OktaAuth
客户端实例在获取会话令牌后获取id_token
。 - 最后,
oktaCypress
localStorage项被设置为包含access token
和用户配置文件。
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以用户身份登录、完成引导流程并注销的测试。
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')
})
})
该测试的可运行版本在真实世界应用(RWA)中。
为测试适配Okta应用
上一节重点介绍了在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。
启动Cypress Real World App时使用yarn dev:okta
命令。
适配后端
为了验证来自前端的API请求,我们安装Okta JWT Verifier for Node.js并使用创建Okta应用后提供的Okta域名和客户端ID进 行配置。
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
令牌。
// ...
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',
})
}
}
定义此辅助函数后,我们可以全局应用它到所有路由:
// 初始导入 ...
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,用SecureRoute
和LoginCallback
替换Sign Up和Sign In路由,并用withOktaAuth
高阶组件(HOC)包装组件。
添加一个useEffect
钩子以获取认证用户的访问令牌,并向现有的认证层(authMachine.ts
)发送带有user
和token
对象的OKTA
事件。我们为implicit/callback
定义一个路由以渲染LoginCallback
组件,并为根路径定义一个SecureRoute
。
// 初始导入 ...
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)
完整的AppOkta.tsx组件在真实世界应用(RWA)中。
接下来,我们更新入口点(index.tsx
),用Okta React SDK的<Security>
组件包装我们的应用程序,提供issuer
、clientId
(来自我们的Okta应用)以及redirectUri
作为props,使用.env
中定义的REACT_APP_OKTA
变量。
// 初始导入 ...
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测试中不使用。
// 初始导入 ...
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;