Loading src/portal_service/adapters/keycloak/oidc_client.py +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 Loading @@ -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", Loading @@ -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( Loading src/portal_service/api/auth/routes.py +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 Loading @@ -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. Loading Loading @@ -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, Loading src/portal_service/application/login_service.py +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 Loading @@ -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() Loading @@ -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), Loading src/portal_service/config.py +4 −0 Changes for src/portal_service/config.py: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 = "" Loading @@ -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: Loading src/portal_service/domain/ports/identity_provider.py +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 Loading @@ -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 Loading
src/portal_service/adapters/keycloak/oidc_client.py +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 Loading @@ -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", Loading @@ -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( Loading
src/portal_service/api/auth/routes.py +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 Loading @@ -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. Loading Loading @@ -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, Loading
src/portal_service/application/login_service.py +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 Loading @@ -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() Loading @@ -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), Loading
src/portal_service/config.py +4 −0 Changes for src/portal_service/config.py: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -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 = "" Loading @@ -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: Loading
src/portal_service/domain/ports/identity_provider.py +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 Loading @@ -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