Skip to content

Commit 7c9064e

Browse files
committed
implemented Authorization Code flow with Proof Key for Code Exchange
1 parent b4ee1af commit 7c9064e

File tree

2 files changed

+154
-20
lines changed

2 files changed

+154
-20
lines changed

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ a real OIDC server is deployed.
99

1010
This fake server has the following features:
1111
* it is implemented in Java as Spring Boot application
12-
* implements **Implicit Grant flow** (for JavaScript clients)
12+
* implements the following grant types:
13+
* **Implicit Grant flow** (for JavaScript clients - deprecated)
14+
* **Authorization Code flow with Proof Key for Code Exchange** (for JavaScript clients - recommended)
15+
* **Authorization Code flow without PKCE** (for web server clients)
1316
* provides the following endpoints:
1417
* /.well-known/openid-configuration providing metadata
1518
* /jwks providing JSON Web Key Set for validating cryptographic signature of id_token
1619
* /authorize which uses HTTP Basic Auth for asking for username and password
20+
* /token for exchanging authorization code for access token
1721
* /userinfo that provides data about the user
1822
* /introspection that provides access token introspection
1923

@@ -23,10 +27,6 @@ mvn package
2327

2428
java -jar target/fake_oidc.jar
2529
```
26-
or build and run from maven:
27-
```bash
28-
mvn spring-boot:run
29-
```
3030

3131
By default the application runs at TCP port 8090, uses a self-signed certificate for localhost, and the only
3232
user has username "perun" and password "test". This can be changed by using command line options:

src/main/java/cz/metacentrum/fake_oidc/OidcController.java

Lines changed: 149 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import org.springframework.http.HttpStatus;
1919
import org.springframework.http.MediaType;
2020
import org.springframework.http.ResponseEntity;
21+
import org.springframework.util.Base64Utils;
2122
import org.springframework.web.bind.annotation.CrossOrigin;
2223
import org.springframework.web.bind.annotation.RequestHeader;
2324
import org.springframework.web.bind.annotation.RequestMapping;
@@ -33,6 +34,7 @@
3334
import java.nio.charset.StandardCharsets;
3435
import java.security.MessageDigest;
3536
import java.security.NoSuchAlgorithmException;
37+
import java.security.SecureRandom;
3638
import java.text.ParseException;
3739
import java.util.Arrays;
3840
import 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

Comments
 (0)