Skip to content

Commit 00ab9df

Browse files
Merge pull request #43 from authlete/feature/token-exchange
[feature] RFC 8693 OAuth 2.0 Token Exchange
2 parents a2ae786 + 8721045 commit 00ab9df

File tree

4 files changed

+386
-4
lines changed

4 files changed

+386
-4
lines changed

pom.xml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
<properties>
1313
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
1414

15-
<authlete.java.common.version>3.19</authlete.java.common.version>
16-
<authlete.java.jaxrs.version>2.43</authlete.java.jaxrs.version>
15+
<authlete.java.common.version>3.26</authlete.java.common.version>
16+
<authlete.java.jaxrs.version>2.47</authlete.java.jaxrs.version>
1717
<javax.servlet-api.version>3.0.1</javax.servlet-api.version>
1818
<jersey.version>2.30.1</jersey.version>
1919
<jetty.version>9.4.27.v20200227</jetty.version>

src/main/java/com/authlete/jaxrs/server/api/TokenEndpoint.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ private Response processTokenRequest(
9999
Params params = buildParams(request, parameters);
100100

101101
// Handle the token request.
102-
return handle(authleteApi, new TokenRequestHandlerSpiImpl(), params);
102+
return handle(authleteApi, new TokenRequestHandlerSpiImpl(authleteApi), params);
103103
}
104104

105105

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
/*
2+
* Copyright (C) 2022 Authlete, Inc.
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,
11+
* software distributed under the License is distributed on an
12+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13+
* either express or implied. See the License for the specific
14+
* language governing permissions and limitations under the
15+
* License.
16+
*/
17+
package com.authlete.jaxrs.server.api;
18+
19+
20+
import java.net.URI;
21+
import javax.ws.rs.WebApplicationException;
22+
import javax.ws.rs.core.CacheControl;
23+
import javax.ws.rs.core.MediaType;
24+
import javax.ws.rs.core.Response;
25+
import javax.ws.rs.core.Response.Status;
26+
import com.authlete.common.api.AuthleteApi;
27+
import com.authlete.common.dto.TokenCreateRequest;
28+
import com.authlete.common.dto.TokenCreateResponse;
29+
import com.authlete.common.dto.TokenInfo;
30+
import com.authlete.common.dto.TokenResponse;
31+
import com.authlete.common.types.GrantType;
32+
import com.authlete.common.types.TokenType;
33+
import com.nimbusds.jwt.EncryptedJWT;
34+
import com.nimbusds.jwt.JWT;
35+
import com.nimbusds.jwt.JWTParser;
36+
37+
38+
/**
39+
* A sample implementation of processing a token exchange request (<a href=
40+
* "https://www.rfc-editor.org/rfc/rfc8693.html">RFC 8693 OAuth 2&#x002E;0
41+
* Token Exchange</a>).
42+
*
43+
* <p>
44+
* RFC 8693 is very flexible. In other words, the specification does not define
45+
* details that are necessary for secure token exchange. Therefore,
46+
* implementations have to complement the specification with their own rules.
47+
* </p>
48+
*
49+
* <p>
50+
* There are various patterns for such deployment-specific rules. The
51+
* implementation in this file is just an example and does not intend to be
52+
* perfect for commercial use.
53+
* </p>
54+
*
55+
* @see <a href="https://www.rfc-editor.org/rfc/rfc8693.html"
56+
* >RFC 8693 OAuth 2.0 Token Exchange</a>
57+
*/
58+
class TokenExchanger
59+
{
60+
private final AuthleteApi mAuthleteApi;
61+
private final TokenResponse mTokenResponse;
62+
63+
64+
public TokenExchanger(AuthleteApi authleteApi, TokenResponse tokenResponse)
65+
{
66+
mAuthleteApi = authleteApi;
67+
mTokenResponse = tokenResponse;
68+
}
69+
70+
71+
public Response handle()
72+
{
73+
try
74+
{
75+
return createResponse();
76+
}
77+
catch (WebApplicationException cause)
78+
{
79+
return cause.getResponse();
80+
}
81+
}
82+
83+
84+
private Response createResponse() throws WebApplicationException
85+
{
86+
// This sample implementation creates an access token.
87+
88+
// Client ID to assign.
89+
long clientId = determineClientId();
90+
91+
// Scopes to assign.
92+
String[] scopes = determineScopes();
93+
94+
// Resources to assign.
95+
URI[] resources = determineResources();
96+
97+
// Subject to assign.
98+
String subject = determineSubject();
99+
100+
// Create an access token.
101+
TokenCreateResponse tcResponse =
102+
createAccessToken(clientId, scopes, resources, subject);
103+
104+
// Create a successful token response.
105+
return createSuccessfulResponse(tcResponse);
106+
}
107+
108+
109+
private long determineClientId()
110+
{
111+
// The client ID of the client that made the token exchange request.
112+
long clientId = mTokenResponse.getClientId();
113+
114+
// If 'Service.tokenExchangeByIdentifiableClientsOnly' is false,
115+
// token exchange requests that contain no client identifier are not
116+
// rejected. In that case, 'clientId' here becomes 0.
117+
//
118+
// However, this authorization server implementation does not allow
119+
// unidentifiable clients to make token exchange requests regardless
120+
// of whether 'Service.tokenExchangeByIdentifiableClientsOnly' is
121+
// true or false.
122+
if (clientId == 0)
123+
{
124+
throw invalidRequest(
125+
"This authorization server does not allow unidentifiable " +
126+
"clients to make token exchange requests.");
127+
}
128+
129+
// This simple implementation uses the client ID of the client
130+
// that made the token exchange request.
131+
return clientId;
132+
}
133+
134+
135+
private String[] determineScopes()
136+
{
137+
// This simple implementation uses the scopes specified
138+
// by the token exchange request.
139+
return mTokenResponse.getScopes();
140+
}
141+
142+
143+
private URI[] determineResources()
144+
{
145+
// This simple implementation uses the resources specified
146+
// by the token exchange request.
147+
return mTokenResponse.getResources();
148+
}
149+
150+
151+
private String determineSubject()
152+
{
153+
// The value of the "subject_token_type" request parameter.
154+
TokenType tokenType = mTokenResponse.getSubjectTokenType();
155+
156+
// The subject to be assigned to a new access token.
157+
String subject = null;
158+
159+
switch (tokenType)
160+
{
161+
case ACCESS_TOKEN:
162+
case REFRESH_TOKEN:
163+
// Use the subject associated with the token as the subject of
164+
// a new access token.
165+
subject = determineSubjectByTokenInfo();
166+
break;
167+
168+
case JWT:
169+
case ID_TOKEN:
170+
// Use the value of the "sub" claim of the JWT as the subject of
171+
// a new access token.
172+
subject = determineSubjectByJwt();
173+
break;
174+
175+
case SAML1:
176+
case SAML2:
177+
default:
178+
throw invalidRequest(
179+
"This authorization server does not support the token type '" +
180+
tokenType + "'.");
181+
}
182+
183+
// If 'subject' failed to be determined.
184+
if (subject == null)
185+
{
186+
// This happens (1) when an access token that was created by
187+
// the client credentials flow was given or (2) when a JWT
188+
// that does not contain the "sub" claim was given.
189+
throw invalidRequest(
190+
"Could not determine the subject from the given subject token.");
191+
}
192+
193+
return subject;
194+
}
195+
196+
197+
private String determineSubjectByTokenInfo()
198+
{
199+
// When the token type is "urn:ietf:params:oauth:token-type:access_token"
200+
// or "urn:ietf:params:oauth:token-type:refresh_token", Authlete returns
201+
// more information about the token.
202+
TokenInfo tokenInfo = mTokenResponse.getSubjectTokenInfo();
203+
204+
// The subject associated with the token. If the token was created by the
205+
// client credentials flow, the value is null.
206+
return tokenInfo.getSubject();
207+
}
208+
209+
210+
private String determineSubjectByJwt()
211+
{
212+
// When the token type is "urn:ietf:params:oauth:token-type:jwt" or
213+
// "urn:ietf:params:oauth:token-type:id_token", the format of the
214+
// subject token is JWT.
215+
//
216+
// Basic validation on the JWT has already been done by Authlete's
217+
// /auth/token API. See the JavaDoc of the TokenResponse class for
218+
// details about the validation steps.
219+
String subjectToken = mTokenResponse.getSubjectToken();
220+
221+
JWT jwt;
222+
223+
try
224+
{
225+
// Parse the subject token as JWT.
226+
jwt = JWTParser.parse(subjectToken);
227+
}
228+
catch (Exception cause)
229+
{
230+
// This won't happen because Authlete has already confirmed that
231+
// the format of the subject token conforms to the JWT specification.
232+
throw invalidRequest("The subject token failed to be parsed as JWT.");
233+
}
234+
235+
// If the JWT is encrypted.
236+
if (jwt instanceof EncryptedJWT)
237+
{
238+
throw invalidRequest(
239+
"This authorization server does not accept " +
240+
"an encrypted JWT as a subject token.");
241+
}
242+
243+
try
244+
{
245+
// Get the value of the "sub" claim from the payload of the JWT.
246+
//
247+
// An ID Token must always have the "sub" claim (OIDC Core) while
248+
// a JWT does not necessarily have the "sub" claim (RFC 7519).
249+
return jwt.getJWTClaimsSet().getSubject();
250+
}
251+
catch (Exception cause)
252+
{
253+
throw invalidRequest(
254+
"The value of the 'sub' claim failed to be extracted " +
255+
"from the payload of the subject token.");
256+
}
257+
}
258+
259+
260+
private TokenCreateResponse createAccessToken(
261+
long clientId, String[] scopes, URI[] resources, String subject)
262+
{
263+
// A request to Authlete's /auth/token/create API.
264+
TokenCreateRequest request = new TokenCreateRequest()
265+
.setGrantType(GrantType.TOKEN_EXCHANGE)
266+
.setClientId(clientId)
267+
.setScopes(scopes)
268+
.setResources(resources)
269+
.setSubject(subject)
270+
;
271+
272+
try
273+
{
274+
// Call Authlete's /auth/token/create API to create an access token.
275+
return mAuthleteApi.tokenCreate(request);
276+
}
277+
catch (Exception cause)
278+
{
279+
// API call to /auth/token/create failed.
280+
cause.printStackTrace();
281+
throw serverError("API call to /auth/token/create failed.");
282+
}
283+
}
284+
285+
286+
private Response createSuccessfulResponse(TokenCreateResponse tcResponse)
287+
{
288+
// The content of a successful token response that conforms to
289+
// Section 2.2.1. Successful Response of RFC 8693.
290+
String content = String.format(
291+
"{\n" +
292+
" \"access_token\":\"%s\",\n" +
293+
" \"issued_token_type\":\"urn:ietf:params:oauth:token-type:access_token\",\n" +
294+
" \"token_type\":\"Bearer\",\n" +
295+
" \"expires_in\":%d,\n" +
296+
" \"scope\":\"%s\",\n" +
297+
" \"refresh_token\":\"%s\"\n" +
298+
"}\n",
299+
tcResponse.getAccessToken(),
300+
tcResponse.getExpiresIn(),
301+
buildScope(tcResponse),
302+
tcResponse.getRefreshToken()
303+
);
304+
305+
return toJsonResponse(Status.OK, content);
306+
}
307+
308+
309+
private String buildScope(TokenCreateResponse tcResponse)
310+
{
311+
String[] scopes = tcResponse.getScopes();
312+
313+
if (scopes == null)
314+
{
315+
return "";
316+
}
317+
318+
return String.join(" ", scopes);
319+
}
320+
321+
322+
private Response toJsonResponse(Status status, String content)
323+
{
324+
CacheControl cacheControl = new CacheControl();
325+
cacheControl.setNoCache(true);
326+
cacheControl.setNoStore(true);
327+
328+
return Response
329+
.status(status)
330+
.type(MediaType.APPLICATION_JSON_TYPE)
331+
.cacheControl(cacheControl)
332+
.entity(content)
333+
.build();
334+
}
335+
336+
337+
private WebApplicationException toException(Status status, String error, String message)
338+
{
339+
String content = String.format(
340+
"{\n" +
341+
" \"error\":\"%s\",\n" +
342+
" \"error_message\":\"%s\"\n" +
343+
"}\n",
344+
error, message);
345+
346+
Response response = toJsonResponse(status, content);
347+
348+
return new WebApplicationException(response);
349+
}
350+
351+
352+
private WebApplicationException invalidRequest(String message)
353+
{
354+
return toException(Status.BAD_REQUEST, "invalid_request", message);
355+
}
356+
357+
358+
private WebApplicationException serverError(String message)
359+
{
360+
return toException(Status.INTERNAL_SERVER_ERROR, "server_error", message);
361+
}
362+
}

0 commit comments

Comments
 (0)