providers/oauth2: offline access (#8026)

* improve scope check (log when application requests non-configured scopes)

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add offline_access special scope

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* ensure scope is set

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* update tests for refresh tokens

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* special handling of scopes for github compat

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix spec

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* attempt to fix oidc tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* remove hardcoded slug

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* check scope from authorization code instead of request

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix injection for consent stage checking incorrectly

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens L 2024-01-04 19:57:11 +01:00 committed by GitHub
parent 1b36cb8331
commit 509b502d3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 369 additions and 171 deletions

View File

@ -7,8 +7,8 @@ GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
GRANT_TYPE_PASSWORD = "password" # nosec GRANT_TYPE_PASSWORD = "password" # nosec
GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code" GRANT_TYPE_DEVICE_CODE = "urn:ietf:params:oauth:grant-type:device_code"
CLIENT_ASSERTION_TYPE = "client_assertion_type"
CLIENT_ASSERTION = "client_assertion" CLIENT_ASSERTION = "client_assertion"
CLIENT_ASSERTION_TYPE = "client_assertion_type"
CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" CLIENT_ASSERTION_TYPE_JWT = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
PROMPT_NONE = "none" PROMPT_NONE = "none"
@ -18,9 +18,9 @@ PROMPT_LOGIN = "login"
SCOPE_OPENID = "openid" SCOPE_OPENID = "openid"
SCOPE_OPENID_PROFILE = "profile" SCOPE_OPENID_PROFILE = "profile"
SCOPE_OPENID_EMAIL = "email" SCOPE_OPENID_EMAIL = "email"
SCOPE_OFFLINE_ACCESS = "offline_access"
# https://www.iana.org/assignments/oauth-parameters/\ # https://www.iana.org/assignments/oauth-parameters/auth-parameters.xhtml#pkce-code-challenge-method
# oauth-parameters.xhtml#pkce-code-challenge-method
PKCE_METHOD_PLAIN = "plain" PKCE_METHOD_PLAIN = "plain"
PKCE_METHOD_S256 = "S256" PKCE_METHOD_S256 = "S256"
@ -36,6 +36,12 @@ SCOPE_GITHUB_USER_READ = "read:user"
SCOPE_GITHUB_USER_EMAIL = "user:email" SCOPE_GITHUB_USER_EMAIL = "user:email"
# Read info about teams # Read info about teams
SCOPE_GITHUB_ORG_READ = "read:org" 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" ACR_AUTHENTIK_DEFAULT = "goauthentik.io/providers/oauth2/default"

View File

@ -127,7 +127,7 @@ class AuthorizeError(OAuth2Error):
"account_selection_required": ( "account_selection_required": (
"The End-User is required to select a session at the Authorization Server" "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": ( "invalid_request_uri": (
"The request_uri in the Authorization Request returns an error or contains invalid data" "The request_uri in the Authorization Request returns an error or contains invalid data"
), ),

View File

@ -5,6 +5,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import now from django.utils.timezone import now
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -18,6 +19,7 @@ from authentik.providers.oauth2.models import (
AuthorizationCode, AuthorizationCode,
GrantTypes, GrantTypes,
OAuth2Provider, OAuth2Provider,
ScopeMapping,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams from authentik.providers.oauth2.views.authorize import OAuthAuthorizationParams
@ -172,14 +174,24 @@ class TestAuthorize(OAuthTestCase):
) )
OAuthAuthorizationParams.from_request(request) OAuthAuthorizationParams.from_request(request)
@apply_blueprint("system/providers-oauth2.yaml")
def test_response_type(self): def test_response_type(self):
"""test response_type""" """test response_type"""
OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name=generate_id(), name=generate_id(),
client_id="test", client_id="test",
authorization_flow=create_test_flow(), authorization_flow=create_test_flow(),
redirect_uris="http://local.invalid/Foo", 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( request = self.factory.get(
"/", "/",
data={ data={
@ -292,6 +304,7 @@ class TestAuthorize(OAuthTestCase):
delta=5, delta=5,
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_implicit(self): def test_full_implicit(self):
"""Test full authorization""" """Test full authorization"""
flow = create_test_flow() flow = create_test_flow()
@ -302,6 +315,15 @@ class TestAuthorize(OAuthTestCase):
redirect_uris="http://localhost", redirect_uris="http://localhost",
signing_key=self.keypair, 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) Application.objects.create(name="app", slug="app", provider=provider)
state = generate_id() state = generate_id()
user = create_test_admin_user() user = create_test_admin_user()
@ -409,6 +431,7 @@ class TestAuthorize(OAuthTestCase):
delta=5, delta=5,
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_full_form_post_id_token(self): def test_full_form_post_id_token(self):
"""Test full authorization (form_post response)""" """Test full authorization (form_post response)"""
flow = create_test_flow() flow = create_test_flow()
@ -419,6 +442,15 @@ class TestAuthorize(OAuthTestCase):
redirect_uris="http://localhost", redirect_uris="http://localhost",
signing_key=self.keypair, 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) app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
state = generate_id() state = generate_id()
user = create_test_admin_user() user = create_test_admin_user()
@ -440,6 +472,7 @@ class TestAuthorize(OAuthTestCase):
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}),
) )
token: AccessToken = AccessToken.objects.filter(user=user).first() token: AccessToken = AccessToken.objects.filter(user=user).first()
self.assertIsNotNone(token)
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {

View File

@ -6,6 +6,7 @@ from django.test import RequestFactory
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from authentik.blueprints.tests import apply_blueprint
from authentik.core.models import Application from authentik.core.models import Application
from authentik.core.tests.utils import create_test_admin_user, create_test_flow from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction from authentik.events.models import Event, EventAction
@ -21,6 +22,7 @@ from authentik.providers.oauth2.models import (
AuthorizationCode, AuthorizationCode,
OAuth2Provider, OAuth2Provider,
RefreshToken, RefreshToken,
ScopeMapping,
) )
from authentik.providers.oauth2.tests.utils import OAuthTestCase from authentik.providers.oauth2.tests.utils import OAuthTestCase
from authentik.providers.oauth2.views.token import TokenParams from authentik.providers.oauth2.views.token import TokenParams
@ -136,21 +138,20 @@ class TestToken(OAuthTestCase):
HTTP_AUTHORIZATION=f"Basic {header}", HTTP_AUTHORIZATION=f"Basic {header}",
) )
access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first() access: AccessToken = AccessToken.objects.filter(user=user, provider=provider).first()
refresh: RefreshToken = RefreshToken.objects.filter(user=user, provider=provider).first()
self.assertJSONEqual( self.assertJSONEqual(
response.content.decode(), response.content.decode(),
{ {
"access_token": access.token, "access_token": access.token,
"refresh_token": refresh.token,
"token_type": TOKEN_TYPE, "token_type": TOKEN_TYPE,
"expires_in": 3600, "expires_in": 3600,
"id_token": provider.encode( "id_token": provider.encode(
refresh.id_token.to_dict(), access.id_token.to_dict(),
), ),
}, },
) )
self.validate_jwt(access, provider) self.validate_jwt(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view(self): def test_refresh_token_view(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -159,6 +160,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
signing_key=self.keypair, 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 # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
self.app.save() self.app.save()
@ -170,6 +181,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -201,6 +213,7 @@ class TestToken(OAuthTestCase):
) )
self.validate_jwt(access, provider) self.validate_jwt(access, provider)
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_view_invalid_origin(self): def test_refresh_token_view_invalid_origin(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -209,6 +222,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://local.invalid", redirect_uris="http://local.invalid",
signing_key=self.keypair, 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() header = b64encode(f"{provider.client_id}:{provider.client_secret}".encode()).decode()
user = create_test_admin_user() user = create_test_admin_user()
token: RefreshToken = RefreshToken.objects.create( token: RefreshToken = RefreshToken.objects.create(
@ -217,6 +240,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
response = self.client.post( response = self.client.post(
reverse("authentik_providers_oauth2:token"), reverse("authentik_providers_oauth2:token"),
@ -247,6 +271,7 @@ class TestToken(OAuthTestCase):
}, },
) )
@apply_blueprint("system/providers-oauth2.yaml")
def test_refresh_token_revoke(self): def test_refresh_token_revoke(self):
"""test request param""" """test request param"""
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
@ -255,6 +280,16 @@ class TestToken(OAuthTestCase):
redirect_uris="http://testserver", redirect_uris="http://testserver",
signing_key=self.keypair, 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 # Needs to be assigned to an application for iss to be set
self.app.provider = provider self.app.provider = provider
self.app.save() self.app.save()
@ -266,6 +301,7 @@ class TestToken(OAuthTestCase):
token=generate_id(), token=generate_id(),
_id_token=dumps({}), _id_token=dumps({}),
auth_time=timezone.now(), auth_time=timezone.now(),
_scope="offline_access",
) )
# Create initial refresh token # Create initial refresh token
response = self.client.post( response = self.client.post(

View File

@ -10,7 +10,7 @@ from authentik.providers.oauth2.views.token import TokenView
github_urlpatterns = [ github_urlpatterns = [
path( path(
"login/oauth/authorize", "login/oauth/authorize",
AuthorizationFlowInitView.as_view(), AuthorizationFlowInitView.as_view(github_compat=True),
name="github-authorize", name="github-authorize",
), ),
path( path(

View File

@ -1,5 +1,5 @@
"""authentik OAuth2 Authorization views""" """authentik OAuth2 Authorization views"""
from dataclasses import dataclass, field from dataclasses import InitVar, dataclass, field
from datetime import timedelta from datetime import timedelta
from hashlib import sha256 from hashlib import sha256
from json import dumps from json import dumps
@ -41,6 +41,8 @@ from authentik.providers.oauth2.constants import (
PROMPT_CONSENT, PROMPT_CONSENT,
PROMPT_LOGIN, PROMPT_LOGIN,
PROMPT_NONE, PROMPT_NONE,
SCOPE_GITHUB,
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
TOKEN_TYPE, TOKEN_TYPE,
) )
@ -66,7 +68,6 @@ from authentik.stages.consent.models import ConsentMode, ConsentStage
from authentik.stages.consent.stage import ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS, PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView,
) )
LOGGER = get_logger() LOGGER = get_logger()
@ -86,7 +87,7 @@ class OAuthAuthorizationParams:
redirect_uri: str redirect_uri: str
response_type: str response_type: str
response_mode: Optional[str] response_mode: Optional[str]
scope: list[str] scope: set[str]
state: str state: str
nonce: Optional[str] nonce: Optional[str]
prompt: set[str] prompt: set[str]
@ -101,8 +102,10 @@ class OAuthAuthorizationParams:
code_challenge: Optional[str] = None code_challenge: Optional[str] = None
code_challenge_method: Optional[str] = None code_challenge_method: Optional[str] = None
github_compat: InitVar[bool] = False
@staticmethod @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 Get all the params used by the Authorization Code Flow
(and also for the Implicit and Hybrid). (and also for the Implicit and Hybrid).
@ -154,7 +157,7 @@ class OAuthAuthorizationParams:
response_type=response_type, response_type=response_type,
response_mode=response_mode, response_mode=response_mode,
grant_type=grant_type, grant_type=grant_type,
scope=query_dict.get("scope", "").split(), scope=set(query_dict.get("scope", "").split()),
state=state, state=state,
nonce=query_dict.get("nonce"), nonce=query_dict.get("nonce"),
prompt=ALLOWED_PROMPT_PARAMS.intersection(set(query_dict.get("prompt", "").split())), 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, max_age=int(max_age) if max_age else None,
code_challenge=query_dict.get("code_challenge"), code_challenge=query_dict.get("code_challenge"),
code_challenge_method=query_dict.get("code_challenge_method", "plain"), 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( self.provider: OAuth2Provider = OAuth2Provider.objects.filter(
client_id=self.client_id client_id=self.client_id
).first() ).first()
@ -172,7 +176,7 @@ class OAuthAuthorizationParams:
LOGGER.warning("Invalid client identifier", client_id=self.client_id) LOGGER.warning("Invalid client identifier", client_id=self.client_id)
raise ClientIdError(client_id=self.client_id) raise ClientIdError(client_id=self.client_id)
self.check_redirect_uri() self.check_redirect_uri()
self.check_scope() self.check_scope(github_compat)
self.check_nonce() self.check_nonce()
self.check_code_challenge() self.check_code_challenge()
@ -199,8 +203,8 @@ class OAuthAuthorizationParams:
if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls): if not any(fullmatch(x, self.redirect_uri) for x in allowed_redirect_urls):
LOGGER.warning( LOGGER.warning(
"Invalid redirect uri (regex comparison)", "Invalid redirect uri (regex comparison)",
redirect_uri=self.redirect_uri, redirect_uri_given=self.redirect_uri,
expected=allowed_redirect_urls, redirect_uri_expected=allowed_redirect_urls,
) )
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
except RegexError as exc: except RegexError as exc:
@ -208,8 +212,8 @@ class OAuthAuthorizationParams:
if not any(x == self.redirect_uri for x in allowed_redirect_urls): if not any(x == self.redirect_uri for x in allowed_redirect_urls):
LOGGER.warning( LOGGER.warning(
"Invalid redirect uri (strict comparison)", "Invalid redirect uri (strict comparison)",
redirect_uri=self.redirect_uri, redirect_uri_given=self.redirect_uri,
expected=allowed_redirect_urls, redirect_uri_expected=allowed_redirect_urls,
) )
raise RedirectUriError(self.redirect_uri, allowed_redirect_urls) raise RedirectUriError(self.redirect_uri, allowed_redirect_urls)
if self.request: if self.request:
@ -217,24 +221,50 @@ class OAuthAuthorizationParams:
self.redirect_uri, "request_not_supported", self.grant_type, self.state 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""" """Ensure openid scope is set in Hybrid flows, or when requesting an id_token"""
if len(self.scope) == 0: default_scope_names = set(
default_scope_names = set( ScopeMapping.objects.filter(provider__in=[self.provider]).values_list(
ScopeMapping.objects.filter(provider__in=[self.provider]).values_list( "scope_name", flat=True
"scope_name", flat=True
)
) )
)
if len(self.scope) == 0:
self.scope = default_scope_names self.scope = default_scope_names
LOGGER.info( LOGGER.info(
"No scopes requested, defaulting to all configured scopes", scopes=self.scope "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 ( if SCOPE_OPENID not in self.scope and (
self.grant_type == GrantTypes.HYBRID self.grant_type == GrantTypes.HYBRID
or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN] or self.response_type in [ResponseTypes.ID_TOKEN, ResponseTypes.ID_TOKEN_TOKEN]
): ):
LOGGER.warning("Missing 'openid' scope.") LOGGER.warning("Missing 'openid' scope.")
raise AuthorizeError(self.redirect_uri, "invalid_scope", self.grant_type, self.state) 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): def check_nonce(self):
"""Nonce parameter validation.""" """Nonce parameter validation."""
@ -297,6 +327,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow""" """OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams params: OAuthAuthorizationParams
# Enable GitHub compatibility (only allow for scopes which are handled
# differently for github compat)
github_compat = False
def pre_permission_check(self): def pre_permission_check(self):
"""Check prompt parameter before checking permission/authentication, """Check prompt parameter before checking permission/authentication,
@ -305,7 +338,9 @@ class AuthorizationFlowInitView(PolicyAccessView):
if len(self.request.GET) < 1: if len(self.request.GET) < 1:
raise Http404 raise Http404
try: try:
self.params = OAuthAuthorizationParams.from_request(self.request) self.params = OAuthAuthorizationParams.from_request(
self.request, github_compat=self.github_compat
)
except AuthorizeError as error: except AuthorizeError as error:
LOGGER.warning(error.description, redirect_uri=error.redirect_uri) LOGGER.warning(error.description, redirect_uri=error.redirect_uri)
raise RequestValidationError(error.get_response(self.request)) 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 # OpenID clients can specify a `prompt` parameter, and if its set to consent we
# need to inject a consent stage # need to inject a consent stage
if PROMPT_CONSENT in self.params.prompt: 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 # Plan does not have any consent stage, so we add an in-memory one
stage = ConsentStage( stage = ConsentStage(
name="OAuth2 Provider In-memory consent stage", name="OAuth2 Provider In-memory consent stage",

View File

@ -41,6 +41,7 @@ from authentik.providers.oauth2.constants import (
GRANT_TYPE_PASSWORD, GRANT_TYPE_PASSWORD,
GRANT_TYPE_REFRESH_TOKEN, GRANT_TYPE_REFRESH_TOKEN,
PKCE_METHOD_S256, PKCE_METHOD_S256,
SCOPE_OFFLINE_ACCESS,
TOKEN_TYPE, TOKEN_TYPE,
) )
from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError from authentik.providers.oauth2.errors import DeviceCodeError, TokenError, UserAuthError
@ -459,7 +460,7 @@ class TokenView(View):
op="authentik.providers.oauth2.post.response", op="authentik.providers.oauth2.post.response",
): ):
if self.params.grant_type == GRANT_TYPE_AUTHORIZATION_CODE: 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()) return TokenResponse(self.create_code_response())
if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN: if self.params.grant_type == GRANT_TYPE_REFRESH_TOKEN:
LOGGER.debug("Refreshing refresh token") LOGGER.debug("Refreshing refresh token")
@ -496,42 +497,47 @@ class TokenView(View):
) )
access_token.save() access_token.save()
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) response = {
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 {
"access_token": access_token.token, "access_token": access_token.token,
"refresh_token": refresh_token.token,
"token_type": TOKEN_TYPE, "token_type": TOKEN_TYPE,
"expires_in": int( "expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds() 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]: def create_refresh_response(self) -> dict[str, Any]:
"""See https://datatracker.ietf.org/doc/html/rfc6749#section-6""" """See https://datatracker.ietf.org/doc/html/rfc6749#section-6"""
unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope) unauthorized_scopes = set(self.params.scope) - set(self.params.refresh_token.scope)
if unauthorized_scopes: if unauthorized_scopes:
raise TokenError("invalid_scope") raise TokenError("invalid_scope")
if SCOPE_OFFLINE_ACCESS not in self.params.scope:
raise TokenError("invalid_scope")
now = timezone.now() now = timezone.now()
access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity) access_token_expiry = now + timedelta_from_string(self.provider.access_token_validity)
access_token = AccessToken( access_token = AccessToken(
@ -630,31 +636,34 @@ class TokenView(View):
) )
access_token.save() access_token.save()
refresh_token_expiry = now + timedelta_from_string(self.provider.refresh_token_validity) response = {
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 {
"access_token": access_token.token, "access_token": access_token.token,
"refresh_token": refresh_token.token,
"token_type": TOKEN_TYPE, "token_type": TOKEN_TYPE,
"expires_in": int( "expires_in": int(
timedelta_from_string(self.provider.access_token_validity).total_seconds() 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

View File

@ -45,3 +45,14 @@ entries:
# groups is not part of the official userinfo schema, but is a quasi-standard # 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()], "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 {}

View File

@ -74,7 +74,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -82,8 +82,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )
@ -129,7 +129,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -137,8 +137,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )
@ -200,7 +200,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
@ -208,8 +208,8 @@ class TestProviderOAuth2Github(SeleniumTestCase):
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
) )
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug="grafana", slug=generate_id(),
provider=provider, provider=provider,
) )

View File

@ -14,6 +14,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -80,7 +81,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -90,12 +91,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -113,12 +119,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): 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)"""
@ -128,7 +130,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -138,11 +140,16 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -174,12 +181,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_logout(self): def test_authorization_logout(self):
"""test OpenID Provider flow with logout""" """test OpenID Provider flow with logout"""
@ -189,7 +192,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-implicit-consent" slug="default-provider-authorization-implicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
client_secret=self.client_secret, client_secret=self.client_secret,
@ -199,12 +202,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
Application.objects.create( Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -244,12 +252,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
@ -259,7 +263,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
@ -269,12 +273,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )
@ -323,12 +332,8 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@apply_blueprint(
"system/providers-oauth2.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -338,7 +343,7 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
slug="default-provider-authorization-explicit-consent" slug="default-provider-authorization-explicit-consent"
) )
provider = OAuth2Provider.objects.create( provider = OAuth2Provider.objects.create(
name="grafana", name=generate_id(),
authorization_flow=authorization_flow, authorization_flow=authorization_flow,
client_type=ClientTypes.CONFIDENTIAL, client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id, client_id=self.client_id,
@ -348,12 +353,17 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
app = Application.objects.create( app = Application.objects.create(
name="Grafana", name=generate_id(),
slug=self.app_slug, slug=self.app_slug,
provider=provider, provider=provider,
) )

View File

@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -29,7 +30,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
def setUp(self): def setUp(self):
self.client_id = generate_id() self.client_id = generate_id()
self.client_secret = generate_key() self.client_secret = generate_key()
self.application_slug = "test" self.application_slug = generate_id()
super().setUp() super().setUp()
def setup_client(self) -> Container: def setup_client(self) -> Container:
@ -37,7 +38,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
sleep(1) sleep(1)
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="ghcr.io/beryju/oidc-test-client:1.3", image="ghcr.io/beryju/oidc-test-client:2.1",
detach=True, detach=True,
ports={ ports={
"9009": "9009", "9009": "9009",
@ -56,9 +57,7 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_redirect_uri_error(self): def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)""" """test OpenID Provider flow (invalid redirect URI, check error message)"""
@ -78,10 +77,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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( Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
@ -101,13 +104,12 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): 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) sleep(1)
# Bootstrap all needed objects # Bootstrap all needed objects
authorization_flow = Flow.objects.get( authorization_flow = Flow.objects.get(
@ -124,11 +126,15 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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(
Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
provider=provider, provider=provider,
@ -137,6 +143,20 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
self.driver.get("http://localhost:9009") self.driver.get("http://localhost:9009")
self.login() 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.presence_of_element_located((By.CSS_SELECTOR, "pre")))
self.wait.until(ec.text_to_be_present_in_element((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) 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-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1) sleep(1)
@ -178,10 +196,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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( app = Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,
@ -224,9 +246,8 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -246,10 +267,14 @@ class TestProviderOAuth2OIDC(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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( app = Application.objects.create(
name=self.application_slug, name=self.application_slug,
slug=self.application_slug, slug=self.application_slug,

View File

@ -15,6 +15,7 @@ from authentik.lib.generators import generate_id, generate_key
from authentik.policies.expression.models import ExpressionPolicy from authentik.policies.expression.models import ExpressionPolicy
from authentik.policies.models import PolicyBinding from authentik.policies.models import PolicyBinding
from authentik.providers.oauth2.constants import ( from authentik.providers.oauth2.constants import (
SCOPE_OFFLINE_ACCESS,
SCOPE_OPENID, SCOPE_OPENID,
SCOPE_OPENID_EMAIL, SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE, SCOPE_OPENID_PROFILE,
@ -37,7 +38,7 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
sleep(1) sleep(1)
client: DockerClient = from_env() client: DockerClient = from_env()
container = client.containers.run( container = client.containers.run(
image="ghcr.io/beryju/oidc-test-client:1.3", image="ghcr.io/beryju/oidc-test-client:2.1",
detach=True, detach=True,
ports={ ports={
"9009": "9009", "9009": "9009",
@ -56,9 +57,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_redirect_uri_error(self): def test_redirect_uri_error(self):
"""test OpenID Provider flow (invalid redirect URI, check error message)""" """test OpenID Provider flow (invalid redirect URI, check error message)"""
@ -78,7 +78,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
@ -101,11 +106,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied(self): 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)"""
sleep(1) sleep(1)
@ -124,7 +127,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
@ -150,11 +158,9 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml",
)
@reconcile_app("authentik_crypto")
@apply_blueprint("system/providers-oauth2.yaml") @apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_explicit(self): def test_authorization_consent_explicit(self):
"""test OpenID Provider flow (default authorization flow with explicit consent)""" """test OpenID Provider flow (default authorization flow with explicit consent)"""
sleep(1) sleep(1)
@ -173,7 +179,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()
@ -215,9 +226,8 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
"default/flow-default-authentication-flow.yaml", "default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml", "default/flow-default-invalidation-flow.yaml",
) )
@apply_blueprint( @apply_blueprint("default/flow-default-provider-authorization-explicit-consent.yaml")
"default/flow-default-provider-authorization-explicit-consent.yaml", @apply_blueprint("system/providers-oauth2.yaml")
)
@reconcile_app("authentik_crypto") @reconcile_app("authentik_crypto")
def test_authorization_denied(self): def test_authorization_denied(self):
"""test OpenID Provider flow (default authorization with access deny)""" """test OpenID Provider flow (default authorization with access deny)"""
@ -237,7 +247,12 @@ class TestProviderOAuth2OIDCImplicit(SeleniumTestCase):
) )
provider.property_mappings.set( provider.property_mappings.set(
ScopeMapping.objects.filter( 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() provider.save()

View File

@ -290,9 +290,13 @@ export class OAuth2ProviderFormPage extends BaseProviderForm<OAuth2Provider> {
let selected = false; let selected = false;
if (!provider?.propertyMappings) { if (!provider?.propertyMappings) {
selected = selected =
scope.managed?.startsWith( // By default select all managed scope mappings, except offline_access
(scope.managed?.startsWith(
"goauthentik.io/providers/oauth2/scope-", "goauthentik.io/providers/oauth2/scope-",
) || false; ) &&
scope.managed !==
"goauthentik.io/providers/oauth2/scope-offline_access") ||
false;
} else { } else {
selected = Array.from(provider?.propertyMappings).some((su) => { selected = Array.from(provider?.propertyMappings).some((su) => {
return su == scope.pk; return su == scope.pk;

View File

@ -35,12 +35,20 @@ To access the user's email address, a scope of `user:email` is required. To acce
### `authorization_code`: ### `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_token`:
Refresh tokens can be used as long-lived tokens to access user data, and further renew the refresh token down the road. 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`: ### `client_credentials`:
See [Machine-to-machine authentication](./client_credentials) See [Machine-to-machine authentication](./client_credentials)

View File

@ -17,6 +17,12 @@ slug: "/releases/2024.1"
- `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total` - `authentik_outpost_radius_requests_rejected` -> `authentik_outpost_radius_requests_rejected_total`
- `authentik_main_requests` -> `authentik_main_request_duration_seconds` - `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 ## New features
- "Pretend user exists" option for Identification stage - "Pretend user exists" option for Identification stage