Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README-keycloak.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,52 @@ follow these steps:
2. Add `DISABLE_APISIX_USER_MIDDLEWARE=True` to your `backend.local.env` file
3. Set `COMPOSE_PROFILES=backend,frontend` in your .env file

### Changing email and password

The settings page at `/dashboard/settings` lets users change their email and
password by handing off to Keycloak ("application initiated actions"). Two
things have to be in place for `kc_action=UPDATE_EMAIL` to work:

1. Keycloak must be started with the `update-email` feature. `UPDATE_EMAIL` is
still a preview feature, so it is listed explicitly in the `keycloak`
service's `--features` flag in `docker-compose.services.yml`.
2. The `UPDATE_EMAIL` required action must be registered in the realm. It is in
`config/keycloak/realms/ol-local-realm.json`, but `--import-realm` skips
realms that already exist in the database. If you set Keycloak up before this
was added, register it once via Keycloak admin
(Authentication → Required actions → Register → Update Email), or reset the
Keycloak database so the realm re-imports.

Without both, Keycloak fails the request with a generic "Unexpected error when
handling authentication request to identity provider" page, and its logs show
`NullPointerException ... "requiredActionProvider" is null`.

`UPDATE_PASSWORD` is a built-in action and needs neither step.

Note that deployed realms have `verify_email` enabled, so submitting the form
there emails a confirmation link rather than changing the address immediately,
and the new address reaches Learn when Keycloak pushes it over SCIM. The local
realm has verification off, so the change applies straight away.

### Controlling user provisioning from APISIX headers

By default, `ApisixUserMiddleware` creates users it hasn't seen before, but does _not_
update existing users or their profiles from the APISIX userinfo headers. Two settings in
`backend.local.env` control that:

- `MITOL_APIGATEWAY_USERINFO_CREATE` (defaults to `True`) - controls whether the
middleware will create _new_ users. If `False`, users have to be pre-created (for
example via SCIM) before they can authenticate; an unknown identity is treated as
anonymous.
- `MITOL_APIGATEWAY_USERINFO_UPDATE` (defaults to `False`) - controls whether the
middleware will update _existing_ users. While it is `False`, neither the `User` nor its
`Profile` is written from the headers, so a backchannel (SCIM) needs to keep that data
in sync with Keycloak. Set it to `True` if nothing else is keeping users up to date.

These names match the settings in
[mitol-django-apigateway](https://github.com/mitodl/ol-django/tree/main/src/apigateway),
which this middleware is intended to be replaced by.

### MITx Online integration

The user dashboard at `/dashboard` includes some integration with the MITx Online
Expand Down
12 changes: 12 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
Release Notes
=============

Version 0.77.9
--------------

- always select currently running course run for card context (#3792)
- refactor(otel): own the OpenTelemetry setup instead of patching Sentry's (#3788)
- Display price ranges on product pages (#3794)
- Make apisix userinfo updates togglable (#3747)
- make canvas etl resistant to pod culling (#3779)
- fix(otel): continue the edge trace by extracting W3C traceparent (#3787)
- Update Terms of Service (MicroMasters bundle, AI Tutor, date) (#3767)
- feat(settings): change email and password via Keycloak (#3726)

Version 0.77.8 (Released August 19, 2026)
--------------

Expand Down
51 changes: 51 additions & 0 deletions authentication/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

from django.contrib.auth import get_user_model
from django.db import transaction
from keycloak.exceptions import KeycloakError
from mitol.keycloak import api as keycloak_api
from requests.exceptions import RequestException

from authentication.hooks import get_plugin_manager
from profiles import api as profile_api
Expand Down Expand Up @@ -39,6 +42,54 @@ def create_user(username, email, profile_data=None, user_extra=None):
return user


def is_sso_user(user) -> bool:
"""
Return True if the user signs in through an external identity provider.

Such users have no local Keycloak credentials, so they can't change their
email or password here — that lives with their institution.

The answer lives on `User.is_sso_user`, which starts null and is filled in
the first time it is needed. Once set it is authoritative and Keycloak is
not consulted again: the value effectively never changes on its own, and
keeping it editable means someone can be granted local credentials — for
instance to keep their account after leaving the organization that provided
their identity — by clearing the flag.

Determining it requires the Keycloak admin client. When that isn't
configured (e.g. local development) we can't tell, so we report False
without storing anything and let Keycloak be the final arbiter.
"""
stored = getattr(user, "is_sso_user", None)
if stored is not None:
return stored

global_id = getattr(user, "global_id", None)
if not global_id:
return False

if not keycloak_api.is_admin_client_configured():
log.debug(
"Keycloak admin client is not configured; cannot determine whether "
"user %s is an SSO user",
user.id,
)
return False

try:
admin_client = keycloak_api.get_admin_client()
is_sso = bool(admin_client.get_user_social_logins(global_id))
except (KeycloakError, RequestException):
# Leave the field null so the next read tries again, rather than
# recording a guess.
log.exception("Failed to fetch federated identities for user %s", user.id)
return False

user.is_sso_user = is_sso
user.save(update_fields=["is_sso_user", "updated_on"])
return is_sso


def user_created_actions(*, user, details, **kwargs):
"""
Trigger plugins when a user is created
Expand Down
119 changes: 119 additions & 0 deletions authentication/api_test.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""API tests"""

from uuid import uuid4

import pytest
from django.contrib.auth import get_user_model
from keycloak.exceptions import KeycloakError

from authentication import api
from authentication.constants import AccountAction, parse_account_action
from main.factories import UserFactory
from profiles.models import Profile

Expand Down Expand Up @@ -70,3 +74,118 @@ def test_user_created_actions(mocker, is_new):

api.user_created_actions(**kwargs)
assert user.user_lists.count() == (1 if is_new else 0)


@pytest.fixture
def mock_keycloak_admin(mocker):
"""Mock the Keycloak admin client used for federated identity lookups"""
mocker.patch(
"authentication.api.keycloak_api.is_admin_client_configured",
return_value=True,
)
return mocker.patch("authentication.api.keycloak_api.get_admin_client").return_value


@pytest.mark.parametrize(
("social_logins", "expected"),
[
([{"identityProvider": "touchstone"}], True),
([], False),
],
)
def test_is_sso_user(mock_keycloak_admin, social_logins, expected):
"""Users with a federated identity in Keycloak are SSO users"""
user = UserFactory.create(global_id=uuid4().hex, is_sso_user=None)
mock_keycloak_admin.get_user_social_logins.return_value = social_logins

assert api.is_sso_user(user) is expected
mock_keycloak_admin.get_user_social_logins.assert_called_once_with(user.global_id)

# the answer is recorded, not just returned
user.refresh_from_db()
assert user.is_sso_user is expected


def test_is_sso_user_only_asks_keycloak_once(mock_keycloak_admin):
"""Once stored on the user, Keycloak isn't consulted again"""
user = UserFactory.create(global_id=uuid4().hex, is_sso_user=None)
mock_keycloak_admin.get_user_social_logins.return_value = [
{"identityProvider": "touchstone"}
]

assert api.is_sso_user(user) is True
assert api.is_sso_user(user) is True
assert mock_keycloak_admin.get_user_social_logins.call_count == 1


@pytest.mark.parametrize("stored", [True, False])
def test_is_sso_user_stored_value_wins(mock_keycloak_admin, stored):
"""
An explicitly set flag overrides Keycloak.

This is what lets someone keep an account after leaving the organization
that provided their identity: clear the flag and they can manage their own
email and password, whatever Keycloak still reports.
"""
user = UserFactory.create(global_id=uuid4().hex, is_sso_user=stored)
mock_keycloak_admin.get_user_social_logins.return_value = [
{"identityProvider": "touchstone"}
]

assert api.is_sso_user(user) is stored
mock_keycloak_admin.get_user_social_logins.assert_not_called()


def test_is_sso_user_no_global_id(mock_keycloak_admin):
"""Users who have never been through Keycloak can't be SSO users"""
user = UserFactory.create(global_id=None, is_sso_user=None)

assert api.is_sso_user(user) is False
mock_keycloak_admin.get_user_social_logins.assert_not_called()


def test_is_sso_user_admin_client_unconfigured(mocker):
"""Without an admin client we can't tell, so we don't block the user"""
mocker.patch(
"authentication.api.keycloak_api.is_admin_client_configured",
return_value=False,
)
get_admin_client = mocker.patch("authentication.api.keycloak_api.get_admin_client")
user = UserFactory.create(global_id=uuid4().hex, is_sso_user=None)

assert api.is_sso_user(user) is False
get_admin_client.assert_not_called()
user.refresh_from_db()
assert user.is_sso_user is None


def test_is_sso_user_keycloak_error(mock_keycloak_admin):
"""A Keycloak failure shouldn't take the settings page down with it"""
user = UserFactory.create(global_id=uuid4().hex, is_sso_user=None)
mock_keycloak_admin.get_user_social_logins.side_effect = KeycloakError("boom")

assert api.is_sso_user(user) is False
# left null so a later read retries rather than recording a guess
user.refresh_from_db()
assert user.is_sso_user is None


@pytest.mark.parametrize(
("value", "expected"),
[
("update-email", AccountAction.UPDATE_EMAIL),
("update-password", AccountAction.UPDATE_PASSWORD),
("delete-account", None),
("", None),
(None, None),
],
)
def test_parse_account_action(value, expected):
"""
A raw query-string value maps to its AccountAction, or None.

Pins that a plain string is recognised: `value in AccountAction` only
accepts values from Python 3.12 onwards and raises TypeError before that,
so the callback's reporting branch depends on this.
"""
assert parse_account_action(value) is expected
61 changes: 61 additions & 0 deletions authentication/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Authentication constants"""

from enum import StrEnum

# Query params appended to the frontend URL the user lands back on after a
# Keycloak account action completes. The frontend consumes these once to show a
# success/error alert and then strips them from the URL.
ACCOUNT_ACTION_PARAM = "account_action"
ACCOUNT_ACTION_STATUS_PARAM = "account_action_status"

# Frontend path an account action starts from and returns to.
ACCOUNT_SETTINGS_PATH = "/dashboard/settings"


class AccountAction(StrEnum):
"""Account actions a user can start from the settings page"""

UPDATE_EMAIL = "update-email"
UPDATE_PASSWORD = "update-password" # noqa: S105


def parse_account_action(value: str | None) -> "AccountAction | None":
"""
Return the AccountAction matching a raw query-string value, or None.

Prefer this over `value in AccountAction`: membership tests against an enum
only accept plain values from Python 3.12 onwards, and raise TypeError
before that, so the explicit lookup keeps the intent obvious and pins the
behaviour regardless of interpreter version.
"""
try:
return AccountAction(value)
except ValueError:
return None


class AccountActionStatus(StrEnum):
"""Outcome of an account action, as reported back to the frontend"""

SUCCESS = "success"
CANCELLED = "cancelled"
ERROR = "error"
# The user authenticates through an external identity provider, so the
# action isn't theirs to perform.
UNAVAILABLE = "unavailable"


# Maps our URL slugs onto Keycloak's `kc_action` values.
KEYCLOAK_ACTIONS = {
AccountAction.UPDATE_EMAIL: "UPDATE_EMAIL",
AccountAction.UPDATE_PASSWORD: "UPDATE_PASSWORD",
}

# Keycloak appends this to the redirect URI when an application initiated action
# finishes, with one of the values below.
KEYCLOAK_ACTION_STATUS_PARAM = "kc_action_status"
KEYCLOAK_ACTION_STATUSES = {
"success": AccountActionStatus.SUCCESS,
"cancelled": AccountActionStatus.CANCELLED,
"error": AccountActionStatus.ERROR,
}
19 changes: 17 additions & 2 deletions authentication/urls.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
"""URL configurations for authentication"""

from django.urls import re_path
from django.urls import path, re_path

from authentication.views import CustomLoginView, CustomLogoutView
from authentication.views import (
AccountActionCompleteView,
AccountActionStartView,
CustomLoginView,
CustomLogoutView,
)

urlpatterns = [
re_path(r"^logout", CustomLogoutView.as_view(), name="logout"),
re_path(r"^login", CustomLoginView.as_view(), name="login"),
path(
"account/action/start/<slug:action>/",
AccountActionStartView.as_view(),
name="account-action-start",
),
path(
"account/action/complete",
AccountActionCompleteView.as_view(),
name="account-action-complete",
),
]
Loading
Loading