Skip to content

Commit

Permalink
Merge pull request #675 from SUNET/lundberg_ylle_freja_oidc
Browse files Browse the repository at this point in the history
Freja eID OIDC implementation
  • Loading branch information
helylle authored Sep 3, 2024
2 parents c6c79b3 + 756768b commit 7670ab5
Show file tree
Hide file tree
Showing 28 changed files with 1,658 additions and 14 deletions.
2 changes: 2 additions & 0 deletions src/eduid/common/clients/oidc_client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
__author__ = "lundberg"
14 changes: 14 additions & 0 deletions src/eduid/common/clients/oidc_client/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-

from pydantic import AnyUrl, BaseModel, Field

__author__ = "lundberg"


class AuthlibClientConfig(BaseModel):
client_id: str
client_secret: str
issuer: AnyUrl
code_challenge_method: str = Field(default="S256")
acr_values: list[str] = Field(default_factory=list)
scopes: list[str] = Field(default=["openid"])
1 change: 1 addition & 0 deletions src/eduid/common/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ class ProofingConfigMixin(FrontendActionMixin):
foreign_eid_proofing_version: str = Field(default="2022v1")
svipe_id_proofing_version: str = Field(default="2023v2")
bankid_proofing_version: str = Field(default="2023v1")
freja_eid_proofing_version: str = Field(default="2024v1")

# security key proofing
security_key_proofing_method: CredentialProofingMethod = Field(default=CredentialProofingMethod.SWAMID_AL3_MFA)
Expand Down
1 change: 1 addition & 0 deletions src/eduid/userdb/credentials/external.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class TrustFramework(str, Enum):
EIDAS = "EIDAS"
SVIPE = "SVIPE"
BANKID = "BANKID"
FREJA = "FREJA"


class ExternalCredential(Credential):
Expand Down
49 changes: 49 additions & 0 deletions src/eduid/userdb/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class IdentityType(str, Enum):
NIN = "nin"
EIDAS = "eidas"
SVIPE = "svipe"
FREJA = "freja"


class IdentityProofingMethod(str, Enum):
Expand All @@ -29,6 +30,7 @@ class IdentityProofingMethod(str, Enum):
SWEDEN_CONNECT = "swedenconnect"
TELEADRESS = "TeleAdress"
BANKID = "bankid"
FREJA_EID = "freja_eid"


class IdentityElement(VerifiedElement, ABC):
Expand Down Expand Up @@ -84,6 +86,8 @@ def get_missing_proofing_method(self) -> Optional[IdentityProofingMethod]:
return IdentityProofingMethod.SE_LEG
case "svipe_id":
return IdentityProofingMethod.SVIPE_ID
case "freja_eid":
return IdentityProofingMethod.FREJA_EID
case _:
logger.warning(f"Unknown verified_by value: {self.verified_by}")
return None
Expand Down Expand Up @@ -184,6 +188,39 @@ def unique_value(self) -> str:
return self.svipe_id


class FrejaRegistrationLevel(str, Enum):
EXTENDED = "EXTENDED"
PLUS = "PLUS"


class FrejaIdentity(ForeignIdentityElement):
"""
Element that is used as a Freja identity for a user
Properties of FrejaIdentity:
user_id
personal_identity_number
registration_level
country_code
"""

identity_type: Literal[IdentityType.FREJA] = IdentityType.FREJA
# claim: https://frejaeid.com/oidc/scopes/relyingPartyUserId
# A unique, user-specific value that allows the Relying Party to identify the same user across multiple sessions
user_id: str
personal_identity_number: Optional[str] = None
registration_level: FrejaRegistrationLevel

@property
def unique_key_name(self) -> str:
return "user_id"

@property
def unique_value(self) -> str:
return self.user_id


class IdentityList(VerifiedElementList[IdentityElement]):
"""
Hold a list of IdentityElement instances.
Expand All @@ -200,6 +237,8 @@ def from_list_of_dicts(cls: type[IdentityList], items: list[dict[str, Any]]) ->
elements.append(EIDASIdentity.from_dict(item))
elif _type == IdentityType.SVIPE.value:
elements.append(SvipeIdentity.from_dict(item))
elif _type == IdentityType.FREJA.value:
elements.append(FrejaIdentity.from_dict(item))
else:
raise ValueError(f"identity_type {_type} not valid")
return cls(elements=elements)
Expand Down Expand Up @@ -236,6 +275,13 @@ def svipe(self) -> Optional[SvipeIdentity]:
return _svipe[0]
return None

@property
def freja(self) -> Optional[FrejaIdentity]:
_freja = self.filter(FrejaIdentity)
if _freja:
return _freja[0]
return None

@property
def date_of_birth(self) -> Optional[datetime]:
if not self.is_verified:
Expand Down Expand Up @@ -263,6 +309,9 @@ def date_of_birth(self) -> Optional[datetime]:
# SVIPE
if self.svipe and self.svipe.is_verified:
return self.svipe.date_of_birth
# Freja eID
if self.freja and self.freja.is_verified:
return self.freja.date_of_birth
return None

def to_frontend_format(self) -> dict[str, Any]:
Expand Down
63 changes: 63 additions & 0 deletions src/eduid/userdb/logs/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,69 @@ class BankIDProofing(NinEIDProofingLogElement):
proofing_method: str = IdentityProofingMethod.BANKID.value


class FrejaEIDNINProofing(NinEIDProofingLogElement):
"""
{
'eduPersonPrincipalName': eppn,
'created_ts': utc_now(),
'created_by': 'application',
'proofing_method': 'freja_eid',
'proofing_version': '2024v1',
'user_id': 'unique identifier for the user',
'document_type': 'type of document used for identification',
'document_number': 'document number',
'nin': 'national_identity_number',
'given_name': 'name',
'surname': 'name',
}
Proofing version history:
2024v1 - inital deployment
"""

# unique identifier
user_id: str
# transaction id
transaction_id: str
# document type
document_type: str
# document number
document_number: str
# Proofing method name
proofing_method: str = IdentityProofingMethod.FREJA_EID.value


class FrejaEIDForeignProofing(ForeignIdProofingLogElement):
"""
{
'eduPersonPrincipalName': eppn,
'created_ts': utc_now(),
'created_by': 'application',
'proofing_method': 'freja_eid',
'proofing_version': '2024v1',
'user_id': 'unique identifier for the user',
'document_type': 'type of document used for identification',
'document_number': 'document number',
'issuing_country': 'country of issuance',
}
"""

# unique identifier
user_id: str
# transaction id
transaction_id: str
# document administrative number
administrative_number: Optional[str]
# document type (standardized english)
document_type: str
# document number
document_number: str
# issuing country
issuing_country: str
# Proofing method name
proofing_method: str = IdentityProofingMethod.FREJA_EID.value


class MFATokenProofing(SwedenConnectProofing):
"""
{
Expand Down
5 changes: 5 additions & 0 deletions src/eduid/userdb/proofing/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,8 @@ def __init__(self, db_uri: str, db_name: str = "eduid_svipe_id"):
class BankIDProofingUserDB(ProofingUserDB):
def __init__(self, db_uri: str, db_name: str = "eduid_bankid"):
super().__init__(db_uri, db_name)


class FrejaEIDProofingUserDB(ProofingUserDB):
def __init__(self, db_uri: str, db_name: str = "eduid_freja_eid"):
super().__init__(db_uri, db_name)
1 change: 1 addition & 0 deletions src/eduid/webapp/common/api/schemas/identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ class IdentitiesSchema(EduidSchema):
nin = fields.Nested(NinIdentitySchema)
eidas = fields.Nested(ForeignIdentitySchema)
svipe = fields.Nested(ForeignIdentitySchema)
freja = fields.Nested(ForeignIdentitySchema)
35 changes: 33 additions & 2 deletions src/eduid/webapp/common/proofing/methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from eduid.webapp.common.authn.session_info import SessionInfo
from eduid.webapp.common.proofing.messages import ProofingMsg
from eduid.webapp.eidas.saml_session_info import ForeignEidSessionInfo, NinSessionInfo
from eduid.webapp.freja_eid.helpers import FrejaEIDDocumentUserInfo, FrejaEIDTokenResponse
from eduid.webapp.svipe_id.helpers import SvipeDocumentUserInfo

logger = logging.getLogger(__name__)
Expand All @@ -22,7 +23,9 @@
@dataclass
class SessionInfoParseResult:
error: Optional[TranslatableMsg] = None
info: Optional[Union[NinSessionInfo, ForeignEidSessionInfo, SvipeDocumentUserInfo, BankIDSessionInfo]] = None
info: Optional[
Union[NinSessionInfo, ForeignEidSessionInfo, SvipeDocumentUserInfo, BankIDSessionInfo, FrejaEIDDocumentUserInfo]
] = None


@dataclass(frozen=True)
Expand Down Expand Up @@ -119,11 +122,33 @@ def parse_session_info(self, session_info: SessionInfo, backdoor: bool) -> Sessi
return SessionInfoParseResult(info=parsed_session_info)


@dataclass(frozen=True)
class ProofingMethodFrejaEID(ProofingMethod):
def parse_session_info(self, session_info: SessionInfo, backdoor: bool) -> SessionInfoParseResult:
try:
parsed_session_info = FrejaEIDDocumentUserInfo(**session_info)
logger.debug(f"session info: {parsed_session_info}")
except ValidationError:
logger.exception("missing claim in userinfo response")
return SessionInfoParseResult(error=ProofingMsg.attribute_missing)

# verify session info data
# document should not have expired
expiration_date = parsed_session_info.document.expiration_date
if expiration_date < utc_now().date():
logger.error(f"Document has expired {expiration_date}")
return SessionInfoParseResult(error=ProofingMsg.session_info_not_valid)

return SessionInfoParseResult(info=parsed_session_info)


def get_proofing_method(
method: Optional[str],
frontend_action: FrontendAction,
config: ProofingConfigMixin,
) -> Optional[Union[ProofingMethodFreja, ProofingMethodEidas, ProofingMethodSvipe, ProofingMethodBankID]]:
) -> Optional[
Union[ProofingMethodFreja, ProofingMethodEidas, ProofingMethodSvipe, ProofingMethodBankID, ProofingMethodFrejaEID]
]:

authn_params = config.frontend_action_authn_parameters.get(frontend_action)
assert authn_params is not None # please mypy
Expand Down Expand Up @@ -167,6 +192,12 @@ def get_proofing_method(
idp=config.bankid_idp,
required_loa=config.bankid_required_loa,
)
if method == "freja_eid":
return ProofingMethodFrejaEID(
method=method,
framework=TrustFramework.FREJA,
finish_url=authn_params.finish_url,
)

logger.warning(f"Unknown proofing method {method}")
return None
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 @@ -24,6 +24,7 @@
BankIDNamespace,
Common,
EidasNamespace,
FrejaEIDNamespace,
IdP_Namespace,
MfaAction,
Phone,
Expand Down Expand Up @@ -59,6 +60,7 @@ class EduidNamespaces(BaseModel):
authn: Optional[AuthnNamespace] = None
svipe_id: Optional[SvipeIDNamespace] = None
bankid: Optional[BankIDNamespace] = None
freja_eid: Optional[FrejaEIDNamespace] = None


class EduidSession(SessionMixin, MutableMapping[str, Any]):
Expand Down Expand Up @@ -245,6 +247,12 @@ def bankid(self) -> BankIDNamespace:
self._namespaces.bankid = BankIDNamespace.from_dict(self._session.get("bankid", {}))
return self._namespaces.bankid

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

@property
def created(self) -> datetime:
"""
Expand Down
9 changes: 8 additions & 1 deletion src/eduid/webapp/common/session/namespaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from eduid.userdb.credentials.external import TrustFramework
from eduid.userdb.element import ElementKey
from eduid.webapp.common.authn.acs_enums import AuthnAcsAction, BankIDAcsAction, EidasAcsAction
from eduid.webapp.freja_eid.callback_enums import FrejaEIDAction
from eduid.webapp.idp.other_device.data import OtherDeviceId
from eduid.webapp.svipe_id.callback_enums import SvipeIDAction

Expand Down Expand Up @@ -245,7 +246,9 @@ class BaseAuthnRequest(BaseModel, ABC):
frontend_action: FrontendAction # what action frontend is performing
frontend_state: Optional[str] = None # opaque data from frontend, returned in /status
method: Optional[str] = None # proofing method that frontend is invoking
post_authn_action: Optional[Union[AuthnAcsAction, EidasAcsAction, SvipeIDAction, BankIDAcsAction]] = None
post_authn_action: Optional[
Union[AuthnAcsAction, EidasAcsAction, SvipeIDAction, BankIDAcsAction, FrejaEIDAction]
] = None
created_ts: datetime = Field(default_factory=utc_now)
authn_instant: Optional[datetime] = None
status: Optional[str] = None # populated by the SAML2 ACS/OIDC callback action
Expand Down Expand Up @@ -332,3 +335,7 @@ class SvipeIDNamespace(SessionNSBase):

class BankIDNamespace(SessionNSBase):
sp: SPAuthnData = Field(default=SPAuthnData())


class FrejaEIDNamespace(SessionNSBase):
rp: RPAuthnData = Field(default=RPAuthnData())
Empty file.
Loading

0 comments on commit 7670ab5

Please sign in to comment.