From 509b502d3c4abafbb9df7f6a106849f8e1b948e6 Mon Sep 17 00:00:00 2001 From: Jens L Date: Thu, 4 Jan 2024 19:57:11 +0100 Subject: [PATCH] providers/oauth2: offline access (#8026) * improve scope check (log when application requests non-configured scopes) Signed-off-by: Jens Langhammer * add offline_access special scope Signed-off-by: Jens Langhammer * ensure scope is set Signed-off-by: Jens Langhammer * update tests for refresh tokens Signed-off-by: Jens Langhammer * special handling of scopes for github compat Signed-off-by: Jens Langhammer * fix spec Signed-off-by: Jens Langhammer * attempt to fix oidc tests Signed-off-by: Jens Langhammer * remove hardcoded slug Signed-off-by: Jens Langhammer * check scope from authorization code instead of request Signed-off-by: Jens Langhammer * fix injection for consent stage checking incorrectly Signed-off-by: Jens Langhammer --------- Signed-off-by: Jens Langhammer --- authentik/providers/oauth2/constants.py | 12 +- authentik/providers/oauth2/errors.py | 2 +- .../providers/oauth2/tests/test_authorize.py | 35 +++++- .../providers/oauth2/tests/test_token.py | 42 ++++++- authentik/providers/oauth2/urls_root.py | 2 +- authentik/providers/oauth2/views/authorize.py | 73 +++++++++---- authentik/providers/oauth2/views/token.py | 103 ++++++++++-------- blueprints/system/providers-oauth2.yaml | 11 ++ tests/e2e/test_provider_oauth2_github.py | 18 +-- tests/e2e/test_provider_oauth2_grafana.py | 88 ++++++++------- tests/e2e/test_provider_oidc.py | 77 ++++++++----- tests/e2e/test_provider_oidc_implicit.py | 53 +++++---- .../providers/oauth2/OAuth2ProviderForm.ts | 8 +- website/docs/providers/oauth2/index.md | 10 +- website/docs/releases/2024/v2024.1.md | 6 + 15 files changed, 369 insertions(+), 171 deletions(-) diff --git a/authentik/providers/oauth2/constants.py b/authentik/providers/oauth2/constants.py index 81460b707..baaebbd60 100644 --- a/authentik/providers/oauth2/constants.py +++ b/authentik/providers/oauth2/constants.py @@ -7,8 +7,8 @@ GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" GRANT_TYPE_PASSWORD = "password" # nosec GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" -CLIENT_ASSERTION_TYPE = "client_assertion_type" CLIENT_ASSERTION = "client_assertion" +CLIENT_ASSERTION_TYPE = "client_assertion_type" CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" PROMPT_NONE = "none" @@ -18,9 +18,9 @@ PROMPT_LOGIN = "login" SCOPE_OPENID = "openid" SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_EMAIL = "email" +SCOPE_OFFLINE_ACCESS = "offline_access" -# https://www.iana.org/assignments/oauth-parameters/\ -# oauth-parameters.xhtml#pkce-code-challenge-method +# https://www.iana.org/assignments/oauth-parameters/auth-parameters.xhtml#pkce-code-challenge-method PKCE_METHOD_PLAIN = "plain" PKCE_METHOD_S256 = "S256" @@ -36,6 +36,12 @@ SCOPE_GITHUB_USER_READ = "read:user" SCOPE_GITHUB_USER_EMAIL = "user:email" # Read info about teams SCOPE_GITHUB_ORG_READ = "read:org" +SCOPE_GITHUB = { + SCOPE_GITHUB_USER, + SCOPE_GITHUB_USER_READ, + SCOPE_GITHUB_USER_EMAIL, + SCOPE_GITHUB_ORG_READ, +} ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default" diff --git a/authentik/providers/oauth2/errors.py b/authentik/providers/oauth2/errors.py index 1788c9370..fd124cb91 100644 --- a/authentik/providers/oauth2/errors.py +++ b/authentik/providers/oauth2/errors.py @@ -127,7 +127,7 @@ class AuthorizeError(OAuth2Error): "account_selection_required": ( "The End-User is required to select a session at the Authorization Server" ), - "consent_required": "The Authorization Server requires End-Userconsent", + "consent_required": "The Authorization Server requires End-User consent", "invalid_request_uri": ( "The request_uri in the Authorization Request returns an error or contains invalid data" ), diff --git a/authentik/providers/oauth2/tests/test_authorize.py b/authentik/providers/oauth2/tests/test_authorize.py index 91cdc330a..7903c685c 100644 --- a/authentik/providers/oauth2/tests/test_authorize.py +++ b/authentik/providers/oauth2/tests/test_authorize.py @@ -5,6 +5,7 @@ from django.test import RequestFactory from django.urls import reverse from django.utils.timezone import now +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.events.models import Event, EventAction @@ -18,6 +19,7 @@ from authentik.providers.oauth2.models import ( AuthorizationCode, GrantTypes, OAuth2Provider, + ScopeMapping, ) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams @@ -172,14 +174,24 @@ class TestAuthorize(OAuthTestCase): ) OAuthAuthorizationParams.from_request(request) + @apply_blueprint("system/providers-oauth2.yaml") def test_response_type(self): """test response_type""" - OAuth2Provider.objects.create( + provider = OAuth2Provider.objects.create( name=generate_id(), client_id="test", authorization_flow=create_test_flow(), redirect_uris="http://local.invalid/Foo", ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + ] + ) + ) request = self.factory.get( "/", data={ @@ -292,6 +304,7 @@ class TestAuthorize(OAuthTestCase): delta=5, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_full_implicit(self): """Test full authorization""" flow = create_test_flow() @@ -302,6 +315,15 @@ class TestAuthorize(OAuthTestCase): redirect_uris="http://localhost", signing_key=self.keypair, ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + ] + ) + ) Application.objects.create(name="app", slug="app", provider=provider) state = generate_id() user = create_test_admin_user() @@ -409,6 +431,7 @@ class TestAuthorize(OAuthTestCase): delta=5, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_full_form_post_id_token(self): """Test full authorization (form_post response)""" flow = create_test_flow() @@ -419,6 +442,15 @@ class TestAuthorize(OAuthTestCase): redirect_uris="http://localhost", signing_key=self.keypair, ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + ] + ) + ) app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider) state = generate_id() user = create_test_admin_user() @@ -440,6 +472,7 @@ class TestAuthorize(OAuthTestCase): reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), ) token: AccessToken = AccessToken.objects.filter(user=user).first() + self.assertIsNotNone(token) self.assertJSONEqual( response.content.decode(), { diff --git a/authentik/providers/oauth2/tests/test_token.py b/authentik/providers/oauth2/tests/test_token.py index 79b3b13fe..5904d38be 100644 --- a/authentik/providers/oauth2/tests/test_token.py +++ b/authentik/providers/oauth2/tests/test_token.py @@ -6,6 +6,7 @@ from django.test import RequestFactory from django.urls import reverse from django.utils import timezone +from authentik.blueprints.tests import apply_blueprint from authentik.core.models import Application from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.events.models import Event, EventAction @@ -21,6 +22,7 @@ from authentik.providers.oauth2.models import ( AuthorizationCode, OAuth2Provider, RefreshToken, + ScopeMapping, ) from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.views.token import TokenParams @@ -136,21 +138,20 @@ class TestToken(OAuthTestCase): HTTP_AUTHORIZATION=f"Basic {header}", ) access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() - refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first() self.assertJSONEqual( response.content.decode(), { "access_token": access.token, - "refresh_token": refresh.token, "token_type": TOKEN_TYPE, "expires_in": 3600, "id_token": provider.encode( - refresh.id_token.to_dict(), + access.id_token.to_dict(), ), }, ) self.validate_jwt(access, provider) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_view(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -159,6 +160,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://local.invalid", signing_key=self.keypair, ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-offline_access", + ] + ) + ) # Needs to be assigned to an application for iss to be set self.app.provider = provider self.app.save() @@ -170,6 +181,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) response = self.client.post( reverse("authentik_providers_oauth2:token"), @@ -201,6 +213,7 @@ class TestToken(OAuthTestCase): ) self.validate_jwt(access, provider) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_view_invalid_origin(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -209,6 +222,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://local.invalid", signing_key=self.keypair, ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-offline_access", + ] + ) + ) header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode() user = create_test_admin_user() token: RefreshToken = RefreshToken.objects.create( @@ -217,6 +240,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) response = self.client.post( reverse("authentik_providers_oauth2:token"), @@ -247,6 +271,7 @@ class TestToken(OAuthTestCase): }, ) + @apply_blueprint("system/providers-oauth2.yaml") def test_refresh_token_revoke(self): """test request param""" provider = OAuth2Provider.objects.create( @@ -255,6 +280,16 @@ class TestToken(OAuthTestCase): redirect_uris="http://testserver", signing_key=self.keypair, ) + provider.property_mappings.set( + ScopeMapping.objects.filter( + managed__in=[ + "goauthentik.io/providers/oauth2/scope-openid", + "goauthentik.io/providers/oauth2/scope-email", + "goauthentik.io/providers/oauth2/scope-profile", + "goauthentik.io/providers/oauth2/scope-offline_access", + ] + ) + ) # Needs to be assigned to an application for iss to be set self.app.provider = provider self.app.save() @@ -266,6 +301,7 @@ class TestToken(OAuthTestCase): token=generate_id(), _id_token=dumps({}), auth_time=timezone.now(), + _scope="offline_access", ) # Create initial refresh token response = self.client.post( diff --git a/authentik/providers/oauth2/urls_root.py b/authentik/providers/oauth2/urls_root.py index b00a90de4..9f7fe05ac 100644 --- a/authentik/providers/oauth2/urls_root.py +++ b/authentik/providers/oauth2/urls_root.py @@ -10,7 +10,7 @@ from authentik.providers.oauth2.views.token import TokenView github_urlpatterns = [ path( "login/oauth/authorize", - AuthorizationFlowInitView.as_view(), + AuthorizationFlowInitView.as_view(github_compat=True), name="github-authorize", ), path( diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index 520f02d65..7ad76a642 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -1,5 +1,5 @@ """authentik OAuth2 Authorization views""" -from dataclasses import dataclass, field +from dataclasses import InitVar, dataclass, field from datetime import timedelta from hashlib import sha256 from json import dumps @@ -41,6 +41,8 @@ from authentik.providers.oauth2.constants import ( PROMPT_CONSENT, PROMPT_LOGIN, PROMPT_NONE, + SCOPE_GITHUB, + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, TOKEN_TYPE, ) @@ -66,7 +68,6 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage from authentik.stages.consent.stage import ( PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_PERMISSIONS, - ConsentStageView, ) LOGGER = get_logger() @@ -86,7 +87,7 @@ class OAuthAuthorizationParams: redirect_uri: str response_type: str response_mode: Optional[str] - scope: list[str] + scope: set[str] state: str nonce: Optional[str] prompt: set[str] @@ -101,8 +102,10 @@ class OAuthAuthorizationParams: code_challenge: Optional[str] = None code_challenge_method: Optional[str] = None + github_compat: InitVar[bool] = False + @staticmethod - def from_request(request: HttpRequest) -> "OAuthAuthorizationParams": + def from_request(request: HttpRequest, github_compat=False) -> "OAuthAuthorizationParams": """ Get all the params used by the Authorization Code Flow (and also for the Implicit and Hybrid). @@ -154,7 +157,7 @@ class OAuthAuthorizationParams: response_type=response_type, response_mode=response_mode, grant_type=grant_type, - scope=query_dict.get("scope", "").split(), + scope=set(query_dict.get("scope", "").split()), state=state, nonce=query_dict.get("nonce"), prompt=ALLOWED_PROMPT_PARAMS.intersection(set(query_dict.get("prompt", "").split())), @@ -162,9 +165,10 @@ class OAuthAuthorizationParams: max_age=int(max_age) if max_age else None, code_challenge=query_dict.get("code_challenge"), code_challenge_method=query_dict.get("code_challenge_method", "plain"), + github_compat=github_compat, ) - def __post_init__(self): + def __post_init__(self, github_compat=False): self.provider: OAuth2Provider = OAuth2Provider.objects.filter( client_id=self.client_id ).first() @@ -172,7 +176,7 @@ class OAuthAuthorizationParams: LOGGER.warning("Invalid client identifier", client_id=self.client_id) raise ClientIdError(client_id=self.client_id) self.check_redirect_uri() - self.check_scope() + self.check_scope(github_compat) self.check_nonce() self.check_code_challenge() @@ -199,8 +203,8 @@ class OAuthAuthorizationParams: if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls): LOGGER.warning( "Invalid redirect uri (regex comparison)", - redirect_uri=self.redirect_uri, - expected=allowed_redirect_urls, + redirect_uri_given=self.redirect_uri, + redirect_uri_expected=allowed_redirect_urls, ) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) except RegexError as exc: @@ -208,8 +212,8 @@ class OAuthAuthorizationParams: if not any(x == self.redirect_uri for x in allowed_redirect_urls): LOGGER.warning( "Invalid redirect uri (strict comparison)", - redirect_uri=self.redirect_uri, - expected=allowed_redirect_urls, + redirect_uri_given=self.redirect_uri, + redirect_uri_expected=allowed_redirect_urls, ) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) if self.request: @@ -217,24 +221,50 @@ class OAuthAuthorizationParams: self.redirect_uri, "request_not_supported", self.grant_type, self.state ) - def check_scope(self): + def check_scope(self, github_compat=False): """Ensure openid scope is set in Hybrid flows, or when requesting an id_token""" - if len(self.scope) == 0: - default_scope_names = set( - ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( - "scope_name", flat=True - ) + default_scope_names = set( + ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( + "scope_name", flat=True ) + ) + if len(self.scope) == 0: self.scope = default_scope_names LOGGER.info( "No scopes requested, defaulting to all configured scopes", scopes=self.scope ) + scopes_to_check = self.scope + if github_compat: + scopes_to_check = self.scope - SCOPE_GITHUB + if not scopes_to_check.issubset(default_scope_names): + LOGGER.info( + "Application requested scopes not configured, setting to overlap", + scope_allowed=default_scope_names, + scope_given=self.scope, + ) + self.scope = self.scope.intersection(default_scope_names) if SCOPE_OPENID not in self.scope and ( self.grant_type == GrantTypes.HYBRID or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] ): LOGGER.warning("Missing 'openid' scope.") raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state) + if SCOPE_OFFLINE_ACCESS in self.scope: + # https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess + if PROMPT_CONSENT not in self.prompt: + raise AuthorizeError( + self.redirect_uri, "consent_required", self.grant_type, self.state + ) + if self.response_type not in [ + ResponseTypes.CODE, + ResponseTypes.CODE_TOKEN, + ResponseTypes.CODE_ID_TOKEN, + ResponseTypes.CODE_ID_TOKEN_TOKEN, + ]: + # offline_access requires a response type that has some sort of token + # Spec says to ignore the scope when the response_type wouldn't result + # in an authorization code being generated + self.scope.remove(SCOPE_OFFLINE_ACCESS) def check_nonce(self): """Nonce parameter validation.""" @@ -297,6 +327,9 @@ class AuthorizationFlowInitView(PolicyAccessView): """OAuth2 Flow initializer, checks access to application and starts flow""" params: OAuthAuthorizationParams + # Enable GitHub compatibility (only allow for scopes which are handled + # differently for github compat) + github_compat = False def pre_permission_check(self): """Check prompt parameter before checking permission/authentication, @@ -305,7 +338,9 @@ class AuthorizationFlowInitView(PolicyAccessView): if len(self.request.GET) < 1: raise Http404 try: - self.params = OAuthAuthorizationParams.from_request(self.request) + self.params = OAuthAuthorizationParams.from_request( + self.request, github_compat=self.github_compat + ) except AuthorizeError as error: LOGGER.warning(error.description, redirect_uri=error.redirect_uri) raise RequestValidationError(error.get_response(self.request)) @@ -402,7 +437,7 @@ class AuthorizationFlowInitView(PolicyAccessView): # OpenID clients can specify a `prompt` parameter, and if its set to consent we # need to inject a consent stage if PROMPT_CONSENT in self.params.prompt: - if not any(isinstance(x.stage, ConsentStageView) for x in plan.bindings): + if not any(isinstance(x.stage, ConsentStage) for x in plan.bindings): # Plan does not have any consent stage, so we add an in-memory one stage = ConsentStage( name="OAuth2 Provider In-memory consent stage", diff --git a/authentik/providers/oauth2/views/token.py b/authentik/providers/oauth2/views/token.py index 93578d6ce..78931b6c8 100644 --- a/authentik/providers/oauth2/views/token.py +++ b/authentik/providers/oauth2/views/token.py @@ -41,6 +41,7 @@ from authentik.providers.oauth2.constants import ( GRANT_TYPE_PASSWORD, GRANT_TYPE_REFRESH_TOKEN, PKCE_METHOD_S256, + SCOPE_OFFLINE_ACCESS, TOKEN_TYPE, ) from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError @@ -459,7 +460,7 @@ class TokenView(View): op="authentik.providers.oauth2.post.response", ): if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: - LOGGER.debug("Converting authorization code to refresh token") + LOGGER.debug("Converting authorization code to access token") return TokenResponse(self.create_code_response()) if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: LOGGER.debug("Refreshing refresh token") @@ -496,42 +497,47 @@ class TokenView(View): ) access_token.save() - refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) - refresh_token = RefreshToken( - user=self.params.authorization_code.user, - scope=self.params.authorization_code.scope, - expires=refresh_token_expiry, - provider=self.provider, - auth_time=self.params.authorization_code.auth_time, - session_id=self.params.authorization_code.session_id, - ) - id_token = IDToken.new( - self.provider, - refresh_token, - self.request, - ) - id_token.nonce = self.params.authorization_code.nonce - id_token.at_hash = access_token.at_hash - refresh_token.id_token = id_token - refresh_token.save() - - # Delete old code - self.params.authorization_code.delete() - return { + response = { "access_token": access_token.token, - "refresh_token": refresh_token.token, "token_type": TOKEN_TYPE, "expires_in": int( timedelta_from_string(self.provider.access_token_validity).total_seconds() ), - "id_token": id_token.to_jwt(self.provider), + "id_token": access_token.id_token.to_jwt(self.provider), } + if SCOPE_OFFLINE_ACCESS in self.params.authorization_code.scope: + refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) + refresh_token = RefreshToken( + user=self.params.authorization_code.user, + scope=self.params.authorization_code.scope, + expires=refresh_token_expiry, + provider=self.provider, + auth_time=self.params.authorization_code.auth_time, + session_id=self.params.authorization_code.session_id, + ) + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.nonce = self.params.authorization_code.nonce + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token + refresh_token.save() + response["refresh_token"] = refresh_token.token + + # Delete old code + self.params.authorization_code.delete() + return response + def create_refresh_response(self) -> dict[str, Any]: """See https://datatracker.ietf.org/doc/html/rfc6749#section-6""" unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) if unauthorized_scopes: raise TokenError("invalid_scope") + if SCOPE_OFFLINE_ACCESS not in self.params.scope: + raise TokenError("invalid_scope") now = timezone.now() access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token = AccessToken( @@ -630,31 +636,34 @@ class TokenView(View): ) access_token.save() - refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) - refresh_token = RefreshToken( - user=self.params.device_code.user, - scope=self.params.device_code.scope, - expires=refresh_token_expiry, - provider=self.provider, - auth_time=auth_event.created if auth_event else now, - ) - id_token = IDToken.new( - self.provider, - refresh_token, - self.request, - ) - id_token.at_hash = access_token.at_hash - refresh_token.id_token = id_token - refresh_token.save() - - # Delete device code - self.params.device_code.delete() - return { + response = { "access_token": access_token.token, - "refresh_token": refresh_token.token, "token_type": TOKEN_TYPE, "expires_in": int( timedelta_from_string(self.provider.access_token_validity).total_seconds() ), - "id_token": id_token.to_jwt(self.provider), + "id_token": access_token.id_token.to_jwt(self.provider), } + + if SCOPE_OFFLINE_ACCESS in self.params.scope: + refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) + refresh_token = RefreshToken( + user=self.params.device_code.user, + scope=self.params.device_code.scope, + expires=refresh_token_expiry, + provider=self.provider, + auth_time=auth_event.created if auth_event else now, + ) + id_token = IDToken.new( + self.provider, + refresh_token, + self.request, + ) + id_token.at_hash = access_token.at_hash + refresh_token.id_token = id_token + refresh_token.save() + response["refresh_token"] = refresh_token.token + + # Delete device code + self.params.device_code.delete() + return response diff --git a/blueprints/system/providers-oauth2.yaml b/blueprints/system/providers-oauth2.yaml index eb4ca5442..0d2448202 100644 --- a/blueprints/system/providers-oauth2.yaml +++ b/blueprints/system/providers-oauth2.yaml @@ -45,3 +45,14 @@ entries: # groups is not part of the official userinfo schema, but is a quasi-standard "groups": [group.name for group in request.user.ak_groups.all()], } + - identifiers: + managed: goauthentik.io/providers/oauth2/scope-offline_access + model: authentik_providers_oauth2.scopemapping + attrs: + name: "authentik default OAuth Mapping: OpenID 'offline_access'" + scope_name: offline_access + description: "Access to request new tokens without interaction" + expression: | + # This scope grants the application a refresh token that can be used to refresh user data + # and let the application access authentik without the users interaction + return {} diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index 5e19dd146..5095421a8 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -74,7 +74,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -82,8 +82,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) @@ -129,7 +129,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -137,8 +137,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) app = Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) @@ -200,7 +200,7 @@ class TestProviderOAuth2Github(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_id=self.client_id, client_secret=self.client_secret, client_type=ClientTypes.CONFIDENTIAL, @@ -208,8 +208,8 @@ class TestProviderOAuth2Github(SeleniumTestCase): authorization_flow=authorization_flow, ) app = Application.objects.create( - name="Grafana", - slug="grafana", + name=generate_id(), + slug=generate_id(), provider=provider, ) diff --git a/tests/e2e/test_provider_oauth2_grafana.py b/tests/e2e/test_provider_oauth2_grafana.py index 2538fae70..fa8f12d1d 100644 --- a/tests/e2e/test_provider_oauth2_grafana.py +++ b/tests/e2e/test_provider_oauth2_grafana.py @@ -14,6 +14,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -80,7 +81,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -90,12 +91,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() Application.objects.create( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -113,12 +119,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_consent_implied(self): """test OpenID Provider flow (default authorization flow with implied consent)""" @@ -128,7 +130,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -138,11 +140,16 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) Application.objects.create( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -174,12 +181,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_logout(self): """test OpenID Provider flow with logout""" @@ -189,7 +192,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-implicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, client_secret=self.client_secret, @@ -199,12 +202,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() Application.objects.create( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -244,12 +252,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" @@ -259,7 +263,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), authorization_flow=authorization_flow, client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, @@ -269,12 +273,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() app = Application.objects.create( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) @@ -323,12 +332,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @apply_blueprint( - "system/providers-oauth2.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -338,7 +343,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): slug="default-provider-authorization-explicit-consent" ) provider = OAuth2Provider.objects.create( - name="grafana", + name=generate_id(), authorization_flow=authorization_flow, client_type=ClientTypes.CONFIDENTIAL, client_id=self.client_id, @@ -348,12 +353,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() app = Application.objects.create( - name="Grafana", + name=generate_id(), slug=self.app_slug, provider=provider, ) diff --git a/tests/e2e/test_provider_oidc.py b/tests/e2e/test_provider_oidc.py index 3180f9534..c14a498a6 100644 --- a/tests/e2e/test_provider_oidc.py +++ b/tests/e2e/test_provider_oidc.py @@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -29,7 +30,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): def setUp(self): self.client_id = generate_id() self.client_secret = generate_key() - self.application_slug = "test" + self.application_slug = generate_id() super().setUp() def setup_client(self) -> Container: @@ -37,7 +38,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): sleep(1) client: DockerClient = from_env() container = client.containers.run( - image="ghcr.io/beryju/oidc-test-client:1.3", + image="ghcr.io/beryju/oidc-test-client:2.1", detach=True, ports={ "9009": "9009", @@ -56,9 +57,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @reconcile_app("authentik_crypto") def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" @@ -78,10 +77,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) - provider.save() Application.objects.create( name=self.application_slug, slug=self.application_slug, @@ -101,13 +104,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_implied(self): - """test OpenID Provider flow (default authorization flow with implied consent)""" + """test OpenID Provider flow (default authorization flow with implied consent) + (due to offline_access a consent will still be triggered)""" sleep(1) # Bootstrap all needed objects authorization_flow = Flow.objects.get( @@ -124,11 +126,15 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) - provider.save() - Application.objects.create( + app = Application.objects.create( name=self.application_slug, slug=self.application_slug, provider=provider, @@ -137,6 +143,20 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): self.driver.get("http://localhost:9009") self.login() + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor"))) + + flow_executor = self.get_shadow_root("ak-flow-executor") + consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor) + + self.assertIn( + app.name, + consent_stage.find_element(By.CSS_SELECTOR, "#header-text").text, + ) + consent_stage.find_element( + By.CSS_SELECTOR, + "[type=submit]", + ).click() + self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "pre"))) self.wait.until(ec.text_to_be_present_in_element((By.CSS_SELECTOR, "pre"), "{")) body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text) @@ -155,11 +175,9 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" sleep(1) @@ -178,10 +196,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) - provider.save() app = Application.objects.create( name=self.application_slug, slug=self.application_slug, @@ -224,9 +246,8 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -246,10 +267,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) - provider.save() app = Application.objects.create( name=self.application_slug, slug=self.application_slug, diff --git a/tests/e2e/test_provider_oidc_implicit.py b/tests/e2e/test_provider_oidc_implicit.py index c5d9d37d0..37dff0aa3 100644 --- a/tests/e2e/test_provider_oidc_implicit.py +++ b/tests/e2e/test_provider_oidc_implicit.py @@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.models import PolicyBinding from authentik.providers.oauth2.constants import ( + SCOPE_OFFLINE_ACCESS, SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE, @@ -37,7 +38,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): sleep(1) client: DockerClient = from_env() container = client.containers.run( - image="ghcr.io/beryju/oidc-test-client:1.3", + image="ghcr.io/beryju/oidc-test-client:2.1", detach=True, ports={ "9009": "9009", @@ -56,9 +57,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_redirect_uri_error(self): """test OpenID Provider flow (invalid redirect URI, check error message)""" @@ -78,7 +78,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() @@ -101,11 +106,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-implicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_implied(self): """test OpenID Provider flow (default authorization flow with implied consent)""" sleep(1) @@ -124,7 +127,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() @@ -150,11 +158,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) - @reconcile_app("authentik_crypto") + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") @apply_blueprint("system/providers-oauth2.yaml") + @reconcile_app("authentik_crypto") def test_authorization_consent_explicit(self): """test OpenID Provider flow (default authorization flow with explicit consent)""" sleep(1) @@ -173,7 +179,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() @@ -215,9 +226,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): "default/flow-default-authentication-flow.yaml", "default/flow-default-invalidation-flow.yaml", ) - @apply_blueprint( - "default/flow-default-provider-authorization-explicit-consent.yaml", - ) + @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml") + @apply_blueprint("system/providers-oauth2.yaml") @reconcile_app("authentik_crypto") def test_authorization_denied(self): """test OpenID Provider flow (default authorization with access deny)""" @@ -237,7 +247,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase): ) provider.property_mappings.set( ScopeMapping.objects.filter( - scope_name__in=[SCOPE_OPENID, SCOPE_OPENID_EMAIL, SCOPE_OPENID_PROFILE] + scope_name__in=[ + SCOPE_OPENID, + SCOPE_OPENID_EMAIL, + SCOPE_OPENID_PROFILE, + SCOPE_OFFLINE_ACCESS, + ] ) ) provider.save() diff --git a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts index 000df8cad..74f3acbeb 100644 --- a/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts +++ b/web/src/admin/providers/oauth2/OAuth2ProviderForm.ts @@ -290,9 +290,13 @@ export class OAuth2ProviderFormPage extends BaseProviderForm { let selected = false; if (!provider?.propertyMappings) { selected = - scope.managed?.startsWith( + // By default select all managed scope mappings, except offline_access + (scope.managed?.startsWith( "goauthentik.io/providers/oauth2/scope-", - ) || false; + ) && + scope.managed !== + "goauthentik.io/providers/oauth2/scope-offline_access") || + false; } else { selected = Array.from(provider?.propertyMappings).some((su) => { return su == scope.pk; diff --git a/website/docs/providers/oauth2/index.md b/website/docs/providers/oauth2/index.md index 0eba63765..90ce75778 100644 --- a/website/docs/providers/oauth2/index.md +++ b/website/docs/providers/oauth2/index.md @@ -35,12 +35,20 @@ To access the user's email address, a scope of `user:email` is required. To acce ### `authorization_code`: -This grant is used to convert an authorization code to a refresh token. The authorization code is retrieved through the Authorization flow, and can only be used once, and expires quickly. +This grant is used to convert an authorization code to an access token (and optionally refresh token). The authorization code is retrieved through the Authorization flow, and can only be used once, and expires quickly. + +:::info +Starting with authentik 2024.1, applications only receive an access token. To receive a refresh token, applications must be allowed to request the `offline_access` scope in authentik and also be configured to request the scope. +::: ### `refresh_token`: Refresh tokens can be used as long-lived tokens to access user data, and further renew the refresh token down the road. +:::info +Starting with authentik 2024.1, this grant requires the `offline_access` scope. +::: + ### `client_credentials`: See [Machine-to-machine authentication](./client_credentials) diff --git a/website/docs/releases/2024/v2024.1.md b/website/docs/releases/2024/v2024.1.md index c6ffd0f28..e9f31fc55 100644 --- a/website/docs/releases/2024/v2024.1.md +++ b/website/docs/releases/2024/v2024.1.md @@ -17,6 +17,12 @@ slug: "/releases/2024.1" - `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total` - `authentik_main_requests` -> `authentik_main_request_duration_seconds` +- Required `offline_access` scope for Refresh tokens + + The OAuth2 provider ships with a new default scope called `offline_access`, which must be requested by applications that need a refresh token. Previously, authentik would always issue a refresh token for the _Authorization code_ and _Device code_ OAuth grants. + + Applications which require will need their configuration update to include the `offline_access` scope mapping. + ## New features - "Pretend user exists" option for Identification stage