Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
db34346
feat(samleid): add SamlEidApp and SamlEidProofingUserDB
johanlundberg Feb 5, 2026
ea73eee
feat(samleid): add helpers.py with SamlEidMsg and create_authn_info
johanlundberg Feb 5, 2026
13fb25c
feat(samleid): add schemas.py with request/response schemas
johanlundberg Feb 5, 2026
36c85e6
feat(samleid): add saml_session_info.py re-exports
johanlundberg Feb 5, 2026
29cb03a
feat(samleid): add proofing.py with unified proofing dispatch
johanlundberg Feb 5, 2026
3c35305
feat(samleid): add full acs_actions.py with ACS handlers
johanlundberg Feb 5, 2026
a0d0b62
feat(samleid): add full views.py with all routes
johanlundberg Feb 5, 2026
c304f1e
feat(samleid): add run.py entry point
johanlundberg Feb 5, 2026
ebdb58e
feat(samleid): add test infrastructure (certs, saml2_settings)
johanlundberg Feb 5, 2026
a6354fc
feat(samleid): add SamlEidTests base class for test infrastructure
johanlundberg Feb 6, 2026
a781321
feat(samleid): add BankIDMethodTests and shared infrastructure
johanlundberg Feb 6, 2026
40d7b1e
feat(samleid): add FrejaMethodTests with all Freja/Sweden Connect tests
johanlundberg Feb 6, 2026
4618ae8
feat(samleid): add EidasMethodTests with all foreign eID tests
johanlundberg Feb 6, 2026
5813cb2
test(samleid): remove duplicate tests, unskip backdoor tests, add cov…
johanlundberg Feb 9, 2026
a617499
test(samleid): add error path tests and fix misnamed test method
johanlundberg Feb 9, 2026
ab5283e
test(samleid): parametrize freja/bankid tests using subTest
johanlundberg Feb 9, 2026
01dd4d1
add missing files
johanlundberg Feb 9, 2026
71a4d8f
please ruff
johanlundberg Feb 10, 2026
ab56b4a
copy paste fail
johanlundberg Feb 10, 2026
fdc5817
FREJA trust framework is for the freja_eid app
johanlundberg Feb 10, 2026
ec91561
nitpicking
johanlundberg Feb 10, 2026
52c6ecc
Tests for both freja and bankid
johanlundberg Feb 10, 2026
a1fe1d6
explicit name for responses
johanlundberg Feb 10, 2026
e141261
no support for magic cookie backdoor and bankid (yet)
johanlundberg Feb 10, 2026
7b14ba1
mfa-authenticate not mfa-authentication
johanlundberg Feb 10, 2026
d634e91
feat(samleid): add magic cookie backdoor support for bankid
johanlundberg Feb 10, 2026
1699508
Merge branch 'main' into lundberg_samleid
johanlundberg Feb 10, 2026
1c0cafb
add AttributeFetcher for samleid
johanlundberg Feb 11, 2026
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
5 changes: 5 additions & 0 deletions src/eduid/userdb/proofing/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,8 @@ def __init__(self, db_uri: str, db_name: str = "eduid_bankid", auto_expire: time
class FrejaEIDProofingUserDB(ProofingUserDB):
def __init__(self, db_uri: str, db_name: str = "eduid_freja_eid", auto_expire: timedelta | None = None) -> None:
super().__init__(db_uri, db_name, auto_expire=auto_expire)


class SamlEidProofingUserDB(ProofingUserDB):
def __init__(self, db_uri: str, db_name: str = "eduid_samleid", auto_expire: timedelta | None = None) -> None:
super().__init__(db_uri, db_name, auto_expire=auto_expire)
7 changes: 7 additions & 0 deletions src/eduid/webapp/common/authn/acs_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,10 @@ class BankIDAcsAction(StrEnum):
verify_identity = "verify-identity-action"
verify_credential = "verify-credential-action"
mfa_authenticate = "mfa-authenticate-action"


@unique
class SamlEidAcsAction(StrEnum):
verify_identity = "verify-identity-action"
verify_credential = "verify-credential-action"
mfa_authenticate = "mfa-authenticate-action"
9 changes: 9 additions & 0 deletions src/eduid/webapp/common/proofing/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,15 @@ def parse_session_info(self, session_info: SessionInfo, backdoor: bool) -> Sessi
logger.exception("missing attribute in SAML response")
return SessionInfoParseResult(error=ProofingMsg.attribute_missing)

if backdoor:
# change asserted nin to nin from the integration test cookie
magic_cookie_nin = request.cookies.get("nin")
if magic_cookie_nin is None:
logger.error("Bad nin cookie")
return SessionInfoParseResult(error=ProofingMsg.malformed_identity)
logger.debug(f"Using nin from magic cookie: {magic_cookie_nin}")
parsed_session_info.attributes.nin = magic_cookie_nin

return SessionInfoParseResult(info=parsed_session_info)


Expand Down
8 changes: 8 additions & 0 deletions src/eduid/webapp/common/session/eduid_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
MfaAction,
Phone,
ResetPasswordNS,
SamlEidNamespace,
SecurityNS,
SessionNSBase,
Signup,
Expand Down Expand Up @@ -59,6 +60,7 @@ class EduidNamespaces(BaseModel):
svipe_id: SvipeIDNamespace | None = None
bankid: BankIDNamespace | None = None
freja_eid: FrejaEIDNamespace | None = None
samleid: SamlEidNamespace | None = None


class EduidSession(SessionMixin):
Expand Down Expand Up @@ -253,6 +255,12 @@ def freja_eid(self) -> FrejaEIDNamespace:
self._namespaces.freja_eid = FrejaEIDNamespace.from_dict(self._session.get("freja_eid", {}))
return self._namespaces.freja_eid

@property
def samleid(self) -> SamlEidNamespace:
if not self._namespaces.samleid:
self._namespaces.samleid = SamlEidNamespace.from_dict(self._session.get("samleid", {}))
return self._namespaces.samleid

@property
def created(self) -> datetime:
"""
Expand Down
4 changes: 4 additions & 0 deletions src/eduid/webapp/common/session/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,7 @@ class BankIDNamespace(SessionNSBase):

class FrejaEIDNamespace(SessionNSBase):
rp: RPAuthnData = Field(default=RPAuthnData())


class SamlEidNamespace(SessionNSBase):
sp: SPAuthnData = Field(default=SPAuthnData())
1 change: 1 addition & 0 deletions src/eduid/webapp/samleid/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# eduID samleid webapp - unified SAML-based identity proofing
247 changes: 247 additions & 0 deletions src/eduid/webapp/samleid/acs_actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
from eduid.common.models.saml_models import BaseSessionInfo
from eduid.userdb import User
from eduid.userdb.credentials.fido import FidoCredential
from eduid.webapp.common.api.decorators import require_user
from eduid.webapp.common.api.messages import AuthnStatusMsg
from eduid.webapp.common.authn.acs_enums import SamlEidAcsAction
from eduid.webapp.common.authn.acs_registry import ACSArgs, ACSResult, acs_action
from eduid.webapp.common.authn.utils import check_reauthn
from eduid.webapp.common.proofing.messages import ProofingMsg
from eduid.webapp.common.proofing.methods import ProofingMethodSAML
from eduid.webapp.common.proofing.saml_helpers import is_required_loa, is_valid_authn_instant
from eduid.webapp.common.session import session
from eduid.webapp.common.session.namespaces import SP_AuthnRequest
from eduid.webapp.samleid.app import current_samleid_app as current_app
from eduid.webapp.samleid.helpers import SamlEidMsg
from eduid.webapp.samleid.proofing import get_proofing_functions

__author__ = "lundberg"


def common_saml_checks(args: ACSArgs) -> ACSResult | None:
"""
Perform common checks for SAML ACS actions.

Validates that the SAML response meets the required Level of Assurance (LOA)
and that the authentication instant is not too old.

:param args: ACS action arguments containing session info and proofing method
:returns: ACSResult with error message if validation fails, None if all checks pass
"""
assert isinstance(args.proofing_method, ProofingMethodSAML) # please mypy
if not is_required_loa(
args.session_info, args.proofing_method.required_loa, current_app.conf.loa_authn_context_map
):
current_app.logger.error("SAML response did not meet required LOA")
args.authn_req.error = True
args.authn_req.status = SamlEidMsg.authn_context_mismatch.value
return ACSResult(message=SamlEidMsg.authn_context_mismatch)

if not is_valid_authn_instant(args.session_info):
current_app.logger.error("SAML response was not a valid reauthn")
args.authn_req.error = True
args.authn_req.status = SamlEidMsg.authn_instant_too_old.value
return ACSResult(message=SamlEidMsg.authn_instant_too_old)

return None


@acs_action(SamlEidAcsAction.verify_identity)
@require_user
def samleid_verify_identity_action(user: User, args: ACSArgs) -> ACSResult:
"""
Use a SAML IdP assertion to verify a user's identity.

This action handles identity verification for all supported methods:
- Freja eID (Swedish NIN via NinSessionInfo)
- BankID (Swedish NIN via BankIDSessionInfo)
- eIDAS (Foreign identity via ForeignEidSessionInfo)

The appropriate proofing functions are automatically selected based on
the session info type from the SAML assertion.

:param user: Central db user
:param args: ACS action arguments

:returns: ACS action result
"""
# please type checking
if not args.proofing_method:
return ACSResult(message=SamlEidMsg.method_not_available)

# validate the assertion data
if ret := common_saml_checks(args=args):
return ret

parsed = args.proofing_method.parse_session_info(args.session_info, backdoor=args.backdoor)
if parsed.error:
return ACSResult(message=parsed.error)

# please type checking
assert isinstance(parsed.info, BaseSessionInfo)

proofing = get_proofing_functions(
session_info=parsed.info, app_name=current_app.conf.app_name, config=current_app.conf, backdoor=args.backdoor
)

current = proofing.get_identity(user)
if current and current.is_verified:
current_app.logger.error(f"User already has a verified identity for {args.proofing_method.method}")
current_app.logger.debug(f"Current: {current}. Assertion: {args.session_info}")
return ACSResult(message=ProofingMsg.identity_already_verified)

verify_result = proofing.verify_identity(user=user)
if verify_result.error is not None:
return ACSResult(message=verify_result.error)

return ACSResult(success=True, message=SamlEidMsg.identity_verify_success)


@acs_action(SamlEidAcsAction.verify_credential)
@require_user
def samleid_verify_credential_action(user: User, args: ACSArgs) -> ACSResult:
"""
Use a SAML IdP assertion to person-proof a user's FIDO credential.

This action handles credential verification for all supported methods:
- Freja eID (Swedish NIN via NinSessionInfo)
- BankID (Swedish NIN via BankIDSessionInfo)
- eIDAS (Foreign identity via ForeignEidSessionInfo)

If the user doesn't have a verified identity for this method, their identity
will be verified as part of this process.

:param user: Central db user
:param args: ACS action arguments

:returns: ACS action result
"""
# please type checking
if not args.proofing_method:
return ACSResult(message=SamlEidMsg.method_not_available)

# validate the assertion data
if ret := common_saml_checks(args=args):
return ret

assert isinstance(args.authn_req, SP_AuthnRequest)

credential = user.credentials.find(args.authn_req.proofing_credential_id)
if not isinstance(credential, FidoCredential):
current_app.logger.error(f"Credential {credential} is not a FidoCredential")
return ACSResult(message=SamlEidMsg.credential_not_found)

# Check (again) if token was used to authenticate this session and that the auth is not stale.
_need_reauthn = check_reauthn(
frontend_action=args.authn_req.frontend_action, user=user, credential_requested=credential
)
if _need_reauthn:
current_app.logger.error(f"User needs to authenticate: {_need_reauthn}")
return ACSResult(message=AuthnStatusMsg.must_authenticate)

parsed = args.proofing_method.parse_session_info(args.session_info, args.backdoor)
if parsed.error:
return ACSResult(message=parsed.error)

# please type checking
assert isinstance(parsed.info, BaseSessionInfo)

proofing = get_proofing_functions(
session_info=parsed.info, app_name=current_app.conf.app_name, config=current_app.conf, backdoor=args.backdoor
)

_identity = proofing.get_identity(user=user)
if not _identity or not _identity.is_verified:
# proof users' identity too in this process if the user didn't have a verified identity of this type already
verify_result = proofing.verify_identity(user=user)
if verify_result.error is not None:
return ACSResult(message=verify_result.error)
if verify_result.user:
# Get an updated user object
user = verify_result.user
# It is necessary to look up the credential again in order for changes to the instance to
# actually be saved to the database. Can't be references to old user objects credential.
credential = user.credentials.find(credential.key)
if not isinstance(credential, FidoCredential):
current_app.logger.error(f"Credential {credential} is not a FidoCredential")
return ACSResult(message=SamlEidMsg.credential_not_found)

# Check that the users' verified identity matches the one that was asserted now
match_res = proofing.match_identity(user=user, proofing_method=args.proofing_method)
if match_res.error is not None:
return ACSResult(message=match_res.error)

if not match_res.matched:
# Matching external mfa authentication with user identity failed, bail
current_app.stats.count(name=f"verify_credential_{args.proofing_method.method}_identity_not_matching")
return ACSResult(message=SamlEidMsg.identity_not_matching)

loa = None
if parsed.info.authn_context is not None:
loa = current_app.conf.authn_context_loa_map.get(parsed.info.authn_context)

verify_result = proofing.verify_credential(user=user, credential=credential, loa=loa)
if verify_result.error is not None:
return ACSResult(message=verify_result.error)

current_app.stats.count(name="fido_token_verified")
current_app.stats.count(name=f"verify_credential_{args.proofing_method.method}_success")

return ACSResult(success=True, message=SamlEidMsg.credential_verify_success)


@acs_action(SamlEidAcsAction.mfa_authenticate)
def samleid_mfa_authenticate_action(args: ACSArgs) -> ACSResult:
"""
Authenticate a user using a SAML IdP assertion for multi-factor authentication.

This action handles MFA authentication for all supported methods:
- Freja eID (Swedish NIN via NinSessionInfo)
- BankID (Swedish NIN via BankIDSessionInfo)
- eIDAS (Foreign identity via ForeignEidSessionInfo)

NOTE: While this code looks up the user from session.mfa_action.eppn, it doesn't require the user
to be already logged in, so it can't use the @require_user decorator.

:param args: ACS action arguments

:returns: ACS action result
"""
# please type checking
if not args.proofing_method:
return ACSResult(message=SamlEidMsg.method_not_available)

# validate the assertion data
if ret := common_saml_checks(args=args):
return ret

# Get user from central database
current_app.logger.debug(f"{session.mfa_action=}")
user = current_app.central_userdb.get_user_by_eppn(session.mfa_action.eppn)

parsed = args.proofing_method.parse_session_info(args.session_info, backdoor=args.backdoor)
if parsed.error:
return ACSResult(message=parsed.error)

# please type checking
assert isinstance(parsed.info, BaseSessionInfo)

proofing = get_proofing_functions(
session_info=parsed.info, app_name=current_app.conf.app_name, config=current_app.conf, backdoor=args.backdoor
)

# Check that a verified identity is equal to the asserted identity
match_res = proofing.match_identity(user=user, proofing_method=args.proofing_method)
current_app.logger.debug(f"MFA authentication identity matching result: {match_res}")
if match_res.error is not None:
return ACSResult(message=match_res.error)

if not match_res.matched:
# Matching external mfa authentication with user identity failed, bail
current_app.stats.count(name=f"mfa_auth_{args.proofing_method.method}_identity_not_matching")
return ACSResult(message=SamlEidMsg.identity_not_matching)

current_app.stats.count(name="mfa_auth_success")
current_app.stats.count(name=f"mfa_auth_{args.proofing_method.method}_success")
current_app.stats.count(name=f"mfa_auth_{parsed.info.issuer}_success")
return ACSResult(success=True, message=SamlEidMsg.mfa_authn_success)
74 changes: 74 additions & 0 deletions src/eduid/webapp/samleid/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
from collections.abc import Mapping
from typing import Any, cast

from flask import current_app

from eduid.common.config.parsers import load_config
from eduid.common.rpc.am_relay import AmRelay
from eduid.common.rpc.msg_relay import MsgRelay
from eduid.userdb.logs.db import ProofingLog
from eduid.userdb.proofing.db import SamlEidProofingUserDB
from eduid.webapp.common.authn.middleware import AuthnBaseApp
from eduid.webapp.common.authn.utils import get_saml2_config, no_authn_views
from eduid.webapp.samleid.settings.common import SamlEidConfig

__author__ = "lundberg"


class SamlEidApp(AuthnBaseApp):
def __init__(self, config: SamlEidConfig, **kwargs: Any) -> None:
super().__init__(config, **kwargs)

self.conf = config

self.saml2_config = get_saml2_config(config.saml2_settings_module)

# Init dbs
self.private_userdb = SamlEidProofingUserDB(config.mongo_uri, auto_expire=config.private_userdb_auto_expire)
self.proofing_log = ProofingLog(config.mongo_uri)

# Init celery
self.am_relay = AmRelay(config)
self.msg_relay = MsgRelay(config)


current_samleid_app: SamlEidApp = cast(SamlEidApp, current_app)


def init_samleid_app(name: str = "samleid", test_config: Mapping[str, Any] | None = None) -> SamlEidApp:
"""
Create an instance of a samleid app.

:param name: The name of the instance, it will affect the configuration loaded.
:param test_config: Override config, used in test cases.
"""
config = load_config(typ=SamlEidConfig, app_name=name, ns="webapp", test_config=test_config)

# Load acs actions on app init
from . import acs_actions

# Make sure pycharm doesn't think the import above is unused and removes it
if acs_actions.__author__:
pass

app = SamlEidApp(config)

app.logger.info(f"Init {app}...")

# Register views
from eduid.webapp.samleid.views import samleid_views

app.register_blueprint(samleid_views)

# Register view path that should not be authorized
no_authn_views(
config,
[
"/saml2-metadata",
"/saml2-acs",
"/mfa-authenticate",
"/get-status",
],
)

return app
Loading