使用OAuth 2.0、OIDC和PKCE保护Express应用

本教程详细介绍了如何在Express应用中集成Okta身份验证,使用OAuth 2.0、OIDC和PKCE构建安全的基于角色的费用仪表板。包含完整的技术实现步骤和代码示例。

使用OAuth 2.0、OIDC和PKCE保护Express应用

每个Web应用都需要身份验证,但自己构建既风险又耗时。你可以集成Okta来管理用户身份,并在Express中将Passport与openid-client库配对,以简化和保护登录流程。在本教程中,你将构建一个安全的、基于角色的费用仪表板,用户可以根据其团队查看相应的费用。

在GitHub上查看完整源代码,无需从零开始设置。

目录

  • 为什么使用Okta进行身份验证
  • 为什么在OAuth 2.0中使用PKCE
  • 使用Express、OAuth 2.0和PKCE构建安全Web应用
  • 运行带身份验证的Express应用
  • 了解更多关于OAuth 2.0、OIDC和PKCE的信息

为什么使用Okta进行身份验证

构建身份验证系统并处理凭据、会话和令牌非常不安全,会使应用面临严重漏洞。Okta使用OpenID Connect(OIDC)和OAuth 2.0提供安全、可扩展且基于标准的解决方案。它还可以与你喜欢的技术栈的OIDC客户端库无缝集成,并允许你获取令牌。

为什么在OAuth 2.0中使用PKCE

为了进一步加强安全性,本项目使用PKCE(Proof Key for Code Exchange),在RFC 7636中定义。PKCE是授权码流程的安全扩展。开发人员最初为移动应用设计了PKCE,但专家现在建议所有OAuth客户端使用,包括Web应用。它有助于防止CSRF和授权码注入攻击,使其对每种类型的OAuth客户端都有用,即使是使用客户端密钥的Web应用等机密客户端。随着OAuth 2.0的稳步发展,安全最佳实践也在进步。RFC 9700:OAuth 2.0安全最佳实践捕捉了关于最有效和安全实施策略的共识。此外,即将发布的OAuth 2.1草案要求所有授权码流程使用PKCE,将其强化为基线安全标准。

使用Okta,你可以实现现代身份验证功能,并专注于应用逻辑,而无需担心身份验证基础设施。

使用Express、OAuth 2.0和PKCE构建安全Web应用

让我们构建一个费用仪表板,用户使用Okta登录并根据其角色查看支出数据。无论他们在财务、市场还是人力资源部门工作,每个团队只能查看自己的费用。为了在这个演示项目中保持简洁,我们将在应用中直接定义角色和用户。

你将通过openid-client库使用OpenID Connect(OIDC)进行身份验证。然后,你将ID令牌中的每个用户的电子邮件映射到一个团队。仪表板应用最小权限原则,并按团队显示费用,因此每个用户只能看到其部门的支出。

先决条件

  • 安装Node.js(推荐v22+)
  • Okta Integrator免费计划组织

创建Express项目并安装依赖

创建一个名为express-project-okta的新项目文件夹,并在项目文件夹中打开终端窗口。

初始化新的Node.js项目:

1
npm init -y

安装所需的包:

1
npm install express@5.1 passport@0.7 openid-client@6.6 express-session@1.18 ejs@3.1 express-ejs-layouts@2.5 dotenv

现在安装开发依赖:

1
npm install --save-dev nodemon

在package.json文件中,使用以下内容更新scripts属性:

1
2
3
"scripts": {
  "start": "nodemon index.js"
}

这些依赖的作用是什么?

这些安装的包成为Express项目的依赖。

  • express:处理Web应用的路由和HTTP中间件
  • passport:设置和维护服务器端会话
  • openid-client:支持PKCE的Node.js OIDC库;处理OAuth握手和令牌交换
  • express-session:在服务器上管理用户会话
  • dotenv:从.env文件加载环境变量
  • ejs:使用嵌入式JavaScript模板启用动态HTML渲染
  • express-ejs-layouts:为EJS添加布局支持,帮助管理视图中的通用布局结构

为OIDC身份验证配置环境变量

在根目录中创建一个.env文件,其中包含Okta配置的占位符。

1
2
3
4
5

OKTA_CLIENT_ID={yourClientId}
OKTA_CLIENT_SECRET={clientSecret}
APP_BASE_URL=http://localhost:3000
POST_LOGOUT_URL=http://localhost:3000

在下一步中,你将从Okta管理控制台获取这些值。

创建Okta OIDC Web应用

在开始之前,你需要一个Okta Integrator免费计划账户。要获取一个,请注册一个Integrator账户。拥有账户后,登录到你的Integrator账户。接下来,在管理控制台中:

  1. 转到Applications > Applications
  2. 单击Create App Integration
  3. 选择OIDC - OpenID Connect作为登录方法
  4. 选择Web Application作为应用类型,然后单击Next

输入应用集成名称

配置重定向URI:

  • 登录重定向URI:http://localhost:3000/authorization-code/callback
  • 登出重定向URI:http://localhost:3000

在Controlled access部分,选择适当的访问级别 单击Save

在哪里找到新应用的凭据?

在管理控制台中手动创建OIDC Web应用会使用应用设置配置你的Okta组织。

创建应用后,你可以在应用的General选项卡上找到配置详细信息:

  • Client ID:在Client Credentials部分找到
  • Client Secret:在Client Credentials部分单击Show以显示

你将需要这些值用于应用配置:

1
2
3

OKTA_OAUTH2_CLIENT_ID="0oab8eb55Kb9jdMIr5d6"
OKTA_OAUTH2_CLIENT_SECRET="NEVER-SHOW-SECRETS"

你的Okta域是颁发者的第一部分,在/oauth2/default之前。

注意:你也可以使用Okta CLI客户端或Okta PowerShell模块自动化此过程。有关设置应用的更多信息,请参阅本指南。

构建Express应用

在项目根目录中创建一个index.js文件。它作为应用的主要入口点。使用它来初始化Express应用,设置路由,并配置Passport以通过在每个请求上序列化和反序列化用户来管理用户会话。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import express from 'express';
import session from 'express-session';
import passport from 'passport'; 
import routes from './routes.js';
import expressLayouts from 'express-ejs-layouts';

const app = express();

app.set('view engine', 'ejs');
app.use(expressLayouts); 
app.set('layout', 'layout');
app.use(express.urlencoded({ extended: false }));

app.use(session({
  secret: "your-hardcoded-secret",
  resave: false,
  saveUninitialized: true,
}));

app.use(passport.initialize());
app.use(passport.session());

passport.serializeUser(function (user, done) {
  done(null, user);
});

passport.deserializeUser(function (obj, done) {
  done(null, obj);
});

app.use('/', routes);

app.listen(3000, () => {
  console.log('Server listening on http://localhost:3000');
});

定义团队映射和示例费用

创建一个utils.js文件作为项目的数据模块。此文件包括用户到团队的映射,并为每个团队包含虚拟费用数据,涵盖为Web应用测试配置的所有团队。

应用从ID令牌中的电子邮件声明确定用户的团队上下文,并相应地过滤费用列表,因此仪表板仅显示该团队的数据。

要自定义数据,请打开utils.js并更新以下对象:

  • ALL_TEAMS_NAME - 列出组织中所有团队的数组
  • userTeamMap - 将每个用户的电子邮件(或"admin"用于完全访问)映射到特定团队
  • dummyExpenseData - 包含每个团队的示例费用数据
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
export const ALL_TEAMS_NAME = ["finance", "hr", "legal", "marketing", "dev advocacy"];

export const userTeamMap = {
  "hannah.smith@task-vantage.com": "admin",
  "grace.li@task-vantage.com": "legal",
  "frank.wilson+@task-vantage.com": "dev advocacy",
  "carol.lee@task-vantage.com": "finance",
  "alice.johnson@task-vantage.com": "marketing",
  "sarah.morgan@task-vantage.com": "hr",
};

export const dummyExpenseData = {
  finance: [
    {
      name: "Alice Johnson",
      item: "Product Launch Campaign",
      amount: 1200,
    },
    {
      name: "Bob Smith",
      item: "Promotional Material",
      amount: 450,
    },
    {
      name: "Carol Lee",
      item: "Team Lunch",
      amount: 180,
    },
    {
      name: "David Kim",
      item: "Event Booth",
      amount: 950,
    },
  ],
  hr: [
    {
      name: "Eve Martinez",
      item: "Internet",
      amount: 300,
    },
    {
      name: "Frank Wilson",
      item: "Compliance Training",
      amount: 600,
    },
    {
      name: "Grace Li",
      item: "Conference Travel",
      amount: 1500,
    },
    {
      name: "Henry Zhang",
      item: "Team Offsite",
      amount: 1000,
    },
  ],
  marketing: [
    {
      name: "Alice Johnson",
      item: "Payroll Processing",
      amount: 750,
    },
    {
      name: "Carol Lee",
      item: "Compliance Training",
      amount: 400,
    },
    {
      name: "Eve Martinez",
      item: "Team Lunch",
      amount: 200,
    },
    {
      name: "Frank Wilson",
      item: "Team Offsite",
      amount: 850,
    },
  ],
  legal: [
    {
      name: "Grace Li",
      item: "Event Booth",
      amount: 1100,
    },
    {
      name: "David Kim",
      item: "Product Launch Campaign",
      amount: 1300,
    },
    {
      name: "Bob Smith",
      item: "Conference Travel",
      amount: 1250,
    },
    {
      name: "Henry Zhang",
      item: "Team Lunch",
      amount: 170,
    },
  ],
  "dev-advocacy": [
    {
      name: "Eve Martinez",
      item: "Internet",
      amount: 280,
    },
    {
      name: "Frank Wilson",
      item: "Payroll Processing",
      amount: 720,
    },
    {
      name: "Grace Li",
      item: "Compliance Training",
      amount: 500,
    },
    {
      name: "Alice Johnson",
      item: "Team Offsite",
      amount: 950,
    },
  ],
};

export function getModifiedTeam(team) {
  if (!team?.trim()) return [];

  const toPascalCase = (str) =>
    str
      .trim()
      .split(/\s+/)
      .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
      .join(' ');

  const toKebabCase = (str) => str.trim().toLowerCase().split(' ').join('-');

  if (team === 'admin') {
    return ALL_TEAMS_NAME.map((element) => ({
      id: toKebabCase(element),
      label: toPascalCase(element),
    }));
  }

  return [
    {
      id: toKebabCase(team),
      label: toPascalCase(team),
    },
  ];
}

该文件还定义了getModifiedTeam,一个帮助程序,将团队名称转换为对象数组。每个对象都有一个id和label。如果团队是admin,该函数为ALL_TEAMS_NAME中的每个条目返回一个对象;否则,它为指定团队返回单个对象。在项目的后面,应用调用此函数来转换用户的团队信息。

创建处理身份验证的文件

为此步骤创建一个auth.js文件。此文件使用openid-client库处理OIDC流程:它登录用户,将授权代码交换为令牌,并登出用户。它还定义了一个中间件来保护受保护的路由。

在auth.js文件中,添加以下代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
import * as client from "openid-client";
import "dotenv/config";

import { getModifiedTeam, userTeamMap } from './utils.js';

async function getClientConfig() {

}

export async function login(req, res) {
  try {
    const openIdClientConfig = await getClientConfig();

    const code_verifier = client.randomPKCECodeVerifier();
    const code_challenge = await client.calculatePKCECodeChallenge(code_verifier);
    const state = client.randomState();

    req.session.pkce = { code_verifier, state };
    req.session.save(); 

    const authUrl = client.buildAuthorizationUrl(openIdClientConfig, {
      scope: "openid profile email offline_access",
      state,
      code_challenge,
      code_challenge_method: "S256",
      redirect_uri: `${process.env.APP_BASE_URL}/authorization-code/callback`,
    });

    res.redirect(authUrl);
  } catch (error) {
    res.status(500).send("Something failed during the authorization request");
  }
}

function getCallbackUrlWithParams(req) {
  const host = req.headers["x-forwarded-host"] || req.headers.host || "localhost";
  const protocol = req.headers["x-forwarded-proto"] || req.protocol;
  const currentUrl = new URL(`${protocol}://${host}${req.originalUrl}`);
  return currentUrl;
}

export async function authCallback(req, res, next) {
  try {
    const openIdClientConfig = await getClientConfig();

    const { pkce } = req.session;

    if (!pkce || !pkce.code_verifier || !pkce.state) {
      throw new Error("Login session expired or invalid. Please try logging in again.");
    }

    const tokenSet = await client.authorizationCodeGrant(openIdClientConfig, getCallbackUrlWithParams(req), {
      pkceCodeVerifier: pkce.code_verifier,
      expectedState: pkce.state,
    });

    const { name, email } = tokenSet.claims();
    const teams = getModifiedTeam(userTeamMap[email]);

    const userProfile = {
      name,
      email,
      teams,
      idToken: tokenSet.id_token,
    };

    delete req.session.pkce;

    req.logIn(userProfile, (err) => {
      if (err) {
        return next(err);
      }

      return res.redirect("/dashboard");
    });
  } catch (error) {
    console.error("Authentication error:", error.message);
    return res.status(500).send(`Authentication failed: ${error.message}`);
  }
}

export async function logout(req, res) {
  try {
    const openIdClientConfig = await getClientConfig();

    const id_token_hint = req.user?.idToken;

    const logoutUrl = client.buildEndSessionUrl(openIdClientConfig, {
      id_token_hint,
      post_logout_redirect_uri: process.env.POST_LOGOUT_URL,
    });

    req.logout((err) => {
      if (err) return next(err);

      req.session.destroy((err) => {
        if (err) return next(err);
        res.redirect(logoutUrl);
      });
    });
  } catch (error) {
    res.status(500).send('Something went wrong during logout.');
  }
}

export function ensureAuthenticated(req, res, next) {
  if (req.isAuthenticated()) {
    return next();
  }
  res.redirect("/login");
}

此文件包括以下函数:

  • getClientConfig - 使用发现端点检索授权服务器的元数据。
  • login - 此函数启动授权码+PKCE流程。它生成启用PKCE所需的值:code_verifier和code_challenge。这些值与state值一起保护用户登录过程免受攻击向量。PKCE防止授权码拦截攻击,state参数防止跨站请求伪造(CSRF)。openid-client使用这些值构建用户登录URL,并将用户重定向到Okta以完成身份验证质询。
  • getCallbackUrlWithParams - 重建完整的回调URL,包括协议、主机、路径和查询。
  • authCallback - 当用户在身份验证质询成功后重定向回应用时,此函数运行。此时,重定向回应用的URL包括授权码。OIDC客户端通过检查state值与第一次重定向中的参数匹配来验证授权码。一旦验证,openid-client库通过将code_verifier添加到令牌请求来使用授权码进行令牌交换。授权服务器验证授权码和code_verifier值,以确保请求来自进行原始身份验证请求的客户端,减轻使用被盗授权码的攻击。 一旦我们获得有效令牌,我们处理应用的业务逻辑,例如将用户映射到团队,并将配置文件详细信息和ID令牌存储在会话中。如果一切成功,它将用户重定向到仪表板。
  • logout - 将用户登出应用并重定向到登出后URL。
  • ensureAuthenticated - 允许已认证用户继续并重定向其他用户到登录页面的中间件。

在Express中设置路由

现在事情开始整合起来,感觉像一个真正的应用。routes.js文件定义了所有基本路由,从登录和登出到查看你的配置文件、费用仪表板和单个团队费用页面。应用处理每个端点的核心逻辑,并在授予对受保护页面的访问权限之前检查用户的身份验证状态。

它充当我们应用的流量控制器,将用户引导到正确的页面,并确保只有登录的用户才能查看敏感信息,如费用仪表板或组详细信息。这种结构使我们的应用组织有序且安全,并为流畅的用户体验奠定了基础。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import express from "express";
import "dotenv/config";

import { authCallback, ensureAuthenticated, login, logout } from "./auth.js";
import { dummyExpenseData } from './utils.js';

const router = express.Router();

router.get("/", (req, res) => {
  res.render("home", { title: "Home", user: req.user });
});

router.get("/login", login);

router.get("/authorization-code/callback", authCallback);

router.get("/profile", ensureAuthenticated, (req, res) => {
  res.render("profile", { title: "Profile", user: req.user });
});

router.get("/dashboard", ensureAuthenticated, (req, res) => {
  const team = req.user?.teams || [];

  res.render("dashboard", {
    title: "Dashboard",
    user: req.user,
    team,
  });
});

router.get("/team/:id", ensureAuthenticated, (req, res) => {
  const teamId = req.params.id;
  const teamList = req.user?.teams || [];

  const team = teamList.find((team) => team.id === teamId);
  if (!team) {
    return res.status(404).send("Team not found");
  }

  const expenses = dummyExpenseData[teamId];
  const total = expenses.reduce((sum, exp) => sum + exp.amount, 0);

  res.render("expenses", {
    title: team.name,
    user: req.user,
    team,
    expenses,
    total,
  });
});

router.get("/logout", logout);

export default router;

在Express中添加EJS视图

现在是时候给应用一个用户界面了。你将使用EJS模板构建页面,这些页面动态响应谁登录以及他们看到什么数据。应用使用ejs模板渲染页面,加上express-ejs-layouts用于通用布局结构。

创建一个名为views的文件夹,然后添加以下EJS文件:

home.ejs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<% if (user) { %>
<h1>Welcome, <%= user.name || 'User' %>!</h1>
<% } else { %>
<h1>Welcome</h1>
<% } %>

<p class="lead">Log your expenses and manage your team's spending on the dashboard.</p>

<% if (user) { %>
<a href="/dashboard" class="btn btn-primary">Go to Dashboard</a>
<% } else { %>
<a href="/login" class="btn btn-success">Login</a>
<% } %>

profile.ejs

1
2
3
<h1>Profile</h1>
<p><h2 style="display: inline-block; margin: 0; font-size: 16px;">Name:</h2> <%= user.name %></p>
<p><h2 style="display: inline-block; margin: 0; font-size: 16px;">Email:</h2> <%= user.email %></p>

layout.ejs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title><%= typeof title !== 'undefined' ? title : 'Expense Dashboard' %></title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" />
    <style>
    html, body {
        height: 100%;
        margin: 0;
      }
      body {
        display: flex;
        flex-direction: column;
      }
      .content {
        flex: 1;
      }
      .team-heading {
        display: inline-block;
        font-weight: 600;
        color: #2c3e50; 
        margin-bottom: 1rem;
      }
    </style>
  </head>
  <body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-primary mb-4">
      <div class="container">
        <a class="navbar-brand" href="/dashboard">Expense Dashboard</a>
        <div>
          <% if (user) { %>
          <a href="/dashboard" class="btn btn-light btn-sm me-2">Dashboard</a>
          <a href="/profile" class="btn btn-light btn-sm me-2">Profile</a>
          <a href="/logout" class="btn btn-danger btn-sm">Logout</a>
          <% } else { %>
          <a href="/login" class="btn btn-success btn-sm">Login</a>
          <% } %>
        </div>
      </div>
    </nav>

  <main class="container content">
    <%- body %>
  </main>

  <footer class="text-center mt-5 mb-3 text-muted">
    &copy; Okta Inc. Expense Dashboard
  </footer>
  </body>
</html>

dashboard.ejs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<h1>Dashboard</h1>
<p>Welcome, <%= user.name || 'User' %></p>
<h2 style="font-size: 24px;">Your Teams</h2>
<% if (team && team.length > 0) { %>
<ul class="list-group">
  <% team.forEach(team => { %>
  <li class="list-group-item d-flex justify-content-between align-items-center">
    <%= team.label %>
    <a href="/team/<%= team.id %>" class="btn btn-primary btn-sm">View</a>
  </li>
  <% }) %>
</ul>
<% } else { %>
<p>You are not part of any teams yet.</p>
<% } %>

expenses.ejs EJS模板以表格格式渲染团队信息和费用数据。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<h1><%= team.label %></h1>
<div>Welcome to the <p class="team-heading"><%= team.label %></p> team page.</div>
<br/>
<% if (expenses && expenses.length > 0) { %>
<h2 style="font-size: 24px;">Expenses</h2>
<table class="table table-bordered">
  <thead>
    <tr>
      <th>Name</th>
      <th>Item</th>
      <th>Amount ($)</th>
    </tr>
  </thead>
  <tbody>
      <% expenses.forEach(exp => { %>
    <tr>
      <td><%= exp.name %></td>
      <td><%= exp.item %></td>
      <td><%= exp.amount %></td>
    </tr>
    <% }) %>
  </tbody>
</table>
<div class="alert alert-info"><h6 style="display: inline-block; margin: 0;">Total:</h6> $<%= total %></div>
<% } else { %>
<p>No expenses found for this team.</p>
<% } %>

运行带身份验证的Express应用

在终端中,启动服务器:

1
npm start

打开浏览器并导航到http://localhost:3000。 单击Login并使用你的Okta账户进行身份验证。然后应用将显示你的费用仪表板、配置文件和登出选项。

注意:当你作为管理员登录到开发控制台时,Okta保持你的组织会话活动,并自动将你登录到应用。要测试其他用户账户,请使用隐身标签测试登录流程。

管理员视图:

用户视图:

费用视图:

就是这样!你已经构建了一个安全的费用仪表板,并使用OIDC和OAuth将你的Express应用连接到Okta。

了解更多关于OAuth 2.0、OIDC和PKCE的信息

以下是我在此项目中用于构建安全费用仪表板的功能的快速概述:

  • OpenID Connect(OIDC)是建立在OAuth 2.0之上的身份和身份验证层。
  • 带PKCE的授权码流程是服务器端和基于浏览器的Web应用最安全的流程。

如果你想探索整个项目并跳过从零开始设置,请在GitHub上查看完整源代码。

要进一步探索,请查看这些官方Okta资源以了解关键概念。

  • 身份验证与授权
  • OAuth 2.0和OpenID Connect概述
  • 使用PKCE实现授权码
  • Okta中的授权服务器

在LinkedIn、Twitter上关注我们,并订阅我们的YouTube频道以查看更多类似内容。如果你有任何问题,请在下面评论!

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计