From 66a450ed38063c36934280387d2eea9884de217c Mon Sep 17 00:00:00 2001 From: James Elliott Date: Fri, 8 Apr 2022 15:35:21 +1000 Subject: [PATCH] feat(oidc): pre-configured consent (#3118) Allows users to pre-configure consent if enabled by the client configuration by selecting a checkbox during consent. Closes #2598 --- config.template.yml | 5 +++ docs/configuration/identity-providers/oidc.md | 16 ++++++++ internal/configuration/config.template.yml | 5 +++ .../schema/identity_providers.go | 6 ++- internal/configuration/validator/const.go | 1 + internal/handlers/handler_oidc_consent.go | 14 ++++++- internal/oidc/client.go | 7 +++- internal/oidc/types.go | 8 +++- internal/server/locales/en/portal.json | 2 + web/src/hooks/Consent.ts | 6 +-- web/src/services/Consent.ts | 14 +++++-- .../LoginPortal/ConsentView/ConsentView.tsx | 41 +++++++++++++++++-- 12 files changed, 107 insertions(+), 18 deletions(-) diff --git a/config.template.yml b/config.template.yml index 9e46ed6f3..4e45513b8 100644 --- a/config.template.yml +++ b/config.template.yml @@ -813,6 +813,11 @@ notifier: ## The policy to require for this client; one_factor or two_factor. # authorization_policy: two_factor + ## By default users cannot remember pre-configured consents. Setting this value to a period of time using a + ## duration notation will enable users to remember consent for this client. The time configured is the amount + ## of time the pre-configured consent is valid for granting new authorizations to the user. + # pre_configured_consent_duration: + ## Audience this client is allowed to request. # audience: [] diff --git a/docs/configuration/identity-providers/oidc.md b/docs/configuration/identity-providers/oidc.md index 55a8c2819..65fa3ba0e 100644 --- a/docs/configuration/identity-providers/oidc.md +++ b/docs/configuration/identity-providers/oidc.md @@ -51,6 +51,7 @@ identity_providers: sector_identifier: '' public: false authorization_policy: two_factor + pre_configured_consent_duration: '' audience: [] scopes: - openid @@ -402,6 +403,21 @@ required: no The authorization policy for this client: either `one_factor` or `two_factor`. +#### pre_configured_consent_duration +
+type: string (duration) +{: .label .label-config .label-purple } +required: no +{: .label .label-config .label-green } +
+ +Configuring this enables users of this client to remember their consent as a pre-configured consent. The value is period +of time is in [duration notation format](../index.md#duration-notation-format). The period of time dictates how long a +users choice to remember the pre-configured consent lasts. + +Pre-configured consents are only valid if the subject, client id are exactly the same and the requested scopes/audience +match exactly with the granted scopes/audience. + #### audience
type: list(string) diff --git a/internal/configuration/config.template.yml b/internal/configuration/config.template.yml index 9e46ed6f3..4e45513b8 100644 --- a/internal/configuration/config.template.yml +++ b/internal/configuration/config.template.yml @@ -813,6 +813,11 @@ notifier: ## The policy to require for this client; one_factor or two_factor. # authorization_policy: two_factor + ## By default users cannot remember pre-configured consents. Setting this value to a period of time using a + ## duration notation will enable users to remember consent for this client. The time configured is the amount + ## of time the pre-configured consent is valid for granting new authorizations to the user. + # pre_configured_consent_duration: + ## Audience this client is allowed to request. # audience: [] diff --git a/internal/configuration/schema/identity_providers.go b/internal/configuration/schema/identity_providers.go index 69c82a3ea..0bfb969d9 100644 --- a/internal/configuration/schema/identity_providers.go +++ b/internal/configuration/schema/identity_providers.go @@ -47,8 +47,6 @@ type OpenIDConnectClientConfiguration struct { SectorIdentifier url.URL `koanf:"sector_identifier"` Public bool `koanf:"public"` - Policy string `koanf:"authorization_policy"` - RedirectURIs []string `koanf:"redirect_uris"` Audience []string `koanf:"audience"` @@ -58,6 +56,10 @@ type OpenIDConnectClientConfiguration struct { ResponseModes []string `koanf:"response_modes"` UserinfoSigningAlgorithm string `koanf:"userinfo_signing_algorithm"` + + Policy string `koanf:"authorization_policy"` + + PreConfiguredConsentDuration *time.Duration `koanf:"pre_configured_consent_duration"` } // DefaultOpenIDConnectConfiguration contains defaults for OIDC. diff --git a/internal/configuration/validator/const.go b/internal/configuration/validator/const.go index cf7390030..0698c8995 100644 --- a/internal/configuration/validator/const.go +++ b/internal/configuration/validator/const.go @@ -495,6 +495,7 @@ var ValidKeys = []string{ "identity_providers.oidc.clients[].public", "identity_providers.oidc.clients[].redirect_uris", "identity_providers.oidc.clients[].authorization_policy", + "identity_providers.oidc.clients[].pre_configured_consent_duration", "identity_providers.oidc.clients[].scopes", "identity_providers.oidc.clients[].audience", "identity_providers.oidc.clients[].grant_types", diff --git a/internal/handlers/handler_oidc_consent.go b/internal/handlers/handler_oidc_consent.go index af41dfb59..35e163224 100644 --- a/internal/handlers/handler_oidc_consent.go +++ b/internal/handlers/handler_oidc_consent.go @@ -3,6 +3,7 @@ package handlers import ( "encoding/json" "fmt" + "time" "github.com/authelia/authelia/v4/internal/middlewares" "github.com/authelia/authelia/v4/internal/model" @@ -78,6 +79,17 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) { return } + if body.PreConfigure { + if client.PreConfiguredConsentDuration == nil { + ctx.Logger.Warnf("Consent session with challenge id '%s' for user '%s': consent pre-configuration was requested and was ignored because it is not permitted on this client", consent.ChallengeID.String(), userSession.Username) + } else { + expiresAt := time.Now().Add(*client.PreConfiguredConsentDuration) + consent.ExpiresAt = &expiresAt + + ctx.Logger.Debugf("Consent session with challenge id '%s' for user '%s': pre-configured and set to expire at %v", consent.ChallengeID.String(), userSession.Username, consent.ExpiresAt) + } + } + consent.GrantedScopes = consent.RequestedScopes consent.GrantedAudience = consent.RequestedAudience @@ -87,7 +99,7 @@ func OpenIDConnectConsentPOST(ctx *middlewares.AutheliaCtx) { case reject: authorized = false default: - ctx.Logger.Warnf("User '%s' tried to reply to consent with an unexpected verb", userSession.Username) + ctx.Logger.Warnf("User '%s' tried to reply to consent with an unexpected verb '%s'", userSession.Username, body.AcceptOrReject) ctx.ReplyBadRequest() return diff --git a/internal/oidc/client.go b/internal/oidc/client.go index 2b7a04e46..0be6ce4f0 100644 --- a/internal/oidc/client.go +++ b/internal/oidc/client.go @@ -18,8 +18,6 @@ func NewClient(config schema.OpenIDConnectClientConfiguration) (client *Client) SectorIdentifier: config.SectorIdentifier.String(), Public: config.Public, - Policy: authorization.PolicyToLevel(config.Policy), - Audience: config.Audience, Scopes: config.Scopes, RedirectURIs: config.RedirectURIs, @@ -28,6 +26,10 @@ func NewClient(config schema.OpenIDConnectClientConfiguration) (client *Client) ResponseModes: []fosite.ResponseModeType{fosite.ResponseModeDefault}, UserinfoSigningAlgorithm: config.UserinfoSigningAlgorithm, + + Policy: authorization.PolicyToLevel(config.Policy), + + PreConfiguredConsentDuration: config.PreConfiguredConsentDuration, } for _, mode := range config.ResponseModes { @@ -57,6 +59,7 @@ func (c Client) GetConsentResponseBody(consent *model.OAuth2ConsentSession) Cons body := ConsentGetResponseBody{ ClientID: c.ID, ClientDescription: c.Description, + PreConfiguration: c.PreConfiguredConsentDuration != nil, } if consent != nil { diff --git a/internal/oidc/types.go b/internal/oidc/types.go index a144295e6..43a79a3ec 100644 --- a/internal/oidc/types.go +++ b/internal/oidc/types.go @@ -102,8 +102,6 @@ type Client struct { SectorIdentifier string Public bool - Policy authorization.Level - Audience []string Scopes []string RedirectURIs []string @@ -112,6 +110,10 @@ type Client struct { ResponseModes []fosite.ResponseModeType UserinfoSigningAlgorithm string + + Policy authorization.Level + + PreConfiguredConsentDuration *time.Duration } // KeyManager keeps track of all of the active/inactive rsa keys and provides them to services requiring them. @@ -132,12 +134,14 @@ type ConsentGetResponseBody struct { ClientDescription string `json:"client_description"` Scopes []string `json:"scopes"` Audience []string `json:"audience"` + PreConfiguration bool `json:"pre_configuration"` } // ConsentPostRequestBody schema of the request body of the consent POST endpoint. type ConsentPostRequestBody struct { ClientID string `json:"client_id"` AcceptOrReject string `json:"accept_or_reject"` + PreConfigure bool `json:"pre_configure"` } // ConsentPostResponseBody schema of the response body of the consent POST endpoint. diff --git a/internal/server/locales/en/portal.json b/internal/server/locales/en/portal.json index e57e4677f..ed845d49a 100644 --- a/internal/server/locales/en/portal.json +++ b/internal/server/locales/en/portal.json @@ -62,6 +62,8 @@ "Must have at least one special character": "Must have at least one special character", "Must be at least {{len}} characters in length": "Must be at least {{len}} characters in length", "Must not be more than {{len}} characters in length": "Must not be more than {{len}} characters in length", + "This saves this consent as a pre-configured consent for future use": "This saves this consent as a pre-configured consent for future use", + "Remember Consent": "Remember Consent", "Consent Request": "Consent Request", "Client ID": "Client ID: {{client_id}}" } \ No newline at end of file diff --git a/web/src/hooks/Consent.ts b/web/src/hooks/Consent.ts index 4c7c7fed7..08300c6e6 100644 --- a/web/src/hooks/Consent.ts +++ b/web/src/hooks/Consent.ts @@ -1,6 +1,6 @@ import { useRemoteCall } from "@hooks/RemoteCall"; -import { getRequestedScopes } from "@services/Consent"; +import { getConsentResponse } from "@services/Consent"; -export function useRequestedScopes() { - return useRemoteCall(getRequestedScopes, []); +export function useConsentResponse() { + return useRemoteCall(getConsentResponse, []); } diff --git a/web/src/services/Consent.ts b/web/src/services/Consent.ts index befecc1c5..36601feb5 100644 --- a/web/src/services/Consent.ts +++ b/web/src/services/Consent.ts @@ -4,6 +4,7 @@ import { Post, Get } from "@services/Client"; interface ConsentPostRequestBody { client_id: string; accept_or_reject: "accept" | "reject"; + pre_configure: boolean; } interface ConsentPostResponseBody { @@ -15,18 +16,23 @@ interface ConsentGetResponseBody { client_description: string; scopes: string[]; audience: string[]; + pre_configuration: boolean; } -export function getRequestedScopes() { +export function getConsentResponse() { return Get(ConsentPath); } -export function acceptConsent(clientID: string) { - const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "accept" }; +export function acceptConsent(clientID: string, preConfigure: boolean) { + const body: ConsentPostRequestBody = { + client_id: clientID, + accept_or_reject: "accept", + pre_configure: preConfigure, + }; return Post(ConsentPath, body); } export function rejectConsent(clientID: string) { - const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "reject" }; + const body: ConsentPostRequestBody = { client_id: clientID, accept_or_reject: "reject", pre_configure: false }; return Post(ConsentPath, body); } diff --git a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx index f52119cf0..965bdab2e 100644 --- a/web/src/views/LoginPortal/ConsentView/ConsentView.tsx +++ b/web/src/views/LoginPortal/ConsentView/ConsentView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, Fragment, ReactNode } from "react"; +import React, { useEffect, Fragment, ReactNode, useState } from "react"; import { Button, @@ -10,13 +10,15 @@ import { Tooltip, Typography, makeStyles, + Checkbox, + FormControlLabel, } from "@material-ui/core"; import { AccountBox, CheckBox, Contacts, Drafts, Group } from "@material-ui/icons"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { IndexRoute } from "@constants/Routes"; -import { useRequestedScopes } from "@hooks/Consent"; +import { useConsentResponse } from "@hooks/Consent"; import { useNotifications } from "@hooks/NotificationsContext"; import { useRedirector } from "@hooks/Redirector"; import { useUserInfoGET } from "@hooks/UserInfo"; @@ -46,9 +48,15 @@ const ConsentView = function (props: Props) { const navigate = useNavigate(); const redirect = useRedirector(); const { createErrorNotification, resetNotification } = useNotifications(); - const [resp, fetch, , err] = useRequestedScopes(); + const [resp, fetch, , err] = useConsentResponse(); const { t: translate } = useTranslation(); + const [preConfigure, setPreConfigure] = useState(false); + + const handlePreConfigureChanged = () => { + setPreConfigure((preConfigure) => !preConfigure); + }; + const [userInfo, fetchUserInfo, , fetchUserInfoError] = useUserInfoGET(); useEffect(() => { @@ -92,7 +100,7 @@ const ConsentView = function (props: Props) { if (!resp) { return; } - const res = await acceptConsent(resp.client_id); + const res = await acceptConsent(resp.client_id, preConfigure); if (res.redirect_uri) { redirect(res.redirect_uri); } else { @@ -154,6 +162,30 @@ const ConsentView = function (props: Props) {
+ {resp?.pre_configuration ? ( + + + + } + className={classes.preConfigure} + label={translate("Remember Consent")} + /> + + + ) : null} @@ -226,6 +258,7 @@ const useStyles = makeStyles((theme) => ({ textAlign: "center", marginRight: theme.spacing(2), }, + preConfigure: {}, })); export default ConsentView;