feat: add OAuth consent page

This commit is contained in:
hikarukimi
2026-02-23 15:16:04 +08:00
parent 712bc756bc
commit 4f78d56e31
20 changed files with 1135 additions and 11 deletions

View File

@@ -323,6 +323,17 @@ func (c *ApiController) Signup() {
// If OAuth parameters are present, generate OAuth code and return it
if clientId != "" && responseType == ResponseTypeCode {
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
c.ResponseOk(map[string]bool{"required": true})
return
}
code, err := object.GetOAuthCode(userId, clientId, "", "password", responseType, redirectUri, scope, state, nonce, codeChallenge, "", c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)

View File

@@ -167,6 +167,19 @@ func (c *ApiController) HandleLoggedIn(application *object.Application, user *ob
c.ResponseError(c.T("auth:Challenge method should be S256"))
return
}
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
c.ResponseError(err.Error())
return
}
if consentRequired {
resp = &Response{Status: "ok", Data: map[string]bool{"required": true}}
resp.Data3 = user.NeedUpdatePassword
return
}
code, err := object.GetOAuthCode(userId, clientId, form.Provider, form.SigninMethod, responseType, redirectUri, scope, state, nonce, codeChallenge, resource, c.Ctx.Request.Host, c.GetAcceptLanguage())
if err != nil {
c.ResponseError(err.Error(), nil)

226
controllers/consent.go Normal file
View File

@@ -0,0 +1,226 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package controllers
import (
"encoding/json"
"github.com/casdoor/casdoor/object"
)
// RevokeConsent revokes a consent record
// @Title RevokeConsent
// @Tag Consent API
// @Description revoke a consent record
// @Param body body object.ConsentRecord true "The consent object"
// @Success 200 {object} controllers.Response The Response object
// @router /revoke-consent [post]
func (c *ApiController) RevokeConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var consent object.ConsentRecord
err := json.Unmarshal(c.Ctx.Input.RequestBody, &consent)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate that consent.Application is not empty
if consent.Application == "" {
c.ResponseError(c.T("general:Application cannot be empty"))
return
}
// Validate that GrantedScopes is not empty when scope-specific revoke is requested
if len(consent.GrantedScopes) == 0 {
c.ResponseError(c.T("general:Granted scopes cannot be empty"))
return
}
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:The user doesn't exist"))
return
}
newScopes := []object.ConsentRecord{}
for _, record := range userObj.ApplicationScopes {
if record.Application != consent.Application {
// skip other applications
newScopes = append(newScopes, record)
continue
}
// revoke specified scopes
revokeSet := make(map[string]bool)
for _, s := range consent.GrantedScopes {
revokeSet[s] = true
}
remaining := []string{}
for _, s := range record.GrantedScopes {
if !revokeSet[s] {
remaining = append(remaining, s)
}
}
if len(remaining) > 0 {
// still have remaining scopes, keep the record and update
record.GrantedScopes = remaining
newScopes = append(newScopes, record)
}
// otherwise the application authorization is revoked, delete the whole record
}
userObj.ApplicationScopes = newScopes
success, err := object.UpdateUser(userObj.GetId(), userObj, nil, false)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(success)
}
// GrantConsent grants consent for an OAuth application and returns authorization code
// @Title GrantConsent
// @Tag Consent API
// @Description grant consent for an OAuth application and get authorization code
// @Param body body object.ConsentRecord true "The consent object with OAuth parameters"
// @Success 200 {object} controllers.Response The Response object
// @router /grant-consent [post]
func (c *ApiController) GrantConsent() {
userId := c.GetSessionUsername()
if userId == "" {
c.ResponseError(c.T("general:Please login first"))
return
}
var request struct {
Application string `json:"application"`
Scopes []string `json:"grantedScopes"`
ClientId string `json:"clientId"`
Provider string `json:"provider"`
SigninMethod string `json:"signinMethod"`
ResponseType string `json:"responseType"`
RedirectUri string `json:"redirectUri"`
Scope string `json:"scope"`
State string `json:"state"`
Nonce string `json:"nonce"`
Challenge string `json:"challenge"`
Resource string `json:"resource"`
}
err := json.Unmarshal(c.Ctx.Input.RequestBody, &request)
if err != nil {
c.ResponseError(err.Error())
return
}
// Validate application by clientId
application, err := object.GetApplicationByClientId(request.ClientId)
if err != nil {
c.ResponseError(err.Error())
return
}
if application == nil {
c.ResponseError(c.T("general:Invalid client_id"))
return
}
// Verify that request.Application matches the application's actual ID
if request.Application != application.GetId() {
c.ResponseError(c.T("general:Invalid application"))
return
}
// Update user's ApplicationScopes
userObj, err := object.GetUser(userId)
if err != nil {
c.ResponseError(err.Error())
return
}
if userObj == nil {
c.ResponseError(c.T("general:User not found"))
return
}
appId := application.GetId()
found := false
// Insert new scope into existing applicationScopes
for i, record := range userObj.ApplicationScopes {
if record.Application == appId {
existing := make(map[string]bool)
for _, s := range userObj.ApplicationScopes[i].GrantedScopes {
existing[s] = true
}
for _, s := range request.Scopes {
if !existing[s] {
userObj.ApplicationScopes[i].GrantedScopes = append(userObj.ApplicationScopes[i].GrantedScopes, s)
existing[s] = true
}
}
found = true
break
}
}
// create a new applicationScopes if not found
if !found {
uniqueScopes := []string{}
existing := make(map[string]bool)
for _, s := range request.Scopes {
if !existing[s] {
uniqueScopes = append(uniqueScopes, s)
existing[s] = true
}
}
userObj.ApplicationScopes = append(userObj.ApplicationScopes, object.ConsentRecord{
Application: appId,
GrantedScopes: uniqueScopes,
})
}
_, err = object.UpdateUser(userObj.GetId(), userObj, []string{"application_scopes"}, false)
if err != nil {
c.ResponseError(err.Error())
return
}
// Now get the OAuth code
code, err := object.GetOAuthCode(
userId,
request.ClientId,
request.Provider,
request.SigninMethod,
request.ResponseType,
request.RedirectUri,
request.Scope,
request.State,
request.Nonce,
request.Challenge,
request.Resource,
c.Ctx.Request.Host,
c.GetAcceptLanguage(),
)
if err != nil {
c.ResponseError(err.Error())
return
}
c.ResponseOk(code.Code)
}

View File

@@ -156,6 +156,8 @@ type Application struct {
FailedSigninFrozenTime int `json:"failedSigninFrozenTime"`
CodeResendTimeout int `json:"codeResendTimeout"`
CustomScopes []*ScopeDescription `xorm:"mediumtext" json:"customScopes"`
// Reverse proxy fields
Domain string `xorm:"varchar(100)" json:"domain"`
OtherDomains []string `xorm:"varchar(1000)" json:"otherDomains"`
@@ -746,6 +748,11 @@ func UpdateApplication(id string, application *Application, isGlobalAdmin bool,
return false, err
}
err = validateCustomScopes(application.CustomScopes, lang)
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}
@@ -801,6 +808,11 @@ func AddApplication(application *Application) (bool, error) {
return false, err
}
err = validateCustomScopes(application.CustomScopes, "en")
if err != nil {
return false, err
}
for _, providerItem := range application.Providers {
providerItem.Provider = nil
}

View File

@@ -94,6 +94,7 @@ func getBuiltInAccountItems() []*AccountItem {
{Name: "Roles", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Permissions", Visible: true, ViewRule: "Public", ModifyRule: "Immutable"},
{Name: "Groups", Visible: true, ViewRule: "Public", ModifyRule: "Admin"},
{Name: "Consents", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "3rd-party logins", Visible: true, ViewRule: "Self", ModifyRule: "Self"},
{Name: "Properties", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},
{Name: "Is admin", Visible: true, ViewRule: "Admin", ModifyRule: "Admin"},

View File

@@ -242,6 +242,7 @@ type User struct {
MfaRememberDeadline string `xorm:"varchar(100)" json:"mfaRememberDeadline"`
NeedUpdatePassword bool `json:"needUpdatePassword"`
IpWhitelist string `xorm:"varchar(200)" json:"ipWhitelist"`
ApplicationScopes []ConsentRecord `xorm:"mediumtext" json:"applicationScopes"`
}
type Userinfo struct {
@@ -871,7 +872,7 @@ func UpdateUser(id string, user *User, columns []string, isAdmin bool) (bool, er
"microsoftonline", "naver", "nextcloud", "onedrive", "oura", "patreon", "paypal", "salesforce", "shopify", "soundcloud",
"spotify", "strava", "stripe", "type", "telegram", "tiktok", "tumblr", "twitch", "twitter", "typetalk", "uber", "vk", "wepay", "xero", "yahoo",
"yammer", "yandex", "zoom", "custom", "need_update_password", "ip_whitelist", "mfa_remember_deadline",
"cart",
"cart", "application_scopes",
}
}
if isAdmin {

119
object/user_scope.go Normal file
View File

@@ -0,0 +1,119 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package object
import (
"fmt"
"strings"
"github.com/casdoor/casdoor/i18n"
)
// ConsentRecord represents the data for OAuth consent API requests/responses
type ConsentRecord struct {
// owner/name
Application string `json:"application"`
GrantedScopes []string `json:"grantedScopes"`
}
// ScopeDescription represents a human-readable description of an OAuth scope
type ScopeDescription struct {
Scope string `json:"scope"`
DisplayName string `json:"displayName"`
Description string `json:"description"`
}
// parseScopes converts a space-separated scope string to a slice
func parseScopes(scopeStr string) []string {
if scopeStr == "" {
return []string{}
}
scopes := strings.Split(scopeStr, " ")
var result []string
for _, scope := range scopes {
trimmed := strings.TrimSpace(scope)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
// CheckConsentRequired checks if user consent is required for the OAuth flow
func CheckConsentRequired(userObj *User, application *Application, scopeStr string) (bool, error) {
// Skip consent when no custom scopes are configured
if len(application.CustomScopes) == 0 {
return false, nil
}
// Once policy: check if consent already granted
requestedScopes := parseScopes(scopeStr)
appId := application.GetId()
// Filter requestedScopes to only include scopes defined in application.CustomScopes
customScopesMap := make(map[string]bool)
for _, customScope := range application.CustomScopes {
if customScope.Scope != "" {
customScopesMap[customScope.Scope] = true
}
}
validRequestedScopes := []string{}
for _, scope := range requestedScopes {
if customScopesMap[scope] {
validRequestedScopes = append(validRequestedScopes, scope)
}
}
// If no valid requested scopes, no consent required
if len(validRequestedScopes) == 0 {
return false, nil
}
for _, record := range userObj.ApplicationScopes {
if record.Application == appId {
// Check if grantedScopes contains all validRequestedScopes
grantedMap := make(map[string]bool)
for _, scope := range record.GrantedScopes {
grantedMap[scope] = true
}
allGranted := true
for _, scope := range validRequestedScopes {
if !grantedMap[scope] {
allGranted = false
break
}
}
if allGranted {
// Consent already granted for all valid requested scopes
return false, nil
}
}
}
// Consent required
return true, nil
}
func validateCustomScopes(customScopes []*ScopeDescription, lang string) error {
for _, scope := range customScopes {
if scope == nil || strings.TrimSpace(scope.Scope) == "" {
return fmt.Errorf("%s: custom scope name", i18n.Translate(lang, "general:Missing parameter"))
}
}
return nil
}

View File

@@ -319,6 +319,9 @@ func InitAPI() {
web.Router("/api/delete-mfa", &controllers.ApiController{}, "POST:DeleteMfa")
web.Router("/api/set-preferred-mfa", &controllers.ApiController{}, "POST:SetPreferredMfa")
web.Router("/api/grant-consent", &controllers.ApiController{}, "POST:GrantConsent")
web.Router("/api/revoke-consent", &controllers.ApiController{}, "POST:RevokeConsent")
web.Router("/.well-known/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscovery")
web.Router("/.well-known/:application/openid-configuration", &controllers.RootController{}, "GET:GetOidcDiscoveryByApplication")
web.Router("/.well-known/oauth-authorization-server", &controllers.RootController{}, "GET:GetOAuthServerMetadata")

View File

@@ -89,6 +89,23 @@ func fastAutoSignin(ctx *context.Context) (string, error) {
return "", nil
}
user, err := object.GetUser(userId)
if err != nil {
return "", err
}
if user == nil {
return "", nil
}
consentRequired, err := object.CheckConsentRequired(user, application, scope)
if err != nil {
return "", err
}
if consentRequired {
return "", nil
}
code, err := object.GetOAuthCode(userId, clientId, "", "autoSignin", responseType, redirectUri, scope, state, nonce, codeChallenge, "", ctx.Request.Host, getAcceptLanguage(ctx))
if err != nil {
return "", err

View File

@@ -539,15 +539,16 @@ class App extends Component {
isEntryPages() {
return window.location.pathname.startsWith("/signup") ||
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/captcha");
window.location.pathname.startsWith("/login") ||
window.location.pathname.startsWith("/forget") ||
window.location.pathname.startsWith("/prompt") ||
window.location.pathname.startsWith("/result") ||
window.location.pathname.startsWith("/cas") ||
window.location.pathname.startsWith("/select-plan") ||
window.location.pathname.startsWith("/buy-plan") ||
window.location.pathname.startsWith("/qrcode") ||
window.location.pathname.startsWith("/consent") ||
window.location.pathname.startsWith("/captcha");
}
onClick = ({key}) => {

View File

@@ -248,6 +248,33 @@ class ApplicationEditPage extends React.Component {
return value;
}
trimCustomScopes(customScopes) {
if (!Array.isArray(customScopes)) {
return [];
}
return customScopes.map((item) => {
const scope = (item?.scope || "").trim();
const displayName = (item?.displayName || "").trim();
const description = (item?.description || "").trim();
return {
...item,
scope: scope,
displayName: displayName,
description: description,
};
});
}
validateCustomScopes(customScopes) {
const trimmed = this.trimCustomScopes(customScopes);
for (const item of trimmed) {
if (!item || !item.scope || item.scope === "") {
return {ok: false, scopes: trimmed};
}
}
return {ok: true, scopes: trimmed};
}
updateApplicationField(key, value) {
value = this.parseApplicationField(key, value);
const application = this.state.application;
@@ -1647,6 +1674,12 @@ class ApplicationEditPage extends React.Component {
const application = Setting.deepCopy(this.state.application);
application.providers = application.providers?.filter(provider => this.state.providers.map(provider => provider.name).includes(provider.name));
application.signinMethods = application.signinMethods?.filter(signinMethod => ["Password", "Verification code", "WebAuthn", "LDAP", "Face ID", "WeChat"].includes(signinMethod.name));
const customScopeValidation = this.validateCustomScopes(application.customScopes);
application.customScopes = customScopeValidation.scopes;
if (!customScopeValidation.ok) {
Setting.showMessage("error", `${i18next.t("general:Name")}: ${i18next.t("provider:This field is required")}`);
return;
}
ApplicationBackend.updateApplication("admin", this.state.applicationName, application)
.then((res) => {

View File

@@ -26,6 +26,7 @@ import LoginPage from "./auth/LoginPage";
import SelfForgetPage from "./auth/SelfForgetPage";
import ForgetPage from "./auth/ForgetPage";
import PromptPage from "./auth/PromptPage";
import ConsentPage from "./auth/ConsentPage";
import ResultPage from "./auth/ResultPage";
import CasLogout from "./auth/CasLogout";
import {authConfig} from "./auth/Auth";
@@ -125,6 +126,7 @@ class EntryPage extends React.Component {
<Route exact path="/forget/:applicationName" render={(props) => <ForgetPage {...this.props} account={this.props.account} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />} />
<Route exact path="/prompt" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/prompt/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<PromptPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/consent/:applicationName" render={(props) => this.renderLoginIfNotLoggedIn(<ConsentPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/result/:applicationName" render={(props) => this.renderHomeIfLoggedIn(<ResultPage {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />
<Route exact path="/cas/:owner/:casApplicationName/logout" render={(props) => this.renderHomeIfLoggedIn(<CasLogout {...this.props} application={this.state.application} onUpdateApplication={onUpdateApplication} {...props} />)} />

View File

@@ -98,6 +98,7 @@ class OrganizationListPage extends BaseListPage {
{name: "Groups", visible: true, viewRule: "Public", modifyRule: "Admin"},
{name: "Roles", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Permissions", visible: true, viewRule: "Public", modifyRule: "Immutable"},
{name: "Consents", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "3rd-party logins", visible: true, viewRule: "Self", modifyRule: "Self"},
{name: "Properties", visible: false, viewRule: "Admin", modifyRule: "Admin"},
{name: "Is online", visible: true, viewRule: "Admin", modifyRule: "Admin"},

View File

@@ -50,6 +50,7 @@ import MfaTable from "./table/MfaTable";
import TransactionTable from "./table/TransactionTable";
import CartTable from "./table/CartTable";
import * as TransactionBackend from "./backend/TransactionBackend";
import ConsentTable from "./table/ConsentTable";
import {Content, Header} from "antd/es/layout/layout";
import Sider from "antd/es/layout/Sider";
@@ -73,6 +74,7 @@ class UserEditPage extends React.Component {
idCardInfo: ["ID card front", "ID card back", "ID card with person"],
openFaceRecognitionModal: false,
transactions: [],
consents: [],
activeMenuKey: window.location.hash?.slice(1) || "",
menuMode: "Horizontal",
};
@@ -110,6 +112,7 @@ class UserEditPage extends React.Component {
this.setState({
user: res.data,
multiFactorAuths: res.data?.multiFactorAuths ?? [],
consents: res.data?.applicationScopes ?? [],
loading: false,
});
@@ -1129,6 +1132,21 @@ class UserEditPage extends React.Component {
/>
</Col>
</Row>);
} else if (accountItem.name === "Consents") {
return (
<Row style={{marginTop: "20px"}}>
<Col style={{marginTop: "5px"}} span={Setting.isMobile() ? 22 : 2}>
{Setting.getLabel(i18next.t("consent:Consents"), i18next.t("consent:Consents - Tooltip"))} :
</Col>
<Col span={22}>
<ConsentTable
title={i18next.t("consent:Consents")}
table={this.state.consents}
onUpdateTable={() => this.getUser()}
/>
</Col>
</Row>
);
} else if (accountItem.name === "Multi-factor authentication") {
return (
!this.isSelfOrAdmin() ? null : (

260
web/src/auth/ConsentPage.js Normal file
View File

@@ -0,0 +1,260 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Card, List, Result, Space} from "antd";
import {CheckOutlined, LockOutlined} from "@ant-design/icons";
import * as ApplicationBackend from "../backend/ApplicationBackend";
import * as ConsentBackend from "../backend/ConsentBackend";
import * as Setting from "../Setting";
import i18next from "i18next";
import {withRouter} from "react-router-dom";
import * as Util from "./Util";
class ConsentPage extends React.Component {
constructor(props) {
super(props);
const params = new URLSearchParams(window.location.search);
this.state = {
applicationName: props.match?.params?.applicationName || params.get("application"),
scopeDescriptions: [],
granting: false,
oAuthParams: Util.getOAuthGetParameters(),
};
}
getApplicationObj() {
return this.props.application;
}
componentDidMount() {
this.getApplication();
this.loadScopeDescriptions();
}
componentDidUpdate(prevProps) {
if (this.props.application !== prevProps.application) {
this.loadScopeDescriptions();
}
}
getApplication() {
if (!this.state.applicationName) {
return;
}
ApplicationBackend.getApplication("admin", this.state.applicationName)
.then((res) => {
if (res.status === "error") {
Setting.showMessage("error", res.msg);
return;
}
this.props.onUpdateApplication(res.data);
});
}
loadScopeDescriptions() {
const {oAuthParams} = this.state;
const application = this.getApplicationObj();
if (!oAuthParams?.scope || !application) {
return;
}
// Check if urlPar scope is within application scopes
const scopes = oAuthParams.scope.split(" ").map(s => s.trim()).filter(Boolean);
const customScopes = application.customScopes || [];
const customScopesMap = {};
customScopes.forEach(s => {
if (s?.scope) {
customScopesMap[s.scope] = s;
}
});
const scopeDescriptions = scopes
.map(scope => {
const item = customScopesMap[scope];
if (item) {
return {
...item,
displayName: item.displayName || item.scope,
};
}
return {
scope: scope,
displayName: scope,
description: i18next.t("consent:This scope is not defined in the application"),
};
})
.filter(Boolean);
this.setState({
scopeDescriptions: scopeDescriptions,
});
}
handleGrant() {
const {oAuthParams, scopeDescriptions} = this.state;
const application = this.getApplicationObj();
this.setState({granting: true});
const consent = {
owner: application.owner,
application: application.owner + "/" + application.name,
grantedScopes: scopeDescriptions.map(s => s.scope),
};
ConsentBackend.grantConsent(consent, oAuthParams)
.then((res) => {
if (res.status === "ok") {
// res.data contains the authorization code
const code = res.data;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
const redirectUrl = `${oAuthParams.redirectUri}${concatChar}code=${code}&state=${oAuthParams.state}`;
Setting.goToLink(redirectUrl);
} else {
Setting.showMessage("error", res.msg);
this.setState({granting: false});
}
});
}
handleDeny() {
const {oAuthParams} = this.state;
const concatChar = oAuthParams?.redirectUri?.includes("?") ? "&" : "?";
Setting.goToLink(`${oAuthParams.redirectUri}${concatChar}error=access_denied&error_description=User denied consent&state=${oAuthParams.state}`);
}
render() {
const application = this.getApplicationObj();
if (application === undefined) {
return null;
}
if (!application) {
return (
<Result
status="error"
title={i18next.t("general:Invalid application")}
/>
);
}
const {scopeDescriptions, granting} = this.state;
const isScopeEmpty = scopeDescriptions.length === 0;
return (
<div className="login-content">
<div className={Setting.isDarkTheme(this.props.themeAlgorithm) ? "login-panel-dark" : "login-panel"}>
<div className="login-form">
<Card
style={{
padding: "32px",
width: 450,
borderRadius: "12px",
boxShadow: "0 10px 25px rgba(0, 0, 0, 0.05)",
border: "1px solid #f0f0f0",
}}
>
<div style={{textAlign: "center", marginBottom: 24}}>
{application.logo && (
<div style={{marginBottom: 16}}>
<img
src={application.logo}
alt={application.displayName || application.name}
style={{height: 56, objectFit: "contain"}}
/>
</div>
)}
<h2 style={{margin: 0, fontWeight: 600, fontSize: "24px"}}>
{i18next.t("consent:Authorization Request")}
</h2>
</div>
<div style={{marginBottom: 32}}>
<p style={{fontSize: 15, color: "#666", textAlign: "center", lineHeight: "1.6"}}>
<span style={{fontWeight: 600, color: "#000"}}>{application.displayName || application.name}</span>
{" "}{i18next.t("consent:wants to access your account")}
</p>
{application.homepageUrl && (
<div style={{textAlign: "center", marginTop: 4}}>
<a href={application.homepageUrl} target="_blank" rel="noopener noreferrer" style={{fontSize: 13, color: "#1890ff"}}>
{application.homepageUrl}
</a>
</div>
)}
</div>
<div style={{marginBottom: 32}}>
<div style={{fontSize: 14, color: "#8c8c8c", marginBottom: 16}}>
<LockOutlined style={{marginRight: 8}} /> {i18next.t("consent:This application is requesting")}
</div>
<div style={{display: "flex", justifyContent: "center"}}>
<List
size="small"
dataSource={scopeDescriptions}
style={{width: "100%"}}
renderItem={item => (
<List.Item style={{borderBottom: "none", width: "100%"}}>
<div style={{display: "inline-grid", gridTemplateColumns: "16px auto", columnGap: 8, alignItems: "start"}}>
<CheckOutlined style={{color: "#52c41a", fontSize: "14px", marginTop: "4px", justifySelf: "center"}} />
<div style={{fontWeight: 500, fontSize: "14px", lineHeight: "22px"}}>{item.displayName || item.scope}</div>
</div>
<div style={{fontSize: "12px", color: "#8c8c8c", marginTop: 2}}>{item.description}</div>
</List.Item>
)}
/>
</div>
</div>
<div style={{textAlign: "center", marginBottom: 24}}>
<Space size={16}>
<Button
type="primary"
size="large"
shape="round"
onClick={() => this.handleGrant()}
loading={granting}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Allow")}
</Button>
<Button
size="large"
shape="round"
onClick={() => this.handleDeny()}
disabled={granting || isScopeEmpty}
style={{minWidth: 120, height: 44, fontWeight: 500}}
>
{i18next.t("consent:Deny")}
</Button>
</Space>
</div>
<div style={{padding: "16px", backgroundColor: "#fafafa", borderRadius: "8px", border: "1px solid #f0f0f0"}}>
<p style={{margin: 0, fontSize: 12, color: "#8c8c8c", textAlign: "center", lineHeight: "1.5"}}>
{i18next.t("consent:By clicking Allow, you allow this app to use your information")}
</p>
</div>
</Card>
</div>
</div>
</div>
);
}
}
export default withRouter(ConsentPage);

View File

@@ -369,6 +369,13 @@ class LoginPage extends React.Component {
return;
}
// Check if consent is required
if (resp.data?.required === true) {
// Consent required, redirect to consent page
Setting.goToLinkSoft(ths, `/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
if (Setting.hasPromptPage(application)) {
AuthBackend.getAccount()
.then((res) => {

View File

@@ -293,8 +293,17 @@ class SignupPage extends React.Component {
return;
}
// Check if consent is required
if (oAuthParams && res.data && typeof res.data === "object" && res.data.required === true) {
// Consent required, redirect to consent page
Setting.goToLink(`/consent/${application.name}?${window.location.search.substring(1)}`);
return;
}
// the user's id will be returned by `signup()`, if user signup by phone, the `username` in `values` is undefined.
values.username = res.data.split("/")[1];
if (typeof res.data === "string") {
values.username = res.data.split("/")[1];
}
if (Setting.hasPromptPage(application) && (!values.plan || !values.pricing)) {
AuthBackend.getAccount("")
.then((res) => {

View File

@@ -0,0 +1,50 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import * as Setting from "../Setting";
export function grantConsent(consent, oAuthParams) {
const request = {
...consent,
clientId: oAuthParams.clientId,
provider: "",
signinMethod: "",
responseType: oAuthParams.responseType || "code",
redirectUri: oAuthParams.redirectUri,
scope: oAuthParams.scope,
state: oAuthParams.state,
nonce: oAuthParams.nonce || "",
challenge: oAuthParams.codeChallenge || "",
resource: "",
};
return fetch(`${Setting.ServerUrl}/api/grant-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(request),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}
export function revokeConsent(consent) {
return fetch(`${Setting.ServerUrl}/api/revoke-consent`, {
method: "POST",
credentials: "include",
body: JSON.stringify(consent),
headers: {
"Accept-Language": Setting.getAcceptLanguage(),
},
}).then(res => res.json());
}

View File

@@ -0,0 +1,132 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {Button, Popconfirm, Table, Tag} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
import * as ConsentBackend from "../backend/ConsentBackend";
class ConsentTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
deleteScope(record, scopeToDelete) {
ConsentBackend.revokeConsent({
application: record.application,
grantedScopes: scopeToDelete ? [scopeToDelete] : record.grantedScopes,
})
.then((res) => {
if (res.status === "ok") {
Setting.showMessage("success", i18next.t("general:Successfully revoked"));
this.props.onUpdateTable();
} else {
Setting.showMessage("error", res.msg);
}
})
.catch(error => {
Setting.showMessage("error", `${i18next.t("general:Failed to connect to server")}: ${error}`);
});
}
renderTable(table) {
const columns = [
{
title: i18next.t("general:Application"),
dataIndex: "application",
key: "application",
width: "200px",
render: (text) => {
return text;
},
},
{
title: i18next.t("consent:Granted scopes"),
dataIndex: "grantedScopes",
key: "grantedScopes",
render: (text, record) => {
return (
<div style={{display: "flex", flexWrap: "wrap", gap: "4px"}}>
{
(Array.isArray(text) ? text : []).map((scope, index) => {
return (
<Popconfirm
key={index}
title={`${i18next.t("consent:Are you sure you want to revoke scope")}: ${scope}?`}
onConfirm={() => this.deleteScope(record, scope)}
okText={i18next.t("general:OK")}
cancelText={i18next.t("general:Cancel")}
>
<Tag
color="blue"
style={{cursor: "pointer"}}
>
{scope}
</Tag>
</Popconfirm>
);
})
}
</div>
);
},
},
{
title: i18next.t("general:Action"),
key: "action",
width: "100px",
render: (_, record, __) => {
return (
<Popconfirm
title={i18next.t("consent:Are you sure you want to revoke this consent?")}
onConfirm={() => this.deleteScope(record)}
okText={i18next.t("general:OK")}
cancelText={i18next.t("general:Cancel")}
>
<Button type="primary" danger size="small">
{i18next.t("consent:Delete")}
</Button>
</Popconfirm>
);
},
},
];
return (
<Table scroll={{x: "max-content"}} rowKey="application" columns={columns} dataSource={table} size="middle" bordered pagination={false}
title={() => (
<div>
{this.props.title}
</div>
)}
/>
);
}
render() {
return (
<div>
{
this.renderTable(this.props.table)
}
</div>
);
}
}
export default ConsentTable;

View File

@@ -0,0 +1,208 @@
// Copyright 2026 The Casdoor Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import React from "react";
import {DeleteOutlined, DownOutlined, UpOutlined} from "@ant-design/icons";
import {AutoComplete, Button, Col, Input, Row, Table, Tooltip} from "antd";
import * as Setting from "../Setting";
import i18next from "i18next";
const DefaultScopes = [
{scope: "openid", displayName: "OpenID", description: "Authenticate the user and obtain an ID token"},
{scope: "profile", displayName: "Profile", description: "Read all user profile data"},
{scope: "email", displayName: "Email", description: "Access user email addresses (read-only)"},
{scope: "address", displayName: "Address", description: "Access the user's address information"},
{scope: "phone", displayName: "Phone", description: "Access the user's phone number information"},
{scope: "offline_access", displayName: "Offline Access", description: "Obtain refresh tokens for offline access"},
];
class CustomScopeTable extends React.Component {
constructor(props) {
super(props);
this.state = {
classes: props,
};
}
normalizeScope(scope) {
return (scope || "").trim().toLowerCase();
}
getAvailableDefaultScopes(table) {
const existingScopes = new Set((table || []).map(item => this.normalizeScope(item?.scope)).filter(Boolean));
return DefaultScopes.filter(item => !existingScopes.has(this.normalizeScope(item.scope)));
}
updateTable(table) {
this.props.onUpdateTable(table);
}
updateField(table, index, key, value) {
table[index][key] = value;
this.updateTable(table);
}
isScopeMissing(row) {
if (!row) {
return true;
}
const scope = (row.scope || "").trim();
return scope === "";
}
addRow(table) {
const row = {scope: "", displayName: "", description: ""};
if (table === undefined || table === null) {
table = [];
}
table = Setting.addRow(table, row);
this.updateTable(table);
}
deleteRow(table, i) {
table = Setting.deleteRow(table, i);
this.updateTable(table);
}
upRow(table, i) {
table = Setting.swapRow(table, i - 1, i);
this.updateTable(table);
}
downRow(table, i) {
table = Setting.swapRow(table, i, i + 1);
this.updateTable(table);
}
renderTable(table) {
table = table || [];
const columns = [
{
title: (
<div style={{display: "flex", alignItems: "center", gap: "8px"}}>
<span className="ant-form-item-required">{i18next.t("general:Name")}</span>
<div style={{color: "red"}}>*</div>
</div>
),
dataIndex: "scope",
key: "scope",
width: "260px",
render: (text, record, index) => {
const availableDefaultScopes = this.getAvailableDefaultScopes(table);
const autoCompleteOptions = availableDefaultScopes.map(item => ({
label: `${item.scope}`,
value: item.scope,
}));
return (
<AutoComplete
status={this.isScopeMissing(record) ? "error" : ""}
value={text}
options={autoCompleteOptions}
placeholder="Select or input scope"
onSelect={(value) => {
this.updateField(table, index, "scope", value);
const selectedScope = availableDefaultScopes.find(item => item.scope === value);
if (selectedScope) {
this.updateField(table, index, "displayName", selectedScope.displayName);
this.updateField(table, index, "description", selectedScope.description);
}
}}
onChange={(value) => {
this.updateField(table, index, "scope", value);
}}
>
<Input />
</AutoComplete>
);
},
},
{
title: i18next.t("general:Display name"),
dataIndex: "displayName",
key: "displayName",
width: "200px",
render: (text, _, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "displayName", e.target.value);
}} />
);
},
},
{
title: i18next.t("general:Description"),
dataIndex: "description",
key: "description",
render: (text, record, index) => {
return (
<Input value={text} onChange={e => {
this.updateField(table, index, "description", e.target.value);
}} />
);
},
},
{
title: i18next.t("general:Action"),
dataIndex: "action",
key: "action",
width: "110px",
// eslint-disable-next-line
render: (_, __, index) => {
return (
<div>
<Tooltip placement="bottomLeft" title={i18next.t("general:Up")}>
<Button style={{marginRight: "5px"}} disabled={index === 0} icon={<UpOutlined />} size="small" onClick={() => this.upRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Down")}>
<Button style={{marginRight: "5px"}} disabled={index === table.length - 1} icon={<DownOutlined />} size="small" onClick={() => this.downRow(table, index)} />
</Tooltip>
<Tooltip placement="topLeft" title={i18next.t("general:Delete")}>
<Button icon={<DeleteOutlined />} size="small" onClick={() => this.deleteRow(table, index)} />
</Tooltip>
</div>
);
},
},
];
return (
<Table title={() => (
<div style={{display: "flex", justifyContent: "space-between"}}>
<div style={{marginTop: "5px"}}>{this.props.title}</div>
<Button type="primary" size="small" onClick={() => this.addRow(table)}>{i18next.t("general:Add")}</Button>
</div>
)}
columns={columns} dataSource={table} rowKey={(record, index) => record.scope?.trim() || `temp_${index}`} size="middle" bordered pagination={false}
/>
);
}
render() {
return (
<div>
<Row style={{marginTop: "20px"}} >
<Col span={24}>
{
this.renderTable(this.props.table)
}
</Col>
</Row>
</div>
);
}
}
export default CustomScopeTable;