Commit 809c354c authored by Dimitrios Gogos's avatar Dimitrios Gogos
Browse files

feat: add registration endpoint and logic for user sign-up flow

parent 8aebfdb2
Loading
Loading
Loading
Loading
+19 −1
Changes for src/portal_service/adapters/keycloak/oidc_client.py: 19 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -47,6 +47,24 @@ class KeycloakOIDCClient(IdentityProvider):
        self._transport = transport

    def authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
        return self._entry_url(
            self._settings.authorization_endpoint,
            state=state,
            nonce=nonce,
            code_challenge=code_challenge,
        )

    def registration_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
        # Same parameters, different Keycloak page: sign-up instead of sign-in. The
        # user comes back to our callback either way.
        return self._entry_url(
            self._settings.registration_endpoint,
            state=state,
            nonce=nonce,
            code_challenge=code_challenge,
        )

    def _entry_url(self, endpoint: str, *, state: str, nonce: str, code_challenge: str) -> str:
        params = {
            "client_id": self._settings.client_id,
            "response_type": "code",
@@ -57,7 +75,7 @@ class KeycloakOIDCClient(IdentityProvider):
            "code_challenge": code_challenge,
            "code_challenge_method": "S256",
        }
        return f"{self._settings.authorization_endpoint}?{urlencode(params)}"
        return f"{endpoint}?{urlencode(params)}"

    async def exchange_code(self, *, code: str, code_verifier: str) -> IssuedTokens:
        return await self._token_request(
+16 −2
Changes for src/portal_service/api/auth/routes.py: 16 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ Login routes.

  GET /api/auth/login     — start a login: remember the one-time values, send the
                            browser to Keycloak.
  GET /api/auth/register  — the same, but landing on Keycloak's sign-up page.
  GET /api/auth/callback  — finish it: check the callback belongs to that login,
                            trade the code for tokens, and open a session.
  GET /api/auth/me        — who the session cookie belongs to.
@@ -51,8 +52,21 @@ _LOGIN_FAILED = "Login failed, please try again"

@auth_router.get("/login")
async def login(service: LoginServiceDep, settings: SettingsDep) -> RedirectResponse:
    started = await service.start()
    logger.info("login started")
    return await _start(service, settings, register=False)


@auth_router.get("/register")
async def register(service: LoginServiceDep, settings: SettingsDep) -> RedirectResponse:
    """
    Sign-up happens on Keycloak's own page, so the portal never handles a password.
    The realm gives every new user `app-provider` automatically (ADR-0048).
    """
    return await _start(service, settings, register=True)


async def _start(service: LoginService, settings: Settings, *, register: bool) -> RedirectResponse:
    started = await service.start(register=register)
    logger.info("login started", register=register)

    response = RedirectResponse(
        started.authorization_url,
+11 −2
Changes for src/portal_service/application/login_service.py: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -41,7 +41,11 @@ class LoginService:
        self._identity_provider = identity_provider
        self._attempt_ttl = attempt_ttl

    async def start(self) -> StartedLogin:
    async def start(self, *, register: bool = False) -> StartedLogin:
        """
        `register` only changes which Keycloak page the browser lands on: sign-up
        instead of sign-in. Everything after that, including our callback, is identical.
        """
        code_verifier = new_code_verifier()
        state = new_state()
        nonce = new_nonce()
@@ -58,8 +62,13 @@ class LoginService:
                expires_at=datetime.now(UTC) + self._attempt_ttl,
            )
        )
        entry_point = (
            self._identity_provider.registration_url
            if register
            else self._identity_provider.authorization_url
        )
        return StartedLogin(
            authorization_url=self._identity_provider.authorization_url(
            authorization_url=entry_point(
                state=state,
                nonce=nonce,
                code_challenge=code_challenge(code_verifier),
+4 −0
Changes for src/portal_service/config.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ class OIDCSettings(BaseModel):
    timeout: float = 10.0
    jwks_uri: str = ""
    authorization_endpoint: str = ""
    registration_endpoint: str = ""
    token_endpoint: str = ""
    end_session_endpoint: str = ""
    admin_base_url: str = ""
@@ -36,6 +37,9 @@ class OIDCSettings(BaseModel):
            self.jwks_uri = f"{self.issuer}/protocol/openid-connect/certs"
        if not self.authorization_endpoint:
            self.authorization_endpoint = f"{self.issuer}/protocol/openid-connect/auth"
        if not self.registration_endpoint:
            # Keycloak's sign-up page; the flow afterwards is the same as a login.
            self.registration_endpoint = f"{self.issuer}/protocol/openid-connect/registrations"
        if not self.token_endpoint:
            self.token_endpoint = f"{self.issuer}/protocol/openid-connect/token"
        if not self.end_session_endpoint:
+4 −0
Changes for src/portal_service/domain/ports/identity_provider.py: 4 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -15,6 +15,10 @@ class IdentityProvider(ABC):
    def authorization_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
        """Where to send the browser to log in."""

    @abstractmethod
    def registration_url(self, *, state: str, nonce: str, code_challenge: str) -> str:
        """Where to send the browser to sign up. It returns through the same callback."""

    @abstractmethod
    async def exchange_code(self, *, code: str, code_verifier: str) -> IssuedTokens:
        """Trade the code from the callback for tokens."""
Loading