-
Notifications
You must be signed in to change notification settings - Fork 45
/
Copy pathOidcAuthenticationService.java
250 lines (220 loc) · 11.4 KB
/
OidcAuthenticationService.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
/*
* This file is part of Alpine.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* SPDX-License-Identifier: Apache-2.0
* Copyright (c) Steve Springett. All Rights Reserved.
*/
package alpine.server.auth;
import alpine.Config;
import alpine.common.logging.Logger;
import alpine.model.OidcUser;
import alpine.persistence.AlpineQueryManager;
import alpine.server.util.OidcUtil;
import com.nimbusds.openid.connect.sdk.claims.UserInfo;
import jakarta.annotation.Nonnull;
import java.security.Principal;
import java.util.List;
import java.util.Objects;
/**
* @since 1.8.0
*/
public class OidcAuthenticationService implements AuthenticationService {
private static final Logger LOGGER = Logger.getLogger(OidcAuthenticationService.class);
private final Config config;
private final OidcConfiguration oidcConfiguration;
private final OidcIdTokenAuthenticator idTokenAuthenticator;
private final OidcUserInfoAuthenticator userInfoAuthenticator;
private final String idToken;
private final String accessToken;
/**
* @param accessToken The access token acquired by authenticating with an IdP
* @deprecated Use {@link #OidcAuthenticationService(String, String)} instead
*/
@Deprecated
public OidcAuthenticationService(final String accessToken) {
this(Config.getInstance(), OidcConfigurationResolver.getInstance().resolve(), null, accessToken);
}
/**
* @param idToken The ID token acquired by authenticating with an IdP
* @param accessToken The access token acquired by authenticating with an IdP
* @since 1.10.0
*/
public OidcAuthenticationService(final String idToken, final String accessToken) {
this(Config.getInstance(), OidcConfigurationResolver.getInstance().resolve(), idToken, accessToken);
}
/**
* Constructor for unit tests
*/
OidcAuthenticationService(final Config config, final OidcConfiguration oidcConfiguration, final String idToken, final String accessToken) {
this(config, oidcConfiguration, new OidcIdTokenAuthenticator(oidcConfiguration, config.getProperty(Config.AlpineKey.OIDC_CLIENT_ID)), new OidcUserInfoAuthenticator(oidcConfiguration), idToken, accessToken);
}
/**
* Constructor for unit tests
*
* @since 1.10.0
*/
OidcAuthenticationService(final Config config,
final OidcConfiguration oidcConfiguration,
final OidcIdTokenAuthenticator idTokenAuthenticator,
final OidcUserInfoAuthenticator userInfoAuthenticator,
final String idToken,
final String accessToken) {
this.config = config;
this.oidcConfiguration = oidcConfiguration;
this.idTokenAuthenticator = idTokenAuthenticator;
this.userInfoAuthenticator = userInfoAuthenticator;
this.idToken = idToken;
this.accessToken = accessToken;
}
@Override
public boolean isSpecified() {
return OidcUtil.isOidcAvailable(config, oidcConfiguration)
&& (accessToken != null || idToken != null);
}
/**
* Authenticate a {@link Principal} using the provided credentials.
* <p>
* If an ID token is provided, Alpine will validate it and source configured claims from it.
* <p>
* If an access token is provided, Alpine will call the IdP's {@code /userinfo} endpoint with it
* to verify its validity, and source configured claims from the response.
* <p>
* If both access token and ID token are provided, the ID token takes precedence.
* When all configured claims are found in the ID token, {@code /userinfo} won't be requested.
* When not all claims were found in the ID token, {@code /userinfo} will be requested supplementary.
*
* @return An authenticated {@link Principal}
* @throws AlpineAuthenticationException When authentication failed
*/
@Nonnull
@Override
public Principal authenticate() throws AlpineAuthenticationException {
final String usernameClaimName = config.getProperty(Config.AlpineKey.OIDC_USERNAME_CLAIM);
if (usernameClaimName == null) {
LOGGER.error("No username claim has been configured");
throw new AlpineAuthenticationException(AlpineAuthenticationException.CauseType.OTHER);
}
final boolean teamSyncEnabled = config.getPropertyAsBoolean(Config.AlpineKey.OIDC_TEAM_SYNCHRONIZATION);
final String teamsClaimName = config.getProperty(Config.AlpineKey.OIDC_TEAMS_CLAIM);
if (teamSyncEnabled && teamsClaimName == null) {
LOGGER.error("Team synchronization is enabled, but no teams claim has been configured");
throw new AlpineAuthenticationException(AlpineAuthenticationException.CauseType.OTHER);
}
final OidcProfileCreator profileCreator = claims -> {
final var profile = new OidcProfile();
profile.setSubject(claims.getStringClaim(UserInfo.SUB_CLAIM_NAME));
profile.setUsername(claims.getStringClaim(usernameClaimName));
profile.setGroups(claims.getStringListClaim(teamsClaimName));
profile.setEmail(claims.getStringClaim(UserInfo.EMAIL_CLAIM_NAME));
return profile;
};
OidcProfile idTokenProfile = null;
if (idToken != null) {
idTokenProfile = idTokenAuthenticator.authenticate(idToken, profileCreator);
LOGGER.debug("ID token profile: " + idTokenProfile);
if (isProfileComplete(idTokenProfile, teamSyncEnabled)) {
LOGGER.debug("ID token profile is complete, proceeding to authenticate");
return authenticateInternal(idTokenProfile);
}
}
OidcProfile userInfoProfile = null;
if (accessToken != null) {
userInfoProfile = userInfoAuthenticator.authenticate(accessToken, profileCreator);
LOGGER.debug("UserInfo profile: " + userInfoProfile);
if (isProfileComplete(userInfoProfile, teamSyncEnabled)) {
LOGGER.debug("UserInfo profile is complete, proceeding to authenticate");
return authenticateInternal(userInfoProfile);
}
}
OidcProfile mergedProfile = null;
if (idTokenProfile != null && userInfoProfile != null) {
mergedProfile = mergeProfiles(idTokenProfile, userInfoProfile);
LOGGER.debug("Merged profile: " + mergedProfile);
if (isProfileComplete(mergedProfile, teamSyncEnabled)) {
LOGGER.debug("Merged profile is complete, proceeding to authenticate");
return authenticateInternal(mergedProfile);
}
}
LOGGER.error("Unable to assemble complete profile (ID token: " + idTokenProfile +
", UserInfo: " + userInfoProfile + ", Merged: " + mergedProfile + ")");
throw new AlpineAuthenticationException(AlpineAuthenticationException.CauseType.OTHER);
}
private OidcUser authenticateInternal(final OidcProfile profile) throws AlpineAuthenticationException {
try (final var qm = new AlpineQueryManager()) {
OidcUser user = qm.getOidcUser(profile.getUsername());
if (user != null) {
LOGGER.debug("Attempting to authenticate user: " + user.getUsername());
if (user.getSubjectIdentifier() == null) {
LOGGER.debug("Assigning subject identifier " + profile.getSubject() + " to user " + user.getUsername());
user.setSubjectIdentifier(profile.getSubject());
user.setEmail(profile.getEmail());
return qm.updateOidcUser(user);
} else if (!user.getSubjectIdentifier().equals(profile.getSubject())) {
LOGGER.error("Refusing to authenticate user " + user.getUsername() + ": subject identifier has changed (" +
user.getSubjectIdentifier() + " to " + profile.getSubject() + ")");
throw new AlpineAuthenticationException(AlpineAuthenticationException.CauseType.INVALID_CREDENTIALS);
}
if (!Objects.equals(user.getEmail(), profile.getEmail())) {
LOGGER.debug("Updating email of user " + user.getUsername() + ": " + user.getEmail() + " -> " + profile.getEmail());
user.setEmail(profile.getEmail());
user = qm.updateOidcUser(user);
}
if (config.getPropertyAsBoolean(Config.AlpineKey.OIDC_TEAM_SYNCHRONIZATION)) {
return qm.synchronizeTeamMembership(user, profile.getGroups());
}
return user;
} else if (config.getPropertyAsBoolean(Config.AlpineKey.OIDC_USER_PROVISIONING)) {
LOGGER.debug("The user (" + profile.getUsername() + ") authenticated successfully but the account has not been provisioned");
return autoProvision(qm, profile);
} else {
LOGGER.debug("The user (" + profile.getUsername() + ") is unmapped and user provisioning is not enabled");
throw new AlpineAuthenticationException(AlpineAuthenticationException.CauseType.UNMAPPED_ACCOUNT);
}
}
}
private boolean isProfileComplete(final OidcProfile profile, final boolean teamSyncEnabled) {
return profile.getSubject() != null
&& profile.getUsername() != null
&& (!teamSyncEnabled || (profile.getGroups() != null));
}
private OidcProfile mergeProfiles(final OidcProfile left, final OidcProfile right) {
final var profile = new OidcProfile();
profile.setSubject(selectProfileClaim(left.getSubject(), right.getSubject()));
profile.setUsername(selectProfileClaim(left.getUsername(), right.getUsername()));
profile.setGroups(selectProfileClaim(left.getGroups(), right.getGroups()));
profile.setEmail(selectProfileClaim(left.getEmail(), right.getEmail()));
return profile;
}
private <T> T selectProfileClaim(final T left, final T right) {
return (left != null) ? left : right;
}
private OidcUser autoProvision(final AlpineQueryManager qm, final OidcProfile profile) {
var user = new OidcUser();
user.setUsername(profile.getUsername());
user.setSubjectIdentifier(profile.getSubject());
user.setEmail(profile.getEmail());
user = qm.persist(user);
if (config.getPropertyAsBoolean(Config.AlpineKey.OIDC_TEAM_SYNCHRONIZATION)) {
LOGGER.debug("Synchronizing teams for user " + user.getUsername());
return qm.synchronizeTeamMembership(user, profile.getGroups());
}
final List<String> defaultTeams = config.getPropertyAsList(Config.AlpineKey.OIDC_TEAMS_DEFAULT);
if (!defaultTeams.isEmpty()) {
LOGGER.debug("Assigning default teams %s to user %s".formatted(defaultTeams, user.getUsername()));
return qm.addUserToTeams(user, defaultTeams);
}
return user;
}
}