@@ -19,21 +19,25 @@ package io.tolgee.api.v2.controllers.oauth2
1919import io.swagger.v3.oas.annotations.Operation
2020import io.swagger.v3.oas.annotations.tags.Tag
2121import io.tolgee.api.v2.controllers.IController
22+ import io.tolgee.dtos.cacheable.ProjectDto
2223import io.tolgee.exceptions.NotFoundException
2324import io.tolgee.exceptions.PermissionException
25+ import io.tolgee.hateoas.oauth2.ConsentInfoModel
26+ import io.tolgee.hateoas.oauth2.OAuth2ProjectModel
2427import io.tolgee.security.authentication.AuthenticationFacade
2528import io.tolgee.security.authentication.BypassEmailVerification
2629import io.tolgee.security.authentication.BypassForcedSsoAuthentication
2730import io.tolgee.security.oauth2.OAuth2Constants
31+ import io.tolgee.security.oauth2.projectHint
2832import io.tolgee.service.project.ProjectService
2933import io.tolgee.service.security.SecurityService
3034import jakarta.servlet.http.HttpServletRequest
3135import org.springframework.http.HttpStatus
3236import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
3337import org.springframework.security.core.authority.AuthorityUtils
3438import org.springframework.security.core.context.SecurityContextHolder
35- import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest
3639import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames
40+ import org.springframework.security.oauth2.server.authorization.OAuth2Authorization
3741import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService
3842import org.springframework.security.oauth2.server.authorization.OAuth2TokenType
3943import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository
@@ -90,52 +94,74 @@ class OAuth2FlowController(
9094 ): ConsentInfoModel {
9195 val client = registeredClientRepository.findByClientId(clientId) ? : throw NotFoundException ()
9296 val scopes = scope?.split(" " )?.filter { it.isNotBlank() } ? : emptyList()
93- val binding = state?.let { projectBinding(it) } ? : ProjectBinding .allProjects()
9497 return ConsentInfoModel (
9598 appName = client.clientName,
9699 scopes = scopes,
97- project = binding.project ,
98- allProjects = binding.allProjects ,
100+ project = state?. let { hintedProject(it) } ,
101+ projects = accessibleProjects() ,
99102 )
100103 }
101104
105+ // Only membership projects — public projects the user is a non-member of are grantable via the community floor but
106+ // reach the token only through a client hint (see hintedProject) or the "all projects" choice, not this list.
107+ private fun accessibleProjects (): List <OAuth2ProjectModel > =
108+ projectService
109+ .findAllPermitted(authenticationFacade.authenticatedUserEntity)
110+ .mapNotNull { dto -> dto.id?.let { id -> projectModel(id, dto.name) } }
111+
102112 /* * Resolves the hinted project's name only when the user has access, so an unrelated hint can't leak a name. */
103- private fun projectBinding (state : String ): ProjectBinding {
104- val authorization =
105- oAuth2AuthorizationService.findByToken(state, OAuth2TokenType (OAuth2ParameterNames .STATE ))
106- ? : return ProjectBinding .allProjects()
107- val authorizationRequest =
108- authorization.getAttribute<OAuth2AuthorizationRequest >(OAuth2AuthorizationRequest ::class .java.name)
109- ? : return ProjectBinding .allProjects()
110- val projectId =
111- (authorizationRequest.additionalParameters[OAuth2Constants .PROJECT_PARAM ] as ? String )?.toLongOrNull()
112- ? : return ProjectBinding .allProjects()
113- val project =
114- projectId
115- .takeIf { securityService.getCurrentPermittedScopes(it).isNotEmpty() }
116- ?.let { projectService.findDto(it) }
117- ?.let { ProjectInfo (id = it.id, name = it.name) }
118- return ProjectBinding (allProjects = false , project = project)
113+ private fun hintedProject (state : String ): OAuth2ProjectModel ? {
114+ val projectId = ownAuthorization(state)?.projectHint() ? : return null
115+ return accessibleProject(projectId)?.let { projectModel(it.id, it.name) }
119116 }
120117
121- private data class ProjectBinding (
122- val allProjects : Boolean ,
123- val project : ProjectInfo ? ,
118+ private fun projectModel (
119+ id : Long ,
120+ name : String? ,
121+ ) = OAuth2ProjectModel (id = id, name = name ? : " #$id " )
122+
123+ @PostMapping(" /select-project" )
124+ @Operation(summary = " Bind the pending authorization to the project chosen on the consent screen" )
125+ @ResponseStatus(HttpStatus .NO_CONTENT )
126+ @BypassEmailVerification
127+ @BypassForcedSsoAuthentication
128+ fun selectProject (
129+ @RequestParam state : String ,
130+ @RequestParam(required = false ) projectId : Long? ,
124131 ) {
125- companion object {
126- fun allProjects () = ProjectBinding (allProjects = true , project = null )
127- }
132+ val authorization = ownAuthorization(state) ? : throw NotFoundException ()
133+ val selection = projectSelectionValue(projectId)
134+ oAuth2AuthorizationService.save(
135+ OAuth2Authorization .from(authorization).attribute(OAuth2Constants .PROJECT_ATTRIBUTE , selection).build(),
136+ )
137+ }
138+
139+ /* *
140+ * The pending authorization for [state], only when it belongs to the caller — so a guessed/replayed state value
141+ * can't retarget another user's in-flight authorization.
142+ */
143+ private fun ownAuthorization (state : String ): OAuth2Authorization ? {
144+ val authorization =
145+ oAuth2AuthorizationService.findByToken(state, OAuth2TokenType (OAuth2ParameterNames .STATE )) ? : return null
146+ if (authorization.principalName != authenticationFacade.authenticatedUser.id.toString()) return null
147+ return authorization
128148 }
129149
130- data class ConsentInfoModel (
131- val appName : String ,
132- val scopes : List <String >,
133- val project : ProjectInfo ? ,
134- val allProjects : Boolean ,
135- )
150+ private fun projectSelectionValue (projectId : Long? ): String {
151+ if (projectId == null ) return OAuth2Constants .ALL_PROJECTS
152+ if (accessibleProject(projectId) == null ) throw PermissionException ()
153+ return projectId.toString()
154+ }
136155
137- data class ProjectInfo (
138- val id : Long ,
139- val name : String ,
140- )
156+ /* *
157+ * The single project-access decision shared by the consent-info (read) and select-project (write) paths: the DTO
158+ * when the project exists and the user has a live permitted scope on it, else null. Existence is resolved first
159+ * because the permission lookup throws NotFound for a missing project, which would otherwise 404 the consent screen
160+ * on a stale/bogus hint (or a project deleted mid-flow).
161+ */
162+ private fun accessibleProject (projectId : Long ): ProjectDto ? {
163+ val dto = projectService.findDto(projectId) ? : return null
164+ if (securityService.getCurrentPermittedScopes(projectId).isEmpty()) return null
165+ return dto
166+ }
141167}
0 commit comments