diff options
| author | James Elliott <james-d-elliott@users.noreply.github.com> | 2025-02-22 19:20:34 +1100 | 
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-02-22 08:20:34 +0000 | 
| commit | 111344eaea4fd0c32ce58a181b94414ae639fe2b (patch) | |
| tree | 76e024658c1e2483795a8153fe18661ca035c138 /web | |
| parent | 9c718b39888bbaafdbc623acd0efd2138b6b8068 (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.ts | 2 | ||||
| -rw-r--r-- | web/src/services/ConsentOpenIDConnect.ts | 56 | ||||
| -rw-r--r-- | web/src/views/ConsentPortal/OpenIDConnectConsentPortal/OpenIDConnectConsentDecisionFormView.tsx | 114 | 
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",      },  | 
