View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
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,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.sso.oic;
17  
18  import java.io.IOException;
19  import java.util.ArrayList;
20  import java.util.Arrays;
21  import java.util.HashMap;
22  import java.util.List;
23  import java.util.Map;
24  
25  import org.apache.logging.log4j.LogManager;
26  import org.apache.logging.log4j.Logger;
27  import org.codelibs.core.lang.StringUtil;
28  import org.codelibs.core.misc.DynamicProperties;
29  import org.codelibs.core.net.UuidUtil;
30  import org.codelibs.fess.app.web.base.login.ActionResponseCredential;
31  import org.codelibs.fess.app.web.base.login.FessLoginAssist.LoginCredentialResolver;
32  import org.codelibs.fess.app.web.base.login.OpenIdConnectCredential;
33  import org.codelibs.fess.crawler.Constants;
34  import org.codelibs.fess.mylasta.action.FessUserBean;
35  import org.codelibs.fess.sso.SsoAuthenticator;
36  import org.codelibs.fess.sso.SsoResponseType;
37  import org.codelibs.fess.util.ComponentUtil;
38  import org.dbflute.optional.OptionalEntity;
39  import org.lastaflute.web.login.credential.LoginCredential;
40  import org.lastaflute.web.response.ActionResponse;
41  import org.lastaflute.web.response.HtmlResponse;
42  import org.lastaflute.web.util.LaRequestUtil;
43  
44  import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
45  import com.google.api.client.auth.oauth2.AuthorizationCodeTokenRequest;
46  import com.google.api.client.auth.oauth2.TokenResponse;
47  import com.google.api.client.http.GenericUrl;
48  import com.google.api.client.http.HttpTransport;
49  import com.google.api.client.http.javanet.NetHttpTransport;
50  import com.google.api.client.json.JsonFactory;
51  import com.google.api.client.json.JsonParser;
52  import com.google.api.client.json.JsonToken;
53  import com.google.api.client.json.gson.GsonFactory;
54  import com.google.common.io.BaseEncoding;
55  import com.google.common.io.BaseEncoding.DecodingException;
56  
57  import jakarta.annotation.PostConstruct;
58  import jakarta.servlet.http.HttpServletRequest;
59  import jakarta.servlet.http.HttpSession;
60  
61  /**
62   * OpenID Connect authenticator for SSO integration.
63   */
64  public class OpenIdConnectAuthenticator implements SsoAuthenticator {
65  
66      /**
67       * Default constructor.
68       */
69      public OpenIdConnectAuthenticator() {
70          // Default constructor
71      }
72  
73      private static final Logger logger = LogManager.getLogger(OpenIdConnectAuthenticator.class);
74  
75      private static final BaseEncoding BASE64_DECODER = BaseEncoding.base64().withSeparator("\n", 64);
76  
77      private static final BaseEncoding BASE64URL_DECODER = BaseEncoding.base64Url().withSeparator("\n", 64);
78  
79      /** Configuration key for OpenID Connect authorization server URL. */
80      protected static final String OIC_AUTH_SERVER_URL = "oic.auth.server.url";
81  
82      /** Configuration key for OpenID Connect client ID. */
83      protected static final String OIC_CLIENT_ID = "oic.client.id";
84  
85      /** Configuration key for OpenID Connect scope. */
86      protected static final String OIC_SCOPE = "oic.scope";
87  
88      /** Configuration key for OpenID Connect redirect URL. */
89      protected static final String OIC_REDIRECT_URL = "oic.redirect.url";
90  
91      /** Configuration key for OpenID Connect token server URL. */
92      protected static final String OIC_TOKEN_SERVER_URL = "oic.token.server.url";
93  
94      /** Configuration key for OpenID Connect client secret. */
95      protected static final String OIC_CLIENT_SECRET = "oic.client.secret";
96  
97      /** Session key for OpenID Connect state parameter. */
98      protected static final String OIC_STATE = "OIC_STATE";
99  
100     /** Configuration key for OpenID Connect base URL. */
101     protected static final String OIC_BASE_URL = "oic.base.url";
102 
103     /** HTTP transport for OpenID Connect requests. */
104     protected final HttpTransport httpTransport = new NetHttpTransport();
105 
106     /** JSON factory for OpenID Connect response parsing. */
107     protected final JsonFactory jsonFactory = GsonFactory.getDefaultInstance();
108 
109     /**
110      * Initializes the OpenID Connect authenticator.
111      */
112     @PostConstruct
113     public void init() {
114         if (logger.isDebugEnabled()) {
115             logger.debug("Initializing {}", this.getClass().getSimpleName());
116         }
117         ComponentUtil.getSsoManager().register(this);
118     }
119 
120     @Override
121     public LoginCredential getLoginCredential() {
122         return LaRequestUtil.getOptionalRequest().map(request -> {
123             if (logger.isDebugEnabled()) {
124                 logger.debug("Logging in with OpenID Connect Authenticator");
125             }
126             final HttpSession session = request.getSession(false);
127             if (session != null) {
128                 final String sesState = (String) session.getAttribute(OIC_STATE);
129                 if (StringUtil.isNotBlank(sesState)) {
130                     session.removeAttribute(OIC_STATE);
131                     final String code = request.getParameter("code");
132                     final String reqState = request.getParameter("state");
133                     if (logger.isDebugEnabled()) {
134                         logger.debug("code: {}, state(request): {}, state(session): {}", code, reqState, sesState);
135                     }
136                     if (sesState.equals(reqState) && StringUtil.isNotBlank(code)) {
137                         return processCallback(request, code);
138                     }
139                 }
140             }
141 
142             return new ActionResponseCredential(() -> HtmlResponse.fromRedirectPathAsIs(getAuthUrl(request)));
143         }).orElse(null);
144     }
145 
146     /**
147      * Gets the authorization URL for OpenID Connect.
148      *
149      * @param request the HTTP servlet request
150      * @return the authorization URL
151      */
152     protected String getAuthUrl(final HttpServletRequest request) {
153         final String state = UuidUtil.create();
154         request.getSession().setAttribute(OIC_STATE, state);
155         return new AuthorizationCodeRequestUrl(getOicAuthServerUrl(), getOicClientId())//
156                 .setScopes(Arrays.asList(getOicScope()))//
157                 .setResponseTypes(Arrays.asList("code"))//
158                 .setRedirectUri(getOicRedirectUrl())//
159                 .setState(state)//
160                 .build();
161     }
162 
163     /**
164      * Decodes a Base64 string to bytes.
165      *
166      * @param base64String the Base64 string to decode
167      * @return the decoded bytes, or null if input is null
168      */
169     protected byte[] decodeBase64(String base64String) {
170         if (base64String == null) {
171             return null;
172         }
173         try {
174             return BASE64_DECODER.decode(base64String);
175         } catch (IllegalArgumentException e) {
176             if (e.getCause() instanceof DecodingException) {
177                 return BASE64URL_DECODER.decode(base64String.trim());
178             }
179             throw e;
180         }
181     }
182 
183     /**
184      * Processes the callback from OpenID Connect provider.
185      *
186      * @param request the HTTP servlet request
187      * @param code the authorization code
188      * @return the login credential
189      */
190     protected LoginCredential processCallback(final HttpServletRequest request, final String code) {
191         try {
192             final TokenResponse tr = getTokenUrl(code);
193 
194             final String[] jwt = ((String) tr.get("id_token")).split("\\.");
195             final String jwtHeader = new String(decodeBase64(jwt[0]), Constants.UTF_8_CHARSET);
196             final String jwtClaim = new String(decodeBase64(jwt[1]), Constants.UTF_8_CHARSET);
197             final String jwtSignature = new String(decodeBase64(jwt[2]), Constants.UTF_8_CHARSET);
198 
199             if (logger.isDebugEnabled()) {
200                 logger.debug("jwtHeader={}", jwtHeader);
201                 logger.debug("jwtClaim={}", jwtClaim);
202                 logger.debug("jwtSignature={}", jwtSignature);
203             }
204 
205             // SECURITY WARNING: JWT signature validation is not implemented.
206             // This is a critical security vulnerability. The ID token should be validated
207             // to ensure it was issued by the expected OpenID Connect provider and has not been tampered with.
208             // TODO: Implement JWT signature validation using the provider's public key
209 
210             final Map<String, Object> attributes = new HashMap<>();
211             attributes.put("accesstoken", tr.getAccessToken());
212             attributes.put("refreshtoken", tr.getRefreshToken() == null ? "null" : tr.getRefreshToken());
213             attributes.put("tokentype", tr.getTokenType());
214             attributes.put("expire", tr.getExpiresInSeconds());
215             attributes.put("jwtheader", jwtHeader);
216             attributes.put("jwtclaim", jwtClaim);
217             attributes.put("jwtsignature", jwtSignature);
218 
219             if (logger.isDebugEnabled()) {
220                 logger.debug("attributes={}", attributes);
221             }
222             parseJwtClaim(jwtClaim, attributes);
223 
224             return new OpenIdConnectCredential(attributes);
225         } catch (final IOException e) {
226             if (logger.isDebugEnabled()) {
227                 logger.debug("Failed to process callback request.", e);
228             }
229         }
230         return null;
231     }
232 
233     /**
234      * Parses the JWT claim and extracts attributes.
235      *
236      * @param jwtClaim the JWT claim string
237      * @param attributes the attributes map to populate
238      * @throws IOException if an I/O error occurs
239      */
240     protected void parseJwtClaim(final String jwtClaim, final Map<String, Object> attributes) throws IOException {
241         try (final JsonParser jsonParser = jsonFactory.createJsonParser(jwtClaim)) {
242             while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
243                 final String name = jsonParser.getCurrentName();
244                 if (name != null) {
245                     jsonParser.nextToken();
246 
247                     if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) {
248                         // Handle array type
249                         attributes.put(name, parseArray(jsonParser));
250                     } else if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
251                         // Handle nested object type
252                         attributes.put(name, parseObject(jsonParser));
253                     } else {
254                         // Handle primitive types (string, number, boolean, etc.)
255                         attributes.put(name, parsePrimitive(jsonParser));
256                     }
257                 }
258             }
259         }
260     }
261 
262     /**
263      * Parses primitive values from JSON parser.
264      *
265      * @param jsonParser the JSON parser
266      * @return the parsed primitive value
267      * @throws IOException if an I/O error occurs
268      */
269     protected Object parsePrimitive(final JsonParser jsonParser) throws IOException {
270         final JsonToken token = jsonParser.getCurrentToken();
271         return switch (token) {
272         case VALUE_STRING -> jsonParser.getText();
273         case VALUE_NUMBER_INT -> jsonParser.getLongValue();
274         case VALUE_NUMBER_FLOAT -> jsonParser.getDoubleValue();
275         case VALUE_TRUE -> true;
276         case VALUE_FALSE -> false;
277         case VALUE_NULL -> null;
278         default -> null; // Or throw an exception if unexpected token
279         };
280     }
281 
282     /**
283      * Parses array values from JSON parser.
284      *
285      * @param jsonParser the JSON parser
286      * @return the parsed array as a list
287      * @throws IOException if an I/O error occurs
288      */
289     protected Object parseArray(final JsonParser jsonParser) throws IOException {
290         final List<Object> list = new ArrayList<>();
291         while (jsonParser.nextToken() != JsonToken.END_ARRAY) {
292             if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
293                 list.add(parseObject(jsonParser));
294             } else if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) {
295                 list.add(parseArray(jsonParser)); // Nested array
296             } else {
297                 list.add(parsePrimitive(jsonParser));
298             }
299         }
300 
301         return list;
302     }
303 
304     /**
305      * Parses object values from JSON parser.
306      *
307      * @param jsonParser the JSON parser
308      * @return the parsed object as a map
309      * @throws IOException if an I/O error occurs
310      */
311     protected Map<String, Object> parseObject(final JsonParser jsonParser) throws IOException {
312         final Map<String, Object> nestedMap = new HashMap<>();
313         while (jsonParser.nextToken() != JsonToken.END_OBJECT) {
314             final String fieldName = jsonParser.getCurrentName();
315             if (fieldName != null) {
316                 jsonParser.nextToken(); // Move to the value of the current field
317 
318                 if (jsonParser.getCurrentToken() == JsonToken.START_ARRAY) {
319                     nestedMap.put(fieldName, parseArray(jsonParser));
320                 } else if (jsonParser.getCurrentToken() == JsonToken.START_OBJECT) {
321                     nestedMap.put(fieldName, parseObject(jsonParser));
322                 } else {
323                     nestedMap.put(fieldName, parsePrimitive(jsonParser));
324                 }
325             }
326         }
327         return nestedMap;
328     }
329 
330     /**
331      * Gets the token response from the OpenID Connect provider.
332      *
333      * @param code the authorization code
334      * @return the token response
335      * @throws IOException if an I/O error occurs
336      */
337     protected TokenResponse getTokenUrl(final String code) throws IOException {
338         return new AuthorizationCodeTokenRequest(httpTransport, jsonFactory, new GenericUrl(getOicTokenServerUrl()), code)//
339                 .setGrantType("authorization_code")//
340                 .setRedirectUri(getOicRedirectUrl())//
341                 .set("client_id", getOicClientId())//
342                 .set("client_secret", getOicClientSecret())//
343                 .execute();
344     }
345 
346     /**
347      * Gets the OpenID Connect client secret.
348      *
349      * @return the client secret
350      */
351     protected String getOicClientSecret() {
352         return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_SECRET, StringUtil.EMPTY);
353     }
354 
355     /**
356      * Gets the OpenID Connect token server URL.
357      *
358      * @return the token server URL
359      */
360     protected String getOicTokenServerUrl() {
361         return ComponentUtil.getSystemProperties().getProperty(OIC_TOKEN_SERVER_URL, "https://accounts.google.com/o/oauth2/token");
362     }
363 
364     /**
365      * Gets the OpenID Connect redirect URL.
366      *
367      * @return the redirect URL
368      */
369     protected String getOicRedirectUrl() {
370         return ComponentUtil.getSystemProperties().getProperty(OIC_REDIRECT_URL, buildDefaultRedirectUrl());
371     }
372 
373     /**
374      * Builds a default redirect URL for OpenID Connect based on the environment.
375      * Uses the configured base URL or defaults to http://localhost:8080 for compatibility
376      * with common OIDC provider configurations.
377      *
378      * @return the default redirect URL
379      */
380     protected String buildDefaultRedirectUrl() {
381         final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
382         String baseUrl = systemProperties.getProperty(OIC_BASE_URL);
383         if (StringUtil.isBlank(baseUrl)) {
384             baseUrl = "http://localhost:8080";
385         }
386         if (baseUrl.endsWith("/")) {
387             baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
388         }
389         return baseUrl + "/sso/";
390     }
391 
392     /**
393      * Gets the OpenID Connect scope.
394      *
395      * @return the scope
396      */
397     protected String getOicScope() {
398         return ComponentUtil.getSystemProperties().getProperty(OIC_SCOPE, StringUtil.EMPTY);
399     }
400 
401     /**
402      * Gets the OpenID Connect client ID.
403      *
404      * @return the client ID
405      */
406     protected String getOicClientId() {
407         return ComponentUtil.getSystemProperties().getProperty(OIC_CLIENT_ID, StringUtil.EMPTY);
408     }
409 
410     /**
411      * Gets the OpenID Connect authorization server URL.
412      *
413      * @return the authorization server URL
414      */
415     protected String getOicAuthServerUrl() {
416         return ComponentUtil.getSystemProperties().getProperty(OIC_AUTH_SERVER_URL, "https://accounts.google.com/o/oauth2/auth");
417     }
418 
419     @Override
420     public void resolveCredential(final LoginCredentialResolver resolver) {
421         resolver.resolve(OpenIdConnectCredential.class, credential -> OptionalEntity.of(credential.getUser()));
422     }
423 
424     @Override
425     public ActionResponse getResponse(final SsoResponseType responseType) {
426         return null;
427     }
428 
429     @Override
430     public String logout(final FessUserBean user) {
431         return null;
432     }
433 }