summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorJames Elliott <james-d-elliott@users.noreply.github.com>2025-02-22 19:20:34 +1100
committerGitHub <noreply@github.com>2025-02-22 08:20:34 +0000
commit111344eaea4fd0c32ce58a181b94414ae639fe2b (patch)
tree76e024658c1e2483795a8153fe18661ca035c138 /web
parent9c718b39888bbaafdbc623acd0efd2138b6b8068 (diff)
feat(oidc): claims parameter support (#8081)
This adds formal support for the claims parameter. Closes #2868 Signed-off-by: James Elliott <james-d-elliott@users.noreply.github.com>
Diffstat (limited to 'web')
-rw-r--r--web/src/i18n/index.ts2
-rw-r--r--web/src/services/ConsentOpenIDConnect.ts56
-rw-r--r--web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx114
3 files changed, 157 insertions, 15 deletions
diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts
index f115988d4..4782eb3e5 100644
--- a/web/src/i18n/index.ts
+++ b/web/src/i18n/index.ts
@@ -35,7 +35,7 @@ i18n.use(Backend)
loadPath: basePath + "/locales/{{lng}}/{{ns}}.json",
},
load: "all",
- ns: ["portal", "settings"],
+ ns: ["portal", "settings", "consent"],
defaultNS: "portal",
fallbackLng: {
default: ["en"],
diff --git a/web/src/services/ConsentOpenIDConnect.ts b/web/src/services/ConsentOpenIDConnect.ts
index b22916fb2..3bc2afbc2 100644
--- a/web/src/services/ConsentOpenIDConnect.ts
+++ b/web/src/services/ConsentOpenIDConnect.ts
@@ -6,6 +6,7 @@ interface ConsentPostRequestBody {
client_id: string;
consent: boolean;
pre_configure: boolean;
+ claims?: string[];
}
interface ConsentPostResponseBody {
@@ -18,18 +19,21 @@ export interface ConsentGetResponseBody {
scopes: string[];
audience: string[];
pre_configuration: boolean;
+ claims: string[] | null;
+ essential_claims: string[] | null;
}
export function getConsentResponse(consentID: string) {
return Get<ConsentGetResponseBody>(ConsentPath + "?id=" + consentID);
}
-export function acceptConsent(preConfigure: boolean, clientID: string, consentID: string | null) {
+export function acceptConsent(preConfigure: boolean, clientID: string, consentID: string | null, claims: string[]) {
const body: ConsentPostRequestBody = {
id: consentID === null ? undefined : consentID,
client_id: clientID,
consent: true,
pre_configure: preConfigure,
+ claims: claims,
};
return Post<ConsentPostResponseBody>(ConsentPath, body);
}
@@ -44,6 +48,22 @@ export function rejectConsent(clientID: string, consentID: string | null) {
return Post<ConsentPostResponseBody>(ConsentPath, body);
}
+export function formatScope(scope: string, fallback: string): string {
+ if (!scope.startsWith("scopes.") && scope !== "") {
+ return scope;
+ } else {
+ return getScopeDescription(fallback);
+ }
+}
+
+export function formatClaim(claim: string, fallback: string): string {
+ if (!claim.startsWith("claims.") && claim !== "") {
+ return claim;
+ } else {
+ return getClaimDescription(fallback);
+ }
+}
+
export function getScopeDescription(scope: string): string {
switch (scope) {
case "openid":
@@ -62,3 +82,37 @@ export function getScopeDescription(scope: string): string {
return scope;
}
}
+
+export function getClaimDescription(claim: string): string {
+ switch (claim) {
+ case "name":
+ return "Display Name";
+ case "sub":
+ return "Unique Identifier";
+ case "zoneinfo":
+ return "Timezone";
+ case "locale":
+ return "Locale / Language";
+ case "updated_at":
+ return "Information Updated Time";
+ case "profile":
+ case "website":
+ case "picture":
+ return `${setClaimCase(claim)} URL`;
+ default:
+ return setClaimCase(claim);
+ }
+}
+
+function setClaimCase(claim: string): string {
+ claim = (claim.charAt(0).toUpperCase() + claim.slice(1)).replace("_verified", " (Verified)").replace("_", " ");
+
+ for (let i = 0; i < claim.length; i++) {
+ const j = i + 1;
+
+ if (claim[i] === " " && j < claim.length) {
+ claim.charAt(j).toUpperCase();
+ }
+ }
+ return claim;
+}
diff --git a/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx b/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx
index cc1071bc4..955cf2757 100644
--- a/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx
+++ b/web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx
@@ -1,7 +1,8 @@
-import React, { Fragment, ReactNode, useEffect, useState } from "react";
+import React, { Fragment, ReactNode, useCallback, useEffect, useState } from "react";
-import { AccountBox, Autorenew, CheckBox, Contacts, Drafts, Group, LockOpen } from "@mui/icons-material";
+import { AccountBox, Autorenew, Contacts, Drafts, Group, LockOpen, Policy } from "@mui/icons-material";
import {
+ Box,
Button,
Checkbox,
FormControlLabel,
@@ -27,8 +28,9 @@ import { UserInfo } from "@models/UserInfo";
import {
ConsentGetResponseBody,
acceptConsent,
+ formatClaim,
+ formatScope,
getConsentResponse,
- getScopeDescription,
rejectConsent,
} from "@services/ConsentOpenIDConnect";
import { AutheliaState } from "@services/State";
@@ -54,12 +56,12 @@ function scopeNameToAvatar(id: string) {
case "authelia.bearer.authz":
return <LockOpen />;
default:
- return <CheckBox />;
+ return <Policy />;
}
}
const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) => {
- const { t: translate } = useTranslation();
+ const { t: translate } = useTranslation(["portal", "consent"]);
const { createErrorNotification, resetNotification } = useNotifications();
const navigate = useNavigate();
@@ -69,6 +71,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
const [response, setResponse] = useState<ConsentGetResponseBody>();
const [error, setError] = useState<any>(undefined);
+ const [claims, setClaims] = useState<string>("");
const [preConfigure, setPreConfigure] = useState(false);
const styles = useStyles();
@@ -82,6 +85,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
getConsentResponse(consentID)
.then((r) => {
setResponse(r);
+ setClaims(JSON.stringify(r.claims));
})
.catch((error) => {
setError(error);
@@ -101,7 +105,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
if (!response) {
return;
}
- const res = await acceptConsent(preConfigure, response.client_id, consentID);
+ const res = await acceptConsent(preConfigure, response.client_id, consentID, JSON.parse(claims));
if (res.redirect_uri) {
redirect(res.redirect_uri);
} else {
@@ -121,6 +125,39 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
}
};
+ const handleClaimCheckboxOnChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+ setClaims((prevState) => {
+ const value = event.target.value;
+ const arrClaims: string[] = JSON.parse(prevState);
+ const checking = !arrClaims.includes(event.target.value);
+
+ if (checking) {
+ if (!arrClaims.includes(value)) {
+ arrClaims.push(value);
+ }
+ } else {
+ const i = arrClaims.indexOf(value);
+
+ if (i > -1) {
+ arrClaims.splice(i, 1);
+ }
+ }
+
+ return JSON.stringify(arrClaims);
+ });
+ };
+
+ const claimChecked = useCallback(
+ (claim: string) => {
+ const arrClaims: string[] = JSON.parse(claims);
+
+ return arrClaims.includes(claim);
+ },
+ [claims],
+ );
+
+ const hasClaims = response?.essential_claims || response?.claims;
+
return (
<ComponentOrLoading ready={response !== undefined}>
<LoginLayout
@@ -130,7 +167,7 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
>
<Grid container alignItems={"center"} justifyContent="center">
<Grid size={{ xs: 12 }}>
- <div>
+ <Box>
<Tooltip
title={
translate("Client ID", { client_id: response?.client_id }) ||
@@ -143,25 +180,67 @@ const OpenIDConnectConsentDecisionFormView: React.FC<Props> = (props: Props) =>
: response?.client_id}
</Typography>
</Tooltip>
- </div>
+ </Box>
</Grid>
<Grid size={{ xs: 12 }}>
- <div>{translate("The above application is requesting the following permissions")}:</div>
+ <Box>{translate("The above application is requesting the following permissions")}:</Box>
</Grid>
<Grid size={{ xs: 12 }}>
- <div className={styles.scopesListContainer}>
+ <Box className={styles.scopesListContainer}>
<List className={styles.scopesList}>
{response?.scopes.map((scope: string) => (
- <Tooltip title={translate("Scope", { name: scope })}>
+ <Tooltip title={translate("Scope", { name: scope, ns: "consent" })}>
<ListItem id={"scope-" + scope} dense>
<ListItemIcon>{scopeNameToAvatar(scope)}</ListItemIcon>
- <ListItemText primary={translate(getScopeDescription(scope))} />
+ <ListItemText
+ primary={formatScope(
+ translate(`scopes.${scope}`, { ns: "consent" }),
+ scope,
+ )}
+ />
</ListItem>
</Tooltip>
))}
</List>
- </div>
+ </Box>
</Grid>
+ {hasClaims ? (
+ <Grid size={{ xs: 12 }}>
+ <Box className={styles.claimsListContainer}>
+ <List className={styles.claimsList}>
+ {response?.essential_claims?.map((claim: string) => (
+ <Tooltip title={translate("Claim", { name: claim, ns: "consent" })}>
+ <FormControlLabel
+ control={<Checkbox id={`claim-${claim}-essential`} disabled checked />}
+ label={formatClaim(
+ translate(`claims.${claim}`, { ns: "consent" }),
+ claim,
+ )}
+ />
+ </Tooltip>
+ ))}
+ {response?.claims?.map((claim: string) => (
+ <Tooltip title={translate("Claim", { name: claim, ns: "consent" })}>
+ <FormControlLabel
+ control={
+ <Checkbox
+ id={"claim-" + claim}
+ value={claim}
+ checked={claimChecked(claim)}
+ onChange={handleClaimCheckboxOnChange}
+ />
+ }
+ label={formatClaim(
+ translate(`claims.${claim}`, { ns: "consent" }),
+ claim,
+ )}
+ />
+ </Tooltip>
+ ))}
+ </List>
+ </Box>
+ </Grid>
+ ) : null}
{response?.pre_configuration ? (
<Grid size={{ xs: 12 }}>
<Tooltip
@@ -236,6 +315,15 @@ const useStyles = makeStyles((theme: Theme) => ({
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
+ claimsListContainer: {
+ textAlign: "center",
+ },
+ claimsList: {
+ display: "inline-block",
+ backgroundColor: theme.palette.background.paper,
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ },
clientID: {
fontWeight: "bold",
},