1818import org .springframework .http .HttpStatus ;
1919import org .springframework .http .MediaType ;
2020import org .springframework .http .ResponseEntity ;
21+ import org .springframework .util .Base64Utils ;
2122import org .springframework .web .bind .annotation .CrossOrigin ;
2223import org .springframework .web .bind .annotation .RequestHeader ;
2324import org .springframework .web .bind .annotation .RequestMapping ;
3334import java .nio .charset .StandardCharsets ;
3435import java .security .MessageDigest ;
3536import java .security .NoSuchAlgorithmException ;
37+ import java .security .SecureRandom ;
3638import java .text .ParseException ;
3739import java .util .Arrays ;
3840import java .util .Base64 ;
@@ -67,6 +69,8 @@ public class OidcController {
6769 private JWSHeader jwsHeader ;
6870
6971 private final Map <String , AccessTokenInfo > accessTokens = new HashMap <>();
72+ private final Map <String , CodeInfo > authorizationCodes = new HashMap <>();
73+ private final SecureRandom random = new SecureRandom ();
7074
7175 private final FakeOidcServerProperties serverProperties ;
7276
@@ -102,7 +106,7 @@ public ResponseEntity<?> metadata(UriComponentsBuilder uriBuilder, HttpServletRe
102106 m .put ("jwks_uri" , urlPrefix + JWKS_ENDPOINT ); // REQUIRED
103107 m .put ("introspection_endpoint" , urlPrefix + INTROSPECTION_ENDPOINT );
104108 m .put ("scopes_supported" , Arrays .asList ("openid" , "profile" , "email" )); // RECOMMENDED
105- m .put ("response_types_supported" , Collections . singletonList ("id_token token" )); // REQUIRED
109+ m .put ("response_types_supported" , Arrays . asList ("id_token token" , "code " )); // REQUIRED
106110 m .put ("subject_types_supported" , Collections .singletonList ("public" )); // REQUIRED
107111 m .put ("id_token_signing_alg_values_supported" , Arrays .asList ("RS256" , "none" )); // REQUIRED
108112 m .put ("claims_supported" , Arrays .asList ("sub" , "iss" , "name" , "family_name" , "given_name" , "preferred_username" , "email" ));
@@ -124,17 +128,23 @@ public ResponseEntity<String> jwks(HttpServletRequest req) {
124128 */
125129 @ RequestMapping (value = USERINFO_ENDPOINT , method = RequestMethod .GET , produces = MediaType .APPLICATION_JSON_VALUE )
126130 @ CrossOrigin (allowedHeaders = {"Authorization" , "Content-Type" })
127- public ResponseEntity <?> userinfo (@ RequestHeader ("Authorization" ) String auth , HttpServletRequest req ) {
131+ public ResponseEntity <?> userinfo (@ RequestHeader ("Authorization" ) String auth ,
132+ @ RequestParam (required = false ) String access_token ,
133+ HttpServletRequest req ) {
128134 log .info ("called " + USERINFO_ENDPOINT + " from {}" , req .getRemoteHost ());
129135 if (!auth .startsWith ("Bearer " )) {
130- return ResponseEntity .status (HttpStatus .UNAUTHORIZED ).body ("No token" );
136+ if (access_token == null ) {
137+ return ResponseEntity .status (HttpStatus .UNAUTHORIZED ).body ("No token" );
138+ }
139+ auth = access_token ;
140+ } else {
141+ auth = auth .substring (7 );
131142 }
132- auth = auth .substring (7 );
133143 AccessTokenInfo accessTokenInfo = accessTokens .get (auth );
134144 if (accessTokenInfo == null ) {
135145 return ResponseEntity .status (HttpStatus .UNAUTHORIZED ).body ("access token not found" );
136146 }
137- Set <String > scopes = new HashSet <>( Arrays . asList ( accessTokenInfo .scope . split ( " " )) );
147+ Set <String > scopes = setFromSpaceSeparatedString ( accessTokenInfo .scope );
138148 Map <String , Object > m = new LinkedHashMap <>();
139149 User user = accessTokenInfo .user ;
140150 m .put ("sub" , user .getSub ());
@@ -178,6 +188,64 @@ public ResponseEntity<?> introspection(@RequestParam String token,
178188 return ResponseEntity .ok ().body (m );
179189 }
180190
191+ /**
192+ * Provides token endpoint.
193+ */
194+ @ RequestMapping (value = TOKEN_ENDPOINT , method = RequestMethod .POST , produces = MediaType .APPLICATION_JSON_VALUE )
195+ @ CrossOrigin
196+ public ResponseEntity <?> token (@ RequestParam String grant_type ,
197+ @ RequestParam String code ,
198+ @ RequestParam String redirect_uri ,
199+ @ RequestParam (required = false ) String client_id ,
200+ @ RequestParam (required = false ) String code_verifier ,
201+ @ RequestHeader (name = "Authorization" , required = false ) String auth ,
202+ UriComponentsBuilder uriBuilder ,
203+ HttpServletRequest req ) throws NoSuchAlgorithmException , JOSEException {
204+ log .info ("called " + TOKEN_ENDPOINT + " from {}, grant_type={} code={} redirect_uri={} client_id={}" , req .getRemoteHost (), grant_type , code , redirect_uri , client_id );
205+ if (!"authorization_code" .equals (grant_type )) {
206+ return jsonError ("unsupported_grant_type" , "grant_type is not authorization_code" );
207+ }
208+ CodeInfo codeInfo = authorizationCodes .get (code );
209+ if (codeInfo == null ) {
210+ return jsonError ("invalid_grant" , "code not valid" );
211+ }
212+ if (!redirect_uri .equals (codeInfo .redirect_uri )) {
213+ return jsonError ("invalid_request" , "redirect_uri not valid" );
214+ }
215+ if (codeInfo .codeChallenge != null ) {
216+ // check PKCE
217+ if (code_verifier == null ) {
218+ return jsonError ("invalid_request" , "code_verifier missing" );
219+ }
220+ if ("S256" .equals (codeInfo .codeChallengeMethod )) {
221+ MessageDigest s256 = MessageDigest .getInstance ("SHA-256" );
222+ s256 .reset ();
223+ s256 .update (code_verifier .getBytes (StandardCharsets .UTF_8 ));
224+ String hashedVerifier = Base64URL .encode (s256 .digest ()).toString ();
225+ if (!codeInfo .codeChallenge .equals (hashedVerifier )) {
226+ log .warn ("code_verifier {} hashed using S256 to {} does not match code_challenge {}" , code_verifier , hashedVerifier , codeInfo .codeChallenge );
227+ return jsonError ("invalid_request" , "code_verifier not correct" );
228+ }
229+ log .info ("code_verifier OK" );
230+ } else {
231+ if (!codeInfo .codeChallenge .equals (code_verifier )) {
232+ log .warn ("code_verifier {} does not match code_challenge {}" , code_verifier , codeInfo .codeChallenge );
233+ return jsonError ("invalid_request" , "code_verifier not correct" );
234+ }
235+ }
236+ }
237+ // return access token
238+ Map <String , String > map = new LinkedHashMap <>();
239+ String accessToken = createAccessToken (codeInfo .iss , codeInfo .user , codeInfo .client_id , codeInfo .scope );
240+ map .put ("access_token" , accessToken );
241+ map .put ("token_type" , "Bearer" );
242+ map .put ("expires_in" , String .valueOf (serverProperties .getTokenExpirationSeconds ()));
243+ map .put ("scope" , codeInfo .scope );
244+ map .put ("id_token" , createIdToken (codeInfo .iss , codeInfo .user , codeInfo .client_id , codeInfo .nonce , accessToken ));
245+ return ResponseEntity .ok (map );
246+ }
247+
248+
181249 /**
182250 * Provides authorization endpoint.
183251 */
@@ -187,7 +255,10 @@ public ResponseEntity<?> authorize(@RequestParam String client_id,
187255 @ RequestParam String response_type ,
188256 @ RequestParam String scope ,
189257 @ RequestParam String state ,
190- @ RequestParam String nonce ,
258+ @ RequestParam (required = false ) String nonce ,
259+ @ RequestParam (required = false ) String code_challenge ,
260+ @ RequestParam (required = false ) String code_challenge_method ,
261+ @ RequestParam (required = false ) String response_mode ,
191262 @ RequestHeader (name = "Authorization" , required = false ) String auth ,
192263 UriComponentsBuilder uriBuilder ,
193264 HttpServletRequest req ) throws JOSEException , NoSuchAlgorithmException {
@@ -203,23 +274,48 @@ public ResponseEntity<?> authorize(@RequestParam String client_id,
203274 User user = serverProperties .getUser ();
204275 if (user .getLogname ().equals (login ) && user .getPassword ().equals (password )) {
205276 log .info ("password for user {} is correct" , login );
277+ Set <String > responseType = setFromSpaceSeparatedString (response_type );
206278 String iss = uriBuilder .replacePath ("/" ).build ().encode ().toUriString ();
207- String access_token = createAccessToken (iss , user , client_id , scope );
208- String id_token = createIdToken (iss , user , client_id , nonce , access_token );
209- String url = redirect_uri + "#" +
210- "access_token=" + urlencode (access_token ) +
211- "&token_type=Bearer" +
212- "&state=" + urlencode (state ) +
213- "&expires_in=" + serverProperties .getTokenExpirationSeconds () +
214- "&id_token=" + urlencode (id_token );
215- return ResponseEntity .status (HttpStatus .FOUND ).header ("Location" , url ).build ();
279+ if (responseType .contains ("token" )) {
280+ // implicit flow
281+ log .info ("using implicit flow" );
282+ String access_token = createAccessToken (iss , user , client_id , scope );
283+ String id_token = createIdToken (iss , user , client_id , nonce , access_token );
284+ String url = redirect_uri + "#" +
285+ "access_token=" + urlencode (access_token ) +
286+ "&token_type=Bearer" +
287+ "&state=" + urlencode (state ) +
288+ "&expires_in=" + serverProperties .getTokenExpirationSeconds () +
289+ "&id_token=" + urlencode (id_token );
290+ return ResponseEntity .status (HttpStatus .FOUND ).header ("Location" , url ).build ();
291+ } else if (responseType .contains ("code" )) {
292+ // authorization code flow
293+ log .info ("using authorization code flow {}" , code_challenge !=null ? "with PKCE" : "" );
294+ String code = createAuthorizationCode (code_challenge , code_challenge_method , client_id , redirect_uri , user , iss , scope , nonce );
295+ String url = redirect_uri + "?" +
296+ "code=" + code +
297+ "&state=" + urlencode (state );
298+ return ResponseEntity .status (HttpStatus .FOUND ).header ("Location" , url ).build ();
299+ } else {
300+ String url = redirect_uri + "#" + "error=unsupported_response_type" ;
301+ return ResponseEntity .status (HttpStatus .FOUND ).header ("Location" , url ).build ();
302+ }
216303 } else {
217304 log .info ("wrong user and password combination" );
218305 return response401 ();
219306 }
220307 }
221308 }
222309
310+ private String createAuthorizationCode (String code_challenge , String code_challenge_method , String client_id , String redirect_uri , User user , String iss , String scope , String nonce ) {
311+ byte [] bytes = new byte [16 ];
312+ random .nextBytes (bytes );
313+ String code = Base64URL .encode (bytes ).toString ();
314+ log .info ("issuing code={}" , code );
315+ authorizationCodes .put (code , new CodeInfo (code_challenge , code_challenge_method , code , client_id , redirect_uri , user , iss , scope , nonce ));
316+ return code ;
317+ }
318+
223319 private String createAccessToken (String iss , User user , String client_id , String scope ) throws JOSEException {
224320 // create JWT claims
225321 Date expiration = new Date (System .currentTimeMillis () + serverProperties .getTokenExpirationSeconds () * 1000L );
@@ -297,4 +393,42 @@ public AccessTokenInfo(User user, String accessToken, Date expiration, String sc
297393 }
298394
299395 }
396+
397+ private static class CodeInfo {
398+ final String codeChallenge ;
399+ final String codeChallengeMethod ;
400+ final String code ;
401+ final String client_id ;
402+ final String redirect_uri ;
403+ final User user ;
404+ final String iss ;
405+ final String scope ;
406+ final String nonce ;
407+
408+ public CodeInfo (String codeChallenge , String codeChallengeMethod , String code , String client_id , String redirect_uri , User user , String iss , String scope , String nonce ) {
409+ this .codeChallenge = codeChallenge ;
410+ this .codeChallengeMethod = codeChallengeMethod ;
411+ this .code = code ;
412+ this .client_id = client_id ;
413+ this .redirect_uri = redirect_uri ;
414+ this .user = user ;
415+ this .iss = iss ;
416+ this .scope = scope ;
417+ this .nonce = nonce ;
418+ }
419+ }
420+
421+ private static Set <String > setFromSpaceSeparatedString (String s ) {
422+ if (s == null || s .isBlank ()) return Collections .emptySet ();
423+ return new HashSet <>(Arrays .asList (s .split (" " )));
424+ }
425+
426+ private static ResponseEntity <?> jsonError (String error , String error_description ) {
427+ log .warn ("error={} error_description={}" , error , error_description );
428+ Map <String , String > map = new LinkedHashMap <>();
429+ map .put ("error" , error );
430+ map .put ("error_description" , error_description );
431+ return ResponseEntity .status (HttpStatus .BAD_REQUEST ).body (map );
432+ }
433+
300434}
0 commit comments