Skip to content

Commit 9ea938e

Browse files
committed
feat: choose the OAuth token's project on the consent screen
The consent screen now offers a project selector next to the scopes. consent-info returns the user's accessible projects; select-project binds the pending authorization to the chosen project (or leaves it unscoped for "all projects"), which the token customizer stamps into tg.prj — a consent choice overriding any client hint. A hinted project (e.g. the public project a community contributor is editing) is pre-selected but stays changeable, so one token can also cover several community projects via "all projects", bounded by live per-project permissions.
1 parent 74c74fb commit 9ea938e

8 files changed

Lines changed: 536 additions & 77 deletions

File tree

backend/api/src/main/kotlin/io/tolgee/api/v2/controllers/oauth2/OAuth2FlowController.kt

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,25 @@ package io.tolgee.api.v2.controllers.oauth2
1919
import io.swagger.v3.oas.annotations.Operation
2020
import io.swagger.v3.oas.annotations.tags.Tag
2121
import io.tolgee.api.v2.controllers.IController
22+
import io.tolgee.dtos.cacheable.ProjectDto
2223
import io.tolgee.exceptions.NotFoundException
2324
import io.tolgee.exceptions.PermissionException
25+
import io.tolgee.hateoas.oauth2.ConsentInfoModel
26+
import io.tolgee.hateoas.oauth2.OAuth2ProjectModel
2427
import io.tolgee.security.authentication.AuthenticationFacade
2528
import io.tolgee.security.authentication.BypassEmailVerification
2629
import io.tolgee.security.authentication.BypassForcedSsoAuthentication
2730
import io.tolgee.security.oauth2.OAuth2Constants
31+
import io.tolgee.security.oauth2.projectHint
2832
import io.tolgee.service.project.ProjectService
2933
import io.tolgee.service.security.SecurityService
3034
import jakarta.servlet.http.HttpServletRequest
3135
import org.springframework.http.HttpStatus
3236
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
3337
import org.springframework.security.core.authority.AuthorityUtils
3438
import org.springframework.security.core.context.SecurityContextHolder
35-
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest
3639
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames
40+
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization
3741
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService
3842
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType
3943
import 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
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Copyright (C) 2026 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.hateoas.oauth2
18+
19+
data class ConsentInfoModel(
20+
val appName: String,
21+
val scopes: List<String>,
22+
val project: OAuth2ProjectModel?,
23+
val projects: List<OAuth2ProjectModel>,
24+
)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/**
2+
* Copyright (C) 2026 Tolgee s.r.o. and contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.tolgee.hateoas.oauth2
18+
19+
data class OAuth2ProjectModel(
20+
val id: Long,
21+
val name: String,
22+
)

0 commit comments

Comments
 (0)